@plannotator/ui 0.50.0 → 0.51.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 +144 -11
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +4 -0
- 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 +24 -19
- 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 +124 -8
- package/components/html-viewer/bridge-script.ts +124 -8
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/hooks/useAIChat.ts +26 -5
- 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 +101 -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 +13 -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 +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
-
import type
|
|
2
|
+
import { buildDiagramAnchorValue, type DiagramAnchor, type DiagramKind, type DiagramTarget } from '@plannotator/core/diagram-anchor';
|
|
3
3
|
import { cn } from '../../lib/utils';
|
|
4
4
|
import { diagramFamilyOf } from '../../utils/diagram-anchor';
|
|
5
5
|
import { diagramFinder, type DiagramTheme } from '../../utils/diagram-render';
|
|
@@ -33,6 +33,16 @@ const DiagramSourcePane = lazy(async () => ({ default: (await import('./DiagramS
|
|
|
33
33
|
* Never `null` here — that would collapse the row back onto the canvas. */
|
|
34
34
|
const PANE_CLASS = 'order-last min-h-0 shrink-0 basis-2/5 border-t border-border md:order-first md:w-80 md:basis-auto md:border-r md:border-t-0';
|
|
35
35
|
|
|
36
|
+
/** A question typed in the composer, asked about the part instead of saved
|
|
37
|
+
* as a comment. The anchor is the one a comment there would carry (its
|
|
38
|
+
* `sourceLine` already in document lines). Resolve false (or throw) to keep
|
|
39
|
+
* the draft open; anything else closes it. */
|
|
40
|
+
export type DiagramAskAI = (
|
|
41
|
+
question: string,
|
|
42
|
+
anchor: DiagramAnchor,
|
|
43
|
+
additionalTargets: readonly DiagramTarget[],
|
|
44
|
+
) => boolean | void | Promise<boolean | void>;
|
|
45
|
+
|
|
36
46
|
export interface DiagramViewerProps {
|
|
37
47
|
readonly kind: DiagramKind;
|
|
38
48
|
/** The diagram text. With `onSave` this is the saved baseline the pane's
|
|
@@ -42,6 +52,8 @@ export interface DiagramViewerProps {
|
|
|
42
52
|
readonly comments: readonly DiagramComment[];
|
|
43
53
|
/** A comment composed on a part. Absent: clicks open nothing. */
|
|
44
54
|
readonly onCreateComment?: DiagramCreateComment;
|
|
55
|
+
/** Ask AI from the composer. Absent: the composer offers no Ask AI. */
|
|
56
|
+
readonly onAskAI?: DiagramAskAI;
|
|
45
57
|
/** Save the pane's text. Absent: there is no Source pane. */
|
|
46
58
|
readonly onSave?: (source: string) => Promise<SaveResult>;
|
|
47
59
|
/** With `onSave`: show the pane read-only (no edit access). */
|
|
@@ -92,6 +104,7 @@ export function DiagramViewer({
|
|
|
92
104
|
theme,
|
|
93
105
|
comments,
|
|
94
106
|
onCreateComment,
|
|
107
|
+
onAskAI,
|
|
95
108
|
onSave,
|
|
96
109
|
readOnlySource = false,
|
|
97
110
|
sourceOpen = false,
|
|
@@ -177,6 +190,21 @@ export function DiagramViewer({
|
|
|
177
190
|
if (!sourceOpen) cancelComposer();
|
|
178
191
|
}, [cancelComposer, sourceOpen]);
|
|
179
192
|
|
|
193
|
+
// Ask AI asks about the part the draft is on; the host's handler decides
|
|
194
|
+
// whether the question was taken, and a taken question closes the draft
|
|
195
|
+
// exactly as the markdown composer closes.
|
|
196
|
+
const composerDraft = commentsState.composer;
|
|
197
|
+
const askFromComposer = useMemo(() => {
|
|
198
|
+
if (onAskAI === undefined || composerDraft === null || onCreateComment === undefined) return undefined;
|
|
199
|
+
return async (question: string): Promise<boolean> => {
|
|
200
|
+
const anchor = buildDiagramAnchorValue(composerDraft.primary.target, composerDraft.sourceLine);
|
|
201
|
+
const accepted = await onAskAI(question, anchor, composerDraft.additional.map((extra) => extra.target));
|
|
202
|
+
if (accepted === false) return false;
|
|
203
|
+
cancelComposer();
|
|
204
|
+
return true;
|
|
205
|
+
};
|
|
206
|
+
}, [cancelComposer, composerDraft, onAskAI, onCreateComment]);
|
|
207
|
+
|
|
180
208
|
const overlay = useCallback(
|
|
181
209
|
(handle: DiagramCanvasHandle) => (
|
|
182
210
|
<DiagramOverlay
|
|
@@ -199,12 +227,13 @@ export function DiagramViewer({
|
|
|
199
227
|
disabledReason={commentingDisabledReason}
|
|
200
228
|
onSubmit={(text) => void commentsState.submit(text)}
|
|
201
229
|
onCancel={commentsState.cancel}
|
|
230
|
+
onAskAI={askFromComposer}
|
|
202
231
|
/>
|
|
203
232
|
)
|
|
204
233
|
}
|
|
205
234
|
/>
|
|
206
235
|
),
|
|
207
|
-
[commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
|
|
236
|
+
[askFromComposer, commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
|
|
208
237
|
);
|
|
209
238
|
|
|
210
239
|
const showFallback = render.svgNode === null;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `graphviz`, `mermaid`, `diagram-anchor`, `diagram-anchor-graphviz`,
|
|
7
7
|
* `diagram-projection`).
|
|
8
8
|
*/
|
|
9
|
-
export { DiagramViewer, type DiagramViewerProps } from './DiagramViewer';
|
|
9
|
+
export { DiagramViewer, type DiagramAskAI, type DiagramViewerProps } from './DiagramViewer';
|
|
10
10
|
export { DiagramPopout } from './DiagramPopout';
|
|
11
11
|
export { DiagramPending, DiagramInlineSource, DiagramBlockPending } from './DiagramPending';
|
|
12
12
|
export { isDiagramControlEvent, DIAGRAM_CONTROL_SELECTOR } from './diagramControls';
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
|
|
18
18
|
import { AnnotationType } from "../../types";
|
|
19
19
|
import { copyTextPreservingFocus } from "../../utils/clipboard";
|
|
20
|
+
import { elementIdentityForAskAI } from "../../utils/parser";
|
|
20
21
|
import { getIdentity } from "../../utils/identity";
|
|
21
22
|
import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
|
|
22
23
|
import {
|
|
@@ -674,6 +675,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
674
675
|
}));
|
|
675
676
|
}, [hook.draftTargets]);
|
|
676
677
|
|
|
678
|
+
// Ask AI from a pinpoint draft: which element(s) the question is about,
|
|
679
|
+
// in the identity lines the feedback export prints (no outline).
|
|
680
|
+
const askAIElementDetail = useMemo(
|
|
681
|
+
() => elementIdentityForAskAI(hook.draftTargets) || undefined,
|
|
682
|
+
[hook.draftTargets],
|
|
683
|
+
);
|
|
684
|
+
|
|
677
685
|
useEffect(() => {
|
|
678
686
|
function handler(e: MessageEvent<unknown>) {
|
|
679
687
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
@@ -1244,9 +1252,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1244
1252
|
draftKey={`html:${hook.commentPopover.draftKey}`}
|
|
1245
1253
|
onSubmit={hook.handleCommentSubmit}
|
|
1246
1254
|
mentionSource={mentionSource}
|
|
1247
|
-
//
|
|
1248
|
-
// the surface's one-click
|
|
1249
|
-
//
|
|
1255
|
+
// A pinpoint click opens this composer directly (never the
|
|
1256
|
+
// selection toolbar), so it carries the surface's one-click 👍.
|
|
1257
|
+
// It also shows when the composer opens from a text selection's
|
|
1258
|
+
// Comment button: a harmless duplicate of the toolbar 👍.
|
|
1250
1259
|
onQuickLookGood={hook.handleCommentLooksGood}
|
|
1251
1260
|
onClose={hook.handleCommentClose}
|
|
1252
1261
|
skillReferences
|
|
@@ -1255,6 +1264,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1255
1264
|
kind: "selection",
|
|
1256
1265
|
label: "Selected HTML",
|
|
1257
1266
|
text: hook.commentPopover.selectedText ?? hook.commentPopover.contextText,
|
|
1267
|
+
...(askAIElementDetail ? { detail: askAIElementDetail } : {}),
|
|
1258
1268
|
}}
|
|
1259
1269
|
targetChips={targetChips}
|
|
1260
1270
|
onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
|
|
@@ -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);
|
|
@@ -2680,6 +2686,25 @@
|
|
|
2680
2686
|
function ctxScrubUrl(value) {
|
|
2681
2687
|
var v = String(value).trim();
|
|
2682
2688
|
if (/^javascript:/i.test(v)) return null;
|
|
2689
|
+
// Srcdoc sessions serve a document's relative assets through the
|
|
2690
|
+
// session's own asset route (/api/html-assets/<token>/<path>). The token
|
|
2691
|
+
// means nothing outside this session; the path after it is the author's
|
|
2692
|
+
// own, relative to the document, which is what an agent can find.
|
|
2693
|
+
if (!LIVE) {
|
|
2694
|
+
var asset = v.match(/^\/api\/html-assets\/[^\/?#]+\/([^?#]*)/);
|
|
2695
|
+
if (asset && asset[1]) {
|
|
2696
|
+
// The route percent-encodes each segment; show the name the author
|
|
2697
|
+
// wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
|
|
2698
|
+
// that decodes to ? or # (which the query scrub below would cut), keeps
|
|
2699
|
+
// the raw text.
|
|
2700
|
+
var authored = asset[1];
|
|
2701
|
+
try {
|
|
2702
|
+
var decoded = decodeURIComponent(authored);
|
|
2703
|
+
if (!/[?#]/.test(decoded)) authored = decoded;
|
|
2704
|
+
} catch (ex) {}
|
|
2705
|
+
v = authored + v.slice(asset[0].length);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2683
2708
|
if (/^data:/i.test(v)) {
|
|
2684
2709
|
var comma = v.indexOf(',');
|
|
2685
2710
|
return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
|
|
@@ -2989,7 +3014,8 @@
|
|
|
2989
3014
|
}
|
|
2990
3015
|
|
|
2991
3016
|
// Shed order when over budget: the fields an agent can live without first.
|
|
2992
|
-
|
|
3017
|
+
// Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
|
|
3018
|
+
var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
|
|
2993
3019
|
|
|
2994
3020
|
function buildElementContext(el, maxBytes) {
|
|
2995
3021
|
if (!el || el.nodeType !== 1) return null;
|
|
@@ -3016,6 +3042,12 @@
|
|
|
3016
3042
|
if (heading) context.heading = heading;
|
|
3017
3043
|
var component = ctxComponent(el);
|
|
3018
3044
|
if (component) context.component = component;
|
|
3045
|
+
// The media file this element shows, resolved exactly as the composer
|
|
3046
|
+
// quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
|
|
3047
|
+
// poster; see descRawSource), so the exported heading can name the file
|
|
3048
|
+
// even when the element has no usable src attribute.
|
|
3049
|
+
var sourceName = descSourceName(el);
|
|
3050
|
+
if (sourceName) context.sourceName = sourceName;
|
|
3019
3051
|
if (LIVE) {
|
|
3020
3052
|
context.page = { url: currentPageUrl() };
|
|
3021
3053
|
var title = ctxCollapse(document.title, 200);
|
|
@@ -3128,11 +3160,95 @@
|
|
|
3128
3160
|
}
|
|
3129
3161
|
}
|
|
3130
3162
|
|
|
3131
|
-
//
|
|
3132
|
-
//
|
|
3163
|
+
// --- Text-less element descriptions ---
|
|
3164
|
+
// A pinpoint on an element with no text quotes a description instead
|
|
3165
|
+
// ("[element: …]"). The hover label alone ("Image") cannot tell one image
|
|
3166
|
+
// from the next, so the description adds what identifies THIS element: its
|
|
3167
|
+
// accessible name (aria-label, alt, title, ...) and, for media, the source
|
|
3168
|
+
// file name. The source goes through the same scrub the element context
|
|
3169
|
+
// uses (no query, no fragment, data: reduced to its media type). Square
|
|
3170
|
+
// brackets become parentheses and double quotes single ones, so the whole
|
|
3171
|
+
// description stays one "[element: …]" token the export recognizes.
|
|
3172
|
+
var DESC_MAX_NAME = 80;
|
|
3173
|
+
var DESC_MAX_FILE = 60;
|
|
3174
|
+
var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
|
|
3175
|
+
|
|
3176
|
+
function descClean(value, max) {
|
|
3177
|
+
return ctxCollapse(String(value).replace(/\[/g, '(').replace(/\]/g, ')').replace(/"/g, "'"), max);
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
|
|
3181
|
+
// first candidate's first word. A data: URL carries its own comma, so it is
|
|
3182
|
+
// taken up to whitespace instead.
|
|
3183
|
+
function descFirstSrcsetUrl(value) {
|
|
3184
|
+
var srcset = String(value || '').trim();
|
|
3185
|
+
if (!srcset) return '';
|
|
3186
|
+
return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\s+/)[0] || '';
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
function descRawSource(el) {
|
|
3190
|
+
var tag = el.tagName;
|
|
3191
|
+
if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
|
|
3192
|
+
var attr = DESC_SRC_ATTR[tag];
|
|
3193
|
+
var src = '';
|
|
3194
|
+
if (attr) src = (el.getAttribute(attr) || '').trim();
|
|
3195
|
+
else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
|
|
3196
|
+
else return '';
|
|
3197
|
+
// Lazy loaders put a data: placeholder in src and the real file in data-src.
|
|
3198
|
+
var lazy = (el.getAttribute('data-src') || '').trim();
|
|
3199
|
+
if (lazy && (!src || /^data:/i.test(src))) src = lazy;
|
|
3200
|
+
if (!src && tag === 'IMG') {
|
|
3201
|
+
src = descFirstSrcsetUrl(el.getAttribute('srcset'));
|
|
3202
|
+
// <picture><source srcset="…"><img></picture>: the img itself may carry
|
|
3203
|
+
// no source at all; the first <source> candidate names the file.
|
|
3204
|
+
var picture = el.parentElement;
|
|
3205
|
+
if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
|
|
3206
|
+
var pictureSource = picture.querySelector('source[srcset]');
|
|
3207
|
+
if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
|
|
3211
|
+
var child = el.querySelector && el.querySelector('source[src]');
|
|
3212
|
+
if (child) src = (child.getAttribute('src') || '').trim();
|
|
3213
|
+
if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
|
|
3214
|
+
}
|
|
3215
|
+
return src;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
// The file name a source points at: "team.jpg" for
|
|
3219
|
+
// https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
|
|
3220
|
+
function descSourceName(el) {
|
|
3221
|
+
var src = descRawSource(el);
|
|
3222
|
+
if (!src) return '';
|
|
3223
|
+
var scrubbed = ctxScrubUrl(src);
|
|
3224
|
+
if (!scrubbed) return '';
|
|
3225
|
+
if (/^data:/i.test(scrubbed)) {
|
|
3226
|
+
var media = scrubbed.slice(5).split(/[;,]/)[0];
|
|
3227
|
+
return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
|
|
3228
|
+
}
|
|
3229
|
+
if (/^blob:/i.test(scrubbed)) return 'blob';
|
|
3230
|
+
var path = scrubbed.replace(/\?…$/, '').replace(/\/+$/, '');
|
|
3231
|
+
var name = path.slice(path.lastIndexOf('/') + 1);
|
|
3232
|
+
try { name = decodeURIComponent(name); } catch (ex) {}
|
|
3233
|
+
if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
|
|
3234
|
+
return descClean(name, DESC_MAX_FILE);
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function describeTextlessElement(el, label) {
|
|
3238
|
+
var out = descClean(label, DESC_MAX_NAME) || 'element';
|
|
3239
|
+
var name = ctxName(el);
|
|
3240
|
+
name = name ? descClean(name, DESC_MAX_NAME) : '';
|
|
3241
|
+
if (name && name !== out) out += ' "' + name + '"';
|
|
3242
|
+
var file = descSourceName(el);
|
|
3243
|
+
if (file && file !== name) out += ' (' + file + ')';
|
|
3244
|
+
return out;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
// Element text for a pinpoint target (primary or additional): the
|
|
3248
|
+
// element's own text, or for a text-less element its description.
|
|
3133
3249
|
function elementTargetText(el, label) {
|
|
3134
3250
|
var text = capSelectionText((el.textContent || '').trim());
|
|
3135
|
-
if (!text) text = capSelectionText('[element: ' + label + ']');
|
|
3251
|
+
if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
|
|
3136
3252
|
return text;
|
|
3137
3253
|
}
|
|
3138
3254
|
|
|
@@ -3371,10 +3487,10 @@
|
|
|
3371
3487
|
if (!posted) clearPendingPin();
|
|
3372
3488
|
return posted;
|
|
3373
3489
|
}
|
|
3374
|
-
|
|
3375
|
-
//
|
|
3376
|
-
//
|
|
3377
|
-
|
|
3490
|
+
// Text-less elements (images, icon buttons, decorative chips, empty
|
|
3491
|
+
// containers) are still annotatable: describe the element instead of
|
|
3492
|
+
// quoting it, naming WHICH one it is where the page says so.
|
|
3493
|
+
var elText = elementTargetText(el, pinpointHoverLabel(el));
|
|
3378
3494
|
var r = el.getBoundingClientRect();
|
|
3379
3495
|
pendingSelection = { element: true };
|
|
3380
3496
|
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);
|
|
@@ -2916,6 +2922,25 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2916
2922
|
function ctxScrubUrl(value) {
|
|
2917
2923
|
var v = String(value).trim();
|
|
2918
2924
|
if (/^javascript:/i.test(v)) return null;
|
|
2925
|
+
// Srcdoc sessions serve a document's relative assets through the
|
|
2926
|
+
// session's own asset route (/api/html-assets/<token>/<path>). The token
|
|
2927
|
+
// means nothing outside this session; the path after it is the author's
|
|
2928
|
+
// own, relative to the document, which is what an agent can find.
|
|
2929
|
+
if (!LIVE) {
|
|
2930
|
+
var asset = v.match(/^\\/api\\/html-assets\\/[^\\/?#]+\\/([^?#]*)/);
|
|
2931
|
+
if (asset && asset[1]) {
|
|
2932
|
+
// The route percent-encodes each segment; show the name the author
|
|
2933
|
+
// wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
|
|
2934
|
+
// that decodes to ? or # (which the query scrub below would cut), keeps
|
|
2935
|
+
// the raw text.
|
|
2936
|
+
var authored = asset[1];
|
|
2937
|
+
try {
|
|
2938
|
+
var decoded = decodeURIComponent(authored);
|
|
2939
|
+
if (!/[?#]/.test(decoded)) authored = decoded;
|
|
2940
|
+
} catch (ex) {}
|
|
2941
|
+
v = authored + v.slice(asset[0].length);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2919
2944
|
if (/^data:/i.test(v)) {
|
|
2920
2945
|
var comma = v.indexOf(',');
|
|
2921
2946
|
return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
|
|
@@ -3225,7 +3250,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3225
3250
|
}
|
|
3226
3251
|
|
|
3227
3252
|
// Shed order when over budget: the fields an agent can live without first.
|
|
3228
|
-
|
|
3253
|
+
// Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
|
|
3254
|
+
var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
|
|
3229
3255
|
|
|
3230
3256
|
function buildElementContext(el, maxBytes) {
|
|
3231
3257
|
if (!el || el.nodeType !== 1) return null;
|
|
@@ -3252,6 +3278,12 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3252
3278
|
if (heading) context.heading = heading;
|
|
3253
3279
|
var component = ctxComponent(el);
|
|
3254
3280
|
if (component) context.component = component;
|
|
3281
|
+
// The media file this element shows, resolved exactly as the composer
|
|
3282
|
+
// quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
|
|
3283
|
+
// poster; see descRawSource), so the exported heading can name the file
|
|
3284
|
+
// even when the element has no usable src attribute.
|
|
3285
|
+
var sourceName = descSourceName(el);
|
|
3286
|
+
if (sourceName) context.sourceName = sourceName;
|
|
3255
3287
|
if (LIVE) {
|
|
3256
3288
|
context.page = { url: currentPageUrl() };
|
|
3257
3289
|
var title = ctxCollapse(document.title, 200);
|
|
@@ -3364,11 +3396,95 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3364
3396
|
}
|
|
3365
3397
|
}
|
|
3366
3398
|
|
|
3367
|
-
//
|
|
3368
|
-
//
|
|
3399
|
+
// --- Text-less element descriptions ---
|
|
3400
|
+
// A pinpoint on an element with no text quotes a description instead
|
|
3401
|
+
// ("[element: …]"). The hover label alone ("Image") cannot tell one image
|
|
3402
|
+
// from the next, so the description adds what identifies THIS element: its
|
|
3403
|
+
// accessible name (aria-label, alt, title, ...) and, for media, the source
|
|
3404
|
+
// file name. The source goes through the same scrub the element context
|
|
3405
|
+
// uses (no query, no fragment, data: reduced to its media type). Square
|
|
3406
|
+
// brackets become parentheses and double quotes single ones, so the whole
|
|
3407
|
+
// description stays one "[element: …]" token the export recognizes.
|
|
3408
|
+
var DESC_MAX_NAME = 80;
|
|
3409
|
+
var DESC_MAX_FILE = 60;
|
|
3410
|
+
var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
|
|
3411
|
+
|
|
3412
|
+
function descClean(value, max) {
|
|
3413
|
+
return ctxCollapse(String(value).replace(/\\[/g, '(').replace(/\\]/g, ')').replace(/"/g, "'"), max);
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
// Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
|
|
3417
|
+
// first candidate's first word. A data: URL carries its own comma, so it is
|
|
3418
|
+
// taken up to whitespace instead.
|
|
3419
|
+
function descFirstSrcsetUrl(value) {
|
|
3420
|
+
var srcset = String(value || '').trim();
|
|
3421
|
+
if (!srcset) return '';
|
|
3422
|
+
return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\\s+/)[0] || '';
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
function descRawSource(el) {
|
|
3426
|
+
var tag = el.tagName;
|
|
3427
|
+
if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
|
|
3428
|
+
var attr = DESC_SRC_ATTR[tag];
|
|
3429
|
+
var src = '';
|
|
3430
|
+
if (attr) src = (el.getAttribute(attr) || '').trim();
|
|
3431
|
+
else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
|
|
3432
|
+
else return '';
|
|
3433
|
+
// Lazy loaders put a data: placeholder in src and the real file in data-src.
|
|
3434
|
+
var lazy = (el.getAttribute('data-src') || '').trim();
|
|
3435
|
+
if (lazy && (!src || /^data:/i.test(src))) src = lazy;
|
|
3436
|
+
if (!src && tag === 'IMG') {
|
|
3437
|
+
src = descFirstSrcsetUrl(el.getAttribute('srcset'));
|
|
3438
|
+
// <picture><source srcset="…"><img></picture>: the img itself may carry
|
|
3439
|
+
// no source at all; the first <source> candidate names the file.
|
|
3440
|
+
var picture = el.parentElement;
|
|
3441
|
+
if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
|
|
3442
|
+
var pictureSource = picture.querySelector('source[srcset]');
|
|
3443
|
+
if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
|
|
3447
|
+
var child = el.querySelector && el.querySelector('source[src]');
|
|
3448
|
+
if (child) src = (child.getAttribute('src') || '').trim();
|
|
3449
|
+
if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
|
|
3450
|
+
}
|
|
3451
|
+
return src;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
// The file name a source points at: "team.jpg" for
|
|
3455
|
+
// https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
|
|
3456
|
+
function descSourceName(el) {
|
|
3457
|
+
var src = descRawSource(el);
|
|
3458
|
+
if (!src) return '';
|
|
3459
|
+
var scrubbed = ctxScrubUrl(src);
|
|
3460
|
+
if (!scrubbed) return '';
|
|
3461
|
+
if (/^data:/i.test(scrubbed)) {
|
|
3462
|
+
var media = scrubbed.slice(5).split(/[;,]/)[0];
|
|
3463
|
+
return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
|
|
3464
|
+
}
|
|
3465
|
+
if (/^blob:/i.test(scrubbed)) return 'blob';
|
|
3466
|
+
var path = scrubbed.replace(/\\?…$/, '').replace(/\\/+$/, '');
|
|
3467
|
+
var name = path.slice(path.lastIndexOf('/') + 1);
|
|
3468
|
+
try { name = decodeURIComponent(name); } catch (ex) {}
|
|
3469
|
+
if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
|
|
3470
|
+
return descClean(name, DESC_MAX_FILE);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
function describeTextlessElement(el, label) {
|
|
3474
|
+
var out = descClean(label, DESC_MAX_NAME) || 'element';
|
|
3475
|
+
var name = ctxName(el);
|
|
3476
|
+
name = name ? descClean(name, DESC_MAX_NAME) : '';
|
|
3477
|
+
if (name && name !== out) out += ' "' + name + '"';
|
|
3478
|
+
var file = descSourceName(el);
|
|
3479
|
+
if (file && file !== name) out += ' (' + file + ')';
|
|
3480
|
+
return out;
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
// Element text for a pinpoint target (primary or additional): the
|
|
3484
|
+
// element's own text, or for a text-less element its description.
|
|
3369
3485
|
function elementTargetText(el, label) {
|
|
3370
3486
|
var text = capSelectionText((el.textContent || '').trim());
|
|
3371
|
-
if (!text) text = capSelectionText('[element: ' + label + ']');
|
|
3487
|
+
if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
|
|
3372
3488
|
return text;
|
|
3373
3489
|
}
|
|
3374
3490
|
|
|
@@ -3607,10 +3723,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3607
3723
|
if (!posted) clearPendingPin();
|
|
3608
3724
|
return posted;
|
|
3609
3725
|
}
|
|
3610
|
-
|
|
3611
|
-
//
|
|
3612
|
-
//
|
|
3613
|
-
|
|
3726
|
+
// Text-less elements (images, icon buttons, decorative chips, empty
|
|
3727
|
+
// containers) are still annotatable: describe the element instead of
|
|
3728
|
+
// quoting it, naming WHICH one it is where the page says so.
|
|
3729
|
+
var elText = elementTargetText(el, pinpointHoverLabel(el));
|
|
3614
3730
|
var r = el.getBoundingClientRect();
|
|
3615
3731
|
pendingSelection = { element: true };
|
|
3616
3732
|
pendingRange = null;
|
|
@@ -477,8 +477,8 @@ export function useHtmlAnnotation({
|
|
|
477
477
|
flashDraftTarget: (key: string) => void;
|
|
478
478
|
/** Bumped after every target add/remove so the composer can refocus its textarea. */
|
|
479
479
|
composerFocusToken: number;
|
|
480
|
-
/** Composer one-click
|
|
481
|
-
*
|
|
480
|
+
/** Composer one-click 👍: submits the hardcoded positive label with the
|
|
481
|
+
* same anchor and multi-select targets a typed comment would carry. */
|
|
482
482
|
handleCommentLooksGood: () => void;
|
|
483
483
|
/** Ids this module minted for locally created annotations (create-mark),
|
|
484
484
|
* for the unanchored union: a minted id the host never listed is a
|
|
@@ -910,8 +910,14 @@ export function useHtmlAnnotation({
|
|
|
910
910
|
[getOrCreateAnchor],
|
|
911
911
|
);
|
|
912
912
|
|
|
913
|
-
|
|
914
|
-
|
|
913
|
+
// One commit path for everything the pinpoint composer submits (a typed
|
|
914
|
+
// comment or the one-click 👍), so both carry the same anchor, element
|
|
915
|
+
// context and multi-select targets.
|
|
916
|
+
const commitComposerDraft = useCallback(
|
|
917
|
+
(
|
|
918
|
+
body: Pick<Annotation, "text"> &
|
|
919
|
+
Partial<Pick<Annotation, "images" | "mentions" | "isQuickLabel" | "quickLabelTip">>,
|
|
920
|
+
) => {
|
|
915
921
|
if (!enabledRef.current) return;
|
|
916
922
|
// Prefer the text captured when the popover opened — it can't be clobbered by
|
|
917
923
|
// a later selection change or clear while the user is composing the comment.
|
|
@@ -939,14 +945,10 @@ export function useHtmlAnnotation({
|
|
|
939
945
|
startOffset: 0,
|
|
940
946
|
endOffset: 0,
|
|
941
947
|
type: AnnotationType.COMMENT,
|
|
942
|
-
text: comment,
|
|
943
948
|
originalText: text,
|
|
944
949
|
author: getIdentity(),
|
|
945
950
|
createdA: Date.now(),
|
|
946
|
-
|
|
947
|
-
// Host capability: present only when a mentionSource was supplied AND
|
|
948
|
-
// a token survived, so a comment without one is unchanged.
|
|
949
|
-
...(mentions && mentions.length > 0 ? { mentions } : {}),
|
|
951
|
+
...body,
|
|
950
952
|
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
951
953
|
elementContext: pendingContextRef.current ?? undefined,
|
|
952
954
|
htmlAdditionalTargets: additionalTargets,
|
|
@@ -961,53 +963,32 @@ export function useHtmlAnnotation({
|
|
|
961
963
|
[post],
|
|
962
964
|
);
|
|
963
965
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const additionalTargets: HtmlAnnotationTarget[] | undefined =
|
|
976
|
-
targets.length > 1
|
|
977
|
-
? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
|
|
978
|
-
label: t.label,
|
|
979
|
-
text: t.text,
|
|
980
|
-
anchor: t.anchor ?? undefined,
|
|
981
|
-
...(t.context ? { context: t.context } : {}),
|
|
982
|
-
}))
|
|
983
|
-
: undefined;
|
|
984
|
-
|
|
985
|
-
const id = nextHtmlAnnId();
|
|
986
|
-
mintedIdsRef.current.add(id);
|
|
987
|
-
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
988
|
-
onAddRef.current?.({
|
|
989
|
-
id,
|
|
990
|
-
blockId: "",
|
|
991
|
-
startOffset: 0,
|
|
992
|
-
endOffset: 0,
|
|
993
|
-
type: AnnotationType.COMMENT,
|
|
994
|
-
text: THUMBS_UP_LABEL.text,
|
|
995
|
-
originalText: text,
|
|
996
|
-
isQuickLabel: true,
|
|
997
|
-
quickLabelTip: THUMBS_UP_LABEL.tip,
|
|
998
|
-
author: getIdentity(),
|
|
999
|
-
createdA: Date.now(),
|
|
1000
|
-
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
1001
|
-
elementContext: pendingContextRef.current ?? undefined,
|
|
1002
|
-
htmlAdditionalTargets: additionalTargets,
|
|
1003
|
-
});
|
|
966
|
+
const handleCommentSubmit = useCallback(
|
|
967
|
+
(comment: string, images?: ImageAttachment[], mentions?: readonly string[]) =>
|
|
968
|
+
commitComposerDraft({
|
|
969
|
+
text: comment,
|
|
970
|
+
images,
|
|
971
|
+
// Host capability: present only when a mentionSource was supplied AND
|
|
972
|
+
// a token survived, so a comment without one is unchanged.
|
|
973
|
+
...(mentions && mentions.length > 0 ? { mentions } : {}),
|
|
974
|
+
}),
|
|
975
|
+
[commitComposerDraft],
|
|
976
|
+
);
|
|
1004
977
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
978
|
+
// The composer's one-click 👍. A pinpoint click opens this composer
|
|
979
|
+
// directly and never shows the selection toolbar, so without it a reviewer
|
|
980
|
+
// could not mark an element "looks good" in one click. Emits the same
|
|
981
|
+
// hardcoded positive label as the toolbar's 👍 (the only label comment-only
|
|
982
|
+
// surfaces may create), on the pinned element(s).
|
|
983
|
+
const handleCommentLooksGood = useCallback(
|
|
984
|
+
() =>
|
|
985
|
+
commitComposerDraft({
|
|
986
|
+
text: THUMBS_UP_LABEL.text,
|
|
987
|
+
isQuickLabel: true,
|
|
988
|
+
quickLabelTip: THUMBS_UP_LABEL.tip,
|
|
989
|
+
}),
|
|
990
|
+
[commitComposerDraft],
|
|
991
|
+
);
|
|
1011
992
|
|
|
1012
993
|
const handleCommentClose = useCallback(() => {
|
|
1013
994
|
post({ type: `${PREFIX}cancel-selection` });
|