import type { Context } from '@deepseek-ai/cordis' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { svgMarkupOf } from '../svg.js' import { parseDrawioAddress, relativeToWorkspace } from './address.js' export const inject = ['slots', 'sidebarRightTabs'] const TAB_ID = '@civilization/dsh-drawio' const TAB_KIND = 'drawio' interface SessionScope { sessionId: string; cwd?: string } interface FileViewerProps { content?: string; version?: string; truncated?: boolean; path: string; scope: SessionScope } interface OfficialContext extends Context { slots: any; sidebarRightTabs: any } export function apply(ctx: Context): void { const official = ctx as OfficialContext ctx.effect(() => official.sidebarRightTabs.register({ id: TAB_ID, kind: TAB_KIND, // The whole-address glob is what actually wins this file: tab types are // ranked by priority band, then by the length of the matched pattern, so a // bare `*.drawio` (8 characters) loses the claim to any type declaring a // broader address glob in the same `extension` band — e.g. // dsh-better-sidebar's file editor (`dsh-resource://file/**`, 22), which // then renders the XML instead of this canvas. Keeping both patterns makes // the specific one win wherever it matches. patterns: ['dsh-resource://file/**/*.drawio', '*.drawio'], priority: 'extension', title: (address: string) => fileName(parseDrawioAddress(address)?.path ?? 'Draw.io'), }), 'dsh-drawio: official tab definition') ctx.effect(() => official.slots.inject('sidebar.right.pane.tab', () => official.slots.register({ name: 'sidebar.right.pane.tab', key: TAB_ID, }, OfficialDrawioTabBody)), 'dsh-drawio: official tab body') ctx.effect(() => official.slots.inject('sidebar.right.pane.tab.title', () => official.slots.register({ name: 'sidebar.right.pane.tab.title', key: TAB_ID, }, OfficialDrawioTabTitle)), 'dsh-drawio: official tab title') } function OfficialDrawioTabTitle({ useTabInfo }: any): JSX.Element { const { tab } = useTabInfo() const title = tab?.title || (tab?.navigation?.address ? fileName(parseDrawioAddress(tab.navigation.address)?.path ?? 'Draw.io') : 'Draw.io') return <>{title} } function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX.Element { const { tab } = useTabInfo() const rawAddress = tab?.navigation?.address const resource = useMemo(() => parseDrawioAddress(rawAddress, sessionId), [rawAddress, sessionId]) const activeCwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined const resourceCwd = useSessions((sessions: any) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : undefined) as string | undefined const cwd = activeCwd || resourceCwd const effectiveSessionId = sessionId || resource?.sessionId || '' // An absolute address carries no session, so it is openable only when it names // a file under this session's workspace; the host re-checks that boundary. const relativePath = resource?.scope === 'absolute' ? relativeToWorkspace(resource.path, cwd) : null const outsideWorkspace = resource?.scope === 'absolute' && cwd !== undefined && relativePath === null const openPath = resource?.scope === 'absolute' ? relativePath : resource?.path ?? null const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null) const [error, setError] = useState(null) const key = `${effectiveSessionId}\0${cwd ?? ''}\0${openPath ?? ''}` useEffect(() => { if (!openPath || !effectiveSessionId || !cwd) return const controller = new AbortController() setError(null) void fsRead({ sessionId: effectiveSessionId, cwd }, openPath, controller.signal) .then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) }) .catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) }) return () => controller.abort() }, [key, openPath, effectiveSessionId, cwd]) if (!resource || !resource.path) { return ( ) } if (!cwd) { return ( ) } if (outsideWorkspace) { return ( ) } if (!openPath) return if (error) return if (file?.key !== key) return return } function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element { const frame = useRef(null) const latestFile = useRef(content ?? '') const latestEditor = useRef(content ?? '') const latestVersion = useRef(version) const saveTimer = useRef(undefined) const saveGeneration = useRef(0) const [ready, setReady] = useState(false) const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'conflict' | 'error'>('loading') const [error, setError] = useState(truncated ? '文件过大,DSH 只返回了截断内容,无法安全打开。' : null) const lastUserActivity = useRef(0) const isInteracting = useRef(false) const { dark, accent: accentColor } = useDshTheme() const initialDark = useRef(dark) const src = useMemo(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${initialDark.current ? '1' : '0'}`, []) const post = useCallback((message: unknown) => { frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin) }, []) const reloadFromDisk = useCallback(async () => { try { setSaveState('loading') const result = await fsRead(scope, path) latestFile.current = result.content latestEditor.current = result.content latestVersion.current = result.version post({ action: 'load', xml: result.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 }) setSaveState('saved') setError(null) } catch (reason) { setError(messageOf(reason)) } }, [scope, path, dark, post]) const persist = useCallback(async (xml: string) => { const generation = ++saveGeneration.current setSaveState('saving') try { const outcome = await fsWrite(scope, path, xml, latestVersion.current) if (generation !== saveGeneration.current) return latestFile.current = xml latestEditor.current = xml if (outcome?.version) latestVersion.current = outcome.version setSaveState('saved') setError(null) } catch (reason) { if (generation !== saveGeneration.current) return const msg = messageOf(reason) if (msg.includes('modified by an external process') || msg.includes('409')) { setSaveState('conflict') setError('文件已被外部或 AI 修改,当前存在并发冲突,点击可重新载入最新内容') } else { setSaveState('error') setError(msg) } } }, [scope.sessionId, scope.cwd, path]) const scheduleSave = useCallback((xml: string, immediate = false) => { lastUserActivity.current = Date.now() latestEditor.current = xml if (saveTimer.current !== undefined) { window.clearTimeout(saveTimer.current) saveTimer.current = undefined } if (immediate) { void persist(xml) } else { saveTimer.current = window.setTimeout(() => { saveTimer.current = undefined void persist(latestEditor.current) }, 350) } }, [persist]) useEffect(() => { latestFile.current = content ?? '' latestEditor.current = content ?? '' latestVersion.current = version }, [content, version, path]) const markActivity = useCallback(() => { lastUserActivity.current = Date.now() }, []) useEffect(() => { const attached = new WeakSet() const handleFrameAttach = () => { try { const doc = frame.current?.contentDocument if (!doc || attached.has(doc)) return attached.add(doc) const onDown = () => { isInteracting.current = true lastUserActivity.current = Date.now() } const onUp = () => { isInteracting.current = false lastUserActivity.current = Date.now() } const onKey = () => { lastUserActivity.current = Date.now() } // 仅在用户按住拖拽、松手或输入时感知,单纯鼠标移动(hover)不打断 doc.addEventListener('pointerdown', onDown, true) doc.addEventListener('pointerup', onUp, true) doc.addEventListener('keydown', onKey, true) } catch {} } const timer = window.setInterval(handleFrameAttach, 800) return () => window.clearInterval(timer) }, []) const [menuOpen, setMenuOpen] = useState(false) const [exportFormat, setExportFormat] = useState<'png' | 'svg' | 'xmlpng'>('png') const [isTransparent, setIsTransparent] = useState(false) const [isExporting, setIsExporting] = useState(false) const [toast, setToast] = useState(null) const pendingExport = useRef<{ format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string } | null>(null) const menuRef = useRef(null) const showToast = useCallback((msg: string) => { setToast(msg) window.setTimeout(() => setToast(current => current === msg ? null : current), 3200) }, []) const startExport = useCallback((target: 'workspace' | 'download' | 'clipboard') => { if (!ready || isExporting) return // The workspace target is SVG-only: DSH's filesystem service writes text // (there is no binary write seam), so a raster export would land as base64 // text. PNG and XML-PNG stay available through download and clipboard. if (target === 'workspace' && exportFormat !== 'svg') { showToast('仅 SVG 支持保存到工作区,PNG / XML-PNG 请使用下载或剪贴板') return } const ext = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png' const base = path.replace(/\.drawio$/i, '') const targetPath = `${base}${ext}` pendingExport.current = { format: exportFormat, target, targetPath, } setIsExporting(true) setMenuOpen(false) post({ action: 'export', format: exportFormat, scale: exportFormat === 'svg' ? 1 : 2, transparent: isTransparent, border: 10, spin: '正在渲染导出图片...', }) }, [ready, isExporting, exportFormat, isTransparent, path, post, showToast]) const handleExportResult = useCallback(async (pending: { format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string }, data: string) => { try { if (pending.target === 'download') { const link = document.createElement('a') link.download = fileName(pending.targetPath) link.href = data document.body.appendChild(link) link.click() document.body.removeChild(link) showToast(`已开始下载:${fileName(pending.targetPath)}`) } else if (pending.target === 'clipboard') { try { if (pending.format === 'svg') { const svgText = svgMarkupOf(data) ?? data await navigator.clipboard.writeText(svgText) showToast('已复制 SVG 代码到剪贴板') } else { const res = await fetch(data) const blob = await res.blob() await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) showToast('已复制图片到剪贴板') } } catch { // 剪贴板受限或报错时降级为自动下载 const link = document.createElement('a') link.download = fileName(pending.targetPath) link.href = data document.body.appendChild(link) link.click() document.body.removeChild(link) showToast(`剪贴板无权限,已自动下载:${fileName(pending.targetPath)}`) } } else if (pending.target === 'workspace') { const result = await fsSaveImage(scope, pending.targetPath, data) showToast(`已保存到工作区:${fileName(result.path)} (${Math.round(result.bytes / 1024)} KB)`) } } catch (reason) { setError(`导出失败:${messageOf(reason)}`) } }, [scope, showToast]) useEffect(() => { const handleOutsideClick = (e: MouseEvent) => { if (menuOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) { setMenuOpen(false) } } window.addEventListener('pointerdown', handleOutsideClick) return () => window.removeEventListener('pointerdown', handleOutsideClick) }, [menuOpen]) useEffect(() => { setReady(false) setSaveState('loading') const receive = (event: MessageEvent) => { if (event.source !== frame.current?.contentWindow || event.origin !== window.location.origin) return let message: { event?: string; xml?: string; format?: string; data?: string } try { message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data } catch { return } if (message.event === 'configure') { post({ action: 'configure', config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: 'general;uml;er;bpmn;flowchart;basic;arrows2', enabledLibraries: null } }) } else if (message.event === 'init') { post({ action: 'load', xml: latestFile.current, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 }) setReady(true) setSaveState('saved') } else if ((message.event === 'autosave' || message.event === 'save') && typeof message.xml === 'string') { scheduleSave(message.xml, message.event === 'save') } else if (message.event === 'export' && typeof message.data === 'string') { const pending = pendingExport.current pendingExport.current = null setIsExporting(false) if (pending) { void handleExportResult(pending, message.data) } } } window.addEventListener('message', receive) return () => window.removeEventListener('message', receive) }, [src, dark, path, post, scheduleSave, handleExportResult]) useEffect(() => { if (!ready || truncated) return let cancelled = false const poll = async () => { // 1. 用户按住鼠标拖拽中,绝不读取 if (isInteracting.current) return // 2. 刚松手或打字后 1.5 秒内(操作缓冲),暂停轮询 if (Date.now() - lastUserActivity.current < 1500) return // 3. 本地正在防抖保存中,不从磁盘读取 if (saveTimer.current !== undefined) return // 4. 浏览器标签页切后台时静默 if (typeof document !== 'undefined' && document.hidden) return try { // 轻量检查版本号 const stat = await fsStat(scope, path) if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return if (stat.version && latestVersion.current && stat.version === latestVersion.current) return const res = await fsRead(scope, path) // 5. 响应返回后二次防护:若这期间用户按下了鼠标或开始拖动,果断丢弃本次结果 if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return if (res.content === latestFile.current || res.content === latestEditor.current) { latestVersion.current = res.version return } latestFile.current = res.content latestEditor.current = res.content latestVersion.current = res.version post({ action: 'load', xml: res.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 }) setSaveState('external') window.setTimeout(() => { if (!cancelled) setSaveState('saved') }, 1200) } catch (reason) { if (!cancelled) setError(messageOf(reason)) } } // 空闲时每 2.5 秒正常读取一次 const timer = window.setInterval(() => { void poll() }, 2500) return () => { cancelled = true; window.clearInterval(timer) } }, [ready, truncated, scope.sessionId, scope.cwd, path, dark, post]) useEffect(() => () => { if (saveTimer.current !== undefined) window.clearTimeout(saveTimer.current) if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, latestVersion.current, true) }, [scope.sessionId, scope.cwd, path]) if (truncated) return const targetExt = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png' const previewName = `${fileName(path).replace(/\.drawio$/i, '')}${targetExt}` return (