@plannotator/ui 0.51.0 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +235 -0
- package/README.md +15 -0
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/BlockRenderer.tsx +16 -1
- package/components/CommentPopover.tsx +4 -0
- package/components/CompletionOverlay.tsx +12 -5
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +3 -191
- package/components/Viewer.tsx +44 -1
- package/components/blocks/QuestionBlock.tsx +137 -8
- package/components/html-viewer/bridge-script.asset.js +106 -6
- package/components/html-viewer/bridge-script.ts +106 -6
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +57 -1
- package/hooks/useAnnotationHighlighter.ts +28 -0
- package/package.json +2 -2
- package/styles.css +1 -1
- package/types.ts +6 -0
- package/utils/parser.ts +149 -7
- package/utils/quickLabels.ts +80 -0
- package/utils/serverSession.ts +85 -0
|
@@ -2457,9 +2457,88 @@
|
|
|
2457
2457
|
|
|
2458
2458
|
function anchorTextSnapshot(el) {
|
|
2459
2459
|
var text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
2460
|
+
if (!text) return anchorSourceSnapshot(el);
|
|
2460
2461
|
return text.length > 180 ? text.slice(0, 180) : text;
|
|
2461
2462
|
}
|
|
2462
2463
|
|
|
2464
|
+
// Media snapshots. An image, video or frame has no text to verify a weak
|
|
2465
|
+
// selector against, but its source says what it shows the way text says
|
|
2466
|
+
// what a paragraph says, and unlike the structure-derived signatures D1
|
|
2467
|
+
// ruled out, two siblings showing different files never share it. The key
|
|
2468
|
+
// is the author's reference to the resource: relative to the document's
|
|
2469
|
+
// base when it lies under it (a host may re-root assets per session, through
|
|
2470
|
+
// a <base> on its own asset route or by rewriting attributes to a session
|
|
2471
|
+
// host), else its root-relative path on the document's own origin (a live
|
|
2472
|
+
// app's proxy port changes per session), else origin and path. The query
|
|
2473
|
+
// and the fragment are part of what a source shows (/_next/image?url=a.jpg,
|
|
2474
|
+
// proto.html?step=2, icons.svg#close), so they count, but only as a digest:
|
|
2475
|
+
// they can carry per-visit state (signed URLs, tokens) that must never be
|
|
2476
|
+
// stored in plain text in an annotation. A source whose query changes per
|
|
2477
|
+
// load therefore does not restore, which fails closed. Inline sources
|
|
2478
|
+
// (data:, blob:) name no resource and get no key.
|
|
2479
|
+
var ANCHOR_SOURCE_PREFIX = 'src:';
|
|
2480
|
+
var ANCHOR_SOURCE_MAX = 176;
|
|
2481
|
+
|
|
2482
|
+
// FNV-1a, 32 bits, as 8 hex digits: an identity check, not a secret.
|
|
2483
|
+
function anchorDigest(value) {
|
|
2484
|
+
var hash = 0x811c9dc5;
|
|
2485
|
+
for (var i = 0; i < value.length; i++) {
|
|
2486
|
+
hash ^= value.charCodeAt(i);
|
|
2487
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
2488
|
+
}
|
|
2489
|
+
return ('0000000' + hash.toString(16)).slice(-8);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function anchorSourceKey(el) {
|
|
2493
|
+
var raw = descRawSource(el);
|
|
2494
|
+
if (!raw || /^(data|blob):/i.test(raw)) return '';
|
|
2495
|
+
var path = raw;
|
|
2496
|
+
var variant = '';
|
|
2497
|
+
var url = null;
|
|
2498
|
+
try { url = new URL(raw, document.baseURI); } catch (ex) {}
|
|
2499
|
+
if (!url || !/^https?:$/i.test(url.protocol)) {
|
|
2500
|
+
var mark = raw.search(/[?#]/);
|
|
2501
|
+
if (mark >= 0) {
|
|
2502
|
+
path = raw.slice(0, mark);
|
|
2503
|
+
variant = raw.slice(mark);
|
|
2504
|
+
}
|
|
2505
|
+
} else {
|
|
2506
|
+
variant = url.search + url.hash;
|
|
2507
|
+
var base = null;
|
|
2508
|
+
try { base = new URL('.', document.baseURI); } catch (ex2) {}
|
|
2509
|
+
if (base && url.origin === base.origin && url.pathname.indexOf(base.pathname) === 0) {
|
|
2510
|
+
path = url.pathname.slice(base.pathname.length);
|
|
2511
|
+
} else if (base && url.origin === base.origin) {
|
|
2512
|
+
path = url.pathname;
|
|
2513
|
+
} else {
|
|
2514
|
+
path = url.origin + url.pathname;
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
if (!path) return '';
|
|
2518
|
+
return variant ? path + '?' + anchorDigest(variant) : path;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function sourceSnapshotOf(el) {
|
|
2522
|
+
var key = anchorSourceKey(el);
|
|
2523
|
+
if (!key) return '';
|
|
2524
|
+
// A long key keeps its head for readability and a digest of the whole,
|
|
2525
|
+
// so two sources that differ only past the cut never compare equal.
|
|
2526
|
+
if (key.length > ANCHOR_SOURCE_MAX) key = key.slice(0, ANCHOR_SOURCE_MAX - 9) + '~' + anchorDigest(key);
|
|
2527
|
+
return ANCHOR_SOURCE_PREFIX + key;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// A source that another element of the same kind shares cannot tell the two
|
|
2531
|
+
// apart, which is the D1 failure again: no snapshot, so no anchor.
|
|
2532
|
+
function anchorSourceSnapshot(el) {
|
|
2533
|
+
var snapshot = sourceSnapshotOf(el);
|
|
2534
|
+
if (!snapshot) return '';
|
|
2535
|
+
var sameKind = document.getElementsByTagName(el.tagName);
|
|
2536
|
+
for (var i = 0; i < sameKind.length; i++) {
|
|
2537
|
+
if (sameKind[i] !== el && sourceSnapshotOf(sameKind[i]) === snapshot) return '';
|
|
2538
|
+
}
|
|
2539
|
+
return snapshot;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2463
2542
|
function uniquelySelects(selector, el) {
|
|
2464
2543
|
try {
|
|
2465
2544
|
var matches = document.querySelectorAll(selector);
|
|
@@ -2558,12 +2637,13 @@
|
|
|
2558
2637
|
if (!selector) return null;
|
|
2559
2638
|
var snapshot = anchorTextSnapshot(el);
|
|
2560
2639
|
// Text-less elements anchor ONLY through a stable-identity rung (#id or
|
|
2561
|
-
// an author-controlled data-* identity attribute)
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
2565
|
-
//
|
|
2566
|
-
//
|
|
2640
|
+
// an author-controlled data-* identity attribute), or, for media, through
|
|
2641
|
+
// a source no other element of its kind shows (anchorSourceSnapshot).
|
|
2642
|
+
// Otherwise a weak (positional / class) selector has nothing to verify
|
|
2643
|
+
// against — identical siblings share every structural trait, so any
|
|
2644
|
+
// derived signature would validate the WRONG element after a sibling is
|
|
2645
|
+
// inserted or removed. A wrong-binding anchor is worse than no anchor:
|
|
2646
|
+
// ship none and fail closed (the pin simply doesn't restore).
|
|
2567
2647
|
if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
|
|
2568
2648
|
return {
|
|
2569
2649
|
selector: selector,
|
|
@@ -2596,6 +2676,26 @@
|
|
|
2596
2676
|
|
|
2597
2677
|
function resolveAnchorElement(anchor) {
|
|
2598
2678
|
if (!anchor || typeof anchor.selector !== 'string' || !anchor.selector || typeof anchor.tagName !== 'string') return null;
|
|
2679
|
+
return resolveAnchorSelector(anchor) || relocateBySource(anchor);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// A media anchor whose selector no longer lands on its element (a sibling
|
|
2683
|
+
// was inserted or removed, a refresh reordered the page) finds it again by
|
|
2684
|
+
// its source, the way a text annotation falls back to searching for its
|
|
2685
|
+
// text. Exactly one element may match: two resolve nothing.
|
|
2686
|
+
function relocateBySource(anchor) {
|
|
2687
|
+
if (typeof anchor.text !== 'string' || anchor.text.indexOf(ANCHOR_SOURCE_PREFIX) !== 0) return null;
|
|
2688
|
+
var found = null;
|
|
2689
|
+
var candidates = document.getElementsByTagName(anchor.tagName);
|
|
2690
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
2691
|
+
if (sourceSnapshotOf(candidates[i]) !== anchor.text) continue;
|
|
2692
|
+
if (found) return null;
|
|
2693
|
+
found = candidates[i];
|
|
2694
|
+
}
|
|
2695
|
+
return found;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function resolveAnchorSelector(anchor) {
|
|
2599
2699
|
var matches;
|
|
2600
2700
|
try {
|
|
2601
2701
|
matches = document.querySelectorAll(anchor.selector);
|
|
@@ -2693,9 +2693,88 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2693
2693
|
|
|
2694
2694
|
function anchorTextSnapshot(el) {
|
|
2695
2695
|
var text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
2696
|
+
if (!text) return anchorSourceSnapshot(el);
|
|
2696
2697
|
return text.length > 180 ? text.slice(0, 180) : text;
|
|
2697
2698
|
}
|
|
2698
2699
|
|
|
2700
|
+
// Media snapshots. An image, video or frame has no text to verify a weak
|
|
2701
|
+
// selector against, but its source says what it shows the way text says
|
|
2702
|
+
// what a paragraph says, and unlike the structure-derived signatures D1
|
|
2703
|
+
// ruled out, two siblings showing different files never share it. The key
|
|
2704
|
+
// is the author's reference to the resource: relative to the document's
|
|
2705
|
+
// base when it lies under it (a host may re-root assets per session, through
|
|
2706
|
+
// a <base> on its own asset route or by rewriting attributes to a session
|
|
2707
|
+
// host), else its root-relative path on the document's own origin (a live
|
|
2708
|
+
// app's proxy port changes per session), else origin and path. The query
|
|
2709
|
+
// and the fragment are part of what a source shows (/_next/image?url=a.jpg,
|
|
2710
|
+
// proto.html?step=2, icons.svg#close), so they count, but only as a digest:
|
|
2711
|
+
// they can carry per-visit state (signed URLs, tokens) that must never be
|
|
2712
|
+
// stored in plain text in an annotation. A source whose query changes per
|
|
2713
|
+
// load therefore does not restore, which fails closed. Inline sources
|
|
2714
|
+
// (data:, blob:) name no resource and get no key.
|
|
2715
|
+
var ANCHOR_SOURCE_PREFIX = 'src:';
|
|
2716
|
+
var ANCHOR_SOURCE_MAX = 176;
|
|
2717
|
+
|
|
2718
|
+
// FNV-1a, 32 bits, as 8 hex digits: an identity check, not a secret.
|
|
2719
|
+
function anchorDigest(value) {
|
|
2720
|
+
var hash = 0x811c9dc5;
|
|
2721
|
+
for (var i = 0; i < value.length; i++) {
|
|
2722
|
+
hash ^= value.charCodeAt(i);
|
|
2723
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
2724
|
+
}
|
|
2725
|
+
return ('0000000' + hash.toString(16)).slice(-8);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
function anchorSourceKey(el) {
|
|
2729
|
+
var raw = descRawSource(el);
|
|
2730
|
+
if (!raw || /^(data|blob):/i.test(raw)) return '';
|
|
2731
|
+
var path = raw;
|
|
2732
|
+
var variant = '';
|
|
2733
|
+
var url = null;
|
|
2734
|
+
try { url = new URL(raw, document.baseURI); } catch (ex) {}
|
|
2735
|
+
if (!url || !/^https?:$/i.test(url.protocol)) {
|
|
2736
|
+
var mark = raw.search(/[?#]/);
|
|
2737
|
+
if (mark >= 0) {
|
|
2738
|
+
path = raw.slice(0, mark);
|
|
2739
|
+
variant = raw.slice(mark);
|
|
2740
|
+
}
|
|
2741
|
+
} else {
|
|
2742
|
+
variant = url.search + url.hash;
|
|
2743
|
+
var base = null;
|
|
2744
|
+
try { base = new URL('.', document.baseURI); } catch (ex2) {}
|
|
2745
|
+
if (base && url.origin === base.origin && url.pathname.indexOf(base.pathname) === 0) {
|
|
2746
|
+
path = url.pathname.slice(base.pathname.length);
|
|
2747
|
+
} else if (base && url.origin === base.origin) {
|
|
2748
|
+
path = url.pathname;
|
|
2749
|
+
} else {
|
|
2750
|
+
path = url.origin + url.pathname;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
if (!path) return '';
|
|
2754
|
+
return variant ? path + '?' + anchorDigest(variant) : path;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
function sourceSnapshotOf(el) {
|
|
2758
|
+
var key = anchorSourceKey(el);
|
|
2759
|
+
if (!key) return '';
|
|
2760
|
+
// A long key keeps its head for readability and a digest of the whole,
|
|
2761
|
+
// so two sources that differ only past the cut never compare equal.
|
|
2762
|
+
if (key.length > ANCHOR_SOURCE_MAX) key = key.slice(0, ANCHOR_SOURCE_MAX - 9) + '~' + anchorDigest(key);
|
|
2763
|
+
return ANCHOR_SOURCE_PREFIX + key;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// A source that another element of the same kind shares cannot tell the two
|
|
2767
|
+
// apart, which is the D1 failure again: no snapshot, so no anchor.
|
|
2768
|
+
function anchorSourceSnapshot(el) {
|
|
2769
|
+
var snapshot = sourceSnapshotOf(el);
|
|
2770
|
+
if (!snapshot) return '';
|
|
2771
|
+
var sameKind = document.getElementsByTagName(el.tagName);
|
|
2772
|
+
for (var i = 0; i < sameKind.length; i++) {
|
|
2773
|
+
if (sameKind[i] !== el && sourceSnapshotOf(sameKind[i]) === snapshot) return '';
|
|
2774
|
+
}
|
|
2775
|
+
return snapshot;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2699
2778
|
function uniquelySelects(selector, el) {
|
|
2700
2779
|
try {
|
|
2701
2780
|
var matches = document.querySelectorAll(selector);
|
|
@@ -2794,12 +2873,13 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2794
2873
|
if (!selector) return null;
|
|
2795
2874
|
var snapshot = anchorTextSnapshot(el);
|
|
2796
2875
|
// Text-less elements anchor ONLY through a stable-identity rung (#id or
|
|
2797
|
-
// an author-controlled data-* identity attribute)
|
|
2798
|
-
//
|
|
2799
|
-
//
|
|
2800
|
-
//
|
|
2801
|
-
//
|
|
2802
|
-
//
|
|
2876
|
+
// an author-controlled data-* identity attribute), or, for media, through
|
|
2877
|
+
// a source no other element of its kind shows (anchorSourceSnapshot).
|
|
2878
|
+
// Otherwise a weak (positional / class) selector has nothing to verify
|
|
2879
|
+
// against — identical siblings share every structural trait, so any
|
|
2880
|
+
// derived signature would validate the WRONG element after a sibling is
|
|
2881
|
+
// inserted or removed. A wrong-binding anchor is worse than no anchor:
|
|
2882
|
+
// ship none and fail closed (the pin simply doesn't restore).
|
|
2803
2883
|
if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
|
|
2804
2884
|
return {
|
|
2805
2885
|
selector: selector,
|
|
@@ -2832,6 +2912,26 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2832
2912
|
|
|
2833
2913
|
function resolveAnchorElement(anchor) {
|
|
2834
2914
|
if (!anchor || typeof anchor.selector !== 'string' || !anchor.selector || typeof anchor.tagName !== 'string') return null;
|
|
2915
|
+
return resolveAnchorSelector(anchor) || relocateBySource(anchor);
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// A media anchor whose selector no longer lands on its element (a sibling
|
|
2919
|
+
// was inserted or removed, a refresh reordered the page) finds it again by
|
|
2920
|
+
// its source, the way a text annotation falls back to searching for its
|
|
2921
|
+
// text. Exactly one element may match: two resolve nothing.
|
|
2922
|
+
function relocateBySource(anchor) {
|
|
2923
|
+
if (typeof anchor.text !== 'string' || anchor.text.indexOf(ANCHOR_SOURCE_PREFIX) !== 0) return null;
|
|
2924
|
+
var found = null;
|
|
2925
|
+
var candidates = document.getElementsByTagName(anchor.tagName);
|
|
2926
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
2927
|
+
if (sourceSnapshotOf(candidates[i]) !== anchor.text) continue;
|
|
2928
|
+
if (found) return null;
|
|
2929
|
+
found = candidates[i];
|
|
2930
|
+
}
|
|
2931
|
+
return found;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
function resolveAnchorSelector(anchor) {
|
|
2835
2935
|
var matches;
|
|
2836
2936
|
try {
|
|
2837
2937
|
matches = document.querySelectorAll(anchor.selector);
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import React, { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { isMac, isWindows, altKey } from '../../utils/platform';
|
|
3
|
+
import {
|
|
4
|
+
type QuickLabel,
|
|
5
|
+
DEFAULT_QUICK_LABELS,
|
|
6
|
+
LABEL_COLOR_MAP,
|
|
7
|
+
emojiFromFieldInput,
|
|
8
|
+
getLabelColors,
|
|
9
|
+
getQuickLabels,
|
|
10
|
+
moveQuickLabel,
|
|
11
|
+
quickLabelShortcutDigit,
|
|
12
|
+
resetQuickLabels,
|
|
13
|
+
saveQuickLabels,
|
|
14
|
+
} from '../../utils/quickLabels';
|
|
15
|
+
|
|
16
|
+
/** Most labels the tab lets a person keep (only the first ten get a key). */
|
|
17
|
+
const MAX_QUICK_LABELS = 12;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One editable row. `key` is a session-only identity, never persisted: the
|
|
21
|
+
* saved shape stays exactly `QuickLabel[]`. A label's `id` cannot serve here —
|
|
22
|
+
* it is re-derived from the text on every keystroke and two labels with the
|
|
23
|
+
* same text share it — and position cannot either, because moving a row must
|
|
24
|
+
* not move the open tip editor, an emoji draft, or focus onto a neighbour
|
|
25
|
+
* (the #829 bug class, #1736).
|
|
26
|
+
*/
|
|
27
|
+
interface QuickLabelRow {
|
|
28
|
+
key: string;
|
|
29
|
+
label: QuickLabel;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let rowKeySeq = 0;
|
|
33
|
+
const nextRowKey = () => `quick-label-row-${++rowKeySeq}`;
|
|
34
|
+
const toRows = (labels: readonly QuickLabel[]): QuickLabelRow[] =>
|
|
35
|
+
labels.map((label) => ({ key: nextRowKey(), label }));
|
|
36
|
+
|
|
37
|
+
const shortcutHint = (digit: string) => `${altKey}${isMac ? '' : '+'}${digit}`;
|
|
38
|
+
const shortcutWords = (digit: string) => `${isMac ? 'Option' : 'Alt'}+${digit}`;
|
|
39
|
+
const labelName = (label: QuickLabel) => label.text.trim() || 'label';
|
|
40
|
+
|
|
41
|
+
const EMOJI_HINT = isMac
|
|
42
|
+
? 'Enter one emoji. Control-Command-Space opens the emoji picker.'
|
|
43
|
+
: isWindows
|
|
44
|
+
? 'Enter one emoji. Windows+period opens the emoji picker.'
|
|
45
|
+
: 'Enter one emoji.';
|
|
46
|
+
|
|
47
|
+
const ChevronIcon: React.FC<{ up: boolean }> = ({ up }) => (
|
|
48
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
|
49
|
+
<path strokeLinecap="round" strokeLinejoin="round" d={up ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'} />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** Settings → Labels: the Quick Labels editor (plan review and annotate). */
|
|
54
|
+
export const QuickLabelsTab: React.FC = () => {
|
|
55
|
+
const [rows, setRows] = useState<QuickLabelRow[]>(() => toRows(getQuickLabels()));
|
|
56
|
+
const [editingTipKey, setEditingTipKey] = useState<string | null>(null);
|
|
57
|
+
const [editingTipValue, setEditingTipValue] = useState('');
|
|
58
|
+
/** Emoji fields whose current text is not (yet) one emoji, by row key. */
|
|
59
|
+
const [emojiDrafts, setEmojiDrafts] = useState<Record<string, string>>({});
|
|
60
|
+
const [announcement, setAnnouncement] = useState('');
|
|
61
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const pendingFocus = useRef<{ key: string; target: 'up' | 'down' | 'text' } | null>(null);
|
|
63
|
+
|
|
64
|
+
const commit = (next: QuickLabelRow[]) => {
|
|
65
|
+
setRows(next);
|
|
66
|
+
saveQuickLabels(next.map((row) => row.label));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const updateLabel = (key: string, patch: Partial<QuickLabel>) => {
|
|
70
|
+
commit(rows.map((row) => (row.key === key ? { ...row, label: { ...row.label, ...patch } } : row)));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const setEmojiDraft = (key: string, value: string | null) => {
|
|
74
|
+
setEmojiDrafts((drafts) => {
|
|
75
|
+
if (value === null) {
|
|
76
|
+
if (!(key in drafts)) return drafts;
|
|
77
|
+
const { [key]: _dropped, ...rest } = drafts;
|
|
78
|
+
return rest;
|
|
79
|
+
}
|
|
80
|
+
return { ...drafts, [key]: value };
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const move = (index: number, delta: -1 | 1) => {
|
|
85
|
+
const to = index + delta;
|
|
86
|
+
if (to < 0 || to >= rows.length) return;
|
|
87
|
+
const row = rows[index];
|
|
88
|
+
commit(moveQuickLabel(rows, index, to));
|
|
89
|
+
pendingFocus.current = { key: row.key, target: delta < 0 ? 'up' : 'down' };
|
|
90
|
+
const digit = quickLabelShortcutDigit(to);
|
|
91
|
+
setAnnouncement(
|
|
92
|
+
`Moved ${labelName(row.label)} to position ${to + 1} of ${rows.length}, ${digit ? shortcutWords(digit) : 'no shortcut key'}.`,
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Moving a row re-orders DOM nodes, which can drop focus from the button
|
|
97
|
+
// that was pressed; put it back on the same row's button so repeated
|
|
98
|
+
// presses keep walking the same label. At either end the pressed button is
|
|
99
|
+
// disabled, so focus goes to the other direction instead.
|
|
100
|
+
useLayoutEffect(() => {
|
|
101
|
+
const pending = pendingFocus.current;
|
|
102
|
+
if (!pending) return;
|
|
103
|
+
pendingFocus.current = null;
|
|
104
|
+
const row = listRef.current?.querySelector<HTMLElement>(`[data-quick-label-row="${pending.key}"]`);
|
|
105
|
+
if (!row) return;
|
|
106
|
+
if (pending.target === 'text') {
|
|
107
|
+
const input = row.querySelector<HTMLInputElement>('[data-quick-label-text]');
|
|
108
|
+
input?.focus();
|
|
109
|
+
input?.select();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const other = pending.target === 'up' ? 'down' : 'up';
|
|
113
|
+
const wanted = row.querySelector<HTMLButtonElement>(`[data-quick-label-move="${pending.target}"]`);
|
|
114
|
+
const fallback = row.querySelector<HTMLButtonElement>(`[data-quick-label-move="${other}"]`);
|
|
115
|
+
(wanted && !wanted.disabled ? wanted : fallback)?.focus();
|
|
116
|
+
}, [rows]);
|
|
117
|
+
|
|
118
|
+
const saveTip = (key: string) => {
|
|
119
|
+
updateLabel(key, { tip: editingTipValue || undefined });
|
|
120
|
+
setEditingTipKey(null);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<div className="flex items-center justify-between">
|
|
126
|
+
<div>
|
|
127
|
+
<div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
|
|
128
|
+
<div className="text-xs text-muted-foreground">
|
|
129
|
+
Preset annotations for one-click feedback
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => {
|
|
134
|
+
resetQuickLabels();
|
|
135
|
+
setRows(toRows(DEFAULT_QUICK_LABELS));
|
|
136
|
+
setEditingTipKey(null);
|
|
137
|
+
setEmojiDrafts({});
|
|
138
|
+
}}
|
|
139
|
+
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
140
|
+
>
|
|
141
|
+
Reset to defaults
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<style>{`
|
|
146
|
+
@keyframes tip-slide-open {
|
|
147
|
+
from { opacity: 0; transform: translateY(-4px); }
|
|
148
|
+
to { opacity: 1; transform: translateY(0); }
|
|
149
|
+
}
|
|
150
|
+
`}</style>
|
|
151
|
+
<div ref={listRef} className="space-y-1.5">
|
|
152
|
+
{rows.map((row, index) => {
|
|
153
|
+
const { key, label } = row;
|
|
154
|
+
const colors = getLabelColors(label.color);
|
|
155
|
+
const hasTip = !!label.tip;
|
|
156
|
+
const isEditingTip = editingTipKey === key;
|
|
157
|
+
const digit = quickLabelShortcutDigit(index);
|
|
158
|
+
const name = labelName(label);
|
|
159
|
+
const emojiDraft = emojiDrafts[key];
|
|
160
|
+
const emojiInvalid = emojiDraft !== undefined;
|
|
161
|
+
const emojiErrorId = `${key}-emoji-error`;
|
|
162
|
+
return (
|
|
163
|
+
<div
|
|
164
|
+
key={key}
|
|
165
|
+
data-quick-label-row={key}
|
|
166
|
+
className="rounded-lg overflow-hidden"
|
|
167
|
+
style={{ backgroundColor: colors.bg }}
|
|
168
|
+
>
|
|
169
|
+
{/* Main row. On a narrow dialog the controls after the text
|
|
170
|
+
field wrap onto a second line instead of being clipped by
|
|
171
|
+
the row's overflow-hidden. */}
|
|
172
|
+
<div className="flex flex-wrap items-center gap-2 p-2">
|
|
173
|
+
<input
|
|
174
|
+
type="text"
|
|
175
|
+
data-quick-label-emoji
|
|
176
|
+
value={emojiDraft ?? label.emoji}
|
|
177
|
+
aria-label={`Emoji for ${name}`}
|
|
178
|
+
aria-invalid={emojiInvalid || undefined}
|
|
179
|
+
aria-describedby={emojiInvalid ? emojiErrorId : undefined}
|
|
180
|
+
title="Change emoji"
|
|
181
|
+
autoComplete="off"
|
|
182
|
+
spellCheck={false}
|
|
183
|
+
onFocus={(e) => e.target.select()}
|
|
184
|
+
onChange={(e) => {
|
|
185
|
+
const picked = emojiFromFieldInput(e.target.value, label.emoji);
|
|
186
|
+
if (!picked) {
|
|
187
|
+
setEmojiDraft(key, e.target.value);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
setEmojiDraft(key, null);
|
|
191
|
+
if (picked !== label.emoji) updateLabel(key, { emoji: picked });
|
|
192
|
+
}}
|
|
193
|
+
onBlur={() => setEmojiDraft(key, null)}
|
|
194
|
+
onKeyDown={(e) => {
|
|
195
|
+
if (e.key === 'Escape' && emojiInvalid) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
e.stopPropagation();
|
|
198
|
+
setEmojiDraft(key, null);
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
className="w-8 py-1 bg-background/80 rounded text-sm text-center leading-none focus:outline-none focus:ring-1 focus:ring-primary/50 flex-shrink-0"
|
|
202
|
+
style={emojiInvalid ? { boxShadow: '0 0 0 1px var(--destructive)' } : undefined}
|
|
203
|
+
/>
|
|
204
|
+
<input
|
|
205
|
+
type="text"
|
|
206
|
+
data-quick-label-text
|
|
207
|
+
value={label.text}
|
|
208
|
+
aria-label={`Label text, position ${index + 1}`}
|
|
209
|
+
onChange={(e) => {
|
|
210
|
+
updateLabel(key, {
|
|
211
|
+
text: e.target.value,
|
|
212
|
+
id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
|
213
|
+
});
|
|
214
|
+
}}
|
|
215
|
+
className="flex-1 min-w-0 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
216
|
+
// The basis is what makes the controls wrap: a row that
|
|
217
|
+
// cannot give the text this much room moves them below.
|
|
218
|
+
style={{ flexBasis: '8rem' }}
|
|
219
|
+
/>
|
|
220
|
+
<div className="flex items-center gap-2 ml-auto">
|
|
221
|
+
{/* Tip indicator button */}
|
|
222
|
+
<button
|
|
223
|
+
data-quick-label-tip-toggle
|
|
224
|
+
onClick={() => {
|
|
225
|
+
if (isEditingTip) {
|
|
226
|
+
setEditingTipKey(null);
|
|
227
|
+
} else {
|
|
228
|
+
setEditingTipKey(key);
|
|
229
|
+
setEditingTipValue(label.tip || '');
|
|
230
|
+
}
|
|
231
|
+
}}
|
|
232
|
+
className={`relative p-1 rounded transition-all flex-shrink-0 ${
|
|
233
|
+
hasTip
|
|
234
|
+
? 'bg-foreground/10 text-foreground/70 hover:text-foreground border border-foreground/15'
|
|
235
|
+
: 'text-muted-foreground/30 hover:text-muted-foreground/60 border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40'
|
|
236
|
+
}`}
|
|
237
|
+
title={hasTip ? `Tip: ${label.tip}` : 'Add AI instruction tip'}
|
|
238
|
+
>
|
|
239
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
240
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
241
|
+
</svg>
|
|
242
|
+
{hasTip && (
|
|
243
|
+
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-foreground/50" />
|
|
244
|
+
)}
|
|
245
|
+
</button>
|
|
246
|
+
<select
|
|
247
|
+
value={label.color}
|
|
248
|
+
aria-label={`Colour for ${name}`}
|
|
249
|
+
onChange={(e) => updateLabel(key, { color: e.target.value })}
|
|
250
|
+
className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
251
|
+
>
|
|
252
|
+
{Object.keys(LABEL_COLOR_MAP).map(c => (
|
|
253
|
+
<option key={c} value={c}>{c}</option>
|
|
254
|
+
))}
|
|
255
|
+
</select>
|
|
256
|
+
<span
|
|
257
|
+
data-quick-label-shortcut
|
|
258
|
+
// Hidden below sm: touch devices that narrow rarely have an Alt key.
|
|
259
|
+
className="hidden sm:block text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0"
|
|
260
|
+
>
|
|
261
|
+
{digit ? shortcutHint(digit) : ''}
|
|
262
|
+
</span>
|
|
263
|
+
{/* Reorder: position decides which Alt/⌥ digit applies the label. */}
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
data-quick-label-move="up"
|
|
267
|
+
disabled={index === 0}
|
|
268
|
+
onClick={() => move(index, -1)}
|
|
269
|
+
aria-label={`Move ${name} up`}
|
|
270
|
+
title="Move up"
|
|
271
|
+
className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-30 disabled:pointer-events-none"
|
|
272
|
+
>
|
|
273
|
+
<ChevronIcon up />
|
|
274
|
+
</button>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
data-quick-label-move="down"
|
|
278
|
+
disabled={index === rows.length - 1}
|
|
279
|
+
onClick={() => move(index, 1)}
|
|
280
|
+
aria-label={`Move ${name} down`}
|
|
281
|
+
title="Move down"
|
|
282
|
+
className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-30 disabled:pointer-events-none"
|
|
283
|
+
>
|
|
284
|
+
<ChevronIcon up={false} />
|
|
285
|
+
</button>
|
|
286
|
+
<button
|
|
287
|
+
onClick={() => {
|
|
288
|
+
commit(rows.filter((r) => r.key !== key));
|
|
289
|
+
setEmojiDraft(key, null);
|
|
290
|
+
if (editingTipKey === key) setEditingTipKey(null);
|
|
291
|
+
}}
|
|
292
|
+
aria-label={`Remove ${name}`}
|
|
293
|
+
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
|
|
294
|
+
title="Remove label"
|
|
295
|
+
>
|
|
296
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
297
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
298
|
+
</svg>
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
{emojiInvalid && (
|
|
303
|
+
<div id={emojiErrorId} className="px-2 pb-2 text-[10px] text-destructive">
|
|
304
|
+
{EMOJI_HINT}
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
{/* Tip editor — slides open below the row */}
|
|
308
|
+
{isEditingTip && (
|
|
309
|
+
<div
|
|
310
|
+
className="flex items-center gap-1.5 px-2 pb-2 pt-0"
|
|
311
|
+
style={{ animation: 'tip-slide-open 0.15s ease-out' }}
|
|
312
|
+
>
|
|
313
|
+
<svg className="w-3 h-3 text-muted-foreground/40 flex-shrink-0 ml-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
314
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
315
|
+
</svg>
|
|
316
|
+
<input
|
|
317
|
+
type="text"
|
|
318
|
+
data-quick-label-tip
|
|
319
|
+
value={editingTipValue}
|
|
320
|
+
aria-label={`AI instruction tip for ${name}`}
|
|
321
|
+
onChange={(e) => setEditingTipValue(e.target.value)}
|
|
322
|
+
onKeyDown={(e) => {
|
|
323
|
+
if (e.key === 'Enter') saveTip(key);
|
|
324
|
+
if (e.key === 'Escape') {
|
|
325
|
+
e.preventDefault();
|
|
326
|
+
e.stopPropagation();
|
|
327
|
+
setEditingTipKey(null);
|
|
328
|
+
}
|
|
329
|
+
}}
|
|
330
|
+
placeholder="AI instruction tip..."
|
|
331
|
+
className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
332
|
+
autoFocus
|
|
333
|
+
onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }}
|
|
334
|
+
/>
|
|
335
|
+
<button
|
|
336
|
+
onClick={() => saveTip(key)}
|
|
337
|
+
className="p-1 rounded text-muted-foreground/50 hover:text-green-500 hover:bg-green-500/10 transition-colors flex-shrink-0"
|
|
338
|
+
title="Save tip"
|
|
339
|
+
>
|
|
340
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
341
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
342
|
+
</svg>
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
</div>
|
|
350
|
+
<div role="status" aria-live="polite" className="sr-only">{announcement}</div>
|
|
351
|
+
|
|
352
|
+
{rows.length < MAX_QUICK_LABELS && (
|
|
353
|
+
<button
|
|
354
|
+
onClick={() => {
|
|
355
|
+
const row: QuickLabelRow = {
|
|
356
|
+
key: nextRowKey(),
|
|
357
|
+
label: {
|
|
358
|
+
id: `custom-${Date.now()}`,
|
|
359
|
+
emoji: '📌',
|
|
360
|
+
text: 'New label',
|
|
361
|
+
color: 'blue',
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
commit([...rows, row]);
|
|
365
|
+
pendingFocus.current = { key: row.key, target: 'text' };
|
|
366
|
+
}}
|
|
367
|
+
className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
|
|
368
|
+
>
|
|
369
|
+
+ Add label
|
|
370
|
+
</button>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
<div className="text-[10px] text-muted-foreground/70">
|
|
374
|
+
Use {shortcutHint('1')} through {shortcutHint('0')} when the annotation toolbar is visible to apply a label instantly. The order of the list decides the key; use the arrows to move a label, and click its emoji to change it.
|
|
375
|
+
</div>
|
|
376
|
+
</>
|
|
377
|
+
);
|
|
378
|
+
};
|