From d95f033d1f74754db742caa6195e3c0f15e4b948 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 12 Sep 2026 09:37:31 +0800 Subject: [PATCH] feat: sync canvas toolbar locale with DSH zh/en Co-authored-by: Cursor --- apps/web/scripts/check-canvas-cjk.mjs | 67 +++++++------ apps/web/scripts/verify-dist-i18n.py | 7 ++ apps/web/src/TopologyCanvas.tsx | 5 +- apps/web/src/canvas-ui-text.ts | 134 +++++++++++++++++++++++--- apps/web/src/useCanvasUi.ts | 81 ++++++++++++++++ packages/dsh-plugin/lib/client.js | 74 +++++++++++++- 6 files changed, 322 insertions(+), 46 deletions(-) create mode 100644 apps/web/scripts/verify-dist-i18n.py create mode 100644 apps/web/src/useCanvasUi.ts diff --git a/apps/web/scripts/check-canvas-cjk.mjs b/apps/web/scripts/check-canvas-cjk.mjs index d276546..2a58f83 100644 --- a/apps/web/scripts/check-canvas-cjk.mjs +++ b/apps/web/scripts/check-canvas-cjk.mjs @@ -1,8 +1,6 @@ #!/usr/bin/env node /** - * Fail the build if canvas UI CJK is corrupted or missing. - * Root cause we guard against: Windows tools rewriting TopologyCanvas.tsx - * with "?" for multibyte UTF-8, and Latin-1 middot 0xb7 breaking UTF-8. + * Fail the build if canvas UI CJK/i18n tables are corrupted or incomplete. */ import { readFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -26,59 +24,68 @@ function mustUtf8(rel) { } } +const requiredZh = ["选择", "平移", "连线", "单点", "整枝", "气泡", "卡片", "撤销", "未命名"]; +const requiredEn = [ + "Select", + "Pan", + "Link", + "Node", + "Branch", + "Bubble", + "Card", + "Undo", + "Untitled", +]; + const labels = mustUtf8("src/canvas-ui-text.ts"); if (labels) { - const required = [ - "选择", - "平移", - "连线", - "单点", - "整枝", - "气泡", - "卡片", - "撤销", - "未命名", - ]; - for (const s of required) { - if (!labels.includes(s)) errors.push(`canvas-ui-text.ts missing: ${s}`); + for (const s of requiredZh) { + if (!labels.includes(s)) errors.push(`canvas-ui-text.ts missing zh: ${s}`); + } + for (const s of requiredEn) { + if (!labels.includes(`"${s}"`) && !labels.includes(`'${s}'`)) { + errors.push(`canvas-ui-text.ts missing en: ${s}`); + } + } + if (!labels.includes("const zh:") && !labels.includes("const zh =")) { + errors.push("canvas-ui-text.ts must define zh table"); + } + if (!labels.includes("const en:") && !labels.includes("const en =")) { + errors.push("canvas-ui-text.ts must define en table"); } if (/^\s*\w+:\s*["']\?{2,}["']/m.test(labels)) { errors.push("canvas-ui-text.ts contains corrupted ?? string literals"); } } +const hook = mustUtf8("src/useCanvasUi.ts"); +if (hook && !hook.includes("dsh-mind-map:locale")) { + errors.push("useCanvasUi.ts must listen for dsh-mind-map:locale"); +} + const canvas = mustUtf8("src/TopologyCanvas.tsx"); if (canvas) { - // Toolbar / controls must use canvasUi — no inline ?? placeholders. const badUi = [ ...canvas.matchAll(/\["select",\s*"(\?+)"/g), ...canvas.matchAll(/\["pan",\s*"(\?+)"/g), ...canvas.matchAll(/\["link",\s*"(\?+)"/g), ]; if (badUi.length) { - errors.push( - "TopologyCanvas.tsx has corrupted tool labels (??). Use canvasUi from canvas-ui-text.ts", - ); + errors.push("TopologyCanvas.tsx has corrupted tool labels (??)"); } - if (!canvas.includes("canvas-ui-text") && !canvas.includes("canvasUi")) { - errors.push("TopologyCanvas.tsx must import canvasUi from canvas-ui-text.ts"); + if (!canvas.includes("useCanvasUi")) { + errors.push("TopologyCanvas.tsx must use useCanvasUi()"); } - // Detect classic corruption: many consecutive ? inside quotes in JSX title/aria const titleQ = [...canvas.matchAll(/title=\{?["'](\?{2,})["']\}?/g)]; if (titleQ.length >= 3) { - errors.push( - `TopologyCanvas.tsx looks CJK-corrupted (${titleQ.length} title="??" hits). Restore via canvasUi.`, - ); + errors.push(`TopologyCanvas.tsx CJK-corrupted (${titleQ.length} title="??")`); } } if (errors.length) { console.error("[check-canvas-cjk] FAILED:"); for (const e of errors) console.error(" -", e); - console.error( - "Fix: edit src/canvas-ui-text.ts (UTF-8 only). Never pipe CJK through PowerShell Set-Content.", - ); process.exit(1); } -console.log("[check-canvas-cjk] OK"); +console.log("[check-canvas-cjk] OK (zh/en i18n)"); diff --git a/apps/web/scripts/verify-dist-i18n.py b/apps/web/scripts/verify-dist-i18n.py new file mode 100644 index 0000000..ffc4d88 --- /dev/null +++ b/apps/web/scripts/verify-dist-i18n.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +js = next((Path(__file__).resolve().parents[1] / "dist" / "assets").glob("index-*.js")).read_text( + encoding="utf-8" +) +print({k: (k in js) for k in ["选择", "Select", "平移", "Pan", "连线", "Link", "dsh-mind-map:locale"]}) diff --git a/apps/web/src/TopologyCanvas.tsx b/apps/web/src/TopologyCanvas.tsx index 99eb261..8862d89 100644 --- a/apps/web/src/TopologyCanvas.tsx +++ b/apps/web/src/TopologyCanvas.tsx @@ -24,7 +24,7 @@ import { import { drawPropSheet, NodePropOverlay } from "./node-props"; import { branchPalette, readSystemTheme } from "./system-theme"; import { useAppStore } from "./store"; -import { canvasUi as U } from "./canvas-ui-text"; +import { useCanvasUi } from "./useCanvasUi"; const NODE_W = 200; @@ -37,6 +37,7 @@ const MINI_W = 168; const MINI_H = 118; export function TopologyCanvas() { + const { U } = useCanvasUi(); const canvasRef = useRef(null); const miniRef = useRef(null); const hostRef = useRef(null); @@ -676,6 +677,7 @@ export function TopologyCanvas() { canvas?.prefs.showRelationEdges, canvas?.prefs.nodeBadges, imageTick, + U, ]); const drawMini = useCallback(() => { @@ -888,6 +890,7 @@ export function TopologyCanvas() { fitView, setZoomAroundCenter, viewport.zoom, + U, ]); const cursor = diff --git a/apps/web/src/canvas-ui-text.ts b/apps/web/src/canvas-ui-text.ts index e64e1eb..cdc2c2f 100644 --- a/apps/web/src/canvas-ui-text.ts +++ b/apps/web/src/canvas-ui-text.ts @@ -1,31 +1,72 @@ /** - * Canvas UI copy (zh). Keep ALL user-facing CJK here. - * TopologyCanvas.tsx must not embed Chinese literals — they get corrupted - * by Windows/PowerShell default encodings (question-mark placeholders / invalid 0xb7 bytes). + * Canvas UI copy (zh / en). Keep ALL user-facing strings here. + * TopologyCanvas must not embed literals — Windows tools corrupt CJK in large files. */ -export const canvasUi = { + +export type CanvasLocale = "zh" | "en"; + +export type CanvasUi = { + unnamed: string; + newNode: string; + leftChild: string; + rightChild: string; + sibling: string; + relationLabel: string; + toolbarAria: string; + controlsAria: string; + backRosterTitle: string; + backRoster: string; + toolSelect: string; + toolPan: string; + toolLink: string; + dragNode: string; + dragBranch: string; + dragNodeTitle: string; + dragBranchTitle: string; + bubble: string; + card: string; + bubbleTitle: string; + cardTitle: string; + unpin: string; + unpinTitle: string; + undo: string; + fullscreen: string; + exitFullscreen: string; + zoomIn: string; + zoomInTitle: string; + zoomOut: string; + zoomOutTitle: string; + fit: string; + fitTitle: string; + lock: string; + unlock: string; + lockTitle: string; + unlockTitle: string; + addLeftChildTitle: string; + addRightChildTitle: string; + hintLocked: string; + hintNodeDrag: string; + hintBranchDrag: string; +}; + +const zh: CanvasUi = { unnamed: "未命名", newNode: "新节点", leftChild: "左侧子主题", rightChild: "右侧子主题", sibling: "兄弟节点", relationLabel: "关联", - toolbarAria: "画布工具", controlsAria: "视图控制", - backRosterTitle: "返回画布列表", backRoster: "返回", - toolSelect: "选择", toolPan: "平移", toolLink: "连线", - dragNode: "单点", dragBranch: "整枝", dragNodeTitle: "单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点", dragBranchTitle: "整枝拖拽:节点与子孙一起移动 (B)", - bubble: "气泡", card: "卡片", bubbleTitle: "气泡视图:精简标题", @@ -33,7 +74,6 @@ export const canvasUi = { unpin: "解钉", unpinTitle: "取消钉住:让选中节点重新参与自动布局", undo: "撤销", - fullscreen: "全屏", exitFullscreen: "退出全屏", zoomIn: "放大", @@ -46,13 +86,79 @@ export const canvasUi = { unlock: "解锁", lockTitle: "锁定画布 (L)", unlockTitle: "解锁画布 (L)", - addLeftChildTitle: "添加左侧子主题", addRightChildTitle: "添加右侧子主题", - hintLocked: "已锁定 · 仅浏览/缩放/平移", hintNodeDrag: "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消", hintBranchDrag: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消", -} as const; +}; -export type CanvasUiKey = keyof typeof canvasUi; +const en: CanvasUi = { + unnamed: "Untitled", + newNode: "New node", + leftChild: "Left child", + rightChild: "Right child", + sibling: "Sibling", + relationLabel: "Link", + toolbarAria: "Canvas tools", + controlsAria: "View controls", + backRosterTitle: "Back to canvas list", + backRoster: "Back", + toolSelect: "Select", + toolPan: "Pan", + toolLink: "Link", + dragNode: "Node", + dragBranch: "Branch", + dragNodeTitle: "Move node only (N); hold Shift for temporary node-drag", + dragBranchTitle: "Move node with descendants (B)", + bubble: "Bubble", + card: "Card", + bubbleTitle: "Bubble view: title only", + cardTitle: "Card view: property sheet", + unpin: "Unpin", + unpinTitle: "Unpin selected node for auto layout", + undo: "Undo", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", + zoomIn: "Zoom in", + zoomInTitle: "Zoom in (+)", + zoomOut: "Zoom out", + zoomOutTitle: "Zoom out (-)", + fit: "Fit", + fitTitle: "Fit view (F)", + lock: "Lock", + unlock: "Unlock", + lockTitle: "Lock canvas (L)", + unlockTitle: "Unlock canvas (L)", + addLeftChildTitle: "Add left child", + addRightChildTitle: "Add right child", + hintLocked: "Locked · browse / zoom / pan only", + hintNodeDrag: "Node drag · Shift temp node · Esc cancel", + hintBranchDrag: "Branch drag · pin on blank · drop to reparent · N node · Esc cancel", +}; + +export const canvasUiTables = { zh, en } as const; + +export function normalizeCanvasLocale(raw: unknown): CanvasLocale { + const s = String(raw ?? "") + .trim() + .toLowerCase(); + if (s.startsWith("zh")) return "zh"; + if (!s) { + // Standalone / no hint: prefer browser language + if (typeof navigator !== "undefined") { + const nav = String(navigator.language || "").toLowerCase(); + if (nav.startsWith("zh")) return "zh"; + } + return "en"; + } + return "en"; +} + +export function getCanvasUi(locale: CanvasLocale | string): CanvasUi { + const loc = normalizeCanvasLocale(locale); + return canvasUiTables[loc]; +} + +/** @deprecated Prefer getCanvasUi(locale) — kept so old imports fail loudly in check. */ +export const canvasUi = zh; diff --git a/apps/web/src/useCanvasUi.ts b/apps/web/src/useCanvasUi.ts new file mode 100644 index 0000000..7b6d180 --- /dev/null +++ b/apps/web/src/useCanvasUi.ts @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useState } from "react"; +import { + getCanvasUi, + normalizeCanvasLocale, + type CanvasLocale, + type CanvasUi, +} from "./canvas-ui-text"; + +const MSG_TYPE = "dsh-mind-map:locale"; + +function readLocaleFromUrl(): CanvasLocale | null { + try { + const q = new URLSearchParams(window.location.search); + const raw = q.get("lang") || q.get("locale"); + if (!raw) return null; + return normalizeCanvasLocale(raw); + } catch { + return null; + } +} + +function readLocaleFromDocument(): CanvasLocale | null { + try { + const raw = document.documentElement?.lang; + if (!raw) return null; + return normalizeCanvasLocale(raw); + } catch { + return null; + } +} + +function initialLocale(): CanvasLocale { + return ( + readLocaleFromUrl() || + readLocaleFromDocument() || + normalizeCanvasLocale(navigator.language) + ); +} + +/** + * Follow DSH host language: ?lang= on embed URL, then postMessage, then . + */ +export function useCanvasUi(): { locale: CanvasLocale; U: CanvasUi } { + const [locale, setLocale] = useState(initialLocale); + + useEffect(() => { + const apply = (raw: unknown) => { + const next = normalizeCanvasLocale(raw); + setLocale((prev) => (prev === next ? prev : next)); + }; + + const onMessage = (ev: MessageEvent) => { + const data = ev?.data; + if (!data || typeof data !== "object") return; + if (data.type !== MSG_TYPE) return; + apply(data.lang ?? data.locale); + }; + window.addEventListener("message", onMessage); + + const mo = new MutationObserver(() => { + const fromDoc = readLocaleFromDocument(); + if (fromDoc) apply(fromDoc); + }); + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["lang"], + }); + + // Re-read URL once (in case router rewrote search without remount) + const fromUrl = readLocaleFromUrl(); + if (fromUrl) apply(fromUrl); + + return () => { + window.removeEventListener("message", onMessage); + mo.disconnect(); + }; + }, []); + + const U = useMemo(() => getCanvasUi(locale), [locale]); + return { locale, U }; +} diff --git a/packages/dsh-plugin/lib/client.js b/packages/dsh-plugin/lib/client.js index becfe6b..a572fc8 100644 --- a/packages/dsh-plugin/lib/client.js +++ b/packages/dsh-plugin/lib/client.js @@ -21,6 +21,47 @@ window.__ModuleLoader__.load({ const API = '/dsh-mind-map' const LOCALE_NS = 'settings.dshMindMap' const TAB_ID = 'dsh-mind-map' + /** Filled in apply(); also used by resolveHostLang / iframe locale sync. */ + const runtime = { ctx: null } + + function resolveHostLang() { + try { + const loc = runtime.ctx && runtime.ctx.locale + const snap = loc && typeof loc.getSnapshot === 'function' ? loc.getSnapshot() : null + const raw = + (snap && (snap.active || snap.locale || snap.preference || snap.lang)) || + (loc && loc.active) || + (document.documentElement && document.documentElement.lang) || + (typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage)) || + '' + return String(raw).trim().toLowerCase().startsWith('zh') ? 'zh' : 'en' + } catch { + try { + return String(document.documentElement.lang || '') + .toLowerCase() + .startsWith('zh') + ? 'zh' + : 'en' + } catch { + return 'en' + } + } + } + + function postLocaleToMindmapFrames(lang) { + const next = lang === 'zh' ? 'zh' : 'en' + const frames = document.querySelectorAll('iframe.dsh-mm-frame') + for (const frame of frames) { + try { + frame.contentWindow?.postMessage( + { type: 'dsh-mind-map:locale', lang: next }, + '*', + ) + } catch { + /* ignore */ + } + } + } const DEFAULTS = { enabled: true, @@ -199,6 +240,7 @@ window.__ModuleLoader__.load({ ).replace(/\/$/, '') const q = new URLSearchParams({ embed: '1', _v: String(Date.now()) }) if (canvasId) q.set('canvasId', String(canvasId)) + q.set('lang', resolveHostLang()) // Pass DSH host CSS tokens into the iframe (cross-origin). try { const root = document.documentElement @@ -555,6 +597,36 @@ window.__ModuleLoader__.load({ return () => window.removeEventListener('message', onMsg) }, [view, sessionId, canvasId]) + // Keep iframe UI language in sync with DSH (no full reload). + useEffect(() => { + if (view !== 'canvas' || !src) return undefined + const push = () => postLocaleToMindmapFrames(resolveHostLang()) + push() + const onLoad = () => push() + const frame = document.querySelector('iframe.dsh-mm-frame') + if (frame) frame.addEventListener('load', onLoad) + const mo = new MutationObserver(push) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ['lang'], + }) + let off = null + try { + const loc = runtime.ctx && runtime.ctx.locale + if (loc && typeof loc.subscribe === 'function') off = loc.subscribe(push) + else if (loc && typeof loc.on === 'function') off = loc.on('change', push) + } catch { + /* ignore */ + } + const timer = setInterval(push, 2000) + return () => { + if (frame) frame.removeEventListener('load', onLoad) + mo.disconnect() + clearInterval(timer) + if (typeof off === 'function') off() + } + }, [view, src]) + async function backToRoster() { if (sessionId) { await fetch(`${API}/active`, { @@ -722,7 +794,7 @@ window.__ModuleLoader__.load({ } /** Filled in apply(); settings card uses it to openTab. */ - const runtime = { ctx: null } + // runtime declared near top (locale + iframe helpers) /** Suppress roster-on-activate while AI is jumping into a canvas. */ let aiJumpUntil = 0 /** Latest AI-requested canvas enter (survives tab remount / event race). */