@plannotator/ui 0.51.0 → 0.52.0
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 +151 -0
- package/README.md +8 -0
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/BlockRenderer.tsx +10 -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 +26 -1
- package/components/blocks/QuestionBlock.tsx +75 -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
package/components/Viewer.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { AnnotationType, type Block, type Annotation, type EditorMode, type Inpu
|
|
|
6
6
|
import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
|
|
7
7
|
import { paintCodeBlockMark } from '../utils/codeBlockMark';
|
|
8
8
|
import { useFenceTheme } from '../hooks/useFenceTheme';
|
|
9
|
-
import { computeListIndices, groupBlocks, type Frontmatter, type FrontmatterValue } from '../utils/parser';
|
|
9
|
+
import { computeListIndices, groupBlocks, selectionSourceLines, type Frontmatter, type FrontmatterValue } from '../utils/parser';
|
|
10
10
|
import { buildHeadingSlugMap } from '../utils/slugify';
|
|
11
11
|
import { copyTextToClipboard } from '../utils/clipboard';
|
|
12
12
|
import { BlockRenderer } from './BlockRenderer';
|
|
@@ -218,6 +218,21 @@ export interface ViewerProps {
|
|
|
218
218
|
* decision"), given the indexed question and its saved answer. Rendered in
|
|
219
219
|
* read-only cards too; return null for none. */
|
|
220
220
|
renderQuestionFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
|
|
221
|
+
/** Whether answering a `Decision: when answered` question records a
|
|
222
|
+
* decision, per question: return false to draw its "Records a decision"
|
|
223
|
+
* tag dimmed with a dotted outline and hide the "Answering this records a
|
|
224
|
+
* decision" row; true or undefined keeps today's card. Not consulted for a
|
|
225
|
+
* question whose decision is already recorded. */
|
|
226
|
+
questionDecisionRecording?: (question: IndexedQuestion) => boolean | undefined;
|
|
227
|
+
/** Makes a `Decision: when answered` question's "Records a decision" tag a
|
|
228
|
+
* toggle button (`aria-pressed`); a click calls this with the question's
|
|
229
|
+
* key and the next state, which the host stores and returns from
|
|
230
|
+
* `questionDecisionRecording`. Ignored when `readOnly`. */
|
|
231
|
+
onToggleQuestionDecisionRecording?: (key: string, next: boolean) => void;
|
|
232
|
+
/** `'none'` hides each question card's own status tag (Open / Answered /
|
|
233
|
+
* Settled / Skipped) so the host can draw its own; the decision tags stay.
|
|
234
|
+
* Default `'card'`. */
|
|
235
|
+
questionStatusTag?: 'card' | 'none';
|
|
221
236
|
onAskAI?: CommentAskAIHandler;
|
|
222
237
|
/** Whether comment popovers offer image attachments. Hosts without an
|
|
223
238
|
* uploadTransport pass false so the attach affordance never dead-ends.
|
|
@@ -590,6 +605,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
590
605
|
onSaveQuestionAnswer,
|
|
591
606
|
saveQuestionAnswerLabel,
|
|
592
607
|
renderQuestionFooter,
|
|
608
|
+
questionDecisionRecording,
|
|
609
|
+
onToggleQuestionDecisionRecording,
|
|
610
|
+
questionStatusTag,
|
|
593
611
|
onAskAI,
|
|
594
612
|
allowImages = true,
|
|
595
613
|
readOnly = false,
|
|
@@ -1551,6 +1569,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1551
1569
|
onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
|
|
1552
1570
|
saveQuestionAnswerLabel={saveQuestionAnswerLabel}
|
|
1553
1571
|
renderQuestionFooter={renderQuestionFooter}
|
|
1572
|
+
questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
|
|
1573
|
+
onToggleQuestionDecisionRecording={readOnly ? undefined : onToggleQuestionDecisionRecording}
|
|
1574
|
+
questionStatusTag={questionStatusTag}
|
|
1554
1575
|
/>
|
|
1555
1576
|
);
|
|
1556
1577
|
})()
|
|
@@ -1715,6 +1736,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1715
1736
|
label: 'Selected text',
|
|
1716
1737
|
text: hookCommentPopover.selectedText ?? hookCommentPopover.contextText,
|
|
1717
1738
|
sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
|
|
1739
|
+
...selectionSourceLines(blocks, hookCommentPopover.blockIds ?? []),
|
|
1718
1740
|
}}
|
|
1719
1741
|
/>
|
|
1720
1742
|
)}
|
|
@@ -1740,6 +1762,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1740
1762
|
label: viewerCommentPopover.isGlobal ? 'Document' : 'Code block',
|
|
1741
1763
|
text: viewerCommentPopover.selectedText,
|
|
1742
1764
|
sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
|
|
1765
|
+
...(!viewerCommentPopover.isGlobal && viewerCommentPopover.codeBlock
|
|
1766
|
+
? selectionSourceLines(blocks, [viewerCommentPopover.codeBlock.block.id])
|
|
1767
|
+
: null),
|
|
1743
1768
|
}}
|
|
1744
1769
|
/>
|
|
1745
1770
|
)}
|
|
@@ -36,7 +36,10 @@ import { QuestionContext } from './QuestionContext';
|
|
|
36
36
|
*
|
|
37
37
|
* A host can add its own actions at the right of the footer
|
|
38
38
|
* (`renderFooter`), and a question carrying `Decision: when answered` or a
|
|
39
|
-
* `Decision: [statement](url)` link shows that beside the prompt.
|
|
39
|
+
* `Decision: [statement](url)` link shows that beside the prompt. A host can
|
|
40
|
+
* also let the reviewer switch a `Decision: when answered` question's
|
|
41
|
+
* recording off and on (`decisionRecording` + `onToggleDecisionRecording`),
|
|
42
|
+
* and hide the card's own status tag to draw its own (`statusTag: 'none'`).
|
|
40
43
|
*/
|
|
41
44
|
|
|
42
45
|
/** Pointer travel (px) above which a press on a choice row is a drag. */
|
|
@@ -50,7 +53,7 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now(
|
|
|
50
53
|
|
|
51
54
|
const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
|
|
52
55
|
|
|
53
|
-
const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string }> = ({ tone, children, className }) => (
|
|
56
|
+
const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
|
|
54
57
|
<span
|
|
55
58
|
className={cx(
|
|
56
59
|
'annotation-exclude select-none inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em]',
|
|
@@ -60,6 +63,7 @@ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.Reac
|
|
|
60
63
|
tone === 'open' && 'border border-dashed border-border text-muted-foreground',
|
|
61
64
|
className,
|
|
62
65
|
)}
|
|
66
|
+
style={style}
|
|
63
67
|
data-pinpoint-ignore=""
|
|
64
68
|
>
|
|
65
69
|
{children}
|
|
@@ -78,6 +82,19 @@ const DiamondGlyph = () => (
|
|
|
78
82
|
</svg>
|
|
79
83
|
);
|
|
80
84
|
|
|
85
|
+
/** Draws a decision tag in its "off" state: the same words, dimmed, with a
|
|
86
|
+
* dotted outline instead of the tinted fill. Dimmed means the theme's
|
|
87
|
+
* muted-foreground, not opacity: 60% opacity on primary text dropped the
|
|
88
|
+
* default light theme under 3:1, while muted-foreground is the token every
|
|
89
|
+
* theme already tunes for secondary text. Inline so it adds no utility
|
|
90
|
+
* classes to the shared stylesheet and wins over the tag's `text-primary`. */
|
|
91
|
+
const DECISION_OFF_STYLE: React.CSSProperties = {
|
|
92
|
+
background: 'transparent',
|
|
93
|
+
color: 'var(--muted-foreground)',
|
|
94
|
+
outline: '1px dotted currentColor',
|
|
95
|
+
outlineOffset: '-1px',
|
|
96
|
+
};
|
|
97
|
+
|
|
81
98
|
/** Two answers say the same thing (identity and line ignored). */
|
|
82
99
|
const sameAnswer = (a: QuestionAnswer | null | undefined, b: QuestionAnswer | null | undefined): boolean => {
|
|
83
100
|
const norm = (x: QuestionAnswer | null | undefined) =>
|
|
@@ -110,6 +127,21 @@ export interface QuestionBlockProps {
|
|
|
110
127
|
* saved answer (never the unsaved draft). Rendered in read-only cards too;
|
|
111
128
|
* return null for nothing. */
|
|
112
129
|
renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
|
|
130
|
+
/** Whether answering a `Decision: when answered` question records a
|
|
131
|
+
* decision (default true). False draws the "Records a decision" tag dimmed
|
|
132
|
+
* with a dotted outline and hides the "Answering this records a decision"
|
|
133
|
+
* row. Has no effect on a question whose decision is already recorded
|
|
134
|
+
* (`Decision: [statement](url)`) or that carries no decision line. */
|
|
135
|
+
decisionRecording?: boolean;
|
|
136
|
+
/** Makes the "Records a decision" tag a toggle button (`aria-pressed`):
|
|
137
|
+
* a click calls this with the question's key and the next state. The host
|
|
138
|
+
* stores it and passes it back as `decisionRecording`. Not offered in a
|
|
139
|
+
* read-only card (no answer handler), which draws the tag as it stands. */
|
|
140
|
+
onToggleDecisionRecording?: (key: string, next: boolean) => void;
|
|
141
|
+
/** `'none'` hides the card's own status tag (Open / Answered / Settled /
|
|
142
|
+
* Skipped) so the host can draw its own; the decision tags stay. Default
|
|
143
|
+
* `'card'`. */
|
|
144
|
+
statusTag?: 'card' | 'none';
|
|
113
145
|
onOpenLinkedDoc?: (path: string) => void;
|
|
114
146
|
onOpenCodeFile?: (path: string) => void;
|
|
115
147
|
imageBaseDir?: string;
|
|
@@ -128,6 +160,9 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
128
160
|
onSaveAnswer,
|
|
129
161
|
saveLabel,
|
|
130
162
|
renderFooter,
|
|
163
|
+
decisionRecording,
|
|
164
|
+
onToggleDecisionRecording,
|
|
165
|
+
statusTag = 'card',
|
|
131
166
|
onOpenLinkedDoc,
|
|
132
167
|
onOpenCodeFile,
|
|
133
168
|
imageBaseDir,
|
|
@@ -320,6 +355,17 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
320
355
|
const other = answer?.other ?? '';
|
|
321
356
|
const otherOn = other.trim() !== '';
|
|
322
357
|
|
|
358
|
+
// A `Decision: when answered` question the host may switch off. A recorded
|
|
359
|
+
// decision (`question.decision`) is never affected.
|
|
360
|
+
const decisionCapable = !question.decision && !!question.decisionOnAnswer;
|
|
361
|
+
const recordingOn = decisionRecording ?? true;
|
|
362
|
+
const decisionToggleable = decisionCapable && !readOnly && !!onToggleDecisionRecording;
|
|
363
|
+
// Present only when the host speaks to recording, so a card without these
|
|
364
|
+
// props carries exactly the attributes it always did.
|
|
365
|
+
const recordingAttr = decisionCapable && (decisionRecording !== undefined || !!onToggleDecisionRecording)
|
|
366
|
+
? (recordingOn ? 'on' : 'off')
|
|
367
|
+
: undefined;
|
|
368
|
+
|
|
323
369
|
const describedBy = [question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
|
|
324
370
|
.filter(Boolean)
|
|
325
371
|
.join(' ') || undefined;
|
|
@@ -344,23 +390,44 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
344
390
|
aria-describedby={describedBy}
|
|
345
391
|
aria-disabled={readOnly || undefined}
|
|
346
392
|
data-question-decision={question.decision ? 'recorded' : question.decisionOnAnswer ? 'on-answer' : undefined}
|
|
393
|
+
data-question-decision-recording={recordingAttr}
|
|
347
394
|
>
|
|
348
395
|
<div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
|
|
349
396
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
|
|
350
397
|
<span className="ml-auto">
|
|
351
398
|
{question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
|
|
352
|
-
{
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
399
|
+
{decisionCapable && (decisionToggleable ? (
|
|
400
|
+
// Focus shows as a ring, not an outline: the dotted outline is
|
|
401
|
+
// what draws the off state.
|
|
402
|
+
<><button
|
|
403
|
+
type="button"
|
|
404
|
+
aria-pressed={recordingOn}
|
|
405
|
+
onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
|
|
406
|
+
className="annotation-exclude select-none inline-flex cursor-pointer items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em] bg-primary/15 text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
407
|
+
style={recordingOn ? undefined : DECISION_OFF_STYLE}
|
|
408
|
+
data-pinpoint-ignore=""
|
|
409
|
+
data-question-decision-toggle=""
|
|
410
|
+
><DiamondGlyph />Records a decision</button>{' '}</>
|
|
411
|
+
) : recordingOn ? (
|
|
412
|
+
<><Tag tone="rec"><DiamondGlyph />Records a decision</Tag>{' '}</>
|
|
413
|
+
) : (
|
|
414
|
+
<><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
|
|
415
|
+
))}
|
|
416
|
+
{statusTag !== 'none' && (
|
|
417
|
+
<>
|
|
418
|
+
{status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
|
|
419
|
+
{status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
|
|
420
|
+
{status === 'skipped' && <Tag tone="skip">Skipped</Tag>}
|
|
421
|
+
{status === 'open' && <Tag tone="open">Open</Tag>}
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
357
424
|
</span>
|
|
358
425
|
</div>
|
|
359
426
|
|
|
360
427
|
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
|
|
361
428
|
{inline(question.prompt)}
|
|
362
429
|
</p>
|
|
363
|
-
{(question.decision || question.decisionOnAnswer) && (
|
|
430
|
+
{(question.decision || (question.decisionOnAnswer && recordingOn)) && (
|
|
364
431
|
<div
|
|
365
432
|
className="annotation-exclude mt-2 mb-0.5 flex items-center gap-2 rounded-lg bg-primary/8 px-[9px] py-1.5 text-[12.5px] leading-[18px] text-foreground [&>svg]:text-primary"
|
|
366
433
|
data-pinpoint-ignore=""
|
|
@@ -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);
|