@plannotator/ui 0.39.0 → 0.41.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 +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- 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 +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- package/components/mermaidSvg.ts +0 -33
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graphviz runtime slot — the same shape as `./mermaid`, one per engine so
|
|
3
|
+
* a host fills either independently.
|
|
4
|
+
*
|
|
5
|
+
* ONE code path feeds the renderer slot (`./diagram-render`):
|
|
6
|
+
* `loadGraphvizRuntime()`. It resolves at once from a filled slot and
|
|
7
|
+
* otherwise imports the engine lazily: `@viz-js/viz` (about 1.2 MB of
|
|
8
|
+
* Emscripten JS with the wasm inlined) is fetched on the first dot fence, a
|
|
9
|
+
* failed import is dropped from the memo so the next call issues a fresh
|
|
10
|
+
* `import()`, and the block re-attempts once and offers Retry. In
|
|
11
|
+
* Plannotator's single-file builds the import is inlined and resolves from
|
|
12
|
+
* the bundle; a host that bundles by route fetches it on demand.
|
|
13
|
+
*
|
|
14
|
+
* This module has NO static import of `@viz-js/viz`; the only place the
|
|
15
|
+
* dependency is named at runtime is the default loader's `import()`.
|
|
16
|
+
*/
|
|
17
|
+
import type { Viz } from '@viz-js/viz';
|
|
18
|
+
|
|
19
|
+
export type GraphvizRuntime = Viz;
|
|
20
|
+
|
|
21
|
+
/** Who filled the slot. */
|
|
22
|
+
export type GraphvizRuntimeSource = 'loader' | 'host';
|
|
23
|
+
|
|
24
|
+
export type GraphvizRuntimeLoader = () => Promise<Viz>;
|
|
25
|
+
|
|
26
|
+
/** Default lazy loader: import the engine and instantiate its wasm once. */
|
|
27
|
+
const defaultGraphvizLoader: GraphvizRuntimeLoader = () => import('@viz-js/viz').then((m) => m.instance());
|
|
28
|
+
|
|
29
|
+
let runtime: Viz | null = null;
|
|
30
|
+
let runtimeSource: GraphvizRuntimeSource | null = null;
|
|
31
|
+
let loader: GraphvizRuntimeLoader = defaultGraphvizLoader;
|
|
32
|
+
let pending: Promise<Viz> | null = null;
|
|
33
|
+
|
|
34
|
+
/** Delay before the one automatic re-attempt after a failed lazy import. */
|
|
35
|
+
let retryDelayMs = 750;
|
|
36
|
+
|
|
37
|
+
/** Current runtime, or `null` while the slot is empty. */
|
|
38
|
+
export function getGraphvizRuntime(): Viz | null {
|
|
39
|
+
return runtime;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How the current runtime was registered, or `null` while the slot is empty. */
|
|
43
|
+
export function getGraphvizRuntimeSource(): GraphvizRuntimeSource | null {
|
|
44
|
+
return runtimeSource;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Register an already-instantiated engine (a host with its own import). */
|
|
48
|
+
export function setGraphvizRuntime(next: Viz, source: GraphvizRuntimeSource = 'host'): void {
|
|
49
|
+
runtime = next;
|
|
50
|
+
runtimeSource = source;
|
|
51
|
+
pending = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The renderer's retry delay for the lazy path. */
|
|
55
|
+
export function getGraphvizRetryDelayMs(): number {
|
|
56
|
+
return retryDelayMs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the engine: at once from a filled slot, otherwise through the
|
|
61
|
+
* loader. A rejected load is dropped from the memo so the next call (the
|
|
62
|
+
* automatic re-attempt, a later mount, or the Retry button) issues a fresh
|
|
63
|
+
* `import()` instead of replaying the cached rejection.
|
|
64
|
+
*/
|
|
65
|
+
export function loadGraphvizRuntime(): Promise<Viz> {
|
|
66
|
+
if (runtime) return Promise.resolve(runtime);
|
|
67
|
+
if (!pending) {
|
|
68
|
+
const attempt = loader().then(
|
|
69
|
+
(loaded) => {
|
|
70
|
+
setGraphvizRuntime(loaded, 'loader');
|
|
71
|
+
return loaded;
|
|
72
|
+
},
|
|
73
|
+
(err: unknown) => {
|
|
74
|
+
if (pending === attempt) pending = null;
|
|
75
|
+
throw err;
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
pending = attempt;
|
|
79
|
+
}
|
|
80
|
+
return pending;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Test hook: empty the slot, stand in for the lazy import, shorten the retry delay. */
|
|
84
|
+
export function __setGraphvizRuntimeLoaderForTests(
|
|
85
|
+
next: GraphvizRuntimeLoader | undefined,
|
|
86
|
+
options?: { retryDelayMs?: number },
|
|
87
|
+
): void {
|
|
88
|
+
runtime = null;
|
|
89
|
+
runtimeSource = null;
|
|
90
|
+
pending = null;
|
|
91
|
+
loader = next ?? defaultGraphvizLoader;
|
|
92
|
+
retryDelayMs = options?.retryDelayMs ?? 750;
|
|
93
|
+
}
|
package/utils/htmlChrome.ts
CHANGED
|
@@ -16,9 +16,16 @@ import { isStalePreference } from './preferenceTtl';
|
|
|
16
16
|
*
|
|
17
17
|
* `toolsHidden` is the header "Hide tools" toggle: while true, ALL floating
|
|
18
18
|
* chrome over the page (sidebar tongue tabs + the comment/attachments
|
|
19
|
-
* cluster) is removed from the DOM.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* cluster) is removed from the DOM. It DEFAULTS to true — an HTML document is
|
|
20
|
+
* authored to fill the viewport, so a first-ever session shows the page and
|
|
21
|
+
* nothing else, and the header eye (plus Mod+Shift+X) is what reveals the
|
|
22
|
+
* tools. Defaulting hidden can never strand a user for the same reason
|
|
23
|
+
* restoring hidden can't: the control that flips it back lives in the header,
|
|
24
|
+
* never in the hidden chrome. A fresh persisted record still wins in both
|
|
25
|
+
* directions, so a user who showed the tools keeps them next session —
|
|
26
|
+
* including in a folder annotate session, which restores and records the
|
|
27
|
+
* `toolsHidden` half while leaving the sidebar/panel halves to sessions whose
|
|
28
|
+
* sidebar it actually owns (see {@link mergeHtmlChromeState}).
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
const STORAGE_KEY = 'plannotator-html-chrome';
|
|
@@ -32,11 +39,14 @@ export interface HtmlChromeState {
|
|
|
32
39
|
toolsHidden: boolean;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Default: both side surfaces closed AND the floating tools hidden — the page
|
|
44
|
+
* gets the whole viewport until the user asks for the tools.
|
|
45
|
+
*/
|
|
36
46
|
export const DEFAULT_HTML_CHROME_STATE: HtmlChromeState = {
|
|
37
47
|
sidebarOpen: false,
|
|
38
48
|
panelOpen: false,
|
|
39
|
-
toolsHidden:
|
|
49
|
+
toolsHidden: true,
|
|
40
50
|
};
|
|
41
51
|
|
|
42
52
|
/** Pure resolution logic (exported for tests): raw cookie value → state. */
|
|
@@ -68,6 +78,61 @@ export function resolveHtmlChromeState(
|
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
/** Inputs to the restore-on-entry decision (see {@link shouldRestoreHtmlChrome}). */
|
|
82
|
+
export interface HtmlChromeRestoreConditions {
|
|
83
|
+
/** The surface being rendered now is raw HTML or a live app. */
|
|
84
|
+
isHtmlSurface: boolean;
|
|
85
|
+
/** The surface rendered on the previous pass was too. */
|
|
86
|
+
wasHtmlSurface: boolean;
|
|
87
|
+
/** This session takes no part in the persisted chrome at all (archive, goal
|
|
88
|
+
* setup): nothing is restored and nothing is written. A folder annotate
|
|
89
|
+
* session is NOT suppressed — see {@link mergeHtmlChromeState}. */
|
|
90
|
+
suppressed: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether to apply the persisted chrome state, i.e. whether this render is an
|
|
95
|
+
* ENTRY into an HTML surface.
|
|
96
|
+
*
|
|
97
|
+
* The `wasHtmlSurface` term is what keeps navigation between two HTML
|
|
98
|
+
* documents from re-running the restore: `toolsHidden` defaults to true and a
|
|
99
|
+
* link click deliberately leaves the sidebar alone, so a re-run mid-session
|
|
100
|
+
* would flip both back under the user. Leaving an HTML surface for a markdown
|
|
101
|
+
* one and returning IS an entry, and restores again on purpose — that is what
|
|
102
|
+
* stops the markdown surface's sidebar state from leaking into the HTML
|
|
103
|
+
* cookie.
|
|
104
|
+
*/
|
|
105
|
+
export function shouldRestoreHtmlChrome(conditions: HtmlChromeRestoreConditions): boolean {
|
|
106
|
+
if (!conditions.isHtmlSurface) return false;
|
|
107
|
+
if (conditions.wasHtmlSurface) return false;
|
|
108
|
+
return !conditions.suppressed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The record to persist when the chrome changes on an HTML surface.
|
|
113
|
+
*
|
|
114
|
+
* `sideSurfacesOwned` marks a session where the left sidebar and the
|
|
115
|
+
* annotations drawer are not this surface's to remember — a folder annotate
|
|
116
|
+
* session, whose file browser owns the sidebar for the whole session. Those two
|
|
117
|
+
* halves then keep whatever the last ordinary HTML session left, while the
|
|
118
|
+
* `toolsHidden` half is recorded normally: it is a property of the HTML surface
|
|
119
|
+
* itself and means the same thing in every session.
|
|
120
|
+
*/
|
|
121
|
+
export function mergeHtmlChromeState(input: {
|
|
122
|
+
/** The record on disk right now. */
|
|
123
|
+
persisted: HtmlChromeState;
|
|
124
|
+
/** The chrome this session is in. */
|
|
125
|
+
live: HtmlChromeState;
|
|
126
|
+
sideSurfacesOwned: boolean;
|
|
127
|
+
}): HtmlChromeState {
|
|
128
|
+
if (!input.sideSurfacesOwned) return input.live;
|
|
129
|
+
return {
|
|
130
|
+
sidebarOpen: input.persisted.sidebarOpen,
|
|
131
|
+
panelOpen: input.persisted.panelOpen,
|
|
132
|
+
toolsHidden: input.live.toolsHidden,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
71
136
|
export function getHtmlChromeState(): HtmlChromeState {
|
|
72
137
|
return resolveHtmlChromeState(storage.getItem(STORAGE_KEY));
|
|
73
138
|
}
|
|
@@ -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
|
|