diff --git a/apps/readest-app/src/__tests__/services/settings-highlight-migration.test.ts b/apps/readest-app/src/__tests__/services/settings-highlight-migration.test.ts new file mode 100644 index 00000000..0e3fa959 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/settings-highlight-migration.test.ts @@ -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' }); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/annotator-util.test.ts b/apps/readest-app/src/__tests__/utils/annotator-util.test.ts index 342bde9f..3b50818a 100644 --- a/apps/readest-app/src/__tests__/utils/annotator-util.test.ts +++ b/apps/readest-app/src/__tests__/utils/annotator-util.test.ts @@ -1,6 +1,12 @@ 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 { UserHighlightColor } from '@/types/book'; +import { SystemSettings } from '@/types/settings'; describe('getExternalDragHandle', () => { const currentStart: Point = { x: 100, y: 200 }; @@ -99,3 +105,38 @@ describe('toParentViewportPoint', () => { expect(result).toEqual({ x: 300, y: 100 }); }); }); + +describe('getHighlightColorLabel', () => { + const makeSettings = ( + userHighlightColors: UserHighlightColor[], + defaultHighlightLabels: Partial> = {}, + ): 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(); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx b/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx index 419b2e85..442ed0ec 100644 --- a/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx @@ -1,30 +1,29 @@ import clsx from 'clsx'; -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; 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 { useThemeStore } from '@/store/themeStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useSettingsStore } from '@/store/settingsStore'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { useDragScroll } from '@/hooks/useDragScroll'; import { saveSysSettings } from '@/helpers/settings'; +import { LONG_HOLD_THRESHOLD } from '@/services/constants'; +import { getHighlightColorLabel } from '../../utils/annotatorUtil'; 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 defaultColors = [ - _('red'), - _('violet'), - _('blue'), - _('green'), - _('yellow'), -] as HighlightColor[]; +void [_('red'), _('yellow'), _('green'), _('blue'), _('violet')]; const getColorHex = ( customColors: Record, color: HighlightColor, ): string => { if (color.startsWith('#')) return color; - return customColors[color as HighlightColor] ?? color; + return customColors[color] ?? color; }; interface HighlightOptionsProps { @@ -39,6 +38,7 @@ interface HighlightOptionsProps { const OPTIONS_HEIGHT_PIX = 28; const OPTIONS_PADDING_PIX = 16; +const LABEL_PREVIEW_MS = 2200; const HighlightOptions: React.FC = ({ isVertical, @@ -61,13 +61,96 @@ const HighlightOptions: React.FC = ({ const einkFgColor = isDarkMode ? '#ffffff' : '#000000'; const customColors = globalReadSettings.customHighlightColors; const userColors = globalReadSettings.userHighlightColors ?? []; + const allColors: HighlightColor[] = [ + ...DEFAULT_HIGHLIGHT_COLORS, + ...userColors.map((c) => c.hex), + ]; const [selectedStyle, setSelectedStyle] = useState(_selectedStyle); const [selectedColor, setSelectedColor] = useState(_selectedColor); + const [previewColor, setPreviewColor] = useState(null); + const longPressTimerRef = useRef | null>(null); + const previewTimerRef = useRef | null>(null); + const suppressTapRef = useRef(false); + const colorStripRef = useRef(null); const size16 = useResponsiveSize(16); const size28 = useResponsiveSize(28); const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_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, + 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 newGlobalReadSettings = { ...globalReadSettings, highlightStyle: style }; saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings); @@ -90,7 +173,7 @@ const HighlightOptions: React.FC = ({ return (
= ({
- {defaultColors - .concat(userColors) + {allColors .filter((c) => (isBwEink ? selectedColor === c : true)) - .map((color) => ( -
- -
- ))} + +
+ ); + })} ); diff --git a/apps/readest-app/src/app/reader/utils/annotatorUtil.ts b/apps/readest-app/src/app/reader/utils/annotatorUtil.ts index 3d031d54..f8943d93 100644 --- a/apps/readest-app/src/app/reader/utils/annotatorUtil.ts +++ b/apps/readest-app/src/app/reader/utils/annotatorUtil.ts @@ -1,8 +1,14 @@ 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 { 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 = ( settings: SystemSettings, color?: HighlightColor, @@ -13,6 +19,27 @@ export const getHighlightColorHex = ( 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( currentStart: Point, currentEnd: Point, diff --git a/apps/readest-app/src/components/settings/ColorPanel.tsx b/apps/readest-app/src/components/settings/ColorPanel.tsx index 252fe316..00988fe6 100644 --- a/apps/readest-app/src/components/settings/ColorPanel.tsx +++ b/apps/readest-app/src/components/settings/ColorPanel.tsx @@ -20,6 +20,7 @@ import { SettingsPanelPanelProp } from './SettingsDialog'; import { useFileSelector } from '@/hooks/useFileSelector'; import { PREDEFINED_TEXTURES } from '@/styles/textures'; import { useAtmosphereStore } from '@/store/atmosphereStore'; +import { DefaultHighlightColor, HighlightColor, UserHighlightColor } from '@/types/book'; import { HIGHLIGHT_COLOR_HEX } from '@/services/constants'; import ThemeEditor from './color/ThemeEditor'; import ThemeModeSelector from './color/ThemeModeSelector'; @@ -54,9 +55,12 @@ const ColorPanel: React.FC = ({ bookKey, onRegisterReset const [customHighlightColors, setCustomHighlightColors] = useState( settings.globalReadSettings.customHighlightColors, ); - const [userHighlightColors, setUserHighlightColors] = useState( - settings.globalReadSettings.userHighlightColors || [], + const [userHighlightColors, setUserHighlightColors] = useState( + settings.globalReadSettings.userHighlightColors ?? [], ); + const [defaultHighlightLabels, setDefaultHighlightLabels] = useState< + Partial> + >(settings.globalReadSettings.defaultHighlightLabels ?? {}); const [readingRulerEnabled, setReadingRulerEnabled] = useState(viewSettings.readingRulerEnabled); const [readingRulerLines, setReadingRulerLines] = useState(viewSettings.readingRulerLines); @@ -94,6 +98,7 @@ const ColorPanel: React.FC = ({ bookKey, onRegisterReset setBackgroundSize('cover'); setCustomHighlightColors(HIGHLIGHT_COLOR_HEX); setUserHighlightColors([]); + setDefaultHighlightLabels({}); deactivateAtmosphere(); }; @@ -267,20 +272,29 @@ const ColorPanel: React.FC = ({ bookKey, onRegisterReset saveCustomTextures(envConfig); }; - const handleHighlightColorsChange = (colors: typeof customHighlightColors) => { + const handleCustomHighlightColorsChange = (colors: Record) => { setCustomHighlightColors(colors); settings.globalReadSettings.customHighlightColors = colors; setSettings(settings); saveSettings(envConfig, settings); }; - const handleUserHighlightColorsChange = (colors: string[]) => { + const handleUserHighlightColorsChange = (colors: UserHighlightColor[]) => { setUserHighlightColors(colors); settings.globalReadSettings.userHighlightColors = colors; setSettings(settings); saveSettings(envConfig, settings); }; + const handleDefaultHighlightLabelsChange = ( + labels: Partial>, + ) => { + setDefaultHighlightLabels(labels); + settings.globalReadSettings.defaultHighlightLabels = labels; + setSettings(settings); + saveSettings(envConfig, settings); + }; + return (
{showCustomThemeEditor ? ( @@ -352,10 +366,12 @@ const ColorPanel: React.FC = ({ bookKey, onRegisterReset diff --git a/apps/readest-app/src/components/settings/color/HighlightColorsEditor.tsx b/apps/readest-app/src/components/settings/color/HighlightColorsEditor.tsx index 0ae0a69b..537fe487 100644 --- a/apps/readest-app/src/components/settings/color/HighlightColorsEditor.tsx +++ b/apps/readest-app/src/components/settings/color/HighlightColorsEditor.tsx @@ -1,6 +1,11 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; 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 NumberInput from '../NumberInput'; import ColorInput from './ColorInput'; @@ -9,25 +14,70 @@ const MAX_USER_HIGHLIGHT_COLORS = 10; interface HighlightColorsEditorProps { customHighlightColors: Record; - userHighlightColors: string[]; + userHighlightColors: UserHighlightColor[]; + defaultHighlightLabels: Partial>; highlightOpacity: number; isEink: boolean; - onChange: (colors: Record) => void; - onUserColorsChange: (colors: string[]) => void; + onCustomHighlightColorsChange: (colors: Record) => void; + onUserHighlightColorsChange: (colors: UserHighlightColor[]) => void; + onDefaultHighlightLabelsChange: (labels: Partial>) => 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 ( + 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 = ({ customHighlightColors, userHighlightColors, + defaultHighlightLabels, highlightOpacity, isEink, - onChange, - onUserColorsChange, + onCustomHighlightColorsChange, + onUserHighlightColorsChange, + onDefaultHighlightLabelsChange, onOpacityChange, }) => { const _ = useTranslation(); const [newColor, setNewColor] = useState('#808080'); + const [newColorLabel, setNewColorLabel] = useState(''); const highlightPreviewStyle: React.CSSProperties = { opacity: highlightOpacity, @@ -35,125 +85,171 @@ const HighlightColorsEditor: React.FC = ({ 'var(--overlayer-highlight-blend-mode, normal)' as React.CSSProperties['mixBlendMode'], }; - const handleColorChange = (color: HighlightColor, value: string) => { - const updated = { ...customHighlightColors, [color]: value }; - onChange(updated); + const handleDefaultHexChange = (color: DefaultHighlightColor, hex: string) => { + onCustomHighlightColorsChange({ ...customHighlightColors, [color]: hex }); + }; + + 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 = () => { if (userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS) return; - if (!userHighlightColors.includes(newColor)) { - const updatedColors = [...userHighlightColors, newColor]; - onUserColorsChange(updatedColors); - } + const hex = normalizeHex(newColor); + if (!hex.startsWith('#')) return; + if (userHighlightColors.some((entry) => entry.hex === hex)) return; + const label = newColorLabel.trim(); + onUserHighlightColorsChange([...userHighlightColors, label ? { hex, label } : { hex }]); + setNewColorLabel(''); }; const handleDeleteUserColor = (hex: string) => { - const updatedColors = userHighlightColors.filter((c) => c !== hex); - onUserColorsChange(updatedColors); + const key = normalizeHex(hex); + onUserHighlightColorsChange(userHighlightColors.filter((entry) => entry.hex !== key)); }; - const handleUserColorChange = (oldHex: string, newHex: string) => { - const updatedColors = userHighlightColors.map((c) => (c === oldHex ? newHex : c)); - onUserColorsChange(updatedColors); + const handleUserHexChange = (oldHex: string, newHex: string) => { + const oldKey = normalizeHex(oldHex); + 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 (

{_('Highlight Colors')}

-
- {(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map( - (color, index, array) => { - const position = - index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center'; - return ( -
-
-
-
- handleColorChange(color, value)} - /> -
- ); - }, - )} -
- - {(userHighlightColors.length > 0 || true) && ( -
-
- - {_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS}) - -
-
+
+ {DEFAULT_HIGHLIGHT_COLORS.map((color, index, array) => { + const position = index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center'; + return ( +
+ handleDefaultLabelChange(color, 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' + /> +
handleDefaultHexChange(color, value)} /> -
-
+ ); + })} +
- {userHighlightColors.length > 0 && ( -
- {userHighlightColors.map((hex, index) => ( -
-
-
-
- handleUserColorChange(hex, value)} - /> - -
- ))} +
+
+ + {_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS}) + +
+
+
- )} + + 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' + /> + +
- )} + + {userHighlightColors.length > 0 && ( +
+ {userHighlightColors.map(({ hex, label }, index) => ( +
+ 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' + /> +
+
+
+ handleUserHexChange(hex, value)} + /> + +
+ ))} +
+ )} +
{ + /** 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) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + onPointerCancel: (event: React.PointerEvent) => void; + onPointerLeave: (event: React.PointerEvent) => 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( + ref: RefObject, + { enabled = true, threshold = 6, clickSuppressMs = 120 }: UseDragScrollOptions = {}, +): UseDragScrollResult { + const [isDragging, setIsDragging] = useState(false); + const stateRef = useRef({ + active: false, + startX: 0, + startScrollLeft: 0, + moved: false, + }); + const suppressClickRef = useRef(false); + const suppressTimerRef = useRef | null>(null); + + const clearSuppressTimer = useCallback(() => { + if (suppressTimerRef.current) { + clearTimeout(suppressTimerRef.current); + suppressTimerRef.current = null; + } + }, []); + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + 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) => { + 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) => { + if (event.pointerType !== 'mouse') return; + endDrag(); + }, + [endDrag], + ); + + const onPointerCancel = useCallback(() => endDrag(), [endDrag]); + const onPointerLeave = useCallback( + (event: React.PointerEvent) => { + 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, + }; +} diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index d25cc330..45b4ba7f 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -157,6 +157,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = { }, customHighlightColors: HIGHLIGHT_COLOR_HEX, userHighlightColors: [], + defaultHighlightLabels: {}, customTtsHighlightColors: [], }; diff --git a/apps/readest-app/src/services/settingsService.ts b/apps/readest-app/src/services/settingsService.ts index 3da13863..24fa7ab6 100644 --- a/apps/readest-app/src/services/settingsService.ts +++ b/apps/readest-app/src/services/settingsService.ts @@ -1,6 +1,6 @@ import { FileSystem } from '@/types/system'; -import { SystemSettings } from '@/types/settings'; -import { ViewSettings } from '@/types/book'; +import { ReadSettings, SystemSettings } from '@/types/settings'; +import { DEFAULT_HIGHLIGHT_COLORS, UserHighlightColor, ViewSettings } from '@/types/book'; import { v4 as uuidv4 } from 'uuid'; import { 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; + 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 { const defaultSettings: SystemSettings = { ...DEFAULT_SYSTEM_SETTINGS, @@ -85,6 +134,7 @@ export async function loadSettings(ctx: Context): Promise { ...(ctx.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}), ...settings.globalReadSettings, }; + migrateHighlightColorPrefs(settings.globalReadSettings); settings.globalViewSettings = { ...getDefaultViewSettings(ctx), ...settings.globalViewSettings, diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 2ae15a3c..454be23b 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -19,6 +19,13 @@ export type ReadingStatus = 'unread' | 'reading' | 'finished'; export type HighlightStyle = 'highlight' | 'underline' | 'squiggly'; // Predefined highlight colors, can be extended with custom hex colors 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 interface ParagraphModeConfig { diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 83cff57a..f10821b7 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -1,7 +1,7 @@ import { CustomTheme } from '@/styles/themes'; import { CustomFont } from '@/styles/fonts'; import { CustomTexture } from '@/styles/textures'; -import { HighlightColor, HighlightStyle, ViewSettings } from './book'; +import { HighlightColor, HighlightStyle, UserHighlightColor, ViewSettings } from './book'; import { OPDSCatalog } from './opds'; import type { AISettings } from '@/services/ai/types'; import type { NotebookTab } from '@/store/notebookStore'; @@ -48,7 +48,8 @@ export interface ReadSettings { highlightStyle: HighlightStyle; highlightStyles: Record; customHighlightColors: Record; - userHighlightColors: string[]; + userHighlightColors: UserHighlightColor[]; + defaultHighlightLabels: Partial>; customTtsHighlightColors: string[]; customThemes: CustomTheme[]; }