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:
@@ -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<HighlightColor, string>,
|
||||
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<HighlightOptionsProps> = ({
|
||||
isVertical,
|
||||
@@ -61,13 +61,96 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
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<HighlightStyle>(_selectedStyle);
|
||||
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 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<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 newGlobalReadSettings = { ...globalReadSettings, highlightStyle: style };
|
||||
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
|
||||
@@ -90,7 +173,7 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
)}
|
||||
style={{
|
||||
@@ -162,42 +245,64 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={colorStripRef}
|
||||
{...stripPointerHandlers}
|
||||
className={clsx(
|
||||
'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={{
|
||||
...(isVertical ? { width: size28 } : { height: size28 }),
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
WebkitUserSelect: isDraggingColorStrip ? 'none' : undefined,
|
||||
userSelect: isDraggingColorStrip ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
{defaultColors
|
||||
.concat(userColors)
|
||||
{allColors
|
||||
.filter((c) => (isBwEink ? selectedColor === c : true))
|
||||
.map((color) => (
|
||||
<div key={color} className='flex items-center justify-center'>
|
||||
<button
|
||||
key={color}
|
||||
aria-label={_('Select {{color}} color', { color: _(color) })}
|
||||
onClick={() => handleSelectColor(color)}
|
||||
style={{
|
||||
width: size16,
|
||||
height: size16,
|
||||
backgroundColor:
|
||||
selectedColor !== color ? customColors[color] || color : 'transparent',
|
||||
}}
|
||||
className='rounded-full p-0'
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<FaCheckCircle
|
||||
size={size16}
|
||||
style={{ fill: isBwEink ? einkFgColor : customColors[color] || color }}
|
||||
/>
|
||||
.map((color) => {
|
||||
const label = resolveHighlightLabel(color);
|
||||
const swatchColor = customColors[color] || color;
|
||||
return (
|
||||
<div key={color} className='relative flex items-center justify-center'>
|
||||
{previewColor === color && (
|
||||
<div
|
||||
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'
|
||||
style={{ maxWidth: 120 }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user