@keith9681/dsh-sidebar 0.15.2
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/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown/mermaid fence detection for the markdown preview. The preview
|
|
3
|
+
* renders the WHOLE document through one DSH `MarkdownText` pass (so
|
|
4
|
+
* cross-fence semantics — reference-style links, footnotes, list
|
|
5
|
+
* continuity — stay intact) and the mermaid lazy chunk then swaps the
|
|
6
|
+
* rendered `language-mermaid` code blocks for diagrams. This module's pure
|
|
7
|
+
* splitter exists to detect whether the source contains a mermaid fence at
|
|
8
|
+
* all, so the mermaid chunk is only fetched when needed (unit-tested in
|
|
9
|
+
* tests/mermaid-blocks.spec.ts).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One fenced mermaid diagram lifted out of the markdown source. */
|
|
13
|
+
export interface MermaidBlock {
|
|
14
|
+
kind: 'mermaid'
|
|
15
|
+
/** The raw diagram source between the fences (info string stripped). */
|
|
16
|
+
code: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A span of plain markdown source (may itself contain non-mermaid fences). */
|
|
20
|
+
export interface MarkdownBlock {
|
|
21
|
+
kind: 'markdown'
|
|
22
|
+
text: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MdBlock = MarkdownBlock | MermaidBlock
|
|
26
|
+
|
|
27
|
+
/** Props of the chunk-resident `MermaidMarkdown` component (shared contract). */
|
|
28
|
+
export interface MermaidMarkdownProps {
|
|
29
|
+
/** The full markdown source (rendered in a single MarkdownText pass). */
|
|
30
|
+
text: string
|
|
31
|
+
codeLabels: { copyLabel: string; copiedLabel: string }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
|
|
35
|
+
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
|
|
36
|
+
|
|
37
|
+
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
|
|
38
|
+
const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
|
|
39
|
+
|
|
40
|
+
/** Parse the info string from the line tail after the fence run; null when invalid. */
|
|
41
|
+
function fenceInfo(rest: string, fence: string): string | null {
|
|
42
|
+
const info = rest.trimStart().split(/\s+/)[0] ?? ''
|
|
43
|
+
// CommonMark: a backtick fence's info string may not contain backticks —
|
|
44
|
+
// such a line is not an opening fence at all.
|
|
45
|
+
if (fence.charAt(0) === '`' && info.includes('`')) return null
|
|
46
|
+
return info
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the fence info string names mermaid (bare or `mermaid{...}`). */
|
|
50
|
+
function isMermaidInfo(info: string): boolean {
|
|
51
|
+
const word = info.toLowerCase()
|
|
52
|
+
return word === 'mermaid' || word.startsWith('mermaid{')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Split markdown source into md/mermaid blocks for detection: only fences
|
|
57
|
+
* whose info string names mermaid are lifted; every other line stays in the
|
|
58
|
+
* markdown stream untouched. CommonMark fence rules are honored — opening
|
|
59
|
+
* fences of 3+ backticks OR tildes, and a closing fence must use the same
|
|
60
|
+
* character with at least as many characters as the opening fence. An
|
|
61
|
+
* unterminated mermaid fence swallows the rest of the file (the same
|
|
62
|
+
* recovery CommonMark applies to open fences).
|
|
63
|
+
*/
|
|
64
|
+
export function splitMermaidBlocks(text: string): MdBlock[] {
|
|
65
|
+
if (text === '') return []
|
|
66
|
+
const lines = text.split('\n')
|
|
67
|
+
const blocks: MdBlock[] = []
|
|
68
|
+
let markdown: string[] = []
|
|
69
|
+
let index = 0
|
|
70
|
+
const flushMarkdown = (): void => {
|
|
71
|
+
if (markdown.length === 0) return
|
|
72
|
+
blocks.push({ kind: 'markdown', text: markdown.join('\n') })
|
|
73
|
+
markdown = []
|
|
74
|
+
}
|
|
75
|
+
while (index < lines.length) {
|
|
76
|
+
const line = lines[index] ?? ''
|
|
77
|
+
const fenceMatch = OPEN_FENCE_RE.exec(line)
|
|
78
|
+
if (fenceMatch === null) {
|
|
79
|
+
markdown.push(line)
|
|
80
|
+
index += 1
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
const fence = fenceMatch[1]!
|
|
84
|
+
// The line tail after the whole matched prefix (indent + fence run).
|
|
85
|
+
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
|
|
86
|
+
const info = fenceInfo(rest, fence)
|
|
87
|
+
if (info === null || !isMermaidInfo(info)) {
|
|
88
|
+
markdown.push(line)
|
|
89
|
+
index += 1
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
flushMarkdown()
|
|
93
|
+
const char = fence.charAt(0)
|
|
94
|
+
const length = fence.length
|
|
95
|
+
const code: string[] = []
|
|
96
|
+
index += 1
|
|
97
|
+
while (index < lines.length) {
|
|
98
|
+
const candidate = lines[index] ?? ''
|
|
99
|
+
const close = CLOSE_FENCE_RE.exec(candidate)
|
|
100
|
+
if (close !== null && close[1]!.charAt(0) === char && close[1]!.length >= length) break
|
|
101
|
+
code.push(candidate)
|
|
102
|
+
index += 1
|
|
103
|
+
}
|
|
104
|
+
// Consume the closing fence (or run off the end on an open fence).
|
|
105
|
+
index += 1
|
|
106
|
+
blocks.push({ kind: 'mermaid', code: code.join('\n') })
|
|
107
|
+
}
|
|
108
|
+
flushMarkdown()
|
|
109
|
+
return blocks
|
|
110
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG sanitization for mermaid-rendered diagrams (markdown preview). The
|
|
3
|
+
* diagrams come from untrusted markdown sources, so the emitted SVG is
|
|
4
|
+
* re-sanitized before it reaches dangerouslySetInnerHTML — defense in depth
|
|
5
|
+
* on top of mermaid's own `securityLevel: 'strict'` (labels escaped, click
|
|
6
|
+
* directives inert) and `htmlLabels: false` (labels as real SVG <text>).
|
|
7
|
+
*
|
|
8
|
+
* What is stripped, and why:
|
|
9
|
+
* - `foreignObject`: the only channel that can carry raw HTML inside an
|
|
10
|
+
* SVG document — with it gone, a hostile label cannot smuggle an
|
|
11
|
+
* <img onerror> / <iframe> through the XML parse (any non-SVG element
|
|
12
|
+
* outside foreignObject makes the XML parse fail and the whole diagram
|
|
13
|
+
* is rejected).
|
|
14
|
+
* - `script` and foreign HTML elements (`img`/`iframe`/`object`/`embed`/
|
|
15
|
+
* `video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`): belt and
|
|
16
|
+
* braces — real browsers reject these outside foreignObject at XML parse
|
|
17
|
+
* time (→ '') while lenient parsers keep them, so they are stripped
|
|
18
|
+
* explicitly instead of relying on the parser alone. Element names are
|
|
19
|
+
* matched case-insensitively (XML preserves their case; the output is
|
|
20
|
+
* re-parsed as HTML whose parser normalizes tag casing, so `<sCrIpT>` /
|
|
21
|
+
* `<foreignobject>` would otherwise come back as script/foreignObject).
|
|
22
|
+
* - `@*` / `on*` attributes: event-handler channels (Vue-style directives
|
|
23
|
+
* and native listeners), matched case-insensitively — the output is
|
|
24
|
+
* re-parsed as HTML whose parser normalizes attribute casing, so
|
|
25
|
+
* `oNload`/`OnClick` must not survive either.
|
|
26
|
+
* - ALL `href` / `xlink:href` attributes: the diagrams are static (no
|
|
27
|
+
* bindFunctions, no click navigation), so links carry no value — and an
|
|
28
|
+
* attacker-controlled URL (even an http(s) one) could otherwise navigate
|
|
29
|
+
* the whole GUI away from the sidebar. Removing every href keeps the
|
|
30
|
+
* surface deterministic.
|
|
31
|
+
*
|
|
32
|
+
* Parsing happens with `image/svg+xml`, so a malformed SVG fails the XML
|
|
33
|
+
* parse and the function returns '' (the caller surfaces the error
|
|
34
|
+
* fallback) instead of ever passing the raw string through; only a document
|
|
35
|
+
* whose root is an `<svg>` element is accepted.
|
|
36
|
+
*/
|
|
37
|
+
/** Element local names stripped case-insensitively (all lowercase). */
|
|
38
|
+
const STRIP_ELEMENTS = new Set([
|
|
39
|
+
'foreignobject',
|
|
40
|
+
'script',
|
|
41
|
+
'img',
|
|
42
|
+
'iframe',
|
|
43
|
+
'object',
|
|
44
|
+
'embed',
|
|
45
|
+
'video',
|
|
46
|
+
'audio',
|
|
47
|
+
'input',
|
|
48
|
+
'button',
|
|
49
|
+
'form',
|
|
50
|
+
'link',
|
|
51
|
+
'meta',
|
|
52
|
+
'base',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
/** A parse failure keeps nothing of the input: the caller shows the error. */
|
|
56
|
+
export function sanitizeSvg(svg: string): string {
|
|
57
|
+
if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') return ''
|
|
58
|
+
let doc: Document
|
|
59
|
+
try {
|
|
60
|
+
doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
|
61
|
+
} catch {
|
|
62
|
+
return ''
|
|
63
|
+
}
|
|
64
|
+
if (doc.querySelector('parsererror') !== null) return ''
|
|
65
|
+
if (doc.documentElement === null || doc.documentElement.localName !== 'svg') return ''
|
|
66
|
+
doc.querySelectorAll('*').forEach((node) => {
|
|
67
|
+
// XML element names preserve case; the string is later re-parsed as HTML
|
|
68
|
+
// whose parser normalizes tag casing — so mixed-case spellings of the
|
|
69
|
+
// strip list (e.g. `<sCrIpT>`, `<foreignobject>`) must be caught the
|
|
70
|
+
// same way as their canonical forms.
|
|
71
|
+
if (STRIP_ELEMENTS.has(node.localName.toLowerCase())) {
|
|
72
|
+
node.remove()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
for (const attribute of [...node.attributes]) {
|
|
76
|
+
const name = attribute.name
|
|
77
|
+
// XML attribute names preserve case; the string is later re-parsed as
|
|
78
|
+
// HTML (dangerouslySetInnerHTML), whose parser normalizes attribute
|
|
79
|
+
// casing — so a mixed-case `oNload`/`HREF` must be caught the same way
|
|
80
|
+
// as its lowercase form. Compare on the lowercased name, remove with
|
|
81
|
+
// the original.
|
|
82
|
+
const normalized = name.toLowerCase()
|
|
83
|
+
if (normalized.startsWith('@') || normalized.startsWith('on')) {
|
|
84
|
+
node.removeAttribute(name)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (normalized === 'href' || normalized === 'xlink:href') {
|
|
88
|
+
node.removeAttribute(name)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
return new XMLSerializer().serializeToString(doc.documentElement)
|
|
93
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid diagram renderer for the markdown preview, resident in the
|
|
3
|
+
* `mermaid` lazy chunk (src/client/chunks/mermaid.tsx): the mermaid library
|
|
4
|
+
* and its transitive graph deps (d3/dagre/cytoscape) are inlined into
|
|
5
|
+
* lib/client-mermaid.js and fetched only when a previewed markdown file
|
|
6
|
+
* actually contains a mermaid fence (see mermaid-blocks.ts).
|
|
7
|
+
*
|
|
8
|
+
* Rendering architecture: the whole document is rendered ONCE through the
|
|
9
|
+
* DSH `MarkdownText` (so cross-fence semantics — reference-style links,
|
|
10
|
+
* footnotes, ordered-list continuity — stay intact), then a layout effect
|
|
11
|
+
* swaps every rendered `language-mermaid` CodeBlock for a diagram. The
|
|
12
|
+
* swap keeps the React-managed `.md-code-block` host node in the tree and
|
|
13
|
+
* only replaces its children (display:contents suppresses the code-block
|
|
14
|
+
* chrome), so React reconciliation never loses the host; when a swapped
|
|
15
|
+
* block stops being a mermaid fence, the original children are restored.
|
|
16
|
+
*
|
|
17
|
+
* Security: mermaid.render → sanitized SVG injected into the block.
|
|
18
|
+
* `bindFunctions` is intentionally NOT applied (static diagrams; no
|
|
19
|
+
* flowchart click handlers), `securityLevel` stays 'strict' (labels are
|
|
20
|
+
* escaped, no raw-HTML foreignObject), `htmlLabels: false` keeps node text
|
|
21
|
+
* as real SVG <text>, and the emitted SVG is re-sanitized (see
|
|
22
|
+
* mermaid-sanitize.ts) before it reaches dangerouslySetInnerHTML. Clicking
|
|
23
|
+
* a rendered diagram opens a zoom/pan modal (borrowed interaction design
|
|
24
|
+
* from the mermaid PR #75, reimplemented on top of the sanitized SVG —
|
|
25
|
+
* the clone carries no event surface, so the modal adds no attack surface).
|
|
26
|
+
*/
|
|
27
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
|
28
|
+
import { createPortal } from 'react-dom'
|
|
29
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
30
|
+
import mermaid from 'mermaid'
|
|
31
|
+
import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
32
|
+
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
|
33
|
+
import { t } from './locales.ts'
|
|
34
|
+
import { sanitizeSvg } from './mermaid-sanitize.ts'
|
|
35
|
+
import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
|
|
36
|
+
import css from './sidebar.module.css'
|
|
37
|
+
|
|
38
|
+
/** Monotonic id seed: every render call gets a fresh, document-unique id. */
|
|
39
|
+
let mermaidSeq = 0
|
|
40
|
+
|
|
41
|
+
/** Configure mermaid for the current color scheme (idempotent). */
|
|
42
|
+
function configureMermaid(): void {
|
|
43
|
+
mermaid.initialize({
|
|
44
|
+
startOnLoad: false,
|
|
45
|
+
securityLevel: 'strict',
|
|
46
|
+
// Labels as real SVG <text>: mermaid's default htmlLabels renders node
|
|
47
|
+
// text inside <foreignObject>, which the sanitizer strips wholesale —
|
|
48
|
+
// forcing pure SVG text keeps labels visible and the HTML label channel
|
|
49
|
+
// closed (strict already escapes label content).
|
|
50
|
+
htmlLabels: false,
|
|
51
|
+
theme: isDarkScheme() ? 'dark' : 'default',
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** First lines of a mermaid error (its dumps are huge; the head explains). */
|
|
56
|
+
function summarizeError(error: unknown): string {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
58
|
+
return message.split('\n').slice(0, 6).join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
|
|
62
|
+
function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
|
|
63
|
+
const overlayRef = useRef<HTMLDivElement>(null)
|
|
64
|
+
const stageRef = useRef<HTMLDivElement>(null)
|
|
65
|
+
const svgRef = useRef<SVGSVGElement | null>(null)
|
|
66
|
+
const dragRef = useRef({ active: false, startX: 0, startY: 0 })
|
|
67
|
+
const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
|
|
68
|
+
|
|
69
|
+
const applyTransform = (): void => {
|
|
70
|
+
const node = svgRef.current
|
|
71
|
+
if (node === null) return
|
|
72
|
+
const { scale, tx, ty } = zoomRef.current
|
|
73
|
+
node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
|
|
77
|
+
const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
|
|
78
|
+
const stage = stageRef.current
|
|
79
|
+
if (stage === null) return
|
|
80
|
+
const rect = stage.getBoundingClientRect()
|
|
81
|
+
const cx = centerX ?? rect.width / 2
|
|
82
|
+
const cy = centerY ?? rect.height / 2
|
|
83
|
+
const current = zoomRef.current
|
|
84
|
+
const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
|
|
85
|
+
// The svg is flex-centered in the stage, so its center sits at
|
|
86
|
+
// (rect.width/2, rect.height/2). Solve for the translate that keeps the
|
|
87
|
+
// mouse point stationary in stage coordinates across the scale change.
|
|
88
|
+
const sx = rect.width / 2
|
|
89
|
+
const sy = rect.height / 2
|
|
90
|
+
const ratio = newScale / current.scale
|
|
91
|
+
current.tx = cx - sx - (cx - sx - current.tx) * ratio
|
|
92
|
+
current.ty = cy - sy - (cy - sy - current.ty) * ratio
|
|
93
|
+
current.scale = newScale
|
|
94
|
+
applyTransform()
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
const reset = useCallback((): void => {
|
|
98
|
+
zoomRef.current = { scale: 1, tx: 0, ty: 0 }
|
|
99
|
+
applyTransform()
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
const close = useCallback((): void => { onClose() }, [onClose])
|
|
103
|
+
|
|
104
|
+
// Mount the caller-provided (sanitized) svg clone imperatively: React
|
|
105
|
+
// types don't accept a raw DOM node as a child, and the modal owns the
|
|
106
|
+
// node's lifetime (removed on unmount; the preview copy is untouched).
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const stage = stageRef.current
|
|
109
|
+
if (stage === null) return
|
|
110
|
+
svgRef.current = svg
|
|
111
|
+
stage.appendChild(svg)
|
|
112
|
+
return () => {
|
|
113
|
+
svg.remove()
|
|
114
|
+
svgRef.current = null
|
|
115
|
+
}
|
|
116
|
+
}, [svg])
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const stage = stageRef.current
|
|
120
|
+
const node = svgRef.current
|
|
121
|
+
const overlay = overlayRef.current
|
|
122
|
+
if (stage === null || node === null || overlay === null) return
|
|
123
|
+
|
|
124
|
+
const onWheel = (event: WheelEvent): void => {
|
|
125
|
+
event.preventDefault()
|
|
126
|
+
const rect = stage.getBoundingClientRect()
|
|
127
|
+
zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
|
|
128
|
+
}
|
|
129
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
130
|
+
if (event.key === 'Escape') { close(); return }
|
|
131
|
+
if (event.key === '+' || event.key === '=') zoom(1.2)
|
|
132
|
+
else if (event.key === '-') zoom(1 / 1.2)
|
|
133
|
+
else if (event.key === '0') reset()
|
|
134
|
+
}
|
|
135
|
+
const onMouseDown = (event: MouseEvent): void => {
|
|
136
|
+
event.preventDefault()
|
|
137
|
+
dragRef.current = {
|
|
138
|
+
active: true,
|
|
139
|
+
startX: event.clientX - zoomRef.current.tx,
|
|
140
|
+
startY: event.clientY - zoomRef.current.ty,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const onMouseMove = (event: MouseEvent): void => {
|
|
144
|
+
if (!dragRef.current.active) return
|
|
145
|
+
zoomRef.current.tx = event.clientX - dragRef.current.startX
|
|
146
|
+
zoomRef.current.ty = event.clientY - dragRef.current.startY
|
|
147
|
+
applyTransform()
|
|
148
|
+
}
|
|
149
|
+
const onMouseUp = (): void => { dragRef.current.active = false }
|
|
150
|
+
const onOverlayClick = (event: MouseEvent): void => {
|
|
151
|
+
if (event.target === overlay) close()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// React's synthetic wheel is passive; a native listener is required to
|
|
155
|
+
// preventDefault (the page must not scroll while zooming the modal).
|
|
156
|
+
stage.addEventListener('wheel', onWheel, { passive: false })
|
|
157
|
+
node.addEventListener('mousedown', onMouseDown)
|
|
158
|
+
window.addEventListener('mousemove', onMouseMove)
|
|
159
|
+
window.addEventListener('mouseup', onMouseUp)
|
|
160
|
+
window.addEventListener('keydown', onKey)
|
|
161
|
+
overlay.addEventListener('click', onOverlayClick)
|
|
162
|
+
return () => {
|
|
163
|
+
stage.removeEventListener('wheel', onWheel)
|
|
164
|
+
node.removeEventListener('mousedown', onMouseDown)
|
|
165
|
+
window.removeEventListener('mousemove', onMouseMove)
|
|
166
|
+
window.removeEventListener('mouseup', onMouseUp)
|
|
167
|
+
window.removeEventListener('keydown', onKey)
|
|
168
|
+
overlay.removeEventListener('click', onOverlayClick)
|
|
169
|
+
}
|
|
170
|
+
}, [zoom, reset, close])
|
|
171
|
+
|
|
172
|
+
return createPortal(
|
|
173
|
+
<div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
|
|
174
|
+
<div className={css.mermaidModalToolbar}>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className={css.mermaidModalButton}
|
|
178
|
+
title={t('mermaidZoomOut')}
|
|
179
|
+
onClick={() => zoom(1 / 1.2)}
|
|
180
|
+
>
|
|
181
|
+
−
|
|
182
|
+
</button>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className={css.mermaidModalButton}
|
|
186
|
+
title={t('mermaidZoomIn')}
|
|
187
|
+
onClick={() => zoom(1.2)}
|
|
188
|
+
>
|
|
189
|
+
+
|
|
190
|
+
</button>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
className={css.mermaidModalButton}
|
|
194
|
+
title={t('mermaidZoomReset')}
|
|
195
|
+
onClick={reset}
|
|
196
|
+
>
|
|
197
|
+
⟳
|
|
198
|
+
</button>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className={css.mermaidModalButton}
|
|
202
|
+
title={t('close')}
|
|
203
|
+
onClick={close}
|
|
204
|
+
>
|
|
205
|
+
✕
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
<div className={css.mermaidModalStage} ref={stageRef} />
|
|
209
|
+
<div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
|
|
210
|
+
</div>,
|
|
211
|
+
document.body,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One rendered mermaid fence: header chrome + diagram (or error + source). */
|
|
216
|
+
function MermaidDiagram({ code }: { code: string }): React.ReactNode {
|
|
217
|
+
const [svg, setSvg] = useState<string | null>(null)
|
|
218
|
+
const [error, setError] = useState<string | null>(null)
|
|
219
|
+
const [copied, setCopied] = useState(false)
|
|
220
|
+
/** Scheme state: a flip re-renders the diagram with the matching theme. */
|
|
221
|
+
const [dark, setDark] = useState(() => isDarkScheme())
|
|
222
|
+
/** The cloned svg shown in the zoom modal (null = closed). */
|
|
223
|
+
const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
|
|
224
|
+
const copyTimer = useRef<number | undefined>(undefined)
|
|
225
|
+
|
|
226
|
+
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
let cancelled = false
|
|
230
|
+
setSvg(null)
|
|
231
|
+
setError(null)
|
|
232
|
+
if (code.trim() === '') return () => { cancelled = true }
|
|
233
|
+
configureMermaid()
|
|
234
|
+
const id = `dsh-md-mermaid-${mermaidSeq += 1}`
|
|
235
|
+
mermaid.render(id, code)
|
|
236
|
+
.then(({ svg: rendered }) => {
|
|
237
|
+
if (cancelled) return
|
|
238
|
+
const clean = sanitizeSvg(rendered)
|
|
239
|
+
if (clean === '') {
|
|
240
|
+
// Parse/sanitize rejection: never pass the raw string through.
|
|
241
|
+
setError(t('mermaidError'))
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
setSvg(clean)
|
|
245
|
+
})
|
|
246
|
+
.catch((reason: unknown) => {
|
|
247
|
+
if (cancelled) return
|
|
248
|
+
setError(summarizeError(reason))
|
|
249
|
+
})
|
|
250
|
+
return () => { cancelled = true }
|
|
251
|
+
}, [code, dark])
|
|
252
|
+
|
|
253
|
+
const onCopy = useCallback(() => {
|
|
254
|
+
if (copied) return
|
|
255
|
+
writeClipboard(code).then((ok) => {
|
|
256
|
+
if (!ok) return
|
|
257
|
+
setCopied(true)
|
|
258
|
+
window.clearTimeout(copyTimer.current)
|
|
259
|
+
copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
|
|
260
|
+
})
|
|
261
|
+
}, [code, copied])
|
|
262
|
+
|
|
263
|
+
/** Clicking the diagram opens the zoom modal with a sanitized clone. */
|
|
264
|
+
const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
|
|
265
|
+
const svgEl = (event.target as Element).closest('svg')
|
|
266
|
+
if (svgEl === null) return
|
|
267
|
+
const clone = svgEl.cloneNode(true) as SVGSVGElement
|
|
268
|
+
// The modal owns sizing/transform; drop the preview's inline geometry.
|
|
269
|
+
clone.removeAttribute('style')
|
|
270
|
+
clone.removeAttribute('width')
|
|
271
|
+
clone.removeAttribute('height')
|
|
272
|
+
setZoomSvg(clone)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<div className={css.mermaidWrap}>
|
|
277
|
+
<div className={css.mermaidHeader}>
|
|
278
|
+
<span className={css.mermaidInfo}>mermaid</span>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
className={css.mermaidCopy}
|
|
282
|
+
onClick={onCopy}
|
|
283
|
+
aria-label={t('copy')}
|
|
284
|
+
title={t('copy')}
|
|
285
|
+
>
|
|
286
|
+
<IconCopyOutline16 />
|
|
287
|
+
<span>{copied ? t('copied') : t('copy')}</span>
|
|
288
|
+
</button>
|
|
289
|
+
</div>
|
|
290
|
+
{error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
|
|
291
|
+
{svg !== null && (
|
|
292
|
+
<div
|
|
293
|
+
className={css.mermaidBody}
|
|
294
|
+
data-mermaid-diagram
|
|
295
|
+
onClick={onBodyClick}
|
|
296
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
{error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
|
|
300
|
+
{zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** One swapped mount: the diagram root + the CodeBlock children it displaced. */
|
|
306
|
+
interface MermaidMount {
|
|
307
|
+
root: Root
|
|
308
|
+
source: string
|
|
309
|
+
removed: ChildNode[]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* True when a rendered CodeBlock is a mermaid fence. The DSH CodeBlock has
|
|
314
|
+
* two bodies: the shiki path carries the `language-*` class on generated
|
|
315
|
+
* <code> elements, while the plain path (which is always the one mermaid
|
|
316
|
+
* takes — no shiki grammar) only shows the language in the banner
|
|
317
|
+
* infostring (first element of the banner row; CSS-module classes are
|
|
318
|
+
* hashed, so that match is structural).
|
|
319
|
+
*/
|
|
320
|
+
function isMermaidBlock(block: HTMLElement): boolean {
|
|
321
|
+
const code = block.querySelector('code')
|
|
322
|
+
if (code !== null && [...code.classList].some(c => c.startsWith('language-mermaid'))) return true
|
|
323
|
+
const infostring = block.firstElementChild?.firstElementChild?.firstElementChild
|
|
324
|
+
return infostring !== null
|
|
325
|
+
&& infostring !== undefined
|
|
326
|
+
&& (infostring.textContent ?? '').trim() === 'mermaid'
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The chunk-resident markdown preview renderer: ONE MarkdownText pass over
|
|
331
|
+
* the full source (cross-fence reference/footnote/list semantics intact),
|
|
332
|
+
* then every rendered `language-mermaid` code block is swapped for a
|
|
333
|
+
* `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
|
|
334
|
+
* only its children are replaced — so reconciliation never loses the host;
|
|
335
|
+
* a block that stops being a mermaid fence gets its original children back.
|
|
336
|
+
* Only mounted when the source contains at least one mermaid fence (see
|
|
337
|
+
* TextEditor.tsx).
|
|
338
|
+
*/
|
|
339
|
+
export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
|
|
340
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
341
|
+
const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
|
|
342
|
+
|
|
343
|
+
useLayoutEffect(() => {
|
|
344
|
+
const container = containerRef.current
|
|
345
|
+
if (container === null) return
|
|
346
|
+
const mounts = mountsRef.current
|
|
347
|
+
const seen = new Set<HTMLElement>()
|
|
348
|
+
|
|
349
|
+
for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
|
|
350
|
+
const mount = mounts.get(block)
|
|
351
|
+
const isMermaid = isMermaidBlock(block)
|
|
352
|
+
if (!isMermaid) {
|
|
353
|
+
// A previously swapped block that is no longer a mermaid fence:
|
|
354
|
+
// restore the CodeBlock children React still manages, so the plain
|
|
355
|
+
// fence renders normally again.
|
|
356
|
+
if (mount !== undefined) {
|
|
357
|
+
mount.root.unmount()
|
|
358
|
+
block.replaceChildren(...mount.removed)
|
|
359
|
+
block.removeAttribute('data-mermaid-processed')
|
|
360
|
+
mounts.delete(block)
|
|
361
|
+
}
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
seen.add(block)
|
|
365
|
+
// The plain body always carries the fence source in <code>.
|
|
366
|
+
const source = block.querySelector('code')?.textContent ?? ''
|
|
367
|
+
if (mount !== undefined && mount.source === source) continue
|
|
368
|
+
if (mount === undefined) {
|
|
369
|
+
const host = document.createElement('div')
|
|
370
|
+
const removed = [...block.childNodes]
|
|
371
|
+
block.replaceChildren(host)
|
|
372
|
+
block.setAttribute('data-mermaid-processed', 'true')
|
|
373
|
+
const root = createRoot(host)
|
|
374
|
+
mounts.set(block, { root, source, removed })
|
|
375
|
+
root.render(<MermaidDiagram code={source} />)
|
|
376
|
+
} else {
|
|
377
|
+
mount.source = source
|
|
378
|
+
mount.root.render(<MermaidDiagram code={source} />)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Drop mounts whose code block left the tree (fence removed, document
|
|
383
|
+
// restructured): the host node is gone with it.
|
|
384
|
+
for (const [block, mount] of mounts) {
|
|
385
|
+
if (seen.has(block)) continue
|
|
386
|
+
mount.root.unmount()
|
|
387
|
+
mounts.delete(block)
|
|
388
|
+
}
|
|
389
|
+
}, [text])
|
|
390
|
+
|
|
391
|
+
useEffect(() => () => {
|
|
392
|
+
for (const { root } of mountsRef.current.values()) root.unmount()
|
|
393
|
+
mountsRef.current.clear()
|
|
394
|
+
}, [])
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<div className={css.mermaidMarkdown} ref={containerRef}>
|
|
398
|
+
<MarkdownText text={text} codeLabels={codeLabels} />
|
|
399
|
+
</div>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred one-shot open for hosts that may not have a real size yet.
|
|
3
|
+
*
|
|
4
|
+
* xterm's `Terminal.open()` must not run in a zero-size container: the
|
|
5
|
+
* renderer creation fails there (the DomRenderer is built from the host's
|
|
6
|
+
* dimensions), leaving the render service's renderer `undefined`, and the
|
|
7
|
+
* next Viewport refresh crashes reading `.dimensions` off it. WebKit-based
|
|
8
|
+
* hosts (WKWebView) reliably report zero while the bottom panel's expand
|
|
9
|
+
* slide is in flight; any `display:none`-hidden ancestor does the same.
|
|
10
|
+
*
|
|
11
|
+
* The caller's `open` callback (open + fit + resize) is invoked exactly
|
|
12
|
+
* once, on the first frame where the host reports a real size. While the
|
|
13
|
+
* host stays zero-sized the polling continues every frame; it stops when
|
|
14
|
+
* the host leaves the document (`isConnected`), so a pending open never
|
|
15
|
+
* fires after unmount. The returned cancel function drops a pending frame
|
|
16
|
+
* immediately (idempotent).
|
|
17
|
+
*
|
|
18
|
+
* `raf`/`caf` are injectable so tests can drive the polling deterministically.
|
|
19
|
+
*/
|
|
20
|
+
export function openWhenSized(
|
|
21
|
+
host: HTMLElement,
|
|
22
|
+
open: () => void,
|
|
23
|
+
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
|
|
24
|
+
caf: (id: number) => void = cancelAnimationFrame,
|
|
25
|
+
): () => void {
|
|
26
|
+
let frame: number | null = null
|
|
27
|
+
const step = (): void => {
|
|
28
|
+
frame = null
|
|
29
|
+
if (!host.isConnected) return
|
|
30
|
+
if (host.clientWidth > 0 && host.clientHeight > 0) {
|
|
31
|
+
open()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
frame = raf(step)
|
|
35
|
+
}
|
|
36
|
+
frame = raf(step)
|
|
37
|
+
return () => {
|
|
38
|
+
if (frame !== null) {
|
|
39
|
+
caf(frame)
|
|
40
|
+
frame = null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|