@plannotator/ui 0.50.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 +295 -11
- package/README.md +8 -0
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/BlockRenderer.tsx +10 -1
- package/components/CommentPopover.tsx +36 -18
- package/components/CompletionOverlay.tsx +12 -5
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +76 -192
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +30 -1
- package/components/ai/AIProviderBar.tsx +18 -1
- package/components/ai/DocumentAIChatPanel.tsx +4 -9
- package/components/ai/SessionAskNotice.tsx +33 -21
- package/components/blocks/QuestionBlock.tsx +99 -27
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +230 -14
- package/components/html-viewer/bridge-script.ts +230 -14
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +83 -6
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +3 -3
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +129 -5
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +2 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +19 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiProvider.ts +13 -40
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/parser.ts +382 -114
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +82 -2
- package/utils/serverSession.ts +85 -0
|
@@ -980,6 +980,10 @@
|
|
|
980
980
|
|
|
981
981
|
// Floating label naming the element under the cursor (like the markdown overlay).
|
|
982
982
|
var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
|
|
983
|
+
// Media kinds name themselves only when the page gives no accessible name:
|
|
984
|
+
// unlike the semantic tags above, an aria-label on a <video> is a better
|
|
985
|
+
// label than "Video", and it keeps winning exactly as it did before.
|
|
986
|
+
var MEDIA_PINPOINT_LABELS = { VIDEO:'Video', AUDIO:'Audio', IFRAME:'Frame', CANVAS:'Canvas' };
|
|
983
987
|
|
|
984
988
|
var MAX_HOVER_LABEL = 40;
|
|
985
989
|
function truncateLabel(text) {
|
|
@@ -1015,6 +1019,8 @@
|
|
|
1015
1019
|
if (known) return known;
|
|
1016
1020
|
var aria = el.getAttribute && el.getAttribute('aria-label');
|
|
1017
1021
|
if (aria && aria.trim()) return truncateLabel(aria.trim());
|
|
1022
|
+
var media = MEDIA_PINPOINT_LABELS[el.tagName];
|
|
1023
|
+
if (media) return media;
|
|
1018
1024
|
var role = el.getAttribute && el.getAttribute('role');
|
|
1019
1025
|
if (role && role.trim()) return truncateLabel(role.trim());
|
|
1020
1026
|
var tokens = meaningfulClassTokens(el);
|
|
@@ -2451,9 +2457,88 @@
|
|
|
2451
2457
|
|
|
2452
2458
|
function anchorTextSnapshot(el) {
|
|
2453
2459
|
var text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
2460
|
+
if (!text) return anchorSourceSnapshot(el);
|
|
2454
2461
|
return text.length > 180 ? text.slice(0, 180) : text;
|
|
2455
2462
|
}
|
|
2456
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
|
+
|
|
2457
2542
|
function uniquelySelects(selector, el) {
|
|
2458
2543
|
try {
|
|
2459
2544
|
var matches = document.querySelectorAll(selector);
|
|
@@ -2552,12 +2637,13 @@
|
|
|
2552
2637
|
if (!selector) return null;
|
|
2553
2638
|
var snapshot = anchorTextSnapshot(el);
|
|
2554
2639
|
// Text-less elements anchor ONLY through a stable-identity rung (#id or
|
|
2555
|
-
// an author-controlled data-* identity attribute)
|
|
2556
|
-
//
|
|
2557
|
-
//
|
|
2558
|
-
//
|
|
2559
|
-
//
|
|
2560
|
-
//
|
|
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).
|
|
2561
2647
|
if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
|
|
2562
2648
|
return {
|
|
2563
2649
|
selector: selector,
|
|
@@ -2590,6 +2676,26 @@
|
|
|
2590
2676
|
|
|
2591
2677
|
function resolveAnchorElement(anchor) {
|
|
2592
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) {
|
|
2593
2699
|
var matches;
|
|
2594
2700
|
try {
|
|
2595
2701
|
matches = document.querySelectorAll(anchor.selector);
|
|
@@ -2680,6 +2786,25 @@
|
|
|
2680
2786
|
function ctxScrubUrl(value) {
|
|
2681
2787
|
var v = String(value).trim();
|
|
2682
2788
|
if (/^javascript:/i.test(v)) return null;
|
|
2789
|
+
// Srcdoc sessions serve a document's relative assets through the
|
|
2790
|
+
// session's own asset route (/api/html-assets/<token>/<path>). The token
|
|
2791
|
+
// means nothing outside this session; the path after it is the author's
|
|
2792
|
+
// own, relative to the document, which is what an agent can find.
|
|
2793
|
+
if (!LIVE) {
|
|
2794
|
+
var asset = v.match(/^\/api\/html-assets\/[^\/?#]+\/([^?#]*)/);
|
|
2795
|
+
if (asset && asset[1]) {
|
|
2796
|
+
// The route percent-encodes each segment; show the name the author
|
|
2797
|
+
// wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
|
|
2798
|
+
// that decodes to ? or # (which the query scrub below would cut), keeps
|
|
2799
|
+
// the raw text.
|
|
2800
|
+
var authored = asset[1];
|
|
2801
|
+
try {
|
|
2802
|
+
var decoded = decodeURIComponent(authored);
|
|
2803
|
+
if (!/[?#]/.test(decoded)) authored = decoded;
|
|
2804
|
+
} catch (ex) {}
|
|
2805
|
+
v = authored + v.slice(asset[0].length);
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2683
2808
|
if (/^data:/i.test(v)) {
|
|
2684
2809
|
var comma = v.indexOf(',');
|
|
2685
2810
|
return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
|
|
@@ -2989,7 +3114,8 @@
|
|
|
2989
3114
|
}
|
|
2990
3115
|
|
|
2991
3116
|
// Shed order when over budget: the fields an agent can live without first.
|
|
2992
|
-
|
|
3117
|
+
// Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
|
|
3118
|
+
var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
|
|
2993
3119
|
|
|
2994
3120
|
function buildElementContext(el, maxBytes) {
|
|
2995
3121
|
if (!el || el.nodeType !== 1) return null;
|
|
@@ -3016,6 +3142,12 @@
|
|
|
3016
3142
|
if (heading) context.heading = heading;
|
|
3017
3143
|
var component = ctxComponent(el);
|
|
3018
3144
|
if (component) context.component = component;
|
|
3145
|
+
// The media file this element shows, resolved exactly as the composer
|
|
3146
|
+
// quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
|
|
3147
|
+
// poster; see descRawSource), so the exported heading can name the file
|
|
3148
|
+
// even when the element has no usable src attribute.
|
|
3149
|
+
var sourceName = descSourceName(el);
|
|
3150
|
+
if (sourceName) context.sourceName = sourceName;
|
|
3019
3151
|
if (LIVE) {
|
|
3020
3152
|
context.page = { url: currentPageUrl() };
|
|
3021
3153
|
var title = ctxCollapse(document.title, 200);
|
|
@@ -3128,11 +3260,95 @@
|
|
|
3128
3260
|
}
|
|
3129
3261
|
}
|
|
3130
3262
|
|
|
3131
|
-
//
|
|
3132
|
-
//
|
|
3263
|
+
// --- Text-less element descriptions ---
|
|
3264
|
+
// A pinpoint on an element with no text quotes a description instead
|
|
3265
|
+
// ("[element: …]"). The hover label alone ("Image") cannot tell one image
|
|
3266
|
+
// from the next, so the description adds what identifies THIS element: its
|
|
3267
|
+
// accessible name (aria-label, alt, title, ...) and, for media, the source
|
|
3268
|
+
// file name. The source goes through the same scrub the element context
|
|
3269
|
+
// uses (no query, no fragment, data: reduced to its media type). Square
|
|
3270
|
+
// brackets become parentheses and double quotes single ones, so the whole
|
|
3271
|
+
// description stays one "[element: …]" token the export recognizes.
|
|
3272
|
+
var DESC_MAX_NAME = 80;
|
|
3273
|
+
var DESC_MAX_FILE = 60;
|
|
3274
|
+
var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
|
|
3275
|
+
|
|
3276
|
+
function descClean(value, max) {
|
|
3277
|
+
return ctxCollapse(String(value).replace(/\[/g, '(').replace(/\]/g, ')').replace(/"/g, "'"), max);
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
// Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
|
|
3281
|
+
// first candidate's first word. A data: URL carries its own comma, so it is
|
|
3282
|
+
// taken up to whitespace instead.
|
|
3283
|
+
function descFirstSrcsetUrl(value) {
|
|
3284
|
+
var srcset = String(value || '').trim();
|
|
3285
|
+
if (!srcset) return '';
|
|
3286
|
+
return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\s+/)[0] || '';
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
function descRawSource(el) {
|
|
3290
|
+
var tag = el.tagName;
|
|
3291
|
+
if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
|
|
3292
|
+
var attr = DESC_SRC_ATTR[tag];
|
|
3293
|
+
var src = '';
|
|
3294
|
+
if (attr) src = (el.getAttribute(attr) || '').trim();
|
|
3295
|
+
else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
|
|
3296
|
+
else return '';
|
|
3297
|
+
// Lazy loaders put a data: placeholder in src and the real file in data-src.
|
|
3298
|
+
var lazy = (el.getAttribute('data-src') || '').trim();
|
|
3299
|
+
if (lazy && (!src || /^data:/i.test(src))) src = lazy;
|
|
3300
|
+
if (!src && tag === 'IMG') {
|
|
3301
|
+
src = descFirstSrcsetUrl(el.getAttribute('srcset'));
|
|
3302
|
+
// <picture><source srcset="…"><img></picture>: the img itself may carry
|
|
3303
|
+
// no source at all; the first <source> candidate names the file.
|
|
3304
|
+
var picture = el.parentElement;
|
|
3305
|
+
if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
|
|
3306
|
+
var pictureSource = picture.querySelector('source[srcset]');
|
|
3307
|
+
if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
|
|
3311
|
+
var child = el.querySelector && el.querySelector('source[src]');
|
|
3312
|
+
if (child) src = (child.getAttribute('src') || '').trim();
|
|
3313
|
+
if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
|
|
3314
|
+
}
|
|
3315
|
+
return src;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
// The file name a source points at: "team.jpg" for
|
|
3319
|
+
// https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
|
|
3320
|
+
function descSourceName(el) {
|
|
3321
|
+
var src = descRawSource(el);
|
|
3322
|
+
if (!src) return '';
|
|
3323
|
+
var scrubbed = ctxScrubUrl(src);
|
|
3324
|
+
if (!scrubbed) return '';
|
|
3325
|
+
if (/^data:/i.test(scrubbed)) {
|
|
3326
|
+
var media = scrubbed.slice(5).split(/[;,]/)[0];
|
|
3327
|
+
return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
|
|
3328
|
+
}
|
|
3329
|
+
if (/^blob:/i.test(scrubbed)) return 'blob';
|
|
3330
|
+
var path = scrubbed.replace(/\?…$/, '').replace(/\/+$/, '');
|
|
3331
|
+
var name = path.slice(path.lastIndexOf('/') + 1);
|
|
3332
|
+
try { name = decodeURIComponent(name); } catch (ex) {}
|
|
3333
|
+
if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
|
|
3334
|
+
return descClean(name, DESC_MAX_FILE);
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
function describeTextlessElement(el, label) {
|
|
3338
|
+
var out = descClean(label, DESC_MAX_NAME) || 'element';
|
|
3339
|
+
var name = ctxName(el);
|
|
3340
|
+
name = name ? descClean(name, DESC_MAX_NAME) : '';
|
|
3341
|
+
if (name && name !== out) out += ' "' + name + '"';
|
|
3342
|
+
var file = descSourceName(el);
|
|
3343
|
+
if (file && file !== name) out += ' (' + file + ')';
|
|
3344
|
+
return out;
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
// Element text for a pinpoint target (primary or additional): the
|
|
3348
|
+
// element's own text, or for a text-less element its description.
|
|
3133
3349
|
function elementTargetText(el, label) {
|
|
3134
3350
|
var text = capSelectionText((el.textContent || '').trim());
|
|
3135
|
-
if (!text) text = capSelectionText('[element: ' + label + ']');
|
|
3351
|
+
if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
|
|
3136
3352
|
return text;
|
|
3137
3353
|
}
|
|
3138
3354
|
|
|
@@ -3371,10 +3587,10 @@
|
|
|
3371
3587
|
if (!posted) clearPendingPin();
|
|
3372
3588
|
return posted;
|
|
3373
3589
|
}
|
|
3374
|
-
|
|
3375
|
-
//
|
|
3376
|
-
//
|
|
3377
|
-
|
|
3590
|
+
// Text-less elements (images, icon buttons, decorative chips, empty
|
|
3591
|
+
// containers) are still annotatable: describe the element instead of
|
|
3592
|
+
// quoting it, naming WHICH one it is where the page says so.
|
|
3593
|
+
var elText = elementTargetText(el, pinpointHoverLabel(el));
|
|
3378
3594
|
var r = el.getBoundingClientRect();
|
|
3379
3595
|
pendingSelection = { element: true };
|
|
3380
3596
|
pendingRange = null;
|
|
@@ -1216,6 +1216,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1216
1216
|
|
|
1217
1217
|
// Floating label naming the element under the cursor (like the markdown overlay).
|
|
1218
1218
|
var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
|
|
1219
|
+
// Media kinds name themselves only when the page gives no accessible name:
|
|
1220
|
+
// unlike the semantic tags above, an aria-label on a <video> is a better
|
|
1221
|
+
// label than "Video", and it keeps winning exactly as it did before.
|
|
1222
|
+
var MEDIA_PINPOINT_LABELS = { VIDEO:'Video', AUDIO:'Audio', IFRAME:'Frame', CANVAS:'Canvas' };
|
|
1219
1223
|
|
|
1220
1224
|
var MAX_HOVER_LABEL = 40;
|
|
1221
1225
|
function truncateLabel(text) {
|
|
@@ -1251,6 +1255,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1251
1255
|
if (known) return known;
|
|
1252
1256
|
var aria = el.getAttribute && el.getAttribute('aria-label');
|
|
1253
1257
|
if (aria && aria.trim()) return truncateLabel(aria.trim());
|
|
1258
|
+
var media = MEDIA_PINPOINT_LABELS[el.tagName];
|
|
1259
|
+
if (media) return media;
|
|
1254
1260
|
var role = el.getAttribute && el.getAttribute('role');
|
|
1255
1261
|
if (role && role.trim()) return truncateLabel(role.trim());
|
|
1256
1262
|
var tokens = meaningfulClassTokens(el);
|
|
@@ -2687,9 +2693,88 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2687
2693
|
|
|
2688
2694
|
function anchorTextSnapshot(el) {
|
|
2689
2695
|
var text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
2696
|
+
if (!text) return anchorSourceSnapshot(el);
|
|
2690
2697
|
return text.length > 180 ? text.slice(0, 180) : text;
|
|
2691
2698
|
}
|
|
2692
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
|
+
|
|
2693
2778
|
function uniquelySelects(selector, el) {
|
|
2694
2779
|
try {
|
|
2695
2780
|
var matches = document.querySelectorAll(selector);
|
|
@@ -2788,12 +2873,13 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2788
2873
|
if (!selector) return null;
|
|
2789
2874
|
var snapshot = anchorTextSnapshot(el);
|
|
2790
2875
|
// Text-less elements anchor ONLY through a stable-identity rung (#id or
|
|
2791
|
-
// an author-controlled data-* identity attribute)
|
|
2792
|
-
//
|
|
2793
|
-
//
|
|
2794
|
-
//
|
|
2795
|
-
//
|
|
2796
|
-
//
|
|
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).
|
|
2797
2883
|
if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
|
|
2798
2884
|
return {
|
|
2799
2885
|
selector: selector,
|
|
@@ -2826,6 +2912,26 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2826
2912
|
|
|
2827
2913
|
function resolveAnchorElement(anchor) {
|
|
2828
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) {
|
|
2829
2935
|
var matches;
|
|
2830
2936
|
try {
|
|
2831
2937
|
matches = document.querySelectorAll(anchor.selector);
|
|
@@ -2916,6 +3022,25 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2916
3022
|
function ctxScrubUrl(value) {
|
|
2917
3023
|
var v = String(value).trim();
|
|
2918
3024
|
if (/^javascript:/i.test(v)) return null;
|
|
3025
|
+
// Srcdoc sessions serve a document's relative assets through the
|
|
3026
|
+
// session's own asset route (/api/html-assets/<token>/<path>). The token
|
|
3027
|
+
// means nothing outside this session; the path after it is the author's
|
|
3028
|
+
// own, relative to the document, which is what an agent can find.
|
|
3029
|
+
if (!LIVE) {
|
|
3030
|
+
var asset = v.match(/^\\/api\\/html-assets\\/[^\\/?#]+\\/([^?#]*)/);
|
|
3031
|
+
if (asset && asset[1]) {
|
|
3032
|
+
// The route percent-encodes each segment; show the name the author
|
|
3033
|
+
// wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
|
|
3034
|
+
// that decodes to ? or # (which the query scrub below would cut), keeps
|
|
3035
|
+
// the raw text.
|
|
3036
|
+
var authored = asset[1];
|
|
3037
|
+
try {
|
|
3038
|
+
var decoded = decodeURIComponent(authored);
|
|
3039
|
+
if (!/[?#]/.test(decoded)) authored = decoded;
|
|
3040
|
+
} catch (ex) {}
|
|
3041
|
+
v = authored + v.slice(asset[0].length);
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
2919
3044
|
if (/^data:/i.test(v)) {
|
|
2920
3045
|
var comma = v.indexOf(',');
|
|
2921
3046
|
return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
|
|
@@ -3225,7 +3350,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3225
3350
|
}
|
|
3226
3351
|
|
|
3227
3352
|
// Shed order when over budget: the fields an agent can live without first.
|
|
3228
|
-
|
|
3353
|
+
// Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
|
|
3354
|
+
var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
|
|
3229
3355
|
|
|
3230
3356
|
function buildElementContext(el, maxBytes) {
|
|
3231
3357
|
if (!el || el.nodeType !== 1) return null;
|
|
@@ -3252,6 +3378,12 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3252
3378
|
if (heading) context.heading = heading;
|
|
3253
3379
|
var component = ctxComponent(el);
|
|
3254
3380
|
if (component) context.component = component;
|
|
3381
|
+
// The media file this element shows, resolved exactly as the composer
|
|
3382
|
+
// quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
|
|
3383
|
+
// poster; see descRawSource), so the exported heading can name the file
|
|
3384
|
+
// even when the element has no usable src attribute.
|
|
3385
|
+
var sourceName = descSourceName(el);
|
|
3386
|
+
if (sourceName) context.sourceName = sourceName;
|
|
3255
3387
|
if (LIVE) {
|
|
3256
3388
|
context.page = { url: currentPageUrl() };
|
|
3257
3389
|
var title = ctxCollapse(document.title, 200);
|
|
@@ -3364,11 +3496,95 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3364
3496
|
}
|
|
3365
3497
|
}
|
|
3366
3498
|
|
|
3367
|
-
//
|
|
3368
|
-
//
|
|
3499
|
+
// --- Text-less element descriptions ---
|
|
3500
|
+
// A pinpoint on an element with no text quotes a description instead
|
|
3501
|
+
// ("[element: …]"). The hover label alone ("Image") cannot tell one image
|
|
3502
|
+
// from the next, so the description adds what identifies THIS element: its
|
|
3503
|
+
// accessible name (aria-label, alt, title, ...) and, for media, the source
|
|
3504
|
+
// file name. The source goes through the same scrub the element context
|
|
3505
|
+
// uses (no query, no fragment, data: reduced to its media type). Square
|
|
3506
|
+
// brackets become parentheses and double quotes single ones, so the whole
|
|
3507
|
+
// description stays one "[element: …]" token the export recognizes.
|
|
3508
|
+
var DESC_MAX_NAME = 80;
|
|
3509
|
+
var DESC_MAX_FILE = 60;
|
|
3510
|
+
var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
|
|
3511
|
+
|
|
3512
|
+
function descClean(value, max) {
|
|
3513
|
+
return ctxCollapse(String(value).replace(/\\[/g, '(').replace(/\\]/g, ')').replace(/"/g, "'"), max);
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
// Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
|
|
3517
|
+
// first candidate's first word. A data: URL carries its own comma, so it is
|
|
3518
|
+
// taken up to whitespace instead.
|
|
3519
|
+
function descFirstSrcsetUrl(value) {
|
|
3520
|
+
var srcset = String(value || '').trim();
|
|
3521
|
+
if (!srcset) return '';
|
|
3522
|
+
return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\\s+/)[0] || '';
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
function descRawSource(el) {
|
|
3526
|
+
var tag = el.tagName;
|
|
3527
|
+
if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
|
|
3528
|
+
var attr = DESC_SRC_ATTR[tag];
|
|
3529
|
+
var src = '';
|
|
3530
|
+
if (attr) src = (el.getAttribute(attr) || '').trim();
|
|
3531
|
+
else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
|
|
3532
|
+
else return '';
|
|
3533
|
+
// Lazy loaders put a data: placeholder in src and the real file in data-src.
|
|
3534
|
+
var lazy = (el.getAttribute('data-src') || '').trim();
|
|
3535
|
+
if (lazy && (!src || /^data:/i.test(src))) src = lazy;
|
|
3536
|
+
if (!src && tag === 'IMG') {
|
|
3537
|
+
src = descFirstSrcsetUrl(el.getAttribute('srcset'));
|
|
3538
|
+
// <picture><source srcset="…"><img></picture>: the img itself may carry
|
|
3539
|
+
// no source at all; the first <source> candidate names the file.
|
|
3540
|
+
var picture = el.parentElement;
|
|
3541
|
+
if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
|
|
3542
|
+
var pictureSource = picture.querySelector('source[srcset]');
|
|
3543
|
+
if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
|
|
3547
|
+
var child = el.querySelector && el.querySelector('source[src]');
|
|
3548
|
+
if (child) src = (child.getAttribute('src') || '').trim();
|
|
3549
|
+
if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
|
|
3550
|
+
}
|
|
3551
|
+
return src;
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
// The file name a source points at: "team.jpg" for
|
|
3555
|
+
// https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
|
|
3556
|
+
function descSourceName(el) {
|
|
3557
|
+
var src = descRawSource(el);
|
|
3558
|
+
if (!src) return '';
|
|
3559
|
+
var scrubbed = ctxScrubUrl(src);
|
|
3560
|
+
if (!scrubbed) return '';
|
|
3561
|
+
if (/^data:/i.test(scrubbed)) {
|
|
3562
|
+
var media = scrubbed.slice(5).split(/[;,]/)[0];
|
|
3563
|
+
return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
|
|
3564
|
+
}
|
|
3565
|
+
if (/^blob:/i.test(scrubbed)) return 'blob';
|
|
3566
|
+
var path = scrubbed.replace(/\\?…$/, '').replace(/\\/+$/, '');
|
|
3567
|
+
var name = path.slice(path.lastIndexOf('/') + 1);
|
|
3568
|
+
try { name = decodeURIComponent(name); } catch (ex) {}
|
|
3569
|
+
if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
|
|
3570
|
+
return descClean(name, DESC_MAX_FILE);
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
function describeTextlessElement(el, label) {
|
|
3574
|
+
var out = descClean(label, DESC_MAX_NAME) || 'element';
|
|
3575
|
+
var name = ctxName(el);
|
|
3576
|
+
name = name ? descClean(name, DESC_MAX_NAME) : '';
|
|
3577
|
+
if (name && name !== out) out += ' "' + name + '"';
|
|
3578
|
+
var file = descSourceName(el);
|
|
3579
|
+
if (file && file !== name) out += ' (' + file + ')';
|
|
3580
|
+
return out;
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
// Element text for a pinpoint target (primary or additional): the
|
|
3584
|
+
// element's own text, or for a text-less element its description.
|
|
3369
3585
|
function elementTargetText(el, label) {
|
|
3370
3586
|
var text = capSelectionText((el.textContent || '').trim());
|
|
3371
|
-
if (!text) text = capSelectionText('[element: ' + label + ']');
|
|
3587
|
+
if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
|
|
3372
3588
|
return text;
|
|
3373
3589
|
}
|
|
3374
3590
|
|
|
@@ -3607,10 +3823,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3607
3823
|
if (!posted) clearPendingPin();
|
|
3608
3824
|
return posted;
|
|
3609
3825
|
}
|
|
3610
|
-
|
|
3611
|
-
//
|
|
3612
|
-
//
|
|
3613
|
-
|
|
3826
|
+
// Text-less elements (images, icon buttons, decorative chips, empty
|
|
3827
|
+
// containers) are still annotatable: describe the element instead of
|
|
3828
|
+
// quoting it, naming WHICH one it is where the page says so.
|
|
3829
|
+
var elText = elementTargetText(el, pinpointHoverLabel(el));
|
|
3614
3830
|
var r = el.getBoundingClientRect();
|
|
3615
3831
|
pendingSelection = { element: true };
|
|
3616
3832
|
pendingRange = null;
|