Files
readest/apps/readest-app/src/hooks/useDragScroll.ts
T
Mohammed Efaz d682fcbb44 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>
2026-04-05 18:08:55 +08:00

153 lines
4.3 KiB
TypeScript

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,
};
}