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:
Mohammed Efaz
2026-04-05 16:08:55 +06:00
committed by GitHub
parent b3fe33221d
commit d682fcbb44
11 changed files with 739 additions and 147 deletions
@@ -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>
);
@@ -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,