minecodex 2.0.38 → 2.0.39

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.
Files changed (23) hide show
  1. package/features/markdown-reading/assets/qv-dependencies.js +4412 -0
  2. package/features/markdown-reading/assets/qv-export.js +2 -0
  3. package/features/markdown-reading/assets/qv-loading.css +4 -0
  4. package/features/markdown-reading/assets/qv-template.json +1 -0
  5. package/features/markdown-reading/codex-feature.json +6 -1
  6. package/features/markdown-reading/src/adapters/codex/mermaid-viewer.mjs +95 -0
  7. package/features/markdown-reading/src/adapters/codex/mermaid.mjs +91 -0
  8. package/features/markdown-reading/src/adapters/codex/preview.mjs +2 -2
  9. package/features/markdown-reading/src/adapters/codex/reading-runtime.mjs +10 -0
  10. package/features/markdown-reading/src/adapters/codex/table-actions.mjs +14 -0
  11. package/features/markdown-reading/src/injection-source.mjs +16 -1
  12. package/features/markdown-reading/src/markdown-reading.mjs +5 -3
  13. package/features/markdown-reading/src/mermaid/export.mjs +83 -0
  14. package/features/markdown-reading/src/mermaid/frame.mjs +109 -0
  15. package/features/markdown-reading/src/mermaid/loading.mjs +27 -0
  16. package/features/markdown-reading/src/mermaid/theme.css +8 -0
  17. package/features/markdown-reading/src/mermaid/viewport.mjs +75 -0
  18. package/features/markdown-reading/vendor/qv/LICENSE +21 -0
  19. package/features/markdown-reading/vendor/qv/THIRD_PARTY_NOTICES.txt +3009 -0
  20. package/features/markdown-reading/vendor/qv/provenance.json +55 -0
  21. package/features/usage-meter/src/adapters/codex/renderer.mjs +11 -3
  22. package/features/usage-meter/src/usage-meter.mjs +19 -4
  23. package/package.json +14 -4
@@ -15,7 +15,12 @@
15
15
  "script": "src/markdown-reading.mjs",
16
16
  "sourceFactory": "src/injection-source.mjs",
17
17
  "resources": {
18
- "styles": "src/reading.css"
18
+ "styles": "src/reading.css",
19
+ "mermaidStyles": "src/mermaid/theme.css",
20
+ "qvLoadingStyles": "assets/qv-loading.css",
21
+ "qvExport": "assets/qv-export.js",
22
+ "qvTemplate": "assets/qv-template.json",
23
+ "qvDependencies": "assets/qv-dependencies.js"
19
24
  }
20
25
  },
21
26
  "icon": {
@@ -0,0 +1,95 @@
1
+ import { readQvTheme } from '../../mermaid/frame.mjs';
2
+ export function createMarkdownMermaidControls(native) {
3
+ const { React, Actions, Button, CopyButton, Tooltip, ExpandIcon, DownloadIcon, downloadImage, TablePreview } = native;
4
+ const h = React.createElement, chinese = document.documentElement.lang.startsWith('zh');
5
+ const download = chinese ? '下载图表' : 'Download diagram';
6
+ const expand = chinese ? '放大图表' : 'Expand diagram';
7
+ function configureShell(area) {
8
+ const shell=area.parentElement.parentElement;
9
+ shell.style.paddingTop='64px';shell.style.paddingBottom='48px';
10
+ shell.style.paddingInline='clamp(16px, 4vw, 48px)';
11
+ }
12
+ function CanvasMeasure({onMeasured}) {
13
+ const ref=React.useRef(null);
14
+ React.useLayoutEffect(()=>{
15
+ const area=ref.current.closest('[data-testid="image-preview-dismiss-area"]');
16
+ configureShell(area);
17
+ let ticket=requestAnimationFrame(()=>{ticket=requestAnimationFrame(()=>onMeasured({width:area.clientWidth,height:area.clientHeight}));});
18
+ return ()=>cancelAnimationFrame(ticket);
19
+ },[onMeasured]);
20
+ return h('div',{ref});
21
+ }
22
+ function FrameView({ item, size, palette, onViewport }) {
23
+ const ref = React.useRef(null);
24
+ React.useLayoutEffect(() => {
25
+ const slot = ref.current, frame = item.controller.frame;
26
+ item.expanded = true;
27
+ slot.moveBefore(frame, null);
28
+ frame.style.width = size.width + 'px'; frame.style.height = size.height + 'px';
29
+ frame.style.transformOrigin = '0 0'; item.controller.mode('viewer');
30
+ const wrapper = slot.parentElement;
31
+ const area = slot.closest('[data-testid="image-preview-dismiss-area"]');
32
+ configureShell(area);
33
+ const update = () => { const scale=wrapper.getBoundingClientRect().width/size.width;frame.style.transform=`scale(${scale})`;frame.dataset.zoom=String(scale); };
34
+ const measure = () => {
35
+ const rect = area.getBoundingClientRect();
36
+ onViewport(previous => previous?.width === rect.width && previous?.height === rect.height
37
+ ? previous : { width: rect.width, height: rect.height });
38
+ };
39
+ const observer = new ResizeObserver(update); observer.observe(wrapper); update();
40
+ const viewportObserver = new ResizeObserver(measure); viewportObserver.observe(area); measure();
41
+ let drag = null;
42
+ item.controller.input(input => {
43
+ const rect = frame.getBoundingClientRect(), scale = rect.width / size.width;
44
+ if (input.kind === 'wheel') {
45
+ area.dispatchEvent(new WheelEvent('wheel',{bubbles:true,cancelable:true,ctrlKey:true,deltaY:input.deltaY,deltaMode:input.deltaMode,clientX:rect.left+input.x*scale,clientY:rect.top+input.y*scale}));
46
+ }
47
+ if (input.kind === 'start') drag = {left:area.scrollLeft,top:area.scrollTop};
48
+ if (input.kind === 'drag' && drag) { area.scrollLeft = drag.left-input.dx*scale; area.scrollTop = drag.top-input.dy*scale; }
49
+ if (input.kind === 'end') drag = null;
50
+ if (input.kind === 'pan') {area.scrollLeft+=input.dx;area.scrollTop+=input.dy;}
51
+ });
52
+ return () => {
53
+ observer.disconnect(); viewportObserver.disconnect(); item.controller.input(null); item.expanded = false;
54
+ if (item.slot.isConnected) item.slot.moveBefore(frame, null);
55
+ frame.style.transform = ''; frame.style.transformOrigin = ''; frame.style.width = '';
56
+ frame.style.height = item.height + 'px'; item.controller.mode('inline');
57
+ };
58
+ }, [item,size.width]);
59
+ React.useLayoutEffect(()=>{item.controller.frame.style.height=size.height+'px';},[item,size.height]);
60
+ return h('div',{ref,className:'mcx-reading-qv-slot',style:palette});
61
+ }
62
+ return function Controls({ item }) {
63
+ const [open,setOpen] = React.useState(false), [size,setSize] = React.useState(null);
64
+ const [downloading,setDownloading] = React.useState(false), [failed,setFailed] = React.useState(false);
65
+ const [measuring,setMeasuring] = React.useState(false);
66
+ const pendingMeasurement = React.useRef(null);
67
+ const onMeasured = React.useCallback(canvas=>{pendingMeasurement.current?.resolve(canvas);pendingMeasurement.current=null;setMeasuring(false);},[]);
68
+ React.useEffect(()=>()=>{pendingMeasurement.current?.reject(new Error('diagram-disposed'));},[]);
69
+ const [viewport,setViewport] = React.useState(null);
70
+ const [ready,setReady] = React.useState(item.ready), [palette,setPalette] = React.useState({});
71
+ const trigger = React.useRef(null);
72
+ React.useEffect(() => { item.setReady=setReady; item.setNaturalHeight=height=>setSize(size=>size&&size.height!==height?{...size,height}:size); item.close=()=>setOpen(false); return ()=>{item.setReady=null;item.setNaturalHeight=null;item.close=null;}; }, [item]);
73
+ const save = async () => {
74
+ if (downloading || !item.ready) return;
75
+ setDownloading(true);setFailed(false);
76
+ try {
77
+ const canvas = open && viewport ? viewport : await new Promise((resolve,reject)=>{pendingMeasurement.current={resolve,reject};setMeasuring(true);});
78
+ const blob = await item.export(canvas);
79
+ if (!await downloadImage({downloadSrc:blob,suggestedFilename:'mermaid-diagram.png'})) throw new Error('diagram-download-failed');
80
+ } catch { setFailed(true); }
81
+ finally { setDownloading(false); }
82
+ };
83
+ // 把占画幅 90% 的适配尺寸交给原生组件,作为其 100% 缩放基准。
84
+ const fit = viewport && size ? .9 * Math.min(viewport.width / size.width, viewport.height / size.height) : 1;
85
+ return h(React.Fragment,null,
86
+ h(Actions, { 'data-visible':true, 'data-markdown-copy':'exclude' },
87
+ h(CopyButton,{ariaLabel:chinese?'复制图表源码':'Copy diagram source',buttonText:chinese?'复制图表源码':'Copy diagram source',iconOnly:true,variant:'blockAction',onCopy:()=>navigator.clipboard.writeText(item.source).then(()=>true)}),
88
+ h(Tooltip,{tooltipContent:failed?(chinese?'下载失败,点击重试':'Download failed. Retry'):download,delayOpen:true},h(Button,{'aria-label':download,'aria-busy':downloading,disabled:!ready||downloading,color:'blockAction',radius:'full',size:'inputIcon',onClick:save},h(DownloadIcon,{className:'icon-xs'}))),
89
+ h(Tooltip,{tooltipContent:expand,delayOpen:true},h(Button,{ref:trigger,'aria-label':expand,'aria-expanded':open,disabled:!ready,color:'blockAction',radius:'full',size:'inputIcon',onClick:()=>{setFailed(false);const theme=readQvTheme(item.host);setPalette({background:theme.tokens['--background'],color:theme.tokens['--foreground']});setViewport(null);setSize(item.controller.size());setOpen(true);}},h(ExpandIcon)))),
90
+ measuring?h('div',{'aria-hidden':true,inert:true,style:{position:'fixed',inset:0,width:'100vw',height:'100dvh',visibility:'hidden',pointerEvents:'none',zIndex:-1}},
91
+ h(TablePreview,{src:'',alt:expand,open:true,inline:true,imageSurface:'card',showDownload:false,showZoomControls:true,onOpenChange:()=>{},zoomableContent:{width:1,height:1,content:h(CanvasMeasure,{onMeasured})}})):null,
92
+ open&&size?h(TablePreview,{src:'',alt:expand,open:true,dialogAriaLabel:expand,closeAriaLabel:chinese?'关闭图表预览':'Close diagram preview',imageSurface:'card',caption:failed?(chinese?'下载失败,请重试。':'Download failed. Please retry.'):undefined,showDownload:true,downloadAriaLabel:download,onDownload:save,showZoomControls:true,disableOpenAnimation:true,onOpenChange:setOpen,onOpenAutoFocus:event=>{event.preventDefault();item.controller.frame.focus({preventScroll:true});},onCloseAutoFocus:event=>{event.preventDefault();trigger.current?.focus({preventScroll:true});},
93
+ zoomableContent:{width:size.width*fit,height:size.height*fit,content:h(FrameView,{item,size,palette,onViewport:setViewport})}}):null);
94
+ };
95
+ }
@@ -0,0 +1,91 @@
1
+ import { createQvResources, createQvFrame, readQvTheme } from '../../mermaid/frame.mjs';
2
+ import { exportQvDiagram } from '../../mermaid/export.mjs';
3
+ import { createQvLoading } from '../../mermaid/loading.mjs';
4
+ import { captureMarkdownProviders } from './providers.mjs';
5
+ import { createMarkdownMermaidControls } from './mermaid-viewer.mjs';
6
+
7
+ export function installMarkdownMermaid(view, api, native, codex, resources, onStatus = () => {}) {
8
+ if (!Element.prototype.moveBefore) throw new Error('qv-state-preserving-move-unavailable');
9
+ const { Decoration, StateField, StateEffect, Compartment, WidgetType, Prec } = api;
10
+ const assets = createQvResources(resources), mounted = new Set();
11
+ const Controls = createMarkdownMermaidControls(native);
12
+ const withProviders = captureMarkdownProviders(view.dom, codex);
13
+ let disposed = false;
14
+ const available = () => [...mounted].every(item => item.ready);
15
+ const report = () => { if (!disposed) onStatus(available()); };
16
+ class Diagram extends WidgetType {
17
+ constructor(source) { super(); this.source = source; }
18
+ eq(other) { return this.source === other.source; }
19
+ toDOM() {
20
+ const host = document.createElement('div'), slot = document.createElement('div'), actions = document.createElement('div');
21
+ const status = document.createElement('div');
22
+ const chinese = document.documentElement.lang.startsWith('zh');
23
+ status.className = 'mcx-reading-qv-status'; status.setAttribute('role', 'status');
24
+ status.setAttribute('aria-label', chinese ? '正在加载图表…' : 'Loading diagram…');
25
+ host.className = 'mcx-reading-qv-widget'; slot.className = 'mcx-reading-qv-inline'; actions.className = 'mcx-reading-qv-actions';
26
+ host.append(actions, slot); slot.append(status);
27
+ const finishLoading = createQvLoading(status);
28
+ const item = { host, slot, source:this.source, height:280, ready:false, expanded:false };
29
+ slot.style.height = '280px';
30
+ item.controller = createQvFrame(this.source, assets, readQvTheme(view.dom), height => {
31
+ if (disposed || view.destroyed) return;
32
+ if (item.expanded) {item.setNaturalHeight?.(height);return;}
33
+ item.height = height; slot.style.height = height + 'px';
34
+ item.controller.frame.style.height = height + 'px'; view.requestMeasure();
35
+ }, state => {
36
+ finishLoading();
37
+ status.removeAttribute('aria-label');
38
+ item.ready = state === 'enhanced' || state === 'native';
39
+ status.hidden = item.ready;
40
+ if (!item.ready) status.textContent = chinese ? '图表加载失败,可复制源码查看。' : 'Diagram failed to load. Copy the source to inspect it.';
41
+ item.controller.frame.style.visibility = item.ready ? '' : 'hidden';
42
+ item.setReady?.(item.ready); report();
43
+ }, () => item.close?.());
44
+ item.controller.frame.style.visibility = 'hidden';
45
+ slot.append(item.controller.frame);
46
+ item.export = async canvas => exportQvDiagram(item.controller.frame, await assets.exporter(item.controller.frame.contentDocument),canvas);
47
+ const root = native.ReactDOM.createRoot(actions);
48
+ root.render(withProviders(native.React, native.React.createElement(Controls, {item})));
49
+ item.dispose = () => { finishLoading(); root.unmount(); item.controller.dispose(); mounted.delete(item); report(); };
50
+ host._mcxQv = item; mounted.add(item); report();
51
+ return host;
52
+ }
53
+ destroy(host) { host._mcxQv?.dispose(); }
54
+ ignoreEvent() { return true; }
55
+ }
56
+ function build(state) {
57
+ const ranges = [];
58
+ state.tree?.iterate({ enter(n) {
59
+ if (n.name !== 'FencedCode') return;
60
+ const node = n.node, info = node.getChild('CodeInfo');
61
+ if (!info || state.sliceDoc(info.from, info.to).split(/\s/,1)[0] !== 'mermaid'
62
+ || node.getChildren('CodeMark').length !== 2) return false;
63
+ const source = node.getChildren('CodeText').map(n=>state.sliceDoc(n.from,n.to)).join('');
64
+ if (source.length > 50000) return false;
65
+ ranges.push(Decoration.replace({ block:true, widget:new Diagram(source) })
66
+ .range(state.doc.lineAt(n.from).from, state.doc.lineAt(n.to).to));
67
+ return false;
68
+ } });
69
+ return Decoration.set(ranges, true);
70
+ }
71
+ const field = StateField.define({ create:build, update(value,tr) {
72
+ return tr.docChanged || tr.startState.tree !== tr.state.tree ? build(tr.state) : value;
73
+ }, provide:field=>view.constructor.decorations.from(field) });
74
+ const compartment = new Compartment();
75
+ view.dispatch({ effects:StateEffect.appendConfig.of(compartment.of(Prec.highest(field))) });
76
+ const themes = new MutationObserver(() => {
77
+ if (disposed) return;
78
+ const theme = readQvTheme(view.dom);
79
+ for (const item of mounted) item.controller.theme(theme);
80
+ });
81
+ themes.observe(document.documentElement,{attributes:true,attributeFilter:['class','style','data-theme']});
82
+ const cleanup = () => {
83
+ if (disposed) return;
84
+ disposed = true; themes.disconnect();
85
+ if (!view.destroyed) view.dispatch({effects:compartment.reconfigure([])});
86
+ for (const item of [...mounted]) item.dispose();
87
+ assets.dispose();
88
+ };
89
+ cleanup.available = available;
90
+ return cleanup;
91
+ }
@@ -1,7 +1,7 @@
1
1
  import { createMarkdownReadingRuntime } from './reading-runtime.mjs';
2
2
 
3
3
  // 当前原生 Markdown Preview 是 CodeMirror 视图;Source 与 Pages 不属于此契约。
4
- export function createMarkdownPreviewAdapter(codex) {
4
+ export function createMarkdownPreviewAdapter(codex, resources) {
5
5
  let runtime;
6
6
  function preview(editor) {
7
7
  if (!editor.isConnected || !editor.closest('[data-editor-search-surface]')) return null;
@@ -31,7 +31,7 @@ export function createMarkdownPreviewAdapter(codex) {
31
31
  preview,
32
32
  previews,
33
33
  attach(item) {
34
- runtime ??= createMarkdownReadingRuntime(codex);
34
+ runtime ??= createMarkdownReadingRuntime(codex, undefined, resources);
35
35
  return runtime.attach(item);
36
36
  },
37
37
  };
@@ -5,6 +5,7 @@ import { loadNativeMarkdownToc } from './toc-native.mjs';
5
5
  import { installMarkdownToc } from './toc.mjs';
6
6
  import { loadNativeMarkdownTableActions, installMarkdownTableActions } from './table-actions.mjs';
7
7
  import { loadNativeMarkdownRichText, installMarkdownRichText } from './rich-text.mjs';
8
+ import { installMarkdownMermaid } from './mermaid.mjs';
8
9
 
9
10
  // 每个 Preview 共用换行状态,虚拟滚动重挂载的块也读取当前值。
10
11
  export function createMarkdownWrapState() {
@@ -38,8 +39,10 @@ export function createMarkdownReadingRuntime(
38
39
  installMarkdownTableActions,
39
40
  loadNativeMarkdownRichText,
40
41
  installMarkdownRichText,
42
+ installMarkdownMermaid,
41
43
  installMarkdownMetadataScroll,
42
44
  },
45
+ resources = {},
43
46
  ) {
44
47
  const cached = new Map();
45
48
  function load(name, ...args) {
@@ -61,6 +64,7 @@ export function createMarkdownReadingRuntime(
61
64
  ['markdown-reading-toc', 'loading'],
62
65
  ['markdown-table-actions', 'loading'],
63
66
  ['markdown-reading-rich-text', 'loading'],
67
+ ['markdown-reading-mermaid', 'loading'],
64
68
  ]);
65
69
  const cleanups = [capabilities.installMarkdownMetadataScroll(editor, scroller)];
66
70
  let disposed = false;
@@ -99,6 +103,12 @@ export function createMarkdownReadingRuntime(
99
103
  if (!current()) return;
100
104
  cleanups.push(capabilities.installMarkdownTableActions(view, actions, codex, tables.controller));
101
105
  report('markdown-table-actions', true);
106
+ await stage('markdown-reading-mermaid', 'native-mermaid-adapter-unavailable', async () => {
107
+ const cleanup = capabilities.installMarkdownMermaid(view, api, actions, codex, resources,
108
+ available => report('markdown-reading-mermaid', available, available ? null : 'qv-renderer-not-ready'));
109
+ cleanups.push(cleanup);
110
+ report('markdown-reading-mermaid', cleanup.available?.() ?? true);
111
+ });
102
112
  await installRichText(api, actions);
103
113
  });
104
114
  }
@@ -1,3 +1,4 @@
1
+ import { saveQvDiagram } from '../../mermaid/export.mjs';
1
2
  import { loadNativeMarkdownRenderer } from './markdown-native.mjs';
2
3
  import { captureMarkdownProviders } from './providers.mjs';
3
4
 
@@ -92,8 +93,21 @@ export async function loadNativeMarkdownTableActions(native) {
92
93
  binding(icon[3]),
93
94
  loadNativeMarkdownRenderer(source, url, module),
94
95
  ]);
96
+ const preview = section('id:`imagePreviewDialog.label`');
97
+ const downloadName = preview.match(/([\w$]+)\(\{downloadSrc:[\w$]+,onDownloaded:/)?.[1];
98
+ const downloadSection = preview.slice(preview.indexOf('suggestedFilename:', preview.indexOf('downloadSrc:')));
99
+ const downloadIcon = downloadSection.match(/children:\(0,[\w$]+\.jsx\)\(([\w$]+),\{className:`icon-xs`\}/)?.[1];
100
+ if (!downloadName || !downloadIcon) throw new Error('native-diagram-download-unavailable');
101
+ const downloadSource = section('Failed to download image');
102
+ const requestName = downloadSource.match(/await ([\w$]+)\(`save-file`,/)?.[1];
103
+ if (!requestName) throw new Error('native-diagram-save-request-unavailable');
104
+ const [DownloadIcon, saveFile] = await Promise.all([binding(downloadIcon),binding(requestName)]);
95
105
  return {
96
106
  ...native,
107
+ async downloadImage({downloadSrc,suggestedFilename}) {
108
+ return saveQvDiagram(downloadSrc,saveFile,markdownDownloadsDirectory,suggestedFilename);
109
+ },
110
+ DownloadIcon,
97
111
  ...renderer,
98
112
  TablePreview: publicBinding(previewName),
99
113
  Table: publicBinding(tableName),
@@ -15,12 +15,27 @@ import {
15
15
  import { loadNativeMarkdownLinks, installMarkdownLinks } from './adapters/codex/links.mjs';
16
16
  import { loadNativeMarkdownToc } from './adapters/codex/toc-native.mjs';
17
17
  import { installMarkdownToc } from './adapters/codex/toc.mjs';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
18
20
  import { readFile } from 'node:fs/promises';
19
21
  import { installMarkdownTables } from './adapters/codex/tables.mjs';
20
22
  import { loadMarkdownListAPI, installMarkdownLists } from './adapters/codex/lists.mjs';
21
23
  import { createMarkdownPreviewAdapter } from './adapters/codex/preview.mjs';
24
+ import { createQvResources, createQvFrame, readQvTheme } from './mermaid/frame.mjs';
25
+ import { exportQvDiagram, saveQvDiagram } from './mermaid/export.mjs';
26
+ import { createQvLoading } from './mermaid/loading.mjs';
27
+ import { createMarkdownMermaidControls } from './adapters/codex/mermaid-viewer.mjs';
28
+ import { installMarkdownMermaid } from './adapters/codex/mermaid.mjs';
22
29
  export async function createPageScriptSource() {
23
30
  const bindings = [
31
+ exportQvDiagram,
32
+ saveQvDiagram,
33
+ createQvLoading,
34
+ createQvResources,
35
+ createQvFrame,
36
+ readQvTheme,
37
+ createMarkdownMermaidControls,
38
+ installMarkdownMermaid,
24
39
  collectMarkdownFootnotes,
25
40
  captureMarkdownProviders,
26
41
  createMarkdownReadingRuntime,
@@ -43,5 +58,5 @@ export async function createPageScriptSource() {
43
58
  ];
44
59
  const declarations = bindings.map((binding) => `const ${binding.name} = ${binding.toString()};`).join('\n');
45
60
  const entry = await readFile(new URL('./markdown-reading.mjs', import.meta.url), 'utf8');
46
- return `${declarations}\n${entry}`;
61
+ return `const markdownDownloadsDirectory=${JSON.stringify(join(homedir(),'Downloads'))};\n${declarations}\n${entry}`;
47
62
  }
@@ -1,11 +1,13 @@
1
1
  const marker = 'data-minecodex-markdown-reading';
2
- const native = createMarkdownPreviewAdapter(config.codex);
2
+ const native = createMarkdownPreviewAdapter(config.codex, config.resources);
3
3
  const styled = new Map();
4
4
  const sessions = new Map();
5
- const tokenNames = ['--font-content', '--text-document', '--leading-relaxed', '--font-weight-semibold'];
5
+ const tokenNames = ['--font-content', '--text-document', '--leading-relaxed', '--font-weight-semibold',
6
+ '--color-token-main-surface-primary', '--color-token-bg-secondary', '--color-text-foreground',
7
+ '--color-text-foreground-secondary', '--color-border'];
6
8
  const style = document.createElement('style');
7
9
  style.setAttribute('data-minecodex-markdown-reading-styles', '');
8
- style.textContent = config.resources.styles;
10
+ style.textContent = [config.resources.styles, config.resources.mermaidStyles, config.resources.qvLoadingStyles].filter(Boolean).join('\n');
9
11
  document.head.append(style);
10
12
  let disposed = false;
11
13
  let pending = null;
@@ -0,0 +1,83 @@
1
+ // 导出副本保留原始排版;正文和原生查看器的缩放、滚动与动画不受影响。
2
+ export async function exportQvDiagram(frame, toBlob, canvasSize) {
3
+ const doc = frame.contentDocument, root = doc.querySelector('.qv-mermaid');
4
+ await doc.fonts.ready;
5
+ await Promise.all((root.getAnimations?.({subtree:true})||[])
6
+ .filter(a=>a.playState==='running'&&Number.isFinite(a.effect?.getComputedTiming().endTime))
7
+ .map(a=>a.finished.catch(()=>{})));
8
+ // 雷达顶点由 rAF 更新属性;只等待正在变化的几何稳定,不修改原图。
9
+ if (root.classList?.contains('qv-radar')) {
10
+ let last='',stable=0;
11
+ for (let i=0;i<60&&stable<3;i++) {
12
+ const current=[...root.querySelectorAll('polygon')].map(n=>n.getAttribute('points')).join('|');
13
+ stable=current===last?stable+1:0;last=current;
14
+ if(stable<3)await new Promise(resolve=>setTimeout(resolve,20));
15
+ }
16
+ }
17
+ const scene = root.querySelector('.scene'), viewport = root.querySelector('.diagram-scroll');
18
+ const width = Math.ceil(Math.max(root.clientWidth, scene.offsetWidth));
19
+ const copy = root.cloneNode(true);
20
+ copy.classList.add('mcx-qv-export');
21
+ copy.querySelectorAll('.scope-nav,.edge-key,.scope-status,[popover],[role="tooltip"],.node-tip,.seq-tip,.qv-notice').forEach(node => node.remove());
22
+ copy.style.cssText += `;position:absolute;left:0;top:0;width:${width}px;max-width:none;margin:0;transform:translateX(-100000px);overflow:visible;`;
23
+ const copiedViewport = copy.querySelector('.diagram-scroll'), copiedScene = copy.querySelector('.scene');
24
+ copiedViewport.style.cssText += `;width:${viewport.offsetWidth}px;height:${scene.offsetHeight}px;overflow:visible;padding-bottom:0;transform:none;`;
25
+ copiedScene.style.cssText += `;width:${scene.offsetWidth}px;height:${scene.offsetHeight}px;min-width:${scene.offsetWidth}px;transform:none;`;
26
+ copy.querySelectorAll('svg [mask]').forEach(node => {
27
+ if(node.getAttribute('mask').includes('reveal'))node.removeAttribute('mask');
28
+ });
29
+ copy.querySelectorAll('svg [clip-path]').forEach(node => {
30
+ if(node.getAttribute('clip-path').includes('sankey-mask'))node.removeAttribute('clip-path');
31
+ });
32
+ const style = doc.createElement('style');
33
+ style.textContent = `#${root.id}.mcx-qv-export *{animation:none!important;transition:none!important}`;
34
+ copy.append(style);
35
+ doc.body.append(copy);
36
+ try {
37
+ // html-to-image 深拷贝 SVG 而不逐个解析其子节点样式;先冻结线条、文字和渐变。
38
+ const svgProperties=['fill','fill-opacity','stroke','stroke-width','stroke-opacity','stroke-dasharray','stroke-dashoffset','stroke-linecap','stroke-linejoin','vector-effect','opacity','visibility','font-family','font-size','font-weight','text-anchor','dominant-baseline','stop-color','stop-opacity','mask','clip-path'];
39
+ for(const node of copy.querySelectorAll('svg,svg *')) {
40
+ const computed=frame.contentWindow.getComputedStyle(node);
41
+ for(const name of svgProperties)node.style.setProperty(name,computed.getPropertyValue(name));
42
+ }
43
+ copy.querySelectorAll('svg style').forEach(node=>node.remove());
44
+ // 系统字体在 SVG 图像内的字宽可能有细微差异;冻结原本单行的文字,避免导出时多换一行。
45
+ for(const node of copy.querySelectorAll('*')) {
46
+ if(node.namespaceURI!=='http://www.w3.org/1999/xhtml'||![...node.childNodes].some(n=>n.nodeType===3&&n.textContent.trim()))continue;
47
+ const textStyle=frame.contentWindow.getComputedStyle(node),line=parseFloat(textStyle.lineHeight);
48
+ if(Number.isFinite(line)&&node.getBoundingClientRect().height<=line+.5)node.style.whiteSpace='nowrap';
49
+ }
50
+ const customProperties = new Set(Array.from(frame.contentWindow.getComputedStyle(root)).filter(name=>name.startsWith('--')));
51
+ for(const node of copy.querySelectorAll('*'))for(const name of node.style)if(name.startsWith('--'))customProperties.add(name);
52
+ const height = Math.ceil(Math.max(copy.offsetHeight,copy.scrollHeight||0));
53
+ const blob = await toBlob(copy, { width, height, pixelRatio:canvasSize?2*Math.max(1,.9*Math.min(canvasSize.width/width,canvasSize.height/height)):2, skipAutoScale:true, filter:node=>node.tagName!=='STYLE',
54
+ includeStyleProperties:['display','position','left','top','right','bottom','width','height','min-width','max-width','min-height','max-height','box-sizing','padding','margin','overflow','background-color','background-image','background-size','background-position','color','font-family','font-size','font-weight','font-style','line-height','letter-spacing','text-align','text-decoration','text-overflow','white-space','word-break','overflow-wrap','flex-direction','flex-wrap','flex-grow','flex-shrink','flex-basis','align-items','align-content','align-self','justify-content','gap','row-gap','column-gap','grid-template-columns','grid-template-rows','grid-auto-flow','vertical-align','border','border-top','border-right','border-bottom','border-left','border-radius','box-shadow','opacity','transform','transform-origin','z-index','visibility','fill','fill-opacity','stroke','stroke-width','stroke-opacity','stroke-dasharray','stroke-dashoffset','stroke-linecap','stroke-linejoin','vector-effect','mask','clip-path','stop-color','stop-opacity','color-scheme',...customProperties],
55
+ backgroundColor:frame.contentWindow.getComputedStyle(doc.body).backgroundColor,
56
+ style:{position:'relative',left:'0',top:'0',transform:'none'} });
57
+ if (!blob) throw new Error('diagram-export-empty');
58
+ if (!canvasSize) return blob;
59
+ const canvas=doc.createElement('canvas');
60
+ canvas.width=Math.round(canvasSize.width*2);canvas.height=Math.round(canvasSize.height*2);
61
+ const context=canvas.getContext('2d'),image=new frame.contentWindow.Image();
62
+ const url=frame.contentWindow.URL.createObjectURL(blob);
63
+ try {
64
+ await new Promise((resolve,reject)=>{image.onload=resolve;image.onerror=()=>reject(new Error('diagram-export-image-failed'));image.src=url;});
65
+ context.fillStyle=frame.contentWindow.getComputedStyle(doc.body).backgroundColor;
66
+ context.fillRect(0,0,canvas.width,canvas.height);
67
+ const fit=.9*Math.min(canvas.width/image.naturalWidth,canvas.height/image.naturalHeight);
68
+ const w=image.naturalWidth*fit,h=image.naturalHeight*fit;
69
+ context.drawImage(image,(canvas.width-w)/2,(canvas.height-h)/2,w,h);
70
+ const result=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
71
+ if(!result)throw new Error('diagram-export-empty');
72
+ return result;
73
+ } finally {frame.contentWindow.URL.revokeObjectURL(url);}
74
+
75
+ } finally { copy.remove(); }
76
+ }
77
+
78
+ export async function saveQvDiagram(blob, saveFile, directory, filename) {
79
+ const bytes = new Uint8Array(await blob.arrayBuffer());
80
+ let binary='';
81
+ for (let start=0;start<bytes.length;start+=32768) binary+=String.fromCharCode(...bytes.subarray(start,start+32768));
82
+ return saveFile('save-file',{params:{kind:'contents',suggestedFilename:directory+'/'+filename,contentsBase64:btoa(binary)}});
83
+ }
@@ -0,0 +1,109 @@
1
+ export function markdownMermaidSource(fence) {
2
+ const match = String(fence || '').match(/^(`{3,}|~{3,})mermaid[ \t]*\r?\n([\s\S]*?)\r?\n\1\s*$/);
3
+ return match && match[2].length <= 50000 ? match[2] : null;
4
+ }
5
+
6
+ // 同一文档共享脚本 URL,减少源码复制和 Blob 创建,并允许浏览器复用编译缓存。
7
+ // 各帧仍独立求值;URL 由引用计数持有,最后一个预览释放时回收。
8
+ export function createQvResources(resources) {
9
+ if (!resources.qvTemplate?.script || !resources.qvDependencies) throw new Error('qv-resources-unavailable');
10
+ const store = (globalThis.__mcxQvScripts ??= new Map());
11
+ const acquire = (text) => {
12
+ const held = store.get(text);
13
+ if (held) { held.refs += 1; return held.url; }
14
+ const entry = { url: URL.createObjectURL(new Blob([text], { type: 'text/javascript' })), refs: 1 };
15
+ store.set(text, entry);
16
+ return entry.url;
17
+ };
18
+ const release = (url) => {
19
+ for (const [text, entry] of store) {
20
+ if (entry.url !== url) continue;
21
+ if (--entry.refs > 0) return;
22
+ URL.revokeObjectURL(entry.url);
23
+ store.delete(text);
24
+ return;
25
+ }
26
+ };
27
+ const dependencies = acquire(resources.qvDependencies);
28
+ const renderer = acquire(resources.qvTemplate.script);
29
+ let exporter;
30
+ return { template: resources.qvTemplate, dependencies, renderer,
31
+ async exporter(doc) {
32
+ if (!resources.qvExport) throw new Error('qv-export-unavailable');
33
+ exporter ??= acquire(resources.qvExport);
34
+ const child = doc.defaultView;
35
+ child.__mcxQVExportReady ??= new Promise((resolve,reject) => {
36
+ const script = doc.createElement('script');script.src=exporter;
37
+ script.onload=()=>{script.remove();resolve(child.__mcxQVExport);};
38
+ script.onerror=()=>{script.remove();delete child.__mcxQVExportReady;reject(new Error('qv-export-load-failed'));};
39
+ doc.head.append(script);
40
+ });
41
+ return (await child.__mcxQVExportReady).toBlob;
42
+ },
43
+ dispose() { release(dependencies); release(renderer); if (exporter) release(exporter); } };
44
+ }
45
+
46
+ export function createQvFrame(source, assets, theme, onHeight, onReady, onEscape) {
47
+ const frame = document.createElement('iframe'), token = crypto.randomUUID();
48
+ frame.className = 'mcx-reading-qv-frame';
49
+ frame.title = document.documentElement.lang.startsWith('zh') ? 'QV Mermaid 图表' : 'QV Mermaid diagram';
50
+ frame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
51
+ frame.style.height = '240px';
52
+ let disposed = false, height = 240, input = null;
53
+ const timeout = setTimeout(() => { if (!disposed) onReady('error'); }, 30000);
54
+ const config = { source, title: '', descriptions: {}, icons: 'auto', navigation: 'expanded', readingWidth: theme.readingWidth };
55
+ const encode = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
56
+ const bootstrap = `const token=${encode(token)};window.__mcxDiagramConfig=${encode(config)};window.openai={displayMode:'inline',maxHeight:innerHeight};
57
+ const send=()=>{const root=document.querySelector('.qv-mermaid');parent.postMessage({type:'mcx-qv',token,state:root.dataset.state,height:Math.ceil(root.offsetHeight)},'*');};
58
+ const theme=value=>{for(const [key,v]of Object.entries(value.tokens))document.documentElement.style.setProperty(key,v);document.documentElement.style.colorScheme=value.scheme;document.body.style.fontFamily=value.font;document.body.style.fontSize=value.size;};theme(${encode(theme)});
59
+ const load=value=>new Promise((resolve,reject)=>{const url=String(value);if(!/^blob:/.test(url))return reject(new Error('QV runtime unavailable'));const script=document.createElement('script');script.src=url;script.onload=()=>resolve();script.onerror=()=>reject(new Error('QV runtime unavailable'));document.body.append(script);});
60
+ window.addEventListener('message',async event=>{const data=event.data;if(event.source!==parent||data?.token!==token)return;if(data.type==='boot'){try{await load(data.dependencies);await load(data.renderer);}catch(error){const root=document.querySelector('.qv-mermaid');root.dataset.state='error';root.querySelector('.qv-notice').hidden=false;root.querySelector('.qv-notice').textContent=error.message;send();}}if(data.type==='theme')theme(data.theme);if(data.type==='mode'){window.openai={displayMode:data.mode,maxHeight:innerHeight};dispatchEvent(new CustomEvent('openai:set_globals',{detail:{globals:window.openai}}));}});
61
+ document.addEventListener('mcx:qv-viewer-input',event=>parent.postMessage({type:'mcx-qv-viewer-input',token,input:event.detail},'*'));
62
+ document.addEventListener('qv:ready',()=>queueMicrotask(send));document.addEventListener('keydown',event=>{if(event.key==='Escape')parent.postMessage({type:'mcx-qv-escape',token},'*');});
63
+ new ResizeObserver(send).observe(document.querySelector('.qv-mermaid'));parent.postMessage({type:'mcx-qv-bootstrap',token},'*');`;
64
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none';script-src blob: 'unsafe-inline';style-src 'unsafe-inline';img-src data: blob:;font-src data:;connect-src 'none'"><style>html,body{margin:0;overflow:hidden}body{color:var(--foreground);background:var(--background);font-size:14px;line-height:1.5}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}button,input{font:inherit}.scope-toggle{display:flex;align-items:center;gap:8px}.form-check-input{accent-color:var(--viz-series-1)}</style></head><body>${assets.template.markup}<script>${bootstrap}</script></body></html>`;
65
+ frame.srcdoc = html;
66
+ function message(event) {
67
+ if (disposed || event.source !== frame.contentWindow || event.data?.token !== token) return;
68
+ if (event.data.type === 'mcx-qv-bootstrap') {
69
+ frame.contentWindow.postMessage({type:'boot',token,dependencies:assets.dependencies,renderer:assets.renderer},'*');
70
+ return;
71
+ }
72
+ if (event.data.type === 'mcx-qv-escape') { onEscape(); return; }
73
+ if (event.data.type === 'mcx-qv-viewer-input') { input?.(event.data.input); return; }
74
+ if (event.data.type !== 'mcx-qv' || event.data.state === 'loading') return;
75
+ clearTimeout(timeout);
76
+ onReady(event.data.state);
77
+ const next = event.data.height;
78
+ if (Number.isFinite(next) && next > 0 && next <= 100000 && next !== height) {
79
+ height = next; onHeight(next);
80
+ }
81
+ }
82
+ window.addEventListener('message', message);
83
+ return { frame, get height() { return height; },
84
+ size() {
85
+ const root = frame.contentDocument.querySelector('.qv-mermaid'), viewport = root.querySelector('.diagram-scroll'), scene = root.querySelector('.scene');
86
+ return { width:Math.max(root.clientWidth,scene.offsetWidth), height:root.offsetHeight-viewport.offsetHeight+scene.offsetHeight };
87
+ },
88
+ input(handler) { input = handler; },
89
+ mode(mode) { frame.contentWindow?.postMessage({ type: 'mode', token, mode }, '*'); },
90
+ theme(theme) { frame.contentWindow?.postMessage({ type: 'theme', token, theme }, '*'); },
91
+ dispose() { disposed = true; clearTimeout(timeout); window.removeEventListener('message', message); frame.remove(); } };
92
+ }
93
+
94
+ export function readQvTheme(element) {
95
+ const style = getComputedStyle(element), get = (name) => style.getPropertyValue(name).trim();
96
+ const background = get('--mcx-color-token-main-surface-primary'), foreground = get('--mcx-color-text-foreground');
97
+ const tokens = { '--background': background, '--foreground': foreground,
98
+ '--muted': get('--mcx-color-token-bg-secondary'), '--muted-foreground': get('--mcx-color-text-foreground-secondary'),
99
+ '--border': get('--mcx-color-border'), '--popover': background, '--popover-foreground': foreground,
100
+ '--secondary': get('--mcx-color-token-bg-secondary'), '--secondary-foreground': foreground };
101
+ const colors = ['#3b82f6','#10b981','#8b5cf6','#f59e0b','#ec4899','#06b6d4'];
102
+ colors.forEach((color, index) => tokens[`--viz-series-${index+1}`] = get(`--viz-series-${index+1}`) || color);
103
+ const probe = document.createElement('div');
104
+ probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;width:var(--mcx-reading-width);height:0';
105
+ element.append(probe);
106
+ const readingWidth = probe.getBoundingClientRect().width;
107
+ probe.remove();
108
+ return { readingWidth, tokens, scheme: style.colorScheme, font: style.fontFamily, size: style.fontSize };
109
+ }
@@ -0,0 +1,27 @@
1
+ // 由 scripts/build-qv-mermaid.mjs 从 QV chart-loading.html 生成,请勿手改。
2
+ export function createQvLoading(region) {
3
+ const field=document.createElement('div'),canvas=document.createElement('canvas');
4
+ field.className='qv-loading-field';canvas.className='qv-loading-canvas';field.setAttribute('aria-hidden','true');field.append(canvas);region.append(field);
5
+ region.setAttribute('data-mcx-qv-loading','');region.setAttribute('aria-busy','true');
6
+ const ctx=canvas.getContext('2d'),reduced=matchMedia('(prefers-reduced-motion: reduce)');
7
+ let frame=0,last=0,start=0,done=false,geometry;
8
+ const clamp=x=>Math.max(0,Math.min(1,x)),smooth=x=>x*x*(3-2*x),cubic=x=>x<.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2,lerp=(a,b,t)=>a+(b-a)*t;
9
+ const random=(a,b)=>lerp(a,b,Math.random()),cycle=t=>{const p=t%1;return p<.5?p*2:2-p*2;};
10
+ // 对照本机 image-loading-state:固定点阵、双明暗场、30fps 与相同运动参数;不依赖宿主 React。
11
+ const durations=[4500,6330,5600,5750,3600,2400].map(n=>n*1.2*random(1,1.35)),phases=durations.map(()=>Math.random());
12
+ const bounds=[[random(.1,.32),random(.68,.9)],[random(.1,.32),random(.68,.9)],[random(.68,.9),random(.1,.32)],[random(.68,.9),random(.1,.32)],[random(.42,.52),random(.62,.75)],[random(.5,.62),random(.74,.9)]];
13
+ function resize(){const {width,height}=field.getBoundingClientRect(),dpr=Math.max(1,devicePixelRatio||1),spacing=Math.max(1,12/dpr);canvas.width=Math.floor(width*dpr);canvas.height=Math.floor(height*dpr);geometry={width,height,dpr,spacing,cols:Math.max(1,Math.floor(width/spacing)),rows:Math.max(1,Math.floor(height/spacing))};schedule();}
14
+ function paint(now){frame=0;if(done||document.hidden||!ctx)return;if(!reduced.matches&&now-last<1000/30){schedule();return;}last=now;if(!start)start=now;const t=reduced.matches?0:now-start,g=geometry;if(!g||!g.width||!g.height)return;
15
+ const values=durations.map((d,i)=>lerp(...bounds[i],(i===0||i===2?cubic:smooth)(cycle(t/d+phases[i])))),[x1,y1,x2,y2]=values,r1=.78*values[4],r2=.78*values[5];
16
+ ctx.setTransform(g.dpr,0,0,g.dpr,0,0);ctx.clearRect(0,0,g.width,g.height);ctx.fillStyle=getComputedStyle(field).color;
17
+ const radius=Math.max(.8,g.spacing*.075),ox=(g.width-(g.cols-1)*g.spacing)/2,oy=(g.height-(g.rows-1)*g.spacing)/2;
18
+ for(let y=0;y<g.rows;y++)for(let x=0;x<g.cols;x++){const nx=g.cols===1?.5:x/(g.cols-1),ny=g.rows===1?.5:y/(g.rows-1),a=Math.pow(clamp((1-smooth(clamp(Math.hypot(nx-x1,ny-y1)/r1)))*1.2+(1-smooth(clamp(Math.hypot(nx-x2,ny-y2)/r2)))*.82),1.18);if(a<=.03)continue;ctx.globalAlpha=a;ctx.beginPath();ctx.arc(ox+x*g.spacing,oy+y*g.spacing,radius,0,Math.PI*2);ctx.fill();}
19
+ ctx.globalAlpha=1;if(!reduced.matches)schedule();}
20
+ function schedule(){if(!done&&!frame&&!document.hidden)frame=requestAnimationFrame(paint);}
21
+ const observer=new ResizeObserver(resize);observer.observe(field);
22
+ function visibility(){if(document.hidden){cancelAnimationFrame(frame);frame=0;}else schedule();}
23
+ function motion(){cancelAnimationFrame(frame);frame=0;schedule();}
24
+ document.addEventListener('visibilitychange',visibility);reduced.addEventListener('change',motion);
25
+
26
+ return function finish(){if(done)return;done=true;cancelAnimationFrame(frame);observer.disconnect();document.removeEventListener('visibilitychange',visibility);reduced.removeEventListener('change',motion);field.remove();region.removeAttribute('data-mcx-qv-loading');region.setAttribute('aria-busy','false');};
27
+ }
@@ -0,0 +1,8 @@
1
+ .mcx-reading-qv-widget { position:relative; padding-top:44px; }
2
+ .mcx-reading-qv-frame { display:block; width:100%; border:0; }
3
+ .mcx-reading-qv-actions { position:absolute; inset:0 0 auto; height:44px; }
4
+ .mcx-reading-qv-actions [data-block-actions] { opacity:1; pointer-events:auto; }
5
+ .mcx-reading-qv-inline { position:relative; }
6
+ .mcx-reading-qv-status:not([hidden]) { position:absolute; inset:0; display:grid; place-items:center; }
7
+ .mcx-reading-qv-slot { position:relative; width:100%; height:100%; overflow:hidden; }
8
+ .mcx-reading-qv-slot > iframe { position:absolute; inset:0 auto auto 0; max-width:none; }