@plannotator/ui 0.31.0 → 0.32.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/README.md +45 -1
- package/components/AnnotationPanel.tsx +96 -4
- package/components/AnnotationToolbar.tsx +25 -25
- package/components/CommentPopover.tsx +26 -0
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +22 -2
- package/components/MermaidBlock.tsx +60 -26
- package/components/Settings.tsx +40 -1
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/html-viewer/HtmlViewer.tsx +115 -5
- package/components/html-viewer/bridge-script.ts +41 -7
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +95 -5
- package/configure.ts +32 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useSharing.ts +31 -5
- package/package.json +4 -2
- package/styles.css +1 -1
- package/types.ts +1 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/identity-tater.ts +36 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +38 -0
- package/utils/quickLabels.ts +13 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- package/webmcp/useToolset.ts +74 -0
|
@@ -339,6 +339,17 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
339
339
|
var pendingMultiTargets = []; // { key, el, anchor, label, text, box }
|
|
340
340
|
var multiTargetSeq = 0;
|
|
341
341
|
var MAX_MULTI_TARGETS = 16;
|
|
342
|
+
// Per-draft cap on additional targets: the parent may lower it on
|
|
343
|
+
// arm-multi-select ({ max }) to its product cap so the toggle stops where
|
|
344
|
+
// the saved annotation would. Never above MAX_MULTI_TARGETS; reset with
|
|
345
|
+
// the arm on every draft.
|
|
346
|
+
var multiSelectMax = MAX_MULTI_TARGETS;
|
|
347
|
+
function clampMultiSelectMax(value) {
|
|
348
|
+
if (typeof value !== 'number' || !isFinite(value)) return MAX_MULTI_TARGETS;
|
|
349
|
+
var whole = Math.floor(value);
|
|
350
|
+
if (whole < 0) return 0;
|
|
351
|
+
return whole > MAX_MULTI_TARGETS ? MAX_MULTI_TARGETS : whole;
|
|
352
|
+
}
|
|
342
353
|
// Live mode clamps the INPUT METHOD to pinpoint (click = element). Text
|
|
343
354
|
// drag-selection is a separate, always-on channel — see the mouseup handler
|
|
344
355
|
// — so the clamp only decides what a plain click does, never whether text
|
|
@@ -637,6 +648,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
637
648
|
&& e.data.key === pendingPinKey
|
|
638
649
|
) {
|
|
639
650
|
multiSelectArmed = true;
|
|
651
|
+
// Optional product cap for THIS draft; absent keeps the bridge's own.
|
|
652
|
+
multiSelectMax = e.data.max === undefined
|
|
653
|
+
? MAX_MULTI_TARGETS
|
|
654
|
+
: clampMultiSelectMax(e.data.max);
|
|
640
655
|
}
|
|
641
656
|
}
|
|
642
657
|
|
|
@@ -655,14 +670,25 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
655
670
|
// Selecting an annotation scrolls its first resolved target into view
|
|
656
671
|
// and flashes the overlay focus highlight over EVERY rect of EVERY
|
|
657
672
|
// target — never a class write on page elements, and never only the
|
|
658
|
-
// first fragment of a multi-paragraph selection.
|
|
659
|
-
|
|
673
|
+
// first fragment of a multi-paragraph selection. The optional
|
|
674
|
+
// behavior lets the parent pass its reduced-motion preference across
|
|
675
|
+
// the boundary; absent means smooth, as before.
|
|
676
|
+
scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
|
|
660
677
|
}
|
|
661
678
|
|
|
662
679
|
else if (type === PREFIX + 'focus-mark') {
|
|
663
680
|
focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
|
|
664
681
|
}
|
|
665
682
|
|
|
683
|
+
else if (type === PREFIX + 'report-unanchored') {
|
|
684
|
+
// The parent posted its restore batch and wants the complete set once
|
|
685
|
+
// the next complete overlay pass has run, even if the set is unchanged
|
|
686
|
+
// (empty included). Messages are processed in order, so the pass this
|
|
687
|
+
// schedules sees every find-and-mark posted before this request.
|
|
688
|
+
unanchoredReportRequested = true;
|
|
689
|
+
schedulePinpointReconcile();
|
|
690
|
+
}
|
|
691
|
+
|
|
666
692
|
else if (type === PREFIX + 'set-input-method') {
|
|
667
693
|
// Live mode clamps the input method to pinpoint (what a plain click
|
|
668
694
|
// does); text drag-selection commenting stays live regardless.
|
|
@@ -1488,6 +1514,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1488
1514
|
// whose records are removed and therefore invisible to the per-pass scan.
|
|
1489
1515
|
var lastUnanchoredKey = '[]';
|
|
1490
1516
|
var restoreFailedIds = new Set();
|
|
1517
|
+
// Set by report-unanchored: the parent asks for the complete set after
|
|
1518
|
+
// its restore batch, so the next COMPLETE pass emits even when the set
|
|
1519
|
+
// did not change (an all-restored document reports its empty set once).
|
|
1520
|
+
var unanchoredReportRequested = false;
|
|
1491
1521
|
function emitUnanchored(deadRecordIds) {
|
|
1492
1522
|
var seen = new Set();
|
|
1493
1523
|
var combined = [];
|
|
@@ -1506,7 +1536,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1506
1536
|
combined.sort();
|
|
1507
1537
|
if (combined.length > 512) combined = combined.slice(0, 512);
|
|
1508
1538
|
var key = JSON.stringify(combined);
|
|
1509
|
-
if (key === lastUnanchoredKey) return;
|
|
1539
|
+
if (key === lastUnanchoredKey && !unanchoredReportRequested) return;
|
|
1540
|
+
unanchoredReportRequested = false;
|
|
1510
1541
|
lastUnanchoredKey = key;
|
|
1511
1542
|
// postToParent, not a raw '*' post: live sessions stamp the session
|
|
1512
1543
|
// token and post only to the listed editor origins, and the parent
|
|
@@ -2495,7 +2526,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2495
2526
|
}
|
|
2496
2527
|
}
|
|
2497
2528
|
|
|
2498
|
-
function scrollToAnnotation(id) {
|
|
2529
|
+
function scrollToAnnotation(id, behavior) {
|
|
2499
2530
|
var record = findAnnRecord(id);
|
|
2500
2531
|
if (!record) return;
|
|
2501
2532
|
beginDeadSearchPass(Infinity); // user-initiated one-shot: never budget-starved
|
|
@@ -2511,7 +2542,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2511
2542
|
}
|
|
2512
2543
|
}
|
|
2513
2544
|
if (scrollEl) {
|
|
2514
|
-
try { scrollEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
|
|
2545
|
+
try { scrollEl.scrollIntoView({ behavior: behavior || 'smooth', block: 'center' }); } catch (ex) {}
|
|
2515
2546
|
}
|
|
2516
2547
|
focusAnnotationRecord(id, true);
|
|
2517
2548
|
}
|
|
@@ -2693,6 +2724,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2693
2724
|
pendingPinPoint = null;
|
|
2694
2725
|
pendingPinViaPinpoint = false;
|
|
2695
2726
|
multiSelectArmed = false;
|
|
2727
|
+
multiSelectMax = MAX_MULTI_TARGETS;
|
|
2696
2728
|
hidePinpointBox();
|
|
2697
2729
|
}
|
|
2698
2730
|
|
|
@@ -2857,8 +2889,9 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2857
2889
|
}
|
|
2858
2890
|
}
|
|
2859
2891
|
}
|
|
2860
|
-
// Cap at the source: never grow the draft past the parent-side DTO cap
|
|
2861
|
-
|
|
2892
|
+
// Cap at the source: never grow the draft past the parent-side DTO cap
|
|
2893
|
+
// (or the lower product cap the parent armed this draft with).
|
|
2894
|
+
if (pendingMultiTargets.length >= multiSelectMax) return;
|
|
2862
2895
|
var point = normalizePointInElement(el, clickPoint);
|
|
2863
2896
|
if (anchor && point) anchor.point = point;
|
|
2864
2897
|
var label = pinpointHoverLabel(el);
|
|
@@ -2961,6 +2994,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2961
2994
|
// drafts (comment -> quick label) leaves a stale arm and the bridge
|
|
2962
2995
|
// accumulates pins the saved annotation will not carry.
|
|
2963
2996
|
multiSelectArmed = false;
|
|
2997
|
+
multiSelectMax = MAX_MULTI_TARGETS;
|
|
2964
2998
|
pendingPinEl = el;
|
|
2965
2999
|
pendingPinAnchor = buildElementAnchor(el);
|
|
2966
3000
|
pendingPinKey = makeTargetKey();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side helpers for the raw-HTML viewer, re-exported from
|
|
3
|
+
* `@plannotator/core/html-anchor` with the package's `Annotation` type.
|
|
4
|
+
*
|
|
5
|
+
* `projectHostThreads` turns a host's stored rows into the `annotations`
|
|
6
|
+
* prop (output order == marker numbering); `buildPersistedHtmlAnchor` trims a
|
|
7
|
+
* composed comment's anchor to a bounded record the host can persist. Both
|
|
8
|
+
* are pure and dependency-free (they live in `@plannotator/core`).
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
projectHostThreads as projectHostThreadsCore,
|
|
12
|
+
type HostThread,
|
|
13
|
+
type ProjectHostThreadsOptions,
|
|
14
|
+
} from "@plannotator/core/html-anchor";
|
|
15
|
+
import type { Annotation } from "../../types";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
buildPersistedHtmlAnchor,
|
|
19
|
+
type BuildPersistedHtmlAnchorOptions,
|
|
20
|
+
type HostThread,
|
|
21
|
+
type PersistedHtmlAnchor,
|
|
22
|
+
type PersistedHtmlAnchorResult,
|
|
23
|
+
type ProjectHostThreadsOptions,
|
|
24
|
+
} from "@plannotator/core/html-anchor";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Project stored host rows onto the viewer's `annotations` prop, in the
|
|
28
|
+
* host's order (which becomes the marker numbering). See the core function
|
|
29
|
+
* for the projection rules; the `type` literals it emits are the string
|
|
30
|
+
* values of `AnnotationType`, so the cast below is representation-exact.
|
|
31
|
+
*/
|
|
32
|
+
export function projectHostThreads(
|
|
33
|
+
threads: readonly HostThread[],
|
|
34
|
+
options?: ProjectHostThreadsOptions,
|
|
35
|
+
): Annotation[] {
|
|
36
|
+
return projectHostThreadsCore(threads, options) as unknown as Annotation[];
|
|
37
|
+
}
|
|
@@ -1 +1,10 @@
|
|
|
1
1
|
export { HtmlViewer, type HtmlViewerProps } from "./HtmlViewer";
|
|
2
|
+
export {
|
|
3
|
+
buildPersistedHtmlAnchor,
|
|
4
|
+
projectHostThreads,
|
|
5
|
+
type BuildPersistedHtmlAnchorOptions,
|
|
6
|
+
type HostThread,
|
|
7
|
+
type PersistedHtmlAnchor,
|
|
8
|
+
type PersistedHtmlAnchorResult,
|
|
9
|
+
type ProjectHostThreadsOptions,
|
|
10
|
+
} from "./hostThreads";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { AnnotationType, type Annotation } from "../../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A page-anchored row the viewer can never post to the bridge: nothing to
|
|
5
|
+
* find it by (no quoted text, no element anchor, no additional target
|
|
6
|
+
* anchor). Document-level comments are excluded on purpose: a
|
|
7
|
+
* GLOBAL_COMMENT has no page location by design and is not "unanchored".
|
|
8
|
+
*/
|
|
9
|
+
export function isTextlessPageAnnotation(annotation: Annotation): boolean {
|
|
10
|
+
if (annotation.type === AnnotationType.GLOBAL_COMMENT) return false;
|
|
11
|
+
if (annotation.originalText) return false;
|
|
12
|
+
if (annotation.htmlAnchor) return false;
|
|
13
|
+
return !(annotation.htmlAdditionalTargets ?? []).some((target) => !!target.anchor);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The host-facing unanchored set: the bridge's report (ids with no live
|
|
18
|
+
* representation on the page) completed with what the bridge cannot see.
|
|
19
|
+
*
|
|
20
|
+
* - Textless page rows are added: they were never posted, so the bridge
|
|
21
|
+
* cannot report them, yet they have no marker and no highlight.
|
|
22
|
+
* - An id this viewer minted for a locally created comment (`create-mark`)
|
|
23
|
+
* that the host never carried in `annotations`, or has since swapped out
|
|
24
|
+
* for its own id, is dropped: the host holds no card for it, so naming it
|
|
25
|
+
* would be noise. Every other bridge id passes through untouched, so a
|
|
26
|
+
* host that paints through the imperative handle keeps today's delivery.
|
|
27
|
+
*
|
|
28
|
+
* Sorted and deduplicated like the bridge's own emission. With no textless
|
|
29
|
+
* rows and no swapped-out minted ids the result is exactly the bridge list.
|
|
30
|
+
*/
|
|
31
|
+
export function mergeUnanchoredIds(input: {
|
|
32
|
+
bridgeIds: readonly string[];
|
|
33
|
+
annotations: readonly Annotation[];
|
|
34
|
+
createdIds: ReadonlySet<string>;
|
|
35
|
+
}): string[] {
|
|
36
|
+
const known = new Set<string>();
|
|
37
|
+
for (const annotation of input.annotations) known.add(annotation.id);
|
|
38
|
+
const out = new Set<string>();
|
|
39
|
+
for (const id of input.bridgeIds) {
|
|
40
|
+
if (input.createdIds.has(id) && !known.has(id)) continue;
|
|
41
|
+
out.add(id);
|
|
42
|
+
}
|
|
43
|
+
for (const annotation of input.annotations) {
|
|
44
|
+
if (isTextlessPageAnnotation(annotation)) out.add(annotation.id);
|
|
45
|
+
}
|
|
46
|
+
return [...out].sort();
|
|
47
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
|
|
2
2
|
import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type ImageAttachment } from "../../types";
|
|
3
|
-
import type
|
|
3
|
+
import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
|
|
4
4
|
import { getIdentity } from "../../utils/identity";
|
|
5
5
|
import type {
|
|
6
6
|
ToolbarState,
|
|
@@ -18,6 +18,13 @@ function nextHtmlAnnId(): string {
|
|
|
18
18
|
return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Ids minted by this module for locally created annotations (create-mark).
|
|
22
|
+
* Module-scoped like the sequence above: a host that swaps a local id for
|
|
23
|
+
* its own server id keeps the local mark until it removes it, and the
|
|
24
|
+
* unanchored union needs to recognise such ids whichever viewer instance
|
|
25
|
+
* minted them. Bounded: only ids from this page load, one entry per create. */
|
|
26
|
+
const mintedHtmlAnnIds = new Set<string>();
|
|
27
|
+
|
|
21
28
|
function htmlCommentDraftKey(
|
|
22
29
|
text: string,
|
|
23
30
|
anchor?: HtmlElementAnchor | null,
|
|
@@ -129,6 +136,20 @@ export interface UseHtmlAnnotationOptions {
|
|
|
129
136
|
* empty on recovery. Delivered in readOnly mode too: view-only surfaces
|
|
130
137
|
* are exactly where silently missing markers would go unnoticed. */
|
|
131
138
|
onUnanchoredChange?: (ids: string[]) => void;
|
|
139
|
+
/** Product cap on additional (shift-click) targets per comment, 0..16.
|
|
140
|
+
* Applied at the trust boundary, on submit, on restore, and carried to
|
|
141
|
+
* the bridge on arm-multi-select so the in-page toggle stops at the
|
|
142
|
+
* same number. Absent: the package's 16, and the arm message is unchanged. */
|
|
143
|
+
maxAdditionalTargets?: number;
|
|
144
|
+
/** scrollIntoView behavior for scroll-to (selecting an annotation).
|
|
145
|
+
* Absent: smooth, as before; pass 'auto' to honor reduced motion. */
|
|
146
|
+
scrollBehavior?: 'smooth' | 'auto';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Clamp a host cap into the package's bound; anything unusable is the default. */
|
|
150
|
+
export function resolveMaxAdditionalTargets(value: number | undefined): number {
|
|
151
|
+
if (value === undefined || !Number.isFinite(value)) return MAX_ADDITIONAL_TARGETS;
|
|
152
|
+
return Math.max(0, Math.min(MAX_ADDITIONAL_TARGETS, Math.floor(value)));
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
function postToIframe(
|
|
@@ -365,6 +386,8 @@ export function useHtmlAnnotation({
|
|
|
365
386
|
onPageChange,
|
|
366
387
|
onBridgePointer,
|
|
367
388
|
onUnanchoredChange,
|
|
389
|
+
maxAdditionalTargets,
|
|
390
|
+
scrollBehavior,
|
|
368
391
|
}: UseHtmlAnnotationOptions): Omit<
|
|
369
392
|
UseAnnotationHighlighterReturn,
|
|
370
393
|
"highlighterRef" | "highlightRange" | "highlightMathElement"
|
|
@@ -377,6 +400,13 @@ export function useHtmlAnnotation({
|
|
|
377
400
|
flashDraftTarget: (key: string) => void;
|
|
378
401
|
/** Bumped after every target add/remove so the composer can refocus its textarea. */
|
|
379
402
|
composerFocusToken: number;
|
|
403
|
+
/** Composer one-click "Looks good": submits the hardcoded positive label
|
|
404
|
+
* with the same anchor and multi-select targets a typed comment would carry. */
|
|
405
|
+
handleCommentLooksGood: () => void;
|
|
406
|
+
/** Ids this module minted for locally created annotations (create-mark),
|
|
407
|
+
* for the unanchored union: a minted id the host never listed is a
|
|
408
|
+
* swapped-out local mark, not a host row. Read-only, stable identity. */
|
|
409
|
+
createdAnnotationIds: ReadonlySet<string>;
|
|
380
410
|
} {
|
|
381
411
|
const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
|
|
382
412
|
const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
|
|
@@ -417,6 +447,12 @@ export function useHtmlAnnotation({
|
|
|
417
447
|
liveRef.current = live ?? null;
|
|
418
448
|
const onPageChangeRef = useRef(onPageChange);
|
|
419
449
|
onPageChangeRef.current = onPageChange;
|
|
450
|
+
// The effective cap and whether the host set one: only an explicit cap
|
|
451
|
+
// rides on arm-multi-select, so an unconfigured viewer posts today's message.
|
|
452
|
+
const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
|
|
453
|
+
maxTargetsRef.current = resolveMaxAdditionalTargets(maxAdditionalTargets);
|
|
454
|
+
const hostCapRef = useRef(maxAdditionalTargets !== undefined);
|
|
455
|
+
hostCapRef.current = maxAdditionalTargets !== undefined;
|
|
420
456
|
|
|
421
457
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
|
422
458
|
|
|
@@ -577,6 +613,7 @@ export function useHtmlAnnotation({
|
|
|
577
613
|
post({
|
|
578
614
|
type: `${PREFIX}arm-multi-select`,
|
|
579
615
|
key: message.targetKey,
|
|
616
|
+
...(hostCapRef.current ? { max: maxTargetsRef.current } : {}),
|
|
580
617
|
});
|
|
581
618
|
}
|
|
582
619
|
} else {
|
|
@@ -596,7 +633,7 @@ export function useHtmlAnnotation({
|
|
|
596
633
|
if (
|
|
597
634
|
commentPopoverRef.current
|
|
598
635
|
&& targets.length > 0
|
|
599
|
-
&& targets.length < 1 +
|
|
636
|
+
&& targets.length < 1 + maxTargetsRef.current
|
|
600
637
|
&& !targets.some((t) => t.key === message.key)
|
|
601
638
|
) {
|
|
602
639
|
setDraftTargets([
|
|
@@ -712,6 +749,9 @@ export function useHtmlAnnotation({
|
|
|
712
749
|
post({
|
|
713
750
|
type: `${PREFIX}scroll-to`,
|
|
714
751
|
id: selectedAnnotationId,
|
|
752
|
+
// Only an explicit host preference rides along; the default message
|
|
753
|
+
// is unchanged and the bridge scrolls smoothly as before.
|
|
754
|
+
...(scrollBehavior ? { behavior: scrollBehavior } : {}),
|
|
715
755
|
});
|
|
716
756
|
} else {
|
|
717
757
|
post({
|
|
@@ -719,7 +759,7 @@ export function useHtmlAnnotation({
|
|
|
719
759
|
id: null,
|
|
720
760
|
});
|
|
721
761
|
}
|
|
722
|
-
}, [selectedAnnotationId, post]);
|
|
762
|
+
}, [selectedAnnotationId, post, scrollBehavior]);
|
|
723
763
|
|
|
724
764
|
const handleAnnotate = useCallback(
|
|
725
765
|
(type: AnnotationType) => {
|
|
@@ -728,6 +768,7 @@ export function useHtmlAnnotation({
|
|
|
728
768
|
if (!text || type !== AnnotationType.DELETION) return;
|
|
729
769
|
|
|
730
770
|
const id = nextHtmlAnnId();
|
|
771
|
+
mintedHtmlAnnIds.add(id);
|
|
731
772
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
|
|
732
773
|
onAddRef.current?.({
|
|
733
774
|
id,
|
|
@@ -778,7 +819,7 @@ export function useHtmlAnnotation({
|
|
|
778
819
|
const targets = draftTargetsRef.current;
|
|
779
820
|
const additionalTargets: HtmlAnnotationTarget[] | undefined =
|
|
780
821
|
targets.length > 1
|
|
781
|
-
? targets.slice(1, 1 +
|
|
822
|
+
? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
|
|
782
823
|
label: t.label,
|
|
783
824
|
text: t.text,
|
|
784
825
|
anchor: t.anchor ?? undefined,
|
|
@@ -786,6 +827,7 @@ export function useHtmlAnnotation({
|
|
|
786
827
|
: undefined;
|
|
787
828
|
|
|
788
829
|
const id = nextHtmlAnnId();
|
|
830
|
+
mintedHtmlAnnIds.add(id);
|
|
789
831
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
790
832
|
onAddRef.current?.({
|
|
791
833
|
id,
|
|
@@ -810,6 +852,51 @@ export function useHtmlAnnotation({
|
|
|
810
852
|
[post],
|
|
811
853
|
);
|
|
812
854
|
|
|
855
|
+
// The composer's one-click "Looks good" (the restored thumbs-up for
|
|
856
|
+
// comment-only surfaces, where pinpoint clicks land straight in the
|
|
857
|
+
// composer and never see the selection toolbar). Mirrors
|
|
858
|
+
// handleCommentSubmit — same anchor, same multi-select targets — but
|
|
859
|
+
// emits the hardcoded positive label instead of typed prose.
|
|
860
|
+
const handleCommentLooksGood = useCallback(() => {
|
|
861
|
+
if (!enabledRef.current) return;
|
|
862
|
+
const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
|
|
863
|
+
if (!text) return;
|
|
864
|
+
|
|
865
|
+
const targets = draftTargetsRef.current;
|
|
866
|
+
const additionalTargets: HtmlAnnotationTarget[] | undefined =
|
|
867
|
+
targets.length > 1
|
|
868
|
+
? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
|
|
869
|
+
label: t.label,
|
|
870
|
+
text: t.text,
|
|
871
|
+
anchor: t.anchor ?? undefined,
|
|
872
|
+
}))
|
|
873
|
+
: undefined;
|
|
874
|
+
|
|
875
|
+
const id = nextHtmlAnnId();
|
|
876
|
+
mintedHtmlAnnIds.add(id);
|
|
877
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
878
|
+
onAddRef.current?.({
|
|
879
|
+
id,
|
|
880
|
+
blockId: "",
|
|
881
|
+
startOffset: 0,
|
|
882
|
+
endOffset: 0,
|
|
883
|
+
type: AnnotationType.COMMENT,
|
|
884
|
+
text: THUMBS_UP_LABEL.text,
|
|
885
|
+
originalText: text,
|
|
886
|
+
isQuickLabel: true,
|
|
887
|
+
quickLabelTip: THUMBS_UP_LABEL.tip,
|
|
888
|
+
author: getIdentity(),
|
|
889
|
+
createdA: Date.now(),
|
|
890
|
+
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
891
|
+
htmlAdditionalTargets: additionalTargets,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
setCommentPopover(null);
|
|
895
|
+
setDraftTargets([]);
|
|
896
|
+
pendingTextRef.current = "";
|
|
897
|
+
pendingAnchorRef.current = null;
|
|
898
|
+
}, [post]);
|
|
899
|
+
|
|
813
900
|
const handleCommentClose = useCallback(() => {
|
|
814
901
|
post({ type: `${PREFIX}cancel-selection` });
|
|
815
902
|
setCommentPopover(null);
|
|
@@ -846,6 +933,7 @@ export function useHtmlAnnotation({
|
|
|
846
933
|
const text = pendingTextRef.current;
|
|
847
934
|
if (!text) return;
|
|
848
935
|
const id = nextHtmlAnnId();
|
|
936
|
+
mintedHtmlAnnIds.add(id);
|
|
849
937
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
850
938
|
onAddRef.current?.({
|
|
851
939
|
id,
|
|
@@ -906,7 +994,7 @@ export function useHtmlAnnotation({
|
|
|
906
994
|
const additionalAnchors = (ann.htmlAdditionalTargets ?? [])
|
|
907
995
|
.map((t) => t.anchor)
|
|
908
996
|
.filter((a): a is HtmlElementAnchor => !!a)
|
|
909
|
-
.slice(0,
|
|
997
|
+
.slice(0, maxTargetsRef.current);
|
|
910
998
|
post({
|
|
911
999
|
type: `${PREFIX}find-and-mark`,
|
|
912
1000
|
id: ann.id,
|
|
@@ -931,6 +1019,7 @@ export function useHtmlAnnotation({
|
|
|
931
1019
|
handleToolbarClose,
|
|
932
1020
|
handleRequestComment,
|
|
933
1021
|
handleCommentSubmit,
|
|
1022
|
+
handleCommentLooksGood,
|
|
934
1023
|
handleCommentClose,
|
|
935
1024
|
handleFloatingQuickLabel,
|
|
936
1025
|
handleQuickLabelPickerDismiss,
|
|
@@ -941,5 +1030,6 @@ export function useHtmlAnnotation({
|
|
|
941
1030
|
removeDraftTarget,
|
|
942
1031
|
flashDraftTarget,
|
|
943
1032
|
composerFocusToken,
|
|
1033
|
+
createdAnnotationIds: mintedHtmlAnnIds,
|
|
944
1034
|
};
|
|
945
1035
|
}
|
package/configure.ts
CHANGED
|
@@ -8,6 +8,9 @@ import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDra
|
|
|
8
8
|
import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations';
|
|
9
9
|
import { setAITransport, type AITransport } from './hooks/useAIChat';
|
|
10
10
|
import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTransport, type SkillContentTransport } from './utils/skillCatalog';
|
|
11
|
+
import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
|
|
12
|
+
import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
|
|
13
|
+
import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
|
|
11
14
|
import { configStore } from './config';
|
|
12
15
|
import type { ServerSyncFn } from './config/configStore';
|
|
13
16
|
import type { ExternalAnnotationEvent, VaultNode } from './types';
|
|
@@ -31,6 +34,10 @@ export type {
|
|
|
31
34
|
SkillCatalogTransport,
|
|
32
35
|
SkillContentTransport,
|
|
33
36
|
ServerSyncFn,
|
|
37
|
+
WebMcpPolicy,
|
|
38
|
+
MathRenderer,
|
|
39
|
+
MathRendererLoader,
|
|
40
|
+
IdentityGenerator,
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
type ExternalAnnotationBase = { id: string; source?: string };
|
|
@@ -56,6 +63,28 @@ export interface PlannotatorUIConfig {
|
|
|
56
63
|
/** Human-only skill contents request for feedback injection. Default: `GET /api/skills/content?name=` on the page origin. */
|
|
57
64
|
skillContentTransport?: SkillContentTransport;
|
|
58
65
|
serverSync?: ServerSyncFn;
|
|
66
|
+
/**
|
|
67
|
+
* WebMCP provider policy: `{ enabled, namePrefix }`. Default: enabled
|
|
68
|
+
* whenever the browser exposes `document.modelContext`, with the
|
|
69
|
+
* `plannotator.` prefix. There is no confirmation seam because the catalog
|
|
70
|
+
* exposes nothing consequential: no tool decides, submits or closes.
|
|
71
|
+
*/
|
|
72
|
+
webmcp?: WebMcpPolicy;
|
|
73
|
+
/**
|
|
74
|
+
* How the math renderer is loaded when no renderer is registered before the
|
|
75
|
+
* first math node renders. Default: `import('katex')` (JS only; the
|
|
76
|
+
* stylesheet stays the host's job). A host that wants KaTeX and its CSS on
|
|
77
|
+
* one lazy chunk passes a loader that imports both. Hosts that want math
|
|
78
|
+
* typeset on the first commit instead import `@plannotator/ui/utils/math-eager`.
|
|
79
|
+
*/
|
|
80
|
+
mathRendererLoader?: MathRendererLoader;
|
|
81
|
+
/**
|
|
82
|
+
* Synchronous generator for the default "tater" display name, used only when
|
|
83
|
+
* no `identityProvider` is installed. Default: a small built-in pool of the
|
|
84
|
+
* same `adjective-noun-tater` shape. Plannotator registers the full
|
|
85
|
+
* dictionary by importing `@plannotator/ui/utils/identity-tater`.
|
|
86
|
+
*/
|
|
87
|
+
identityGenerator?: IdentityGenerator;
|
|
59
88
|
/** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
|
|
60
89
|
loadSettingsFromBackend?: boolean;
|
|
61
90
|
}
|
|
@@ -73,6 +102,9 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
|
|
|
73
102
|
if (config.skillCatalogTransport) setSkillCatalogTransport(config.skillCatalogTransport);
|
|
74
103
|
if (config.skillContentTransport) setSkillContentTransport(config.skillContentTransport);
|
|
75
104
|
if (config.serverSync) configStore.setServerSync(config.serverSync);
|
|
105
|
+
if (config.webmcp) setWebMcpPolicy(config.webmcp);
|
|
106
|
+
if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
|
|
107
|
+
if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
|
|
76
108
|
// Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
|
|
77
109
|
if (config.loadSettingsFromBackend) configStore.loadFromBackend();
|
|
78
110
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** What a host's `fetchSnapshot` resolves to. */
|
|
4
|
+
export type HtmlRefreshSnapshot =
|
|
5
|
+
| { status: 'ok'; rawHtml: string }
|
|
6
|
+
| { status: 'missing' }
|
|
7
|
+
| { status: 'unavailable' };
|
|
8
|
+
|
|
9
|
+
/** The outcome of one `refresh()` call, for host notifications (toasts). */
|
|
10
|
+
export type HtmlRefreshResult = 'refreshed' | 'missing' | 'unavailable';
|
|
11
|
+
|
|
12
|
+
export interface UseHtmlRefreshOptions {
|
|
13
|
+
/** Whether refresh is offered at all. Default true. */
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Identity of the document under refresh (a path, an id). A change
|
|
17
|
+
* cancels any in-flight fetch and any pending restore acknowledgement, so
|
|
18
|
+
* a snapshot for the previous document can never land on the next one.
|
|
19
|
+
* `null` means no document: `canRefresh` is false. Omit it when the host
|
|
20
|
+
* has a single document.
|
|
21
|
+
*/
|
|
22
|
+
documentKey?: string | null;
|
|
23
|
+
/** Fetch the current bytes of the document. Called with `documentKey`.
|
|
24
|
+
* A rejection is treated as `{ status: 'unavailable' }`. */
|
|
25
|
+
fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot>;
|
|
26
|
+
/** Apply the refreshed bytes (the host owns the viewer's `rawHtml`). */
|
|
27
|
+
onSnapshot: (rawHtml: string) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Once per refresh: the ids the remounted viewer could not re-anchor,
|
|
30
|
+
* possibly empty. Wire the viewer's `onUnanchoredChange` to the returned
|
|
31
|
+
* `reportAnnotationRestore`; only the first report after a refresh is
|
|
32
|
+
* forwarded, and only while the document and reload generation match.
|
|
33
|
+
*/
|
|
34
|
+
onUnanchored?: (ids: string[]) => void;
|
|
35
|
+
/** The outcome of each `refresh()` call that reached a decision. */
|
|
36
|
+
onResult?: (result: HtmlRefreshResult) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseHtmlRefreshReturn {
|
|
40
|
+
canRefresh: boolean;
|
|
41
|
+
isRefreshing: boolean;
|
|
42
|
+
/** Bumps after every applied snapshot. Key the viewer on it to remount. */
|
|
43
|
+
reloadGeneration: number;
|
|
44
|
+
refresh: () => Promise<void>;
|
|
45
|
+
/** Feed the viewer's `onUnanchoredChange` report here. */
|
|
46
|
+
reportAnnotationRestore: (missingIds: string[]) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Re-fetch a rendered HTML document from the host's source and remount the
|
|
51
|
+
* viewer on it, keeping the annotations the viewer can still anchor.
|
|
52
|
+
*
|
|
53
|
+
* Backend-agnostic: the host supplies `fetchSnapshot` (Plannotator wraps its
|
|
54
|
+
* `/api/doc` read; a host with a document store passes its own read). The
|
|
55
|
+
* hook owns the guards: an in-flight fetch that is superseded by a newer
|
|
56
|
+
* refresh, or by a document change, is dropped before `onSnapshot`; the
|
|
57
|
+
* restore acknowledgement is armed per reload generation and consumed by
|
|
58
|
+
* the first viewer report for that generation.
|
|
59
|
+
*/
|
|
60
|
+
export function useHtmlRefresh({
|
|
61
|
+
enabled = true,
|
|
62
|
+
documentKey,
|
|
63
|
+
fetchSnapshot,
|
|
64
|
+
onSnapshot,
|
|
65
|
+
onUnanchored,
|
|
66
|
+
onResult,
|
|
67
|
+
}: UseHtmlRefreshOptions): UseHtmlRefreshReturn {
|
|
68
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
69
|
+
const [reloadGeneration, setReloadGeneration] = useState(0);
|
|
70
|
+
const keyed = documentKey !== undefined;
|
|
71
|
+
const activeKey = keyed ? documentKey : null;
|
|
72
|
+
const activeKeyRef = useRef(activeKey);
|
|
73
|
+
const requestRef = useRef(0);
|
|
74
|
+
const reloadGenerationRef = useRef(0);
|
|
75
|
+
const restorePendingRef = useRef<{ key: string | null; generation: number } | null>(null);
|
|
76
|
+
const onUnanchoredRef = useRef(onUnanchored);
|
|
77
|
+
onUnanchoredRef.current = onUnanchored;
|
|
78
|
+
const onResultRef = useRef(onResult);
|
|
79
|
+
onResultRef.current = onResult;
|
|
80
|
+
const canRefresh = enabled && (!keyed || !!documentKey);
|
|
81
|
+
|
|
82
|
+
useLayoutEffect(() => {
|
|
83
|
+
if (activeKeyRef.current !== activeKey) {
|
|
84
|
+
requestRef.current += 1;
|
|
85
|
+
restorePendingRef.current = null;
|
|
86
|
+
setIsRefreshing(false);
|
|
87
|
+
}
|
|
88
|
+
activeKeyRef.current = activeKey;
|
|
89
|
+
}, [activeKey]);
|
|
90
|
+
|
|
91
|
+
const refresh = useCallback(async () => {
|
|
92
|
+
if (!canRefresh) return;
|
|
93
|
+
|
|
94
|
+
const requestKey = activeKey;
|
|
95
|
+
const requestId = ++requestRef.current;
|
|
96
|
+
setIsRefreshing(true);
|
|
97
|
+
try {
|
|
98
|
+
// A rejecting fetch is an unavailable snapshot: the host hears it
|
|
99
|
+
// through onResult like any other outcome, never as an unhandled
|
|
100
|
+
// rejection out of refresh().
|
|
101
|
+
let result: HtmlRefreshSnapshot;
|
|
102
|
+
try {
|
|
103
|
+
result = await fetchSnapshot(requestKey);
|
|
104
|
+
} catch {
|
|
105
|
+
result = { status: 'unavailable' };
|
|
106
|
+
}
|
|
107
|
+
if (requestId !== requestRef.current || activeKeyRef.current !== requestKey) return;
|
|
108
|
+
|
|
109
|
+
if (result.status === 'missing' || result.status === 'unavailable') {
|
|
110
|
+
onResultRef.current?.(result.status);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onSnapshot(result.rawHtml);
|
|
115
|
+
const nextGeneration = reloadGenerationRef.current + 1;
|
|
116
|
+
reloadGenerationRef.current = nextGeneration;
|
|
117
|
+
// Armed until the remounted viewer's bridge reports its restore. The
|
|
118
|
+
// bridge emits "unanchored" only when the set CHANGES from its initial
|
|
119
|
+
// empty state, so a pass that restores everything never posts and this
|
|
120
|
+
// stays armed; that is harmless because the next refresh replaces it
|
|
121
|
+
// and a document change clears it.
|
|
122
|
+
restorePendingRef.current = { key: requestKey, generation: nextGeneration };
|
|
123
|
+
setReloadGeneration(nextGeneration);
|
|
124
|
+
onResultRef.current?.('refreshed');
|
|
125
|
+
} finally {
|
|
126
|
+
if (requestId === requestRef.current) setIsRefreshing(false);
|
|
127
|
+
}
|
|
128
|
+
}, [activeKey, canRefresh, fetchSnapshot, onSnapshot]);
|
|
129
|
+
|
|
130
|
+
const reportAnnotationRestore = useCallback((missingIds: string[]) => {
|
|
131
|
+
const pending = restorePendingRef.current;
|
|
132
|
+
if (
|
|
133
|
+
!pending ||
|
|
134
|
+
pending.key !== activeKeyRef.current ||
|
|
135
|
+
pending.generation !== reloadGenerationRef.current
|
|
136
|
+
) return;
|
|
137
|
+
|
|
138
|
+
restorePendingRef.current = null;
|
|
139
|
+
onUnanchoredRef.current?.(missingIds);
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
canRefresh,
|
|
144
|
+
isRefreshing,
|
|
145
|
+
reloadGeneration,
|
|
146
|
+
refresh,
|
|
147
|
+
reportAnnotationRestore,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
getMathRenderer,
|
|
4
|
+
loadMathRenderer,
|
|
5
|
+
subscribeMathRenderer,
|
|
6
|
+
type MathRenderer,
|
|
7
|
+
} from '../utils/math';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The registered math renderer, read synchronously during render.
|
|
11
|
+
*
|
|
12
|
+
* With the slot filled before mount (Plannotator: `utils/math-eager`) this
|
|
13
|
+
* returns KaTeX on the first render and the effect below is a no-op, so the
|
|
14
|
+
* typeset HTML is in the first commit. With the slot empty it returns `null`,
|
|
15
|
+
* kicks off `loadMathRenderer()` from an effect, and the subscription
|
|
16
|
+
* re-renders the caller once the renderer lands. A rejected load is left to
|
|
17
|
+
* the loader's retry contract; the caller keeps showing the TeX placeholder.
|
|
18
|
+
*/
|
|
19
|
+
export function useMathRenderer(): MathRenderer | null {
|
|
20
|
+
const renderer = useSyncExternalStore(subscribeMathRenderer, getMathRenderer, getMathRenderer);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (renderer) return;
|
|
24
|
+
loadMathRenderer().catch(() => {
|
|
25
|
+
/* placeholder stays; the next mount retries */
|
|
26
|
+
});
|
|
27
|
+
}, [renderer]);
|
|
28
|
+
|
|
29
|
+
return renderer;
|
|
30
|
+
}
|