d682fcbb44
* 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>
153 lines
4.3 KiB
TypeScript
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,
|
|
};
|
|
}
|