@plannotator/ui 0.31.0 → 0.33.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 +664 -0
- package/README.md +57 -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 +289 -6
- package/components/html-viewer/bridge-script.asset.js +4392 -0
- package/components/html-viewer/bridge-script.lite.ts +9 -0
- package/components/html-viewer/bridge-script.ts +54 -8
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +22 -1
- package/components/html-viewer/srcdoc.ts +70 -1
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +132 -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 +9 -3
- 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-default-loader.ts +24 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +149 -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
package/README.md
CHANGED
|
@@ -27,6 +27,9 @@ configurePlannotatorUI({
|
|
|
27
27
|
skillCatalogTransport, // skill-reference catalog for comment composers
|
|
28
28
|
skillContentTransport, // human-only skill contents for feedback injection
|
|
29
29
|
serverSync,
|
|
30
|
+
webmcp, // browser-agent (WebMCP) provider policy: { enabled, namePrefix }
|
|
31
|
+
mathRendererLoader, // how KaTeX loads when no renderer is registered before first math render
|
|
32
|
+
identityGenerator, // sync generator behind the default "tater" name (no identityProvider)
|
|
30
33
|
});
|
|
31
34
|
```
|
|
32
35
|
|
|
@@ -47,6 +50,16 @@ The sidebar/panel resize handle exposes seams for hosts that want different edge
|
|
|
47
50
|
|
|
48
51
|
Building your own tooltip and removing the built-in double-click reset are host-side concerns (override `onDoubleClick` where you render the handle).
|
|
49
52
|
|
|
53
|
+
### Lazy renderers and the eager entries (`utils/math`, `utils/generateIdentity`, `utils/mermaid`; 0.32.0)
|
|
54
|
+
|
|
55
|
+
The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:
|
|
56
|
+
|
|
57
|
+
- **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias.
|
|
58
|
+
- **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
|
|
59
|
+
- **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
|
|
60
|
+
|
|
61
|
+
Plannotator's own entries import the eager modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`; `mermaid-eager` in the plan editor only, since the review editor never renders a Mermaid block), which is what keeps its single-file builds byte-identical and its portal entry chunk shaped as before; `tests/entry-assets.test.ts` fails if any of them is dropped. See HANDOFF.md "Lazy renderers and eager entries".
|
|
62
|
+
|
|
50
63
|
### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`)
|
|
51
64
|
|
|
52
65
|
- **`MarkdownEditor` takes CM6 extensions.** `extensions?: readonly Extension[]` (from `@codemirror/state`) is forwarded verbatim into the underlying editor — the seam for `wikiLinks(config)`, `y-codemirror.next` collab bindings, custom keymaps.
|
|
@@ -85,6 +98,49 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
|
|
|
85
98
|
|
|
86
99
|
`components/html-viewer` is supported host surface as of 0.29.0: the overlay-projection annotation viewer for raw HTML (placed comment markers, pinpoint element anchors, shift-click multi-target comments). Its contract is **props plus the validated iframe message protocol** — not `configurePlannotatorUI()`, which only governs the backend surfaces around it. Drive the `annotations` prop (marker numbering derives from its array order); `readOnly` keeps markers painted and clickable while disabling all authoring. **0.29.0 also carries a breaking migration:** highlight.js is gone and `.hljs` selectors are inert — style code via the exported `pn-code` class (`CODE_BLOCK_CLASS` in `utils/codeHighlight`). See HANDOFF.md § "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)" before upgrading from 0.28.0.
|
|
87
100
|
|
|
101
|
+
#### HTML annotation parity seams (0.32.0)
|
|
102
|
+
|
|
103
|
+
Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui:
|
|
104
|
+
|
|
105
|
+
- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; on the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors by whole-document text search, but with `blockId` `""` and offsets `0` it loses export ordering (every such row sorts first and ties), the "lines N-M" location label, disambiguation when the same text repeats (first match wins), and the no-flash meta restore; a host that needs those carries `blockId`, the offsets and the web-highlighter metas in its own projection.
|
|
106
|
+
- **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. **Nothing is delivered before the bridge's first post-restore report for a document (per reload generation):** a prop-side change before that point does not fire the callback, so do not gate host state on a prop-side delivery arriving first; treat the first call as the restore's verdict.
|
|
107
|
+
- **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`.
|
|
108
|
+
- **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides.
|
|
109
|
+
- **`AnnotationPanel` `unanchoredIds`**: an "Unanchored" chip on the listed cards.
|
|
110
|
+
- **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too). With the cap enforced upstream (bridge toggle, parent trust boundary on submit and on restore, `projectHostThreads` `maxTargets` on read), a composed comment never reaches the host with more targets than the cap, so a host's own cap-dropped handling (`capDroppedTargets` from `buildPersistedHtmlAnchor`, or a message-counting listener) is unreachable in normal operation; keep it only as a backstop for rows written by an older host build or by another writer. Byte-budget drops (`sizeDroppedTargets`) are a separate path and remain reachable.
|
|
111
|
+
- An `ExternalAnnotationTransport` whose `subscribe` emits `snapshot` on a host push keeps `useExternalAnnotations` off its fallback poll.
|
|
112
|
+
|
|
113
|
+
See HANDOFF.md § "HTML annotation parity seams".
|
|
114
|
+
|
|
115
|
+
#### The bridge script as an asset (`bridgeScriptUrl`; 0.33.0)
|
|
116
|
+
|
|
117
|
+
By default `HtmlViewer` inlines its 185 KB in-page bridge script into every srcdoc document. A host that serves the package's generated `components/html-viewer/bridge-script.asset.js` as a static file can pass its URL instead:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";
|
|
121
|
+
|
|
122
|
+
<HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The srcdoc then carries one classic `<script src>` in the exact place the inline script sat (at the end of `<head>`, before the body), the browser caches the asset across documents, and the bridge's `ready` message carries `BRIDGE_PROTOCOL_VERSION`, which the viewer checks: a stale cached asset (no stamp, or another version) logs one console warning naming both versions and shows a dismissible error banner in the surface (`onBridgeUnavailable` fires too); no `ready` within `bridgeReadyTimeoutMs` (default 5000) shows a timeout banner. The URL is resolved against your document's base (`document.baseURI`) before it is written into the srcdoc, never against the framed page, so a page's own `<base href>` cannot redirect it. Plannotator passes nothing and stays inline; none of this runs on the inline path. **CSP:** the package sets no CSP `<meta>` in the srcdoc document, and the frame is an opaque origin so the classic script needs no CORS (no `crossorigin` is set), but a CSP delivered as a header on your page is inherited by the frame: allow `script-src` for the asset's origin. Because the frame is an opaque origin, an asset served with `Cross-Origin-Resource-Policy: same-origin` (common alongside COEP) is blocked; serve it with a CORP that admits cross-origin loads, or without CORP. To also drop the inline literal from your viewer chunk, alias the package's `./bridge-script` resolution to the generated `bridge-script.lite` module (see HANDOFF.md § "HTML viewer bridge as an asset").
|
|
126
|
+
|
|
127
|
+
#### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
|
|
128
|
+
|
|
129
|
+
- **`@plannotator/ui/shortcuts`**: the declarative keyboard-shortcut engine (`defineShortcutScope`, `useShortcutScope`) and the per-surface scopes, including `useHtmlAnnotateShortcuts` for the Mod+Shift+A Annotate/Interact chord on HTML surfaces. Pure React plus `utils/platform`; no backend.
|
|
130
|
+
- **`@plannotator/ui/utils/inputMethod`**: `getInputMethod(surface)` / `saveInputMethod(method, surface)` / `refreshInputMethodStamp(method)`, the per-surface pinpoint-or-drag preference with its TTL, persisted through the `storageBackend` seam.
|
|
131
|
+
|
|
132
|
+
### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
|
|
133
|
+
|
|
134
|
+
The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
|
|
135
|
+
|
|
136
|
+
- `modelContext.ts` is the only file that spells the spec surface (local structural types, no `webmcp-types` dependency). A spec rename is a one-file change.
|
|
137
|
+
- `useToolset({ id, active, build, deps, hooks })` attaches a named tool set to the document registry; handlers read through refs, so re-renders never re-register, and `active: false` aborts every registration (what Plannotator's Settings opt-out drives).
|
|
138
|
+
- `AnnotationChangeTracker` / `buildNudges` are pure (no DOM): per-annotation `seq`, tombstones, a per-tab watermark with `since` override, and the nudge vocabulary every response carries.
|
|
139
|
+
- A host with its own document state builds the same adapter-driven catalog Plannotator uses (`packages/editor/webmcp/documentTools.ts`, `buildDocumentTools(adapter, state, options)`) over its own getters and actions; multi-document pages should register one set whose tools take `path` (the folder-session shape) rather than one set per viewer (duplicate names across sets are skipped with a warning, never replaced).
|
|
140
|
+
- Never register tools inside an untrusted iframe: the raw-HTML viewer's `sandbox="allow-scripts"` frame and the live-app frame carry no `allow="tools"`, and that is what keeps a framed page from impersonating the host's tools.
|
|
141
|
+
|
|
142
|
+
The one additive data-model change that rides with it: `Annotation.inReplyTo` (threaded replies; the panel indents them under the parent, the export nests them, share links drop them).
|
|
143
|
+
|
|
88
144
|
## Consuming it (e.g. from Workspaces)
|
|
89
145
|
|
|
90
146
|
```bash
|
|
@@ -108,7 +164,7 @@ npm install @plannotator/ui @plannotator/core
|
|
|
108
164
|
- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
|
|
109
165
|
- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
|
|
110
166
|
- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
|
|
111
|
-
- Versioned
|
|
167
|
+
- Versioned together (currently `@plannotator/ui` 0.33.0 on `@plannotator/core` 0.25.0). `core` is bumped only when something under `packages/core` changed, so `ui` can advance alone: 0.33.0 is such a release, published on the already available core 0.25.0. When both change, publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
|
|
112
168
|
|
|
113
169
|
## The one rule
|
|
114
170
|
|
|
@@ -38,6 +38,52 @@ const TrashCardIcon = () => (
|
|
|
38
38
|
</svg>
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Order annotations so every reply follows its parent (replies among
|
|
43
|
+
* themselves stay in creation order). A reply whose parent is absent renders
|
|
44
|
+
* as a top-level card. Without any `inReplyTo` the input order is returned
|
|
45
|
+
* unchanged, so annotations without replies render exactly as before.
|
|
46
|
+
*/
|
|
47
|
+
export function threadReplies(sorted: Annotation[]): Array<{ annotation: Annotation; isReply: boolean }> {
|
|
48
|
+
if (!sorted.some((a) => a.inReplyTo)) return sorted.map((annotation) => ({ annotation, isReply: false }));
|
|
49
|
+
const ids = new Set(sorted.map((a) => a.id));
|
|
50
|
+
const byParent = new Map<string, Annotation[]>();
|
|
51
|
+
for (const a of sorted) {
|
|
52
|
+
if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) {
|
|
53
|
+
const list = byParent.get(a.inReplyTo) ?? [];
|
|
54
|
+
list.push(a);
|
|
55
|
+
byParent.set(a.inReplyTo, list);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const out: Array<{ annotation: Annotation; isReply: boolean }> = [];
|
|
59
|
+
const emitted = new Set<string>();
|
|
60
|
+
const emit = (a: Annotation, isReply: boolean) => {
|
|
61
|
+
if (emitted.has(a.id)) return;
|
|
62
|
+
emitted.add(a.id);
|
|
63
|
+
out.push({ annotation: a, isReply });
|
|
64
|
+
for (const reply of byParent.get(a.id) ?? []) emit(reply, true);
|
|
65
|
+
};
|
|
66
|
+
for (const a of sorted) {
|
|
67
|
+
if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) continue;
|
|
68
|
+
emit(a, false);
|
|
69
|
+
}
|
|
70
|
+
for (const a of sorted) emit(a, false);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Timeline position of an annotation: its own time, or its thread root's for replies. */
|
|
75
|
+
function threadTs(annotation: Annotation, all: Annotation[]): number {
|
|
76
|
+
let current = annotation;
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
while (current.inReplyTo && !seen.has(current.id)) {
|
|
79
|
+
seen.add(current.id);
|
|
80
|
+
const parent = all.find((a) => a.id === current.inReplyTo);
|
|
81
|
+
if (!parent) break;
|
|
82
|
+
current = parent;
|
|
83
|
+
}
|
|
84
|
+
return current.createdA;
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
interface DirectEditsPanelItem {
|
|
42
88
|
id: string;
|
|
43
89
|
title?: string;
|
|
@@ -89,6 +135,10 @@ interface PanelProps {
|
|
|
89
135
|
/** Embed only the timeline body in a host-owned stage. The host owns the
|
|
90
136
|
* title, close control, visible-viewport geometry, and focus boundary. */
|
|
91
137
|
presentation?: 'panel' | 'embedded';
|
|
138
|
+
/** Ids of annotations with no live location in the document (e.g. the
|
|
139
|
+
* HTML viewer's onUnanchoredChange report after a refresh). Matching
|
|
140
|
+
* cards show a small "Unanchored" chip. Absent: no chip, DOM unchanged. */
|
|
141
|
+
unanchoredIds?: ReadonlySet<string>;
|
|
92
142
|
}
|
|
93
143
|
|
|
94
144
|
export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
@@ -115,6 +165,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
115
165
|
renderCardFooter,
|
|
116
166
|
readOnly = false,
|
|
117
167
|
presentation = 'panel',
|
|
168
|
+
unanchoredIds,
|
|
118
169
|
}) => {
|
|
119
170
|
const isMobile = useIsMobile();
|
|
120
171
|
const embedded = presentation === 'embedded';
|
|
@@ -123,10 +174,19 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
123
174
|
const listRef = useRef<HTMLDivElement>(null);
|
|
124
175
|
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
|
|
125
176
|
const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt);
|
|
177
|
+
// Replies (`inReplyTo`) thread under their parent: each reply is lifted to
|
|
178
|
+
// sit right after its parent (and the parent's earlier replies) at the
|
|
179
|
+
// parent's timeline position. With no replies the order is untouched.
|
|
180
|
+
const threadedAnnotations = threadReplies(sortedAnnotations);
|
|
126
181
|
const timelineEntries = [
|
|
127
|
-
...
|
|
128
|
-
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation })),
|
|
129
|
-
].sort((a, b) =>
|
|
182
|
+
...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, annotation, isReply })),
|
|
183
|
+
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation, isReply: false })),
|
|
184
|
+
].sort((a, b) => {
|
|
185
|
+
const ta = a.kind === 'plan' ? threadTs(a.annotation, sortedAnnotations) : a.ts;
|
|
186
|
+
const tb = b.kind === 'plan' ? threadTs(b.annotation, sortedAnnotations) : b.ts;
|
|
187
|
+
if (ta !== tb) return ta - tb;
|
|
188
|
+
return a.ts - b.ts;
|
|
189
|
+
});
|
|
130
190
|
const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
|
|
131
191
|
|
|
132
192
|
// Scroll selected annotation card into view
|
|
@@ -221,6 +281,25 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
221
281
|
<>
|
|
222
282
|
{timelineEntries.map(entry => (
|
|
223
283
|
entry.kind === 'plan' ? (
|
|
284
|
+
entry.isReply ? (
|
|
285
|
+
<div
|
|
286
|
+
key={entry.annotation.id}
|
|
287
|
+
data-annotation-reply="true"
|
|
288
|
+
className="ml-3 border-l-2 border-border/40 pl-1.5"
|
|
289
|
+
>
|
|
290
|
+
<AnnotationCard
|
|
291
|
+
annotation={entry.annotation}
|
|
292
|
+
isSelected={selectedId === entry.annotation.id}
|
|
293
|
+
isMe={isCurrentUser(entry.annotation.author)}
|
|
294
|
+
onSelect={() => onSelect(entry.annotation.id)}
|
|
295
|
+
onDelete={() => onDelete(entry.annotation.id)}
|
|
296
|
+
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
|
|
297
|
+
readOnly={readOnly}
|
|
298
|
+
footer={renderCardFooter?.(entry.annotation)}
|
|
299
|
+
unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
) : (
|
|
224
303
|
<AnnotationCard
|
|
225
304
|
key={entry.annotation.id}
|
|
226
305
|
annotation={entry.annotation}
|
|
@@ -231,7 +310,9 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
231
310
|
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
|
|
232
311
|
readOnly={readOnly}
|
|
233
312
|
footer={renderCardFooter?.(entry.annotation)}
|
|
313
|
+
unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
|
|
234
314
|
/>
|
|
315
|
+
)
|
|
235
316
|
) : (
|
|
236
317
|
<CodeAnnotationCard
|
|
237
318
|
key={entry.annotation.id}
|
|
@@ -458,7 +539,9 @@ const AnnotationCard: React.FC<{
|
|
|
458
539
|
onEdit?: (updates: Partial<Annotation>) => void;
|
|
459
540
|
readOnly?: boolean;
|
|
460
541
|
footer?: React.ReactNode;
|
|
461
|
-
|
|
542
|
+
/** The annotation has no live location in the document (host-reported). */
|
|
543
|
+
unanchored?: boolean;
|
|
544
|
+
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, unanchored = false }) => {
|
|
462
545
|
const [isEditing, setIsEditing] = useState(false);
|
|
463
546
|
const [editText, setEditText] = useState(annotation.text || '');
|
|
464
547
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -563,6 +646,15 @@ const AnnotationCard: React.FC<{
|
|
|
563
646
|
{annotation.pageUrl}
|
|
564
647
|
</span>
|
|
565
648
|
)}
|
|
649
|
+
{unanchored && (
|
|
650
|
+
<span
|
|
651
|
+
data-annotation-unanchored="true"
|
|
652
|
+
className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground"
|
|
653
|
+
title="This comment no longer matches a location in the document"
|
|
654
|
+
>
|
|
655
|
+
Unanchored
|
|
656
|
+
</span>
|
|
657
|
+
)}
|
|
566
658
|
<span className="text-[10px] text-muted-foreground/50 truncate">
|
|
567
659
|
{annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
|
|
568
660
|
</span>
|
|
@@ -2,20 +2,13 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
|
|
|
2
2
|
import { AnnotationType } from "../types";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
4
|
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
|
|
5
|
-
import { type QuickLabel, getQuickLabels } from "../utils/quickLabels";
|
|
5
|
+
import { type QuickLabel, getQuickLabels, THUMBS_UP_LABEL } from "../utils/quickLabels";
|
|
6
6
|
import { copyTextToClipboard } from "../utils/clipboard";
|
|
7
7
|
import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
|
|
8
8
|
import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
|
|
9
9
|
|
|
10
10
|
type PositionMode = 'center-above' | 'top-right';
|
|
11
11
|
|
|
12
|
-
const THUMBS_UP_LABEL: QuickLabel = {
|
|
13
|
-
id: 'thumbs-up',
|
|
14
|
-
emoji: '👍',
|
|
15
|
-
text: 'Looks good',
|
|
16
|
-
color: 'green',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
12
|
const isEditableElement = (node: EventTarget | Element | null): boolean => {
|
|
20
13
|
if (!(node instanceof Element)) return false;
|
|
21
14
|
if (node.matches('input, textarea, select, [role="textbox"]')) return true;
|
|
@@ -34,9 +27,11 @@ interface AnnotationToolbarProps {
|
|
|
34
27
|
onQuickLabel?: (label: QuickLabel) => void;
|
|
35
28
|
/** Text to copy when the button is clicked */
|
|
36
29
|
copyText?: string;
|
|
37
|
-
/** Comment-only surfaces (HTML / live-app viewer): hide the Delete action
|
|
38
|
-
*
|
|
39
|
-
*
|
|
30
|
+
/** Comment-only surfaces (HTML / live-app viewer): hide the Delete action,
|
|
31
|
+
* the quick-label picker, and the Alt+digit label shortcuts. A provided
|
|
32
|
+
* onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button —
|
|
33
|
+
* the one label affordance restored to these surfaces. Markdown surfaces
|
|
34
|
+
* keep the full toolbar. */
|
|
40
35
|
commentOnly?: boolean;
|
|
41
36
|
/** Hide the copy button (set when a keyboard copy handler exists) */
|
|
42
37
|
hideCopyButton?: boolean;
|
|
@@ -132,14 +127,17 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
132
127
|
return;
|
|
133
128
|
}
|
|
134
129
|
|
|
135
|
-
// Alt+N applies quick label (picker closed)
|
|
130
|
+
// Alt+N applies quick label (picker closed). Comment-only surfaces
|
|
131
|
+
// suppress this path: their only label affordance is the 👍 button.
|
|
136
132
|
const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
|
|
137
133
|
if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
|
|
138
134
|
e.preventDefault();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
135
|
+
if (!commentOnly) {
|
|
136
|
+
const digit = parseInt(e.code.slice(5), 10);
|
|
137
|
+
const index = digit === 0 ? 9 : digit - 1;
|
|
138
|
+
if (index < quickLabels.length) {
|
|
139
|
+
onQuickLabel?.(quickLabels[index]);
|
|
140
|
+
}
|
|
143
141
|
}
|
|
144
142
|
return;
|
|
145
143
|
}
|
|
@@ -160,7 +158,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
160
158
|
window.removeEventListener("keydown", handleKeyDown);
|
|
161
159
|
releaseCapture();
|
|
162
160
|
};
|
|
163
|
-
}, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels]);
|
|
161
|
+
}, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels, commentOnly]);
|
|
164
162
|
|
|
165
163
|
useDismissOnOutsideAndEscape({
|
|
166
164
|
enabled: !showQuickLabels,
|
|
@@ -238,20 +236,22 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
238
236
|
/>
|
|
239
237
|
{onQuickLabel && (
|
|
240
238
|
<>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
239
|
+
{!commentOnly && (
|
|
240
|
+
<ToolbarButton
|
|
241
|
+
ref={zapButtonRef}
|
|
242
|
+
onClick={() => setShowQuickLabels(prev => !prev)}
|
|
243
|
+
icon={<ZapIcon />}
|
|
244
|
+
label="Quick label"
|
|
245
|
+
className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
|
|
246
|
+
/>
|
|
247
|
+
)}
|
|
248
248
|
<ToolbarButton
|
|
249
249
|
onClick={() => onQuickLabel(THUMBS_UP_LABEL)}
|
|
250
250
|
icon={<span className="block w-4 h-4 text-sm leading-4 text-center">👍</span>}
|
|
251
251
|
label="Looks good"
|
|
252
252
|
className="hover:bg-green-500/10"
|
|
253
253
|
/>
|
|
254
|
-
{showQuickLabels && zapButtonRef.current && (
|
|
254
|
+
{!commentOnly && showQuickLabels && zapButtonRef.current && (
|
|
255
255
|
<FloatingQuickLabelPicker
|
|
256
256
|
anchorEl={zapButtonRef.current}
|
|
257
257
|
onSelect={(label) => {
|
|
@@ -53,6 +53,14 @@ interface CommentPopoverProps {
|
|
|
53
53
|
initialText?: string;
|
|
54
54
|
/** Called on submit with comment text and optional images */
|
|
55
55
|
onSubmit: (text: string, images?: ImageAttachment[]) => void;
|
|
56
|
+
/**
|
|
57
|
+
* One-click "Looks good" action (comment-only HTML/live surfaces, where
|
|
58
|
+
* pinpoint clicks open this composer directly and never see the selection
|
|
59
|
+
* toolbar's 👍). Renders a thumbs-up button in the footer; disabled once
|
|
60
|
+
* the user has typed or attached anything, so a click can never discard a
|
|
61
|
+
* draft. The parent owns annotation creation and closing.
|
|
62
|
+
*/
|
|
63
|
+
onQuickLookGood?: () => void;
|
|
56
64
|
/** Optional live draft observer for submit paths outside the popover. */
|
|
57
65
|
onDraftChange?: (text: string, images?: ImageAttachment[]) => void;
|
|
58
66
|
/** Called when popover is closed/cancelled */
|
|
@@ -148,6 +156,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
148
156
|
isGlobal,
|
|
149
157
|
initialText = '',
|
|
150
158
|
onSubmit,
|
|
159
|
+
onQuickLookGood,
|
|
151
160
|
onDraftChange,
|
|
152
161
|
onClose,
|
|
153
162
|
draftKey,
|
|
@@ -521,6 +530,21 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
521
530
|
(allowEmptySubmit && initialText.trim().length > 0);
|
|
522
531
|
const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0;
|
|
523
532
|
|
|
533
|
+
// Shared by both footers. Disabled once anything is typed or attached so a
|
|
534
|
+
// click can never discard a draft; with content present, Save is the path.
|
|
535
|
+
const quickLookGoodButton = onQuickLookGood ? (
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={onQuickLookGood}
|
|
539
|
+
disabled={hasUnsavedContent}
|
|
540
|
+
className="inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:text-foreground hover:bg-green-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
541
|
+
title={hasUnsavedContent ? 'Clear the comment to use Looks good' : 'Add "Looks good" without typing'}
|
|
542
|
+
>
|
|
543
|
+
<span aria-hidden="true">👍</span>
|
|
544
|
+
Looks good
|
|
545
|
+
</button>
|
|
546
|
+
) : null;
|
|
547
|
+
|
|
524
548
|
if (mode === 'dialog') {
|
|
525
549
|
return createPortal(
|
|
526
550
|
<div
|
|
@@ -632,6 +656,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
632
656
|
{!coarsePointer && (
|
|
633
657
|
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
634
658
|
)}
|
|
659
|
+
{quickLookGoodButton}
|
|
635
660
|
{onAskAI && (
|
|
636
661
|
<button
|
|
637
662
|
onClick={handleAskAI}
|
|
@@ -781,6 +806,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
781
806
|
{!coarsePointer && (
|
|
782
807
|
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
783
808
|
)}
|
|
809
|
+
{quickLookGoodButton}
|
|
784
810
|
{onAskAI && (
|
|
785
811
|
<button
|
|
786
812
|
onClick={handleAskAI}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
-
import {
|
|
3
|
+
import type { Viz } from '@viz-js/viz';
|
|
4
4
|
import type { Block } from '../types';
|
|
5
5
|
|
|
6
6
|
interface ViewBox {
|
|
@@ -14,13 +14,52 @@ const ZOOM_STEP = 0.25;
|
|
|
14
14
|
const MIN_ZOOM = 0.25;
|
|
15
15
|
const MAX_ZOOM = 8;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The Graphviz engine (about 1.2 MB of Emscripten JS) is imported inside the
|
|
19
|
+
* render effect, not statically, so a host that bundles by route only fetches
|
|
20
|
+
* it when a dot fence is on the page. WASM instantiation was already deferred
|
|
21
|
+
* to first render; in Plannotator's single-file builds the import is inlined
|
|
22
|
+
* and resolves in a microtask ahead of a render that was already asynchronous.
|
|
23
|
+
*/
|
|
24
|
+
const loadVizInstance = (): Promise<Viz> => import('@viz-js/viz').then((m) => m.instance());
|
|
25
|
+
|
|
26
|
+
let vizLoader = loadVizInstance;
|
|
27
|
+
let vizInstancePromise: Promise<Viz> | null = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Delay before the one automatic re-attempt after a failed engine import.
|
|
31
|
+
* Only chunking hosts can fail here (a single-file build never fetches).
|
|
32
|
+
*/
|
|
33
|
+
let runtimeRetryDelayMs = 750;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Memoized engine. A rejected load is dropped from the memo so the next call
|
|
37
|
+
* (the automatic re-attempt, a later mount, or the Retry button) issues a
|
|
38
|
+
* fresh import() instead of replaying the cached rejection.
|
|
39
|
+
*/
|
|
40
|
+
function getVizInstance(): Promise<Viz> {
|
|
41
|
+
if (!vizInstancePromise) {
|
|
42
|
+
const attempt = vizLoader().catch((err: unknown) => {
|
|
43
|
+
if (vizInstancePromise === attempt) vizInstancePromise = null;
|
|
44
|
+
throw err;
|
|
45
|
+
});
|
|
46
|
+
vizInstancePromise = attempt;
|
|
47
|
+
}
|
|
21
48
|
return vizInstancePromise;
|
|
22
49
|
}
|
|
23
50
|
|
|
51
|
+
/** Test hook: stand in for the engine import and shorten the retry delay. */
|
|
52
|
+
export function __setVizLoaderForTests(
|
|
53
|
+
loader: (() => Promise<Viz>) | undefined,
|
|
54
|
+
options?: { retryDelayMs?: number },
|
|
55
|
+
): void {
|
|
56
|
+
vizLoader = loader ?? loadVizInstance;
|
|
57
|
+
vizInstancePromise = null;
|
|
58
|
+
runtimeRetryDelayMs = options?.retryDelayMs ?? 750;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
62
|
+
|
|
24
63
|
function parseViewBox(svgEl: SVGSVGElement): ViewBox | null {
|
|
25
64
|
const raw = svgEl.getAttribute('viewBox');
|
|
26
65
|
if (!raw) return null;
|
|
@@ -107,6 +146,11 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
107
146
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
108
147
|
const [svg, setSvg] = useState('');
|
|
109
148
|
const [error, setError] = useState<string | null>(null);
|
|
149
|
+
// True when the failure was the engine import itself (a chunking host's
|
|
150
|
+
// fetch), which is the only failure a Retry can change; a dot syntax error
|
|
151
|
+
// keeps the panel exactly as it always was.
|
|
152
|
+
const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
|
|
153
|
+
const [retryToken, setRetryToken] = useState(0);
|
|
110
154
|
const [showSource, setShowSource] = useState(false);
|
|
111
155
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
112
156
|
|
|
@@ -158,8 +202,28 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
158
202
|
let cancelled = false;
|
|
159
203
|
|
|
160
204
|
const renderDiagram = async () => {
|
|
205
|
+
let viz: Viz;
|
|
206
|
+
try {
|
|
207
|
+
try {
|
|
208
|
+
viz = await getVizInstance();
|
|
209
|
+
} catch {
|
|
210
|
+
// Transient chunk failure on a chunking host: one automatic
|
|
211
|
+
// re-attempt with a fresh import() after a short delay. In a
|
|
212
|
+
// single-file build the first await never rejects, so this branch
|
|
213
|
+
// is unreachable there and the success path is unchanged.
|
|
214
|
+
await wait(runtimeRetryDelayMs);
|
|
215
|
+
if (cancelled) return;
|
|
216
|
+
viz = await getVizInstance();
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (!cancelled) {
|
|
220
|
+
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
|
221
|
+
setRuntimeUnavailable(true);
|
|
222
|
+
setSvg('');
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
161
226
|
try {
|
|
162
|
-
const viz = await getVizInstance();
|
|
163
227
|
const renderedSvg = await viz.renderString(block.content, { format: 'svg' });
|
|
164
228
|
const cleaned = renderedSvg
|
|
165
229
|
.replace(/ width="[^"]*"/, ' width="100%"')
|
|
@@ -177,10 +241,12 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
177
241
|
naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned);
|
|
178
242
|
setSvg(cleaned);
|
|
179
243
|
setError(null);
|
|
244
|
+
setRuntimeUnavailable(false);
|
|
180
245
|
}
|
|
181
246
|
} catch (err) {
|
|
182
247
|
if (!cancelled) {
|
|
183
248
|
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
|
249
|
+
setRuntimeUnavailable(false);
|
|
184
250
|
setSvg('');
|
|
185
251
|
}
|
|
186
252
|
}
|
|
@@ -191,7 +257,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
191
257
|
return () => {
|
|
192
258
|
cancelled = true;
|
|
193
259
|
};
|
|
194
|
-
}, [block.content]);
|
|
260
|
+
}, [block.content, retryToken]);
|
|
195
261
|
|
|
196
262
|
useEffect(() => {
|
|
197
263
|
zoomLevelRef.current = 1;
|
|
@@ -362,6 +428,19 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
362
428
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
363
429
|
</svg>
|
|
364
430
|
<span className="text-xs text-destructive font-medium">Graphviz Error</span>
|
|
431
|
+
{runtimeUnavailable && (
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
onClick={() => {
|
|
435
|
+
setError(null);
|
|
436
|
+
setRetryToken((token) => token + 1);
|
|
437
|
+
}}
|
|
438
|
+
className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
|
|
439
|
+
title="Retry loading the diagram renderer"
|
|
440
|
+
>
|
|
441
|
+
Retry
|
|
442
|
+
</button>
|
|
443
|
+
)}
|
|
365
444
|
</div>
|
|
366
445
|
<pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
|
|
367
446
|
<pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
|