dsh-mosaic 0.2.1
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/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/SKILL.md +153 -0
- package/THIRD_PARTY_NOTICES.md +79 -0
- package/assets/icon-180.png +0 -0
- package/assets/icon.svg +13 -0
- package/assets/screenshots/preview.jpg +0 -0
- package/cordis.patch.yml +10 -0
- package/docs/catalog-entry.example.yml +9 -0
- package/docs/publishing.md +29 -0
- package/lib/assets/licenses/braintree-sanitize-url.LICENSE +21 -0
- package/lib/assets/licenses/chevrotain.LICENSE +202 -0
- package/lib/assets/licenses/cytoscape-cose-bilkent.LICENSE +21 -0
- package/lib/assets/licenses/cytoscape-fcose.LICENSE +19 -0
- package/lib/assets/licenses/cytoscape.LICENSE +19 -0
- package/lib/assets/licenses/d3-sankey.LICENSE +27 -0
- package/lib/assets/licenses/d3.LICENSE +13 -0
- package/lib/assets/licenses/dagre-d3-es.LICENSE +23 -0
- package/lib/assets/licenses/dayjs.LICENSE +21 -0
- package/lib/assets/licenses/dompurify.LICENSE +202 -0
- package/lib/assets/licenses/elkjs.LICENSE +263 -0
- package/lib/assets/licenses/es-toolkit.LICENSE +21 -0
- package/lib/assets/licenses/iconify-utils.LICENSE +21 -0
- package/lib/assets/licenses/katex.LICENSE +21 -0
- package/lib/assets/licenses/khroma.LICENSE +21 -0
- package/lib/assets/licenses/marked.LICENSE +44 -0
- package/lib/assets/licenses/mermaid-js-parser.LICENSE +21 -0
- package/lib/assets/licenses/mermaid.LICENSE +21 -0
- package/lib/assets/licenses/roughjs.LICENSE +21 -0
- package/lib/assets/licenses/stylis.LICENSE +21 -0
- package/lib/assets/licenses/ts-dedent.LICENSE +21 -0
- package/lib/assets/licenses/types-d3.LICENSE +21 -0
- package/lib/assets/licenses/upsetjs-venn.js.LICENSE +22 -0
- package/lib/assets/licenses/uuid.LICENSE +9 -0
- package/lib/assets/mermaid.min.js +4330 -0
- package/lib/client.js +3191 -0
- package/lib/index.js +365 -0
- package/lib/log.js +212 -0
- package/lib/prompt-store.js +354 -0
- package/lib/render-tool.js +162 -0
- package/lib/validate-tool.js +160 -0
- package/lib/validate.js +1070 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -0
- package/screenshots.json +3 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,3191 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-mosaic',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const ReactDOMClient = require('react-dom/client')
|
|
6
|
+
const ReactDOM = require('react-dom')
|
|
7
|
+
const h = React.createElement
|
|
8
|
+
const {
|
|
9
|
+
useState, useEffect, useMemo, useRef, useCallback, createContext, useContext, Fragment,
|
|
10
|
+
} = React
|
|
11
|
+
|
|
12
|
+
const PLUGIN_ID = 'dsh-mosaic'
|
|
13
|
+
const STYLE_CSS_ID = 'dsh-mosaic/client.css'
|
|
14
|
+
const STORE_KEY = 'mosaic.state.v1'
|
|
15
|
+
const MAX_STORED = 100
|
|
16
|
+
// Client diagnostics are forwarded to the Host log; failures are what matter here,
|
|
17
|
+
// so the reporter is best-effort, capped, and never throws.
|
|
18
|
+
const LOG_URL = '/dsh-mosaic/log'
|
|
19
|
+
const LOG_REPORTS_PER_WINDOW = 20
|
|
20
|
+
const LOG_WINDOW_MS = 60000
|
|
21
|
+
let logWindow = []
|
|
22
|
+
|
|
23
|
+
function reportLog(level, event, detail) {
|
|
24
|
+
try {
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
logWindow = logWindow.filter((at) => now - at < LOG_WINDOW_MS)
|
|
27
|
+
if (logWindow.length >= LOG_REPORTS_PER_WINDOW) return
|
|
28
|
+
logWindow.push(now)
|
|
29
|
+
fetch(LOG_URL, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'content-type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ level, event, detail: detail ?? {} }),
|
|
33
|
+
}).catch(() => {})
|
|
34
|
+
} catch {
|
|
35
|
+
// Diagnostics must never affect rendering.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const MAX_NODES = 80
|
|
39
|
+
const MAX_DEPTH = 6
|
|
40
|
+
|
|
41
|
+
// Visible copy belongs to the Client locale service: both shipped locales are
|
|
42
|
+
// registered as one namespace and every component reads it through `t`.
|
|
43
|
+
const NS = 'mosaic'
|
|
44
|
+
const dictionaries = {
|
|
45
|
+
zh: {
|
|
46
|
+
copy: '复制',
|
|
47
|
+
copied: '已复制',
|
|
48
|
+
copyMarkdown: '复制 Markdown',
|
|
49
|
+
copyCsv: '复制 CSV',
|
|
50
|
+
total: '合计',
|
|
51
|
+
grandTotal: '总计',
|
|
52
|
+
added: '(新增)',
|
|
53
|
+
openPdf: '打开 PDF 原文件',
|
|
54
|
+
selectPlaceholder: '请选择…',
|
|
55
|
+
answered: '已作答',
|
|
56
|
+
answerCorrect: '回答正确',
|
|
57
|
+
answerWrong: '回答不正确',
|
|
58
|
+
tryAgain: '再试一次',
|
|
59
|
+
code: '代码',
|
|
60
|
+
preview: '预览',
|
|
61
|
+
reload: '重载',
|
|
62
|
+
fullscreen: '全屏',
|
|
63
|
+
close: '关闭',
|
|
64
|
+
drawio: '下载 draw.io 文件',
|
|
65
|
+
mermaidFallback: 'Mermaid 渲染失败,已使用内置布局:{message}',
|
|
66
|
+
parseFailed: 'dsh-view 解析失败:{detail}(已保留代码块)',
|
|
67
|
+
actionNotice: '用户刚刚在界面中触发了动作 "{action}",请根据组件数据更新回答,并用 dsh-view 输出更新后的界面。组件数据:',
|
|
68
|
+
settingsTitle: 'Mosaic',
|
|
69
|
+
promptDesc: '插件会向模型贡献一段说明可视化能力的 Prompt。默认使用内置内容;你可以改成自己的写法,也可以随时恢复默认。',
|
|
70
|
+
promptLabel: 'Prompt 内容',
|
|
71
|
+
promptHint: '只影响能力说明,不规定回答内容;保存后从下一轮模型请求开始生效。',
|
|
72
|
+
promptSave: '保存',
|
|
73
|
+
promptSaving: '保存中…',
|
|
74
|
+
promptReset: '恢复默认',
|
|
75
|
+
promptSaved: '已保存。',
|
|
76
|
+
promptResetDone: '已恢复插件内置内容。',
|
|
77
|
+
promptDirty: '有未保存的修改',
|
|
78
|
+
promptChars: '{count} / {limit} 字符',
|
|
79
|
+
promptSourceUser: '已自定义',
|
|
80
|
+
promptSourceConfig: '来自配置文件',
|
|
81
|
+
promptSourceBuiltin: '内置默认',
|
|
82
|
+
promptLoadFailed: '读取失败:{reason}',
|
|
83
|
+
promptSaveFailed: '保存失败:{reason}',
|
|
84
|
+
promptMemoryOnly: '未能写入 Host 数据目录,本次仅在进程内生效。',
|
|
85
|
+
promptReadCorrupt: '保存的内容无法解析,已按内置内容运行;原文件已保留,可手工修复。',
|
|
86
|
+
promptReadUnreadable: '保存的内容读取失败(权限或路径问题),已按内置内容运行。',
|
|
87
|
+
copyJson: '复制 JSON',
|
|
88
|
+
},
|
|
89
|
+
en: {
|
|
90
|
+
copy: 'Copy',
|
|
91
|
+
copied: 'Copied',
|
|
92
|
+
copyMarkdown: 'Copy Markdown',
|
|
93
|
+
copyCsv: 'Copy CSV',
|
|
94
|
+
total: 'Total',
|
|
95
|
+
grandTotal: 'Total',
|
|
96
|
+
added: '(added)',
|
|
97
|
+
openPdf: 'Open the original PDF',
|
|
98
|
+
selectPlaceholder: 'Select…',
|
|
99
|
+
answered: 'Answered',
|
|
100
|
+
answerCorrect: 'Correct',
|
|
101
|
+
answerWrong: 'Not correct',
|
|
102
|
+
tryAgain: 'Try again',
|
|
103
|
+
code: 'Code',
|
|
104
|
+
preview: 'Preview',
|
|
105
|
+
reload: 'Reload',
|
|
106
|
+
fullscreen: 'Fullscreen',
|
|
107
|
+
close: 'Close',
|
|
108
|
+
drawio: 'Download a draw.io file',
|
|
109
|
+
mermaidFallback: 'Mermaid rendering failed; the built-in layout was used: {message}',
|
|
110
|
+
parseFailed: 'dsh-view could not be parsed: {detail} (the code block is kept)',
|
|
111
|
+
actionNotice: 'The user just triggered the action "{action}" in the interface above. Update the answer from its data and reply with an updated dsh-view fence. Data:',
|
|
112
|
+
settingsTitle: 'Mosaic',
|
|
113
|
+
promptDesc: "This plugin contributes a prompt that explains its visualization to the model. The built-in text is used by default; you can replace it with your own wording and restore the default later.",
|
|
114
|
+
promptLabel: 'Prompt',
|
|
115
|
+
promptHint: 'This only shapes capability guidance, never the answer; a saved change applies from the next model request.',
|
|
116
|
+
promptSave: 'Save',
|
|
117
|
+
promptSaving: 'Saving…',
|
|
118
|
+
promptReset: 'Restore default',
|
|
119
|
+
promptSaved: 'Saved.',
|
|
120
|
+
promptResetDone: 'Restored the built-in prompt.',
|
|
121
|
+
promptDirty: 'Unsaved changes',
|
|
122
|
+
promptChars: '{count} / {limit} characters',
|
|
123
|
+
promptSourceUser: 'Customized',
|
|
124
|
+
promptSourceConfig: 'From the config file',
|
|
125
|
+
promptSourceBuiltin: 'Built-in default',
|
|
126
|
+
promptLoadFailed: 'Could not read the prompt: {reason}',
|
|
127
|
+
promptSaveFailed: 'Could not save: {reason}',
|
|
128
|
+
promptMemoryOnly: 'Could not write the Host data directory, so this change only lives in this process.',
|
|
129
|
+
promptReadCorrupt: 'The saved override is not valid JSON, so the built-in text is used. The file was kept for manual repair.',
|
|
130
|
+
promptReadUnreadable: 'The saved override could not be read (permissions or path), so the built-in text is used.',
|
|
131
|
+
copyJson: 'Copy JSON',
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Safe before the dictionary registers; keys never disappear.
|
|
136
|
+
let translate = (key, vars) => {
|
|
137
|
+
let text = dictionaries.zh[key] ?? key
|
|
138
|
+
if (vars) for (const name of Object.keys(vars)) text = text.replace(`{${name}}`, String(vars[name]))
|
|
139
|
+
return text
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clearLegacyStorage() {
|
|
143
|
+
try {
|
|
144
|
+
localStorage.removeItem('dsh-mosaic.theme.v1')
|
|
145
|
+
localStorage.removeItem('mosaic.theme.v1')
|
|
146
|
+
localStorage.removeItem('dsh-mosaic.prompt.v1')
|
|
147
|
+
} catch {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ------------------------------------------------------------------ */
|
|
151
|
+
/* styles */
|
|
152
|
+
/* ------------------------------------------------------------------ */
|
|
153
|
+
|
|
154
|
+
const CSS = `
|
|
155
|
+
.mosaic-fence{margin:16px 0;min-width:0}
|
|
156
|
+
.mosaic-root,.mosaic-overlay{
|
|
157
|
+
--m-bg:var(--dsw-alias-bg-layer-1);
|
|
158
|
+
--m-bg2:var(--dsw-alias-bg-layer-2);
|
|
159
|
+
--m-text:var(--dsw-alias-label-primary);
|
|
160
|
+
--m-muted:var(--dsw-alias-label-secondary);
|
|
161
|
+
--m-border:var(--dsw-alias-border-l1);
|
|
162
|
+
--m-accent:var(--dsw-alias-brand-primary);
|
|
163
|
+
--m-success:var(--dsw-alias-state-success-primary);
|
|
164
|
+
--m-warn:var(--dsw-alias-state-warn-primary);
|
|
165
|
+
--m-danger:var(--dsw-alias-state-error-primary);
|
|
166
|
+
--m-series-1:#4f6bed;
|
|
167
|
+
--m-series-2:#1f9d8b;
|
|
168
|
+
--m-series-3:#d99a2b;
|
|
169
|
+
--m-series-4:#d35d5d;
|
|
170
|
+
--m-series-5:#8a63d2;
|
|
171
|
+
--m-radius:12px;
|
|
172
|
+
--m-card-bg:var(--m-bg);
|
|
173
|
+
--m-card-border:var(--m-border);
|
|
174
|
+
--m-card-shadow:0 1px 2px rgba(15,23,42,.04);
|
|
175
|
+
--m-card-padding:16px 18px;
|
|
176
|
+
color:var(--m-text);
|
|
177
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
|
178
|
+
font-size:14px;
|
|
179
|
+
line-height:1.55;
|
|
180
|
+
min-width:0;
|
|
181
|
+
}
|
|
182
|
+
.mosaic-root *,.mosaic-root *::before,.mosaic-root *::after{box-sizing:border-box}
|
|
183
|
+
.mosaic-root a{color:var(--m-accent)}
|
|
184
|
+
.mosaic-banner{font-size:12px;font-weight:600;color:var(--m-muted);letter-spacing:.02em;margin:0 0 8px}
|
|
185
|
+
.mosaic-head{display:flex;align-items:flex-start;gap:8px;margin:0 0 8px;position:relative}
|
|
186
|
+
.mosaic-head .mosaic-banner{flex:1;min-width:0;margin:0}
|
|
187
|
+
.mosaic-settings{flex-direction:column;gap:14px;width:100%;display:flex}
|
|
188
|
+
.mosaic-settings-desc{color:var(--dsw-alias-label-secondary);margin:0;font-size:14px;line-height:22px}
|
|
189
|
+
.mosaic-prompt-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
|
190
|
+
.mosaic-prompt-label{color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px}
|
|
191
|
+
.mosaic-prompt-meta{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
192
|
+
.mosaic-prompt-field{box-sizing:border-box;width:100%;min-height:240px;resize:vertical;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;line-height:1.7;tab-size:2}
|
|
193
|
+
.mosaic-prompt-field:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
|
|
194
|
+
.mosaic-prompt-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
195
|
+
.mosaic-prompt-hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;flex:1;min-width:220px}
|
|
196
|
+
.mosaic-prompt-reset,.mosaic-prompt-save{border-radius:var(--dsw-radius-md,8px);height:32px;padding:0 12px;font:inherit;font-size:13px;line-height:18px;cursor:pointer;flex:none}
|
|
197
|
+
.mosaic-prompt-reset{border:.5px solid var(--dsw-alias-border-l3);background:transparent;color:var(--dsw-alias-label-primary)}
|
|
198
|
+
.mosaic-prompt-reset:hover:enabled{background:var(--dsw-alias-interactive-bg-hover)}
|
|
199
|
+
.mosaic-prompt-save{border:0;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base)}
|
|
200
|
+
.mosaic-prompt-save:hover:enabled{opacity:.88}
|
|
201
|
+
.mosaic-prompt-reset:disabled,.mosaic-prompt-save:disabled{opacity:.45;cursor:not-allowed}
|
|
202
|
+
.mosaic-prompt-reset:focus-visible,.mosaic-prompt-save:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
|
203
|
+
.mosaic-prompt-warn{color:var(--dsw-alias-state-warn-primary);margin:0;font-size:12px;line-height:18px}
|
|
204
|
+
.mosaic-prompt-notice{margin:0;font-size:12px;line-height:18px}
|
|
205
|
+
.mosaic-prompt-notice.is-ok{color:var(--dsw-alias-state-success-primary)}
|
|
206
|
+
.mosaic-prompt-notice.is-error{color:var(--dsw-alias-state-error-primary)}
|
|
207
|
+
.mosaic-json-copy{box-sizing:border-box;border:0;border-radius:var(--dsw-radius-sm,6px);cursor:pointer;height:28px;color:var(--dsw-alias-label-secondary);background:transparent;flex:none;margin-left:auto;padding:0 8px;font-family:inherit;font-size:12px;line-height:18px;display:inline-flex;align-items:center}
|
|
208
|
+
.mosaic-json-copy:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
209
|
+
.mosaic-json-copy:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
|
210
|
+
.mosaic-col{display:flex;flex-direction:column;min-width:0}
|
|
211
|
+
.mosaic-row{display:flex;gap:12px;min-width:0;align-items:stretch}
|
|
212
|
+
.mosaic-row-wrap{flex-wrap:wrap}
|
|
213
|
+
.mosaic-grid{display:grid;gap:12px;min-width:0}
|
|
214
|
+
.mosaic-grid-item{min-width:0}
|
|
215
|
+
.mosaic-card{background:var(--m-card-bg);border:1px solid var(--m-card-border);border-radius:var(--m-radius);padding:var(--m-card-padding);min-width:0;box-shadow:var(--m-card-shadow)}
|
|
216
|
+
.mosaic-card-flat{box-shadow:none;background:var(--m-bg2)}
|
|
217
|
+
.mosaic-card-info{background:color-mix(in srgb,var(--m-accent) 8%,var(--m-bg));border-color:color-mix(in srgb,var(--m-accent) 26%,var(--m-border))}
|
|
218
|
+
.mosaic-card-success{background:color-mix(in srgb,var(--m-success) 8%,var(--m-bg));border-color:color-mix(in srgb,var(--m-success) 26%,var(--m-border))}
|
|
219
|
+
.mosaic-card-warning{background:color-mix(in srgb,var(--m-warn) 9%,var(--m-bg));border-color:color-mix(in srgb,var(--m-warn) 28%,var(--m-border))}
|
|
220
|
+
.mosaic-card-danger{background:color-mix(in srgb,var(--m-danger) 8%,var(--m-bg));border-color:color-mix(in srgb,var(--m-danger) 26%,var(--m-border))}
|
|
221
|
+
.mosaic-eyebrow{font-size:10.5px;font-weight:650;letter-spacing:.08em;text-transform:uppercase;color:var(--m-muted);margin:0 0 3px}
|
|
222
|
+
.mosaic-card-title{font-size:15px;font-weight:650;line-height:1.3;margin:0 0 10px}
|
|
223
|
+
.mosaic-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0}
|
|
224
|
+
.mosaic-display{font-size:30px;line-height:1.18;font-weight:650;letter-spacing:-.022em}
|
|
225
|
+
.mosaic-h1{font-size:24px;line-height:1.2;font-weight:650;letter-spacing:-.015em}
|
|
226
|
+
.mosaic-h2{font-size:19px;line-height:1.25;font-weight:650;letter-spacing:-.01em}
|
|
227
|
+
.mosaic-h3{font-size:16px;line-height:1.3;font-weight:650}
|
|
228
|
+
.mosaic-muted{color:var(--m-muted)}
|
|
229
|
+
.mosaic-caption{font-size:12px;color:var(--m-muted)}
|
|
230
|
+
.mosaic-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
231
|
+
.mosaic-tone-danger{color:var(--m-danger)}
|
|
232
|
+
.mosaic-tone-success{color:var(--m-success)}
|
|
233
|
+
.mosaic-tone-warning{color:var(--m-warn)}
|
|
234
|
+
.mosaic-tone-info{color:var(--m-accent)}
|
|
235
|
+
.mosaic-tone-muted{color:var(--m-muted)}
|
|
236
|
+
.mosaic-center{text-align:center}
|
|
237
|
+
.mosaic-right{text-align:right}
|
|
238
|
+
.mosaic-divider{display:flex;align-items:center;gap:10px;color:var(--m-muted);font-size:11px;margin:4px 0}
|
|
239
|
+
.mosaic-divider::before,.mosaic-divider::after{content:"";flex:1;height:1px;background:var(--m-border)}
|
|
240
|
+
.mosaic-spacer{height:8px}
|
|
241
|
+
.mosaic-stat{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
242
|
+
.mosaic-stat-label{font-size:12px;color:var(--m-muted);line-height:1.35}
|
|
243
|
+
.mosaic-stat-value{font-size:26px;line-height:1.15;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
|
|
244
|
+
.mosaic-stat-foot{display:flex;align-items:center;gap:8px;min-width:0}
|
|
245
|
+
.mosaic-delta{font-size:12px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
246
|
+
.mosaic-delta-pos{color:var(--m-success)}
|
|
247
|
+
.mosaic-delta-neg{color:var(--m-danger)}
|
|
248
|
+
.mosaic-delta-flat{color:var(--m-muted)}
|
|
249
|
+
.mosaic-spark{width:84px;height:22px;flex:none;margin-left:4px}
|
|
250
|
+
.mosaic-badge{display:inline-flex;align-items:center;align-self:flex-start;gap:4px;max-width:100%;padding:2px 7px;border-radius:999px;font-size:11.5px;line-height:1.5;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
251
|
+
.mosaic-badge-success{background:color-mix(in srgb,var(--m-success) 14%,transparent);color:var(--m-success)}
|
|
252
|
+
.mosaic-badge-warn{background:color-mix(in srgb,var(--m-warn) 16%,transparent);color:var(--m-warn)}
|
|
253
|
+
.mosaic-badge-danger{background:color-mix(in srgb,var(--m-danger) 14%,transparent);color:var(--m-danger)}
|
|
254
|
+
.mosaic-badge-accent{background:color-mix(in srgb,var(--m-accent) 14%,transparent);color:var(--m-accent)}
|
|
255
|
+
.mosaic-progress{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
256
|
+
.mosaic-progress-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;font-size:12px;color:var(--m-muted)}
|
|
257
|
+
.mosaic-progress-head b{color:var(--m-text);font-size:13px;font-variant-numeric:tabular-nums}
|
|
258
|
+
.mosaic-track{position:relative;height:7px;border-radius:999px;background:var(--m-bg2);overflow:hidden}
|
|
259
|
+
.mosaic-fill{position:absolute;inset:0 auto 0 0;border-radius:inherit;background:var(--m-series-1)}
|
|
260
|
+
.mosaic-target{position:absolute;top:-3px;bottom:-3px;width:2px;background:var(--m-text);opacity:.55}
|
|
261
|
+
.mosaic-ring{display:flex;align-items:center;gap:12px}
|
|
262
|
+
.mosaic-ring svg{width:64px;height:64px;flex:none}
|
|
263
|
+
.mosaic-table-wrap{overflow-x:auto;border:1px solid var(--m-card-border);border-radius:var(--m-radius);background:var(--m-card-bg);box-shadow:var(--m-card-shadow)}
|
|
264
|
+
.mosaic-table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:0}
|
|
265
|
+
.mosaic-table th,.mosaic-table td{padding:9px 12px;text-align:left;vertical-align:top;border-bottom:1px solid var(--m-border);overflow-wrap:anywhere}
|
|
266
|
+
.mosaic-table tr:last-child td{border-bottom:0}
|
|
267
|
+
.mosaic-table tbody tr:hover td{background:color-mix(in srgb,var(--m-accent) 3%,var(--m-bg))}
|
|
268
|
+
.mosaic-table th{position:sticky;top:0;background:var(--m-bg2);z-index:1;font-size:11.5px;font-weight:650;color:var(--m-muted);letter-spacing:.02em;white-space:nowrap}
|
|
269
|
+
/* From four columns on, auto layout lets the longest cell decide the width and the
|
|
270
|
+
card starts scrolling sideways; equal shares keep every column readable instead. */
|
|
271
|
+
.mosaic-table-dense{table-layout:fixed}
|
|
272
|
+
.mosaic-table-dense th{white-space:normal}
|
|
273
|
+
.mosaic-table-dense td.num{white-space:normal}
|
|
274
|
+
.mosaic-table th button{all:unset;cursor:pointer;display:inline-flex;gap:4px;align-items:center;color:inherit;font:inherit}
|
|
275
|
+
.mosaic-table th button:focus-visible{outline:2px solid var(--m-accent);outline-offset:2px;border-radius:4px}
|
|
276
|
+
.mosaic-table td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
277
|
+
.mosaic-table td.delta-pos{color:var(--m-success)}
|
|
278
|
+
.mosaic-table td.delta-neg{color:var(--m-danger)}
|
|
279
|
+
.mosaic-table tr.total td{font-weight:650;background:var(--m-bg2)}
|
|
280
|
+
.mosaic-table-tools{display:flex;justify-content:flex-end;gap:6px;margin:0 0 6px}
|
|
281
|
+
.mosaic-table-tools button,.mosaic-copy{all:unset;cursor:pointer;font-size:11px;color:var(--m-muted);border:1px solid var(--m-border);border-radius:6px;padding:2px 7px}
|
|
282
|
+
.mosaic-table-tools button:hover,.mosaic-copy:hover{color:var(--m-text);border-color:var(--m-muted)}
|
|
283
|
+
.mosaic-cell-bar{display:block;height:5px;border-radius:999px;background:var(--m-bg2);overflow:hidden;min-width:56px}
|
|
284
|
+
.mosaic-cell-bar span{display:block;height:100%;border-radius:inherit;background:var(--m-series-1)}
|
|
285
|
+
.mosaic-list{display:flex;flex-direction:column;gap:7px;margin:0;padding:0;list-style:none}
|
|
286
|
+
.mosaic-list-item{display:flex;gap:8px;align-items:flex-start;min-width:0}
|
|
287
|
+
.mosaic-list-dot{width:5px;height:5px;border-radius:50%;background:var(--m-accent);margin-top:8px;flex:none}
|
|
288
|
+
.mosaic-list-title{font-weight:550}
|
|
289
|
+
.mosaic-list-desc{color:var(--m-muted);font-size:12.5px}
|
|
290
|
+
.mosaic-kv{display:grid;grid-template-columns:minmax(90px,auto) 1fr;gap:6px 16px;font-size:13px}
|
|
291
|
+
.mosaic-kv-key{color:var(--m-muted)}
|
|
292
|
+
.mosaic-kv-value{min-width:0;overflow-wrap:anywhere}
|
|
293
|
+
.mosaic-steps{display:flex;flex-direction:column;gap:10px;margin:0;padding:0;list-style:none}
|
|
294
|
+
.mosaic-step{display:flex;gap:10px;min-width:0}
|
|
295
|
+
.mosaic-step-index{width:22px;height:22px;border-radius:50%;border:1px solid var(--m-border);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:650;flex:none;color:var(--m-muted)}
|
|
296
|
+
.mosaic-step-active .mosaic-step-index{background:var(--m-accent);border-color:var(--m-accent);color:#fff}
|
|
297
|
+
.mosaic-step-title{font-weight:600;line-height:1.35}
|
|
298
|
+
.mosaic-step-desc{color:var(--m-muted);font-size:12.5px;margin-top:2px}
|
|
299
|
+
.mosaic-callout{position:relative;border:1px solid color-mix(in srgb,var(--m-accent) 22%,var(--m-border));background:color-mix(in srgb,var(--m-accent) 5%,var(--m-bg));border-radius:calc(var(--m-radius) - 2px);padding:11px 13px}
|
|
300
|
+
.mosaic-callout-success{border-color:color-mix(in srgb,var(--m-success) 24%,var(--m-border));background:color-mix(in srgb,var(--m-success) 6%,var(--m-bg))}
|
|
301
|
+
.mosaic-callout-warning{border-color:color-mix(in srgb,var(--m-warn) 24%,var(--m-border));background:color-mix(in srgb,var(--m-warn) 7%,var(--m-bg))}
|
|
302
|
+
.mosaic-callout-error{border-color:color-mix(in srgb,var(--m-danger) 24%,var(--m-border));background:color-mix(in srgb,var(--m-danger) 6%,var(--m-bg))}
|
|
303
|
+
.mosaic-callout-title{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--m-accent);margin-bottom:4px}
|
|
304
|
+
.mosaic-callout-title::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
|
|
305
|
+
.mosaic-callout-success .mosaic-callout-title{color:var(--m-success)}
|
|
306
|
+
.mosaic-callout-warning .mosaic-callout-title{color:var(--m-warn)}
|
|
307
|
+
.mosaic-callout-error .mosaic-callout-title{color:var(--m-danger)}
|
|
308
|
+
.mosaic-code{position:relative;background:var(--m-bg2);border:1px solid var(--m-border);border-radius:10px;padding:10px 12px;overflow:auto;font-size:12px;line-height:1.55}
|
|
309
|
+
.mosaic-code pre{margin:0;white-space:pre;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
310
|
+
.mosaic-code .mosaic-copy{position:absolute;top:6px;right:6px;background:var(--m-bg)}
|
|
311
|
+
.mosaic-inline-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em;background:var(--m-bg2);border:1px solid var(--m-border);border-radius:4px;padding:0 3px}
|
|
312
|
+
.mosaic-diff{display:flex;flex-direction:column;gap:8px}
|
|
313
|
+
.mosaic-diff-file{font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--m-muted)}
|
|
314
|
+
.mosaic-diff-pair{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
315
|
+
.mosaic-diff-side{border:1px solid var(--m-border);border-radius:8px;padding:0;overflow:auto;font-size:12px;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
316
|
+
.mosaic-diff-line{display:grid;grid-template-columns:14px 30px minmax(0,1fr);gap:4px;padding:1px 8px;min-height:18px}
|
|
317
|
+
.mosaic-diff-gutter{color:var(--m-muted);text-align:center;user-select:none}
|
|
318
|
+
.mosaic-diff-num{color:var(--m-muted);text-align:right;user-select:none;font-variant-numeric:tabular-nums}
|
|
319
|
+
.mosaic-diff-text{white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}
|
|
320
|
+
.mosaic-diff-old{background:color-mix(in srgb,var(--m-danger) 7%,var(--m-bg))}
|
|
321
|
+
.mosaic-diff-new{background:color-mix(in srgb,var(--m-success) 7%,var(--m-bg))}
|
|
322
|
+
.mosaic-chart{width:100%;height:auto;display:block;overflow:visible}
|
|
323
|
+
.mosaic-chart text{font-size:10px;fill:var(--m-muted)}
|
|
324
|
+
.mosaic-chart .mosaic-chart-title{font-size:12px;font-weight:600;fill:var(--m-text)}
|
|
325
|
+
.mosaic-chart .mosaic-axis{stroke:var(--m-border);stroke-width:1}
|
|
326
|
+
.mosaic-chart .mosaic-axis-label{font-size:9px;fill:var(--m-muted);font-variant-numeric:tabular-nums}
|
|
327
|
+
.mosaic-chart .mosaic-grid-line{stroke:var(--m-border);stroke-width:1;opacity:.45}
|
|
328
|
+
.mosaic-chart .mosaic-line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
|
|
329
|
+
.mosaic-chart .mosaic-dot{stroke:var(--m-bg);stroke-width:1.5}
|
|
330
|
+
.mosaic-chart .mosaic-bar{shape-rendering:geometricPrecision}
|
|
331
|
+
.mosaic-chart .mosaic-donut-label{font-size:11px;fill:var(--m-text)}
|
|
332
|
+
.mosaic-diagram-wrap{max-width:100%;max-height:560px;overflow:auto;padding-bottom:2px}
|
|
333
|
+
.mosaic-diagram{display:block;width:auto;max-width:none;height:auto;margin:0 auto;overflow:visible}
|
|
334
|
+
.mosaic-mermaid{max-width:100%;max-height:560px;overflow:auto;padding:2px 0}
|
|
335
|
+
.mosaic-mermaid svg{display:block;max-width:100%;height:auto;margin:0 auto}
|
|
336
|
+
.mosaic-mermaid .labelBkg{background:var(--m-bg)!important;color:var(--m-text)!important;padding:0 3px;border-radius:3px}
|
|
337
|
+
.mosaic-mermaid .edgeLabel{color:var(--m-text)!important}
|
|
338
|
+
.mosaic-diagram-head{display:flex;align-items:center;gap:8px;margin:0 0 6px}
|
|
339
|
+
.mosaic-diagram-export{margin-left:auto;border:1px solid var(--m-border);background:var(--m-bg);color:var(--m-muted);font:11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;padding:2px 7px;border-radius:5px;cursor:pointer}
|
|
340
|
+
.mosaic-diagram-export:hover{color:var(--m-text);border-color:var(--m-accent)}
|
|
341
|
+
.mosaic-diagram .diagram-node{stroke-width:1.2}
|
|
342
|
+
.mosaic-diagram .diagram-node-label{font-size:12px;font-weight:600;fill:var(--m-text)}
|
|
343
|
+
.mosaic-diagram .diagram-node-sub{font-size:10px;fill:var(--m-muted)}
|
|
344
|
+
.mosaic-diagram .diagram-edge{fill:none;stroke:var(--m-muted);stroke-width:1.4}
|
|
345
|
+
.mosaic-diagram .diagram-edge-accent{stroke:var(--m-accent);stroke-width:1.8}
|
|
346
|
+
.mosaic-diagram .diagram-edge-dashed{stroke-dasharray:5 4}
|
|
347
|
+
.mosaic-diagram .diagram-edge-label{font-size:10px;fill:var(--m-muted);paint-order:stroke;stroke:var(--m-bg);stroke-width:3;stroke-linejoin:round}
|
|
348
|
+
.mosaic-diagram .diagram-lifeline{stroke:var(--m-border);stroke-width:1;stroke-dasharray:3 4}
|
|
349
|
+
.mosaic-diagram .diagram-participant{fill:var(--m-bg2);stroke:var(--m-border);stroke-width:1}
|
|
350
|
+
.mosaic-diagram .diagram-participant-label{font-size:11px;font-weight:600;fill:var(--m-text)}
|
|
351
|
+
.mosaic-diagram .diagram-message{stroke:var(--m-text);stroke-width:1.2;fill:none}
|
|
352
|
+
.mosaic-diagram .diagram-message-return{stroke:var(--m-muted);stroke-dasharray:4 3}
|
|
353
|
+
.mosaic-diagram .diagram-message-async{stroke:var(--m-accent);stroke-dasharray:2 3}
|
|
354
|
+
.mosaic-diagram .diagram-msg-label{font-size:10.5px;fill:var(--m-text);paint-order:stroke;stroke:var(--m-bg);stroke-width:3;stroke-linejoin:round}
|
|
355
|
+
.mosaic-timeline{list-style:none;margin:0;padding:0 0 0 14px;position:relative;display:flex;flex-direction:column;gap:14px}
|
|
356
|
+
.mosaic-timeline::before{content:"";position:absolute;left:4px;top:4px;bottom:4px;width:1px;background:var(--m-border)}
|
|
357
|
+
.mosaic-timeline-item{position:relative;padding-left:12px;min-width:0}
|
|
358
|
+
.mosaic-timeline-dot{position:absolute;left:-13px;top:5px;width:7px;height:7px;border-radius:50%;background:var(--m-accent);box-shadow:0 0 0 3px var(--m-bg)}
|
|
359
|
+
.mosaic-timeline-time{font-size:10.5px;color:var(--m-muted);margin-bottom:2px}
|
|
360
|
+
.mosaic-timeline-title{font-weight:600;line-height:1.35}
|
|
361
|
+
.mosaic-timeline-desc{font-size:12.5px;color:var(--m-muted);margin-top:2px}
|
|
362
|
+
.mosaic-layers{display:flex;flex-direction:column;gap:7px}
|
|
363
|
+
.mosaic-layer{border:1px solid var(--m-border);border-radius:9px;background:var(--m-bg);padding:8px 10px}
|
|
364
|
+
.mosaic-layer-title{font-size:12px;font-weight:650;color:var(--m-text);margin-bottom:3px}
|
|
365
|
+
.mosaic-layer-items{margin:0;padding-left:16px;color:var(--m-muted);font-size:12.5px;line-height:1.5}
|
|
366
|
+
.mosaic-caption-line{font-size:12px;color:var(--m-muted);margin-top:5px}
|
|
367
|
+
.mosaic-media{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
368
|
+
.mosaic-image{display:block;max-width:100%;max-height:520px;border-radius:10px;border:1px solid var(--m-border);cursor:zoom-in;object-fit:contain;background:var(--m-bg2)}
|
|
369
|
+
.mosaic-video{display:block;width:100%;border-radius:10px;background:#000;border:1px solid var(--m-border)}
|
|
370
|
+
.mosaic-audio{display:block;width:100%}
|
|
371
|
+
.mosaic-pdf{display:block;width:100%;height:480px;border:1px solid var(--m-border);border-radius:10px;background:var(--m-bg)}
|
|
372
|
+
.mosaic-link{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:1px solid var(--m-border);border-radius:10px;text-decoration:none;color:var(--m-text);background:var(--m-bg)}
|
|
373
|
+
.mosaic-link:hover{border-color:var(--m-accent)}
|
|
374
|
+
.mosaic-link-icon{width:28px;height:28px;border-radius:7px;background:var(--m-bg2);display:flex;align-items:center;justify-content:center;color:var(--m-accent);flex:none;font-size:14px}
|
|
375
|
+
.mosaic-link-label{font-weight:600;overflow-wrap:anywhere}
|
|
376
|
+
.mosaic-link-desc{font-size:12px;color:var(--m-muted);margin-top:2px;overflow-wrap:anywhere}
|
|
377
|
+
.mosaic-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:6px 13px;border:1px solid var(--m-border);border-radius:8px;background:var(--m-bg);color:var(--m-text);font:inherit;font-size:13px;font-weight:550;cursor:pointer}
|
|
378
|
+
.mosaic-button:hover{border-color:var(--m-muted)}
|
|
379
|
+
.mosaic-button:focus-visible{outline:2px solid var(--m-accent);outline-offset:2px}
|
|
380
|
+
.mosaic-button:disabled,.mosaic-button[aria-disabled="true"]{opacity:.5;cursor:not-allowed}
|
|
381
|
+
.mosaic-button-primary{background:var(--m-accent);border-color:var(--m-accent);color:#fff}
|
|
382
|
+
.mosaic-button-primary:hover{filter:brightness(.95);border-color:var(--m-accent)}
|
|
383
|
+
.mosaic-button-danger{background:var(--m-danger);border-color:var(--m-danger);color:#fff}
|
|
384
|
+
.mosaic-button-success{background:var(--m-success);border-color:var(--m-success);color:#fff}
|
|
385
|
+
.mosaic-button-ghost{background:transparent}
|
|
386
|
+
.mosaic-button-full{width:100%}
|
|
387
|
+
.mosaic-form{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
388
|
+
.mosaic-field-label{font-size:12px;color:var(--m-muted)}
|
|
389
|
+
.mosaic-input,.mosaic-select{width:100%;min-height:34px;padding:6px 9px;border:1px solid var(--m-border);border-radius:8px;background:var(--m-bg);color:var(--m-text);font:inherit;font-size:13px}
|
|
390
|
+
.mosaic-input:focus,.mosaic-select:focus{outline:2px solid var(--m-accent);outline-offset:1px;border-color:var(--m-accent)}
|
|
391
|
+
.mosaic-slider-row{display:flex;align-items:center;gap:10px}
|
|
392
|
+
.mosaic-slider{flex:1;min-width:0;accent-color:var(--m-accent)}
|
|
393
|
+
.mosaic-slider-value{font-size:12px;color:var(--m-muted);font-variant-numeric:tabular-nums;min-width:34px;text-align:right}
|
|
394
|
+
.mosaic-tabs{display:flex;flex-direction:column;gap:10px;min-width:0}
|
|
395
|
+
.mosaic-tabbar{display:flex;gap:2px;border-bottom:1px solid var(--m-border);overflow-x:auto}
|
|
396
|
+
.mosaic-tab{border:0;background:transparent;color:var(--m-muted);font:inherit;font-size:12.5px;font-weight:600;padding:7px 10px;cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap}
|
|
397
|
+
.mosaic-tab:hover{color:var(--m-text)}
|
|
398
|
+
.mosaic-tab-active{color:var(--m-text);border-bottom-color:var(--m-accent)}
|
|
399
|
+
.mosaic-tab:focus-visible{outline:2px solid var(--m-accent);outline-offset:-2px}
|
|
400
|
+
.mosaic-tab-body{min-width:0}
|
|
401
|
+
.mosaic-quiz{display:flex;flex-direction:column;gap:10px;min-width:0}
|
|
402
|
+
.mosaic-quiz-question{font-size:15px;font-weight:650;line-height:1.4}
|
|
403
|
+
.mosaic-quiz-options{display:flex;flex-direction:column;gap:6px}
|
|
404
|
+
.mosaic-quiz-option{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border:1px solid var(--m-border);border-radius:8px;background:var(--m-bg);color:var(--m-text);font:inherit;font-size:13px;text-align:left;cursor:pointer}
|
|
405
|
+
.mosaic-quiz-option:hover{border-color:var(--m-muted)}
|
|
406
|
+
.mosaic-quiz-option:disabled{cursor:default}
|
|
407
|
+
.mosaic-quiz-correct{border-color:var(--m-success);background:color-mix(in srgb,var(--m-success) 8%,var(--m-bg))}
|
|
408
|
+
.mosaic-quiz-wrong{border-color:var(--m-danger);background:color-mix(in srgb,var(--m-danger) 8%,var(--m-bg))}
|
|
409
|
+
.mosaic-quiz-mark{font-weight:700;flex:none}
|
|
410
|
+
.mosaic-quiz-feedback{font-size:12px;color:var(--m-muted);margin-top:2px}
|
|
411
|
+
.mosaic-quiz-result{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;border-radius:8px;background:var(--m-bg2);font-weight:600}
|
|
412
|
+
.mosaic-quiz-result.ok{color:var(--m-success)}
|
|
413
|
+
.mosaic-quiz-result.no{color:var(--m-danger)}
|
|
414
|
+
.mosaic-widget{border:1px solid var(--m-border);border-radius:12px;overflow:hidden;background:var(--m-bg);min-width:0}
|
|
415
|
+
.mosaic-widget-head{display:flex;align-items:center;gap:8px;padding:7px 10px;border-bottom:1px solid var(--m-border);background:var(--m-bg2);min-width:0}
|
|
416
|
+
.mosaic-widget-title{font-size:12px;font-weight:650;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
417
|
+
.mosaic-widget-actions{margin-left:auto;display:flex;gap:4px;flex:none}
|
|
418
|
+
.mosaic-widget-action{border:0;background:transparent;color:var(--m-muted);font:inherit;font-size:11px;cursor:pointer;padding:2px 5px;border-radius:5px}
|
|
419
|
+
.mosaic-widget-action:hover{color:var(--m-text);background:var(--m-border)}
|
|
420
|
+
.mosaic-widget-iframe{display:block;width:100%;border:0;background:transparent}
|
|
421
|
+
.mosaic-widget-code{margin:0;padding:10px 12px;max-height:380px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre;background:var(--m-bg2)}
|
|
422
|
+
.mosaic-widget-caption{font-size:12px;color:var(--m-muted);margin-top:5px}
|
|
423
|
+
.mosaic-tool-fallback{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--m-border);border-radius:8px;color:var(--m-muted);font-size:12px}
|
|
424
|
+
.mosaic-tool-fallback b{color:var(--m-text);font-weight:600}
|
|
425
|
+
.mosaic-overlay-host{position:fixed;inset:0;z-index:2147483000}
|
|
426
|
+
.mosaic-overlay{position:fixed;inset:0;outline:none;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:24px;z-index:2147483000}
|
|
427
|
+
.mosaic-overlay-body{position:relative;width:min(1120px,96vw);max-height:92vh;overflow:auto;background:var(--m-bg);color:var(--m-text);border:1px solid var(--m-border);border-radius:14px;padding:16px;box-shadow:0 24px 70px rgba(0,0,0,.35)}
|
|
428
|
+
.mosaic-overlay-close{position:sticky;top:0;float:right;z-index:2;border:1px solid var(--m-border);background:var(--m-bg);color:var(--m-text);border-radius:8px;width:30px;height:30px;font-size:18px;line-height:1;cursor:pointer}
|
|
429
|
+
.mosaic-overlay-close:hover{background:var(--m-bg2)}
|
|
430
|
+
.mosaic-overlay-image{display:block;max-width:100%;max-height:76vh;margin:0 auto;border-radius:10px;object-fit:contain}
|
|
431
|
+
.mosaic-diagnostic{margin:0 0 6px;padding:6px 10px;border:1px solid color-mix(in srgb,var(--m-danger) 40%,transparent);border-radius:7px;background:color-mix(in srgb,var(--m-danger) 10%,transparent);color:var(--m-danger);font-size:12px;line-height:1.5;white-space:pre-wrap}
|
|
432
|
+
.mosaic-error{margin:8px 0;padding:8px 10px;border:1px solid color-mix(in srgb,var(--m-danger) 40%,transparent);border-radius:8px;background:color-mix(in srgb,var(--m-danger) 8%,var(--m-bg));color:var(--m-danger);font-size:12px}
|
|
433
|
+
.mosaic-skeleton{display:flex;flex-direction:column;gap:8px;padding:10px 0}
|
|
434
|
+
.mosaic-skeleton span{display:block;height:10px;border-radius:5px;background:var(--m-bg2);animation:mosaic-pulse 1.2s ease-in-out infinite}
|
|
435
|
+
.mosaic-skeleton span:nth-child(2){width:70%}
|
|
436
|
+
.mosaic-skeleton span:nth-child(3){width:45%}
|
|
437
|
+
@keyframes mosaic-pulse{0%,100%{opacity:.5}50%{opacity:1}}
|
|
438
|
+
@media (max-width:640px){
|
|
439
|
+
.mosaic-row{flex-wrap:wrap}
|
|
440
|
+
.mosaic-diff-pair{grid-template-columns:1fr}
|
|
441
|
+
.mosaic-kv{grid-template-columns:1fr}
|
|
442
|
+
.mosaic-table th,.mosaic-table td{padding:7px 8px}
|
|
443
|
+
}
|
|
444
|
+
@media (prefers-reduced-motion:reduce){
|
|
445
|
+
.mosaic-skeleton span{animation:none}
|
|
446
|
+
}
|
|
447
|
+
`
|
|
448
|
+
|
|
449
|
+
function ensureStyles() {
|
|
450
|
+
if (typeof document === 'undefined') return () => {}
|
|
451
|
+
// Tagged with this package's identity so the client module system's owned-style
|
|
452
|
+
// cleanup (`removeOwnedStyles`) can reclaim it when the entry is replaced or pruned,
|
|
453
|
+
// and so this generation always owns the disposer it returns.
|
|
454
|
+
for (const stale of document.querySelectorAll(`style[data-plugin-css=${JSON.stringify(STYLE_CSS_ID)}]`)) stale.remove()
|
|
455
|
+
const style = document.createElement('style')
|
|
456
|
+
style.dataset.plugin = PLUGIN_ID
|
|
457
|
+
style.dataset.pluginCss = STYLE_CSS_ID
|
|
458
|
+
style.textContent = CSS
|
|
459
|
+
document.head.appendChild(style)
|
|
460
|
+
return () => style.remove()
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ------------------------------------------------------------------ */
|
|
464
|
+
/* tiny utilities */
|
|
465
|
+
/* ------------------------------------------------------------------ */
|
|
466
|
+
|
|
467
|
+
function fingerprint(text) {
|
|
468
|
+
let hash = 5381
|
|
469
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
470
|
+
hash = ((hash << 5) + hash + text.charCodeAt(index)) >>> 0
|
|
471
|
+
}
|
|
472
|
+
return hash.toString(36)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function safeUrl(value, media = false) {
|
|
476
|
+
if (typeof value !== 'string') return undefined
|
|
477
|
+
const url = value.trim()
|
|
478
|
+
if (url === '' || url.length > 2048) return undefined
|
|
479
|
+
if (/^https?:\/\//i.test(url)) return url
|
|
480
|
+
if (!media && /^mailto:[^@\s]+@[^@\s]+$/i.test(url)) return url
|
|
481
|
+
if (url.startsWith('/') && !url.startsWith('//')) return url
|
|
482
|
+
if (url.startsWith('./') || url.startsWith('../')) return url
|
|
483
|
+
return undefined
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function clamp(value, min, max) {
|
|
487
|
+
return Math.min(max, Math.max(min, value))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function asString(value, cap = 2000) {
|
|
491
|
+
return typeof value === 'string' ? value.slice(0, cap) : undefined
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function asNumber(value) {
|
|
495
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function readStore() {
|
|
499
|
+
try {
|
|
500
|
+
const raw = localStorage.getItem(STORE_KEY)
|
|
501
|
+
if (!raw) return { order: [], blocks: {} }
|
|
502
|
+
const parsed = JSON.parse(raw)
|
|
503
|
+
if (!parsed || !Array.isArray(parsed.order) || !parsed.blocks || typeof parsed.blocks !== 'object') {
|
|
504
|
+
return { order: [], blocks: {} }
|
|
505
|
+
}
|
|
506
|
+
return { order: parsed.order.filter((key) => typeof key === 'string'), blocks: parsed.blocks }
|
|
507
|
+
} catch {
|
|
508
|
+
return { order: [], blocks: {} }
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function writeStore(store) {
|
|
513
|
+
try {
|
|
514
|
+
localStorage.setItem(STORE_KEY, JSON.stringify(store))
|
|
515
|
+
} catch {
|
|
516
|
+
// Quota/privacy failures are non-fatal.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function loadState(key) {
|
|
521
|
+
if (!key) return null
|
|
522
|
+
return readStore().blocks[key] ?? null
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function saveState(key, value) {
|
|
526
|
+
if (!key) return
|
|
527
|
+
const store = readStore()
|
|
528
|
+
const order = store.order.filter((entry) => entry !== key)
|
|
529
|
+
order.unshift(key)
|
|
530
|
+
const blocks = { ...store.blocks, [key]: value }
|
|
531
|
+
while (order.length > MAX_STORED) {
|
|
532
|
+
const evicted = order.pop()
|
|
533
|
+
if (evicted) delete blocks[evicted]
|
|
534
|
+
}
|
|
535
|
+
writeStore({ order, blocks })
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function clearState(key) {
|
|
539
|
+
if (!key) return
|
|
540
|
+
const store = readStore()
|
|
541
|
+
if (!(key in store.blocks)) return
|
|
542
|
+
const blocks = { ...store.blocks }
|
|
543
|
+
delete blocks[key]
|
|
544
|
+
writeStore({ order: store.order.filter((entry) => entry !== key), blocks })
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const PROMPT_URL = '/dsh-mosaic/prompt'
|
|
548
|
+
const PROMPT_LIMIT = 128 * 1024
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The model prompt, editable in place. The Host owns the value and persists it, so
|
|
552
|
+
* the same text is used by every browser and survives a restart; this page only
|
|
553
|
+
* reads it, posts a change, and shows which source is active.
|
|
554
|
+
*/
|
|
555
|
+
function MosaicSettings({ t }) {
|
|
556
|
+
const alive = useRef(true)
|
|
557
|
+
const [state, setState] = useState({ loading: true, text: '', saved: '', info: null, notice: null, saving: false })
|
|
558
|
+
useEffect(() => () => { alive.current = false }, [])
|
|
559
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error))
|
|
560
|
+
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
let current = true
|
|
563
|
+
fetch(PROMPT_URL, { headers: { accept: 'application/json' } })
|
|
564
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
565
|
+
.then((data) => {
|
|
566
|
+
if (!current) return
|
|
567
|
+
const text = typeof data.prompt === 'string' ? data.prompt : ''
|
|
568
|
+
setState({ loading: false, text, saved: text, info: data, notice: null, saving: false })
|
|
569
|
+
})
|
|
570
|
+
.catch((error) => {
|
|
571
|
+
if (!current) return
|
|
572
|
+
reportLog('warn', 'prompt.load-failed', { reason: reason(error).slice(0, 200) })
|
|
573
|
+
setState((previous) => ({ ...previous, loading: false, notice: { kind: 'error', text: t('promptLoadFailed', { reason: reason(error) }) } }))
|
|
574
|
+
})
|
|
575
|
+
return () => { current = false }
|
|
576
|
+
}, [t])
|
|
577
|
+
|
|
578
|
+
const submit = useCallback(async (body) => {
|
|
579
|
+
setState((previous) => ({ ...previous, saving: true, notice: null }))
|
|
580
|
+
try {
|
|
581
|
+
const response = await fetch(PROMPT_URL, {
|
|
582
|
+
method: 'POST',
|
|
583
|
+
headers: { 'content-type': 'application/json' },
|
|
584
|
+
body: JSON.stringify(body),
|
|
585
|
+
})
|
|
586
|
+
const data = await response.json().catch(() => ({}))
|
|
587
|
+
if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
|
|
588
|
+
if (!alive.current) return
|
|
589
|
+
const text = typeof data.prompt === 'string' ? data.prompt : ''
|
|
590
|
+
setState({
|
|
591
|
+
loading: false,
|
|
592
|
+
text,
|
|
593
|
+
saved: text,
|
|
594
|
+
info: data,
|
|
595
|
+
saving: false,
|
|
596
|
+
notice: { kind: 'ok', text: body.reset === true ? t('promptResetDone') : t('promptSaved') },
|
|
597
|
+
})
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (!alive.current) return
|
|
600
|
+
reportLog('warn', 'prompt.save-failed', { reason: reason(error).slice(0, 200) })
|
|
601
|
+
setState((previous) => ({ ...previous, saving: false, notice: { kind: 'error', text: t('promptSaveFailed', { reason: reason(error) }) } }))
|
|
602
|
+
}
|
|
603
|
+
}, [t])
|
|
604
|
+
|
|
605
|
+
const info = state.info
|
|
606
|
+
const dirty = !state.loading && state.text !== state.saved
|
|
607
|
+
const sourceLabel = info?.source === 'user'
|
|
608
|
+
? t('promptSourceUser')
|
|
609
|
+
: info?.source === 'config' ? t('promptSourceConfig') : t('promptSourceBuiltin')
|
|
610
|
+
const meta = [sourceLabel, dirty ? t('promptDirty') : '', state.loading ? '' : t('promptChars', { count: state.text.length, limit: PROMPT_LIMIT })]
|
|
611
|
+
.filter(Boolean)
|
|
612
|
+
.join(' · ')
|
|
613
|
+
|
|
614
|
+
return h('div', { className: 'mosaic-root mosaic-settings', 'data-mosaic-root': '' },
|
|
615
|
+
h('p', { className: 'mosaic-settings-desc' }, t('promptDesc')),
|
|
616
|
+
h('div', { className: 'mosaic-prompt-head' },
|
|
617
|
+
h('span', { className: 'mosaic-prompt-label' }, t('promptLabel')),
|
|
618
|
+
h('span', { className: 'mosaic-prompt-meta' }, meta)),
|
|
619
|
+
h('textarea', {
|
|
620
|
+
className: 'mosaic-prompt-field',
|
|
621
|
+
value: state.text,
|
|
622
|
+
rows: 14,
|
|
623
|
+
spellCheck: false,
|
|
624
|
+
'aria-label': t('promptLabel'),
|
|
625
|
+
placeholder: state.loading ? t('promptSaving') : '',
|
|
626
|
+
onChange: (event) => setState((previous) => ({ ...previous, text: event.target.value, notice: null })),
|
|
627
|
+
}),
|
|
628
|
+
info && info.source === 'user' && info.persisted === false
|
|
629
|
+
? h('p', { className: 'mosaic-prompt-warn' }, t('promptMemoryOnly'))
|
|
630
|
+
: null,
|
|
631
|
+
info?.readError === 'corrupt'
|
|
632
|
+
? h('p', { className: 'mosaic-prompt-warn' }, t('promptReadCorrupt'))
|
|
633
|
+
: null,
|
|
634
|
+
info?.readError === 'unreadable'
|
|
635
|
+
? h('p', { className: 'mosaic-prompt-warn' }, t('promptReadUnreadable'))
|
|
636
|
+
: null,
|
|
637
|
+
h('div', { className: 'mosaic-prompt-actions' },
|
|
638
|
+
h('span', { className: 'mosaic-prompt-hint' }, t('promptHint')),
|
|
639
|
+
h('button', {
|
|
640
|
+
type: 'button',
|
|
641
|
+
className: 'mosaic-prompt-reset',
|
|
642
|
+
disabled: state.loading || state.saving || info?.source !== 'user',
|
|
643
|
+
onClick: () => submit({ reset: true }),
|
|
644
|
+
}, t('promptReset')),
|
|
645
|
+
h('button', {
|
|
646
|
+
type: 'button',
|
|
647
|
+
className: 'mosaic-prompt-save',
|
|
648
|
+
disabled: !dirty || state.saving || state.text.trim() === '',
|
|
649
|
+
onClick: () => submit({ prompt: state.text }),
|
|
650
|
+
}, state.saving ? t('promptSaving') : t('promptSave'))),
|
|
651
|
+
state.notice
|
|
652
|
+
? h('p', {
|
|
653
|
+
className: `mosaic-prompt-notice ${state.notice.kind === 'error' ? 'is-error' : 'is-ok'}`,
|
|
654
|
+
role: 'status',
|
|
655
|
+
}, state.notice.text)
|
|
656
|
+
: null)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function currentSessionId(ctx) {
|
|
660
|
+
try {
|
|
661
|
+
const list = ctx.sessions?.list?.getSnapshot?.()
|
|
662
|
+
if (!list || !Array.isArray(list.ids)) return undefined
|
|
663
|
+
return list.ids.find((id) => {
|
|
664
|
+
const snapshot = ctx.sessions?.retainInfo?.(id)?.getSnapshot?.()
|
|
665
|
+
return (snapshot?.retainedBy?.mainView ?? 0) > 0
|
|
666
|
+
})
|
|
667
|
+
} catch {
|
|
668
|
+
return undefined
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// The context stays in the apply closure; components only receive this callback.
|
|
673
|
+
function makeSender(ctx) {
|
|
674
|
+
return function sendAction(sessionId, action, payload) {
|
|
675
|
+
if (!sessionId) return
|
|
676
|
+
try {
|
|
677
|
+
const scoped = ctx.sessions?.scope?.(sessionId)
|
|
678
|
+
const conversation = scoped?.get?.('conversation')
|
|
679
|
+
if (!conversation?.send) return
|
|
680
|
+
const text = `[mosaic-action] ${translate('actionNotice', { action })} ${JSON.stringify(payload ?? {})}`
|
|
681
|
+
Promise.resolve(conversation.send(text)).catch(() => {})
|
|
682
|
+
} catch {
|
|
683
|
+
// A gone session or busy agent drops the action; the UI stays live.
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Resolve the host theme tokens for the sandboxed widget document. The plugin
|
|
690
|
+
* owns no theme state and guesses no light/dark mode: the iframe declares
|
|
691
|
+
* `color-scheme: light dark`, and a token that is absent simply keeps the
|
|
692
|
+
* neutral value the widget document already falls back to.
|
|
693
|
+
*/
|
|
694
|
+
function themeSnapshot(host) {
|
|
695
|
+
if (typeof window === 'undefined') return {}
|
|
696
|
+
const target = host instanceof Element ? host : (document.body ?? document.documentElement)
|
|
697
|
+
const style = getComputedStyle(target)
|
|
698
|
+
const tokens = {
|
|
699
|
+
bg: '--dsw-alias-bg-layer-1',
|
|
700
|
+
text: '--dsw-alias-label-primary',
|
|
701
|
+
muted: '--dsw-alias-label-secondary',
|
|
702
|
+
border: '--dsw-alias-border-l1',
|
|
703
|
+
accent: '--dsw-alias-brand-primary',
|
|
704
|
+
success: '--dsw-alias-state-success-primary',
|
|
705
|
+
warn: '--dsw-alias-state-warn-primary',
|
|
706
|
+
danger: '--dsw-alias-state-error-primary',
|
|
707
|
+
card: '--dsw-alias-bg-layer-2',
|
|
708
|
+
}
|
|
709
|
+
const snapshot = {}
|
|
710
|
+
for (const name of Object.keys(tokens)) {
|
|
711
|
+
const value = style.getPropertyValue(tokens[name]).trim()
|
|
712
|
+
if (value !== '') snapshot[name] = value
|
|
713
|
+
}
|
|
714
|
+
return snapshot
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/* ------------------------------------------------------------------ */
|
|
718
|
+
/* partial JSON parser */
|
|
719
|
+
/* ------------------------------------------------------------------ */
|
|
720
|
+
|
|
721
|
+
function tryPartial(text) {
|
|
722
|
+
const stack = []
|
|
723
|
+
const candidates = []
|
|
724
|
+
let inString = false
|
|
725
|
+
let escaped = false
|
|
726
|
+
const push = (end, suffix) => {
|
|
727
|
+
if (candidates.length >= 24) candidates.shift()
|
|
728
|
+
candidates.push({ end, suffix })
|
|
729
|
+
}
|
|
730
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
731
|
+
const character = text[index]
|
|
732
|
+
if (inString) {
|
|
733
|
+
if (escaped) escaped = false
|
|
734
|
+
else if (character === '\\') escaped = true
|
|
735
|
+
else if (character === '"') inString = false
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
if (character === '"') {
|
|
739
|
+
inString = true
|
|
740
|
+
continue
|
|
741
|
+
}
|
|
742
|
+
if (character === '{' || character === '[') {
|
|
743
|
+
stack.push(character)
|
|
744
|
+
continue
|
|
745
|
+
}
|
|
746
|
+
if (character === '}' || character === ']') {
|
|
747
|
+
const expected = character === '}' ? '{' : '['
|
|
748
|
+
if (stack.pop() !== expected) break
|
|
749
|
+
if (character === '}' && stack.length === 2 && stack[0] === '{' && stack[1] === '[') {
|
|
750
|
+
push(index + 1, ']}')
|
|
751
|
+
}
|
|
752
|
+
if (stack.length === 0) push(index + 1, '')
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
candidates.sort((a, b) => b.end - a.end)
|
|
756
|
+
const seen = new Set()
|
|
757
|
+
for (const candidate of candidates) {
|
|
758
|
+
const key = `${candidate.end}:${candidate.suffix}`
|
|
759
|
+
if (seen.has(key)) continue
|
|
760
|
+
seen.add(key)
|
|
761
|
+
try {
|
|
762
|
+
const value = JSON.parse(text.slice(0, candidate.end) + candidate.suffix)
|
|
763
|
+
if (value && typeof value === 'object') return value
|
|
764
|
+
} catch {
|
|
765
|
+
// Try the next candidate.
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return null
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function parseView(raw) {
|
|
772
|
+
const text = String(raw ?? '').trim()
|
|
773
|
+
if (!text) return null
|
|
774
|
+
let value
|
|
775
|
+
try {
|
|
776
|
+
value = JSON.parse(text)
|
|
777
|
+
} catch {
|
|
778
|
+
value = tryPartial(text)
|
|
779
|
+
}
|
|
780
|
+
if (typeof value === 'string') {
|
|
781
|
+
try {
|
|
782
|
+
value = JSON.parse(value)
|
|
783
|
+
} catch {
|
|
784
|
+
return null
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (Array.isArray(value)) value = { items: value }
|
|
788
|
+
if (!value || typeof value !== 'object') return null
|
|
789
|
+
if (!Array.isArray(value.items)) {
|
|
790
|
+
if (typeof value.type === 'string') value = { items: [value] }
|
|
791
|
+
else return null
|
|
792
|
+
}
|
|
793
|
+
const items = value.items.slice(0, MAX_NODES).map((node) => coerceNode(node, 0)).filter(Boolean)
|
|
794
|
+
if (items.length === 0) return null
|
|
795
|
+
return {
|
|
796
|
+
title: typeof value.title === 'string' ? value.title.slice(0, 2000) : undefined,
|
|
797
|
+
gap: typeof value.gap === 'number' && Number.isFinite(value.gap) ? clamp(value.gap, 0, 48) : undefined,
|
|
798
|
+
items,
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function coerceItems(value, depth) {
|
|
803
|
+
if (!Array.isArray(value)) return []
|
|
804
|
+
return value.slice(0, MAX_NODES).map((node) => coerceNode(node, depth + 1)).filter(Boolean)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function coerceNode(raw, depth) {
|
|
808
|
+
if (depth > MAX_DEPTH || !raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
809
|
+
const type = typeof raw.type === 'string' ? raw.type : undefined
|
|
810
|
+
if (!type) return null
|
|
811
|
+
const out = { ...raw, type }
|
|
812
|
+
const text = (value, cap = 2000) => asString(value, cap)
|
|
813
|
+
switch (type) {
|
|
814
|
+
case 'text':
|
|
815
|
+
out.content = text(raw.content ?? raw.text)
|
|
816
|
+
if (!out.content) return null
|
|
817
|
+
break
|
|
818
|
+
case 'callout': {
|
|
819
|
+
out.content = text(raw.content ?? raw.text ?? raw.body ?? raw.desc)
|
|
820
|
+
if (!out.content) return null
|
|
821
|
+
if (raw.tone === 'danger') out.tone = 'error'
|
|
822
|
+
if (raw.tone === 'warn') out.tone = 'warning'
|
|
823
|
+
break
|
|
824
|
+
}
|
|
825
|
+
case 'table': {
|
|
826
|
+
let columns = Array.isArray(raw.columns) ? raw.columns.slice(0, 8).map((column) => text(column, 256) ?? '') : undefined
|
|
827
|
+
let rows = Array.isArray(raw.rows) ? raw.rows : Array.isArray(raw.data) ? raw.data : []
|
|
828
|
+
if (rows.length > 0 && rows[0] && typeof rows[0] === 'object' && !Array.isArray(rows[0])) {
|
|
829
|
+
const keys = columns && columns.length ? columns : Object.keys(rows[0])
|
|
830
|
+
rows = rows.map((row) => keys.map((key) => row?.[key]))
|
|
831
|
+
if (!columns || columns.length === 0) columns = keys
|
|
832
|
+
}
|
|
833
|
+
if ((!columns || columns.length === 0) && rows.length > 0 && Array.isArray(rows[0])) {
|
|
834
|
+
columns = rows[0].map((cell) => String(cell ?? ''))
|
|
835
|
+
rows = rows.slice(1)
|
|
836
|
+
}
|
|
837
|
+
if (!columns || columns.length === 0) return null
|
|
838
|
+
out.columns = columns
|
|
839
|
+
out.rows = rows.slice(0, 40).map((row) => Array.isArray(row) ? row.slice(0, 8).map((cell) => typeof cell === 'number' ? cell : String(cell ?? '')) : [])
|
|
840
|
+
break
|
|
841
|
+
}
|
|
842
|
+
case 'list':
|
|
843
|
+
out.items = Array.isArray(raw.items) ? raw.items.slice(0, 30).map((item) => {
|
|
844
|
+
if (typeof item === 'string') return item.slice(0, 2000)
|
|
845
|
+
if (item && typeof item.type === 'string') return coerceNode(item, depth + 1)
|
|
846
|
+
if (item && typeof item === 'object') {
|
|
847
|
+
const title = text(item.title ?? item.label ?? item.name)
|
|
848
|
+
if (!title) return null
|
|
849
|
+
return { title, desc: text(item.desc ?? item.description ?? item.content ?? item.text) }
|
|
850
|
+
}
|
|
851
|
+
return null
|
|
852
|
+
}).filter(Boolean) : []
|
|
853
|
+
if (out.items.length === 0) return null
|
|
854
|
+
break
|
|
855
|
+
case 'chart': {
|
|
856
|
+
out.kind = ['bars', 'line', 'donut'].includes(raw.kind) ? raw.kind : 'bars'
|
|
857
|
+
out.data = Array.isArray(raw.data) ? raw.data.slice(0, 40).map((datum) => ({
|
|
858
|
+
label: text(datum?.label, 128) ?? '',
|
|
859
|
+
value: asNumber(datum?.value) ?? 0,
|
|
860
|
+
})).filter((datum) => datum.label !== '') : []
|
|
861
|
+
out.series = Array.isArray(raw.series) ? raw.series.slice(0, 8).map((series) => ({
|
|
862
|
+
label: text(series?.label, 128) ?? '',
|
|
863
|
+
data: Array.isArray(series?.data) ? series.data.slice(0, 40).map((datum) => ({
|
|
864
|
+
label: text(datum?.label, 128) ?? '',
|
|
865
|
+
value: asNumber(datum?.value) ?? 0,
|
|
866
|
+
})).filter((datum) => datum.label !== '') : [],
|
|
867
|
+
})).filter((series) => series.label !== '') : []
|
|
868
|
+
if (out.data.length === 0 && out.series.length === 0) return null
|
|
869
|
+
if (out.kind === 'donut') out.series = []
|
|
870
|
+
break
|
|
871
|
+
}
|
|
872
|
+
case 'progress':
|
|
873
|
+
out.value = clamp(asNumber(raw.value) ?? 0, 0, 100)
|
|
874
|
+
break
|
|
875
|
+
case 'stat':
|
|
876
|
+
if (!text(raw.label ?? raw.title) || !text(raw.value, 128)) return null
|
|
877
|
+
out.label = text(raw.label ?? raw.title)
|
|
878
|
+
out.value = text(raw.value, 128)
|
|
879
|
+
out.spark = Array.isArray(raw.spark) ? raw.spark.filter((n) => typeof n === 'number' && Number.isFinite(n)).slice(0, 20) : undefined
|
|
880
|
+
break
|
|
881
|
+
case 'badge':
|
|
882
|
+
out.label = text(raw.label ?? raw.text)
|
|
883
|
+
if (!out.label) return null
|
|
884
|
+
break
|
|
885
|
+
case 'keyvalue':
|
|
886
|
+
out.pairs = Array.isArray(raw.pairs) ? raw.pairs.slice(0, 20).map((pair) => ({
|
|
887
|
+
key: text(pair?.key ?? pair?.label, 256) ?? '',
|
|
888
|
+
value: text(pair?.value, 2000) ?? '',
|
|
889
|
+
})).filter((pair) => pair.key !== '') : []
|
|
890
|
+
if (out.pairs.length === 0) return null
|
|
891
|
+
break
|
|
892
|
+
case 'steps':
|
|
893
|
+
out.steps = Array.isArray(raw.steps) ? raw.steps.slice(0, 12).map((step) => ({
|
|
894
|
+
title: text(step?.title, 256) ?? '',
|
|
895
|
+
desc: text(step?.desc, 2000),
|
|
896
|
+
})).filter((step) => step.title !== '') : []
|
|
897
|
+
if (out.steps.length === 0) return null
|
|
898
|
+
out.current = Number.isFinite(raw.current) ? clamp(Math.trunc(raw.current), 0, out.steps.length) : undefined
|
|
899
|
+
break
|
|
900
|
+
case 'code':
|
|
901
|
+
out.code = text(raw.code, 12000)
|
|
902
|
+
if (!out.code) return null
|
|
903
|
+
break
|
|
904
|
+
case 'diff':
|
|
905
|
+
out.diffs = Array.isArray(raw.diffs) ? raw.diffs.slice(0, 8).map((diff) => ({
|
|
906
|
+
path: text(diff?.path, 512) ?? '',
|
|
907
|
+
newText: text(diff?.newText, 12000) ?? '',
|
|
908
|
+
oldText: typeof diff?.oldText === 'string' ? diff.oldText.slice(0, 12000) : null,
|
|
909
|
+
})).filter((diff) => diff.path !== '') : []
|
|
910
|
+
if (out.diffs.length === 0) return null
|
|
911
|
+
break
|
|
912
|
+
case 'image':
|
|
913
|
+
case 'video':
|
|
914
|
+
case 'audio':
|
|
915
|
+
case 'pdf': {
|
|
916
|
+
const src = safeUrl(raw.src ?? raw.url, true)
|
|
917
|
+
if (!src) return null
|
|
918
|
+
out.src = src
|
|
919
|
+
if (type === 'video' && raw.poster !== undefined) {
|
|
920
|
+
const poster = safeUrl(raw.poster, true)
|
|
921
|
+
if (poster) out.poster = poster
|
|
922
|
+
else delete out.poster
|
|
923
|
+
}
|
|
924
|
+
break
|
|
925
|
+
}
|
|
926
|
+
case 'link': {
|
|
927
|
+
const href = safeUrl(raw.href ?? raw.url)
|
|
928
|
+
const label = text(raw.label ?? raw.text)
|
|
929
|
+
if (!href || !label) return null
|
|
930
|
+
out.href = href
|
|
931
|
+
out.label = label
|
|
932
|
+
break
|
|
933
|
+
}
|
|
934
|
+
case 'button':
|
|
935
|
+
out.label = text(raw.label ?? raw.text)
|
|
936
|
+
if (!out.label) return null
|
|
937
|
+
if (raw.action !== undefined && typeof raw.action !== 'string') delete out.action
|
|
938
|
+
if (raw.local !== undefined && !['reset', 'copy', 'open', 'fullscreen'].includes(raw.local)) delete out.local
|
|
939
|
+
break
|
|
940
|
+
case 'input':
|
|
941
|
+
case 'select':
|
|
942
|
+
case 'slider':
|
|
943
|
+
out.id = text(raw.id, 128)
|
|
944
|
+
break
|
|
945
|
+
case 'tabs':
|
|
946
|
+
out.tabs = Array.isArray(raw.tabs) ? raw.tabs.slice(0, 8).map((tab) => ({
|
|
947
|
+
label: text(tab?.label, 128) ?? '',
|
|
948
|
+
items: coerceItems(tab?.items ?? [], depth + 1),
|
|
949
|
+
})).filter((tab) => tab.label !== '') : []
|
|
950
|
+
if (out.tabs.length === 0) return null
|
|
951
|
+
break
|
|
952
|
+
case 'quiz':
|
|
953
|
+
out.question = text(raw.question)
|
|
954
|
+
out.options = Array.isArray(raw.options) ? raw.options.slice(0, 6).map((option) => {
|
|
955
|
+
if (typeof option === 'string') return { label: option.slice(0, 512) }
|
|
956
|
+
return {
|
|
957
|
+
label: text(option?.label, 512) ?? '',
|
|
958
|
+
correct: option?.correct === true,
|
|
959
|
+
feedback: text(option?.feedback),
|
|
960
|
+
}
|
|
961
|
+
}).filter((option) => option.label !== '') : []
|
|
962
|
+
if (!out.question || out.options.length < 2) return null
|
|
963
|
+
break
|
|
964
|
+
case 'widget':
|
|
965
|
+
out.code = text(raw.code, 20000)
|
|
966
|
+
if (!out.code) return null
|
|
967
|
+
out.height = clamp(asNumber(raw.height) ?? 320, 80, 900)
|
|
968
|
+
break
|
|
969
|
+
case 'diagram': {
|
|
970
|
+
const inferred = raw.kind === undefined
|
|
971
|
+
? (Array.isArray(raw.nodes) || Array.isArray(raw.edges) ? 'flowchart'
|
|
972
|
+
: Array.isArray(raw.participants) || Array.isArray(raw.messages) ? 'sequence'
|
|
973
|
+
: Array.isArray(raw.layers) ? 'layers'
|
|
974
|
+
: Array.isArray(raw.items) ? 'timeline' : undefined)
|
|
975
|
+
: undefined
|
|
976
|
+
const kind = ['flowchart', 'sequence', 'timeline', 'layers'].includes(raw.kind) ? raw.kind : inferred
|
|
977
|
+
if (!kind) return null
|
|
978
|
+
out.kind = kind
|
|
979
|
+
out.title = text(raw.title, 256)
|
|
980
|
+
if (kind === 'flowchart') {
|
|
981
|
+
const ids = new Set()
|
|
982
|
+
out.nodes = (Array.isArray(raw.nodes) ? raw.nodes : []).slice(0, 12).map((node) => {
|
|
983
|
+
const id = text(node?.id, 64)
|
|
984
|
+
const label = text(node?.label, 128)
|
|
985
|
+
if (!id || !label || ids.has(id)) return null
|
|
986
|
+
ids.add(id)
|
|
987
|
+
return {
|
|
988
|
+
id,
|
|
989
|
+
label,
|
|
990
|
+
sub: text(node?.sub, 128),
|
|
991
|
+
tone: ['accent', 'success', 'warning', 'danger', 'muted'].includes(node?.tone) ? node.tone : undefined,
|
|
992
|
+
shape: ['box', 'round', 'diamond'].includes(node?.shape) ? node.shape : undefined,
|
|
993
|
+
}
|
|
994
|
+
}).filter(Boolean)
|
|
995
|
+
if (out.nodes.length === 0) return null
|
|
996
|
+
out.edges = (Array.isArray(raw.edges) ? raw.edges : []).slice(0, 20).map((edge) => {
|
|
997
|
+
const from = text(edge?.from, 64)
|
|
998
|
+
const to = text(edge?.to, 64)
|
|
999
|
+
if (!from || !to || !ids.has(from) || !ids.has(to)) return null
|
|
1000
|
+
return {
|
|
1001
|
+
from,
|
|
1002
|
+
to,
|
|
1003
|
+
label: text(edge?.label, 64),
|
|
1004
|
+
kind: ['solid', 'dashed', 'accent'].includes(edge?.kind) ? edge.kind : undefined,
|
|
1005
|
+
}
|
|
1006
|
+
}).filter(Boolean)
|
|
1007
|
+
out.direction = raw.direction === 'LR' ? 'LR' : 'TB'
|
|
1008
|
+
} else if (kind === 'sequence') {
|
|
1009
|
+
const ids = new Set()
|
|
1010
|
+
out.participants = (Array.isArray(raw.participants) ? raw.participants : []).slice(0, 8).map((participant) => {
|
|
1011
|
+
const id = text(participant?.id, 64)
|
|
1012
|
+
const label = text(participant?.label, 128)
|
|
1013
|
+
if (!id || !label || ids.has(id)) return null
|
|
1014
|
+
ids.add(id)
|
|
1015
|
+
return { id, label }
|
|
1016
|
+
}).filter(Boolean)
|
|
1017
|
+
if (out.participants.length === 0) return null
|
|
1018
|
+
out.messages = (Array.isArray(raw.messages) ? raw.messages : []).slice(0, 20).map((message) => {
|
|
1019
|
+
const from = text(message?.from, 64)
|
|
1020
|
+
const to = text(message?.to, 64)
|
|
1021
|
+
const label = text(message?.label, 128)
|
|
1022
|
+
if (!from || !to || !label || !ids.has(from) || !ids.has(to)) return null
|
|
1023
|
+
return {
|
|
1024
|
+
from,
|
|
1025
|
+
to,
|
|
1026
|
+
label,
|
|
1027
|
+
kind: ['sync', 'return', 'async'].includes(message?.kind) ? message.kind : undefined,
|
|
1028
|
+
}
|
|
1029
|
+
}).filter(Boolean)
|
|
1030
|
+
if (out.messages.length === 0) return null
|
|
1031
|
+
} else if (kind === 'timeline') {
|
|
1032
|
+
out.items = (Array.isArray(raw.items) ? raw.items : []).slice(0, 12).map((item) => {
|
|
1033
|
+
const itemTitle = text(item?.title, 256)
|
|
1034
|
+
if (!itemTitle) return null
|
|
1035
|
+
return { title: itemTitle, desc: text(item?.desc), time: text(item?.time, 64) }
|
|
1036
|
+
}).filter(Boolean)
|
|
1037
|
+
if (out.items.length === 0) return null
|
|
1038
|
+
} else {
|
|
1039
|
+
out.layers = (Array.isArray(raw.layers) ? raw.layers : []).slice(0, 8).map((layer) => {
|
|
1040
|
+
const layerTitle = text(layer?.title, 128)
|
|
1041
|
+
const layerItems = Array.isArray(layer?.items)
|
|
1042
|
+
? layer.items.slice(0, 6).map((item) => text(item, 256)).filter(Boolean)
|
|
1043
|
+
: []
|
|
1044
|
+
if (!layerTitle || layerItems.length === 0) return null
|
|
1045
|
+
return { title: layerTitle, items: layerItems }
|
|
1046
|
+
}).filter(Boolean)
|
|
1047
|
+
if (out.layers.length === 0) return null
|
|
1048
|
+
}
|
|
1049
|
+
break
|
|
1050
|
+
}
|
|
1051
|
+
case 'row':
|
|
1052
|
+
case 'col':
|
|
1053
|
+
case 'grid':
|
|
1054
|
+
case 'card':
|
|
1055
|
+
out.items = coerceItems(raw.items ?? [], depth + 1)
|
|
1056
|
+
if (out.items.length === 0) return null
|
|
1057
|
+
break
|
|
1058
|
+
case 'divider':
|
|
1059
|
+
case 'spacer':
|
|
1060
|
+
break
|
|
1061
|
+
default:
|
|
1062
|
+
return null
|
|
1063
|
+
}
|
|
1064
|
+
if (raw.span !== undefined && asNumber(raw.span) !== undefined) out.span = clamp(Math.trunc(raw.span), 1, 6)
|
|
1065
|
+
return out
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function describeParse(raw) {
|
|
1069
|
+
try {
|
|
1070
|
+
JSON.parse(String(raw ?? ''))
|
|
1071
|
+
return 'JSON parsed but the root has no usable items'
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
return error instanceof Error ? error.message : String(error)
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/* ------------------------------------------------------------------ */
|
|
1078
|
+
/* rich text */
|
|
1079
|
+
/* ------------------------------------------------------------------ */
|
|
1080
|
+
|
|
1081
|
+
const INLINE = /(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^\]\n]+\]\([^)\s]+\)|\r?\n)/g
|
|
1082
|
+
|
|
1083
|
+
function renderRich(value, allowLinks = true, depth = 0) {
|
|
1084
|
+
if (typeof value !== 'string' || value === '' || depth > 4) return value
|
|
1085
|
+
const out = []
|
|
1086
|
+
let last = 0
|
|
1087
|
+
let key = 0
|
|
1088
|
+
for (const match of value.matchAll(INLINE)) {
|
|
1089
|
+
const index = match.index ?? 0
|
|
1090
|
+
const token = match[0]
|
|
1091
|
+
if (index > last) out.push(value.slice(last, index))
|
|
1092
|
+
if (token === '\n' || token === '\r\n') {
|
|
1093
|
+
out.push('\n')
|
|
1094
|
+
} else if (token.startsWith('`')) {
|
|
1095
|
+
out.push(h('code', { key: key += 1, className: 'mosaic-inline-code' }, token.slice(1, -1)))
|
|
1096
|
+
} else if (token.startsWith('**')) {
|
|
1097
|
+
out.push(h('strong', { key: key += 1 }, renderRich(token.slice(2, -2), allowLinks, depth + 1)))
|
|
1098
|
+
} else {
|
|
1099
|
+
const parts = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token)
|
|
1100
|
+
const href = parts ? safeUrl(parts[2]) : undefined
|
|
1101
|
+
if (parts && href && allowLinks) {
|
|
1102
|
+
out.push(h('a', {
|
|
1103
|
+
key: key += 1,
|
|
1104
|
+
href,
|
|
1105
|
+
target: '_blank',
|
|
1106
|
+
rel: 'noreferrer noopener',
|
|
1107
|
+
}, renderRich(parts[1], false, depth + 1)))
|
|
1108
|
+
} else if (parts) {
|
|
1109
|
+
out.push(renderRich(parts[1], false, depth + 1))
|
|
1110
|
+
} else {
|
|
1111
|
+
out.push(token)
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
last = index + token.length
|
|
1115
|
+
}
|
|
1116
|
+
if (last < value.length) out.push(value.slice(last))
|
|
1117
|
+
return out.length === 0 ? value : out
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/* ------------------------------------------------------------------ */
|
|
1121
|
+
/* local state */
|
|
1122
|
+
/* ------------------------------------------------------------------ */
|
|
1123
|
+
|
|
1124
|
+
const ViewContext = createContext(null)
|
|
1125
|
+
|
|
1126
|
+
function useView() {
|
|
1127
|
+
return useContext(ViewContext)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function useDebouncedAction() {
|
|
1131
|
+
const timer = useRef(null)
|
|
1132
|
+
useEffect(() => () => {
|
|
1133
|
+
if (timer.current) clearTimeout(timer.current)
|
|
1134
|
+
}, [])
|
|
1135
|
+
return useCallback((fn) => {
|
|
1136
|
+
if (timer.current) clearTimeout(timer.current)
|
|
1137
|
+
timer.current = setTimeout(fn, 300)
|
|
1138
|
+
}, [])
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function copyText(text) {
|
|
1142
|
+
try {
|
|
1143
|
+
if (navigator.clipboard?.writeText) {
|
|
1144
|
+
navigator.clipboard.writeText(text).catch(() => {})
|
|
1145
|
+
return
|
|
1146
|
+
}
|
|
1147
|
+
} catch {
|
|
1148
|
+
// Fall through to the legacy path.
|
|
1149
|
+
}
|
|
1150
|
+
try {
|
|
1151
|
+
const area = document.createElement('textarea')
|
|
1152
|
+
area.value = text
|
|
1153
|
+
area.style.position = 'fixed'
|
|
1154
|
+
area.style.opacity = '0'
|
|
1155
|
+
document.body.appendChild(area)
|
|
1156
|
+
area.select()
|
|
1157
|
+
document.execCommand('copy')
|
|
1158
|
+
area.remove()
|
|
1159
|
+
} catch {
|
|
1160
|
+
// Clipboard unavailable; nothing to do.
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/* ------------------------------------------------------------------ */
|
|
1165
|
+
/* atomic renderers */
|
|
1166
|
+
/* ------------------------------------------------------------------ */
|
|
1167
|
+
|
|
1168
|
+
function Sparkline({ values }) {
|
|
1169
|
+
const points = values.slice(0, 20)
|
|
1170
|
+
if (points.length < 2) return null
|
|
1171
|
+
const width = 84
|
|
1172
|
+
const height = 22
|
|
1173
|
+
const min = Math.min(...points)
|
|
1174
|
+
const max = Math.max(...points)
|
|
1175
|
+
const span = max - min || 1
|
|
1176
|
+
const step = (width - 4) / (points.length - 1)
|
|
1177
|
+
const coords = points.map((value, index) => [2 + index * step, height - 2 - ((value - min) / span) * (height - 4)])
|
|
1178
|
+
const line = coords.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
|
|
1179
|
+
return h('svg', { className: 'mosaic-spark', viewBox: `0 0 ${width} ${height}`, 'aria-hidden': true }, h('polyline', {
|
|
1180
|
+
points: line,
|
|
1181
|
+
fill: 'none',
|
|
1182
|
+
stroke: 'var(--m-series-1)',
|
|
1183
|
+
strokeWidth: 1.5,
|
|
1184
|
+
strokeLinejoin: 'round',
|
|
1185
|
+
strokeLinecap: 'round',
|
|
1186
|
+
}))
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function deltaClass(value) {
|
|
1190
|
+
if (typeof value !== 'string') return ''
|
|
1191
|
+
if (value.trim().startsWith('+')) return 'mosaic-delta-pos'
|
|
1192
|
+
if (value.trim().startsWith('-')) return 'mosaic-delta-neg'
|
|
1193
|
+
return 'mosaic-delta-flat'
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function TextNode({ node }) {
|
|
1197
|
+
const size = ['display', 'h1', 'h2', 'h3', 'body', 'muted', 'caption'].includes(node.size) ? node.size : 'body'
|
|
1198
|
+
const align = ['left', 'center', 'right'].includes(node.align) ? node.align : undefined
|
|
1199
|
+
const tone = ['danger', 'success', 'warning', 'info', 'muted'].includes(node.tone) ? node.tone : undefined
|
|
1200
|
+
return h('div', {
|
|
1201
|
+
className: [
|
|
1202
|
+
'mosaic-text',
|
|
1203
|
+
size === 'body' ? '' : `mosaic-${size}`,
|
|
1204
|
+
align ? `mosaic-${align}` : '',
|
|
1205
|
+
tone ? `mosaic-tone-${tone}` : '',
|
|
1206
|
+
node.mono ? 'mosaic-mono' : '',
|
|
1207
|
+
].filter(Boolean).join(' '),
|
|
1208
|
+
}, renderRich(node.content))
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function StatNode({ node }) {
|
|
1212
|
+
return h('div', { className: 'mosaic-stat' },
|
|
1213
|
+
h('div', { className: 'mosaic-stat-label' }, renderRich(node.label)),
|
|
1214
|
+
h('div', { className: 'mosaic-stat-value' }, renderRich(node.value)),
|
|
1215
|
+
(node.delta || (node.spark && node.spark.length >= 2))
|
|
1216
|
+
? h('div', { className: 'mosaic-stat-foot' },
|
|
1217
|
+
node.delta ? h('span', { className: `mosaic-delta ${deltaClass(node.delta)}` }, node.delta) : null,
|
|
1218
|
+
node.spark && node.spark.length >= 2 ? h(Sparkline, { values: node.spark }) : null)
|
|
1219
|
+
: null)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function BadgeNode({ node }) {
|
|
1223
|
+
return h('span', { className: `mosaic-badge mosaic-badge-${node.tone ?? 'accent'}` }, renderRich(node.label))
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function ProgressNode({ node }) {
|
|
1227
|
+
const value = clamp(Number(node.value) || 0, 0, 100)
|
|
1228
|
+
if (node.variant === 'ring') {
|
|
1229
|
+
const radius = 24
|
|
1230
|
+
const circumference = 2 * Math.PI * radius
|
|
1231
|
+
const dash = (value / 100) * circumference
|
|
1232
|
+
return h('div', { className: 'mosaic-ring' },
|
|
1233
|
+
h('svg', { viewBox: '0 0 64 64', role: 'img', 'aria-label': `${value}%` },
|
|
1234
|
+
h('circle', { cx: 32, cy: 32, r: radius, fill: 'none', stroke: 'var(--m-bg2)', strokeWidth: 6 }),
|
|
1235
|
+
h('circle', {
|
|
1236
|
+
cx: 32, cy: 32, r: radius, fill: 'none', stroke: 'var(--m-series-1)', strokeWidth: 6,
|
|
1237
|
+
strokeLinecap: 'round', strokeDasharray: `${dash} ${circumference - dash}`,
|
|
1238
|
+
transform: 'rotate(-90 32 32)',
|
|
1239
|
+
}),
|
|
1240
|
+
h('text', { x: 32, y: 36, textAnchor: 'middle', style: { fontSize: 12, fontWeight: 650, fill: 'var(--m-text)' } }, `${Math.round(value)}%`)),
|
|
1241
|
+
h('div', { className: 'mosaic-progress' },
|
|
1242
|
+
node.label ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.label)) : null,
|
|
1243
|
+
node.valueLabel ? h('b', null, node.valueLabel) : null))
|
|
1244
|
+
}
|
|
1245
|
+
return h('div', { className: 'mosaic-progress' },
|
|
1246
|
+
(node.label || node.valueLabel)
|
|
1247
|
+
? h('div', { className: 'mosaic-progress-head' },
|
|
1248
|
+
h('span', null, node.label ? renderRich(node.label) : ''),
|
|
1249
|
+
h('b', null, node.valueLabel ?? `${Math.round(value)}%`))
|
|
1250
|
+
: null,
|
|
1251
|
+
h('div', { className: 'mosaic-track', role: 'progressbar', 'aria-valuenow': value, 'aria-valuemin': 0, 'aria-valuemax': 100 },
|
|
1252
|
+
h('span', { className: 'mosaic-fill', style: { width: `${value}%` } }),
|
|
1253
|
+
node.target !== undefined ? h('span', { className: 'mosaic-target', style: { left: `${clamp(Number(node.target) || 0, 0, 100)}%` } }) : null))
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function KeyValueNode({ node }) {
|
|
1257
|
+
return h('div', { className: 'mosaic-kv' }, node.pairs.flatMap((pair, index) => [
|
|
1258
|
+
h('div', { key: `k${index}`, className: 'mosaic-kv-key' }, renderRich(pair.key)),
|
|
1259
|
+
h('div', { key: `v${index}`, className: 'mosaic-kv-value' }, renderRich(pair.value)),
|
|
1260
|
+
]))
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function StepsNode({ node }) {
|
|
1264
|
+
return h('ol', { className: 'mosaic-steps' }, node.steps.map((step, index) => {
|
|
1265
|
+
const active = node.current !== undefined && index <= node.current
|
|
1266
|
+
return h('li', { key: index, className: `mosaic-step${active ? ' mosaic-step-active' : ''}` },
|
|
1267
|
+
h('span', { className: 'mosaic-step-index' }, String(index + 1)),
|
|
1268
|
+
h('div', null,
|
|
1269
|
+
h('div', { className: 'mosaic-step-title' }, renderRich(step.title)),
|
|
1270
|
+
step.desc ? h('div', { className: 'mosaic-step-desc' }, renderRich(step.desc)) : null))
|
|
1271
|
+
}))
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function CalloutNode({ node }) {
|
|
1275
|
+
return h('div', { className: `mosaic-callout${node.tone ? ` mosaic-callout-${node.tone}` : ''}` },
|
|
1276
|
+
node.title ? h('div', { className: 'mosaic-callout-title' }, renderRich(node.title)) : null,
|
|
1277
|
+
h('div', null, renderRich(node.content)))
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function CodeNode({ node }) {
|
|
1281
|
+
return h('div', { className: 'mosaic-code' },
|
|
1282
|
+
h('button', { type: 'button', className: 'mosaic-copy', onClick: () => copyText(node.code) }, translate('copy')),
|
|
1283
|
+
h('pre', null, h('code', null, node.code)))
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function DiffSide({ text, kind }) {
|
|
1287
|
+
const lines = String(text ?? '').split('\n')
|
|
1288
|
+
return h('div', { className: `mosaic-diff-side mosaic-diff-${kind}` },
|
|
1289
|
+
lines.map((line, index) => h('div', { className: 'mosaic-diff-line', key: index },
|
|
1290
|
+
h('span', { className: 'mosaic-diff-gutter' }, kind === 'old' ? '-' : '+'),
|
|
1291
|
+
h('span', { className: 'mosaic-diff-num' }, String(index + 1)),
|
|
1292
|
+
h('span', { className: 'mosaic-diff-text' }, line === '' ? ' ' : line))))
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function DiffNode({ node }) {
|
|
1296
|
+
return h('div', { className: 'mosaic-diff' }, node.diffs.map((diff, index) => h('div', { key: index },
|
|
1297
|
+
h('div', { className: 'mosaic-diff-file' }, diff.path),
|
|
1298
|
+
h('div', { className: 'mosaic-diff-pair' },
|
|
1299
|
+
h(DiffSide, { text: diff.oldText ?? translate('added'), kind: 'old' }),
|
|
1300
|
+
h(DiffSide, { text: diff.newText ?? '', kind: 'new' })))))
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function DividerNode({ node }) {
|
|
1304
|
+
return h('div', { className: 'mosaic-divider' }, node.label ? h('span', null, renderRich(node.label)) : null)
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function SpacerNode() {
|
|
1308
|
+
return h('div', { className: 'mosaic-spacer' })
|
|
1309
|
+
}function numberValue(value) {
|
|
1310
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
1311
|
+
const text = String(value ?? '').trim().replace(/[,$£€¥%]/g, '')
|
|
1312
|
+
if (text === '') return null
|
|
1313
|
+
const match = /^([+-]?(?:\d+\.?\d*|\.\d+))\s*(k|m|b|万|亿)?$/i.exec(text)
|
|
1314
|
+
if (!match) return null
|
|
1315
|
+
const base = Number(match[1])
|
|
1316
|
+
if (!Number.isFinite(base)) return null
|
|
1317
|
+
const unit = (match[2] ?? '').toLowerCase()
|
|
1318
|
+
const factor = unit === 'k' ? 1e3 : unit === 'm' ? 1e6 : unit === 'b' ? 1e9 : unit === '万' ? 1e4 : unit === '亿' ? 1e8 : 1
|
|
1319
|
+
return base * factor
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function tableMarkdown(columns, rows) {
|
|
1323
|
+
const escape = (cell) => String(cell ?? '').replace(/\|/g, '\\|')
|
|
1324
|
+
return [
|
|
1325
|
+
`| ${columns.map(escape).join(' | ')} |`,
|
|
1326
|
+
`| ${columns.map(() => '---').join(' | ')} |`,
|
|
1327
|
+
...rows.map((row) => `| ${columns.map((_, index) => escape(row[index])).join(' | ')} |`),
|
|
1328
|
+
].join('\n')
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function tableCsv(rows) {
|
|
1332
|
+
return rows.map((row) => row.map((cell) => {
|
|
1333
|
+
const text = String(cell ?? '')
|
|
1334
|
+
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
1335
|
+
}).join(',')).join('\n')
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function TableNode({ node }) {
|
|
1339
|
+
const view = useView()
|
|
1340
|
+
const [sort, setSort] = useState(null)
|
|
1341
|
+
const columns = Array.isArray(node.columns) ? node.columns : []
|
|
1342
|
+
const rows = Array.isArray(node.rows) ? node.rows : []
|
|
1343
|
+
const types = Array.isArray(node.types) ? node.types : []
|
|
1344
|
+
const filterValue = node.filter && view ? (view.state.fields[node.filter] ?? '') : ''
|
|
1345
|
+
const boundSort = node.sort && view ? view.state.fields[node.sort] : undefined
|
|
1346
|
+
const filtered = filterValue.trim() === ''
|
|
1347
|
+
? rows.map((row, index) => ({ row, index }))
|
|
1348
|
+
: rows.map((row, index) => ({ row, index })).filter(({ row }) => row.some((cell) => String(cell ?? '').toLowerCase().includes(filterValue.trim().toLowerCase())))
|
|
1349
|
+
const boundColumn = boundSort ? columns.indexOf(boundSort) : -1
|
|
1350
|
+
const effectiveSort = sort
|
|
1351
|
+
?? (boundColumn >= 0 ? { column: boundColumn, direction: 1 } : null)
|
|
1352
|
+
const sorted = effectiveSort
|
|
1353
|
+
? [...filtered].sort((left, right) => {
|
|
1354
|
+
const a = left.row[effectiveSort.column]
|
|
1355
|
+
const b = right.row[effectiveSort.column]
|
|
1356
|
+
const na = numberValue(a)
|
|
1357
|
+
const nb = numberValue(b)
|
|
1358
|
+
const order = na !== null && nb !== null ? na - nb : String(a ?? '').localeCompare(String(b ?? ''), 'zh-CN')
|
|
1359
|
+
return order * effectiveSort.direction
|
|
1360
|
+
})
|
|
1361
|
+
: filtered
|
|
1362
|
+
const totals = columns.map((_, index) => {
|
|
1363
|
+
const values = rows.map((row) => numberValue(row[index])).filter((value) => value !== null)
|
|
1364
|
+
return values.length === rows.length && values.length > 0 ? values.reduce((sum, value) => sum + value, 0) : null
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
const toggleSort = (column) => {
|
|
1368
|
+
setSort((current) => {
|
|
1369
|
+
if (!current || current.column !== column) return { column, direction: 1 }
|
|
1370
|
+
if (current.direction === 1) return { column, direction: -1 }
|
|
1371
|
+
return null
|
|
1372
|
+
})
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const cell = (value, column) => {
|
|
1376
|
+
const type = types[column] ?? 'text'
|
|
1377
|
+
if (type === 'bar') {
|
|
1378
|
+
const numeric = clamp(numberValue(value) ?? 0, 0, 100)
|
|
1379
|
+
return h('span', { className: 'mosaic-cell-bar', title: `${numeric}%` }, h('span', { style: { width: `${numeric}%` } }))
|
|
1380
|
+
}
|
|
1381
|
+
if (type === 'badge') return h('span', { className: 'mosaic-badge mosaic-badge-accent' }, renderRich(String(value ?? '')))
|
|
1382
|
+
if (type === 'num') return h('span', null, renderRich(String(value ?? '')))
|
|
1383
|
+
if (type === 'delta') return h('span', { className: deltaClass(String(value ?? '')) }, renderRich(String(value ?? '')))
|
|
1384
|
+
return renderRich(String(value ?? ''))
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const header = h('thead', null, h('tr', null, columns.map((column, index) => h('th', {
|
|
1388
|
+
key: index,
|
|
1389
|
+
'aria-sort': effectiveSort?.column === index ? (effectiveSort.direction === 1 ? 'ascending' : 'descending') : 'none',
|
|
1390
|
+
}, h('button', { type: 'button', onClick: () => toggleSort(index) },
|
|
1391
|
+
renderRich(column),
|
|
1392
|
+
effectiveSort?.column === index ? h('span', { 'aria-hidden': true }, effectiveSort.direction === 1 ? '▲' : '▼') : null)))))
|
|
1393
|
+
|
|
1394
|
+
const bodyRows = sorted.map(({ row, index }) => h('tr', { key: index }, columns.map((column, columnIndex) => h('td', {
|
|
1395
|
+
key: columnIndex,
|
|
1396
|
+
className: types[columnIndex] === 'num' ? 'num' : types[columnIndex] === 'delta' ? deltaClass(String(row[columnIndex] ?? '')) : '',
|
|
1397
|
+
}, cell(row[columnIndex], columnIndex)))))
|
|
1398
|
+
|
|
1399
|
+
const totalText = (value) => {
|
|
1400
|
+
if (typeof value !== 'number') return String(value)
|
|
1401
|
+
if (Number.isInteger(value)) return String(value)
|
|
1402
|
+
return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
|
|
1403
|
+
}
|
|
1404
|
+
const totalRow = node.total && totals.some((value) => value !== null)
|
|
1405
|
+
? h('tr', { className: 'total' }, columns.map((column, index) => h('td', {
|
|
1406
|
+
key: index,
|
|
1407
|
+
className: types[index] === 'num' || totals[index] !== null ? 'num' : '',
|
|
1408
|
+
}, totals[index] === null ? (index === 0 ? translate('total') : '') : totalText(totals[index]))))
|
|
1409
|
+
: null
|
|
1410
|
+
|
|
1411
|
+
return h('div', null,
|
|
1412
|
+
node.export ? h('div', { className: 'mosaic-table-tools' },
|
|
1413
|
+
h('button', { type: 'button', onClick: () => copyText(tableMarkdown(columns, rows)) }, translate('copyMarkdown')),
|
|
1414
|
+
h('button', { type: 'button', onClick: () => copyText(tableCsv(rows)) }, translate('copyCsv'))) : null,
|
|
1415
|
+
h('div', { className: 'mosaic-table-wrap' },
|
|
1416
|
+
h('table', { className: columns.length >= 4 ? 'mosaic-table mosaic-table-dense' : 'mosaic-table' },
|
|
1417
|
+
header,
|
|
1418
|
+
h('tbody', null, bodyRows, totalRow))))
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function ListNode({ node }) {
|
|
1422
|
+
const view = useView()
|
|
1423
|
+
const filterId = node.filter
|
|
1424
|
+
const filterValue = filterId && view ? (view.state.fields[filterId] ?? '') : ''
|
|
1425
|
+
const needle = filterValue.trim().toLowerCase()
|
|
1426
|
+
const items = needle === '' ? node.items : node.items.filter((item) => {
|
|
1427
|
+
if (typeof item === 'string') return item.toLowerCase().includes(needle)
|
|
1428
|
+
if (item && typeof item.type === 'string') return false
|
|
1429
|
+
return `${item?.title ?? ''} ${item?.desc ?? ''}`.toLowerCase().includes(needle)
|
|
1430
|
+
})
|
|
1431
|
+
return h('ul', { className: 'mosaic-list' }, items.map((item, index) => {
|
|
1432
|
+
if (typeof item === 'string') {
|
|
1433
|
+
return h('li', { key: index, className: 'mosaic-list-item' }, h('span', { className: 'mosaic-list-dot' }), h('div', null, renderRich(item)))
|
|
1434
|
+
}
|
|
1435
|
+
if (item && typeof item.type === 'string') {
|
|
1436
|
+
return h('li', { key: index, className: 'mosaic-list-item' }, h(NodeView, { node: item, path: `list[${index}]`, depth: 1 }))
|
|
1437
|
+
}
|
|
1438
|
+
return h('li', { key: index, className: 'mosaic-list-item' },
|
|
1439
|
+
h('span', { className: 'mosaic-list-dot' }),
|
|
1440
|
+
h('div', null,
|
|
1441
|
+
h('div', { className: 'mosaic-list-title' }, renderRich(item?.title ?? '')),
|
|
1442
|
+
item?.desc ? h('div', { className: 'mosaic-list-desc' }, renderRich(item.desc)) : null))
|
|
1443
|
+
}))
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Run `callback` when the host appearance changes.
|
|
1448
|
+
*
|
|
1449
|
+
* A fence lives in its own React root, so host theme state never reaches it; this
|
|
1450
|
+
* observes the one documented signal and is the only place the client reads it.
|
|
1451
|
+
* Components that copy resolved tokens (the sandboxed widget, the Mermaid diagram)
|
|
1452
|
+
* use this to refresh themselves.
|
|
1453
|
+
*
|
|
1454
|
+
* @param {() => void} callback - invoked at most once per 80 ms burst.
|
|
1455
|
+
* @returns {() => void} unsubscribe.
|
|
1456
|
+
*/
|
|
1457
|
+
function onAppearanceChange(callback) {
|
|
1458
|
+
if (typeof MutationObserver !== 'function' || typeof document === 'undefined') return () => {}
|
|
1459
|
+
let pending = null
|
|
1460
|
+
const observer = new MutationObserver(() => {
|
|
1461
|
+
if (pending !== null) clearTimeout(pending)
|
|
1462
|
+
pending = setTimeout(callback, 80)
|
|
1463
|
+
})
|
|
1464
|
+
for (const target of [document.documentElement, document.body]) {
|
|
1465
|
+
if (target) observer.observe(target, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'class', 'style'] })
|
|
1466
|
+
}
|
|
1467
|
+
return () => {
|
|
1468
|
+
if (pending !== null) clearTimeout(pending)
|
|
1469
|
+
observer.disconnect()
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function chartPalette(index) {
|
|
1474
|
+
return [
|
|
1475
|
+
'var(--m-series-1)',
|
|
1476
|
+
'var(--m-series-2)',
|
|
1477
|
+
'var(--m-series-3)',
|
|
1478
|
+
'var(--m-series-4)',
|
|
1479
|
+
'var(--m-series-5)',
|
|
1480
|
+
][index % 5]
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function ChartNode({ node }) {
|
|
1484
|
+
const kind = node.kind
|
|
1485
|
+
const data = Array.isArray(node.data) ? node.data : []
|
|
1486
|
+
const series = Array.isArray(node.series) ? node.series : []
|
|
1487
|
+
const labels = series.length > 0
|
|
1488
|
+
? (series.find((entry) => entry.data.length > 0)?.data ?? []).map((point) => point.label)
|
|
1489
|
+
: data.map((point) => point.label)
|
|
1490
|
+
// Measure the card instead of scaling one fixed viewBox: a `width:100%` SVG
|
|
1491
|
+
// with a fixed viewBox grows every glyph with the card, which made a chart on a
|
|
1492
|
+
// wide window several times too tall with 15-20px axis text.
|
|
1493
|
+
const wrapRef = useRef(null)
|
|
1494
|
+
const [boxWidth, setBoxWidth] = useState(680)
|
|
1495
|
+
useEffect(() => {
|
|
1496
|
+
const element = wrapRef.current
|
|
1497
|
+
if (!element || typeof ResizeObserver !== 'function') return undefined
|
|
1498
|
+
const observer = new ResizeObserver((entries) => {
|
|
1499
|
+
const next = Math.max(320, Math.round(entries[0].contentRect.width))
|
|
1500
|
+
setBoxWidth((current) => (Math.abs(current - next) < 2 ? current : next))
|
|
1501
|
+
})
|
|
1502
|
+
observer.observe(element)
|
|
1503
|
+
return () => observer.disconnect()
|
|
1504
|
+
}, [])
|
|
1505
|
+
const width = boxWidth
|
|
1506
|
+
const height = 250
|
|
1507
|
+
const pad = { top: 18, right: 16, bottom: 34, left: 42 }
|
|
1508
|
+
const plotW = width - pad.left - pad.right
|
|
1509
|
+
const plotH = height - pad.top - pad.bottom
|
|
1510
|
+
const aria = node.title ?? `${kind} chart`
|
|
1511
|
+
|
|
1512
|
+
if (kind === 'donut') {
|
|
1513
|
+
const total = data.reduce((sum, point) => sum + Math.max(0, point.value), 0)
|
|
1514
|
+
const radius = 64
|
|
1515
|
+
const circumference = 2 * Math.PI * radius
|
|
1516
|
+
let offset = 0
|
|
1517
|
+
return h('div', { ref: wrapRef, style: { width: '100%' } },
|
|
1518
|
+
node.title ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.title)) : null,
|
|
1519
|
+
h('div', { style: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 18 } },
|
|
1520
|
+
h('svg', { viewBox: '0 0 200 200', width: 200, height: 200, role: 'img', 'aria-label': aria },
|
|
1521
|
+
h('circle', { cx: 100, cy: 100, r: radius, fill: 'none', stroke: 'var(--m-bg2)', strokeWidth: 24 }),
|
|
1522
|
+
data.map((point, index) => {
|
|
1523
|
+
const fraction = total <= 0 ? 0 : Math.max(0, point.value) / total
|
|
1524
|
+
const dash = fraction * circumference
|
|
1525
|
+
const segment = h('circle', {
|
|
1526
|
+
key: index,
|
|
1527
|
+
cx: 100, cy: 100, r: radius, fill: 'none',
|
|
1528
|
+
stroke: chartPalette(index), strokeWidth: 24,
|
|
1529
|
+
strokeDasharray: `${dash} ${circumference - dash}`,
|
|
1530
|
+
strokeDashoffset: -offset,
|
|
1531
|
+
transform: 'rotate(-90 100 100)',
|
|
1532
|
+
})
|
|
1533
|
+
offset += dash
|
|
1534
|
+
return segment
|
|
1535
|
+
}),
|
|
1536
|
+
h('text', { x: 100, y: 96, textAnchor: 'middle', style: { fontSize: 18, fontWeight: 650, fill: 'var(--m-text)' } }, String(Math.round(total))),
|
|
1537
|
+
h('text', { x: 100, y: 116, textAnchor: 'middle', style: { fontSize: 10, fill: 'var(--m-muted)' } }, translate('grandTotal'))),
|
|
1538
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 5, minWidth: 160 } },
|
|
1539
|
+
data.map((point, index) => h('div', { key: index, style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12 } },
|
|
1540
|
+
h('span', { style: { width: 8, height: 8, borderRadius: 2, background: chartPalette(index), flex: 'none' } }),
|
|
1541
|
+
h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, point.label),
|
|
1542
|
+
h('b', { style: { marginLeft: 'auto', fontVariantNumeric: 'tabular-nums' } }, String(point.value)))))))
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const multipSeries = series.length > 0
|
|
1546
|
+
const valuesForLabel = (label) => {
|
|
1547
|
+
if (multipSeries) return series.map((entry) => entry.data.find((point) => point.label === label)?.value ?? 0)
|
|
1548
|
+
return [data.find((point) => point.label === label)?.value ?? 0]
|
|
1549
|
+
}
|
|
1550
|
+
const max = Math.max(1, ...labels.flatMap((label) => {
|
|
1551
|
+
const values = valuesForLabel(label)
|
|
1552
|
+
return node.stacked ? [values.reduce((sum, value) => sum + value, 0)] : values
|
|
1553
|
+
}))
|
|
1554
|
+
const y = (value) => pad.top + plotH - (value / max) * plotH
|
|
1555
|
+
const tickText = (value) => {
|
|
1556
|
+
if (value >= 1000) return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}k`
|
|
1557
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
|
1558
|
+
}
|
|
1559
|
+
const grid = [0, 1, 2, 3, 4].map((index) => pad.top + (plotH / 4) * index)
|
|
1560
|
+
const gridNode = (gy, index) => h('g', { key: `g${index}` },
|
|
1561
|
+
h('line', { x1: pad.left, x2: width - pad.right, y1: gy, y2: gy, className: 'mosaic-grid-line' }),
|
|
1562
|
+
h('text', { x: pad.left - 8, y: gy + 3, textAnchor: 'end', className: 'mosaic-axis-label' }, tickText(max * ((4 - index) / 4))))
|
|
1563
|
+
const labelStep = Math.max(1, Math.ceil(labels.length / 8))
|
|
1564
|
+
|
|
1565
|
+
if (kind === 'line') {
|
|
1566
|
+
const lines = multipSeries ? series : [{ label: node.title ?? 'value', data }]
|
|
1567
|
+
return h('div', { ref: wrapRef, style: { width: '100%' } },
|
|
1568
|
+
node.title ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.title)) : null,
|
|
1569
|
+
h('svg', { className: 'mosaic-chart', viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': aria },
|
|
1570
|
+
grid.map((gy, index) => gridNode(gy, index)),
|
|
1571
|
+
h('line', { x1: pad.left, x2: width - pad.right, y1: y(0), y2: y(0), className: 'mosaic-axis' }),
|
|
1572
|
+
lines.map((line, lineIndex) => {
|
|
1573
|
+
const points = labels.map((label, index) => {
|
|
1574
|
+
const value = line.data.find((point) => point.label === label)?.value ?? 0
|
|
1575
|
+
const x = pad.left + (labels.length <= 1 ? plotW / 2 : (index / (labels.length - 1)) * plotW)
|
|
1576
|
+
return [x, y(value)]
|
|
1577
|
+
})
|
|
1578
|
+
return h('g', { key: lineIndex },
|
|
1579
|
+
h('polyline', { points: points.map(([x, py]) => `${x.toFixed(1)},${py.toFixed(1)}`).join(' '), className: 'mosaic-line', stroke: chartPalette(lineIndex) }),
|
|
1580
|
+
points.map(([x, py], index) => h('circle', { key: index, cx: x, cy: py, r: 3, fill: chartPalette(lineIndex), className: 'mosaic-dot' })))
|
|
1581
|
+
}),
|
|
1582
|
+
labels.map((label, index) => index % labelStep === 0
|
|
1583
|
+
? h('text', { key: `l${index}`, x: pad.left + (labels.length <= 1 ? plotW / 2 : (index / (labels.length - 1)) * plotW), y: height - 12, textAnchor: 'middle' }, label.slice(0, 10))
|
|
1584
|
+
: null)))
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
const groupWidth = labels.length === 0 ? 0 : plotW / labels.length
|
|
1588
|
+
const seriesCount = multipSeries ? Math.max(1, series.length) : 1
|
|
1589
|
+
const barWidth = Math.max(3, (groupWidth * 0.68) / seriesCount)
|
|
1590
|
+
return h('div', { ref: wrapRef, style: { width: '100%' } },
|
|
1591
|
+
node.title ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.title)) : null,
|
|
1592
|
+
h('svg', { className: 'mosaic-chart', viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': aria },
|
|
1593
|
+
grid.map((gy, index) => gridNode(gy, index)),
|
|
1594
|
+
h('line', { x1: pad.left, x2: width - pad.right, y1: y(0), y2: y(0), className: 'mosaic-axis' }),
|
|
1595
|
+
labels.map((label, labelIndex) => {
|
|
1596
|
+
const values = valuesForLabel(label)
|
|
1597
|
+
const groupX = pad.left + labelIndex * groupWidth + groupWidth * 0.16
|
|
1598
|
+
let accumulated = 0
|
|
1599
|
+
return h('g', { key: labelIndex },
|
|
1600
|
+
values.map((value, seriesIndex) => {
|
|
1601
|
+
const barHeight = Math.max(1, (Math.max(0, value) / max) * plotH)
|
|
1602
|
+
const x = node.stacked
|
|
1603
|
+
? groupX
|
|
1604
|
+
: groupX + seriesIndex * barWidth
|
|
1605
|
+
const py = node.stacked
|
|
1606
|
+
? y(accumulated + Math.max(0, value))
|
|
1607
|
+
: y(Math.max(0, value))
|
|
1608
|
+
if (node.stacked) accumulated += Math.max(0, value)
|
|
1609
|
+
return h('rect', {
|
|
1610
|
+
key: seriesIndex,
|
|
1611
|
+
x, y: py, width: node.stacked ? groupWidth * 0.68 : barWidth,
|
|
1612
|
+
height: barHeight, rx: 2,
|
|
1613
|
+
fill: chartPalette(seriesIndex),
|
|
1614
|
+
className: 'mosaic-bar',
|
|
1615
|
+
})
|
|
1616
|
+
}),
|
|
1617
|
+
labelIndex % labelStep === 0
|
|
1618
|
+
? h('text', { x: pad.left + labelIndex * groupWidth + groupWidth / 2, y: height - 12, textAnchor: 'middle' }, label.slice(0, 10))
|
|
1619
|
+
: null)
|
|
1620
|
+
})))
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function ImageNode({ node }) {
|
|
1624
|
+
const view = useView()
|
|
1625
|
+
return h('figure', { className: 'mosaic-media', style: { margin: 0 } },
|
|
1626
|
+
h('img', {
|
|
1627
|
+
className: 'mosaic-image',
|
|
1628
|
+
src: node.src,
|
|
1629
|
+
alt: node.alt ?? node.caption ?? '',
|
|
1630
|
+
loading: 'lazy',
|
|
1631
|
+
onClick: () => view?.openOverlay(h('div', { className: 'mosaic-media' },
|
|
1632
|
+
h('img', { className: 'mosaic-overlay-image', src: node.src, alt: node.alt ?? '' }),
|
|
1633
|
+
node.caption ? h('figcaption', { className: 'mosaic-caption-line' }, renderRich(node.caption)) : null)),
|
|
1634
|
+
}),
|
|
1635
|
+
node.caption ? h('figcaption', { className: 'mosaic-caption-line' }, renderRich(node.caption)) : null)
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function VideoNode({ node }) {
|
|
1639
|
+
return h('figure', { className: 'mosaic-media', style: { margin: 0 } },
|
|
1640
|
+
h('video', {
|
|
1641
|
+
className: 'mosaic-video',
|
|
1642
|
+
src: node.src,
|
|
1643
|
+
poster: node.poster,
|
|
1644
|
+
controls: true,
|
|
1645
|
+
playsInline: true,
|
|
1646
|
+
preload: 'metadata',
|
|
1647
|
+
style: node.aspect ? { aspectRatio: node.aspect.replace(':', ' / ') } : undefined,
|
|
1648
|
+
}),
|
|
1649
|
+
node.caption ? h('figcaption', { className: 'mosaic-caption-line' }, renderRich(node.caption)) : null)
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function AudioNode({ node }) {
|
|
1653
|
+
return h('figure', { className: 'mosaic-media', style: { margin: 0 } },
|
|
1654
|
+
h('audio', { className: 'mosaic-audio', src: node.src, controls: true, preload: 'metadata' }),
|
|
1655
|
+
node.caption ? h('figcaption', { className: 'mosaic-caption-line' }, renderRich(node.caption)) : null)
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function PdfNode({ node }) {
|
|
1659
|
+
return h('div', { className: 'mosaic-media' },
|
|
1660
|
+
h('iframe', { className: 'mosaic-pdf', src: node.src, title: node.title ?? 'PDF' }),
|
|
1661
|
+
h('a', { href: node.src, target: '_blank', rel: 'noreferrer noopener', className: 'mosaic-caption-line' }, translate('openPdf')))
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function LinkNode({ node }) {
|
|
1665
|
+
return h('a', {
|
|
1666
|
+
className: 'mosaic-link',
|
|
1667
|
+
href: node.href,
|
|
1668
|
+
target: '_blank',
|
|
1669
|
+
rel: 'noreferrer noopener',
|
|
1670
|
+
},
|
|
1671
|
+
h('span', { className: 'mosaic-link-icon', 'aria-hidden': true }, '↗'),
|
|
1672
|
+
h('span', { style: { minWidth: 0 } },
|
|
1673
|
+
h('span', { className: 'mosaic-link-label' }, renderRich(node.label)),
|
|
1674
|
+
node.description ? h('span', { className: 'mosaic-link-desc' }, renderRich(node.description)) : null))
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function ButtonNode({ node }) {
|
|
1678
|
+
const view = useView()
|
|
1679
|
+
const action = typeof node.action === 'string' && node.action.trim() !== '' ? node.action : undefined
|
|
1680
|
+
const local = ['reset', 'copy', 'open', 'fullscreen'].includes(node.local) ? node.local : undefined
|
|
1681
|
+
const blocked = action !== undefined && view !== null && view?.canAct === false
|
|
1682
|
+
const disabled = blocked || (action === undefined && local === undefined)
|
|
1683
|
+
const activate = () => {
|
|
1684
|
+
if (action !== undefined) {
|
|
1685
|
+
view?.action(action, { type: 'button', label: node.label })
|
|
1686
|
+
return
|
|
1687
|
+
}
|
|
1688
|
+
if (local === 'reset') view?.reset()
|
|
1689
|
+
else if (local === 'copy') copyText(node.text ?? node.label)
|
|
1690
|
+
else if (local === 'open') {
|
|
1691
|
+
const href = safeUrl(node.url)
|
|
1692
|
+
if (href) window.open(href, '_blank', 'noopener,noreferrer')
|
|
1693
|
+
} else if (local === 'fullscreen') {
|
|
1694
|
+
view?.openOverlay(h('pre', { className: 'mosaic-widget-code' }, node.text ?? node.label))
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
return h('button', {
|
|
1698
|
+
type: 'button',
|
|
1699
|
+
className: [
|
|
1700
|
+
'mosaic-button',
|
|
1701
|
+
node.tone ? `mosaic-button-${node.tone}` : '',
|
|
1702
|
+
node.full ? 'mosaic-button-full' : '',
|
|
1703
|
+
].filter(Boolean).join(' '),
|
|
1704
|
+
disabled,
|
|
1705
|
+
'aria-disabled': disabled ? 'true' : undefined,
|
|
1706
|
+
onClick: disabled ? undefined : activate,
|
|
1707
|
+
}, renderRich(node.label))
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function InputNode({ node, path }) {
|
|
1711
|
+
const view = useView()
|
|
1712
|
+
const key = node.id ?? path
|
|
1713
|
+
const [local, setLocal] = useState(node.value ?? '')
|
|
1714
|
+
const stored = node.id && view?.state.fields[node.id] !== undefined ? view.state.fields[node.id] : undefined
|
|
1715
|
+
const value = stored !== undefined ? stored : local
|
|
1716
|
+
const lastSent = useRef(value)
|
|
1717
|
+
const update = (next) => {
|
|
1718
|
+
setLocal(next)
|
|
1719
|
+
if (node.id) view?.setField(node.id, next)
|
|
1720
|
+
}
|
|
1721
|
+
const send = () => {
|
|
1722
|
+
if (!node.action || !view) return
|
|
1723
|
+
if (lastSent.current === value) return
|
|
1724
|
+
lastSent.current = value
|
|
1725
|
+
view.action(node.action, { type: 'input', id: node.id ?? key, value })
|
|
1726
|
+
}
|
|
1727
|
+
return h('label', { className: 'mosaic-form' },
|
|
1728
|
+
node.label ? h('span', { className: 'mosaic-field-label' }, renderRich(node.label)) : null,
|
|
1729
|
+
h('input', {
|
|
1730
|
+
className: 'mosaic-input',
|
|
1731
|
+
value,
|
|
1732
|
+
placeholder: node.placeholder ?? '',
|
|
1733
|
+
onChange: (event) => update(event.target.value),
|
|
1734
|
+
onKeyDown: (event) => { if (event.key === 'Enter') send() },
|
|
1735
|
+
onBlur: send,
|
|
1736
|
+
}))
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function SelectNode({ node }) {
|
|
1740
|
+
const view = useView()
|
|
1741
|
+
const key = node.id ?? 'select'
|
|
1742
|
+
const selected = node.id && view?.state.fields[node.id] !== undefined
|
|
1743
|
+
? view.state.fields[node.id]
|
|
1744
|
+
: (typeof node.selected === 'string' && node.options.includes(node.selected)
|
|
1745
|
+
? node.selected
|
|
1746
|
+
: (node.options[node.selected ?? -1] ?? ''))
|
|
1747
|
+
const update = (value) => {
|
|
1748
|
+
if (node.id) view?.setField(node.id, value)
|
|
1749
|
+
if (node.action && view) view.action(node.action, { type: 'select', id: node.id ?? key, value })
|
|
1750
|
+
}
|
|
1751
|
+
return h('label', { className: 'mosaic-form' },
|
|
1752
|
+
node.label ? h('span', { className: 'mosaic-field-label' }, renderRich(node.label)) : null,
|
|
1753
|
+
h('select', { className: 'mosaic-select', value: selected, onChange: (event) => update(event.target.value) },
|
|
1754
|
+
h('option', { value: '' }, translate('selectPlaceholder')),
|
|
1755
|
+
node.options.map((option, index) => h('option', { key: index, value: option }, option))))
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function SliderNode({ node }) {
|
|
1759
|
+
const view = useView()
|
|
1760
|
+
const key = node.id ?? 'slider'
|
|
1761
|
+
const initial = node.id && view?.state.fields[node.id] !== undefined
|
|
1762
|
+
? Number(view.state.fields[node.id])
|
|
1763
|
+
: (node.value ?? node.min ?? 0)
|
|
1764
|
+
const [value, setValue] = useState(Number.isFinite(initial) ? initial : 0)
|
|
1765
|
+
const debounce = useDebouncedAction()
|
|
1766
|
+
const update = (next) => {
|
|
1767
|
+
setValue(next)
|
|
1768
|
+
if (node.id) view?.setField(node.id, String(next))
|
|
1769
|
+
if (node.action && view) debounce(() => view.action(node.action, { type: 'slider', id: node.id ?? key, value: next }))
|
|
1770
|
+
}
|
|
1771
|
+
return h('label', { className: 'mosaic-form' },
|
|
1772
|
+
node.label ? h('span', { className: 'mosaic-field-label' }, renderRich(node.label)) : null,
|
|
1773
|
+
h('div', { className: 'mosaic-slider-row' },
|
|
1774
|
+
h('input', {
|
|
1775
|
+
className: 'mosaic-slider',
|
|
1776
|
+
type: 'range',
|
|
1777
|
+
min: node.min ?? 0,
|
|
1778
|
+
max: node.max ?? 100,
|
|
1779
|
+
step: node.step ?? 1,
|
|
1780
|
+
value,
|
|
1781
|
+
onChange: (event) => update(Number(event.target.value)),
|
|
1782
|
+
}),
|
|
1783
|
+
h('span', { className: 'mosaic-slider-value' }, String(value))))
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function TabsNode({ node, path }) {
|
|
1787
|
+
const view = useView()
|
|
1788
|
+
const active = clamp(view?.state.tabs[path] ?? 0, 0, node.tabs.length - 1)
|
|
1789
|
+
return h('div', { className: 'mosaic-tabs' },
|
|
1790
|
+
h('div', { className: 'mosaic-tabbar', role: 'tablist' }, node.tabs.map((tab, index) => h('button', {
|
|
1791
|
+
key: index,
|
|
1792
|
+
type: 'button',
|
|
1793
|
+
role: 'tab',
|
|
1794
|
+
'aria-selected': active === index,
|
|
1795
|
+
className: `mosaic-tab${active === index ? ' mosaic-tab-active' : ''}`,
|
|
1796
|
+
onClick: () => view?.setTab(path, index),
|
|
1797
|
+
}, renderRich(tab.label)))),
|
|
1798
|
+
h('div', { className: 'mosaic-tab-body', role: 'tabpanel' }, (node.tabs[active]?.items ?? []).map((item, index) => h(NodeView, {
|
|
1799
|
+
key: index,
|
|
1800
|
+
node: item,
|
|
1801
|
+
path: `${path}.tabs[${active}].items[${index}]`,
|
|
1802
|
+
depth: 1,
|
|
1803
|
+
}))))
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function QuizNode({ node }) {
|
|
1807
|
+
const [selected, setSelected] = useState(null)
|
|
1808
|
+
const [submitted, setSubmitted] = useState(false)
|
|
1809
|
+
const correct = node.options.findIndex((option) => option.correct === true)
|
|
1810
|
+
const choose = (index) => {
|
|
1811
|
+
if (submitted) return
|
|
1812
|
+
setSelected(index)
|
|
1813
|
+
setSubmitted(true)
|
|
1814
|
+
}
|
|
1815
|
+
const reset = () => {
|
|
1816
|
+
setSelected(null)
|
|
1817
|
+
setSubmitted(false)
|
|
1818
|
+
}
|
|
1819
|
+
const selection = selected === null ? null : node.options[selected]
|
|
1820
|
+
return h('div', { className: 'mosaic-quiz' },
|
|
1821
|
+
h('div', { className: 'mosaic-quiz-question' }, renderRich(node.question)),
|
|
1822
|
+
h('div', { className: 'mosaic-quiz-options' }, node.options.map((option, index) => {
|
|
1823
|
+
const isSelected = selected === index
|
|
1824
|
+
const isCorrect = submitted && correct === index
|
|
1825
|
+
const isWrong = submitted && isSelected && correct >= 0 && correct !== index
|
|
1826
|
+
return h('button', {
|
|
1827
|
+
key: index,
|
|
1828
|
+
type: 'button',
|
|
1829
|
+
disabled: submitted,
|
|
1830
|
+
className: `mosaic-quiz-option${isCorrect ? ' mosaic-quiz-correct' : ''}${isWrong ? ' mosaic-quiz-wrong' : ''}`,
|
|
1831
|
+
onClick: () => choose(index),
|
|
1832
|
+
},
|
|
1833
|
+
h('span', { className: 'mosaic-quiz-mark' }, isCorrect ? '✓' : isWrong ? '✗' : String.fromCharCode(65 + index)),
|
|
1834
|
+
h('span', null,
|
|
1835
|
+
renderRich(option.label),
|
|
1836
|
+
submitted && isSelected && option.feedback ? h('span', { className: 'mosaic-quiz-feedback' }, renderRich(option.feedback)) : null))
|
|
1837
|
+
})),
|
|
1838
|
+
submitted
|
|
1839
|
+
? h('div', null,
|
|
1840
|
+
h('div', { className: `mosaic-quiz-result ${correct < 0 ? '' : selected === correct ? 'ok' : 'no'}` },
|
|
1841
|
+
h('span', null, correct < 0 ? translate('answered') : selected === correct ? translate('answerCorrect') : translate('answerWrong')),
|
|
1842
|
+
h('button', { type: 'button', className: 'mosaic-button mosaic-button-ghost', onClick: reset }, translate('tryAgain'))),
|
|
1843
|
+
node.explanation ? h('div', { className: 'mosaic-caption-line' }, renderRich(node.explanation)) : null)
|
|
1844
|
+
: null)
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/* ------------------------------------------------------------------ */
|
|
1848
|
+
/* sandboxed widget */
|
|
1849
|
+
/* ------------------------------------------------------------------ */
|
|
1850
|
+
|
|
1851
|
+
const WIDGET_CSP = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' blob:; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:;; font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"
|
|
1852
|
+
|
|
1853
|
+
function widgetBridge(nonce, policy) {
|
|
1854
|
+
return `(function(){
|
|
1855
|
+
var nonce=${JSON.stringify(String(nonce))};
|
|
1856
|
+
var policy=${JSON.stringify(policy === 'programmatic' ? 'programmatic' : 'user')};
|
|
1857
|
+
var last=-1;
|
|
1858
|
+
var send=function(message){try{parent.postMessage(Object.assign({nonce:nonce},message),'*')}catch(error){}};
|
|
1859
|
+
var height=function(){var root=document.documentElement;var body=document.body;return Math.max(root?root.scrollHeight:0,body?body.scrollHeight:0,root?root.offsetHeight:0,body?body.offsetHeight:0)};
|
|
1860
|
+
var report=function(){var next=Math.max(80,Math.min(1200,Math.ceil(height())));if(next===last)return;last=next;send({type:'mosaic:height',height:next})};
|
|
1861
|
+
var schedule=function(){requestAnimationFrame(report)};
|
|
1862
|
+
// A browser-verified gesture, not a widget-supplied flag: userActivation cannot be forged
|
|
1863
|
+
// by page script, so an auto-firing widget is refused unless it opted into programmatic.
|
|
1864
|
+
var activated=function(){var ua=navigator.userActivation;return ua===undefined||ua.isActive===true};
|
|
1865
|
+
window.mosaic={action:function(name,payload){if(policy!=='programmatic'&&!activated())return;send({type:'mosaic:action',action:String(name||'action'),payload:payload&&typeof payload==='object'?payload:{}})},resize:report};
|
|
1866
|
+
window.addEventListener('message',function(event){var data=event&&event.data;if(!data||typeof data!=='object'||data.type!=='mosaic:theme')return;if(data.nonce!==undefined&&data.nonce!==nonce)return;try{if(data.theme&&typeof data.theme==='object'){Object.keys(data.theme).forEach(function(key){document.documentElement.style.setProperty('--mosaic-'+key,String(data.theme[key]))})}report()}catch(error){}},false);
|
|
1867
|
+
window.addEventListener('load',function(){schedule();setTimeout(schedule,120);setTimeout(schedule,600)});
|
|
1868
|
+
if(typeof ResizeObserver==='function'){try{new ResizeObserver(schedule).observe(document.documentElement)}catch(error){}if(document.body){try{new ResizeObserver(schedule).observe(document.body)}catch(error){}}}
|
|
1869
|
+
if(document.body&&typeof MutationObserver==='function'){try{new MutationObserver(schedule).observe(document.body,{childList:true,subtree:true,attributes:true,characterData:true})}catch(error){}}
|
|
1870
|
+
if(document.fonts&&document.fonts.ready){document.fonts.ready.then(schedule).catch(function(){})}
|
|
1871
|
+
send({type:'mosaic:ready'});schedule();
|
|
1872
|
+
})();`
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function widgetDocument(code, nonce, theme, policy) {
|
|
1876
|
+
const safe = String(code).slice(0, 20000)
|
|
1877
|
+
.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?Content-Security-Policy["']?[^>]*>/gi, '')
|
|
1878
|
+
.replace(/<base\b[^>]*>/gi, '')
|
|
1879
|
+
const style = `<style>:root{${Object.keys(theme).map((key) => `--mosaic-${key}:${theme[key]};`).join('')}color-scheme:light dark}html,body{margin:0;padding:0;background:var(--mosaic-bg,transparent);color:var(--mosaic-text,canvastext);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:14px;line-height:1.55}*,*::before,*::after{box-sizing:border-box}a{color:var(--mosaic-accent)}</style>`
|
|
1880
|
+
const head = `<!-- dsh-mosaic sandbox --><meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}">${style}<script>${widgetBridge(nonce, policy)}</script>`
|
|
1881
|
+
if (/<html[\s>]/i.test(safe)) {
|
|
1882
|
+
if (/<head[\s>]/i.test(safe)) {
|
|
1883
|
+
if (/<\/head>/i.test(safe)) return safe.replace(/<\/head>/i, `${head}</head>`)
|
|
1884
|
+
if (/<body[\s>]/i.test(safe)) return safe.replace(/<body[\s>]/i, `${head}<body`)
|
|
1885
|
+
return safe.replace(/<head[^>]*>/i, (match) => `${match}${head}`)
|
|
1886
|
+
}
|
|
1887
|
+
return safe.replace(/<html[^>]*>/i, (match) => `${match}<head>${head}</head>`)
|
|
1888
|
+
}
|
|
1889
|
+
return `<!doctype html><html lang="${(typeof document !== 'undefined' && document.documentElement.lang) || 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${head}</head><body>${safe}</body></html>`
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/** One widget may send this many actions per window; larger payloads are dropped. */
|
|
1893
|
+
const WIDGET_ACTIONS_PER_WINDOW = 10
|
|
1894
|
+
const WIDGET_ACTION_WINDOW_MS = 60000
|
|
1895
|
+
const WIDGET_ACTION_MAX_CHARS = 16384
|
|
1896
|
+
|
|
1897
|
+
function WidgetFrame({ node, onAction, onFullscreen, fullscreen = false }) {
|
|
1898
|
+
const [height, setHeight] = useState(node.height ?? 320)
|
|
1899
|
+
const [showCode, setShowCode] = useState(false)
|
|
1900
|
+
const [reload, setReload] = useState(0)
|
|
1901
|
+
const iframeRef = useRef(null)
|
|
1902
|
+
const nonce = useMemo(() => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, [reload])
|
|
1903
|
+
// The initial theme is embedded once; later changes ride postMessage so a
|
|
1904
|
+
// theme refresh cannot reload the iframe through a changed srcDoc.
|
|
1905
|
+
const initialTheme = useMemo(() => themeSnapshot(iframeRef.current?.parentElement), [])
|
|
1906
|
+
const policy = node.actions === 'programmatic' ? 'programmatic' : 'user'
|
|
1907
|
+
const html = useMemo(() => widgetDocument(node.code, nonce, initialTheme, policy), [node.code, nonce, initialTheme, policy])
|
|
1908
|
+
const actionTimes = useRef([])
|
|
1909
|
+
const sendTheme = useCallback(() => {
|
|
1910
|
+
const next = themeSnapshot(iframeRef.current?.parentElement)
|
|
1911
|
+
iframeRef.current?.contentWindow?.postMessage({ type: 'mosaic:theme', nonce, theme: next }, '*')
|
|
1912
|
+
}, [nonce])
|
|
1913
|
+
useEffect(() => {
|
|
1914
|
+
const onMessage = (event) => {
|
|
1915
|
+
if (event.source !== iframeRef.current?.contentWindow) return
|
|
1916
|
+
const data = event.data
|
|
1917
|
+
if (!data || typeof data !== 'object' || data.nonce !== nonce) return
|
|
1918
|
+
if (data.type === 'mosaic:height') {
|
|
1919
|
+
setHeight(clamp(Number(data.height) || 320, 120, 900))
|
|
1920
|
+
} else if (data.type === 'mosaic:action') {
|
|
1921
|
+
const now = Date.now()
|
|
1922
|
+
const recent = actionTimes.current.filter((at) => now - at < WIDGET_ACTION_WINDOW_MS)
|
|
1923
|
+
actionTimes.current = recent
|
|
1924
|
+
if (recent.length >= WIDGET_ACTIONS_PER_WINDOW) {
|
|
1925
|
+
reportLog('warn', 'widget.action-dropped', { reason: 'rate', action: String(data.action ?? '').slice(0, 64) })
|
|
1926
|
+
return
|
|
1927
|
+
}
|
|
1928
|
+
const payload = data.payload && typeof data.payload === 'object' ? data.payload : {}
|
|
1929
|
+
let serialized
|
|
1930
|
+
try {
|
|
1931
|
+
serialized = JSON.stringify(payload)
|
|
1932
|
+
} catch {
|
|
1933
|
+
return
|
|
1934
|
+
}
|
|
1935
|
+
if (serialized.length > WIDGET_ACTION_MAX_CHARS) {
|
|
1936
|
+
reportLog('warn', 'widget.action-dropped', { reason: 'size', chars: serialized.length })
|
|
1937
|
+
return
|
|
1938
|
+
}
|
|
1939
|
+
recent.push(now)
|
|
1940
|
+
onAction?.(String(data.action || 'action').slice(0, 64), payload)
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
window.addEventListener('message', onMessage)
|
|
1944
|
+
return () => window.removeEventListener('message', onMessage)
|
|
1945
|
+
}, [nonce, onAction])
|
|
1946
|
+
useEffect(() => {
|
|
1947
|
+
const timer = setTimeout(sendTheme, 80)
|
|
1948
|
+
// A fence lives in its own React root, so a host appearance change does not
|
|
1949
|
+
// re-render it; the iframe copies resolved tokens and needs the update posted.
|
|
1950
|
+
// `body[data-ds-dark-theme]` is the documented signal the theme sets, and this
|
|
1951
|
+
// is the only host attribute the plugin reads.
|
|
1952
|
+
const stopWatching = onAppearanceChange(sendTheme)
|
|
1953
|
+
return () => {
|
|
1954
|
+
clearTimeout(timer)
|
|
1955
|
+
stopWatching()
|
|
1956
|
+
}
|
|
1957
|
+
}, [sendTheme])
|
|
1958
|
+
return h('div', { className: 'mosaic-widget' },
|
|
1959
|
+
h('div', { className: 'mosaic-widget-head' },
|
|
1960
|
+
h('span', { className: 'mosaic-widget-title' }, renderRich(node.title ?? 'Interactive widget')),
|
|
1961
|
+
h('span', { className: 'mosaic-widget-actions' },
|
|
1962
|
+
h('button', { type: 'button', className: 'mosaic-widget-action', onClick: () => setShowCode((value) => !value) }, showCode ? translate('preview') : translate('code')),
|
|
1963
|
+
h('button', { type: 'button', className: 'mosaic-widget-action', onClick: () => setReload((value) => value + 1) }, translate('reload')),
|
|
1964
|
+
!fullscreen && onFullscreen ? h('button', { type: 'button', className: 'mosaic-widget-action', onClick: onFullscreen }, translate('fullscreen')) : null)),
|
|
1965
|
+
showCode
|
|
1966
|
+
? h('pre', { className: 'mosaic-widget-code' }, node.code)
|
|
1967
|
+
: h('iframe', {
|
|
1968
|
+
key: reload,
|
|
1969
|
+
ref: iframeRef,
|
|
1970
|
+
className: 'mosaic-widget-iframe',
|
|
1971
|
+
sandbox: 'allow-scripts allow-forms allow-popups',
|
|
1972
|
+
referrerPolicy: 'no-referrer',
|
|
1973
|
+
srcDoc: html,
|
|
1974
|
+
title: node.title ?? 'Interactive widget',
|
|
1975
|
+
style: { height: fullscreen ? 'calc(86vh - 90px)' : `${height}px` },
|
|
1976
|
+
onLoad: sendTheme,
|
|
1977
|
+
}),
|
|
1978
|
+
node.caption ? h('div', { className: 'mosaic-widget-caption' }, renderRich(node.caption)) : null)
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/* ------------------------------------------------------------------ */
|
|
1982
|
+
/* layout + dispatcher */
|
|
1983
|
+
/* ------------------------------------------------------------------ */
|
|
1984
|
+
|
|
1985
|
+
function GridItem({ node, path, depth }) {
|
|
1986
|
+
return h('div', {
|
|
1987
|
+
className: 'mosaic-grid-item',
|
|
1988
|
+
style: node.span ? { gridColumn: `span ${node.span}` } : undefined,
|
|
1989
|
+
}, h(NodeView, { node, path, depth }))
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// Where this package's assets live. The Host publishes the current URL on the
|
|
1993
|
+
// prompt endpoint, so a moved route never strands a browser that loaded an older
|
|
1994
|
+
// bundle; the literal path is the fallback when the endpoint is unreachable.
|
|
1995
|
+
const MERMAID_ROUTE = '/dsh-mosaic/assets/mermaid.min.js'
|
|
1996
|
+
const MERMAID_TIMEOUT_MS = 20000
|
|
1997
|
+
let mermaidAssetPromise = null
|
|
1998
|
+
let mermaidThemeKey = null
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Ask the Host for its asset URL, then append the built-in fallback.
|
|
2002
|
+
*
|
|
2003
|
+
* @returns {Promise<string[]>} candidate script URLs, in the order to try them.
|
|
2004
|
+
*/
|
|
2005
|
+
function mermaidCandidates() {
|
|
2006
|
+
const fromHost = fetch(PROMPT_URL, { headers: { accept: 'application/json' } })
|
|
2007
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
2008
|
+
.then((data) => (data && data.assets && typeof data.assets.mermaid === 'string' ? [data.assets.mermaid] : []))
|
|
2009
|
+
.catch(() => [])
|
|
2010
|
+
return fromHost.then((list) => [...new Set([...list, MERMAID_ROUTE])])
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* Load one script and resolve with the engine it registers.
|
|
2015
|
+
*
|
|
2016
|
+
* @param {string} src - absolute same-origin URL.
|
|
2017
|
+
* @returns {Promise<object>} the Mermaid API.
|
|
2018
|
+
*/
|
|
2019
|
+
function injectMermaidScript(src) {
|
|
2020
|
+
return new Promise((resolve, reject) => {
|
|
2021
|
+
const script = document.createElement('script')
|
|
2022
|
+
const timer = setTimeout(() => {
|
|
2023
|
+
cleanup()
|
|
2024
|
+
reject(new Error(`timed out after ${MERMAID_TIMEOUT_MS} ms`))
|
|
2025
|
+
}, MERMAID_TIMEOUT_MS)
|
|
2026
|
+
function cleanup() {
|
|
2027
|
+
clearTimeout(timer)
|
|
2028
|
+
script.onload = null
|
|
2029
|
+
script.onerror = null
|
|
2030
|
+
script.remove()
|
|
2031
|
+
}
|
|
2032
|
+
script.src = src
|
|
2033
|
+
script.async = true
|
|
2034
|
+
script.onload = () => {
|
|
2035
|
+
const api = window.mermaid
|
|
2036
|
+
cleanup()
|
|
2037
|
+
if (!api || typeof api.render !== 'function') reject(new Error(`${src} did not expose the Mermaid API`))
|
|
2038
|
+
else resolve(api)
|
|
2039
|
+
}
|
|
2040
|
+
script.onerror = () => {
|
|
2041
|
+
cleanup()
|
|
2042
|
+
reject(new Error(`${src} could not be loaded`))
|
|
2043
|
+
}
|
|
2044
|
+
document.head.appendChild(script)
|
|
2045
|
+
})
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function loadMermaidAsset() {
|
|
2049
|
+
const existing = window.mermaid
|
|
2050
|
+
if (existing && typeof existing.render === 'function') return Promise.resolve(existing)
|
|
2051
|
+
if (mermaidAssetPromise !== null) return mermaidAssetPromise
|
|
2052
|
+
mermaidAssetPromise = mermaidCandidates()
|
|
2053
|
+
.then(async (candidates) => {
|
|
2054
|
+
const failures = []
|
|
2055
|
+
for (const candidate of candidates) {
|
|
2056
|
+
try {
|
|
2057
|
+
return await injectMermaidScript(candidate)
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
failures.push(error instanceof Error ? error.message : String(error))
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
throw new Error(`Mermaid asset failed to load: ${failures.join('; ')}`)
|
|
2063
|
+
})
|
|
2064
|
+
.catch((error) => {
|
|
2065
|
+
// Let a later diagram retry instead of caching the failure for the session.
|
|
2066
|
+
mermaidAssetPromise = null
|
|
2067
|
+
throw error
|
|
2068
|
+
})
|
|
2069
|
+
return mermaidAssetPromise
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** Resolved host tokens mapped onto Mermaid's base theme. */
|
|
2073
|
+
function mermaidThemeVariables(host) {
|
|
2074
|
+
const theme = themeSnapshot(host)
|
|
2075
|
+
return {
|
|
2076
|
+
background: theme.bg ?? 'transparent',
|
|
2077
|
+
primaryColor: theme.card ?? theme.bg ?? 'transparent',
|
|
2078
|
+
primaryTextColor: theme.text ?? 'canvastext',
|
|
2079
|
+
primaryBorderColor: theme.border ?? 'currentColor',
|
|
2080
|
+
lineColor: theme.muted ?? theme.border ?? 'currentColor',
|
|
2081
|
+
secondaryColor: theme.card ?? theme.bg ?? 'transparent',
|
|
2082
|
+
tertiaryColor: theme.bg ?? 'transparent',
|
|
2083
|
+
fontFamily: 'inherit',
|
|
2084
|
+
fontSize: '12px',
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* Strip anything active from the SVG Mermaid produced before it reaches the page.
|
|
2090
|
+
*
|
|
2091
|
+
* Mermaid runs with `securityLevel: 'strict'` and `htmlLabels: false`, so this is a
|
|
2092
|
+
* second, independent gate against scripts, event handlers, script-bearing URLs and
|
|
2093
|
+
* remote stylesheet imports.
|
|
2094
|
+
*
|
|
2095
|
+
* @param {string} markup - the SVG string returned by `mermaid.render`.
|
|
2096
|
+
* @returns {string} sanitized SVG markup.
|
|
2097
|
+
*/
|
|
2098
|
+
function sanitizeMermaidSvg(markup) {
|
|
2099
|
+
const parsed = new DOMParser().parseFromString(String(markup), 'image/svg+xml')
|
|
2100
|
+
const root = parsed.documentElement
|
|
2101
|
+
if (!root || root.nodeName.toLowerCase() !== 'svg' || parsed.querySelector('parsererror')) {
|
|
2102
|
+
throw new Error('Mermaid returned malformed SVG')
|
|
2103
|
+
}
|
|
2104
|
+
for (const element of [...root.querySelectorAll('script, foreignObject, iframe, object, embed')]) element.remove()
|
|
2105
|
+
for (const element of [root, ...root.querySelectorAll('*')]) {
|
|
2106
|
+
for (const attribute of [...element.attributes]) {
|
|
2107
|
+
const name = attribute.name.toLowerCase()
|
|
2108
|
+
if (name.startsWith('on')) {
|
|
2109
|
+
element.removeAttribute(attribute.name)
|
|
2110
|
+
continue
|
|
2111
|
+
}
|
|
2112
|
+
const scriptable = /^\s*(javascript|vbscript|data:text\/html)/i.test(attribute.value)
|
|
2113
|
+
if ((name === 'href' || name === 'xlink:href' || name === 'src') && scriptable) element.removeAttribute(attribute.name)
|
|
2114
|
+
if (name === 'style' && /javascript:|expression\(/i.test(attribute.value)) element.removeAttribute(attribute.name)
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
for (const style of [...root.querySelectorAll('style')]) {
|
|
2118
|
+
if (/@import|url\(\s*["']?https?:/i.test(style.textContent ?? '')) style.remove()
|
|
2119
|
+
}
|
|
2120
|
+
return new XMLSerializer().serializeToString(root)
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Render one diagram with the official Mermaid build.
|
|
2125
|
+
*
|
|
2126
|
+
* @param {string} source - Mermaid source generated from a diagram node.
|
|
2127
|
+
* @param {Element | null} container - the card being rendered, for theme tokens.
|
|
2128
|
+
* @returns {Promise<string>} sanitized SVG markup.
|
|
2129
|
+
*/
|
|
2130
|
+
async function renderMermaid(source, container) {
|
|
2131
|
+
const api = await loadMermaidAsset()
|
|
2132
|
+
const host = container instanceof Element ? container : document.body
|
|
2133
|
+
// Mermaid resolves its theme once per initialize, so a new appearance has to
|
|
2134
|
+
// re-initialize before the next diagram can look right.
|
|
2135
|
+
const variables = mermaidThemeVariables(host)
|
|
2136
|
+
const themeKey = `${variables.background}|${variables.primaryColor}|${variables.primaryTextColor}`
|
|
2137
|
+
if (mermaidThemeKey !== themeKey) {
|
|
2138
|
+
api.initialize({
|
|
2139
|
+
startOnLoad: false,
|
|
2140
|
+
securityLevel: 'strict',
|
|
2141
|
+
htmlLabels: false,
|
|
2142
|
+
suppressErrorRendering: true,
|
|
2143
|
+
theme: 'base',
|
|
2144
|
+
themeVariables: variables,
|
|
2145
|
+
})
|
|
2146
|
+
mermaidThemeKey = themeKey
|
|
2147
|
+
}
|
|
2148
|
+
const id = `mosaic-mermaid-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
|
|
2149
|
+
const rendered = await api.render(id, String(source))
|
|
2150
|
+
return sanitizeMermaidSvg(rendered.svg)
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function mermaidLabel(value) {
|
|
2154
|
+
return String(value ?? '')
|
|
2155
|
+
.replace(/\s+/g, ' ')
|
|
2156
|
+
.replace(/"/g, '#quot;')
|
|
2157
|
+
.replace(/\|/g, '#124;')
|
|
2158
|
+
.trim()
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/** Parse a token colour into `[r, g, b]`; `undefined` when it is not a plain colour. */
|
|
2162
|
+
function parseColor(value) {
|
|
2163
|
+
const text = String(value ?? '').trim()
|
|
2164
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text)
|
|
2165
|
+
if (hex) {
|
|
2166
|
+
const digits = hex[1].length === 3 ? hex[1].split('').map((c) => c + c).join('') : hex[1]
|
|
2167
|
+
return [0, 2, 4].map((offset) => parseInt(digits.slice(offset, offset + 2), 16))
|
|
2168
|
+
}
|
|
2169
|
+
const rgb = /^rgba?\(([^)]+)\)$/i.exec(text)
|
|
2170
|
+
if (rgb) {
|
|
2171
|
+
const parts = rgb[1].split(',').slice(0, 3).map((part) => Number.parseFloat(part))
|
|
2172
|
+
if (parts.every((part) => Number.isFinite(part))) return parts.map((part) => Math.max(0, Math.min(255, Math.round(part))))
|
|
2173
|
+
}
|
|
2174
|
+
return undefined
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/** Mix `color` over `base` by `weight` (0..1) and return a hex string. */
|
|
2178
|
+
function tint(color, base, weight) {
|
|
2179
|
+
const front = parseColor(color)
|
|
2180
|
+
const back = parseColor(base) ?? [255, 255, 255]
|
|
2181
|
+
if (!front) return undefined
|
|
2182
|
+
const mixed = front.map((channel, index) => Math.round(channel * weight + back[index] * (1 - weight)))
|
|
2183
|
+
return `#${mixed.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Resolve the card's tokens into the literal colours Mermaid's style parser accepts.
|
|
2188
|
+
*
|
|
2189
|
+
* Mermaid rejects `var(...)` and `rgba(...)` in `classDef`, so the source has to
|
|
2190
|
+
* carry resolved hex values; diagrams re-render when the appearance changes.
|
|
2191
|
+
*
|
|
2192
|
+
* @param {Element | null} host - the card being rendered.
|
|
2193
|
+
* @returns {{base: object, tones: Record<string, object>}} node styles.
|
|
2194
|
+
*/
|
|
2195
|
+
function mermaidPalette(host) {
|
|
2196
|
+
const theme = themeSnapshot(host)
|
|
2197
|
+
const bg = theme.bg ?? '#ffffff'
|
|
2198
|
+
const card = theme.card ?? bg
|
|
2199
|
+
const text = theme.text ?? '#111111'
|
|
2200
|
+
const border = theme.border ?? '#999999'
|
|
2201
|
+
const node = (stroke) => ({
|
|
2202
|
+
fill: tint(stroke, card, 0.12) ?? card,
|
|
2203
|
+
stroke,
|
|
2204
|
+
color: text,
|
|
2205
|
+
})
|
|
2206
|
+
return {
|
|
2207
|
+
base: { fill: card, stroke: border, color: text },
|
|
2208
|
+
tones: {
|
|
2209
|
+
accent: node(theme.accent ?? border),
|
|
2210
|
+
success: node(theme.success ?? border),
|
|
2211
|
+
warning: node(theme.warn ?? border),
|
|
2212
|
+
danger: node(theme.danger ?? border),
|
|
2213
|
+
muted: node(theme.muted ?? border),
|
|
2214
|
+
},
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function toMermaid(node, palette) {
|
|
2219
|
+
if (node.kind === 'sequence') {
|
|
2220
|
+
const participants = Array.isArray(node.participants) ? node.participants : []
|
|
2221
|
+
const messages = Array.isArray(node.messages) ? node.messages : []
|
|
2222
|
+
const idOf = new Map(participants.map((participant, index) => [participant.id, `p${index}`]))
|
|
2223
|
+
const lines = ['sequenceDiagram']
|
|
2224
|
+
participants.forEach((participant) => {
|
|
2225
|
+
lines.push(` participant ${idOf.get(participant.id)} as ${mermaidLabel(participant.label)}`)
|
|
2226
|
+
})
|
|
2227
|
+
messages.forEach((message) => {
|
|
2228
|
+
const from = idOf.get(message.from)
|
|
2229
|
+
const to = idOf.get(message.to)
|
|
2230
|
+
if (!from || !to) return
|
|
2231
|
+
const arrow = message.kind === 'return' ? '-->>' : message.kind === 'async' ? '-)' : '->>'
|
|
2232
|
+
lines.push(` ${from}${arrow}${to}: ${mermaidLabel(message.label)}`)
|
|
2233
|
+
})
|
|
2234
|
+
return lines.join('\n')
|
|
2235
|
+
}
|
|
2236
|
+
const nodes = Array.isArray(node.nodes) ? node.nodes : []
|
|
2237
|
+
const edges = Array.isArray(node.edges) ? node.edges : []
|
|
2238
|
+
const idOf = new Map(nodes.map((entry, index) => [entry.id, `n${index}`]))
|
|
2239
|
+
const styleOf = (style) => `fill:${style.fill},stroke:${style.stroke},color:${style.color}`
|
|
2240
|
+
const tones = palette?.tones ?? {}
|
|
2241
|
+
const baseStyle = palette?.base ? styleOf(palette.base) : undefined
|
|
2242
|
+
const usedTones = new Set()
|
|
2243
|
+
const lines = [`flowchart ${node.direction === 'LR' ? 'LR' : 'TB'}`]
|
|
2244
|
+
nodes.forEach((entry) => {
|
|
2245
|
+
const label = mermaidLabel(entry.label)
|
|
2246
|
+
const withSub = entry.sub ? `${label}<br/>${mermaidLabel(entry.sub)}` : label
|
|
2247
|
+
const id = idOf.get(entry.id)
|
|
2248
|
+
if (entry.shape === 'diamond') lines.push(` ${id}{${withSub}}`)
|
|
2249
|
+
else if (entry.shape === 'round') lines.push(` ${id}(${withSub})`)
|
|
2250
|
+
else lines.push(` ${id}[${withSub}]`)
|
|
2251
|
+
if (entry.tone === undefined && baseStyle !== undefined) lines.push(` class ${id} mosaic_base`)
|
|
2252
|
+
if (tones[entry.tone]) {
|
|
2253
|
+
usedTones.add(entry.tone)
|
|
2254
|
+
lines.push(` class ${id} mosaic_${entry.tone}`)
|
|
2255
|
+
}
|
|
2256
|
+
})
|
|
2257
|
+
edges.forEach((edge) => {
|
|
2258
|
+
const from = idOf.get(edge.from)
|
|
2259
|
+
const to = idOf.get(edge.to)
|
|
2260
|
+
if (!from || !to) return
|
|
2261
|
+
const arrow = edge.kind === 'dashed' ? '-.->' : edge.kind === 'accent' ? '==>' : '-->'
|
|
2262
|
+
const label = edge.label ? `|${mermaidLabel(edge.label)}|` : ''
|
|
2263
|
+
lines.push(` ${from} ${arrow}${label} ${to}`)
|
|
2264
|
+
})
|
|
2265
|
+
if (baseStyle !== undefined) lines.push(` classDef mosaic_base ${baseStyle}`)
|
|
2266
|
+
for (const tone of usedTones) lines.push(` classDef mosaic_${tone} ${styleOf(tones[tone])}`)
|
|
2267
|
+
return lines.join('\n')
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function escapeXml(value) {
|
|
2271
|
+
return String(value ?? '')
|
|
2272
|
+
.replace(/&/g, '&')
|
|
2273
|
+
.replace(/</g, '<')
|
|
2274
|
+
.replace(/>/g, '>')
|
|
2275
|
+
.replace(/"/g, '"')
|
|
2276
|
+
.replace(/'/g, ''')
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
function drawioTone(tone) {
|
|
2280
|
+
if (tone === 'accent') return 'fillColor=#dae8fc;strokeColor=#6c8ebf;'
|
|
2281
|
+
if (tone === 'success') return 'fillColor=#d5e8d4;strokeColor=#82b366;'
|
|
2282
|
+
if (tone === 'warning') return 'fillColor=#fff2cc;strokeColor=#d6b656;'
|
|
2283
|
+
if (tone === 'danger') return 'fillColor=#f8cecc;strokeColor=#b85450;'
|
|
2284
|
+
if (tone === 'muted') return 'fillColor=#f5f5f5;strokeColor=#999999;'
|
|
2285
|
+
return 'fillColor=#ffffff;strokeColor=#999999;'
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* Build a portable draw.io (mxGraph XML) file from a diagram node.
|
|
2290
|
+
* Positions are deliberately simple: the file is for opening and
|
|
2291
|
+
* polishing in draw.io, where the user can run its own layout.
|
|
2292
|
+
*/
|
|
2293
|
+
function drawioXml(node) {
|
|
2294
|
+
const cells = []
|
|
2295
|
+
let index = 0
|
|
2296
|
+
const nextId = (prefix) => `${prefix}${index += 1}`
|
|
2297
|
+
const vertex = (id, value, style, x, y, w, h) => {
|
|
2298
|
+
cells.push(`<mxCell id="${escapeXml(id)}" value="${escapeXml(value)}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/></mxCell>`)
|
|
2299
|
+
}
|
|
2300
|
+
const edge = (id, from, to, label, style) => {
|
|
2301
|
+
cells.push(`<mxCell id="${escapeXml(id)}" value="${escapeXml(label ?? '')}" style="${style}" edge="1" parent="1" source="${escapeXml(from)}" target="${escapeXml(to)}"><mxGeometry relative="1" as="geometry"/></mxCell>`)
|
|
2302
|
+
}
|
|
2303
|
+
const baseStyle = 'whiteSpace=wrap;html=1;rounded=1;'
|
|
2304
|
+
const edgeStyle = 'edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;'
|
|
2305
|
+
|
|
2306
|
+
if (node.kind === 'sequence') {
|
|
2307
|
+
const ids = new Map()
|
|
2308
|
+
;(node.participants ?? []).forEach((participant, i) => {
|
|
2309
|
+
const id = nextId('p')
|
|
2310
|
+
ids.set(participant.id, id)
|
|
2311
|
+
vertex(id, participant.label, `${baseStyle}fillColor=#dae8fc;strokeColor=#6c8ebf;`, 40 + i * 210, 40, 160, 44)
|
|
2312
|
+
})
|
|
2313
|
+
;(node.messages ?? []).forEach((message) => {
|
|
2314
|
+
const from = ids.get(message.from)
|
|
2315
|
+
const to = ids.get(message.to)
|
|
2316
|
+
if (!from || !to) return
|
|
2317
|
+
edge(nextId('e'), from, to, message.label, `${edgeStyle}${message.kind === 'return' ? 'dashed=1;' : ''}`)
|
|
2318
|
+
})
|
|
2319
|
+
} else if (node.kind === 'timeline') {
|
|
2320
|
+
;(node.items ?? []).forEach((item, i) => {
|
|
2321
|
+
vertex(nextId('t'), `${item.time ? `${item.time} · ` : ''}${item.title}${item.desc ? `<br/>${item.desc}` : ''}`, `${baseStyle}fillColor=#ffffff;strokeColor=#999999;`, 40, 40 + i * 86, 420, 64)
|
|
2322
|
+
})
|
|
2323
|
+
} else if (node.kind === 'layers') {
|
|
2324
|
+
;(node.layers ?? []).forEach((layer, i) => {
|
|
2325
|
+
vertex(nextId('l'), `${layer.title}<br/>${(layer.items ?? []).join(' · ')}`, `${baseStyle}fillColor=#f5f5f5;strokeColor=#999999;`, 40, 40 + i * 96, 460, 70)
|
|
2326
|
+
})
|
|
2327
|
+
} else {
|
|
2328
|
+
const ids = new Map()
|
|
2329
|
+
;(node.nodes ?? []).forEach((entry, i) => {
|
|
2330
|
+
const id = nextId('n')
|
|
2331
|
+
ids.set(entry.id, id)
|
|
2332
|
+
const x = 40 + (i % 3) * 220
|
|
2333
|
+
const y = 40 + Math.floor(i / 3) * 100
|
|
2334
|
+
const style = `${baseStyle}${drawioTone(entry.tone)}${entry.shape === 'diamond' ? 'rhombus;' : ''}`
|
|
2335
|
+
vertex(id, `${entry.label}${entry.sub ? `<br/>${entry.sub}` : ''}`, style, x, y, 170, 60)
|
|
2336
|
+
})
|
|
2337
|
+
;(node.edges ?? []).forEach((entry) => {
|
|
2338
|
+
const from = ids.get(entry.from)
|
|
2339
|
+
const to = ids.get(entry.to)
|
|
2340
|
+
if (!from || !to) return
|
|
2341
|
+
const style = `${edgeStyle}${entry.kind === 'dashed' ? 'dashed=1;' : ''}${entry.kind === 'accent' ? 'strokeColor=#3b82f6;strokeWidth=2;' : ''}`
|
|
2342
|
+
edge(nextId('e'), from, to, entry.label, style)
|
|
2343
|
+
})
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
const name = node.title ?? 'Mosaic diagram'
|
|
2347
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2348
|
+
<mxfile host="app.diagrams.net" agent="dsh-mosaic" version="24.7.17">
|
|
2349
|
+
<diagram id="mosaic-${Date.now().toString(36)}" name="${escapeXml(name)}">
|
|
2350
|
+
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
|
2351
|
+
<root>
|
|
2352
|
+
<mxCell id="0"/>
|
|
2353
|
+
<mxCell id="1" parent="0"/>
|
|
2354
|
+
${cells.join('\n ')}
|
|
2355
|
+
</root>
|
|
2356
|
+
</mxGraphModel>
|
|
2357
|
+
</diagram>
|
|
2358
|
+
</mxfile>`
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function downloadDrawio(node) {
|
|
2362
|
+
const xml = drawioXml(node)
|
|
2363
|
+
const blob = new Blob([xml], { type: 'application/vnd.jgraph.mxfile' })
|
|
2364
|
+
const url = URL.createObjectURL(blob)
|
|
2365
|
+
const anchor = document.createElement('a')
|
|
2366
|
+
anchor.href = url
|
|
2367
|
+
anchor.download = `${(node.title ?? 'mosaic-diagram').replace(/[\\/:*?"<>|]+/g, '-')}.drawio`
|
|
2368
|
+
anchor.click()
|
|
2369
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function MermaidDiagram({ node, fallback }) {
|
|
2373
|
+
const containerRef = useRef(null)
|
|
2374
|
+
const [state, setState] = useState({ status: 'loading', svg: '' })
|
|
2375
|
+
const [revision, setRevision] = useState(0)
|
|
2376
|
+
// A fence lives in its own root, so an appearance switch would otherwise leave
|
|
2377
|
+
// the diagram in the previous palette. Only this component re-renders; widgets
|
|
2378
|
+
// keep their iframe and state.
|
|
2379
|
+
useEffect(() => onAppearanceChange(() => setRevision((value) => value + 1)), [])
|
|
2380
|
+
useEffect(() => {
|
|
2381
|
+
let alive = true
|
|
2382
|
+
setState({ status: 'loading', svg: '' })
|
|
2383
|
+
const source = toMermaid(node, mermaidPalette(containerRef.current ?? document.body))
|
|
2384
|
+
renderMermaid(source, containerRef.current)
|
|
2385
|
+
.then((svg) => { if (alive) setState({ status: 'ready', svg }) })
|
|
2386
|
+
.catch((error) => {
|
|
2387
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
2388
|
+
reportLog('warn', 'mermaid.failed', { message: message.slice(0, 200) })
|
|
2389
|
+
if (alive) setState({ status: 'error', svg: '', message })
|
|
2390
|
+
})
|
|
2391
|
+
return () => { alive = false }
|
|
2392
|
+
}, [node, revision])
|
|
2393
|
+
if (state.status === 'ready') {
|
|
2394
|
+
return h('div', {
|
|
2395
|
+
ref: containerRef,
|
|
2396
|
+
className: 'mosaic-mermaid',
|
|
2397
|
+
'data-diagram-engine': 'mermaid',
|
|
2398
|
+
// The string already passed sanitizeMermaidSvg; Mermaid itself runs with
|
|
2399
|
+
// securityLevel 'strict' and htmlLabels off.
|
|
2400
|
+
dangerouslySetInnerHTML: { __html: state.svg },
|
|
2401
|
+
})
|
|
2402
|
+
}
|
|
2403
|
+
if (state.status === 'error') {
|
|
2404
|
+
return h('div', null, fallback, h('div', { className: 'mosaic-caption-line' }, translate('mermaidFallback', { message: state.message })))
|
|
2405
|
+
}
|
|
2406
|
+
return h('div', { className: 'mosaic-skeleton', 'data-diagram-engine': 'mermaid' }, h('span'), h('span'), h('span'))
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function shorten(text, max) {
|
|
2410
|
+
const value = String(text ?? '')
|
|
2411
|
+
return value.length > max ? `${value.slice(0, Math.max(1, max - 1))}…` : value
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function diagramTone(tone) {
|
|
2415
|
+
if (tone === 'accent') return { fill: 'color-mix(in srgb,var(--m-accent) 12%,var(--m-bg))', stroke: 'var(--m-accent)' }
|
|
2416
|
+
if (tone === 'success') return { fill: 'color-mix(in srgb,var(--m-success) 12%,var(--m-bg))', stroke: 'var(--m-success)' }
|
|
2417
|
+
if (tone === 'warning') return { fill: 'color-mix(in srgb,var(--m-warn) 12%,var(--m-bg))', stroke: 'var(--m-warn)' }
|
|
2418
|
+
if (tone === 'danger') return { fill: 'color-mix(in srgb,var(--m-danger) 12%,var(--m-bg))', stroke: 'var(--m-danger)' }
|
|
2419
|
+
if (tone === 'muted') return { fill: 'var(--m-bg2)', stroke: 'var(--m-border)' }
|
|
2420
|
+
return { fill: 'var(--m-bg)', stroke: 'var(--m-border)' }
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
function diagramDisplaySize(width, height) {
|
|
2424
|
+
const scale = Math.min(1, 560 / Math.max(1, height))
|
|
2425
|
+
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function FlowchartDiagram({ node }) {
|
|
2429
|
+
const layout = useMemo(() => {
|
|
2430
|
+
const nodes = Array.isArray(node.nodes) ? node.nodes : []
|
|
2431
|
+
const edges = Array.isArray(node.edges) ? node.edges : []
|
|
2432
|
+
const direction = node.direction === 'LR' ? 'LR' : 'TB'
|
|
2433
|
+
const byId = new Map(nodes.map((entry) => [entry.id, entry]))
|
|
2434
|
+
const next = new Map(nodes.map((entry) => [entry.id, []]))
|
|
2435
|
+
const incoming = new Map(nodes.map((entry) => [entry.id, 0]))
|
|
2436
|
+
for (const edge of edges) {
|
|
2437
|
+
if (!byId.has(edge.from) || !byId.has(edge.to)) continue
|
|
2438
|
+
next.get(edge.from).push(edge.to)
|
|
2439
|
+
incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1)
|
|
2440
|
+
}
|
|
2441
|
+
const layer = new Map()
|
|
2442
|
+
const roots = nodes.filter((entry) => (incoming.get(entry.id) ?? 0) === 0).map((entry) => entry.id)
|
|
2443
|
+
const queue = [...roots]
|
|
2444
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
2445
|
+
const id = queue[cursor]
|
|
2446
|
+
if (!layer.has(id)) layer.set(id, 0)
|
|
2447
|
+
for (const child of next.get(id) ?? []) {
|
|
2448
|
+
layer.set(child, Math.max(layer.get(child) ?? 0, (layer.get(id) ?? 0) + 1))
|
|
2449
|
+
incoming.set(child, (incoming.get(child) ?? 1) - 1)
|
|
2450
|
+
if ((incoming.get(child) ?? 0) === 0) queue.push(child)
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
// Kahn's pass cannot rank nodes behind a cycle (for example a cache
|
|
2454
|
+
// backfill edge). Walk from every root once, ignoring back edges, so
|
|
2455
|
+
// those nodes still get a rank instead of collapsing onto layer 0.
|
|
2456
|
+
const visited = new Set()
|
|
2457
|
+
const visit = (id, depth) => {
|
|
2458
|
+
if (depth > nodes.length) return
|
|
2459
|
+
layer.set(id, Math.max(layer.get(id) ?? 0, depth))
|
|
2460
|
+
if (visited.has(id)) return
|
|
2461
|
+
visited.add(id)
|
|
2462
|
+
for (const child of next.get(id) ?? []) {
|
|
2463
|
+
if (!visited.has(child)) visit(child, depth + 1)
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
for (const id of roots) visit(id, 0)
|
|
2467
|
+
for (const entry of nodes) if (!visited.has(entry.id)) visit(entry.id, 0)
|
|
2468
|
+
for (const entry of nodes) if (!layer.has(entry.id)) layer.set(entry.id, 0)
|
|
2469
|
+
const maxLayer = Math.max(0, ...layer.values())
|
|
2470
|
+
const groups = Array.from({ length: maxLayer + 1 }, () => [])
|
|
2471
|
+
for (const entry of nodes) groups[layer.get(entry.id) ?? 0].push(entry)
|
|
2472
|
+
const NODE_W = 150
|
|
2473
|
+
const NODE_H = 48
|
|
2474
|
+
const GAP_X = 28
|
|
2475
|
+
const GAP_Y = 54
|
|
2476
|
+
const maxCount = Math.max(1, ...groups.map((group) => group.length))
|
|
2477
|
+
const width = direction === 'TB'
|
|
2478
|
+
? maxCount * NODE_W + (maxCount - 1) * GAP_X
|
|
2479
|
+
: (maxLayer + 1) * NODE_W + maxLayer * GAP_Y
|
|
2480
|
+
const height = direction === 'TB'
|
|
2481
|
+
? (maxLayer + 1) * NODE_H + maxLayer * GAP_Y
|
|
2482
|
+
: maxCount * NODE_H + (maxCount - 1) * GAP_X
|
|
2483
|
+
const positions = new Map()
|
|
2484
|
+
groups.forEach((group, layerIndex) => {
|
|
2485
|
+
const groupWidth = group.length * NODE_W + (group.length - 1) * GAP_X
|
|
2486
|
+
const groupHeight = group.length * NODE_H + (group.length - 1) * GAP_X
|
|
2487
|
+
group.forEach((entry, index) => {
|
|
2488
|
+
const x = direction === 'TB'
|
|
2489
|
+
? (width - groupWidth) / 2 + index * (NODE_W + GAP_X)
|
|
2490
|
+
: layerIndex * (NODE_W + GAP_Y)
|
|
2491
|
+
const y = direction === 'TB'
|
|
2492
|
+
? layerIndex * (NODE_H + GAP_Y)
|
|
2493
|
+
: (height - groupHeight) / 2 + index * (NODE_H + GAP_X)
|
|
2494
|
+
positions.set(entry.id, { x, y, w: NODE_W, h: NODE_H })
|
|
2495
|
+
})
|
|
2496
|
+
})
|
|
2497
|
+
const paths = []
|
|
2498
|
+
let maxX = width
|
|
2499
|
+
let maxY = height
|
|
2500
|
+
const layerStep = NODE_H + GAP_Y
|
|
2501
|
+
const rankStep = NODE_W + GAP_Y
|
|
2502
|
+
for (const edge of edges) {
|
|
2503
|
+
const from = positions.get(edge.from)
|
|
2504
|
+
const to = positions.get(edge.to)
|
|
2505
|
+
if (!from || !to) continue
|
|
2506
|
+
let path
|
|
2507
|
+
let labelX
|
|
2508
|
+
let labelY
|
|
2509
|
+
if (direction === 'TB') {
|
|
2510
|
+
const x1 = from.x + from.w / 2
|
|
2511
|
+
const y1 = from.y + from.h
|
|
2512
|
+
const x2 = to.x + to.w / 2
|
|
2513
|
+
const y2 = to.y
|
|
2514
|
+
if (to.y - from.y > layerStep * 1.5) {
|
|
2515
|
+
// A skip edge is routed around the right side so it does not
|
|
2516
|
+
// pass through the nodes on the intermediate layers.
|
|
2517
|
+
const lane = Math.max(from.x + from.w, to.x + to.w) + 24
|
|
2518
|
+
path = `M ${x1} ${y1} L ${lane} ${y1} L ${lane} ${y2} L ${x2} ${y2}`
|
|
2519
|
+
labelX = lane + 10
|
|
2520
|
+
labelY = (y1 + y2) / 2
|
|
2521
|
+
maxX = Math.max(maxX, lane + 52)
|
|
2522
|
+
} else if (to.y > from.y) {
|
|
2523
|
+
path = `M ${x1} ${y1} L ${x2} ${y2}`
|
|
2524
|
+
labelX = (x1 + x2) / 2
|
|
2525
|
+
labelY = (y1 + y2) / 2
|
|
2526
|
+
} else {
|
|
2527
|
+
const bend = Math.max(from.x + from.w, to.x + to.w) + 28
|
|
2528
|
+
path = `M ${x1} ${y1} C ${bend} ${y1 + 18}, ${bend} ${y2 - 18}, ${x2} ${y2}`
|
|
2529
|
+
labelX = (x1 + x2) / 2
|
|
2530
|
+
labelY = (y1 + y2) / 2
|
|
2531
|
+
maxX = Math.max(maxX, bend + 34)
|
|
2532
|
+
}
|
|
2533
|
+
} else {
|
|
2534
|
+
const x1 = from.x + from.w
|
|
2535
|
+
const y1 = from.y + from.h / 2
|
|
2536
|
+
const x2 = to.x
|
|
2537
|
+
const y2 = to.y + to.h / 2
|
|
2538
|
+
if (to.x - from.x > rankStep * 1.5) {
|
|
2539
|
+
const lane = Math.max(from.y + from.h, to.y + to.h) + 24
|
|
2540
|
+
path = `M ${x1} ${y1} L ${x1} ${lane} L ${x2} ${lane} L ${x2} ${y2}`
|
|
2541
|
+
labelX = (x1 + x2) / 2
|
|
2542
|
+
labelY = lane + 14
|
|
2543
|
+
maxY = Math.max(maxY, lane + 42)
|
|
2544
|
+
} else if (to.x > from.x) {
|
|
2545
|
+
path = `M ${x1} ${y1} L ${x2} ${y2}`
|
|
2546
|
+
labelX = (x1 + x2) / 2
|
|
2547
|
+
labelY = (y1 + y2) / 2 - 5
|
|
2548
|
+
} else {
|
|
2549
|
+
const bend = Math.max(from.y + from.h, to.y + to.h) + 28
|
|
2550
|
+
path = `M ${x1} ${y1} C ${x1 + 18} ${bend}, ${x2 - 18} ${bend}, ${x2} ${y2}`
|
|
2551
|
+
labelX = (x1 + x2) / 2
|
|
2552
|
+
labelY = (y1 + y2) / 2 - 5
|
|
2553
|
+
maxY = Math.max(maxY, bend + 34)
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
paths.push({ ...edge, path, labelX, labelY })
|
|
2557
|
+
}
|
|
2558
|
+
return { nodes, positions, paths, width: maxX, height: maxY }
|
|
2559
|
+
}, [node.nodes, node.edges, node.direction])
|
|
2560
|
+
|
|
2561
|
+
const display = diagramDisplaySize(layout.width, layout.height)
|
|
2562
|
+
return h('svg', {
|
|
2563
|
+
className: 'mosaic-diagram',
|
|
2564
|
+
width: display.width,
|
|
2565
|
+
height: display.height,
|
|
2566
|
+
viewBox: `0 0 ${layout.width} ${layout.height}`,
|
|
2567
|
+
style: { maxWidth: 'none', height: 'auto' },
|
|
2568
|
+
role: 'img',
|
|
2569
|
+
'aria-label': node.title ?? 'flowchart',
|
|
2570
|
+
},
|
|
2571
|
+
h('defs', null,
|
|
2572
|
+
h('marker', { id: 'mosaic-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
|
|
2573
|
+
h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-muted)' })),
|
|
2574
|
+
h('marker', { id: 'mosaic-arrow-accent', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
|
|
2575
|
+
h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-accent)' }))),
|
|
2576
|
+
layout.paths.map((edge, index) => h('path', {
|
|
2577
|
+
key: `e${index}`,
|
|
2578
|
+
d: edge.path,
|
|
2579
|
+
className: `diagram-edge${edge.kind === 'accent' ? ' diagram-edge-accent' : ''}${edge.kind === 'dashed' ? ' diagram-edge-dashed' : ''}`,
|
|
2580
|
+
markerEnd: edge.kind === 'accent' ? 'url(#mosaic-arrow-accent)' : 'url(#mosaic-arrow)',
|
|
2581
|
+
})),
|
|
2582
|
+
layout.paths.map((edge, index) => edge.label
|
|
2583
|
+
? h('text', { key: `l${index}`, x: edge.labelX, y: edge.labelY, textAnchor: 'middle', className: 'diagram-edge-label' }, shorten(edge.label, 16))
|
|
2584
|
+
: null),
|
|
2585
|
+
layout.nodes.map((entry) => {
|
|
2586
|
+
const position = layout.positions.get(entry.id)
|
|
2587
|
+
if (!position) return null
|
|
2588
|
+
const tone = diagramTone(entry.tone)
|
|
2589
|
+
const shape = entry.shape ?? 'box'
|
|
2590
|
+
const centerX = position.x + position.w / 2
|
|
2591
|
+
const centerY = position.y + position.h / 2
|
|
2592
|
+
const labelY = entry.sub ? centerY - 4 : centerY + 4
|
|
2593
|
+
return h('g', { key: entry.id },
|
|
2594
|
+
shape === 'diamond'
|
|
2595
|
+
? h('polygon', {
|
|
2596
|
+
className: 'diagram-node',
|
|
2597
|
+
points: `${centerX},${position.y} ${position.x + position.w},${centerY} ${centerX},${position.y + position.h} ${position.x},${centerY}`,
|
|
2598
|
+
fill: tone.fill, stroke: tone.stroke,
|
|
2599
|
+
})
|
|
2600
|
+
: h('rect', {
|
|
2601
|
+
className: 'diagram-node',
|
|
2602
|
+
x: position.x, y: position.y, width: position.w, height: position.h,
|
|
2603
|
+
rx: shape === 'round' ? position.h / 2 : 8,
|
|
2604
|
+
fill: tone.fill, stroke: tone.stroke,
|
|
2605
|
+
}),
|
|
2606
|
+
h('text', { x: centerX, y: labelY, textAnchor: 'middle', className: 'diagram-node-label' }, shorten(entry.label, 20)),
|
|
2607
|
+
entry.sub ? h('text', { x: centerX, y: centerY + 13, textAnchor: 'middle', className: 'diagram-node-sub' }, shorten(entry.sub, 22)) : null)
|
|
2608
|
+
}))
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function SequenceDiagram({ node }) {
|
|
2612
|
+
const layout = useMemo(() => {
|
|
2613
|
+
const participants = Array.isArray(node.participants) ? node.participants : []
|
|
2614
|
+
const messages = Array.isArray(node.messages) ? node.messages : []
|
|
2615
|
+
const column = 160
|
|
2616
|
+
const width = Math.max(360, participants.length * column)
|
|
2617
|
+
const height = 80 + messages.length * 36 + 20
|
|
2618
|
+
const xOf = (id) => {
|
|
2619
|
+
const index = participants.findIndex((entry) => entry.id === id)
|
|
2620
|
+
return index < 0 ? 0 : 80 + index * column
|
|
2621
|
+
}
|
|
2622
|
+
return { participants, messages, width, height, xOf }
|
|
2623
|
+
}, [node.participants, node.messages])
|
|
2624
|
+
const display = diagramDisplaySize(layout.width, layout.height)
|
|
2625
|
+
return h('svg', {
|
|
2626
|
+
className: 'mosaic-diagram',
|
|
2627
|
+
width: display.width,
|
|
2628
|
+
height: display.height,
|
|
2629
|
+
viewBox: `0 0 ${layout.width} ${layout.height}`,
|
|
2630
|
+
style: { maxWidth: 'none', height: 'auto' },
|
|
2631
|
+
role: 'img',
|
|
2632
|
+
'aria-label': node.title ?? 'sequence diagram',
|
|
2633
|
+
},
|
|
2634
|
+
h('defs', null,
|
|
2635
|
+
h('marker', { id: 'mosaic-seq-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
|
|
2636
|
+
h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-text)' })),
|
|
2637
|
+
h('marker', { id: 'mosaic-seq-arrow-muted', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
|
|
2638
|
+
h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-muted)' })),
|
|
2639
|
+
h('marker', { id: 'mosaic-seq-arrow-accent', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: 'auto-start-reverse' },
|
|
2640
|
+
h('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'var(--m-accent)' }))),
|
|
2641
|
+
layout.participants.map((participant) => {
|
|
2642
|
+
const x = layout.xOf(participant.id)
|
|
2643
|
+
return h('g', { key: participant.id },
|
|
2644
|
+
h('rect', { className: 'diagram-participant', x: x - 65, y: 8, width: 130, height: 30, rx: 8 }),
|
|
2645
|
+
h('text', { x, y: 27, textAnchor: 'middle', className: 'diagram-participant-label' }, shorten(participant.label, 16)),
|
|
2646
|
+
h('line', { className: 'diagram-lifeline', x1: x, y1: 38, x2: x, y2: layout.height - 12 }))
|
|
2647
|
+
}),
|
|
2648
|
+
layout.messages.map((message, index) => {
|
|
2649
|
+
const x1 = layout.xOf(message.from)
|
|
2650
|
+
const x2 = layout.xOf(message.to)
|
|
2651
|
+
const y = 78 + index * 36
|
|
2652
|
+
const marker = message.kind === 'return'
|
|
2653
|
+
? 'url(#mosaic-seq-arrow-muted)'
|
|
2654
|
+
: message.kind === 'async' ? 'url(#mosaic-seq-arrow-accent)' : 'url(#mosaic-seq-arrow)'
|
|
2655
|
+
return h('g', { key: index },
|
|
2656
|
+
x1 === x2
|
|
2657
|
+
? h('path', {
|
|
2658
|
+
className: `diagram-message${message.kind === 'return' ? ' diagram-message-return' : ''}${message.kind === 'async' ? ' diagram-message-async' : ''}`,
|
|
2659
|
+
d: `M ${x1} ${y} C ${x1 + 34} ${y - 14}, ${x1 + 34} ${y + 14}, ${x1} ${y + 12}`,
|
|
2660
|
+
markerEnd: marker,
|
|
2661
|
+
})
|
|
2662
|
+
: h('line', {
|
|
2663
|
+
className: `diagram-message${message.kind === 'return' ? ' diagram-message-return' : ''}${message.kind === 'async' ? ' diagram-message-async' : ''}`,
|
|
2664
|
+
x1, y1: y, x2, y2: y, markerEnd: marker,
|
|
2665
|
+
}),
|
|
2666
|
+
h('text', { x: (x1 + x2) / 2, y: y - 6, textAnchor: 'middle', className: 'diagram-msg-label' }, shorten(message.label, 26)))
|
|
2667
|
+
}))
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
function TimelineDiagram({ node }) {
|
|
2671
|
+
return h('ol', { className: 'mosaic-timeline' }, (node.items ?? []).map((item, index) => h('li', { key: index, className: 'mosaic-timeline-item' },
|
|
2672
|
+
h('span', { className: 'mosaic-timeline-dot', 'aria-hidden': true }),
|
|
2673
|
+
item.time ? h('div', { className: 'mosaic-timeline-time' }, renderRich(item.time)) : null,
|
|
2674
|
+
h('div', { className: 'mosaic-timeline-title' }, renderRich(item.title)),
|
|
2675
|
+
item.desc ? h('div', { className: 'mosaic-timeline-desc' }, renderRich(item.desc)) : null)))
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
function LayersDiagram({ node }) {
|
|
2679
|
+
return h('div', { className: 'mosaic-layers' }, (node.layers ?? []).map((layer, index) => h('div', { key: index, className: 'mosaic-layer' },
|
|
2680
|
+
h('div', { className: 'mosaic-layer-title' }, renderRich(layer.title)),
|
|
2681
|
+
h('ul', { className: 'mosaic-layer-items' }, (layer.items ?? []).map((item, itemIndex) => h('li', { key: itemIndex }, renderRich(item)))))))
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
function DiagramNode({ node }) {
|
|
2685
|
+
const title = node.title ? h('div', { className: 'mosaic-stat-label' }, renderRich(node.title)) : null
|
|
2686
|
+
const head = h('div', { className: 'mosaic-diagram-head' },
|
|
2687
|
+
title ?? h('span'),
|
|
2688
|
+
h('button', {
|
|
2689
|
+
type: 'button',
|
|
2690
|
+
className: 'mosaic-diagram-export',
|
|
2691
|
+
title: translate('drawio'),
|
|
2692
|
+
onClick: () => downloadDrawio(node),
|
|
2693
|
+
}, 'drawio'))
|
|
2694
|
+
if (node.kind === 'sequence') {
|
|
2695
|
+
return h('div', null, head, h(MermaidDiagram, {
|
|
2696
|
+
node,
|
|
2697
|
+
fallback: h('div', { className: 'mosaic-diagram-wrap' }, h(SequenceDiagram, { node })),
|
|
2698
|
+
}))
|
|
2699
|
+
}
|
|
2700
|
+
if (node.kind === 'timeline') return h('div', null, head, h(TimelineDiagram, { node }))
|
|
2701
|
+
if (node.kind === 'layers') return h('div', null, head, h(LayersDiagram, { node }))
|
|
2702
|
+
return h('div', null, head, h(MermaidDiagram, {
|
|
2703
|
+
node,
|
|
2704
|
+
fallback: h('div', { className: 'mosaic-diagram-wrap' }, h(FlowchartDiagram, { node })),
|
|
2705
|
+
}))
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function NodeView({ node, path = 'items[0]', depth = 0 }) {
|
|
2709
|
+
if (!node || depth > MAX_DEPTH) return null
|
|
2710
|
+
const view = useView()
|
|
2711
|
+
switch (node.type) {
|
|
2712
|
+
case 'text': return h(TextNode, { node })
|
|
2713
|
+
case 'row':
|
|
2714
|
+
return h('div', {
|
|
2715
|
+
className: `mosaic-row${node.wrap ? ' mosaic-row-wrap' : ''}`,
|
|
2716
|
+
style: {
|
|
2717
|
+
gap: node.gap !== undefined ? `${node.gap}px` : undefined,
|
|
2718
|
+
justifyContent: node.align === 'center' ? 'center' : node.align === 'end' ? 'flex-end' : node.align === 'between' ? 'space-between' : undefined,
|
|
2719
|
+
},
|
|
2720
|
+
}, node.items.map((child, index) => h(GridItem, { key: index, node: child, path: `${path}.items[${index}]`, depth: depth + 1 })))
|
|
2721
|
+
case 'col':
|
|
2722
|
+
return h('div', {
|
|
2723
|
+
className: 'mosaic-col',
|
|
2724
|
+
style: {
|
|
2725
|
+
gap: node.gap !== undefined ? `${node.gap}px` : undefined,
|
|
2726
|
+
...(node.width ? { width: `${node.width}%`, flex: '0 0 auto' } : { flex: '1 1 0' }),
|
|
2727
|
+
},
|
|
2728
|
+
}, node.items.map((child, index) => h(NodeView, { key: index, node: child, path: `${path}.items[${index}]`, depth: depth + 1 })))
|
|
2729
|
+
case 'grid':
|
|
2730
|
+
return h('div', {
|
|
2731
|
+
className: 'mosaic-grid',
|
|
2732
|
+
style: {
|
|
2733
|
+
gridTemplateColumns: `repeat(${node.cols ?? 2}, minmax(0, 1fr))`,
|
|
2734
|
+
gap: node.gap !== undefined ? `${node.gap}px` : undefined,
|
|
2735
|
+
},
|
|
2736
|
+
}, node.items.map((child, index) => h(GridItem, { key: index, node: child, path: `${path}.items[${index}]`, depth: depth + 1 })))
|
|
2737
|
+
case 'card':
|
|
2738
|
+
return h('div', { className: `mosaic-card${node.flat ? ' mosaic-card-flat' : ''}${node.tone ? ` mosaic-card-${node.tone}` : ''}` },
|
|
2739
|
+
node.eyebrow ? h('div', { className: 'mosaic-eyebrow' }, renderRich(node.eyebrow)) : null,
|
|
2740
|
+
node.title ? h('div', { className: 'mosaic-card-title' }, renderRich(node.title)) : null,
|
|
2741
|
+
h('div', { className: 'mosaic-col', style: { gap: '10px' } },
|
|
2742
|
+
node.items.map((child, index) => h(NodeView, { key: index, node: child, path: `${path}.items[${index}]`, depth: depth + 1 }))))
|
|
2743
|
+
case 'divider': return h(DividerNode, { node })
|
|
2744
|
+
case 'spacer': return h(SpacerNode, { node })
|
|
2745
|
+
case 'stat': return h(StatNode, { node })
|
|
2746
|
+
case 'badge': return h(BadgeNode, { node })
|
|
2747
|
+
case 'progress': return h(ProgressNode, { node })
|
|
2748
|
+
case 'table': return h(TableNode, { node })
|
|
2749
|
+
case 'list': return h(ListNode, { node })
|
|
2750
|
+
case 'keyvalue': return h(KeyValueNode, { node })
|
|
2751
|
+
case 'steps': return h(StepsNode, { node })
|
|
2752
|
+
case 'callout': return h(CalloutNode, { node })
|
|
2753
|
+
case 'code': return h(CodeNode, { node })
|
|
2754
|
+
case 'diff': return h(DiffNode, { node })
|
|
2755
|
+
case 'chart': return h(ChartNode, { node })
|
|
2756
|
+
case 'diagram': return h(DiagramNode, { node })
|
|
2757
|
+
case 'image': return h(ImageNode, { node })
|
|
2758
|
+
case 'video': return h(VideoNode, { node })
|
|
2759
|
+
case 'audio': return h(AudioNode, { node })
|
|
2760
|
+
case 'pdf': return h(PdfNode, { node })
|
|
2761
|
+
case 'link': return h(LinkNode, { node })
|
|
2762
|
+
case 'button': return h(ButtonNode, { node })
|
|
2763
|
+
case 'input': return h(InputNode, { node, path })
|
|
2764
|
+
case 'select': return h(SelectNode, { node })
|
|
2765
|
+
case 'slider': return h(SliderNode, { node })
|
|
2766
|
+
case 'tabs': return h(TabsNode, { node, path })
|
|
2767
|
+
case 'quiz': return h(QuizNode, { key: `quiz-${view?.state.round ?? 0}`, node })
|
|
2768
|
+
case 'widget':
|
|
2769
|
+
return h(WidgetFrame, {
|
|
2770
|
+
node,
|
|
2771
|
+
onAction: (action, payload) => view?.action(action, payload),
|
|
2772
|
+
onFullscreen: view ? () => view.openOverlay(h(WidgetFrame, {
|
|
2773
|
+
node,
|
|
2774
|
+
fullscreen: true,
|
|
2775
|
+
onAction: (action, payload) => view.action(action, payload),
|
|
2776
|
+
})) : undefined,
|
|
2777
|
+
})
|
|
2778
|
+
default: return null
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function Block({ spec, stateKey, onAgentAction, sessionId, sourceJson }) {
|
|
2783
|
+
const [state, setState] = useState(() => {
|
|
2784
|
+
const base = { fields: {}, tabs: {}, round: 0 }
|
|
2785
|
+
if (!stateKey) return base
|
|
2786
|
+
const stored = loadState(stateKey)
|
|
2787
|
+
return stored ? { ...base, ...stored } : base
|
|
2788
|
+
})
|
|
2789
|
+
const [fullscreen, setFullscreen] = useState(null)
|
|
2790
|
+
const [copiedJson, setCopiedJson] = useState(false)
|
|
2791
|
+
const copyTimer = useRef(null)
|
|
2792
|
+
const closeOverlay = useCallback(() => setFullscreen(null), [])
|
|
2793
|
+
const openOverlay = useCallback((node) => setFullscreen(node), [])
|
|
2794
|
+
const copyJson = useCallback(() => {
|
|
2795
|
+
if (!sourceJson) return
|
|
2796
|
+
Promise.resolve(copyText(sourceJson)).then(() => {
|
|
2797
|
+
setCopiedJson(true)
|
|
2798
|
+
if (copyTimer.current) clearTimeout(copyTimer.current)
|
|
2799
|
+
copyTimer.current = setTimeout(() => setCopiedJson(false), 1600)
|
|
2800
|
+
})
|
|
2801
|
+
}, [sourceJson])
|
|
2802
|
+
useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
|
|
2803
|
+
useEffect(() => {
|
|
2804
|
+
if (!stateKey) return
|
|
2805
|
+
const timer = setTimeout(() => saveState(stateKey, state), 300)
|
|
2806
|
+
return () => clearTimeout(timer)
|
|
2807
|
+
}, [stateKey, state])
|
|
2808
|
+
const setField = useCallback((id, value) => {
|
|
2809
|
+
setState((current) => current.fields[id] === value ? current : { ...current, fields: { ...current.fields, [id]: value } })
|
|
2810
|
+
}, [])
|
|
2811
|
+
const setTab = useCallback((key, index) => {
|
|
2812
|
+
setState((current) => ({ ...current, tabs: { ...current.tabs, [key]: index } }))
|
|
2813
|
+
}, [])
|
|
2814
|
+
const reset = useCallback(() => {
|
|
2815
|
+
setState((current) => ({ fields: {}, tabs: {}, round: (current.round ?? 0) + 1 }))
|
|
2816
|
+
if (stateKey) clearState(stateKey)
|
|
2817
|
+
}, [stateKey])
|
|
2818
|
+
const action = useCallback((name, payload) => {
|
|
2819
|
+
if (sessionId === undefined) return
|
|
2820
|
+
onAgentAction?.(name, payload)
|
|
2821
|
+
}, [onAgentAction, sessionId])
|
|
2822
|
+
const api = useMemo(() => ({
|
|
2823
|
+
state,
|
|
2824
|
+
setField,
|
|
2825
|
+
setTab,
|
|
2826
|
+
reset,
|
|
2827
|
+
action,
|
|
2828
|
+
canAct: sessionId !== undefined,
|
|
2829
|
+
openOverlay,
|
|
2830
|
+
}), [state, setField, setTab, reset, action, sessionId, openOverlay])
|
|
2831
|
+
// The fullscreen surface stays inside this component tree and enters the page
|
|
2832
|
+
// through a portal, so it owns no extra root and unmounts with the block.
|
|
2833
|
+
return h(Fragment, null,
|
|
2834
|
+
h(ViewContext.Provider, { value: api },
|
|
2835
|
+
h('div', { className: 'mosaic-root', 'data-mosaic-root': '' },
|
|
2836
|
+
h('div', { className: 'mosaic-head' },
|
|
2837
|
+
spec.title ? h('div', { className: 'mosaic-banner' }, renderRich(spec.title)) : h('span', { className: 'mosaic-banner' }),
|
|
2838
|
+
sourceJson ? h('button', {
|
|
2839
|
+
type: 'button',
|
|
2840
|
+
className: 'mosaic-json-copy',
|
|
2841
|
+
title: translate('copyJson'),
|
|
2842
|
+
onClick: copyJson,
|
|
2843
|
+
}, copiedJson ? translate('copied') : translate('copyJson')) : null),
|
|
2844
|
+
h('div', { className: 'mosaic-col', style: { gap: `${spec.gap ?? 12}px` } },
|
|
2845
|
+
spec.items.map((node, index) => h(NodeView, {
|
|
2846
|
+
key: index,
|
|
2847
|
+
node,
|
|
2848
|
+
path: `items[${index}]`,
|
|
2849
|
+
depth: 0,
|
|
2850
|
+
})))))
|
|
2851
|
+
, fullscreen !== null ? ReactDOM.createPortal(
|
|
2852
|
+
h(Overlay, { onClose: closeOverlay }, fullscreen),
|
|
2853
|
+
document.body,
|
|
2854
|
+
) : null)
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
function Overlay({ children, onClose }) {
|
|
2858
|
+
const panel = useRef(null)
|
|
2859
|
+
useEffect(() => {
|
|
2860
|
+
const onKey = (event) => { if (event.key === 'Escape') onClose() }
|
|
2861
|
+
document.addEventListener('keydown', onKey)
|
|
2862
|
+
const previous = document.activeElement
|
|
2863
|
+
panel.current?.focus()
|
|
2864
|
+
return () => {
|
|
2865
|
+
document.removeEventListener('keydown', onKey)
|
|
2866
|
+
if (previous instanceof HTMLElement) previous.focus()
|
|
2867
|
+
}
|
|
2868
|
+
}, [onClose])
|
|
2869
|
+
return h('div', {
|
|
2870
|
+
ref: panel,
|
|
2871
|
+
tabIndex: -1,
|
|
2872
|
+
className: 'mosaic-overlay',
|
|
2873
|
+
role: 'dialog',
|
|
2874
|
+
'aria-modal': 'true',
|
|
2875
|
+
onMouseDown: (event) => { if (event.target === event.currentTarget) onClose() },
|
|
2876
|
+
}, h('div', { className: 'mosaic-overlay-body' },
|
|
2877
|
+
h('button', { type: 'button', className: 'mosaic-overlay-close', onClick: onClose, 'aria-label': translate('close') }, '×'),
|
|
2878
|
+
children))
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
/* ------------------------------------------------------------------ */
|
|
2882
|
+
/* fence + tool view + apply */
|
|
2883
|
+
/* ------------------------------------------------------------------ */
|
|
2884
|
+
|
|
2885
|
+
function FenceView({ raw, settled, fenceKey, sessionId, onAgentAction }) {
|
|
2886
|
+
const spec = useMemo(() => parseView(raw), [raw])
|
|
2887
|
+
if (!spec) return h('div', { className: 'mosaic-skeleton' }, h('span'), h('span'), h('span'))
|
|
2888
|
+
const stateKey = settled && sessionId
|
|
2889
|
+
? `mosaic:f:${sessionId}:${fenceKey}:${fingerprint(raw)}`
|
|
2890
|
+
: undefined
|
|
2891
|
+
return h(Block, {
|
|
2892
|
+
key: stateKey ?? 'stream',
|
|
2893
|
+
spec,
|
|
2894
|
+
stateKey,
|
|
2895
|
+
sessionId,
|
|
2896
|
+
onAgentAction,
|
|
2897
|
+
sourceJson: raw,
|
|
2898
|
+
})
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
function ToolFallback({ toolName, callId }) {
|
|
2902
|
+
return h('div', { className: 'mosaic-tool-fallback' },
|
|
2903
|
+
h('b', null, toolName || 'render_view'),
|
|
2904
|
+
h('span', null, callId || 'replayed call has no presentation meta'))
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
function ToolView(props) {
|
|
2908
|
+
const block = props.block
|
|
2909
|
+
const meta = block && typeof block === 'object' && 'meta' in block ? block.meta : undefined
|
|
2910
|
+
const spec = meta && typeof meta === 'object' && Array.isArray(meta.items) && meta.items.length > 0 ? meta : null
|
|
2911
|
+
if (!spec) return h(ToolFallback, { toolName: props.toolName, callId: block?.callId })
|
|
2912
|
+
const sessionId = props.sessionId
|
|
2913
|
+
const stateKey = sessionId && block?.callId ? `mosaic:t:${sessionId}:${block.callId}` : undefined
|
|
2914
|
+
return h(Block, {
|
|
2915
|
+
spec,
|
|
2916
|
+
stateKey,
|
|
2917
|
+
sessionId,
|
|
2918
|
+
onAgentAction: (action, payload) => props.sendAction(sessionId, action, payload),
|
|
2919
|
+
})
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
function blockOf(pre) {
|
|
2923
|
+
if (!(pre instanceof Element)) return null
|
|
2924
|
+
// Prefer the outer code-block surface: the host nests the content holder
|
|
2925
|
+
// (`[data-code-block-content]`) inside the banner-bearing `.md-code-block`.
|
|
2926
|
+
let surface = pre.closest('.md-code-block, .code-block, .code-block-small')
|
|
2927
|
+
if (!surface) {
|
|
2928
|
+
const content = pre.closest('[data-code-block-content]')
|
|
2929
|
+
surface = content?.parentElement
|
|
2930
|
+
?? (pre.parentElement && pre.parentElement.querySelectorAll('pre').length === 1 ? pre.parentElement : null)
|
|
2931
|
+
for (let hops = 0; surface && hops < 3; hops += 1) {
|
|
2932
|
+
if (surface.querySelector('[data-code-block-banner]')
|
|
2933
|
+
|| surface.matches('.md-code-block, .code-block, .code-block-small')) break
|
|
2934
|
+
surface = surface.parentElement
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
if (!surface) return null
|
|
2938
|
+
if (surface.closest('.mosaic-fence, .mosaic-overlay-host, [data-chat-group-part="reasoning"], [data-tool], [data-sidebar-chat], [data-sidebar-right-session], [data-sidebar-right-panel], [data-panel-conversation], [data-plugin-panel]')) return null
|
|
2939
|
+
if (surface.querySelectorAll('pre').length !== 1) return null
|
|
2940
|
+
for (const element of surface.querySelectorAll('p, ul, ol, dl, table, h1, h2, h3, h4, h5, h6, blockquote, hr, img, figure')) {
|
|
2941
|
+
if (!pre.contains(element)) return null
|
|
2942
|
+
}
|
|
2943
|
+
return surface
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
const VIEW_TYPES = new Set([
|
|
2947
|
+
'text', 'row', 'col', 'grid', 'card', 'divider', 'spacer',
|
|
2948
|
+
'stat', 'badge', 'progress', 'list', 'table', 'keyvalue', 'steps',
|
|
2949
|
+
'callout', 'code', 'diff', 'chart', 'diagram',
|
|
2950
|
+
'image', 'video', 'audio', 'pdf', 'link',
|
|
2951
|
+
'button', 'input', 'select', 'slider', 'tabs', 'quiz', 'widget',
|
|
2952
|
+
])
|
|
2953
|
+
|
|
2954
|
+
function looksLikeViewBody(raw) {
|
|
2955
|
+
const spec = parseView(raw)
|
|
2956
|
+
return spec !== null && spec.items.some((node) => node && typeof node.type === 'string' && VIEW_TYPES.has(node.type))
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function languageOf(surface, pre) {
|
|
2960
|
+
const code = pre.querySelector('code')
|
|
2961
|
+
const className = typeof code?.className === 'string' ? code.className : ''
|
|
2962
|
+
if (/(?:^|\s)language-dsh-view(?:\s|$)/.test(className)) return 'dsh-view'
|
|
2963
|
+
for (const element of surface.querySelectorAll('*')) {
|
|
2964
|
+
if (pre.contains(element)) continue
|
|
2965
|
+
const text = (element.textContent ?? '').trim()
|
|
2966
|
+
if (text === 'dsh-view' && element.childElementCount === 0) return 'dsh-view'
|
|
2967
|
+
}
|
|
2968
|
+
const dataLanguage = pre.getAttribute('data-language') ?? code?.getAttribute('data-language')
|
|
2969
|
+
if (dataLanguage === 'dsh-view') return 'dsh-view'
|
|
2970
|
+
const jsonTagged = /(?:^|\s)language-jsonc?(?:\s|$)/.test(className)
|
|
2971
|
+
|| dataLanguage === 'json'
|
|
2972
|
+
|| Array.from(surface.querySelectorAll('*')).some((element) => !pre.contains(element)
|
|
2973
|
+
&& element.childElementCount === 0
|
|
2974
|
+
&& element.textContent.trim() === 'json')
|
|
2975
|
+
// The model sometimes labels the fence ```json even though the body is a
|
|
2976
|
+
// complete dsh-view spec. Render it only in an assistant row, and only
|
|
2977
|
+
// when the body actually contains a known Mosaic component.
|
|
2978
|
+
if ((jsonTagged || className === '')
|
|
2979
|
+
&& surface.closest('[data-chat-flow-kind="assistant-step"]') !== null
|
|
2980
|
+
&& looksLikeViewBody(rawOf(pre))) return 'dsh-view'
|
|
2981
|
+
return null
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function rawOf(pre) {
|
|
2985
|
+
const code = pre.querySelector('code')
|
|
2986
|
+
return (code ? code.textContent : pre.textContent ?? '').replace(/\n$/, '')
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function installFenceRenderer(ctx, sendAction) {
|
|
2990
|
+
if (typeof document === 'undefined') return () => {}
|
|
2991
|
+
const mounts = new Map()
|
|
2992
|
+
const diagnostics = new Map()
|
|
2993
|
+
const ids = new WeakMap()
|
|
2994
|
+
let nextId = 1
|
|
2995
|
+
let timer = null
|
|
2996
|
+
let disposed = false
|
|
2997
|
+
|
|
2998
|
+
const sessionId = () => currentSessionId(ctx)
|
|
2999
|
+
|
|
3000
|
+
function idOf(block) {
|
|
3001
|
+
if (!ids.has(block)) ids.set(block, nextId += 1)
|
|
3002
|
+
return ids.get(block)
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
function showDiagnostic(block, raw) {
|
|
3006
|
+
const existing = diagnostics.get(block)
|
|
3007
|
+
if (existing && existing.raw === raw) return
|
|
3008
|
+
const reason = describeParse(raw)
|
|
3009
|
+
reportLog('warn', 'fence.parse-failed', { chars: String(raw ?? '').length, reason })
|
|
3010
|
+
const text = translate('parseFailed', { detail: reason })
|
|
3011
|
+
const sibling = block.previousElementSibling
|
|
3012
|
+
if (sibling?.classList.contains('mosaic-diagnostic')) {
|
|
3013
|
+
sibling.textContent = text
|
|
3014
|
+
diagnostics.set(block, { strip: sibling, raw })
|
|
3015
|
+
return
|
|
3016
|
+
}
|
|
3017
|
+
if (existing) clearDiagnostic(block)
|
|
3018
|
+
const strip = document.createElement('div')
|
|
3019
|
+
strip.className = 'mosaic-diagnostic'
|
|
3020
|
+
strip.setAttribute('role', 'alert')
|
|
3021
|
+
strip.textContent = text
|
|
3022
|
+
block.before(strip)
|
|
3023
|
+
diagnostics.set(block, { strip, raw })
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
function clearDiagnostic(block) {
|
|
3027
|
+
const existing = diagnostics.get(block)
|
|
3028
|
+
if (!existing) return
|
|
3029
|
+
existing.strip.remove()
|
|
3030
|
+
diagnostics.delete(block)
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
function unmount(block) {
|
|
3034
|
+
const mount = mounts.get(block)
|
|
3035
|
+
if (!mount) return
|
|
3036
|
+
mounts.delete(block)
|
|
3037
|
+
try { mount.root.unmount() } catch {}
|
|
3038
|
+
mount.container.remove()
|
|
3039
|
+
block.style.display = ''
|
|
3040
|
+
block.removeAttribute('data-mosaic-rendered')
|
|
3041
|
+
clearDiagnostic(block)
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
function mount(block, raw, settled) {
|
|
3045
|
+
// Another observer generation may already own this block. The marker
|
|
3046
|
+
// is shared in the DOM, so a duplicate apply cannot stack fences.
|
|
3047
|
+
if (block.hasAttribute('data-mosaic-rendered')) return
|
|
3048
|
+
const spec = parseView(raw)
|
|
3049
|
+
if (!spec) {
|
|
3050
|
+
if (settled) showDiagnostic(block, raw)
|
|
3051
|
+
return
|
|
3052
|
+
}
|
|
3053
|
+
clearDiagnostic(block)
|
|
3054
|
+
const container = document.createElement('div')
|
|
3055
|
+
container.className = 'mosaic-fence'
|
|
3056
|
+
block.after(container)
|
|
3057
|
+
const root = ReactDOMClient.createRoot(container)
|
|
3058
|
+
const fenceSession = sessionId()
|
|
3059
|
+
const render = (nextRaw, nextSettled) => root.render(h(FenceView, {
|
|
3060
|
+
raw: nextRaw,
|
|
3061
|
+
settled: nextSettled,
|
|
3062
|
+
fenceKey: idOf(block),
|
|
3063
|
+
sessionId: fenceSession,
|
|
3064
|
+
onAgentAction: (action, payload) => sendAction(fenceSession, action, payload),
|
|
3065
|
+
}))
|
|
3066
|
+
try {
|
|
3067
|
+
render(raw, settled)
|
|
3068
|
+
} catch {
|
|
3069
|
+
try { root.unmount() } catch {}
|
|
3070
|
+
container.remove()
|
|
3071
|
+
return
|
|
3072
|
+
}
|
|
3073
|
+
block.style.display = 'none'
|
|
3074
|
+
block.setAttribute('data-mosaic-rendered', '')
|
|
3075
|
+
mounts.set(block, { container, root, raw, settled, render })
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
function update(block, raw, settled) {
|
|
3079
|
+
const mount = mounts.get(block)
|
|
3080
|
+
if (!mount) return
|
|
3081
|
+
const spec = parseView(raw)
|
|
3082
|
+
if (!spec) {
|
|
3083
|
+
if (!settled) return
|
|
3084
|
+
unmount(block)
|
|
3085
|
+
showDiagnostic(block, raw)
|
|
3086
|
+
return
|
|
3087
|
+
}
|
|
3088
|
+
mount.raw = raw
|
|
3089
|
+
mount.settled = settled
|
|
3090
|
+
// A host re-render can restore the original block's display without
|
|
3091
|
+
// touching structure; re-apply the surgery before the next paint.
|
|
3092
|
+
block.style.display = 'none'
|
|
3093
|
+
block.setAttribute('data-mosaic-rendered', '')
|
|
3094
|
+
try {
|
|
3095
|
+
mount.render(raw, settled)
|
|
3096
|
+
} catch {
|
|
3097
|
+
unmount(block)
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
function scan() {
|
|
3102
|
+
if (disposed) return
|
|
3103
|
+
const seen = new Set()
|
|
3104
|
+
for (const pre of document.querySelectorAll('pre')) {
|
|
3105
|
+
const block = blockOf(pre)
|
|
3106
|
+
if (!block || seen.has(block)) continue
|
|
3107
|
+
seen.add(block)
|
|
3108
|
+
if (languageOf(block, pre) !== 'dsh-view') {
|
|
3109
|
+
if (mounts.has(block)) unmount(block)
|
|
3110
|
+
continue
|
|
3111
|
+
}
|
|
3112
|
+
const raw = rawOf(pre)
|
|
3113
|
+
if (raw.trim() === '') continue
|
|
3114
|
+
const settled = pre.closest('[data-streaming]') === null
|
|
3115
|
+
const existing = mounts.get(block)
|
|
3116
|
+
if (existing && (!existing.container.isConnected || !block.isConnected)) {
|
|
3117
|
+
unmount(block)
|
|
3118
|
+
}
|
|
3119
|
+
if (mounts.has(block)) update(block, raw, settled)
|
|
3120
|
+
else if (!block.hasAttribute('data-mosaic-rendered')) mount(block, raw, settled)
|
|
3121
|
+
}
|
|
3122
|
+
for (const block of [...mounts.keys()]) {
|
|
3123
|
+
if (!block.isConnected) unmount(block)
|
|
3124
|
+
}
|
|
3125
|
+
for (const block of [...diagnostics.keys()]) {
|
|
3126
|
+
if (!block.isConnected) clearDiagnostic(block)
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
function schedule() {
|
|
3131
|
+
if (disposed || timer !== null) return
|
|
3132
|
+
timer = setTimeout(() => {
|
|
3133
|
+
timer = null
|
|
3134
|
+
scan()
|
|
3135
|
+
}, 40)
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
const onLocale = () => {
|
|
3139
|
+
for (const mount of mounts.values()) {
|
|
3140
|
+
try { mount.render(mount.raw, mount.settled) } catch {}
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
const unsubscribeLocale = ctx.locale.subscribe(onLocale)
|
|
3144
|
+
const observer = new MutationObserver(schedule)
|
|
3145
|
+
observer.observe(document.body, {
|
|
3146
|
+
childList: true,
|
|
3147
|
+
subtree: true,
|
|
3148
|
+
characterData: true,
|
|
3149
|
+
attributes: true,
|
|
3150
|
+
attributeFilter: ['class', 'data-streaming'],
|
|
3151
|
+
})
|
|
3152
|
+
scan()
|
|
3153
|
+
|
|
3154
|
+
return () => {
|
|
3155
|
+
disposed = true
|
|
3156
|
+
unsubscribeLocale()
|
|
3157
|
+
observer.disconnect()
|
|
3158
|
+
if (timer !== null) clearTimeout(timer)
|
|
3159
|
+
for (const block of [...mounts.keys()]) unmount(block)
|
|
3160
|
+
for (const block of [...diagnostics.keys()]) clearDiagnostic(block)
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
function apply(ctx) {
|
|
3165
|
+
clearLegacyStorage()
|
|
3166
|
+
ctx.effect(ensureStyles, 'dsh-mosaic: styles')
|
|
3167
|
+
ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-mosaic: dictionaries')
|
|
3168
|
+
// A bound translate keeps its identity and resolves the active language per
|
|
3169
|
+
// call, so a component can hold it without subscribing on its own.
|
|
3170
|
+
translate = ctx.locale.bind(NS)
|
|
3171
|
+
const sendAction = makeSender(ctx)
|
|
3172
|
+
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
|
3173
|
+
name: 'tool.call.toolview',
|
|
3174
|
+
key: 'render_view',
|
|
3175
|
+
locale: NS,
|
|
3176
|
+
inject: () => ({ sendAction }),
|
|
3177
|
+
}, ToolView))
|
|
3178
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
3179
|
+
name: 'settings.section',
|
|
3180
|
+
id: 'mosaic',
|
|
3181
|
+
order: 30,
|
|
3182
|
+
label: () => translate('settingsTitle'),
|
|
3183
|
+
locale: NS,
|
|
3184
|
+
}, MosaicSettings))
|
|
3185
|
+
ctx.effect(() => installFenceRenderer(ctx, sendAction), 'dsh-mosaic: fence')
|
|
3186
|
+
console.info('[mosaic] client active; fence=dsh-view')
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
return { inject: ['slots', 'sessions', 'locale'], apply }
|
|
3190
|
+
},
|
|
3191
|
+
})
|