@plannotator/ui 0.39.0 → 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.
@@ -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. Restoring it hidden can never strand a
20
- * user: the header button that flips it back is part of the header, not the
21
- * hidden chrome.
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
- /** Default: both side surfaces closed — the page gets the viewport. */
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: false,
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
+ }
@@ -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
- * `packages/editor/App.tsx` imports this module for its side effect, by
7
- * policy: Plannotator's own surfaces keep Mermaid in their entry chunk so it
8
- * can never fail separately from the app (on the share portal this is what
9
- * keeps `mermaid.core` out of a lazy chunk). The review editor does not import
10
- * it because it never renders a Mermaid block; adding the runtime there would
11
- * grow that bundle. A host that wants the same import adds:
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, so a dropped or
19
- * tree-shaken side-effect import is caught by the built-HTML check in
20
- * tests/entry-assets.test.ts (the runtime itself stays inlined in a
21
- * single-file build through the loader's import(), so a Mermaid diagram id
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. Plannotator
6
- * fills the slot at module evaluation through `./mermaid-eager` (imported by
7
- * `packages/editor/App.tsx`), which keeps the runtime in its entry chunk on the
8
- * share portal exactly as it was with the static import, so it cannot fail
9
- * separately from the app. A host that does not import the eager entry gets
10
- * the lazy path: the runtime is fetched on the first diagram, a failed import
11
- * is dropped from the memo so the next call issues a fresh `import()`, and
12
- * the block re-attempts once and offers Retry.
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
- * what `tests/entry-assets.test.ts` asserts on the built HTML.
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 chunking hosts can fail here; a filled slot never loads.
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