feat: add named highlight colors with sync and picker ux fixes (#3741)
* fix: add highlight color label fields
* fix: add default highlight label sync fields
* fix: add highlight prefs sync helpers
* fix: add highlight color name inputs
* fix: persist and sync highlight color names
* fix: add highlight color label helpers
* fix: add long press highlight label preview
* fix: pull highlight color prefs during library sync
* test: cover highlight color label helpers
* fix: widen highlight color name inputs
* fix: show highlight names on hover and touch hold
* fix: prevent highlight name input overlap
* fix: improve highlight name input responsiveness
* fix: support drag scrolling for highlight colors
* fix: batch custom color and label updates
* fix: serialize highlight prefs saves
* fix: align color strip drag and restore color clicks
* fix: translate default highlight color labels
* refactor: remove highlight preference sync wiring
* fix: align highlight option i18n with existing pattern
* fix: remove redundant english highlight keys
* fix: support raw and normalized highlight label keys
* fix: use underscore translator in highlight options
* fix: translate custom highlight color labels in editor
* refactor: simplify highlight settings persistence
* fix: maintianer review
* refactor: simplify highlight prefs save and clean up editor
- Drop the skipUserColors/skipLabels options from handleHighlightPrefsChange
and always persist both arrays; the flags only masked a no-op caller.
- Type handleHighlightColorsChange with Record<HighlightColor, string>
instead of typeof so the signature reads clearly.
- Remove the always-true `|| true` guard around the custom colors section.
- Stop wrapping user-typed custom color labels with _(), matching the
built-in color inputs and keeping the input value equal to what the
user typed.
* refactor: couple highlight labels to their colors
Replace the parallel `highlightColorLabels: Record<string, string>` map
with label storage that lives next to each color. This removes the hex
key normalization layer and its whole class of orphan/case-drift bugs.
Data model:
userHighlightColors: string[] -> UserHighlightColor[]
({ hex, label? })
highlightColorLabels: Record<string, string> -> (removed)
defaultHighlightLabels:
Partial<Record<DefaultHighlightColor, string>>
A `migrateHighlightColorPrefs` helper runs during `loadSettings` and
handles both the shipped `string[]` layout and the draft-build
`highlightColorLabels` layout: hex-keyed labels attach to matching user
colors, name-keyed labels move into `defaultHighlightLabels`. Malformed
entries are dropped.
Editor:
- Label inputs commit on blur (Enter also commits), so typing a long
label no longer fires `setSettings`/`saveSettings` on every
keystroke. A small `LabelInput` component owns the draft state and
syncs if the prop changes externally.
- Three explicit callbacks (`onCustomHighlightColorsChange`,
`onUserHighlightColorsChange`, `onDefaultHighlightLabelsChange`)
replace the previous single callback with opaque skip flags.
Picker:
- Extracted a reusable `useDragScroll` hook (mouse only, 6px
threshold, 120ms click suppression) from the inline state machine
in `HighlightOptions`. The picker drops to ~280 lines.
- Long-press preview stays touch/pen only. It now reads labels via
`getHighlightColorLabel` (which returns `undefined` when no user
label is set), letting the component layer decide whether to fall
back to a translated default name.
i18n:
- Default color names ('red' | 'yellow' | 'green' | 'blue' | 'violet')
are registered once at module scope via `stubTranslation` and
translated at the picker layer through `useTranslation`. User-typed
labels are never run through `_()`, so what the user types is what
the editor shows.
Tests:
- `annotator-util.test.ts`: rewritten around the new helper contract
(user label -> undefined fallback) and case-insensitive hex matching.
- `settings-highlight-migration.test.ts`: new, covers legacy
`string[]`, already-migrated entries, malformed hex filtering, and
the two draft-label fold paths.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { migrateHighlightColorPrefs } from '@/services/settingsService';
|
||||||
|
import type { ReadSettings } from '@/types/settings';
|
||||||
|
|
||||||
|
const baseRead = (): ReadSettings =>
|
||||||
|
({
|
||||||
|
customHighlightColors: {},
|
||||||
|
userHighlightColors: [],
|
||||||
|
defaultHighlightLabels: {},
|
||||||
|
}) as unknown as ReadSettings;
|
||||||
|
|
||||||
|
describe('migrateHighlightColorPrefs', () => {
|
||||||
|
it('lifts legacy string[] userHighlightColors into {hex} entries', () => {
|
||||||
|
const read = baseRead();
|
||||||
|
(read as unknown as { userHighlightColors: unknown }).userHighlightColors = [
|
||||||
|
'#AABBCC',
|
||||||
|
'#112233',
|
||||||
|
];
|
||||||
|
|
||||||
|
migrateHighlightColorPrefs(read);
|
||||||
|
|
||||||
|
expect(read.userHighlightColors).toEqual([{ hex: '#aabbcc' }, { hex: '#112233' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves already-migrated entries', () => {
|
||||||
|
const read = baseRead();
|
||||||
|
read.userHighlightColors = [{ hex: '#abcdef', label: 'Keep me' }, { hex: '#123456' }];
|
||||||
|
|
||||||
|
migrateHighlightColorPrefs(read);
|
||||||
|
|
||||||
|
expect(read.userHighlightColors).toEqual([
|
||||||
|
{ hex: '#abcdef', label: 'Keep me' },
|
||||||
|
{ hex: '#123456' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out entries with malformed hex values', () => {
|
||||||
|
const read = baseRead();
|
||||||
|
(read as unknown as { userHighlightColors: unknown }).userHighlightColors = [
|
||||||
|
'#abcdef',
|
||||||
|
'not-a-hex',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
|
||||||
|
migrateHighlightColorPrefs(read);
|
||||||
|
|
||||||
|
expect(read.userHighlightColors).toEqual([{ hex: '#abcdef' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds draft highlightColorLabels hex entries into matching user colors', () => {
|
||||||
|
const read = baseRead();
|
||||||
|
(read as unknown as { userHighlightColors: unknown }).userHighlightColors = ['#aabbcc'];
|
||||||
|
(read as unknown as { highlightColorLabels: unknown }).highlightColorLabels = {
|
||||||
|
'#aabbcc': 'Romance',
|
||||||
|
};
|
||||||
|
|
||||||
|
migrateHighlightColorPrefs(read);
|
||||||
|
|
||||||
|
expect(read.userHighlightColors).toEqual([{ hex: '#aabbcc', label: 'Romance' }]);
|
||||||
|
expect(
|
||||||
|
(read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds draft highlightColorLabels named entries into defaultHighlightLabels', () => {
|
||||||
|
const read = baseRead();
|
||||||
|
(read as unknown as { highlightColorLabels: unknown }).highlightColorLabels = {
|
||||||
|
yellow: 'Foreshadowing',
|
||||||
|
red: 'Questions',
|
||||||
|
noise: 'Ignored',
|
||||||
|
};
|
||||||
|
|
||||||
|
migrateHighlightColorPrefs(read);
|
||||||
|
|
||||||
|
expect(read.defaultHighlightLabels).toEqual({
|
||||||
|
yellow: 'Foreshadowing',
|
||||||
|
red: 'Questions',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite an already-set defaultHighlightLabel', () => {
|
||||||
|
const read = baseRead();
|
||||||
|
read.defaultHighlightLabels = { yellow: 'Existing' };
|
||||||
|
(read as unknown as { highlightColorLabels: unknown }).highlightColorLabels = {
|
||||||
|
yellow: 'Legacy',
|
||||||
|
};
|
||||||
|
|
||||||
|
migrateHighlightColorPrefs(read);
|
||||||
|
|
||||||
|
expect(read.defaultHighlightLabels).toEqual({ yellow: 'Existing' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { getExternalDragHandle, toParentViewportPoint } from '@/app/reader/utils/annotatorUtil';
|
import {
|
||||||
|
getExternalDragHandle,
|
||||||
|
getHighlightColorLabel,
|
||||||
|
toParentViewportPoint,
|
||||||
|
} from '@/app/reader/utils/annotatorUtil';
|
||||||
import { Point } from '@/utils/sel';
|
import { Point } from '@/utils/sel';
|
||||||
|
import { UserHighlightColor } from '@/types/book';
|
||||||
|
import { SystemSettings } from '@/types/settings';
|
||||||
|
|
||||||
describe('getExternalDragHandle', () => {
|
describe('getExternalDragHandle', () => {
|
||||||
const currentStart: Point = { x: 100, y: 200 };
|
const currentStart: Point = { x: 100, y: 200 };
|
||||||
@@ -99,3 +105,38 @@ describe('toParentViewportPoint', () => {
|
|||||||
expect(result).toEqual({ x: 300, y: 100 });
|
expect(result).toEqual({ x: 300, y: 100 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getHighlightColorLabel', () => {
|
||||||
|
const makeSettings = (
|
||||||
|
userHighlightColors: UserHighlightColor[],
|
||||||
|
defaultHighlightLabels: Partial<Record<string, string>> = {},
|
||||||
|
): SystemSettings =>
|
||||||
|
({
|
||||||
|
globalReadSettings: {
|
||||||
|
userHighlightColors,
|
||||||
|
defaultHighlightLabels,
|
||||||
|
},
|
||||||
|
}) as SystemSettings;
|
||||||
|
|
||||||
|
it('returns the user-set label for a built-in color', () => {
|
||||||
|
const settings = makeSettings([], { yellow: 'Foreshadowing' });
|
||||||
|
expect(getHighlightColorLabel(settings, 'yellow')).toBe('Foreshadowing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the user-set label for a hex color, matching case-insensitively', () => {
|
||||||
|
const settings = makeSettings([{ hex: '#aabbcc', label: 'Romance' }]);
|
||||||
|
expect(getHighlightColorLabel(settings, '#AABBCC')).toBe('Romance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the user has not set a label', () => {
|
||||||
|
const settings = makeSettings([]);
|
||||||
|
expect(getHighlightColorLabel(settings, 'green')).toBeUndefined();
|
||||||
|
expect(getHighlightColorLabel(settings, '#123456')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores labels that collapse to whitespace', () => {
|
||||||
|
const settings = makeSettings([{ hex: '#abcdef', label: ' ' }], { red: ' ' });
|
||||||
|
expect(getHighlightColorLabel(settings, '#abcdef')).toBeUndefined();
|
||||||
|
expect(getHighlightColorLabel(settings, 'red')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { FaCheckCircle } from 'react-icons/fa';
|
import { FaCheckCircle } from 'react-icons/fa';
|
||||||
import { HighlightColor, HighlightStyle } from '@/types/book';
|
import { DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book';
|
||||||
import { useEnv } from '@/context/EnvContext';
|
import { useEnv } from '@/context/EnvContext';
|
||||||
import { useThemeStore } from '@/store/themeStore';
|
import { useThemeStore } from '@/store/themeStore';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useSettingsStore } from '@/store/settingsStore';
|
import { useSettingsStore } from '@/store/settingsStore';
|
||||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||||
|
import { useDragScroll } from '@/hooks/useDragScroll';
|
||||||
import { saveSysSettings } from '@/helpers/settings';
|
import { saveSysSettings } from '@/helpers/settings';
|
||||||
|
import { LONG_HOLD_THRESHOLD } from '@/services/constants';
|
||||||
|
import { getHighlightColorLabel } from '../../utils/annotatorUtil';
|
||||||
import { stubTranslation as _ } from '@/utils/misc';
|
import { stubTranslation as _ } from '@/utils/misc';
|
||||||
|
|
||||||
|
// Register strings for the i18next extractor. These keys are translated by the
|
||||||
|
// component via `useTranslation` below.
|
||||||
const styles = [_('highlight'), _('underline'), _('squiggly')] as HighlightStyle[];
|
const styles = [_('highlight'), _('underline'), _('squiggly')] as HighlightStyle[];
|
||||||
const defaultColors = [
|
void [_('red'), _('yellow'), _('green'), _('blue'), _('violet')];
|
||||||
_('red'),
|
|
||||||
_('violet'),
|
|
||||||
_('blue'),
|
|
||||||
_('green'),
|
|
||||||
_('yellow'),
|
|
||||||
] as HighlightColor[];
|
|
||||||
|
|
||||||
const getColorHex = (
|
const getColorHex = (
|
||||||
customColors: Record<HighlightColor, string>,
|
customColors: Record<HighlightColor, string>,
|
||||||
color: HighlightColor,
|
color: HighlightColor,
|
||||||
): string => {
|
): string => {
|
||||||
if (color.startsWith('#')) return color;
|
if (color.startsWith('#')) return color;
|
||||||
return customColors[color as HighlightColor] ?? color;
|
return customColors[color] ?? color;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface HighlightOptionsProps {
|
interface HighlightOptionsProps {
|
||||||
@@ -39,6 +38,7 @@ interface HighlightOptionsProps {
|
|||||||
|
|
||||||
const OPTIONS_HEIGHT_PIX = 28;
|
const OPTIONS_HEIGHT_PIX = 28;
|
||||||
const OPTIONS_PADDING_PIX = 16;
|
const OPTIONS_PADDING_PIX = 16;
|
||||||
|
const LABEL_PREVIEW_MS = 2200;
|
||||||
|
|
||||||
const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||||
isVertical,
|
isVertical,
|
||||||
@@ -61,13 +61,96 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
|||||||
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
|
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
|
||||||
const customColors = globalReadSettings.customHighlightColors;
|
const customColors = globalReadSettings.customHighlightColors;
|
||||||
const userColors = globalReadSettings.userHighlightColors ?? [];
|
const userColors = globalReadSettings.userHighlightColors ?? [];
|
||||||
|
const allColors: HighlightColor[] = [
|
||||||
|
...DEFAULT_HIGHLIGHT_COLORS,
|
||||||
|
...userColors.map((c) => c.hex),
|
||||||
|
];
|
||||||
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(_selectedStyle);
|
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(_selectedStyle);
|
||||||
const [selectedColor, setSelectedColor] = useState<HighlightColor>(_selectedColor);
|
const [selectedColor, setSelectedColor] = useState<HighlightColor>(_selectedColor);
|
||||||
|
const [previewColor, setPreviewColor] = useState<HighlightColor | null>(null);
|
||||||
|
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const suppressTapRef = useRef(false);
|
||||||
|
const colorStripRef = useRef<HTMLDivElement | null>(null);
|
||||||
const size16 = useResponsiveSize(16);
|
const size16 = useResponsiveSize(16);
|
||||||
const size28 = useResponsiveSize(28);
|
const size28 = useResponsiveSize(28);
|
||||||
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
|
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
|
||||||
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
|
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isDragging: isDraggingColorStrip,
|
||||||
|
pointerHandlers: stripPointerHandlers,
|
||||||
|
shouldSuppressClick: shouldSuppressStripClick,
|
||||||
|
} = useDragScroll(colorStripRef, { enabled: !isVertical });
|
||||||
|
|
||||||
|
const clearLongPressTimer = () => {
|
||||||
|
if (longPressTimerRef.current) {
|
||||||
|
clearTimeout(longPressTimerRef.current);
|
||||||
|
longPressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPreviewTimer = () => {
|
||||||
|
if (previewTimerRef.current) {
|
||||||
|
clearTimeout(previewTimerRef.current);
|
||||||
|
previewTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a color's label. Order of preference:
|
||||||
|
* 1. user-set label (custom string, shown verbatim)
|
||||||
|
* 2. translated default name (only for the 5 predefined colors)
|
||||||
|
* 3. the color value itself (hex fallback)
|
||||||
|
*/
|
||||||
|
const resolveHighlightLabel = (color: HighlightColor): string => {
|
||||||
|
const userLabel = getHighlightColorLabel(settings, color);
|
||||||
|
if (userLabel) return userLabel;
|
||||||
|
if (!color.startsWith('#')) return _(color);
|
||||||
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showHighlightLabelPreview = (color: HighlightColor) => {
|
||||||
|
setPreviewColor(color);
|
||||||
|
clearPreviewTimer();
|
||||||
|
previewTimerRef.current = setTimeout(() => setPreviewColor(null), LABEL_PREVIEW_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorPointerDown = (
|
||||||
|
event: React.PointerEvent<HTMLButtonElement>,
|
||||||
|
color: HighlightColor,
|
||||||
|
) => {
|
||||||
|
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearLongPressTimer();
|
||||||
|
suppressTapRef.current = false;
|
||||||
|
longPressTimerRef.current = setTimeout(() => {
|
||||||
|
suppressTapRef.current = true;
|
||||||
|
showHighlightLabelPreview(color);
|
||||||
|
}, LONG_HOLD_THRESHOLD);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorPointerEnd = () => {
|
||||||
|
clearLongPressTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorClick = (color: HighlightColor) => {
|
||||||
|
if (shouldSuppressStripClick()) return;
|
||||||
|
if (suppressTapRef.current) {
|
||||||
|
suppressTapRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSelectColor(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearLongPressTimer();
|
||||||
|
clearPreviewTimer();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSelectStyle = (style: HighlightStyle) => {
|
const handleSelectStyle = (style: HighlightStyle) => {
|
||||||
const newGlobalReadSettings = { ...globalReadSettings, highlightStyle: style };
|
const newGlobalReadSettings = { ...globalReadSettings, highlightStyle: style };
|
||||||
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
|
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
|
||||||
@@ -90,7 +173,7 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'highlight-options absolute flex items-center justify-between gap-4',
|
'highlight-options absolute flex items-center gap-4',
|
||||||
isVertical ? 'flex-col' : 'flex-row',
|
isVertical ? 'flex-col' : 'flex-row',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -162,42 +245,64 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={colorStripRef}
|
||||||
|
{...stripPointerHandlers}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'not-eink:bg-gray-700 eink-bordered flex items-center gap-2 rounded-3xl',
|
'not-eink:bg-gray-700 eink-bordered flex items-center gap-2 rounded-3xl',
|
||||||
isVertical ? 'flex-col overflow-y-auto py-2' : 'flex-row overflow-x-auto px-2',
|
isVertical
|
||||||
|
? 'flex-col overflow-y-auto py-2'
|
||||||
|
: 'min-w-0 flex-1 flex-row overflow-x-auto px-2',
|
||||||
|
!isVertical && 'cursor-grab',
|
||||||
|
!isVertical && isDraggingColorStrip && 'cursor-grabbing',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
...(isVertical ? { width: size28 } : { height: size28 }),
|
...(isVertical ? { width: size28 } : { height: size28 }),
|
||||||
scrollbarWidth: 'none',
|
scrollbarWidth: 'none',
|
||||||
msOverflowStyle: 'none',
|
msOverflowStyle: 'none',
|
||||||
|
WebkitUserSelect: isDraggingColorStrip ? 'none' : undefined,
|
||||||
|
userSelect: isDraggingColorStrip ? 'none' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{defaultColors
|
{allColors
|
||||||
.concat(userColors)
|
|
||||||
.filter((c) => (isBwEink ? selectedColor === c : true))
|
.filter((c) => (isBwEink ? selectedColor === c : true))
|
||||||
.map((color) => (
|
.map((color) => {
|
||||||
<div key={color} className='flex items-center justify-center'>
|
const label = resolveHighlightLabel(color);
|
||||||
<button
|
const swatchColor = customColors[color] || color;
|
||||||
key={color}
|
return (
|
||||||
aria-label={_('Select {{color}} color', { color: _(color) })}
|
<div key={color} className='relative flex items-center justify-center'>
|
||||||
onClick={() => handleSelectColor(color)}
|
{previewColor === color && (
|
||||||
style={{
|
<div
|
||||||
width: size16,
|
className='eink-bordered pointer-events-none absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-800 px-2 py-0.5 text-[10px] text-white'
|
||||||
height: size16,
|
style={{ maxWidth: 120 }}
|
||||||
backgroundColor:
|
>
|
||||||
selectedColor !== color ? customColors[color] || color : 'transparent',
|
{label}
|
||||||
}}
|
</div>
|
||||||
className='rounded-full p-0'
|
|
||||||
>
|
|
||||||
{selectedColor === color && (
|
|
||||||
<FaCheckCircle
|
|
||||||
size={size16}
|
|
||||||
style={{ fill: isBwEink ? einkFgColor : customColors[color] || color }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
<button
|
||||||
</div>
|
aria-label={_('Select {{color}} color', { color: label })}
|
||||||
))}
|
title={label}
|
||||||
|
onClick={() => handleColorClick(color)}
|
||||||
|
onPointerDown={(event) => handleColorPointerDown(event, color)}
|
||||||
|
onPointerUp={handleColorPointerEnd}
|
||||||
|
onPointerLeave={handleColorPointerEnd}
|
||||||
|
onPointerCancel={handleColorPointerEnd}
|
||||||
|
style={{
|
||||||
|
width: size16,
|
||||||
|
height: size16,
|
||||||
|
backgroundColor: selectedColor !== color ? swatchColor : 'transparent',
|
||||||
|
}}
|
||||||
|
className='rounded-full p-0'
|
||||||
|
>
|
||||||
|
{selectedColor === color && (
|
||||||
|
<FaCheckCircle
|
||||||
|
size={size16}
|
||||||
|
style={{ fill: isBwEink ? einkFgColor : swatchColor }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||||
import { HighlightColor } from '@/types/book';
|
import { DEFAULT_HIGHLIGHT_COLORS, HighlightColor } from '@/types/book';
|
||||||
import { SystemSettings } from '@/types/settings';
|
import { SystemSettings } from '@/types/settings';
|
||||||
import { Point } from '@/utils/sel';
|
import { Point } from '@/utils/sel';
|
||||||
|
|
||||||
|
export const isDefaultHighlightColor = (
|
||||||
|
color: HighlightColor,
|
||||||
|
): color is (typeof DEFAULT_HIGHLIGHT_COLORS)[number] => {
|
||||||
|
return (DEFAULT_HIGHLIGHT_COLORS as readonly string[]).includes(color);
|
||||||
|
};
|
||||||
|
|
||||||
export const getHighlightColorHex = (
|
export const getHighlightColorHex = (
|
||||||
settings: SystemSettings,
|
settings: SystemSettings,
|
||||||
color?: HighlightColor,
|
color?: HighlightColor,
|
||||||
@@ -13,6 +19,27 @@ export const getHighlightColorHex = (
|
|||||||
return customColors?.[color] ?? HIGHLIGHT_COLOR_HEX[color];
|
return customColors?.[color] ?? HIGHLIGHT_COLOR_HEX[color];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a user-defined label for the given color, or `undefined` when none is set.
|
||||||
|
* Callers that want to fall back to a translated default name should handle that in
|
||||||
|
* the component layer (where `useTranslation` is available).
|
||||||
|
*/
|
||||||
|
export const getHighlightColorLabel = (
|
||||||
|
settings: SystemSettings,
|
||||||
|
color: HighlightColor,
|
||||||
|
): string | undefined => {
|
||||||
|
const { defaultHighlightLabels, userHighlightColors } = settings.globalReadSettings;
|
||||||
|
if (color.startsWith('#')) {
|
||||||
|
const hex = color.trim().toLowerCase();
|
||||||
|
const entry = userHighlightColors?.find((c) => c.hex === hex);
|
||||||
|
return entry?.label?.trim() || undefined;
|
||||||
|
}
|
||||||
|
if (isDefaultHighlightColor(color)) {
|
||||||
|
return defaultHighlightLabels?.[color]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export function getExternalDragHandle(
|
export function getExternalDragHandle(
|
||||||
currentStart: Point,
|
currentStart: Point,
|
||||||
currentEnd: Point,
|
currentEnd: Point,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { SettingsPanelPanelProp } from './SettingsDialog';
|
|||||||
import { useFileSelector } from '@/hooks/useFileSelector';
|
import { useFileSelector } from '@/hooks/useFileSelector';
|
||||||
import { PREDEFINED_TEXTURES } from '@/styles/textures';
|
import { PREDEFINED_TEXTURES } from '@/styles/textures';
|
||||||
import { useAtmosphereStore } from '@/store/atmosphereStore';
|
import { useAtmosphereStore } from '@/store/atmosphereStore';
|
||||||
|
import { DefaultHighlightColor, HighlightColor, UserHighlightColor } from '@/types/book';
|
||||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||||
import ThemeEditor from './color/ThemeEditor';
|
import ThemeEditor from './color/ThemeEditor';
|
||||||
import ThemeModeSelector from './color/ThemeModeSelector';
|
import ThemeModeSelector from './color/ThemeModeSelector';
|
||||||
@@ -54,9 +55,12 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
|||||||
const [customHighlightColors, setCustomHighlightColors] = useState(
|
const [customHighlightColors, setCustomHighlightColors] = useState(
|
||||||
settings.globalReadSettings.customHighlightColors,
|
settings.globalReadSettings.customHighlightColors,
|
||||||
);
|
);
|
||||||
const [userHighlightColors, setUserHighlightColors] = useState(
|
const [userHighlightColors, setUserHighlightColors] = useState<UserHighlightColor[]>(
|
||||||
settings.globalReadSettings.userHighlightColors || [],
|
settings.globalReadSettings.userHighlightColors ?? [],
|
||||||
);
|
);
|
||||||
|
const [defaultHighlightLabels, setDefaultHighlightLabels] = useState<
|
||||||
|
Partial<Record<DefaultHighlightColor, string>>
|
||||||
|
>(settings.globalReadSettings.defaultHighlightLabels ?? {});
|
||||||
|
|
||||||
const [readingRulerEnabled, setReadingRulerEnabled] = useState(viewSettings.readingRulerEnabled);
|
const [readingRulerEnabled, setReadingRulerEnabled] = useState(viewSettings.readingRulerEnabled);
|
||||||
const [readingRulerLines, setReadingRulerLines] = useState(viewSettings.readingRulerLines);
|
const [readingRulerLines, setReadingRulerLines] = useState(viewSettings.readingRulerLines);
|
||||||
@@ -94,6 +98,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
|||||||
setBackgroundSize('cover');
|
setBackgroundSize('cover');
|
||||||
setCustomHighlightColors(HIGHLIGHT_COLOR_HEX);
|
setCustomHighlightColors(HIGHLIGHT_COLOR_HEX);
|
||||||
setUserHighlightColors([]);
|
setUserHighlightColors([]);
|
||||||
|
setDefaultHighlightLabels({});
|
||||||
deactivateAtmosphere();
|
deactivateAtmosphere();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,20 +272,29 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
|||||||
saveCustomTextures(envConfig);
|
saveCustomTextures(envConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHighlightColorsChange = (colors: typeof customHighlightColors) => {
|
const handleCustomHighlightColorsChange = (colors: Record<HighlightColor, string>) => {
|
||||||
setCustomHighlightColors(colors);
|
setCustomHighlightColors(colors);
|
||||||
settings.globalReadSettings.customHighlightColors = colors;
|
settings.globalReadSettings.customHighlightColors = colors;
|
||||||
setSettings(settings);
|
setSettings(settings);
|
||||||
saveSettings(envConfig, settings);
|
saveSettings(envConfig, settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserHighlightColorsChange = (colors: string[]) => {
|
const handleUserHighlightColorsChange = (colors: UserHighlightColor[]) => {
|
||||||
setUserHighlightColors(colors);
|
setUserHighlightColors(colors);
|
||||||
settings.globalReadSettings.userHighlightColors = colors;
|
settings.globalReadSettings.userHighlightColors = colors;
|
||||||
setSettings(settings);
|
setSettings(settings);
|
||||||
saveSettings(envConfig, settings);
|
saveSettings(envConfig, settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDefaultHighlightLabelsChange = (
|
||||||
|
labels: Partial<Record<DefaultHighlightColor, string>>,
|
||||||
|
) => {
|
||||||
|
setDefaultHighlightLabels(labels);
|
||||||
|
settings.globalReadSettings.defaultHighlightLabels = labels;
|
||||||
|
setSettings(settings);
|
||||||
|
saveSettings(envConfig, settings);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='my-4 w-full space-y-6'>
|
<div className='my-4 w-full space-y-6'>
|
||||||
{showCustomThemeEditor ? (
|
{showCustomThemeEditor ? (
|
||||||
@@ -352,10 +366,12 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
|||||||
<HighlightColorsEditor
|
<HighlightColorsEditor
|
||||||
customHighlightColors={customHighlightColors}
|
customHighlightColors={customHighlightColors}
|
||||||
userHighlightColors={userHighlightColors}
|
userHighlightColors={userHighlightColors}
|
||||||
|
defaultHighlightLabels={defaultHighlightLabels}
|
||||||
highlightOpacity={highlightOpacity}
|
highlightOpacity={highlightOpacity}
|
||||||
isEink={viewSettings.isEink}
|
isEink={viewSettings.isEink}
|
||||||
onChange={handleHighlightColorsChange}
|
onCustomHighlightColorsChange={handleCustomHighlightColorsChange}
|
||||||
onUserColorsChange={handleUserHighlightColorsChange}
|
onUserHighlightColorsChange={handleUserHighlightColorsChange}
|
||||||
|
onDefaultHighlightLabelsChange={handleDefaultHighlightLabelsChange}
|
||||||
onOpacityChange={setHighlightOpacity}
|
onOpacityChange={setHighlightOpacity}
|
||||||
data-setting-id='settings.color.highlightColors'
|
data-setting-id='settings.color.highlightColors'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { MdClose } from 'react-icons/md';
|
import { MdClose } from 'react-icons/md';
|
||||||
import { HighlightColor } from '@/types/book';
|
import {
|
||||||
|
DEFAULT_HIGHLIGHT_COLORS,
|
||||||
|
DefaultHighlightColor,
|
||||||
|
HighlightColor,
|
||||||
|
UserHighlightColor,
|
||||||
|
} from '@/types/book';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import NumberInput from '../NumberInput';
|
import NumberInput from '../NumberInput';
|
||||||
import ColorInput from './ColorInput';
|
import ColorInput from './ColorInput';
|
||||||
@@ -9,25 +14,70 @@ const MAX_USER_HIGHLIGHT_COLORS = 10;
|
|||||||
|
|
||||||
interface HighlightColorsEditorProps {
|
interface HighlightColorsEditorProps {
|
||||||
customHighlightColors: Record<HighlightColor, string>;
|
customHighlightColors: Record<HighlightColor, string>;
|
||||||
userHighlightColors: string[];
|
userHighlightColors: UserHighlightColor[];
|
||||||
|
defaultHighlightLabels: Partial<Record<DefaultHighlightColor, string>>;
|
||||||
highlightOpacity: number;
|
highlightOpacity: number;
|
||||||
isEink: boolean;
|
isEink: boolean;
|
||||||
onChange: (colors: Record<HighlightColor, string>) => void;
|
onCustomHighlightColorsChange: (colors: Record<HighlightColor, string>) => void;
|
||||||
onUserColorsChange: (colors: string[]) => void;
|
onUserHighlightColorsChange: (colors: UserHighlightColor[]) => void;
|
||||||
|
onDefaultHighlightLabelsChange: (labels: Partial<Record<DefaultHighlightColor, string>>) => void;
|
||||||
onOpacityChange: (opacity: number) => void;
|
onOpacityChange: (opacity: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text input that commits on blur instead of on every keystroke, so we don't
|
||||||
|
* thrash the settings store while the user is typing a label.
|
||||||
|
*/
|
||||||
|
const LabelInput: React.FC<{
|
||||||
|
label: string;
|
||||||
|
onCommit: (next: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
className: string;
|
||||||
|
}> = ({ label, onCommit, placeholder, className }) => {
|
||||||
|
const [draft, setDraft] = useState(label);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(label);
|
||||||
|
}, [label]);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (trimmed !== label) onCommit(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={20}
|
||||||
|
className={className}
|
||||||
|
title={draft}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeHex = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
|
const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
|
||||||
customHighlightColors,
|
customHighlightColors,
|
||||||
userHighlightColors,
|
userHighlightColors,
|
||||||
|
defaultHighlightLabels,
|
||||||
highlightOpacity,
|
highlightOpacity,
|
||||||
isEink,
|
isEink,
|
||||||
onChange,
|
onCustomHighlightColorsChange,
|
||||||
onUserColorsChange,
|
onUserHighlightColorsChange,
|
||||||
|
onDefaultHighlightLabelsChange,
|
||||||
onOpacityChange,
|
onOpacityChange,
|
||||||
}) => {
|
}) => {
|
||||||
const _ = useTranslation();
|
const _ = useTranslation();
|
||||||
const [newColor, setNewColor] = useState('#808080');
|
const [newColor, setNewColor] = useState('#808080');
|
||||||
|
const [newColorLabel, setNewColorLabel] = useState('');
|
||||||
|
|
||||||
const highlightPreviewStyle: React.CSSProperties = {
|
const highlightPreviewStyle: React.CSSProperties = {
|
||||||
opacity: highlightOpacity,
|
opacity: highlightOpacity,
|
||||||
@@ -35,125 +85,171 @@ const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
|
|||||||
'var(--overlayer-highlight-blend-mode, normal)' as React.CSSProperties['mixBlendMode'],
|
'var(--overlayer-highlight-blend-mode, normal)' as React.CSSProperties['mixBlendMode'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleColorChange = (color: HighlightColor, value: string) => {
|
const handleDefaultHexChange = (color: DefaultHighlightColor, hex: string) => {
|
||||||
const updated = { ...customHighlightColors, [color]: value };
|
onCustomHighlightColorsChange({ ...customHighlightColors, [color]: hex });
|
||||||
onChange(updated);
|
};
|
||||||
|
|
||||||
|
const handleDefaultLabelChange = (color: DefaultHighlightColor, label: string) => {
|
||||||
|
const next = { ...defaultHighlightLabels };
|
||||||
|
if (label) {
|
||||||
|
next[color] = label;
|
||||||
|
} else {
|
||||||
|
delete next[color];
|
||||||
|
}
|
||||||
|
onDefaultHighlightLabelsChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserLabelChange = (hex: string, label: string) => {
|
||||||
|
const key = normalizeHex(hex);
|
||||||
|
onUserHighlightColorsChange(
|
||||||
|
userHighlightColors.map((entry) =>
|
||||||
|
entry.hex === key ? { ...entry, label: label || undefined } : entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddUserColor = () => {
|
const handleAddUserColor = () => {
|
||||||
if (userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS) return;
|
if (userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS) return;
|
||||||
if (!userHighlightColors.includes(newColor)) {
|
const hex = normalizeHex(newColor);
|
||||||
const updatedColors = [...userHighlightColors, newColor];
|
if (!hex.startsWith('#')) return;
|
||||||
onUserColorsChange(updatedColors);
|
if (userHighlightColors.some((entry) => entry.hex === hex)) return;
|
||||||
}
|
const label = newColorLabel.trim();
|
||||||
|
onUserHighlightColorsChange([...userHighlightColors, label ? { hex, label } : { hex }]);
|
||||||
|
setNewColorLabel('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteUserColor = (hex: string) => {
|
const handleDeleteUserColor = (hex: string) => {
|
||||||
const updatedColors = userHighlightColors.filter((c) => c !== hex);
|
const key = normalizeHex(hex);
|
||||||
onUserColorsChange(updatedColors);
|
onUserHighlightColorsChange(userHighlightColors.filter((entry) => entry.hex !== key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserColorChange = (oldHex: string, newHex: string) => {
|
const handleUserHexChange = (oldHex: string, newHex: string) => {
|
||||||
const updatedColors = userHighlightColors.map((c) => (c === oldHex ? newHex : c));
|
const oldKey = normalizeHex(oldHex);
|
||||||
onUserColorsChange(updatedColors);
|
const newKey = normalizeHex(newHex);
|
||||||
|
if (oldKey === newKey) return;
|
||||||
|
// Drop the rename if it collides with another existing color.
|
||||||
|
if (userHighlightColors.some((entry) => entry.hex === newKey)) return;
|
||||||
|
onUserHighlightColorsChange(
|
||||||
|
userHighlightColors.map((entry) =>
|
||||||
|
entry.hex === oldKey ? { ...entry, hex: newKey } : entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDuplicateNewColor = userHighlightColors.some(
|
||||||
|
(entry) => entry.hex === normalizeHex(newColor),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
|
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
|
||||||
<div className='card border-base-200 bg-base-100 overflow-visible border shadow'>
|
<div className='card border-base-200 bg-base-100 overflow-visible border shadow'>
|
||||||
<div className='grid grid-cols-3 gap-3 p-4 sm:grid-cols-5'>
|
<div className='grid grid-cols-2 gap-3 p-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5'>
|
||||||
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
|
{DEFAULT_HIGHLIGHT_COLORS.map((color, index, array) => {
|
||||||
(color, index, array) => {
|
const position = index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
|
||||||
const position =
|
return (
|
||||||
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
|
<div key={color} className='flex min-w-0 flex-col items-center gap-2'>
|
||||||
return (
|
<LabelInput
|
||||||
<div key={color} className='flex flex-col items-center gap-2'>
|
label={defaultHighlightLabels[color] ?? ''}
|
||||||
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
|
onCommit={(next) => handleDefaultLabelChange(color, next)}
|
||||||
<div
|
placeholder={_('Name')}
|
||||||
className='h-full w-full rounded-full'
|
className='input input-xs bg-base-100 border-base-200/75 h-6 w-full min-w-0 max-w-24 text-center text-xs'
|
||||||
style={{
|
/>
|
||||||
backgroundColor: customHighlightColors[color],
|
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
|
||||||
...highlightPreviewStyle,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ColorInput
|
|
||||||
label=''
|
|
||||||
value={customHighlightColors[color]!}
|
|
||||||
compact={true}
|
|
||||||
pickerPosition={position}
|
|
||||||
onChange={(value: string) => handleColorChange(color, value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(userHighlightColors.length > 0 || true) && (
|
|
||||||
<div className='border-base-200 border-t p-4'>
|
|
||||||
<div className='mb-2 flex items-center justify-between'>
|
|
||||||
<span className='font-normal'>
|
|
||||||
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
|
|
||||||
</span>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'>
|
|
||||||
<div
|
<div
|
||||||
className='h-full w-full rounded-full'
|
className='h-full w-full rounded-full'
|
||||||
style={{ backgroundColor: newColor, ...highlightPreviewStyle }}
|
style={{
|
||||||
|
backgroundColor: customHighlightColors[color],
|
||||||
|
...highlightPreviewStyle,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ColorInput
|
<ColorInput
|
||||||
label=''
|
label=''
|
||||||
value={newColor}
|
value={customHighlightColors[color]!}
|
||||||
compact={true}
|
compact={true}
|
||||||
pickerPosition='right'
|
pickerPosition={position}
|
||||||
onChange={setNewColor}
|
onChange={(value: string) => handleDefaultHexChange(color, value)}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={handleAddUserColor}
|
|
||||||
disabled={
|
|
||||||
userHighlightColors.includes(newColor) ||
|
|
||||||
userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
|
|
||||||
}
|
|
||||||
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
|
|
||||||
>
|
|
||||||
<span className='text-xs'>{_('Add')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{userHighlightColors.length > 0 && (
|
<div className='border-base-200 border-t p-4'>
|
||||||
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
|
<div className='mb-2 flex items-center justify-between'>
|
||||||
{userHighlightColors.map((hex, index) => (
|
<span className='font-normal'>
|
||||||
<div key={hex} className='group relative flex flex-col items-center gap-2'>
|
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
|
||||||
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
|
</span>
|
||||||
<div
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
className='h-full w-full rounded-full'
|
<div className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'>
|
||||||
style={{ backgroundColor: hex, ...highlightPreviewStyle }}
|
<div
|
||||||
/>
|
className='h-full w-full rounded-full'
|
||||||
</div>
|
style={{ backgroundColor: newColor, ...highlightPreviewStyle }}
|
||||||
<ColorInput
|
/>
|
||||||
label=''
|
|
||||||
value={hex}
|
|
||||||
compact={true}
|
|
||||||
pickerPosition={index === 0 ? 'left' : 'center'}
|
|
||||||
onChange={(value: string) => handleUserColorChange(hex, value)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteUserColor(hex)}
|
|
||||||
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
|
|
||||||
title={_('Delete')}
|
|
||||||
>
|
|
||||||
<MdClose size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<ColorInput
|
||||||
|
label=''
|
||||||
|
value={newColor}
|
||||||
|
compact={true}
|
||||||
|
pickerPosition='right'
|
||||||
|
onChange={setNewColor}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={newColorLabel}
|
||||||
|
onChange={(e) => setNewColorLabel(e.target.value)}
|
||||||
|
placeholder={_('Name')}
|
||||||
|
maxLength={20}
|
||||||
|
className='input input-xs bg-base-100 border-base-200/75 h-6 w-24 text-center text-xs'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddUserColor}
|
||||||
|
disabled={
|
||||||
|
isDuplicateNewColor || userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
|
||||||
|
}
|
||||||
|
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
|
||||||
|
>
|
||||||
|
<span className='text-xs'>{_('Add')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{userHighlightColors.length > 0 && (
|
||||||
|
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
|
||||||
|
{userHighlightColors.map(({ hex, label }, index) => (
|
||||||
|
<div key={hex} className='group relative flex min-w-0 flex-col items-center gap-2'>
|
||||||
|
<LabelInput
|
||||||
|
label={label ?? ''}
|
||||||
|
onCommit={(next) => handleUserLabelChange(hex, next)}
|
||||||
|
placeholder={_('Name')}
|
||||||
|
className='input input-xs bg-base-100 border-base-200/75 h-6 w-full min-w-0 max-w-24 text-center text-xs'
|
||||||
|
/>
|
||||||
|
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
|
||||||
|
<div
|
||||||
|
className='h-full w-full rounded-full'
|
||||||
|
style={{ backgroundColor: hex, ...highlightPreviewStyle }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ColorInput
|
||||||
|
label=''
|
||||||
|
value={hex}
|
||||||
|
compact={true}
|
||||||
|
pickerPosition={index === 0 ? 'left' : 'center'}
|
||||||
|
onChange={(value: string) => handleUserHexChange(hex, value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUserColor(hex)}
|
||||||
|
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
|
||||||
|
title={_('Delete')}
|
||||||
|
>
|
||||||
|
<MdClose size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={_('Opacity')}
|
label={_('Opacity')}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface UseDragScrollOptions {
|
||||||
|
/** When false, pointer events are ignored. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Pixels the pointer must travel before entering drag mode. */
|
||||||
|
threshold?: number;
|
||||||
|
/**
|
||||||
|
* Milliseconds to suppress the synthetic click that browsers fire after a drag.
|
||||||
|
* Without this, releasing the mouse on a child button would register as a tap.
|
||||||
|
*/
|
||||||
|
clickSuppressMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDragScrollResult<T extends HTMLElement> {
|
||||||
|
/** True while the pointer has moved past the threshold. Useful for cursor styling. */
|
||||||
|
isDragging: boolean;
|
||||||
|
/** Spread these on the scroll container. */
|
||||||
|
pointerHandlers: {
|
||||||
|
onPointerDown: (event: React.PointerEvent<T>) => void;
|
||||||
|
onPointerMove: (event: React.PointerEvent<T>) => void;
|
||||||
|
onPointerUp: (event: React.PointerEvent<T>) => void;
|
||||||
|
onPointerCancel: (event: React.PointerEvent<T>) => void;
|
||||||
|
onPointerLeave: (event: React.PointerEvent<T>) => void;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Returns true if a pending drag or recent drag-release should swallow a
|
||||||
|
* click. Child click handlers should early-return when this is true.
|
||||||
|
*/
|
||||||
|
shouldSuppressClick: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds mouse drag-to-scroll to a horizontally scrollable container. Touch users
|
||||||
|
* already get native momentum scrolling, so this hook intentionally ignores
|
||||||
|
* non-mouse pointer types.
|
||||||
|
*/
|
||||||
|
export function useDragScroll<T extends HTMLElement>(
|
||||||
|
ref: RefObject<T | null>,
|
||||||
|
{ enabled = true, threshold = 6, clickSuppressMs = 120 }: UseDragScrollOptions = {},
|
||||||
|
): UseDragScrollResult<T> {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const stateRef = useRef({
|
||||||
|
active: false,
|
||||||
|
startX: 0,
|
||||||
|
startScrollLeft: 0,
|
||||||
|
moved: false,
|
||||||
|
});
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
|
const suppressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearSuppressTimer = useCallback(() => {
|
||||||
|
if (suppressTimerRef.current) {
|
||||||
|
clearTimeout(suppressTimerRef.current);
|
||||||
|
suppressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(event: React.PointerEvent<T>) => {
|
||||||
|
if (!enabled || event.pointerType !== 'mouse') return;
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
stateRef.current = {
|
||||||
|
active: true,
|
||||||
|
startX: event.clientX,
|
||||||
|
startScrollLeft: el.scrollLeft,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
setIsDragging(false);
|
||||||
|
},
|
||||||
|
[enabled, ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(event: React.PointerEvent<T>) => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || !state.active) return;
|
||||||
|
const deltaX = event.clientX - state.startX;
|
||||||
|
if (!state.moved && Math.abs(deltaX) >= threshold) {
|
||||||
|
state.moved = true;
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
if (state.moved) {
|
||||||
|
el.scrollLeft = state.startScrollLeft - deltaX;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ref, threshold],
|
||||||
|
);
|
||||||
|
|
||||||
|
const endDrag = useCallback(() => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
if (!state.active) return;
|
||||||
|
const moved = state.moved;
|
||||||
|
state.active = false;
|
||||||
|
state.moved = false;
|
||||||
|
setIsDragging(false);
|
||||||
|
if (moved) {
|
||||||
|
clearSuppressTimer();
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
suppressTimerRef.current = setTimeout(() => {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
suppressTimerRef.current = null;
|
||||||
|
}, clickSuppressMs);
|
||||||
|
}
|
||||||
|
}, [clearSuppressTimer, clickSuppressMs]);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(
|
||||||
|
(event: React.PointerEvent<T>) => {
|
||||||
|
if (event.pointerType !== 'mouse') return;
|
||||||
|
endDrag();
|
||||||
|
},
|
||||||
|
[endDrag],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerCancel = useCallback(() => endDrag(), [endDrag]);
|
||||||
|
const onPointerLeave = useCallback(
|
||||||
|
(event: React.PointerEvent<T>) => {
|
||||||
|
if (event.pointerType !== 'mouse') return;
|
||||||
|
endDrag();
|
||||||
|
},
|
||||||
|
[endDrag],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearSuppressTimer();
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
stateRef.current.active = false;
|
||||||
|
stateRef.current.moved = false;
|
||||||
|
};
|
||||||
|
}, [clearSuppressTimer]);
|
||||||
|
|
||||||
|
const shouldSuppressClick = useCallback(
|
||||||
|
() => stateRef.current.active || suppressClickRef.current,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDragging,
|
||||||
|
pointerHandlers: {
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
onPointerCancel,
|
||||||
|
onPointerLeave,
|
||||||
|
},
|
||||||
|
shouldSuppressClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -157,6 +157,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
|
|||||||
},
|
},
|
||||||
customHighlightColors: HIGHLIGHT_COLOR_HEX,
|
customHighlightColors: HIGHLIGHT_COLOR_HEX,
|
||||||
userHighlightColors: [],
|
userHighlightColors: [],
|
||||||
|
defaultHighlightLabels: {},
|
||||||
customTtsHighlightColors: [],
|
customTtsHighlightColors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FileSystem } from '@/types/system';
|
import { FileSystem } from '@/types/system';
|
||||||
import { SystemSettings } from '@/types/settings';
|
import { ReadSettings, SystemSettings } from '@/types/settings';
|
||||||
import { ViewSettings } from '@/types/book';
|
import { DEFAULT_HIGHLIGHT_COLORS, UserHighlightColor, ViewSettings } from '@/types/book';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BOOK_LAYOUT,
|
DEFAULT_BOOK_LAYOUT,
|
||||||
@@ -50,6 +50,55 @@ export function getDefaultViewSettings(ctx: Context): ViewSettings {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize highlight color prefs into the current shape:
|
||||||
|
* - `userHighlightColors` becomes `UserHighlightColor[]`. Legacy `string[]` entries
|
||||||
|
* are lifted into `{ hex }`. A legacy `highlightColorLabels` map (shipped only in
|
||||||
|
* draft builds of this feature) is folded in: hex entries attach to matching user
|
||||||
|
* colors, named entries move into `defaultHighlightLabels`.
|
||||||
|
*/
|
||||||
|
export function migrateHighlightColorPrefs(read: ReadSettings): void {
|
||||||
|
const rawUser = (read.userHighlightColors ?? []) as unknown[];
|
||||||
|
const userColors: UserHighlightColor[] = rawUser
|
||||||
|
.map((entry) => {
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
return { hex: entry.trim().toLowerCase() };
|
||||||
|
}
|
||||||
|
if (entry && typeof entry === 'object' && 'hex' in entry) {
|
||||||
|
const { hex, label } = entry as UserHighlightColor;
|
||||||
|
return {
|
||||||
|
hex: typeof hex === 'string' ? hex.trim().toLowerCase() : '',
|
||||||
|
...(label?.trim() ? { label: label.trim() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { hex: '' };
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.hex.startsWith('#'));
|
||||||
|
|
||||||
|
read.defaultHighlightLabels = { ...(read.defaultHighlightLabels ?? {}) };
|
||||||
|
|
||||||
|
const legacyLabels = (read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels;
|
||||||
|
if (legacyLabels && typeof legacyLabels === 'object') {
|
||||||
|
const labels = legacyLabels as Record<string, unknown>;
|
||||||
|
for (const name of DEFAULT_HIGHLIGHT_COLORS) {
|
||||||
|
const value = labels[name];
|
||||||
|
if (typeof value === 'string' && value.trim() && !read.defaultHighlightLabels[name]) {
|
||||||
|
read.defaultHighlightLabels[name] = value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of userColors) {
|
||||||
|
if (entry.label) continue;
|
||||||
|
const value = labels[entry.hex];
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
entry.label = value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete (read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
read.userHighlightColors = userColors;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSettings(ctx: Context): Promise<SystemSettings> {
|
export async function loadSettings(ctx: Context): Promise<SystemSettings> {
|
||||||
const defaultSettings: SystemSettings = {
|
const defaultSettings: SystemSettings = {
|
||||||
...DEFAULT_SYSTEM_SETTINGS,
|
...DEFAULT_SYSTEM_SETTINGS,
|
||||||
@@ -85,6 +134,7 @@ export async function loadSettings(ctx: Context): Promise<SystemSettings> {
|
|||||||
...(ctx.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
|
...(ctx.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
|
||||||
...settings.globalReadSettings,
|
...settings.globalReadSettings,
|
||||||
};
|
};
|
||||||
|
migrateHighlightColorPrefs(settings.globalReadSettings);
|
||||||
settings.globalViewSettings = {
|
settings.globalViewSettings = {
|
||||||
...getDefaultViewSettings(ctx),
|
...getDefaultViewSettings(ctx),
|
||||||
...settings.globalViewSettings,
|
...settings.globalViewSettings,
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export type ReadingStatus = 'unread' | 'reading' | 'finished';
|
|||||||
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
|
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
|
||||||
// Predefined highlight colors, can be extended with custom hex colors
|
// Predefined highlight colors, can be extended with custom hex colors
|
||||||
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet' | string;
|
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet' | string;
|
||||||
|
export const DEFAULT_HIGHLIGHT_COLORS = ['red', 'yellow', 'green', 'blue', 'violet'] as const;
|
||||||
|
export type DefaultHighlightColor = (typeof DEFAULT_HIGHLIGHT_COLORS)[number];
|
||||||
|
// A user-added highlight color with optional label
|
||||||
|
export interface UserHighlightColor {
|
||||||
|
hex: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
export type ReadingRulerColor = 'transparent' | 'yellow' | 'green' | 'blue' | 'rose';
|
export type ReadingRulerColor = 'transparent' | 'yellow' | 'green' | 'blue' | 'rose';
|
||||||
|
|
||||||
export interface ParagraphModeConfig {
|
export interface ParagraphModeConfig {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CustomTheme } from '@/styles/themes';
|
import { CustomTheme } from '@/styles/themes';
|
||||||
import { CustomFont } from '@/styles/fonts';
|
import { CustomFont } from '@/styles/fonts';
|
||||||
import { CustomTexture } from '@/styles/textures';
|
import { CustomTexture } from '@/styles/textures';
|
||||||
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
|
import { HighlightColor, HighlightStyle, UserHighlightColor, ViewSettings } from './book';
|
||||||
import { OPDSCatalog } from './opds';
|
import { OPDSCatalog } from './opds';
|
||||||
import type { AISettings } from '@/services/ai/types';
|
import type { AISettings } from '@/services/ai/types';
|
||||||
import type { NotebookTab } from '@/store/notebookStore';
|
import type { NotebookTab } from '@/store/notebookStore';
|
||||||
@@ -48,7 +48,8 @@ export interface ReadSettings {
|
|||||||
highlightStyle: HighlightStyle;
|
highlightStyle: HighlightStyle;
|
||||||
highlightStyles: Record<HighlightStyle, HighlightColor>;
|
highlightStyles: Record<HighlightStyle, HighlightColor>;
|
||||||
customHighlightColors: Record<HighlightColor, string>;
|
customHighlightColors: Record<HighlightColor, string>;
|
||||||
userHighlightColors: string[];
|
userHighlightColors: UserHighlightColor[];
|
||||||
|
defaultHighlightLabels: Partial<Record<HighlightColor, string>>;
|
||||||
customTtsHighlightColors: string[];
|
customTtsHighlightColors: string[];
|
||||||
customThemes: CustomTheme[];
|
customThemes: CustomTheme[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user