@plannotator/ui 0.38.2 → 0.40.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 +99 -9
- package/README.md +4 -4
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DecisionControl.tsx +0 -10
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +39 -5
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +25 -1
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +533 -9
- package/components/html-viewer/bridge-script.ts +533 -9
- package/components/html-viewer/useHtmlAnnotation.ts +71 -4
- package/hooks/useAnnotationHighlighter.ts +464 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +3 -3
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +45 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/decisionSpec.ts +1 -4
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +150 -4
- package/utils/terminalToolsAnnouncement.ts +76 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a link click inside a raw-HTML annotate document means.
|
|
3
|
+
*
|
|
4
|
+
* A srcdoc document has no URL of its own: its base URL is the PARENT page's,
|
|
5
|
+
* so `<a href="02-detail.html">` resolves onto the Plannotator server and the
|
|
6
|
+
* catch-all answers with the app itself — the whole editor rendered inside the
|
|
7
|
+
* annotated frame. The bridge therefore never lets the frame navigate and
|
|
8
|
+
* hands the RAW href to the parent, which is the trust boundary; this module
|
|
9
|
+
* is the pure decision it makes.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately pure (no DOM, no fetch, no `window`): the caller passes the
|
|
12
|
+
* server origin and the directories, so every branch is unit-testable.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { hasLinkedDocExtension } from "./markdownExtensions";
|
|
16
|
+
|
|
17
|
+
/** Longest href the parent will look at. Matches the bridge's own cap. */
|
|
18
|
+
export const MAX_HTML_LINK_HREF_LENGTH = 2048;
|
|
19
|
+
|
|
20
|
+
export type HtmlLinkIntent =
|
|
21
|
+
/**
|
|
22
|
+
* A local document to open in the linked-doc overlay. `path` is absolute.
|
|
23
|
+
* `rendersHtml` is whether it will open as another raw-HTML surface rather
|
|
24
|
+
* than as markdown, which is what decides whether the sidebar is revealed:
|
|
25
|
+
* HTML surfaces keep it closed and carry their own Back control.
|
|
26
|
+
*/
|
|
27
|
+
| { kind: "document"; path: string; hash: string; rendersHtml: boolean }
|
|
28
|
+
/** Another origin. Opens in a new tab; the frame never navigates. */
|
|
29
|
+
| { kind: "external"; url: string }
|
|
30
|
+
/** A local file Plannotator cannot render as a document (`.pdf`, `.zip`, …). */
|
|
31
|
+
| { kind: "unsupported"; path: string; label: string }
|
|
32
|
+
/** Nothing to do: empty, fragment-only, or a scheme we do not follow. */
|
|
33
|
+
| { kind: "ignored"; reason: HtmlLinkIgnoreReason };
|
|
34
|
+
|
|
35
|
+
export type HtmlLinkIgnoreReason =
|
|
36
|
+
| "empty"
|
|
37
|
+
| "too-long"
|
|
38
|
+
| "fragment"
|
|
39
|
+
| "scheme"
|
|
40
|
+
| "invalid"
|
|
41
|
+
| "no-base";
|
|
42
|
+
|
|
43
|
+
export interface HtmlLinkContext {
|
|
44
|
+
/** Directory of the document the click happened in. */
|
|
45
|
+
baseDir?: string | null;
|
|
46
|
+
/**
|
|
47
|
+
* Directory the session was opened from. Server-absolute paths
|
|
48
|
+
* (`/01-entry-point.html`, and the same path spelled with the server's own
|
|
49
|
+
* origin) resolve against this, because that is the site root the author
|
|
50
|
+
* meant when they wrote a root-relative link.
|
|
51
|
+
*/
|
|
52
|
+
rootDir?: string | null;
|
|
53
|
+
/** The Plannotator server's own origin, e.g. `http://localhost:19601`. */
|
|
54
|
+
serverOrigin: string;
|
|
55
|
+
/** The session's `--markdown` preference: HTML is Turndowned by `/api/doc`,
|
|
56
|
+
* so an `.html` target renders as markdown rather than as an HTML surface. */
|
|
57
|
+
convertHtml?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Control characters never appear in a real href; they are how structure gets smuggled. */
|
|
61
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
62
|
+
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
63
|
+
|
|
64
|
+
export function resolveHtmlLinkIntent(
|
|
65
|
+
href: string,
|
|
66
|
+
context: HtmlLinkContext,
|
|
67
|
+
): HtmlLinkIntent {
|
|
68
|
+
if (typeof href !== "string") return { kind: "ignored", reason: "invalid" };
|
|
69
|
+
const raw = href.trim();
|
|
70
|
+
if (!raw) return { kind: "ignored", reason: "empty" };
|
|
71
|
+
if (raw.length > MAX_HTML_LINK_HREF_LENGTH) return { kind: "ignored", reason: "too-long" };
|
|
72
|
+
if (CONTROL_CHARS.test(raw)) return { kind: "ignored", reason: "invalid" };
|
|
73
|
+
// The bridge scrolls in-page fragments itself; one reaching here is a no-op.
|
|
74
|
+
if (raw.startsWith("#")) return { kind: "ignored", reason: "fragment" };
|
|
75
|
+
|
|
76
|
+
// Scheme-relative (`//host/x`) and absolute URLs both go through URL
|
|
77
|
+
// parsing, which is the only reliable way to compare origins.
|
|
78
|
+
if (raw.startsWith("//") || HAS_SCHEME.test(raw)) {
|
|
79
|
+
return resolveAbsolute(raw, context);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rootRelative = raw.startsWith("/");
|
|
83
|
+
const base = rootRelative ? context.rootDir : context.baseDir;
|
|
84
|
+
return resolveRelative(raw, base, context);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveAbsolute(raw: string, context: HtmlLinkContext): HtmlLinkIntent {
|
|
88
|
+
let url: URL;
|
|
89
|
+
try {
|
|
90
|
+
url = new URL(raw, context.serverOrigin);
|
|
91
|
+
} catch {
|
|
92
|
+
return { kind: "ignored", reason: "invalid" };
|
|
93
|
+
}
|
|
94
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
95
|
+
// javascript:, data:, mailto:, tel:, file:, blob: — none of them name a
|
|
96
|
+
// document this session can annotate, and none may navigate the frame.
|
|
97
|
+
return { kind: "ignored", reason: "scheme" };
|
|
98
|
+
}
|
|
99
|
+
if (url.origin !== context.serverOrigin) {
|
|
100
|
+
return { kind: "external", url: url.href };
|
|
101
|
+
}
|
|
102
|
+
// The server's own origin: the author wrote `http://localhost:<port>/x.html`
|
|
103
|
+
// meaning "the file x.html of this site", so treat it as root-relative.
|
|
104
|
+
// Left alone it would hit the catch-all and render the app in the frame.
|
|
105
|
+
return resolveRelative(`${url.pathname}${url.hash}`, context.rootDir, context);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveRelative(
|
|
109
|
+
raw: string,
|
|
110
|
+
baseDir: string | null | undefined,
|
|
111
|
+
context: HtmlLinkContext,
|
|
112
|
+
): HtmlLinkIntent {
|
|
113
|
+
const { path: pathPart, hash } = splitHash(stripQuery(raw));
|
|
114
|
+
if (!pathPart) return { kind: "ignored", reason: "fragment" };
|
|
115
|
+
const decoded = decodePath(pathPart);
|
|
116
|
+
if (decoded === null || CONTROL_CHARS.test(decoded)) {
|
|
117
|
+
return { kind: "ignored", reason: "invalid" };
|
|
118
|
+
}
|
|
119
|
+
if (!baseDir) return { kind: "ignored", reason: "no-base" };
|
|
120
|
+
const resolved = joinPath(baseDir, decoded);
|
|
121
|
+
if (!resolved) return { kind: "ignored", reason: "invalid" };
|
|
122
|
+
// Containment is the server's call (`/api/doc` answers 403 for an escaping
|
|
123
|
+
// path). The extension gate is ours: a `.pdf` would be a pointless fetch
|
|
124
|
+
// and a confusing server error, so it is reported as unsupported here.
|
|
125
|
+
if (!hasLinkedDocExtension(resolved)) {
|
|
126
|
+
return { kind: "unsupported", path: resolved, label: basename(resolved) };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
kind: "document",
|
|
130
|
+
path: resolved,
|
|
131
|
+
hash,
|
|
132
|
+
rendersHtml: documentRendersHtml(resolved, context.convertHtml),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether opening `path` lands on another raw-HTML surface rather than a
|
|
138
|
+
* markdown one. `--markdown` sessions convert HTML on the way in, so nothing
|
|
139
|
+
* renders as HTML there. This is the one rule behind `rendersHtml`, and the
|
|
140
|
+
* same question the annotations panel's cross-file jump asks before deciding
|
|
141
|
+
* whether to reveal the sidebar — so it lives here rather than being spelled
|
|
142
|
+
* twice.
|
|
143
|
+
*/
|
|
144
|
+
export function documentRendersHtml(path: string, convertHtml?: boolean): boolean {
|
|
145
|
+
return /\.html?$/i.test(path) && !convertHtml;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stripQuery(value: string): string {
|
|
149
|
+
const q = value.indexOf("?");
|
|
150
|
+
if (q < 0) return value;
|
|
151
|
+
const h = value.indexOf("#");
|
|
152
|
+
// `a.html?x=1#frag` — drop the query, keep the fragment.
|
|
153
|
+
return h > q ? value.slice(0, q) + value.slice(h) : value.slice(0, q);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function splitHash(value: string): { path: string; hash: string } {
|
|
157
|
+
const h = value.indexOf("#");
|
|
158
|
+
if (h < 0) return { path: value, hash: "" };
|
|
159
|
+
return { path: value.slice(0, h), hash: value.slice(h + 1) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function decodePath(value: string): string | null {
|
|
163
|
+
try {
|
|
164
|
+
return decodeURIComponent(value);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function basename(path: string): string {
|
|
171
|
+
const parts = path.split("/");
|
|
172
|
+
return parts[parts.length - 1] || path;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Join a relative path onto a directory, resolving `.` and `..` lexically.
|
|
177
|
+
* A root-relative input (`/x.html`) is taken as relative to `baseDir` too —
|
|
178
|
+
* the caller has already chosen which directory plays the role of root.
|
|
179
|
+
*/
|
|
180
|
+
function joinPath(baseDir: string, relative: string): string | null {
|
|
181
|
+
const base = baseDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
182
|
+
const rel = relative.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
183
|
+
if (!rel) return null;
|
|
184
|
+
const segments = base.split("/");
|
|
185
|
+
for (const segment of rel.split("/")) {
|
|
186
|
+
if (!segment || segment === ".") continue;
|
|
187
|
+
if (segment === "..") {
|
|
188
|
+
// Never pop past the root marker (the leading "" of an absolute base).
|
|
189
|
+
if (segments.length > 1) segments.pop();
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
segments.push(segment);
|
|
193
|
+
}
|
|
194
|
+
const joined = segments.join("/");
|
|
195
|
+
return joined || null;
|
|
196
|
+
}
|
package/utils/mermaid-eager.ts
CHANGED
|
@@ -3,23 +3,25 @@
|
|
|
3
3
|
* at module evaluation (exactly where the old module-scope
|
|
4
4
|
* `mermaid.initialize` ran) and fills the slot in `./mermaid`.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* Plannotator does NOT import this module any more. Through @plannotator/ui
|
|
7
|
+
* 0.39.0 `packages/editor/App.tsx` imported it by policy so Mermaid stayed in
|
|
8
|
+
* the plan editor's entry chunk; with Mermaid 12 (ELK layout by default) the
|
|
9
|
+
* runtime is about 1.8 MB larger and the plan editor loads it on the first
|
|
10
|
+
* diagram instead, through the lazy path in `./mermaid`. The module is kept
|
|
11
|
+
* for hosts that want the runtime registered and initialized before the
|
|
12
|
+
* first render (a host that gates first paint on it, or that would rather
|
|
13
|
+
* not have a separate chunk that can fail on its own):
|
|
12
14
|
*
|
|
13
15
|
* import '@plannotator/ui/utils/mermaid-eager';
|
|
14
16
|
*
|
|
15
17
|
* A host that does not import it gets the lazy path in `./mermaid`.
|
|
16
18
|
*
|
|
17
19
|
* The source tag passed below doubles as a build marker: the literal only
|
|
18
|
-
* reaches a bundle when this module is evaluated in it,
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* single-file build through the loader's import(), so a
|
|
22
|
-
* cannot prove registration).
|
|
20
|
+
* reaches a bundle when this module is evaluated in it, which is how
|
|
21
|
+
* tests/entry-assets.test.ts proves on the built HTML that neither of
|
|
22
|
+
* Plannotator's bundles registers the runtime eagerly (the runtime itself is
|
|
23
|
+
* still inlined in a single-file build through the loader's import(), so a
|
|
24
|
+
* Mermaid diagram id cannot prove or disprove registration).
|
|
23
25
|
*/
|
|
24
26
|
import mermaid from 'mermaid';
|
|
25
27
|
import { MERMAID_CONFIG, setMermaidRuntime } from './mermaid';
|
package/utils/mermaid.ts
CHANGED
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
* Mermaid runtime slot.
|
|
3
3
|
*
|
|
4
4
|
* ONE code path feeds `MermaidBlock`: `loadMermaidRuntime()`. It resolves at
|
|
5
|
-
* once from a filled slot and otherwise imports the runtime lazily
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
5
|
+
* once from a filled slot and otherwise imports the runtime lazily: the
|
|
6
|
+
* runtime is fetched on the first diagram, a failed import is dropped from
|
|
7
|
+
* the memo so the next call issues a fresh `import()`, and the block
|
|
8
|
+
* re-attempts once and offers Retry.
|
|
9
|
+
*
|
|
10
|
+
* Since Mermaid 12 (ELK layout by default, about 1.8 MB more runtime than 11)
|
|
11
|
+
* the lazy path IS Plannotator's own path: `packages/editor/App.tsx` no longer
|
|
12
|
+
* imports `./mermaid-eager`, so a plan with no diagram never downloads the
|
|
13
|
+
* runtime in a chunked build (the share portal, any host that bundles by
|
|
14
|
+
* route). The single-file builds inline the `import('mermaid')` target through
|
|
15
|
+
* `inlineDynamicImports`, so there the lazy import resolves from the bundle
|
|
16
|
+
* itself and nothing is fetched. A host that wants the runtime registered at
|
|
17
|
+
* startup imports `./mermaid-eager`, which fills the slot at module
|
|
18
|
+
* evaluation; the slot then short-circuits this loader.
|
|
13
19
|
*
|
|
14
20
|
* This module has NO static import of `mermaid`; the only place the
|
|
15
21
|
* dependency is named at runtime is the default loader's `import('mermaid')`.
|
|
@@ -49,7 +55,8 @@ export const MERMAID_CONFIG: MermaidConfig = {
|
|
|
49
55
|
/**
|
|
50
56
|
* Who filled the slot. The eager value doubles as a build marker: the literal
|
|
51
57
|
* only reaches a bundle when `./mermaid-eager` is evaluated in it, which is
|
|
52
|
-
*
|
|
58
|
+
* how `tests/entry-assets.test.ts` proves on the built HTML that Plannotator's
|
|
59
|
+
* own bundles do NOT register the runtime eagerly.
|
|
53
60
|
*/
|
|
54
61
|
export type MermaidRuntimeSource = 'plannotator-mermaid-eager' | 'loader' | 'host';
|
|
55
62
|
|
|
@@ -69,7 +76,9 @@ let pending: Promise<Mermaid> | null = null;
|
|
|
69
76
|
|
|
70
77
|
/**
|
|
71
78
|
* Delay before the block's one automatic re-attempt after a failed lazy
|
|
72
|
-
* import. Only
|
|
79
|
+
* import. Only chunked builds can fail here (the share portal, a host that
|
|
80
|
+
* bundles by route); a single-file build resolves the import from itself and
|
|
81
|
+
* a filled slot never loads.
|
|
73
82
|
*/
|
|
74
83
|
let retryDelayMs = 750;
|
|
75
84
|
|