mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-09 01:53:16 +08:00
fix: isolate canvas CJK strings and guard build encoding
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2ecd300ada
commit
288ebf4c90
7 changed files with 431 additions and 53 deletions
|
|
@ -5,7 +5,8 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"build": "node scripts/check-canvas-cjk.mjs && tsc -p tsconfig.json --noEmit && vite build",
|
||||
"check:cjk": "node scripts/check-canvas-cjk.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
|
|
|
|||
84
apps/web/scripts/check-canvas-cjk.mjs
Normal file
84
apps/web/scripts/check-canvas-cjk.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/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.
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const errors = [];
|
||||
|
||||
function mustUtf8(rel) {
|
||||
const path = join(root, rel);
|
||||
if (!existsSync(path)) {
|
||||
errors.push(`missing: ${rel}`);
|
||||
return null;
|
||||
}
|
||||
const buf = readFileSync(path);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
||||
} catch (e) {
|
||||
errors.push(`${rel}: invalid UTF-8 (${e.message})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
if (/^\s*\w+:\s*["']\?{2,}["']/m.test(labels)) {
|
||||
errors.push("canvas-ui-text.ts contains corrupted ?? string literals");
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
if (!canvas.includes("canvas-ui-text") && !canvas.includes("canvasUi")) {
|
||||
errors.push("TopologyCanvas.tsx must import canvasUi from canvas-ui-text.ts");
|
||||
}
|
||||
// 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
26
apps/web/scripts/diagnose-topology-encoding.py
Normal file
26
apps/web/scripts/diagnose-topology-encoding.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Diagnose TopologyCanvas.tsx encoding / CJK corruption."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
|
||||
raw = p.read_bytes()
|
||||
print("size", len(raw))
|
||||
print("bom", raw[:3] == b"\xef\xbb\xbf")
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
print("utf8: OK")
|
||||
except UnicodeDecodeError as e:
|
||||
print("utf8: FAIL", e)
|
||||
print("bad-byte context:", raw[max(0, e.start - 30) : e.start + 30])
|
||||
text = raw.decode("latin1")
|
||||
|
||||
quoted = re.findall(r'"\?{2,}"', text)
|
||||
print("quoted ?? count:", len(quoted))
|
||||
print("cjk chars:", len(re.findall(r"[\u4e00-\u9fff]", text)))
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if '["select"' in line or "title=" in line and "?" in line and "??" in line:
|
||||
if i >= 1180 and i <= 1360:
|
||||
print(f"{i}: {line.strip()[:100]}")
|
||||
209
apps/web/scripts/migrate-canvas-ui-text.py
Normal file
209
apps/web/scripts/migrate-canvas-ui-text.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Rewrite TopologyCanvas.tsx to use canvasUi (no inline CJK). Tolerates broken latin1/0xb7 files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
|
||||
raw = p.read_bytes()
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode("latin1").replace("\xb7", "·")
|
||||
|
||||
# Drop prior broken import if re-run
|
||||
text = re.sub(
|
||||
r'^import \{ canvasUi as U \} from "\./canvas-ui-text";\n',
|
||||
"",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
|
||||
# Insert import after last relative import near top
|
||||
if 'from "./canvas-ui-text"' not in text:
|
||||
m = re.search(r'(import .+ from "\./[^"]+";\n)', text)
|
||||
# insert after first block of local imports — after image-cache / node-props style imports
|
||||
insert_at = None
|
||||
for m in re.finditer(r'^import .+ from "\./.+";\n', text, flags=re.M):
|
||||
insert_at = m.end()
|
||||
if insert_at is None:
|
||||
raise SystemExit("no local import found to anchor canvas-ui-text import")
|
||||
text = (
|
||||
text[:insert_at]
|
||||
+ 'import { canvasUi as U } from "./canvas-ui-text";\n'
|
||||
+ text[insert_at:]
|
||||
)
|
||||
|
||||
subs = [
|
||||
# comments
|
||||
(r"// iframe \?+ Tab \?+", "// iframe: notify parent Tab fullscreen when needed"),
|
||||
(r"// Free place .+?pin every dragged id", "// Free place -> pin every dragged id"),
|
||||
(r"// Contain: \?+", "// Contain: fit image inside box, no crop"),
|
||||
# placeholders / creates
|
||||
(r'(wrapTextLines\(ctx, n\.text \|\| )"\?+"', r'\1U.unnamed'),
|
||||
(r'(n\.text \|\| )"\?+"', r"\1U.unnamed"),
|
||||
(r'(wrapTextLines\(ctx, n\.text \|\| )U\.unnamed', r"\1U.unnamed"), # noop safe
|
||||
(r'text: "\?+", parentId: parent\b', "text: U.newNode, parentId: parent"),
|
||||
(r': \{ type: "create_node", text: "\?+" \},', ': { type: "create_node", text: U.newNode },'),
|
||||
(
|
||||
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1',
|
||||
"text: U.leftChild,\n parentId: selectedIds[0],\n sidePref: -1",
|
||||
),
|
||||
(
|
||||
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1',
|
||||
"text: U.rightChild,\n parentId: selectedIds[0],\n sidePref: 1",
|
||||
),
|
||||
(
|
||||
r'text: "\?+",\n parentId: parentOfSel',
|
||||
"text: U.sibling,\n parentId: parentOfSel",
|
||||
),
|
||||
(
|
||||
r'text: "\?+",\n \.\.\.\(selectedIds\[0\]',
|
||||
"text: U.newNode,\n ...(selectedIds[0]",
|
||||
),
|
||||
(r'label: "\?+"', "label: U.relationLabel"),
|
||||
# toolbar
|
||||
(
|
||||
r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=)"\?+"',
|
||||
r'\1{U.toolbarAria}',
|
||||
),
|
||||
(
|
||||
r'(className="canvas-tool-btn"\n title=)"\?+"(\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n </button>)',
|
||||
r"\1{U.backRosterTitle}\2{U.backRoster}\3",
|
||||
),
|
||||
(r'\["select", "\?+", "V"\]', '["select", U.toolSelect, "V"]'),
|
||||
(r'\["pan", "\?+", "H"\]', '["pan", U.toolPan, "H"]'),
|
||||
(r'\["link", "\?+", "C"\]', '["link", U.toolLink, "C"]'),
|
||||
# also if already Chinese from partial fix
|
||||
(r'\["select", "选择", "V"\]', '["select", U.toolSelect, "V"]'),
|
||||
(r'\["pan", "平移", "H"\]', '["pan", U.toolPan, "H"]'),
|
||||
(r'\["link", "连线", "C"\]', '["link", U.toolLink, "C"]'),
|
||||
# drag titles/labels — match mixed ? + latin
|
||||
(
|
||||
r'(dragScope === "node" \? " is-active" : ""\}`\}\n title=)"[^"]*"',
|
||||
r'\1{U.dragNodeTitle}',
|
||||
),
|
||||
(
|
||||
r'(aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )(?:\?+|单点)(\n </button>)',
|
||||
r"\1{U.dragNode}\2",
|
||||
),
|
||||
(
|
||||
r'(dragScope === "branch" \? " is-active" : ""\}`\}\n title=)"[^"]*"',
|
||||
r'\1{U.dragBranchTitle}',
|
||||
),
|
||||
(
|
||||
r'(aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )(?:\?+|整枝)(\n </button>)',
|
||||
r"\1{U.dragBranch}\2",
|
||||
),
|
||||
(
|
||||
r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=)"[^"]*"',
|
||||
r'\1{U.bubbleTitle}',
|
||||
),
|
||||
(
|
||||
r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )(?:\?+|气泡)(\n </button>)',
|
||||
r"\1{U.bubble}\2",
|
||||
),
|
||||
(
|
||||
r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=)"[^"]*"',
|
||||
r'\1{U.cardTitle}',
|
||||
),
|
||||
(
|
||||
r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )(?:\?+|卡片)(\n </button>\n <button\n type="button"\n className="canvas-tool-btn"\n title=)"[^"]*"',
|
||||
r"\1{U.card}\2{U.unpinTitle}",
|
||||
),
|
||||
(
|
||||
r'(pinned: false \},\n \]\);\n \}\}\n >\n )(?:\?+|解钉)(\n </button>)',
|
||||
r"\1{U.unpin}\2",
|
||||
),
|
||||
(
|
||||
r'(className="canvas-tool-btn"\n title=)"[^"]*"(\n disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )(?:\?+|撤销)(\n </button>)',
|
||||
r"\1{U.undo}\2{U.undo}\3",
|
||||
),
|
||||
# controls
|
||||
(
|
||||
r'(className="canvas-controls"\n role="toolbar"\n aria-label=)"\?+"',
|
||||
r"\1{U.controlsAria}",
|
||||
),
|
||||
(
|
||||
r'title=\{fullscreen \? "[^"]*" : "[^"]*"\}\n aria-label=\{fullscreen \? "[^"]*" : "[^"]*"\}',
|
||||
"title={fullscreen ? U.exitFullscreen : U.fullscreen}\n aria-label={fullscreen ? U.exitFullscreen : U.fullscreen}",
|
||||
),
|
||||
(
|
||||
r'title="[^"]* \(\+\)"\n aria-label="[^"]*"',
|
||||
"title={U.zoomInTitle}\n aria-label={U.zoomIn}",
|
||||
),
|
||||
(
|
||||
r'title="[^"]* \(-\)"\n aria-label="[^"]*"',
|
||||
"title={U.zoomOutTitle}\n aria-label={U.zoomOut}",
|
||||
),
|
||||
(
|
||||
r'title="[^"]* \(F\)"\n aria-label="[^"]*"',
|
||||
"title={U.fitTitle}\n aria-label={U.fit}",
|
||||
),
|
||||
(
|
||||
r'title=\{locked \? "[^"]* \(L\)" : "[^"]* \(L\)"\}\n aria-label=\{locked \? "[^"]*" : "[^"]*"\}',
|
||||
"title={locked ? U.unlockTitle : U.lockTitle}\n aria-label={locked ? U.unlock : U.lock}",
|
||||
),
|
||||
# handles
|
||||
(
|
||||
r'const text = side < 0 \? "[^"]*" : "[^"]*";',
|
||||
"const text = side < 0 ? U.leftChild : U.rightChild;",
|
||||
),
|
||||
(
|
||||
r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=)"[^"]*"',
|
||||
r"\1{U.addLeftChildTitle}",
|
||||
),
|
||||
(
|
||||
r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=)"[^"]*"',
|
||||
r"\1{U.addRightChildTitle}",
|
||||
),
|
||||
# hint
|
||||
(
|
||||
r"""\{locked
|
||||
\? "[^"]*"
|
||||
: dragScope === "node"
|
||||
\? "[^"]*"
|
||||
: "[^"]*"\}""",
|
||||
"""{locked
|
||||
? U.hintLocked
|
||||
: dragScope === "node"
|
||||
? U.hintNodeDrag
|
||||
: U.hintBranchDrag}""",
|
||||
),
|
||||
]
|
||||
|
||||
for pat, repl in subs:
|
||||
text, n = re.subn(pat, repl, text, count=0)
|
||||
print(f"x{n}: {pat[:50]}")
|
||||
|
||||
# Fix remaining Chinese literals if any known ones remain (after partial restores)
|
||||
zh_map = {
|
||||
'"未命名"': "U.unnamed",
|
||||
'"新节点"': "U.newNode",
|
||||
'"左侧子主题"': "U.leftChild",
|
||||
'"右侧子主题"': "U.rightChild",
|
||||
'"兄弟节点"': "U.sibling",
|
||||
'"关联"': "U.relationLabel",
|
||||
}
|
||||
for a, b in zh_map.items():
|
||||
if a in text:
|
||||
text = text.replace(a, b)
|
||||
print("zh->U:", a)
|
||||
|
||||
# aria-label leftovers as string attrs that should be expressions
|
||||
text = text.replace('aria-label="画布工具"', "aria-label={U.toolbarAria}")
|
||||
text = text.replace('aria-label="视图控制"', "aria-label={U.controlsAria}")
|
||||
|
||||
remaining = re.findall(r'"\?{2,}"', text)
|
||||
btn = re.findall(r">\n\s+\?{2,}\n\s+</button>", text)
|
||||
print("quoted ?? left:", remaining)
|
||||
print("button ?? left:", len(btn))
|
||||
print("has import:", 'canvas-ui-text' in text)
|
||||
|
||||
p.write_text(text, encoding="utf-8", newline="\n")
|
||||
print("wrote", p)
|
||||
if remaining or btn:
|
||||
raise SystemExit(2)
|
||||
print("SUCCESS")
|
||||
|
|
@ -2,9 +2,8 @@
|
|||
from pathlib import Path
|
||||
|
||||
d = Path(__file__).resolve().parents[1] / "dist" / "assets"
|
||||
js_path = next(d.glob("index-*.js"))
|
||||
js = js_path.read_text(encoding="utf-8")
|
||||
js = next(d.glob("index-*.js")).read_text(encoding="utf-8")
|
||||
needles = ["选择", "平移", "连线", "单点", "整枝", "气泡", "卡片", "解钉", "撤销"]
|
||||
print(js_path.name)
|
||||
print(next(d.glob("index-*.js")).name)
|
||||
print({n: (n in js) for n in needles})
|
||||
print("corrupt select:", '["select","??"' in js or '["select", "??"' in js)
|
||||
print("corrupt select:", '["select","??"' in js)
|
||||
|
|
|
|||
|
|
@ -24,6 +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";
|
||||
|
||||
const NODE_W = 200;
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ export function TopologyCanvas() {
|
|||
}
|
||||
await el.requestFullscreen();
|
||||
} catch {
|
||||
// iframe ??????????? Tab ??
|
||||
// iframe: notify parent Tab fullscreen when needed
|
||||
try {
|
||||
window.parent?.postMessage({ type: "dsh-mind-map:fullscreen" }, "*");
|
||||
} catch {
|
||||
|
|
@ -566,7 +567,7 @@ export function TopologyCanvas() {
|
|||
ctx.fillStyle = theme.labelPrimary;
|
||||
ctx.font = canvasFont(13, 600);
|
||||
ctx.textBaseline = "middle";
|
||||
const ph = wrapTextLines(ctx, n.text || "???", boxW - 40, 1);
|
||||
const ph = wrapTextLines(ctx, n.text || U.unnamed, boxW - 40, 1);
|
||||
ctx.fillText(ph[0] ?? "?", x + 26, y + 18);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -585,7 +586,7 @@ export function TopologyCanvas() {
|
|||
drawNodeShape(ctx, "default", x + contentLeft, contentTop, iw, ih, 8);
|
||||
ctx.clip();
|
||||
if (img && img.naturalWidth > 0) {
|
||||
// Contain: ?????????????
|
||||
// Contain: fit image inside box, no crop
|
||||
const scale = Math.min(
|
||||
iw / img.naturalWidth,
|
||||
ih / img.naturalHeight,
|
||||
|
|
@ -622,7 +623,7 @@ export function TopologyCanvas() {
|
|||
ctx.textBaseline = "top";
|
||||
const maxW = boxW - contentLeft - 12;
|
||||
const maxLines = dens ? 2 : n.stylePreset === "title" ? 2 : 2;
|
||||
const lines = wrapTextLines(ctx, n.text || "???", maxW, maxLines);
|
||||
const lines = wrapTextLines(ctx, n.text || U.unnamed, maxW, maxLines);
|
||||
const lineH = fontSize + 3;
|
||||
const textBlockH = lines.length * lineH;
|
||||
const badges =
|
||||
|
|
@ -815,8 +816,8 @@ export function TopologyCanvas() {
|
|||
const parent = selectedIds[0];
|
||||
void applyOps([
|
||||
parent
|
||||
? { type: "create_node", text: "???", parentId: parent }
|
||||
: { type: "create_node", text: "???" },
|
||||
? { type: "create_node", text: U.newNode, parentId: parent }
|
||||
: { type: "create_node", text: U.newNode },
|
||||
]);
|
||||
}
|
||||
if (e.key === "ArrowLeft" && selectedIds[0] && !editing) {
|
||||
|
|
@ -824,7 +825,7 @@ export function TopologyCanvas() {
|
|||
void applyOps([
|
||||
{
|
||||
type: "create_node",
|
||||
text: "?????",
|
||||
text: U.leftChild,
|
||||
parentId: selectedIds[0],
|
||||
sidePref: -1,
|
||||
},
|
||||
|
|
@ -835,7 +836,7 @@ export function TopologyCanvas() {
|
|||
void applyOps([
|
||||
{
|
||||
type: "create_node",
|
||||
text: "?????",
|
||||
text: U.rightChild,
|
||||
parentId: selectedIds[0],
|
||||
sidePref: 1,
|
||||
},
|
||||
|
|
@ -860,10 +861,10 @@ export function TopologyCanvas() {
|
|||
parentOfSel
|
||||
? {
|
||||
type: "create_node",
|
||||
text: "????",
|
||||
text: U.sibling,
|
||||
parentId: parentOfSel,
|
||||
}
|
||||
: { type: "create_node", text: "???" },
|
||||
: { type: "create_node", text: U.newNode },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
|
@ -1073,7 +1074,7 @@ export function TopologyCanvas() {
|
|||
from: fromId,
|
||||
to: hit,
|
||||
kind: "relation",
|
||||
label: "??",
|
||||
label: U.relationLabel,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
@ -1106,7 +1107,7 @@ export function TopologyCanvas() {
|
|||
},
|
||||
]);
|
||||
} else {
|
||||
// Free place ? pin every dragged id (branch = whole subtree, node = one)
|
||||
// Free place -> pin every dragged id (branch = whole subtree, node = one)
|
||||
const nextPos = { ...positions };
|
||||
const pinOps: {
|
||||
type: "set_pinned";
|
||||
|
|
@ -1165,7 +1166,7 @@ export function TopologyCanvas() {
|
|||
void applyOps([
|
||||
{
|
||||
type: "create_node",
|
||||
text: "???",
|
||||
text: U.newNode,
|
||||
...(selectedIds[0] ? { parentId: selectedIds[0] } : {}),
|
||||
sidePref: world.x < 0 ? -1 : 1,
|
||||
},
|
||||
|
|
@ -1178,7 +1179,7 @@ export function TopologyCanvas() {
|
|||
<div
|
||||
className="canvas-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="????"
|
||||
aria-label={U.toolbarAria}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{embed ? (
|
||||
|
|
@ -1187,10 +1188,10 @@ export function TopologyCanvas() {
|
|||
<button
|
||||
type="button"
|
||||
className="canvas-tool-btn"
|
||||
title="??????"
|
||||
title={U.backRosterTitle}
|
||||
onClick={requestBackToRoster}
|
||||
>
|
||||
??
|
||||
{U.backRoster}
|
||||
</button>
|
||||
</div>
|
||||
<div className="canvas-toolbar__sep" />
|
||||
|
|
@ -1199,9 +1200,9 @@ export function TopologyCanvas() {
|
|||
<div className="canvas-toolbar__group">
|
||||
{(
|
||||
[
|
||||
["select", "??", "V"],
|
||||
["pan", "??", "H"],
|
||||
["link", "??", "C"],
|
||||
["select", U.toolSelect, "V"],
|
||||
["pan", U.toolPan, "H"],
|
||||
["link", U.toolLink, "C"],
|
||||
] as const
|
||||
).map(([mode, label, key]) => (
|
||||
<button
|
||||
|
|
@ -1221,20 +1222,20 @@ export function TopologyCanvas() {
|
|||
<button
|
||||
type="button"
|
||||
className={`canvas-tool-btn${dragScope === "node" ? " is-active" : ""}`}
|
||||
title="???????????? (N)??? Shift ??????"
|
||||
title={U.dragNodeTitle}
|
||||
aria-pressed={dragScope === "node"}
|
||||
onClick={() => setDragScope("node")}
|
||||
>
|
||||
??
|
||||
{U.dragNode}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`canvas-tool-btn${dragScope === "branch" ? " is-active" : ""}`}
|
||||
title="?????????????? (B)"
|
||||
title={U.dragBranchTitle}
|
||||
aria-pressed={dragScope === "branch"}
|
||||
onClick={() => setDragScope("branch")}
|
||||
>
|
||||
??
|
||||
{U.dragBranch}
|
||||
</button>
|
||||
</div>
|
||||
<div className="canvas-toolbar__sep" />
|
||||
|
|
@ -1242,7 +1243,7 @@ export function TopologyCanvas() {
|
|||
<button
|
||||
type="button"
|
||||
className={`canvas-tool-btn${cardView ? "" : " is-active"}`}
|
||||
title="?????????"
|
||||
title={U.bubbleTitle}
|
||||
aria-pressed={!cardView}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
|
|
@ -1250,12 +1251,12 @@ export function TopologyCanvas() {
|
|||
void setPrefs({ nodeViewMode: "bubble" });
|
||||
}}
|
||||
>
|
||||
??
|
||||
{U.bubble}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`canvas-tool-btn${cardView ? " is-active" : ""}`}
|
||||
title="???????????"
|
||||
title={U.cardTitle}
|
||||
aria-pressed={cardView}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
|
|
@ -1263,12 +1264,12 @@ export function TopologyCanvas() {
|
|||
void setPrefs({ nodeViewMode: "card" });
|
||||
}}
|
||||
>
|
||||
??
|
||||
{U.card}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-tool-btn"
|
||||
title="??????????????????"
|
||||
title={U.unpinTitle}
|
||||
disabled={
|
||||
locked ||
|
||||
!selectedIds[0] ||
|
||||
|
|
@ -1281,7 +1282,7 @@ export function TopologyCanvas() {
|
|||
]);
|
||||
}}
|
||||
>
|
||||
??
|
||||
{U.unpin}
|
||||
</button>
|
||||
</div>
|
||||
<div className="canvas-toolbar__sep" />
|
||||
|
|
@ -1289,11 +1290,11 @@ export function TopologyCanvas() {
|
|||
<button
|
||||
type="button"
|
||||
className="canvas-tool-btn"
|
||||
title="??"
|
||||
title={U.undo}
|
||||
disabled={!lastChangeSetId}
|
||||
onClick={() => void undo()}
|
||||
>
|
||||
??
|
||||
{U.undo}
|
||||
</button>
|
||||
</div>
|
||||
<span className="canvas-toolbar__zoom">
|
||||
|
|
@ -1304,14 +1305,14 @@ export function TopologyCanvas() {
|
|||
<div
|
||||
className="canvas-controls"
|
||||
role="toolbar"
|
||||
aria-label="????"
|
||||
aria-label={U.controlsAria}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-controls__btn"
|
||||
title={fullscreen ? "????" : "??"}
|
||||
aria-label={fullscreen ? "????" : "??"}
|
||||
title={fullscreen ? U.exitFullscreen : U.fullscreen}
|
||||
aria-label={fullscreen ? U.exitFullscreen : U.fullscreen}
|
||||
onClick={() => void toggleFullscreen()}
|
||||
>
|
||||
<FullscreenIcon exit={fullscreen} />
|
||||
|
|
@ -1319,8 +1320,8 @@ export function TopologyCanvas() {
|
|||
<button
|
||||
type="button"
|
||||
className="canvas-controls__btn"
|
||||
title="?? (+)"
|
||||
aria-label="??"
|
||||
title={U.zoomInTitle}
|
||||
aria-label={U.zoomIn}
|
||||
onClick={() => setZoomAroundCenter(viewport.zoom * 1.2)}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
|
|
@ -1328,8 +1329,8 @@ export function TopologyCanvas() {
|
|||
<button
|
||||
type="button"
|
||||
className="canvas-controls__btn"
|
||||
title="?? (-)"
|
||||
aria-label="??"
|
||||
title={U.zoomOutTitle}
|
||||
aria-label={U.zoomOut}
|
||||
onClick={() => setZoomAroundCenter(viewport.zoom / 1.2)}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
|
|
@ -1337,8 +1338,8 @@ export function TopologyCanvas() {
|
|||
<button
|
||||
type="button"
|
||||
className="canvas-controls__btn"
|
||||
title="???? (F)"
|
||||
aria-label="????"
|
||||
title={U.fitTitle}
|
||||
aria-label={U.fit}
|
||||
onClick={fitView}
|
||||
>
|
||||
<FitIcon />
|
||||
|
|
@ -1346,8 +1347,8 @@ export function TopologyCanvas() {
|
|||
<button
|
||||
type="button"
|
||||
className={`canvas-controls__btn${locked ? " is-active" : ""}`}
|
||||
title={locked ? "???? (L)" : "???? (L)"}
|
||||
aria-label={locked ? "??" : "??"}
|
||||
title={locked ? U.unlockTitle : U.lockTitle}
|
||||
aria-label={locked ? U.unlock : U.lock}
|
||||
aria-pressed={locked}
|
||||
onClick={() => setLocked((v) => !v)}
|
||||
>
|
||||
|
|
@ -1413,7 +1414,7 @@ export function TopologyCanvas() {
|
|||
// Center-to-center: parent half + child half + breathing room
|
||||
const edgeGap = dens ? 100 : 140;
|
||||
const addChild = (side: -1 | 1) => {
|
||||
const text = side < 0 ? "?????" : "?????";
|
||||
const text = side < 0 ? U.leftChild : U.rightChild;
|
||||
const childW = dens ? 160 : 200;
|
||||
void applyOps([
|
||||
{
|
||||
|
|
@ -1434,7 +1435,7 @@ export function TopologyCanvas() {
|
|||
type="button"
|
||||
className="node-handle node-handle--left"
|
||||
style={{ left: cx - hw - 14, top: cy - 10 }}
|
||||
title="???????"
|
||||
title={U.addLeftChildTitle}
|
||||
onClick={() => addChild(-1)}
|
||||
>
|
||||
+
|
||||
|
|
@ -1443,7 +1444,7 @@ export function TopologyCanvas() {
|
|||
type="button"
|
||||
className="node-handle node-handle--right"
|
||||
style={{ left: cx + hw - 2, top: cy - 10 }}
|
||||
title="???????"
|
||||
title={U.addRightChildTitle}
|
||||
onClick={() => addChild(1)}
|
||||
>
|
||||
+
|
||||
|
|
@ -1498,10 +1499,10 @@ export function TopologyCanvas() {
|
|||
|
||||
<div className="canvas-hint muted">
|
||||
{locked
|
||||
? "??? · ???/??/??"
|
||||
? U.hintLocked
|
||||
: dragScope === "node"
|
||||
? "??? · ??????? · Shift ???? · Esc ??"
|
||||
: "??? · ???? · ?????? · N ?? · Esc ??"}
|
||||
? U.hintNodeDrag
|
||||
: U.hintBranchDrag}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
58
apps/web/src/canvas-ui-text.ts
Normal file
58
apps/web/src/canvas-ui-text.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* 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).
|
||||
*/
|
||||
export const canvasUi = {
|
||||
unnamed: "未命名",
|
||||
newNode: "新节点",
|
||||
leftChild: "左侧子主题",
|
||||
rightChild: "右侧子主题",
|
||||
sibling: "兄弟节点",
|
||||
relationLabel: "关联",
|
||||
|
||||
toolbarAria: "画布工具",
|
||||
controlsAria: "视图控制",
|
||||
|
||||
backRosterTitle: "返回画布列表",
|
||||
backRoster: "返回",
|
||||
|
||||
toolSelect: "选择",
|
||||
toolPan: "平移",
|
||||
toolLink: "连线",
|
||||
|
||||
dragNode: "单点",
|
||||
dragBranch: "整枝",
|
||||
dragNodeTitle: "单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点",
|
||||
dragBranchTitle: "整枝拖拽:节点与子孙一起移动 (B)",
|
||||
|
||||
bubble: "气泡",
|
||||
card: "卡片",
|
||||
bubbleTitle: "气泡视图:精简标题",
|
||||
cardTitle: "卡片视图:展开属性面板",
|
||||
unpin: "解钉",
|
||||
unpinTitle: "取消钉住:让选中节点重新参与自动布局",
|
||||
undo: "撤销",
|
||||
|
||||
fullscreen: "全屏",
|
||||
exitFullscreen: "退出全屏",
|
||||
zoomIn: "放大",
|
||||
zoomInTitle: "放大 (+)",
|
||||
zoomOut: "缩小",
|
||||
zoomOutTitle: "缩小 (-)",
|
||||
fit: "适应画布",
|
||||
fitTitle: "适应画布 (F)",
|
||||
lock: "锁定",
|
||||
unlock: "解锁",
|
||||
lockTitle: "锁定画布 (L)",
|
||||
unlockTitle: "解锁画布 (L)",
|
||||
|
||||
addLeftChildTitle: "添加左侧子主题",
|
||||
addRightChildTitle: "添加右侧子主题",
|
||||
|
||||
hintLocked: "已锁定 · 仅浏览/缩放/平移",
|
||||
hintNodeDrag: "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消",
|
||||
hintBranchDrag: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消",
|
||||
} as const;
|
||||
|
||||
export type CanvasUiKey = keyof typeof canvasUi;
|
||||
Loading…
Add table
Add a link
Reference in a new issue