From 83bdfbe9c49b18b7fa848502b6dd6a2ae73b23e2 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 25 Sep 2026 00:41:45 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20docs:=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=98=85=E8=AF=BB=E8=AE=BE=E8=AE=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=8E=20Figma=20=E5=8E=9F=E5=9E=8B=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../code-reader/figma-plugin/README.md | 84 ++ .../code-reader/figma-plugin/code.js | 967 ++++++++++++++++++ .../code-reader/figma-plugin/manifest.json | 10 + .../code-reader/figma-plugin/test.js | 257 +++++ docs/prototype/figma-lab/fake-figma.js | 311 ++++++ docs/superpowers/specs/code-reader/spec.md | 263 +++++ 6 files changed, 1892 insertions(+) create mode 100644 docs/prototype/code-reader/figma-plugin/README.md create mode 100644 docs/prototype/code-reader/figma-plugin/code.js create mode 100644 docs/prototype/code-reader/figma-plugin/manifest.json create mode 100644 docs/prototype/code-reader/figma-plugin/test.js create mode 100644 docs/prototype/figma-lab/fake-figma.js create mode 100644 docs/superpowers/specs/code-reader/spec.md diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md new file mode 100644 index 0000000..02e6c59 --- /dev/null +++ b/docs/prototype/code-reader/figma-plugin/README.md @@ -0,0 +1,84 @@ +# 代码阅读器 Design Lab + +插件新注册的「代码阅读」入口的 Figma 原型。它和现有的 Superpowers 看板(小火箭)是两套东西,只是放在同一个插件里: + +- VS Code 左侧活动栏多一个图标,点开是一个和自带资源管理器一样的文件树。 +- 在树上点文件,用编辑器打开;编辑器右上角切「编辑 / 预览」。 +- 「预览」把代码拆成一棵代码块树:导入、定义、变量、如果、循环、返回……每一句是一块,嵌套的块往右缩进,左边竖线标出层级。 +- 每块默认显示译文,点块右边的「编辑」换成这一块的源码,点「预览」换回来。 +- 译文是模板直译:结构照抄原语言,只把关键字、内置类型、运算符换成中文,名字和写法原样保留,每个关键字单独上色、前后有空格。例如 `export async function findEnvFiles(workspaceRoot: string): Promise` 译成 `公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>`。注释原文照搬。 +- 块左边有种类标签(导入、函数、类型、字段、常量、变量、条件、循环、尝试、捕获、返回、语句、注释),底色也带这个种类的颜色;有子块的块左边有折叠箭头。 +- 译文按词的来源上色,配色和 VS Code 默认深色主题一致:流程关键字(导入 / 来自 / 如果 / 返回 / 遍历 / 等待 / 公共 / 尝试 / 捕获)紫红,声明关键字(常量 / 函数 / 异步 / 接口 / 新建)和运算符(全等 / 非 / 或 / 与)蓝,变量浅蓝,函数黄,类型青,字符串橙,字面量淡绿。源码状态同样上色。 + +## 画了什么 + +| 画板 | 对应源码 | 设计点 | +| --- | --- | --- | +| `Prototype / envLock.ts · 编辑` | `vscode/src/files/envLock.ts` | 活动栏新图标选中,侧栏文件树,文件在编辑器里打开,右上角「编辑」亮 | +| `Prototype / envLock.ts · 预览` | 同上 | 代码块树:函数里套如果、循环里套尝试 / 出错时,最深 4 层;一块停在「编辑」状态做对照 | +| `Prototype / issueLock.ts · 编辑 / 预览` | `vscode/src/panel/issueLock.ts` | 中文注释块、多行返回语句块 | +| `Prototype / envLock.ts · 预览(编辑区变窄)` | — | 窗口只有 1000 宽时块跟着变窄,长句换行 | + +## 导入与运行 + +1. 打开 Figma Desktop,打开任意 Design 文件。 +2. `Plugins > Development > Import plugin from manifest…`,选本目录的 `manifest.json`(已经导入过就跳过)。 +3. `Plugins > Development > 代码阅读器 Design Lab` 运行。改过 `code.js` 后要重启 Figma 才会用新代码。 + +## 运行条件 + +- 需要一种中文字体:PingFang SC、Noto Sans CJK SC、Noto Sans SC、Source Han Sans SC 任一。 +- 等宽字体可选:JetBrains Mono、SF Mono、Menlo 任一;都没有时代码用中文字体显示。 +- 文件里要有空余的页面名额(免费版文件有页面数上限)。 + +## 生成内容 + +每次运行新建一页 `代码阅读器 · Design Lab`(重名时加 2、3…),不动已有页面。 + +组件集(画板右侧一列): + +- `Reader / CodeBlock`:13 种 `Kind` × `State=preview | edit`,共 26 个。两种状态都带「译文」和「源码」两层文字,只切换显示哪层,所以实例里改过的两段文字切状态时都保留。 +- `Reader / BlockTree`:每个示例文件一整棵代码块树,预览画板直接放它的实例。 +- `Reader / ModeToggle`:编辑器右上角的「编辑 / 预览」。 +- `Explorer / TreeRow`、`Explorer / Header`:侧栏文件树。 +- `Window / TitleBar`、`Window / ActivityBar`、`Window / StatusBar`、`Editor / TitleBar`、`Editor / Breadcrumb`:VS Code 窗口外壳。 + +## 数值来源 + +- 颜色:VS Code Dark Modern 主题,`code.js` 的 `COLORS` 里注明了对应的主题变量;层级竖线用 `editorIndentGuide.background`。 +- 源码:两个示例文件的快照,写在 `code.js` 的 `SOURCES`。 +- 代码块怎么拆、每块的种类:写在 `code.js` 的 `CODE_TREE`,测试会检查每一行源码都落进了某一块(空行和收尾括号除外)。 +- 译文:`translateBlock` 按 `WORD_ZH`(关键字、内置类型)和 `OPERATOR_ZH`(运算符)逐词替换生成,原型只覆盖两个示例文件用到的 TypeScript 写法。 +- 种类颜色:`BLOCK_KINDS`;高亮配色:`COLORS` 里 `token*` 几项,取自 VS Code Dark+。源码上色是原型里的简易规则,真实插件里颜色直接来自语法树。 +- 文件树里的 `M` 标记是示例状态;文件图标是示意,真实插件的树会跟着你装的文件图标主题走。 + +## 原型交互 + +从 `Prototype / envLock.ts · 预览` 开始演示(已设为起点): + +- 块右边「编辑」→ 这一块换成源码;「预览」→ 换回译文。 +- 编辑器右上角「编辑」→ 整个文件回到 VS Code 编辑器;「预览」→ 回到代码块树。 +- 侧栏点 `envLock.ts` / `issueLock.ts` → 在编辑器里打开那个文件。 +- 编辑区和侧栏可以上下滚动。 + +## 本地检查 + +```bash +node --check code.js +node --test test.js +``` + +测试跑在假的 Figma 文档上,只检查结构、组件复用、连线、排布不重叠和图层数量,不量真实字体、不渲染。**测试通过不等于视觉验收**,要在 Figma 里看。 + +## 桌面验收清单 + +- [ ] 运行几秒内完成,新页面出现,已有页面没被改动 +- [ ] 活动栏小火箭下面的书本图标是选中状态 +- [ ] 侧栏看起来和 VS Code 自带资源管理器一致 +- [ ] 代码块树:每层左边一条竖线,嵌套的块往右缩进,竖线从这一层第一块连到最后一块 +- [ ] 块里长句子换行,右边「编辑 / 预览」按钮不被挤走 +- [ ] 停在「编辑」状态的块显示上了色的源码、蓝框,中文注释不是方块 +- [ ] 不同种类的块一眼能分开:标签颜色、底色、边框都不同;同一种类(比如三个「返回」)颜色一致 +- [ ] 译文里的关键字、变量名、函数名颜色和编辑器里对应的代码一致,关键字前后都有空格 +- [ ] 有子块的块左边有折叠箭头,没有子块的没有,标签仍然对齐 +- [ ] 演示:块的编辑 / 预览、右上角编辑 / 预览、侧栏切文件都能点通 diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js new file mode 100644 index 0000000..eb7ebaa --- /dev/null +++ b/docs/prototype/code-reader/figma-plugin/code.js @@ -0,0 +1,967 @@ +// 代码阅读器 Design Lab — local Figma Desktop development plugin. +// 插件新注册的「代码阅读」入口:活动栏一个独立图标,侧栏是和 VS Code 资源管理器一样的文件树; +// 打开文件后编辑器右上角切「编辑 / 预览」。预览把代码拆成一棵代码块树: +// 导入、定义、变量、如果、循环、返回……每一句都是一块,嵌套的块往右缩进,左边竖线标出层级; +// 每块默认显示模板直译的译文,点「编辑」换成这一块的源码,点「预览」换回来。 +// Uses only the Figma Plugin API: no network, no product data, no MCP. +const PAGE_NAME = "代码阅读器 · Design Lab"; +const NAMESPACE = "lab"; +const GENERATOR = "lab.code-reader.design-lab"; +const WINDOW_W = 1440; +const WINDOW_NARROW = 1000; +const WINDOW_H = 900; +const TITLE_H = 30; +const STATUS_H = 22; +const MAIN_H = WINDOW_H - TITLE_H - STATUS_H; +const ACTIVITY_W = 48; +const SIDEBAR_W = 280; +const EDITOR_W = WINDOW_W - ACTIVITY_W - SIDEBAR_W - 1; +const TREE_W = 880; +const VIEW_PAD = 24; + +// VS Code Dark Modern 主题的实际色值,注释是对应的主题变量。 +const COLORS = { + editor: "#1f1f1f", // editor.background + side: "#181818", // sideBar.background / activityBar.background / titleBar.activeBackground + field: "#111111", + border: "#2b2b2b", // sideBar.border / panel.border + borderStrong: "#3c3c3c", + guide: "#404040", // editorIndentGuide.background + fg: "#cccccc", // foreground + fgStrong: "#e8e8e8", + desc: "#9d9d9d", // descriptionForeground + dim: "#6e7681", // editorLineNumber.foreground + activityIdle: "#868686", // activityBar.inactiveForeground + activityOn: "#d7d7d7", // activityBar.foreground + focus: "#0078d4", // focusBorder / activityBar.activeBorder + selection: "#04395e", // list.activeSelectionBackground + toggleOn: "#37373d", + gitModified: "#e2c08d", // gitDecoration.modifiedResourceForeground + code: "#d4d4d4", + comment: "#6a9955", + folder: "#8b9ba8", + // 语法高亮:VS Code Dark+ 的默认配色 + tokenControl: "#c586c0", // keyword.control:import / from / export / if / for / return / try / catch / await + tokenStorage: "#569cd6", // storage:const / function / async / interface / new / true / false + tokenVariable: "#9cdcfe", // variable + tokenFunction: "#dcdcaa", // entity.name.function + tokenType: "#4ec9b0", // entity.name.type + tokenString: "#ce9178", // string + tokenLiteral: "#b5cea8", // constant.numeric + ts: "#3178c6", tsx: "#4fc1ff", json: "#cbcb41", md: "#519aba", +}; +const FONT_FAMILIES = ["PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC"]; +const MONO_FONTS = [{ family: "JetBrains Mono", style: "Regular" }, { family: "SF Mono", style: "Regular" }, { family: "Menlo", style: "Regular" }]; +const CJK = /[ -鿿＀-￯]/; + +// 源码快照(示例内容),生成于 2026-09-24:vscode/src/files/envLock.ts、vscode/src/panel/issueLock.ts +const SOURCES = { + envLock: ["/**"," * .env file lock / unlock helpers."," *"," * Recursively scans the main workspace folder for `.env*` files (skipping"," * `node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or"," * 0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`."," *"," * No persistent watcher — re-scan happens on every toggle, so newly created"," * `.env` files are only picked up the next time the user clicks the button."," */","","import { promises as fsp } from 'node:fs'","import { RelativePattern, workspace } from 'vscode'","","const ENV_PATTERN = '**/.env*'","const EXCLUDE_PATTERN = '{**/node_modules/**,**/.git/**,**/.claude/**}'","","/**"," * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`"," * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute"," * fs paths. Empty array if the workspace folder isn't currently open."," */","export async function findEnvFiles(workspaceRoot: string): Promise {"," const folder = workspace.workspaceFolders?.find(f => f.uri.fsPath === workspaceRoot)"," if (!folder)"," return []"," const include = new RelativePattern(folder, ENV_PATTERN)"," const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)"," return uris.map(u => u.fsPath)","}","","export interface EnvLockResult {"," total: number"," ok: string[]"," failed: { path: string, reason: string }[]","}","","async function chmodAll(workspaceRoot: string, mode: number): Promise {"," const files = await findEnvFiles(workspaceRoot)"," const result: EnvLockResult = { total: files.length, ok: [], failed: [] }"," for (const f of files) {"," try {"," await fsp.chmod(f, mode)"," result.ok.push(f)"," }"," catch (err) {"," result.failed.push({ path: f, reason: err instanceof Error ? err.message : String(err) })"," }"," }"," return result","}","","/** Set every `.env*` file in the workspace to 0o444 (read-only). */","export async function lockEnvFiles(workspaceRoot: string): Promise {"," return chmodAll(workspaceRoot, 0o444)","}","","/** Restore every `.env*` file in the workspace to 0o644 (read-write). */","export async function unlockEnvFiles(workspaceRoot: string): Promise {"," return chmodAll(workspaceRoot, 0o644)","}"], + issueLock: ["import type { Issue } from '../gitea/types'","","export interface LockedReason {"," locked: boolean"," prerequisiteNumber?: number"," prerequisiteColumn?: string","}","","/**"," * 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行,"," * 与 webview 的 `isIssueLocked` 保持同一语义。"," */","export function evaluateLockedReason(issues: Issue[], issueNumber: number): LockedReason {"," const issue = issues.find(i => i.number === issueNumber)"," if (!issue || issue.prerequisite === undefined)"," return { locked: false }"," const prereq = issues.find(i => i.number === issue.prerequisite)"," if (!prereq || prereq.column === 'done')"," return { locked: false }"," return {"," locked: true,"," prerequisiteNumber: prereq.number,"," prerequisiteColumn: prereq.column,"," }","}"], +}; + +// 文件树示例:本仓库的真实目录,只展开示例文件所在的路径。M 标记是示例状态。 +const TREE = [ + { depth: 1, type: "folder", name: "docs" }, + { depth: 1, type: "folder", name: "jetbrains" }, + { depth: 1, type: "folder", name: "tui" }, + { depth: 1, type: "folder", name: "vscode", open: true }, + { depth: 2, type: "folder", name: "prompts" }, + { depth: 2, type: "folder", name: "res" }, + { depth: 2, type: "folder", name: "src", open: true }, + { depth: 3, type: "folder", name: "auth" }, + { depth: 3, type: "folder", name: "cc" }, + { depth: 3, type: "folder", name: "figma" }, + { depth: 3, type: "folder", name: "files", open: true }, + { depth: 4, type: "file", name: "envLock.ts", file: "envLock" }, + { depth: 3, type: "folder", name: "git" }, + { depth: 3, type: "folder", name: "gitea" }, + { depth: 3, type: "folder", name: "issues" }, + { depth: 3, type: "folder", name: "panel", open: true }, + { depth: 4, type: "folder", name: "handlers" }, + { depth: 4, type: "file", name: "issueLock.test.ts" }, + { depth: 4, type: "file", name: "issueLock.ts", file: "issueLock" }, + { depth: 4, type: "file", name: "KanbanPanel.ts", git: "M" }, + { depth: 4, type: "file", name: "messages.ts" }, + { depth: 3, type: "file", name: "extension.ts" }, + { depth: 2, type: "folder", name: "webview-ui", open: true }, + { depth: 3, type: "folder", name: "src", open: true }, + { depth: 4, type: "folder", name: "components", open: true }, + { depth: 5, type: "file", name: "BottomTabs.tsx", git: "M" }, + { depth: 5, type: "file", name: "IssueCard.tsx" }, + { depth: 5, type: "file", name: "KanbanBoard.tsx" }, + { depth: 5, type: "file", name: "PrFilesPanel.tsx" }, + { depth: 4, type: "file", name: "App.tsx" }, + { depth: 2, type: "file", name: "CLAUDE.md" }, + { depth: 2, type: "file", name: "package.json" }, + { depth: 2, type: "file", name: "README.md" }, +]; +const EXT_LABEL = { ts: "TS", tsx: "TSX", json: "{}", md: "M↓" }; + +// 活动栏:VS Code 自带的几个 + Superpowers 的小火箭 + 新注册的「代码阅读」 +const ACTIVITY = [ + { key: "explorer", icon: "files" }, + { key: "search", icon: "search" }, + { key: "scm", icon: "git-branch" }, + { key: "debug", icon: "play" }, + { key: "remote", icon: "monitor" }, + { key: "extensions", icon: "blocks" }, + { key: "superpowers", icon: "rocket" }, + { key: "reader", icon: "book-open-text" }, +]; + +const FILES = { + envLock: { name: "envLock.ts", ext: "ts", crumbs: ["vscode", "src", "files", "envLock.ts"] }, + issueLock: { name: "issueLock.ts", ext: "ts", crumbs: ["vscode", "src", "panel", "issueLock.ts"] }, +}; + +// 代码块的种类:标签文字 + 颜色。同一类语义共用一个颜色(常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),标签区分具体是哪种。 +const BLOCK_KINDS = { + import: { label: "导入", color: "#c586c0" }, + function: { label: "函数", color: "#dcdcaa" }, + type: { label: "类型", color: "#4ec9b0" }, + field: { label: "字段", color: "#9cdcfe" }, + const: { label: "常量", color: "#9cdcfe" }, + variable: { label: "变量", color: "#9cdcfe" }, + if: { label: "条件", color: "#ce9178" }, + loop: { label: "循环", color: "#8f9cff" }, + try: { label: "尝试", color: "#f48771" }, + catch: { label: "捕获", color: "#f48771" }, + return: { label: "返回", color: "#89d185" }, + statement: { label: "语句", color: "#9d9d9d" }, + comment: { label: "注释", color: "#6a9955" }, +}; +// 代码块树。at:源码行号(单行,或 [起, 止]);kind:种类;kids:嵌套在它里面的块; +// edit:原型里默认停在「编辑」状态,用来展示两种样子。块里显示的译文由 translateBlock 按模板直译生成。 +// 没落进任何块的行只能是空行或收尾括号,测试会检查。 +const CODE_TREE = { + envLock: [ + { at: [1, 10], kind: "comment" }, + { at: 12, kind: "import" }, + { at: 13, kind: "import" }, + { at: 15, kind: "const" }, + { at: 16, kind: "const" }, + { at: [18, 22], kind: "comment" }, + { at: 23, kind: "function", kids: [ + { at: 24, kind: "variable" }, + { at: 25, kind: "if", kids: [ + { at: 26, kind: "return" }, + ] }, + { at: 27, kind: "variable", edit: true }, + { at: 28, kind: "variable" }, + { at: 29, kind: "return" }, + ] }, + { at: 32, kind: "type", kids: [ + { at: 33, kind: "field" }, + { at: 34, kind: "field" }, + { at: 35, kind: "field" }, + ] }, + { at: 38, kind: "function", kids: [ + { at: 39, kind: "variable" }, + { at: 40, kind: "variable" }, + { at: 41, kind: "loop", kids: [ + { at: 42, kind: "try", kids: [ + { at: 43, kind: "statement" }, + { at: 44, kind: "statement" }, + ] }, + { at: 46, kind: "catch", kids: [ + { at: 47, kind: "statement" }, + ] }, + ] }, + { at: 50, kind: "return" }, + ] }, + { at: 53, kind: "comment" }, + { at: 54, kind: "function", kids: [ + { at: 55, kind: "return" }, + ] }, + { at: 58, kind: "comment" }, + { at: 59, kind: "function", kids: [ + { at: 60, kind: "return" }, + ] }, + ], + issueLock: [ + { at: 1, kind: "import" }, + { at: 3, kind: "type", kids: [ + { at: 4, kind: "field" }, + { at: 5, kind: "field" }, + { at: 6, kind: "field" }, + ] }, + { at: [9, 12], kind: "comment" }, + { at: 13, kind: "function", kids: [ + { at: 14, kind: "variable" }, + { at: 15, kind: "if", edit: true, kids: [ + { at: 16, kind: "return" }, + ] }, + { at: 17, kind: "variable" }, + { at: 18, kind: "if", kids: [ + { at: 19, kind: "return" }, + ] }, + { at: [20, 24], kind: "return" }, + ] }, + ], +}; +const MODES = ["edit", "preview"]; + +function lineRange(at) { + return Array.isArray(at) ? at : [at, at]; +} + +/** 一块对应的源码:取出行、去掉共同的缩进。 */ +function blockCode(fileKey, at) { + const [from, to] = lineRange(at); + const lines = SOURCES[fileKey].slice(from - 1, to); + const indent = Math.min(...lines.filter((line) => line.trim()).map((line) => line.match(/^ */)[0].length)); + return lines.map((line) => line.slice(indent)).join("\n"); +} + +function walkTree(nodes, visit, depth = 0) { + for (const node of nodes) { + visit(node, depth); + if (node.kids) walkTree(node.kids, visit, depth + 1); + } +} + +// 模板直译(原型版,TypeScript):结构照抄源码,只把关键字、内置类型、运算符换成中文,每个词单独上色; +// 标识符、属性名(点号后面的词)和字面量原样保留;关键字和前后内容之间保留源码里的空格。 +// 块头上包住子块的 { 去掉(嵌套已经由树表示),多行合成一行。 +// 注释块去掉注释符号,原文照搬(模板翻不了自然语言)。 +const WORD_ZH = { + import: ["导入", "tokenControl"], from: ["来自", "tokenControl"], as: ["作为", "tokenControl"], export: ["公共", "tokenControl"], + if: ["如果", "tokenControl"], else: ["否则", "tokenControl"], for: ["遍历", "tokenControl"], of: ["取自", "tokenControl"], + return: ["返回", "tokenControl"], try: ["尝试", "tokenControl"], catch: ["捕获", "tokenControl"], await: ["等待", "tokenControl"], + const: ["常量", "tokenStorage"], let: ["变量", "tokenStorage"], var: ["变量", "tokenStorage"], function: ["函数", "tokenStorage"], + async: ["异步", "tokenStorage"], interface: ["接口", "tokenStorage"], type: ["类型", "tokenStorage"], class: ["类", "tokenStorage"], + new: ["新建", "tokenStorage"], instanceof: ["属于", "tokenStorage"], + true: ["真", "tokenStorage"], false: ["假", "tokenStorage"], null: ["空", "tokenStorage"], undefined: ["未定义", "tokenStorage"], + string: ["字符串", "tokenType"], number: ["数值", "tokenType"], boolean: ["布尔", "tokenType"], void: ["无", "tokenType"], +}; +const OPERATOR_ZH = { "===": "全等", "!==": "不全等", "&&": "与", "||": "或", "!": "非" }; + +function stripComment(code) { + return code.split("\n") + .map((line) => line.replace(/^\s*(\/\*\*?|\*\/|\*|\/\/)\s?/, "").replace(/\s*\*\/\s*$/, "")) + .filter((line) => line.trim()) + .join(" ") + .trim(); +} + +/** 一块的译文和每个词的颜色。 */ +function translateBlock(fileKey, node) { + const code = blockCode(fileKey, node.at); + if (node.kind === "comment") return { text: stripComment(code), ranges: [] }; + const flat = code.replace(/\s*\n\s*/g, " ").replace(/,\s*}/g, " }").replace(/\s*\{$/, ""); + const token = /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(\b0[oxb][0-9a-f]+\b|\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)|(===|!==|&&|\|\||!(?!=))|(\[\])/gi; + const ranges = []; + let text = ""; + let last = 0; + const push = (value, color) => { + ranges.push({ start: text.length, end: text.length + value.length, color }); + text += value; + }; + let match; + while ((match = token.exec(flat))) { + const [whole, string, number, word, operator, emptyArray] = match; + text += flat.slice(last, match.index); + last = match.index + whole.length; + if (string) push(string, COLORS.tokenString); + else if (number) push(number, COLORS.tokenLiteral); + else if (word && flat[match.index - 1] !== "." && WORD_ZH[word]) push(WORD_ZH[word][0], COLORS[WORD_ZH[word][1]]); + else if (word && /^[A-Z]/.test(word)) push(word, COLORS.tokenType); + else if (word && /^\s*\(/.test(flat.slice(last, last + 4))) push(word, COLORS.tokenFunction); + else if (word) push(word, COLORS.tokenVariable); + else if (operator) { + push(OPERATOR_ZH[operator], COLORS.tokenStorage); + if (operator === "!") text += " "; + } + else if (/[\w\]}>)]/.test(flat[match.index - 1] || "")) text += emptyArray; + else push("空数组", COLORS.tokenLiteral); + } + return { text: text + flat.slice(last), ranges }; +} + +const CODE_CONTROL = new Set(["import", "from", "export", "if", "else", "for", "of", "in", "return", "try", "catch", "await", "throw", "while", "as"]); +const CODE_STORAGE = new Set(["const", "let", "var", "function", "async", "interface", "type", "class", "new", "instanceof", "typeof", "true", "false", "null", "undefined"]); + +/** + * 原型用的简易上色,只为画出效果:注释、字符串、数字、关键字、大写开头当类型、后面跟括号当函数、其余当变量。 + * 真实插件里源码的颜色直接来自语法树。 + */ +function highlightCode(code) { + const token = /(\/\*[\s\S]*?\*\/|\/\/[^\n]*)|('(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`)|(\b0[oxb][0-9a-f]+\b|\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)/gi; + const ranges = []; + let match; + while ((match = token.exec(code))) { + const [whole, comment, string, number, word] = match; + let color = COLORS.tokenVariable; + if (comment) color = COLORS.comment; + else if (string) color = COLORS.tokenString; + else if (number) color = COLORS.tokenLiteral; + else if (CODE_CONTROL.has(word)) color = COLORS.tokenControl; + else if (CODE_STORAGE.has(word)) color = COLORS.tokenStorage; + else if (/^[A-Z]/.test(word)) color = COLORS.tokenType; + else if (/^\s*\(/.test(code.slice(match.index + whole.length, match.index + whole.length + 4))) color = COLORS.tokenFunction; + ranges.push({ start: match.index, end: match.index + whole.length, color }); + } + return ranges; +} + +// ─── Small helpers ─────────────────────────────────────────────────────────── + +function nextPageName(pages) { + const names = new Set(pages.map((page) => page.name)); + let name = PAGE_NAME; + for (let index = 2; names.has(name); index += 1) name = `${PAGE_NAME} ${index}`; + return name; +} + +function rgb(hex) { + return { r: parseInt(hex.slice(1, 3), 16) / 255, g: parseInt(hex.slice(3, 5), 16) / 255, b: parseInt(hex.slice(5, 7), 16) / 255 }; +} + +function paint(color, opacity = 1) { + return [{ type: "SOLID", color: rgb(color), opacity }]; +} + +// Pick the first available CJK family; load only the styles that are used. +// Fail before any page is created when no CJK font exists. 等宽字体可选,缺了退回中文字体。 +async function chooseFonts(api) { + const available = (await api.listAvailableFontsAsync()).map((entry) => entry.fontName); + const has = (font) => available.some((item) => item.family === font.family && item.style === font.style); + for (const family of FONT_FAMILIES) { + const styles = available.filter((font) => font.family === family); + const regular = styles.find((font) => ["Regular", "Normal", "Book"].includes(font.style)); + if (!regular) continue; + const medium = styles.find((font) => ["Medium", "Semibold", "SemiBold"].includes(font.style)) || regular; + await api.loadFontAsync(regular); + if (medium !== regular) await api.loadFontAsync(medium); + const mono = MONO_FONTS.find(has) || regular; + if (mono !== regular) await api.loadFontAsync(mono); + return { regular, medium, mono }; + } + throw new Error(`没有找到可用的中文字体。请在 Figma 中启用 ${FONT_FAMILIES.join("、")} 之一后重试。`); +} + +/** + * Auto-layout container. With `width` that axis is fixed and the other hugs; + * without it both axes hug. o: dir / width / height / pad / px / py / gap / + * align (cross axis) / justify (main axis) / fill / fillOpacity / stroke / radius / component + */ +function frame(ctx, name, o = {}) { + const node = o.component ? ctx.api.createComponent() : ctx.api.createFrame(); + node.name = name; + node.layoutMode = o.dir || "VERTICAL"; + const horizontal = node.layoutMode === "HORIZONTAL"; + node.fills = o.fill ? paint(o.fill, o.fillOpacity) : []; + node.strokes = o.stroke ? paint(o.stroke, o.strokeOpacity) : []; + node.strokeWeight = 1; + node.clipsContent = false; + node.cornerRadius = o.radius || 0; + node.paddingTop = node.paddingBottom = o.py ?? o.pad ?? 0; + node.paddingLeft = node.paddingRight = o.px ?? o.pad ?? 0; + node.itemSpacing = o.gap || 0; + node.counterAxisAlignItems = o.align || "MIN"; + node.primaryAxisAlignItems = o.justify || "MIN"; + if (o.width) { + node.resize(o.width, o.height || 1); + node.primaryAxisSizingMode = horizontal ? "FIXED" : o.height ? "FIXED" : "AUTO"; + node.counterAxisSizingMode = horizontal ? (o.height ? "FIXED" : "AUTO") : "FIXED"; + } else { + node.primaryAxisSizingMode = "AUTO"; + node.counterAxisSizingMode = "AUTO"; + } + return node; +} + +function append(parent, node, fill = false) { + parent.appendChild(node); + if (fill) node.layoutSizingHorizontal = "FILL"; + return node; +} + +/** Frame that fills the parent's width, so its own children can fill too. */ +function filled(ctx, parent, name, o = {}) { + return append(parent, frame(ctx, name, { ...o, width: 10 }), true); +} + +/** 只画一条上边框(VS Code 标签页的高亮线、状态栏的分隔线)。 */ +function topBorder(node, color) { + node.strokes = paint(color); + node.strokeTopWeight = 1; + node.strokeRightWeight = node.strokeBottomWeight = node.strokeLeftWeight = 0; +} + +/** + * Text. Default: fills the parent width and grows in height. `hug`: both axes hug (labels, values). + * 不读父节点宽度:每读一次都会逼 Figma 把整棵自动布局树重排一遍。 + */ +function text(ctx, parent, name, characters, o = {}) { + const node = ctx.api.createText(); + node.name = name; + node.fontName = o.font || (o.mono && !CJK.test(characters) ? ctx.fonts.mono : o.medium ? ctx.fonts.medium : ctx.fonts.regular); + node.fontSize = o.size || 13; + node.lineHeight = { unit: "PIXELS", value: o.lineHeight || Math.round(node.fontSize * 1.5) }; + node.characters = characters; + node.fills = paint(o.color || COLORS.fg); + if (o.align) node.textAlignHorizontal = o.align; + parent.appendChild(node); + if (o.hug) { + node.textAutoResize = "WIDTH_AND_HEIGHT"; + return node; + } + node.textAutoResize = "HEIGHT"; + node.layoutSizingHorizontal = "FILL"; + if (o.singleLine) { + node.textTruncation = "ENDING"; + node.maxLines = 1; + } + return node; +} + +/** 等宽字体没有中文字形:连续的中文字符换成中文字体。 */ +function useCjkFont(ctx, node) { + const run = /[\u3000-\u9fff\uff00-\uffef]+/g; + let match; + while ((match = run.exec(node.characters))) node.setRangeFontName(match.index, match.index + match[0].length, ctx.fonts.regular); +} + +function paintRanges(node, ranges) { + for (const range of ranges) node.setRangeFills(range.start, range.end, paint(range.color)); +} + +function divider(ctx, parent, o = {}) { + const node = ctx.api.createFrame(); + node.name = "Divider"; + node.resize(o.vertical ? 1 : 10, o.vertical ? 10 : 1); + node.fills = paint(o.color || COLORS.border); + parent.appendChild(node); + if (o.vertical) node.layoutSizingVertical = "FILL"; + else node.layoutSizingHorizontal = "FILL"; + return node; +} + +function dot(ctx, parent, name, color, size) { + const node = ctx.api.createEllipse(); + node.name = name; + node.resize(size, size); + node.fills = paint(color); + parent.appendChild(node); + return node; +} + +// Inline lucide paths. Add icons here; never fetch them. +const LUCIDE = { + "chevron-right": [["path", { d: "m9 18 6-6-6-6" }]], + "chevron-down": [["path", { d: "m6 9 6 6 6-6" }]], + "chevrons-down-up": [["path", { d: "m7 20 5-5 5 5" }], ["path", { d: "m7 4 5 5 5-5" }]], + "triangle-alert": [["path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }], ["path", { d: "M12 9v4" }], ["path", { d: "M12 17h.01" }]], + "circle-x": [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "m15 9-6 6" }], ["path", { d: "m9 9 6 6" }]], + "eye": [["path", { d: "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" }], ["circle", { cx: "12", cy: "12", r: "3" }]], + "pencil": [["path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }], ["path", { d: "m15 5 4 4" }]], + "refresh-cw": [["path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }], ["path", { d: "M21 3v5h-5" }], ["path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }], ["path", { d: "M8 16H3v5" }]], + "file-plus": [["path", { d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" }], ["path", { d: "M14 2v4a2 2 0 0 0 2 2h4" }], ["path", { d: "M9 15h6" }], ["path", { d: "M12 18v-6" }]], + "folder-plus": [["path", { d: "M12 10v6" }], ["path", { d: "M9 13h6" }], ["path", { d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }]], + "ellipsis": [["circle", { cx: "12", cy: "12", r: "1" }], ["circle", { cx: "19", cy: "12", r: "1" }], ["circle", { cx: "5", cy: "12", r: "1" }]], + "columns-2": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }], ["path", { d: "M12 3v18" }]], + "x": [["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]], + "files": [["path", { d: "M20 7h-3a2 2 0 0 1-2-2V2" }], ["path", { d: "M9 18a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h7l4 4v10a2 2 0 0 1-2 2Z" }], ["path", { d: "M3 7.6v12.8A1.6 1.6 0 0 0 4.6 22h9.8" }]], + "search": [["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]], + "git-branch": [["path", { d: "M6 3v12" }], ["circle", { cx: "18", cy: "6", r: "3" }], ["circle", { cx: "6", cy: "18", r: "3" }], ["path", { d: "M18 9a9 9 0 0 1-9 9" }]], + "play": [["path", { d: "M6 3 20 12 6 21Z" }]], + "monitor": [["rect", { width: "20", height: "14", x: "2", y: "3", rx: "2" }], ["path", { d: "M8 21h8" }], ["path", { d: "M12 17v4" }]], + "blocks": [["rect", { width: "7", height: "7", x: "14", y: "3", rx: "1" }], ["path", { d: "M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" }]], + "rocket": [["path", { d: "M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" }], ["path", { d: "m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" }], ["path", { d: "M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" }], ["path", { d: "M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" }]], + "book-open-text": [["path", { d: "M12 7v14" }], ["path", { d: "M16 12h2" }], ["path", { d: "M16 8h2" }], ["path", { d: "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" }], ["path", { d: "M6 12h2" }], ["path", { d: "M6 8h2" }]], + "circle-user": [["circle", { cx: "12", cy: "12", r: "10" }], ["circle", { cx: "12", cy: "10", r: "3" }], ["path", { d: "M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662" }]], + "settings": [["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], ["circle", { cx: "12", cy: "12", r: "3" }]], +}; + +function icon(ctx, name, color, size, o = {}) { + const shapes = LUCIDE[name]; + if (!shapes) throw new Error(`未收录的图标: ${name}`); + const body = shapes.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([key, value]) => `${key}="${value}"`).join(" ")}/>`).join(""); + const svg = `${body}`; + const node = ctx.api.createNodeFromSvg(svg); + node.name = o.name || `Icon / ${name}`; + return node; +} + +function folderGlyph(ctx) { + const node = ctx.api.createNodeFromSvg(``); + node.name = "Icon / folder"; + return node; +} + +/** Combine components into a variant set and lay them out in a grid. */ +function componentSet(ctx, page, name, components, columns) { + for (const component of components) page.appendChild(component); + const set = ctx.api.combineAsVariants(components, page); + set.name = name; + set.layoutMode = "NONE"; + set.fills = paint(COLORS.side); + set.strokes = paint(COLORS.fgStrong, 0.12); + set.cornerRadius = 16; + const gap = 32; + const width = Math.max(...components.map((node) => node.width)); + let y = gap; + for (let index = 0; index < components.length; index += columns) { + const row = components.slice(index, index + columns); + row.forEach((node, column) => { node.x = gap + column * (width + gap); node.y = y; }); + y += Math.max(...row.map((node) => node.height)) + gap; + } + set.resize(columns * (width + gap) + gap, y); + return set; +} + +/** + * Click wiring. NAVIGATE: only to a top-level frame on the page, never back + * into the source's own board (Figma rejects it). CHANGE_TO: only to a sibling variant. + */ +async function connect(source, destination, navigation) { + await source.setReactionsAsync([{ + trigger: { type: "ON_CLICK" }, + actions: [{ type: "NODE", destinationId: destination.id, navigation, + ...(navigation === "NAVIGATE" ? { resetInteractiveComponents: true } : {}), + transition: { type: navigation === "CHANGE_TO" ? "SMART_ANIMATE" : "DISSOLVE", + duration: 0.16, easing: { type: "EASE_OUT" } } }], + }]); +} + +function named(root, name) { + const node = root.findOne((item) => item.name === name); + if (!node) throw new Error(`找不到图层: ${name}`); + return node; +} + +function setText(root, name, characters) { + named(root, name).characters = characters; +} + +// ─── Components: 代码块 ────────────────────────────────────────────────────── + +/** + * Reader / CodeBlock — Kind=<种类>, State=preview | edit。 + * 左边折叠箭头(没有子块的块在实例里把箭头藏起来)、种类标签,中间是译文 / 源码,右边是切换按钮。 + * 两种状态都带 Plain(译文)和 Code(源码)两个文字层,只切换显示哪个:实例里改过的两段文字切状态时都保留。 + */ +const BLOCK_VARIANTS = Object.keys(BLOCK_KINDS).flatMap((kind) => ["preview", "edit"].map((state) => [kind, state])); +function codeBlock(ctx, kind, state) { + const spec = BLOCK_KINDS[kind]; + const comment = kind === "comment"; + const editing = state === "edit"; + const node = frame(ctx, `Kind=${kind}, State=${state}`, { component: true, dir: "HORIZONTAL", width: TREE_W, py: 8, gap: 10, radius: 8, + fill: editing ? COLORS.side : spec.color, fillOpacity: editing ? 1 : 0.07, + stroke: editing ? COLORS.focus : spec.color, strokeOpacity: editing ? 1 : 0.35 }); + node.paddingLeft = 8; + node.paddingRight = 10; + if (comment) node.dashPattern = [4, 3]; + const fold = append(node, frame(ctx, "Fold", { dir: "HORIZONTAL", width: 14, height: 20, align: "CENTER", justify: "CENTER" })); + append(fold, icon(ctx, "chevron-down", COLORS.desc, 14, { name: "Fold icon" })); + const badge = append(node, frame(ctx, "Badge", { dir: "HORIZONTAL", width: 44, height: 20, radius: 4, align: "CENTER", justify: "CENTER", + fill: spec.color, fillOpacity: 0.18 })); + text(ctx, badge, "Kind", spec.label, { hug: true, medium: true, size: 11, lineHeight: 14, color: spec.color }); + const content = append(node, frame(ctx, "Content", { width: 10 }), true); + const plain = text(ctx, content, "Plain", "导入 { xx } 来自 'xx'", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20, color: comment ? COLORS.desc : COLORS.fg }); + useCjkFont(ctx, plain); + const code = text(ctx, content, "Code", "import { xx } from 'xx'", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20, + color: comment ? COLORS.comment : COLORS.code }); + plain.visible = !editing; + code.visible = editing; + const toggle = append(node, frame(ctx, "Toggle", { dir: "HORIZONTAL", px: 8, py: 2, gap: 4, radius: 4, align: "CENTER", + fill: editing ? COLORS.toggleOn : null })); + append(toggle, icon(ctx, editing ? "eye" : "pencil", COLORS.desc, 13)); + text(ctx, toggle, "Label", editing ? "预览" : "编辑", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc }); + return { node, toggle }; +} + +/** 一层代码块:左边一条竖线标出这一层,右边是这一层的块,块里嵌套的下一层紧跟在块下面。 */ +function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed) { + const level = filled(ctx, parent, `Level ${depth}`, { dir: "HORIZONTAL", gap: 8 }); + const guide = append(level, frame(ctx, "Guide", { width: 12, align: "CENTER" })); + guide.layoutSizingVertical = "FILL"; + const line = ctx.api.createRectangle(); + line.name = "Line"; + line.resize(1, 10); + line.fills = paint(COLORS.guide); + guide.appendChild(line); + line.layoutSizingVertical = "FILL"; + const list = append(level, frame(ctx, "Blocks", { width: 10, gap: 8 }), true); + for (const item of nodes) { + const instance = lib.block.get(`${item.kind}/${item.edit ? "edit" : "preview"}`).node.createInstance(); + instance.name = `Block / L${lineRange(item.at)[0]}`; + append(list, instance, true); + const plain = named(instance, "Plain"); + const translated = translateBlock(fileKey, item); + plain.characters = translated.text; + paintRanges(plain, translated.ranges); + useCjkFont(ctx, plain); + const code = named(instance, "Code"); + code.characters = blockCode(fileKey, item.at); + paintRanges(code, highlightCode(code.characters)); + useCjkFont(ctx, code); + if (!item.kids) named(instance, "Fold icon").visible = false; + placed.push({ item, instance, depth }); + if (item.kids) blockLevel(ctx, list, lib, fileKey, item.kids, depth + 1, placed); + } + return level; +} + +/** Reader / BlockTree — File=<文件>:预览模式下编辑区里的整棵代码块树 */ +function blockTree(ctx, lib, fileKey) { + const node = frame(ctx, `File=${fileKey}`, { component: true, width: TREE_W, py: 20, fill: COLORS.editor }); + const placed = []; + blockLevel(ctx, node, lib, fileKey, CODE_TREE[fileKey], 0, placed); + return { node, placed }; +} + +// ─── Components: VS Code 窗口外壳 ───────────────────────────────────────────── + +/** Reader / ModeToggle — Mode=edit | preview:编辑器标题栏右上角的切换 */ +function modeToggle(ctx, mode) { + const node = frame(ctx, `Mode=${mode}`, { component: true, dir: "HORIZONTAL", pad: 2, gap: 2, radius: 5, align: "CENTER", + fill: COLORS.field, stroke: COLORS.borderStrong }); + for (const [key, label, iconName] of [["edit", "编辑", "pencil"], ["preview", "预览", "eye"]]) { + const on = key === mode; + const seg = append(node, frame(ctx, `Seg / ${label}`, { dir: "HORIZONTAL", px: 8, py: 2, gap: 4, radius: 3, align: "CENTER", + fill: on ? COLORS.toggleOn : null })); + append(seg, icon(ctx, iconName, on ? COLORS.fgStrong : COLORS.desc, 13)); + text(ctx, seg, "Label", label, { hug: true, size: 12, lineHeight: 16, color: on ? COLORS.fgStrong : COLORS.desc }); + } + return node; +} + +/** Explorer / TreeRow — 文件夹展开 / 收起,文件默认 / 选中 / 有改动 / 有改动且选中。名字和扩展名在实例里改。 */ +const ROW_VARIANTS = [ + ["folder", "closed"], ["folder", "open"], + ["file", "default"], ["file", "selected"], ["file", "modified"], ["file", "modified-selected"], +]; +function treeRow(ctx, type, state) { + const selected = state.endsWith("selected"); + const modified = state.startsWith("modified"); + const node = frame(ctx, `Type=${type}, State=${state}`, { component: true, dir: "HORIZONTAL", width: SIDEBAR_W, py: 3, gap: 6, + align: "CENTER", fill: selected ? COLORS.selection : null, stroke: selected ? COLORS.focus : null }); + node.paddingLeft = 8; + node.paddingRight = 12; + const twisty = append(node, frame(ctx, "Twisty", { dir: "HORIZONTAL", width: 16, height: 16, align: "CENTER", justify: "CENTER" })); + if (type === "folder") { + append(twisty, icon(ctx, state === "open" ? "chevron-down" : "chevron-right", COLORS.fg, 16)); + append(node, folderGlyph(ctx)); + } else { + const ext = append(node, frame(ctx, "Ext", { dir: "HORIZONTAL", width: 16, height: 16, align: "CENTER", justify: "CENTER" })); + text(ctx, ext, "Ext label", "TS", { hug: true, medium: true, size: 8, lineHeight: 10, color: COLORS.ts }); + } + text(ctx, node, "Name", type === "folder" ? "folder" : "file.ts", { size: 13, lineHeight: 16, singleLine: true, + color: modified ? COLORS.gitModified : COLORS.fg }); + if (modified) text(ctx, node, "Git", "M", { hug: true, size: 12, lineHeight: 16, color: COLORS.gitModified }); + return node; +} + +/** Explorer / Header — 侧栏顶部:视图标题 + 新建文件 / 新建文件夹 / 刷新 / 全部折叠 */ +function explorerHeader(ctx) { + const node = frame(ctx, "State=default", { component: true, dir: "HORIZONTAL", width: SIDEBAR_W, py: 9, gap: 8, + align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side }); + node.paddingLeft = 20; + node.paddingRight = 10; + text(ctx, node, "Title", "代码阅读", { hug: true, size: 11, lineHeight: 16, color: COLORS.fg }); + const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 10, align: "CENTER" })); + for (const name of ["file-plus", "folder-plus", "refresh-cw", "chevrons-down-up", "ellipsis"]) append(actions, icon(ctx, name, COLORS.fg, 16)); + return node; +} + +/** Window / TitleBar — macOS 窗口标题栏,Title 在实例里改 */ +function windowTitle(ctx) { + const node = frame(ctx, "State=active", { component: true, dir: "HORIZONTAL", width: WINDOW_W, px: 12, py: 8, + align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side }); + const lights = append(node, frame(ctx, "Lights", { dir: "HORIZONTAL", width: 60, gap: 8, align: "CENTER" })); + for (const [name, color] of [["Close", "#ff5f57"], ["Minimize", "#febc2e"], ["Zoom", "#28c840"]]) dot(ctx, lights, name, color, 12); + text(ctx, node, "Title", "superwork", { hug: true, size: 12, lineHeight: 14, color: COLORS.desc }); + append(node, frame(ctx, "Balance", { dir: "HORIZONTAL", width: 60, height: 12 })); + return node; +} + +/** Window / ActivityBar — Active=reader:新注册的「代码阅读」图标选中 */ +function activityBar(ctx) { + const node = frame(ctx, "Active=reader", { component: true, width: ACTIVITY_W, height: MAIN_H, justify: "SPACE_BETWEEN", fill: COLORS.side }); + const top = append(node, frame(ctx, "Top", { width: ACTIVITY_W })); + for (const item of ACTIVITY) { + const on = item.key === "reader"; + const cell = append(top, frame(ctx, `Item / ${item.key}`, { dir: "HORIZONTAL", width: ACTIVITY_W, height: ACTIVITY_W, align: "CENTER", justify: "CENTER" })); + append(cell, icon(ctx, item.icon, on ? COLORS.activityOn : COLORS.activityIdle, 24, { weight: 1.5 })); + if (on) { + const bar = ctx.api.createRectangle(); + bar.name = "Active bar"; + cell.appendChild(bar); + bar.layoutPositioning = "ABSOLUTE"; + bar.resize(2, ACTIVITY_W); + bar.x = 0; + bar.y = 0; + bar.fills = paint(COLORS.focus); + } + } + const bottom = append(node, frame(ctx, "Bottom", { width: ACTIVITY_W })); + for (const name of ["circle-user", "settings"]) { + const cell = append(bottom, frame(ctx, `Item / ${name}`, { dir: "HORIZONTAL", width: ACTIVITY_W, height: ACTIVITY_W, align: "CENTER", justify: "CENTER" })); + append(cell, icon(ctx, name, COLORS.activityIdle, 24, { weight: 1.5 })); + } + return node; +} + +/** Window / StatusBar — Mode=edit | preview */ +function statusBar(ctx, mode) { + const node = frame(ctx, `Mode=${mode}`, { component: true, dir: "HORIZONTAL", width: WINDOW_W, px: 10, py: 3, + align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side }); + topBorder(node, COLORS.border); + const left = append(node, frame(ctx, "Left", { dir: "HORIZONTAL", gap: 12, align: "CENTER" })); + for (const [iconName, label] of [["git-branch", "master"], ["circle-x", "0"], ["triangle-alert", "0"]]) { + const item = append(left, frame(ctx, `Item / ${iconName}`, { dir: "HORIZONTAL", gap: 4, align: "CENTER" })); + append(item, icon(ctx, iconName, COLORS.fg, 14)); + text(ctx, item, "Label", label, { hug: true, size: 12, lineHeight: 16 }); + } + const right = append(node, frame(ctx, "Right", { dir: "HORIZONTAL", gap: 16, align: "CENTER" })); + const items = mode === "edit" ? ["行 23,列 1", "空格: 2", "UTF-8", "LF", "TypeScript"] : ["代码阅读"]; + for (const label of items) text(ctx, right, `Item / ${label}`, label, { hug: true, size: 12, lineHeight: 16 }); + return node; +} + +/** Editor / TitleBar — File=<文件>, Mode=edit | preview:标签页 + 右上角的编辑 / 预览切换 */ +function editorTitle(ctx, lib, fileKey, mode) { + const file = FILES[fileKey]; + const node = frame(ctx, `File=${fileKey}, Mode=${mode}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W, + align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side }); + const tab = append(node, frame(ctx, "Tab", { dir: "HORIZONTAL", px: 12, py: 9, gap: 6, align: "CENTER", fill: COLORS.editor })); + topBorder(tab, COLORS.focus); + text(ctx, tab, "Ext", EXT_LABEL[file.ext], { hug: true, medium: true, size: 8, lineHeight: 10, color: COLORS[file.ext] }); + text(ctx, tab, "Name", file.name, { hug: true, size: 13, lineHeight: 17, color: COLORS.fgStrong }); + append(tab, icon(ctx, "x", COLORS.fg, 14)); + const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 10, align: "CENTER" })); + actions.paddingRight = 10; + const toggle = lib.toggle.get(mode).createInstance(); + toggle.name = "Mode toggle"; + actions.appendChild(toggle); + append(actions, icon(ctx, "columns-2", COLORS.fg, 16)); + append(actions, icon(ctx, "ellipsis", COLORS.fg, 16)); + return node; +} + +/** Editor / Breadcrumb — File=<文件> */ +function breadcrumb(ctx, fileKey) { + const file = FILES[fileKey]; + const node = frame(ctx, `File=${fileKey}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W, px: 16, py: 3, gap: 4, + align: "CENTER", fill: COLORS.editor }); + file.crumbs.forEach((part, index) => { + text(ctx, node, `Crumb / ${part}`, part, { hug: true, size: 12, lineHeight: 16, color: COLORS.desc }); + if (index < file.crumbs.length - 1) append(node, icon(ctx, "chevron-right", COLORS.desc, 12)); + }); + return node; +} + +function createComponents(ctx, page) { + const sets = []; + const lib = {}; + const make = (name, entries, columns, build, node = (item) => item) => { + const map = new Map(entries.map((entry) => [entry, build(entry)])); + sets.push(componentSet(ctx, page, name, [...map.values()].map(node), columns)); + return map; + }; + const fileKeys = Object.keys(FILES); + lib.toggle = make("Reader / ModeToggle", MODES, 2, (mode) => modeToggle(ctx, mode)); + lib.block = make("Reader / CodeBlock", BLOCK_VARIANTS.map(([kind, state]) => `${kind}/${state}`), 2, + (entry) => codeBlock(ctx, ...entry.split("/")), (item) => item.node); + lib.row = make("Explorer / TreeRow", ROW_VARIANTS.map(([type, state]) => `${type}/${state}`), 2, + (entry) => treeRow(ctx, ...entry.split("/"))); + lib.explorerHeader = make("Explorer / Header", ["default"], 1, () => explorerHeader(ctx)); + lib.windowTitle = make("Window / TitleBar", ["active"], 1, () => windowTitle(ctx)); + lib.activity = make("Window / ActivityBar", ["reader"], 1, () => activityBar(ctx)); + lib.status = make("Window / StatusBar", MODES, 1, (mode) => statusBar(ctx, mode)); + lib.editorTitle = make("Editor / TitleBar", fileKeys.flatMap((fileKey) => MODES.map((mode) => `${fileKey}/${mode}`)), 2, + (entry) => editorTitle(ctx, lib, ...entry.split("/"))); + lib.breadcrumb = make("Editor / Breadcrumb", fileKeys, 1, (fileKey) => breadcrumb(ctx, fileKey)); + lib.tree = make("Reader / BlockTree", fileKeys, 2, (fileKey) => blockTree(ctx, lib, fileKey), (item) => item.node); + return { lib, sets }; +} + +// ─── Boards ────────────────────────────────────────────────────────────────── + +function rowVariant(item, selected) { + if (item.type === "folder") return `folder/${item.open ? "open" : "closed"}`; + const isSelected = item.file === selected; + if (item.git) return isSelected ? "file/modified-selected" : "file/modified"; + return isSelected ? "file/selected" : "file/default"; +} + +/** 侧栏:「代码阅读」视图,外观照搬 VS Code 资源管理器。 */ +function sidebar(ctx, parent, lib, selected) { + const pane = append(parent, frame(ctx, "Sidebar", { width: SIDEBAR_W, fill: COLORS.side })); + pane.layoutSizingVertical = "FILL"; + pane.clipsContent = true; + pane.overflowDirection = "VERTICAL"; + const header = lib.explorerHeader.get("default").createInstance(); + header.name = "Header"; + append(pane, header, true); + const root = filled(ctx, pane, "Root", { dir: "HORIZONTAL", py: 3, gap: 6, align: "CENTER" }); + root.paddingLeft = 8; + append(root, icon(ctx, "chevron-down", COLORS.fg, 16)); + text(ctx, root, "Name", "superwork", { hug: true, medium: true, size: 13, lineHeight: 16, color: COLORS.fgStrong }); + const rows = new Map(); + for (const item of TREE) { + const instance = lib.row.get(rowVariant(item, selected)).createInstance(); + instance.name = `Row / ${item.name}`; + append(pane, instance, true); + instance.paddingLeft = 8 + item.depth * 14; + setText(instance, "Name", item.name); + if (item.type === "file") { + const ext = item.name.split(".").pop(); + const label = named(instance, "Ext label"); + label.characters = EXT_LABEL[ext] || ""; + label.fills = paint(COLORS[ext] || COLORS.desc); + } + if (item.file) rows.set(item.file, instance); + } + return { pane, rows }; +} + +/** 编辑模式:VS Code 原生编辑器,整份源码。 */ +function codeView(ctx, editor, fileKey) { + const view = filled(ctx, editor, "Code view", { dir: "HORIZONTAL", py: 4, gap: 22 }); + view.layoutSizingVertical = "FILL"; + view.clipsContent = true; + view.overflowDirection = "VERTICAL"; + const lines = SOURCES[fileKey].map((line) => line || " "); + const numbers = append(view, frame(ctx, "Line numbers", { width: 48 })); + text(ctx, numbers, "No", lines.map((_, index) => String(index + 1)).join("\n"), { size: 13, lineHeight: 19, mono: true, align: "RIGHT", color: COLORS.dim }); + const source = text(ctx, view, "Source", lines.join("\n"), { hug: true, font: ctx.fonts.mono, size: 13, lineHeight: 19, color: COLORS.code }); + paintRanges(source, highlightCode(source.characters)); + useCjkFont(ctx, source); + return view; +} + +/** 预览模式:编辑区换成代码块树。 */ +function previewView(ctx, editor, lib, fileKey, editorWidth) { + const view = filled(ctx, editor, "Preview view", { align: "CENTER", px: VIEW_PAD }); + view.layoutSizingVertical = "FILL"; + view.clipsContent = true; + view.overflowDirection = "VERTICAL"; + const tree = lib.tree.get(fileKey).node.createInstance(); + tree.name = `Block tree / ${FILES[fileKey].name}`; + append(view, tree, editorWidth - 2 * VIEW_PAD < TREE_W); + return view; +} + +/** 一整个 VS Code 窗口:标题栏 / 活动栏 + 侧栏 + 编辑器 / 状态栏。 */ +function windowBoard(ctx, page, lib, name, o) { + const width = o.width || WINDOW_W; + const editorWidth = width - ACTIVITY_W - SIDEBAR_W - 1; + const screen = frame(ctx, `Prototype / ${name}`, { width, height: WINDOW_H, fill: COLORS.editor }); + screen.clipsContent = true; + page.appendChild(screen); + const title = lib.windowTitle.get("active").createInstance(); + title.name = "Title bar"; + append(screen, title, true); + setText(title, "Title", `${FILES[o.file].name} — superwork`); + const main = append(screen, frame(ctx, "Main", { dir: "HORIZONTAL", width, height: MAIN_H })); + const activity = lib.activity.get("reader").createInstance(); + activity.name = "Activity bar"; + main.appendChild(activity); + activity.layoutSizingVertical = "FILL"; + const side = sidebar(ctx, main, lib, o.file); + divider(ctx, main, { vertical: true }); + const editor = append(main, frame(ctx, "Editor", { width: editorWidth, fill: COLORS.editor })); + editor.layoutSizingVertical = "FILL"; + const titleBar = lib.editorTitle.get(`${o.file}/${o.mode}`).createInstance(); + titleBar.name = "Editor title"; + append(editor, titleBar, true); + const crumbs = lib.breadcrumb.get(o.file).createInstance(); + crumbs.name = "Breadcrumb"; + append(editor, crumbs, true); + const view = o.mode === "edit" ? codeView(ctx, editor, o.file) : previewView(ctx, editor, lib, o.file, editorWidth); + const status = lib.status.get(o.mode).createInstance(); + status.name = "Status bar"; + append(screen, status, true); + return { screen, rows: side.rows, titleBar, view }; +} + +// ─── Entry ─────────────────────────────────────────────────────────────────── + +async function build(api, stage = () => {}) { + stage("加载中文字体"); + const ctx = { api, fonts: await chooseFonts(api) }; + stage("新建独立页面"); + const name = nextPageName(api.root.children); + const page = api.createPage(); + page.name = name; + await api.setCurrentPageAsync(page); + page.backgrounds = paint("#111214"); + page.setSharedPluginData(NAMESPACE, "generator", GENERATOR); + + stage("生成组件"); + const components = createComponents(ctx, page); + const { lib } = components; + + stage("生成演示画板"); + const boards = {}; + for (const [fileKey, file] of Object.entries(FILES)) { + boards[fileKey] = { + edit: windowBoard(ctx, page, lib, `${file.name} · 编辑`, { file: fileKey, mode: "edit" }), + preview: windowBoard(ctx, page, lib, `${file.name} · 预览`, { file: fileKey, mode: "preview" }), + }; + } + const narrow = windowBoard(ctx, page, lib, "envLock.ts · 预览(编辑区变窄)", { file: "envLock", mode: "preview", width: WINDOW_NARROW }); + + stage("连接演示入口"); + // 代码块:「编辑」换成源码,「预览」换回译文 + for (const kind of Object.keys(BLOCK_KINDS)) { + const preview = lib.block.get(`${kind}/preview`); + const edit = lib.block.get(`${kind}/edit`); + await connect(preview.toggle, edit.node, "CHANGE_TO"); + await connect(edit.toggle, preview.node, "CHANGE_TO"); + } + // 窗口之间:右上角切编辑 / 预览;侧栏点别的文件就用编辑器打开它 + for (const [fileKey, pair] of Object.entries(boards)) { + await connect(named(pair.edit.titleBar, "Seg / 预览"), pair.preview.screen, "NAVIGATE"); + await connect(named(pair.preview.titleBar, "Seg / 编辑"), pair.edit.screen, "NAVIGATE"); + for (const view of [pair.edit, pair.preview]) { + for (const [other, target] of Object.entries(boards)) { + if (other !== fileKey) await connect(view.rows.get(other), target.edit.screen, "NAVIGATE"); + } + } + } + + stage("排布画板"); + const screens = [...Object.values(boards).flatMap((pair) => [pair.edit.screen, pair.preview.screen]), narrow.screen]; + let y = 0; + for (const screen of screens) { + screen.x = 0; + screen.y = y; + y += screen.height + 120; + } + let setY = 0; + for (const set of components.sets) { + set.x = WINDOW_W + 160; + set.y = setY; + setY += set.height + 64; + } + const start = boards.envLock.preview.screen; + page.flowStartingPoints = [{ nodeId: start.id, name: "预览 envLock.ts" }]; + page.selection = [start]; + api.viewport.scrollAndZoomIntoView(page.selection); + return { page, components, boards, narrow, screens }; +} + +async function run(api) { + let currentStage = "初始化"; + const notice = api.notify("正在生成原型,请稍候…", { timeout: Infinity }); + await new Promise((resolve) => setTimeout(resolve, 50)); + try { + await build(api, (stage) => { currentStage = stage; }); + notice.cancel(); + api.closePlugin("已生成「代码阅读」的组件与演示画板;选中「Prototype / envLock.ts · 预览」后点演示。"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + notice.cancel(); + api.closePlugin(`生成失败(${currentStage}):${message}。已有设计未删除;本次未完成页面保留供检查。`); + } +} + +if (typeof module !== "undefined") { + module.exports = { build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode }; +} +if (typeof figma !== "undefined") void run(figma); diff --git a/docs/prototype/code-reader/figma-plugin/manifest.json b/docs/prototype/code-reader/figma-plugin/manifest.json new file mode 100644 index 0000000..ad1d4da --- /dev/null +++ b/docs/prototype/code-reader/figma-plugin/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "代码阅读器 Design Lab", + "api": "1.0.0", + "main": "code.js", + "editorType": ["figma"], + "documentAccess": "dynamic-page", + "networkAccess": { + "allowedDomains": ["none"] + } +} diff --git a/docs/prototype/code-reader/figma-plugin/test.js b/docs/prototype/code-reader/figma-plugin/test.js new file mode 100644 index 0000000..a2b0b9b --- /dev/null +++ b/docs/prototype/code-reader/figma-plugin/test.js @@ -0,0 +1,257 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); +const { build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode } = require("./code.js"); +const { fakeFigma } = require("../../figma-lab/fake-figma"); + +function action(node) { return node.reactions[0].actions[0]; } +function overlaps(a, b) { + return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; +} +function named(root, name) { return root.findOne((node) => node.name === name); } +function countBlocks(nodes) { + let count = 0; + walkTree(nodes, () => { count += 1; }); + return count; +} + +// 代码块:13 种各「编辑 ↔ 预览」2 条 = 26 +// 窗口:2 个文件 × 编辑 / 预览各 1 个切换 = 4;4 个窗口里侧栏点另一个文件 = 4;合计 34 +const EXPECTED_LINKS = 34; + +const EXPECTED_SETS = [ + ["Reader / ModeToggle", 2], + ["Reader / CodeBlock", 26], + ["Explorer / TreeRow", 6], + ["Explorer / Header", 1], + ["Window / TitleBar", 1], + ["Window / ActivityBar", 1], + ["Window / StatusBar", 2], + ["Editor / TitleBar", 4], + ["Editor / Breadcrumb", 2], + ["Reader / BlockTree", 2], +]; + +test("manifest stays local, without a fabricated plugin ID", () => { + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "manifest.json"), "utf8")); + assert.equal(manifest.main, "code.js"); + assert.equal(manifest.api, "1.0.0"); + assert.equal(manifest.documentAccess, "dynamic-page"); + assert.deepEqual(manifest.editorType, ["figma"]); + assert.deepEqual(manifest.networkAccess.allowedDomains, ["none"]); + assert.equal("id" in manifest, false); +}); + +test("code block trees cover every meaningful source line exactly once", () => { + assert.equal(SOURCES.envLock.length, 61); + assert.equal(SOURCES.issueLock.length, 25); + for (const [fileKey, nodes] of Object.entries(CODE_TREE)) { + const owner = new Map(); + walkTree(nodes, (node) => { + const [from, to] = lineRange(node.at); + assert.ok(from <= to && to <= SOURCES[fileKey].length, `${fileKey} L${from}-${to} in range`); + assert.ok(blockCode(fileKey, node.at).trim(), `${fileKey} L${from} has code`); + assert.ok(BLOCK_KINDS[node.kind], `${fileKey} L${from} kind ${node.kind}`); + for (let line = from; line <= to; line += 1) { + assert.equal(owner.has(line), false, `${fileKey} L${line} belongs to one block`); + owner.set(line, node); + } + }); + SOURCES[fileKey].forEach((line, index) => { + if (!owner.has(index + 1)) assert.match(line.trim(), /^[}\])]*$/, `${fileKey} L${index + 1} left out: ${line}`); + }); + } + // 子块在源码里落在父块之后、兄弟块之前,树的顺序就是阅读顺序 + for (const nodes of Object.values(CODE_TREE)) { + let last = 0; + walkTree(nodes, (node) => { + const [from] = lineRange(node.at); + assert.ok(from > last, `L${from} follows L${last}`); + last = lineRange(node.at)[1]; + }); + } + assert.equal(blockCode("envLock", 25), "if (!folder)"); + assert.equal(blockCode("envLock", 46), "catch (err) {"); + assert.equal(blockCode("issueLock", [20, 24]).split("\n")[1], " locked: true,"); +}); + +test("template translation keeps the source structure and only translates words", () => { + const at = (fileKey, line) => { + let found; + walkTree(CODE_TREE[fileKey], (node) => { if (lineRange(node.at)[0] === line) found = node; }); + return translateBlock(fileKey, found); + }; + assert.equal(at("envLock", 23).text, "公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>"); + assert.equal(at("envLock", 38).text, "异步 函数 chmodAll(workspaceRoot: 字符串, mode: 数值): Promise"); + assert.equal(at("envLock", 12).text, "导入 { promises 作为 fsp } 来自 'node:fs'"); + assert.equal(at("envLock", 25).text, "如果 (非 folder)"); + assert.equal(at("envLock", 26).text, "返回 空数组"); + assert.equal(at("envLock", 41).text, "遍历 (常量 f 取自 files)"); + assert.equal(at("envLock", 35).text, "failed: { path: 字符串, reason: 字符串 }[]"); + assert.equal(at("envLock", 53).text, "Set every `.env*` file in the workspace to 0o444 (read-only)."); + assert.equal(at("issueLock", 14).text, "常量 issue = issues.find(i => i.number 全等 issueNumber)"); + assert.equal(at("issueLock", 20).text, "返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }"); + + // 每个词单独上色,关键字不和前后的字粘在一起 + const signature = at("envLock", 23); + const words = signature.ranges.map((range) => [signature.text.slice(range.start, range.end), range.color]); + assert.deepEqual(words.slice(0, 4), [["公共", "#c586c0"], ["异步", "#569cd6"], ["函数", "#569cd6"], ["findEnvFiles", "#dcdcaa"]]); + for (const [fileKey, nodes] of Object.entries(CODE_TREE)) { + walkTree(nodes, (node) => { + if (node.kind === "comment") return; + const { text, ranges } = translateBlock(fileKey, node); + assert.ok(ranges.length > 0, `${fileKey} L${lineRange(node.at)[0]} has colored words`); + for (const range of ranges) { + for (const neighbor of [text[range.start - 1], text[range.end]]) { + assert.doesNotMatch(neighbor || " ", /[\u4e00-\u9fff\w]/, `${fileKey} "${text}" keeps "${text.slice(range.start, range.end)}" apart`); + } + } + }); + } + + const code = "const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)"; + const colorOf = (word) => highlightCode(code).find((range) => code.slice(range.start, range.end) === word).color; + assert.equal(colorOf("const"), "#569cd6"); + assert.equal(colorOf("await"), "#c586c0"); + assert.equal(colorOf("findFiles"), "#dcdcaa"); + assert.equal(colorOf("EXCLUDE_PATTERN"), "#4ec9b0"); + assert.equal(colorOf("uris"), "#9cdcfe"); +}); + +test("sample files appear in the sidebar tree", () => { + for (const key of Object.keys(FILES)) assert.equal(TREE.filter((row) => row.file === key).length, 1); + assert.deepEqual(Object.keys(CODE_TREE), Object.keys(FILES)); +}); + +test("build creates the variant sets, reuses them on boards and wires the flows", async () => { + const api = fakeFigma(); + const result = await build(api); + assert.equal(result.page.name, PAGE_NAME); + assert.notEqual(result.page.getSharedPluginData(NAMESPACE, "generator"), ""); + assert.equal(api.connected, EXPECTED_LINKS); + + const sets = result.page.children.filter((node) => node.type === "COMPONENT_SET"); + assert.deepEqual(sets.map((set) => [set.name, set.children.length]), EXPECTED_SETS); + + // 代码块:预览显示大白话,编辑显示源码;按钮在两种状态间切换 + const { lib } = result.components; + for (const kind of Object.keys(BLOCK_KINDS)) { + const preview = lib.block.get(`${kind}/preview`); + const edit = lib.block.get(`${kind}/edit`); + assert.equal(named(preview.node, "Plain").visible, true); + assert.equal(named(preview.node, "Code").visible, false); + assert.equal(named(edit.node, "Plain").visible, false); + assert.equal(named(edit.node, "Code").visible, true); + assert.equal(action(preview.toggle).navigation, "CHANGE_TO"); + assert.equal(action(preview.toggle).destinationId, edit.node.id); + assert.equal(action(edit.toggle).destinationId, preview.node.id); + } + + // 代码块树:块数和数据一致,嵌套一层多一条竖线,默认「编辑」的块用编辑状态 + for (const [fileKey, nodes] of Object.entries(CODE_TREE)) { + const { node, placed } = lib.tree.get(fileKey); + assert.equal(placed.length, countBlocks(nodes)); + assert.equal(node.findAll((child) => child.type === "INSTANCE").length, placed.length); + const maxDepth = Math.max(...placed.map((entry) => entry.depth)); + assert.equal(node.findAll((child) => child.name === "Guide").length, 1 + placed.filter((entry) => entry.item.kids).length); + assert.ok(maxDepth >= 2, `${fileKey} nests at least three levels`); + for (const { item, instance } of placed) { + const variant = lib.block.get(`${item.kind}/${item.edit ? "edit" : "preview"}`).node; + assert.equal(instance.mainComponentId, variant.id); + const parsed = translateBlock(fileKey, item); + assert.equal(named(instance, "Plain").characters, parsed.text); + assert.equal(named(instance, "Plain").rangeFills.length, parsed.ranges.length); + assert.equal(named(instance, "Code").characters, blockCode(fileKey, item.at)); + assert.ok(named(instance, "Code").rangeFills.length > 0, "code is highlighted"); + assert.equal(named(instance, "Fold icon").visible, Boolean(item.kids), "only blocks with children show the fold arrow"); + assert.equal(named(instance, "Kind").characters, BLOCK_KINDS[item.kind].label); + } + } + const issueComment = lib.tree.get("issueLock").placed.find(({ item }) => item.kind === "comment").instance; + assert.ok(named(issueComment, "Code").rangeFonts.length >= 2, "Chinese comment lines use the CJK font"); + + const { envLock, issueLock } = result.boards; + const toPreview = action(named(envLock.edit.titleBar, "Seg / 预览")); + assert.equal(toPreview.navigation, "NAVIGATE"); + assert.equal(toPreview.destinationId, envLock.preview.screen.id); + assert.equal(toPreview.resetInteractiveComponents, true); + assert.equal(action(named(envLock.preview.titleBar, "Seg / 编辑")).destinationId, envLock.edit.screen.id); + assert.equal(action(named(issueLock.edit.titleBar, "Seg / 预览")).destinationId, issueLock.preview.screen.id); + // 侧栏点文件 = 用编辑器打开它 + assert.equal(action(envLock.edit.rows.get("issueLock")).destinationId, issueLock.edit.screen.id); + assert.equal(action(envLock.preview.rows.get("issueLock")).destinationId, issueLock.edit.screen.id); + assert.equal(action(issueLock.preview.rows.get("envLock")).destinationId, envLock.edit.screen.id); + assert.equal(envLock.edit.rows.get("envLock").reactions.length, 0); + assert.equal(result.narrow.screen.findAll((node) => node.reactions.length > 0).length, 0); + + const setIds = new Set(sets.flatMap((set) => set.children.map((component) => component.id))); + for (const screen of result.screens) { + const instances = screen.findAll((node) => node.type === "INSTANCE"); + assert.ok(instances.length > 0, `${screen.name} has instances`); + for (const instance of instances) assert.ok(setIds.has(instance.mainComponentId), `${instance.name} comes from a set`); + } + // 编辑窗口显示整份源码,预览窗口显示代码块树 + assert.equal(named(envLock.edit.view, "Source").characters.split("\n").length, 61); + assert.equal(named(issueLock.edit.view, "Source").characters.split("\n").length, 25); + assert.ok(named(issueLock.edit.view, "Source").rangeFonts.length >= 2); + assert.equal(named(envLock.preview.view, "Block tree / envLock.ts").mainComponentId, lib.tree.get("envLock").node.id); + assert.equal(named(result.narrow.view, "Block tree / envLock.ts").mainComponentId, lib.tree.get("envLock").node.id); + assert.equal(named(issueLock.preview.view, "Block tree / issueLock.ts").mainComponentId, lib.tree.get("issueLock").node.id); +}); + +test("boards and sets sit on the page without overlapping", async () => { + const api = fakeFigma(); + const result = await build(api); + const blocks = [...result.screens, ...result.components.sets]; + for (const block of blocks) assert.equal(block.parent, result.page); + for (let i = 0; i < blocks.length; i += 1) { + for (let j = i + 1; j < blocks.length; j += 1) { + assert.equal(overlaps(blocks[i], blocks[j]), false, `${blocks[i].name} overlaps ${blocks[j].name}`); + } + } + assert.equal(result.page.flowStartingPoints[0].nodeId, result.boards.envLock.preview.screen.id); +}); + +test("the plugin stays small enough for Figma to build quickly", async () => { + const api = fakeFigma(); + const result = await build(api); + let owned = 0; + const walk = (node) => { + owned += 1; + if (node.type !== "INSTANCE") node.children.forEach(walk); + }; + result.page.children.forEach(walk); + assert.ok(owned < 1500, `page owns ${owned} layers outside instances`); +}); + +test("running twice numbers the new page and keeps the old one intact", async () => { + const api = fakeFigma(); + const first = await build(api); + const title = named(first.boards.envLock.edit.screen, "Name"); + const before = title.characters; + title.characters = "用户改过的标题"; + const second = await build(api); + assert.equal(second.page.name, `${PAGE_NAME} 2`); + assert.equal(title.characters, "用户改过的标题"); + assert.notEqual(before, "用户改过的标题"); + assert.equal(nextPageName(api.root.children), `${PAGE_NAME} 3`); +}); + +test("missing CJK font fails before any page is created", async () => { + const api = fakeFigma({ fonts: [{ family: "Inter", style: "Regular" }] }); + const pages = api.root.children.length; + await run(api); + assert.equal(api.root.children.length, pages); + assert.match(api.messages[0], /加载中文字体/); +}); + +test("page limit and rejected reactions surface the failing stage", async () => { + const limited = fakeFigma({ rejectPages: true }); + await run(limited); + assert.match(limited.messages[0], /新建独立页面/); + + const rejected = fakeFigma({ rejectReactions: true }); + await run(rejected); + assert.match(rejected.messages[0], /连接演示入口/); +}); diff --git a/docs/prototype/figma-lab/fake-figma.js b/docs/prototype/figma-lab/fake-figma.js new file mode 100644 index 0000000..9a4ce0a --- /dev/null +++ b/docs/prototype/figma-lab/fake-figma.js @@ -0,0 +1,311 @@ +const assert = require("node:assert/strict"); + +const LAYOUT_KEYS = [ + "layoutMode", "layoutPositioning", "visible", + "primaryAxisSizingMode", "counterAxisSizingMode", + "paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "itemSpacing", + "fontSize", "lineHeight", "textAutoResize", "maxLines", +]; +const SKIP_COPY = new Set(["id", "parent", "children", "_rev", "_cachedRev", "_val", "_mark", "_flowRev", "_flowCache"]); + +// 模拟 Figma 文档树与自动排版的尺寸推算, 检查结构、变体配对、实例复用与连线; +// 不模拟真实字体度量、绘制和演示引擎. +// 尺寸记在节点上. 改一个节点只作废它、祖先、以及祖先底下靠父尺寸吃饭的 FILL 孩子. +function fakeFigma(options = {}) { + let sequence = 0; + let stamp = 0; + const nodes = new Map(); + const loaded = new Set(); + const mark = (node, token) => { + if (!node || node._mark === token) return; + node._mark = token; + node._rev += 1; + for (const child of node.children) { + if (child._horizontal === "FILL" || child._vertical === "FILL") mark(child, token); + } + mark(node.parent, token); + }; + const dirty = (node) => { mark(node, ++stamp); }; + const fontKey = (font) => `${font.family}/${font.style}`; + const cjk = /[ -鿿＀-￯]/; + const textWidth = (node) => { + let width = 0; + for (const char of node._characters) width += cjk.test(char) ? node.fontSize : node.fontSize * 0.55; + return Math.max(1, Math.round(width)); + }; + const padX = (node) => node.paddingLeft + node.paddingRight; + const padY = (node) => node.paddingTop + node.paddingBottom; + const flow = (node) => { + if (node._flowRev === node._rev) return node._flowCache; + node._flowCache = node.children.filter((child) => child.visible && child.layoutPositioning !== "ABSOLUTE"); + node._flowRev = node._rev; + return node._flowCache; + }; + const gaps = (node) => Math.max(0, flow(node).length - 1) * node.itemSpacing; + + class Node { + constructor(type) { + this.id = String(++sequence); + this.type = type; + this.name = type; + this.children = []; + this.parent = null; + this.visible = true; + this.layoutMode = "NONE"; + this.layoutPositioning = "AUTO"; + this.primaryAxisSizingMode = this.counterAxisSizingMode = "FIXED"; + this.paddingLeft = this.paddingRight = this.paddingTop = this.paddingBottom = 0; + this.itemSpacing = 0; + this.x = this.y = 0; + this._width = this._height = 100; + this._characters = ""; + this._horizontal = this._vertical = "FIXED"; + this.fontSize = 14; + this.lineHeight = { unit: "PIXELS", value: 20 }; + this.textAutoResize = "NONE"; + this.maxLines = undefined; + this.data = {}; + this.reactions = []; + this.rangeFills = []; + this.rangeFonts = []; + this.flowStartingPoints = []; + this._rev = 0; + this._mark = 0; + this._cachedRev = { w: -1, h: -1, iw: -1, ih: -1, mw: -1, mh: -1 }; + this._val = { w: 0, h: 0, iw: 0, ih: 0, mw: 0, mh: 0 }; + this._flowRev = -1; + this._flowCache = []; + for (const key of LAYOUT_KEYS) { + let value = this[key]; + Object.defineProperty(this, key, { + get() { return value; }, + set(next) { if (value !== next) { value = next; dirty(this); } }, + enumerable: true, + configurable: true, + }); + } + nodes.set(this.id, this); + } + _memo(slot, compute) { + if (this._cachedRev[slot] === this._rev) return this._val[slot]; + this._cachedRev[slot] = this._rev; + const value = compute(); + this._val[slot] = value; + api.layoutComputes += 1; + return value; + } + get intrinsicWidth() { + return this._memo("iw", () => { + if (this.type === "TEXT" && this.textAutoResize === "WIDTH_AND_HEIGHT") return textWidth(this); + if (this.layoutMode === "HORIZONTAL" && this.primaryAxisSizingMode === "AUTO") { + return padX(this) + flow(this).reduce((sum, child) => sum + child.measuredWidth, 0) + gaps(this); + } + if (this.layoutMode === "VERTICAL" && this.counterAxisSizingMode === "AUTO") { + return padX(this) + Math.max(0, ...flow(this).map((child) => child.measuredWidth)); + } + return this._width; + }); + } + get measuredWidth() { + return this._memo("mw", () => (this._horizontal === "FILL" ? this.intrinsicWidth : this.width)); + } + get width() { + return this._memo("w", () => { + if (this._horizontal !== "FILL" || !this.parent) return this.intrinsicWidth; + const parent = this.parent; + const fixed = parent.layoutMode === "HORIZONTAL" ? parent.primaryAxisSizingMode : parent.counterAxisSizingMode; + assert.equal(fixed, "FIXED", `FILL width needs a fixed-width parent: ${this.name} in ${parent.name}`); + const available = parent.width - padX(parent); + if (parent.layoutMode === "VERTICAL") return available; + const siblings = flow(parent); + const fills = siblings.filter((child) => child._horizontal === "FILL"); + const taken = siblings.filter((child) => child._horizontal !== "FILL").reduce((sum, child) => sum + child.width, 0); + return (available - taken - gaps(parent)) / fills.length; + }); + } + get intrinsicHeight() { + return this._memo("ih", () => { + if (this.type === "TEXT") { + if (this.textAutoResize === "WIDTH_AND_HEIGHT") return this.lineHeight.value; + if (this.textAutoResize === "HEIGHT") { + const lines = Math.max(1, Math.ceil(textWidth(this) / Math.max(1, this.width))); + return Math.min(lines, this.maxLines || Infinity) * this.lineHeight.value; + } + } + if (this.layoutMode === "VERTICAL" && this.primaryAxisSizingMode === "AUTO") { + return padY(this) + flow(this).reduce((sum, child) => sum + child.measuredHeight, 0) + gaps(this); + } + if (this.layoutMode === "HORIZONTAL" && this.counterAxisSizingMode === "AUTO") { + return padY(this) + Math.max(0, ...flow(this).map((child) => child.measuredHeight)); + } + return this._height; + }); + } + get measuredHeight() { + return this._memo("mh", () => (this._vertical === "FILL" ? this.intrinsicHeight : this.height)); + } + get height() { + return this._memo("h", () => { + if (this._vertical !== "FILL" || !this.parent) return this.intrinsicHeight; + const parent = this.parent; + const available = parent.height - padY(parent); + if (parent.layoutMode === "HORIZONTAL") return available; + const siblings = flow(parent); + const fills = siblings.filter((child) => child._vertical === "FILL"); + const taken = siblings.filter((child) => child._vertical !== "FILL").reduce((sum, child) => sum + child.height, 0); + return (available - taken - gaps(parent)) / fills.length; + }); + } + set layoutSizingHorizontal(value) { + if (value === "FILL") assert.ok(["HORIZONTAL", "VERTICAL"].includes(this.parent?.layoutMode), `FILL needs an auto-layout parent: ${this.name}`); + if (this._horizontal !== value) { this._horizontal = value; dirty(this); } + } + get layoutSizingHorizontal() { return this._horizontal; } + set layoutSizingVertical(value) { + if (value === "FILL") assert.ok(["HORIZONTAL", "VERTICAL"].includes(this.parent?.layoutMode), `FILL needs an auto-layout parent: ${this.name}`); + if (this._vertical !== value) { this._vertical = value; dirty(this); } + } + get layoutSizingVertical() { return this._vertical; } + set characters(value) { + assert.ok(this.fontName && loaded.has(fontKey(this.fontName)), `Load font before changing text: ${this.name}`); + if (this._characters !== value) { this._characters = value; dirty(this); } + } + get characters() { return this._characters; } + setRangeFills(start, end, fills) { + assert.ok(this.type === "TEXT" && start >= 0 && start < end && end <= this._characters.length, `Text range out of bounds: ${this.name}`); + this.rangeFills.push({ start, end, fills }); + } + setRangeFontName(start, end, font) { + assert.ok(this.type === "TEXT" && start >= 0 && start < end && end <= this._characters.length, `Text range out of bounds: ${this.name}`); + assert.ok(loaded.has(fontKey(font)), `Load font before setting a text range: ${this.name}`); + this.rangeFonts.push({ start, end, font }); + } + resize(width, height) { + assert.ok(width > 0 && height > 0 && Number.isFinite(width + height), `Node dimensions must be positive: ${this.name}`); + if (this._width !== width || this._height !== height) { this._width = width; this._height = height; dirty(this); } + } + appendChild(node) { + const previous = node.parent; + if (previous) previous.children.splice(previous.children.indexOf(node), 1); + node.parent = this; + this.children.push(node); + if (previous && previous !== this) dirty(previous); + dirty(this); + } + findAll(predicate) { + const found = []; + const walk = (node) => { + for (const child of node.children) { + if (predicate(child)) found.push(child); + walk(child); + } + }; + walk(this); + return found; + } + findOne(predicate) { + for (const node of this.children) { + if (predicate(node)) return node; + const found = node.findOne(predicate); + if (found) return found; + } + return null; + } + setSharedPluginData(namespace, key, value) { this.data[`${namespace}:${key}`] = value; } + getSharedPluginData(namespace, key) { return this.data[`${namespace}:${key}`] || ""; } + createInstance() { + assert.equal(this.type, "COMPONENT"); + const copy = (source) => { + const node = new Node(source.type); + for (const key of Object.keys(source)) { + if (SKIP_COPY.has(key)) continue; + node[key] = structuredClone(source[key]); + } + for (const child of source.children) node.appendChild(copy(child)); + return node; + }; + const instance = copy(this); + instance.type = "INSTANCE"; + instance.mainComponentId = this.id; + return instance; + } + async setReactionsAsync(reactions) { + await Promise.resolve(); + if (options.rejectReactions) throw new Error("reaction rejected"); + for (const reaction of reactions) { + const action = reaction.actions[0]; + const target = nodes.get(action.destinationId); + assert.ok(target, "Reaction destination exists"); + if (action.navigation === "NAVIGATE") { + assert.equal(target.type, "FRAME"); + assert.equal(target.parent.type, "PAGE", "Navigate only to a top-level frame"); + let top = this; + while (top.parent && top.parent.type !== "PAGE") top = top.parent; + assert.notEqual(target, top, "Navigate must leave the source's own top-level frame"); + } else { + assert.equal(action.navigation, "CHANGE_TO"); + let ancestor = this; + while (ancestor && ancestor.type !== "COMPONENT") ancestor = ancestor.parent; + assert.equal(ancestor?.parent?.type, "COMPONENT_SET"); + assert.equal(target.parent, ancestor.parent, "Change only to a sibling variant"); + } + } + this.reactions = structuredClone(reactions); + api.connected += 1; + } + } + + const api = { + nodes, connected: 0, loaded, messages: [], notices: [], layoutComputes: 0, + root: new Node("DOCUMENT"), + async listAvailableFontsAsync() { + return (options.fonts || [{ family: "Inter", style: "Regular" }, + { family: "Noto Sans SC", style: "Regular" }, { family: "Noto Sans SC", style: "Medium" }, + { family: "Fraunces", style: "SemiBold" }]).map((fontName) => ({ fontName })); + }, + async loadFontAsync(font) { loaded.add(fontKey(font)); }, + createPage() { + if (options.rejectPages) throw new Error("page limit reached"); + const page = new Node("PAGE"); + this.root.appendChild(page); + return page; + }, + async setCurrentPageAsync(page) { this.currentPage = page; }, + combineAsVariants(components, parent) { + const set = new Node("COMPONENT_SET"); + parent.appendChild(set); + for (const component of components) { + assert.equal(component.type, "COMPONENT"); + set.appendChild(component); + } + return set; + }, + viewport: { scrollAndZoomIntoView(selection) { assert.ok(selection.length > 0); } }, + closePlugin(message) { this.messages.push(message); }, + notify(message) { + this.notices.push(message); + return { cancel() {} }; + }, + }; + for (const type of ["Frame", "Component", "Text", "Vector", "Ellipse", "Rectangle"]) { + api[`create${type}`] = () => { + const node = new Node(type.toUpperCase()); + api.currentPage.appendChild(node); + return node; + }; + } + api.createNodeFromSvg = (svg) => { + const node = new Node("FRAME"); + const size = Number((svg.match(/width="([\d.]+)"/) || [])[1] || 24); + node._width = node._height = Math.max(size, 1); + api.currentPage.appendChild(node); + return node; + }; + const original = new Node("PAGE"); + original.name = "Existing design"; + api.root.appendChild(original); + api.currentPage = original; + return api; +} + +module.exports = { fakeFigma }; diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md new file mode 100644 index 0000000..961cbaf --- /dev/null +++ b/docs/superpowers/specs/code-reader/spec.md @@ -0,0 +1,263 @@ +# 代码阅读(Code Reader)设计 + +## 目标 + +代码几乎全由 agent 写,逐行读太慢。给插件加一个独立的「代码阅读」入口:打开一个源码文件时,可以切到「预览」,把代码拆成一棵代码块树,每块用模板直译成中文,扫一眼就知道这个文件在干什么;要看细节时单块切回源码,并且可以直接改。 + +它和现有的 Superpowers 看板(活动栏小火箭)是两套东西,只是放在同一个插件(`vscode/`)里。 + +视觉和交互以 Figma 原型为准:`docs/prototype/code-reader/figma-plugin/`(`code.js` 里的数据和 `test.js` 里的期望值就是本文的样例)。 + +## 已确认的决定 + +| 事项 | 决定 | +| --- | --- | +| 流程 | 源码 →(tree-sitter)语法树 → 通用代码块树 → 译文 | +| 第一版翻译 | 只做中文模板直译;AI 解读以后作为另一个翻译插件加 | +| 第一版语言 | TypeScript / TSX(含 JavaScript / JSX)、Python | +| 扩展方式 | 写代码注册:语言插件、翻译插件都实现约定接口,通过本插件 `activate()` 导出的 API 注册;内置实现也走同一个 API | +| 多种翻译怎么选 | 预览顶部下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | +| 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | +| 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | +| 折叠 | 有子块的块能折叠,默认全部展开 | +| 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠;不做新建 / 重命名 / 删除 | +| 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | + +## 用户可见行为 + +### 活动栏与侧栏 + +- 活动栏新增图标(书本,`book-open-text` 样式),视图容器标题「代码阅读」,里面一个视图「代码阅读」。 +- 视图是工作区文件树:每个工作区文件夹一个根节点(默认展开),目录在前、文件在后,名字不区分大小写排序;遵守 `files.exclude`。 +- 树节点用 `resourceUri`,图标跟随用户的文件图标主题,git 改动颜色(M / U)由 VS Code 自动加上,外观与自带资源管理器一致。 +- 视图标题栏:刷新、全部折叠(`showCollapseAll`)。文件变化时自动刷新(防抖)。 +- 点文件:语言受支持 → 用「代码阅读预览」自定义编辑器打开;否则 `vscode.open`。 + +### 编辑器右上角切换 + +- 受支持语言的文件:普通编辑器里标题栏显示「预览」,预览里显示「编辑」。点一下在**同一个标签页位置**换成另一种:用 VS Code 内部的 `reopenActiveEditorWith`(「重新打开编辑器的方式」背后的机制)原位替换。不能用「另开再关原标签」:文件有未保存改动时关原标签会弹保存框,选「不保存」会丢改动。 +- 用 `editor/title` 的 `navigation` 组、不带图标的命令,让按钮显示文字。若实测 VS Code 不渲染文字,改成带图标(`$(open-preview)` / `$(go-to-file)`),标题保持「预览」「编辑」。VS Code 标题栏做不出原型里的分段控件,这是和原型唯一的有意差异。 +- 受支持语言 = 当前注册的语言插件覆盖的 `languageId`,通过 `setContext('superpowers.codeReader.languageIds', [...])` 提供给 when 子句(`resourceLangId in superpowers.codeReader.languageIds`)。 + +### 预览(代码块树) + +- 每一句是一块:导入、函数、类、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 +- 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 +- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签、内容、右侧切换按钮。 +- 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 +- 内容两种状态,每块单独切: + - 预览:译文,按词着色,等宽字体(中文部分用系统中文字体回退)。 + - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 +- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。 +- 顶部工具栏:翻译下拉框(多于一种翻译时才显示)。 +- 解析失败:显示错误原因,并提示用「编辑」查看源码。 + +## 通用契约 + +放在 `vscode/src/codeReader/contract.ts`,语法树类型用 `@vscode/tree-sitter-wasm` 自带的 web-tree-sitter 类型。 + +```ts +export type BlockKind = + | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable' + | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' + +/** 源码里的一段,行号从 0 开始,end 为开区间的字节偏移。 */ +export interface SourceSpan { startIndex: number, endIndex: number, startRow: number, endRow: number } + +export interface CodeBlock { + kind: BlockKind + /** 这一块对应的语法树节点(整条语句)。 */ + node: Node + /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 */ + header: SourceSpan + children: CodeBlock[] +} + +/** 着色用的词性,源码着色和译文着色共用。 */ +export type TokenRole = + | 'keyword' // 流程关键字:import from export if else for return try catch await … + | 'storage' // 声明关键字与字面量关键字:const function async class interface new true false null … + | 'operator' // 被翻成文字的运算符:全等 与 或 非 + | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' + +export interface Segment { text: string, role: TokenRole } + +/** 语言插件:语法树 → 代码块树。 */ +export interface LanguageAdapter { + id: string + /** 覆盖的 VS Code languageId,例如 ['typescript', 'typescriptreact', 'javascript', 'javascriptreact']。 */ + languageIds: string[] + /** 该 languageId 用的语法包 .wasm 的绝对路径(宿主负责加载,保证和解析器运行时是同一份)。 */ + grammarPath: (languageId: string) => string + toBlocks: (root: Node, source: string) => CodeBlock[] + /** 源码着色:给语法树叶子节点定词性。 */ + tokenRole: (leaf: Node) => TokenRole +} + +/** 翻译插件:代码块 → 译文。 */ +export interface Translator { + id: string // 'typescript.zh-template' + label: string // '中文直译' + languageIds: string[] + translate: (block: CodeBlock, source: string) => Segment[] +} + +export interface CodeReaderApi { + registerLanguage: (adapter: LanguageAdapter) => Disposable + registerTranslator: (translator: Translator) => Disposable +} +``` + +- `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 +- 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。 +- 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 + +## 解析运行时 + +- 依赖 `@vscode/tree-sitter-wasm`(VS Code 团队维护,包含 web-tree-sitter 运行时和 TypeScript / TSX / JavaScript / Python 语法包,版本配套)。 +- 打包:`esbuild.mjs` 构建时把 `tree-sitter.wasm` 和用到的语法包复制到 `dist/tree-sitter/`(`package.json` 的 `files` 已包含 `dist/**/*`)。 +- 运行时初始化一次 `Parser.init({ locateFile })` 指向 `dist/tree-sitter/tree-sitter.wasm`;语法包按路径懒加载并缓存。 +- 文件扩展名到语法包:`.ts` → typescript;`.tsx` → tsx;`.js/.jsx/.mjs/.cjs` → javascript(tree-sitter-javascript 支持 JSX);`.py` → python。以 `languageId` 为准。 + +## 代码块树规则 + +### TypeScript / JavaScript + +- 顶层与语句块内的每条语句一块;连续的行注释合成一块注释。 +- `import_statement` → import。 +- `export_statement` 包着声明 → 按声明定种类,节点取整个 `export_statement`(头部包含 `export`)。 +- `function_declaration` / `generator_function_declaration` / `method_definition` → function,子块来自主体语句。 +- 变量声明的唯一声明值是带语句块主体的箭头函数 / 函数表达式 → function(React 组件、回调常见写法),子块来自主体。 +- `lexical_declaration` / `variable_declaration`:模块顶层的 `const` → const,其余 → variable。 +- `class_declaration` → class,子块:方法 → function,字段 → field,注释 → comment。 +- `interface_declaration` → type,子块:属性 / 方法签名 → field;`type_alias_declaration` → type;`enum_declaration` → type,成员 → field。 +- `if_statement` → if,子块来自 consequence;`else` / `else if` 展平成紧跟其后的兄弟块 else(`else if` 的头部是 `else if (...)`,子块来自它的 consequence)。 +- `for` / `for…in` / `for…of` / `while` / `do` → loop。 +- `try_statement` → try;`catch_clause` → 兄弟块 catch;`finally_clause` → 兄弟块 finally。 +- `switch_statement` → if,每个 `case` / `default` → 子块 if。 +- `return_statement` → return;其余语句(含 `throw`)→ statement。 +- 表达式语句的参数里带语句块主体的函数(`useEffect(() => { … })`、`describe('x', () => { … })`)→ statement,子块来自那个函数主体,头部到主体开始为止。 + +### Python + +- `import_statement` / `import_from_statement` / `future_import_statement` → import。 +- `function_definition` → function;`decorated_definition` 按内部定义定种类,头部包含装饰器。 +- `class_definition` → class;类体里的带注解赋值 → field。 +- 模块 / 函数 / 类主体开头的字符串表达式(docstring)和 `comment` → comment。 +- 赋值:模块顶层且变量名全大写 → const,其余 → variable。 +- `if_statement` → if;`elif_clause` / `else_clause` → 兄弟块 else;`for` / `while` → loop(循环的 `else` → 兄弟块 else)。 +- `try_statement` → try;`except_clause` → catch;`finally_clause` → finally。 +- `with_statement` → statement,子块来自主体;`match_statement` → if,`case` → 子块 if。 +- `return_statement` → return;其余 → statement。 +- 复合语句头部到主体开始为止(包含行尾冒号,翻译时去掉)。 + +## 中文模板直译 + +结构照抄源码,只替换三类东西,其余(标识符、属性名、字面量、标点)原样保留并按词性着色: + +1. 关键字:只认语法树里的关键字节点(匿名节点),所以属性名 `i.number`、对象键不会被误翻。 +2. 内置类型:只在类型位置翻(TS 的 `predefined_type`、Python 注解里的内置类型名)。 +3. 运算符:`===` 全等、`!==` 不全等、`==` 等于、`!=` 不等于、`&&` 与、`||` 或、`!` 非(Python 的 `not` / `and` / `or` 本身是关键字)。 + +其他规则:空数组字面量 → 空数组(Python 空列表 → 空列表、空字典 → 空字典);复合语句头部去掉结尾的 `{` 或 `:`;多行合成一行(连续空白压成一个空格,右括号前的尾逗号去掉);注释去掉注释符号,原文照搬。关键字与前后内容之间保留源码里的空格(`!` 翻成「非」后补一个空格)。 + +TypeScript 关键字表(流程 = keyword,声明 = storage): + +| 流程 | 声明 / 字面量 | +| --- | --- | +| import 导入、from 来自、as 作为、export 公共、default 默认、if 如果、else 否则、for 遍历、of 取自、in 在、while 当、do 执行、switch 分支、case 情况、break 跳出、continue 继续、return 返回、throw 抛出、try 尝试、catch 捕获、finally 最终、await 等待、yield 产出 | const 常量、let 变量、var 变量、function 函数、async 异步、class 类、extends 继承、implements 实现、interface 接口、type 类型、enum 枚举、new 新建、instanceof 属于、typeof 类型为、keyof 键名、readonly 只读、public 公共、private 私有、protected 受保护、static 静态、abstract 抽象、delete 删除、true 真、false 假、null 空、undefined 未定义 | + +TypeScript 内置类型:string 字符串、number 数值、boolean 布尔、any 任意、unknown 未知、never 永不、void 无、object 对象、bigint 大整数、symbol 符号。`Promise` 等库类型不翻。 + +Python 关键字:import 导入、from 来自、as 作为、def 函数、async 异步、await 等待、class 类、return 返回、if 如果、elif 否则如果、else 否则、for 遍历、in(for 头部与推导式里)取自 / (比较里)在、while 当、try 尝试、except 捕获、finally 最终、raise 抛出、with 使用、yield 产出、lambda 匿名函数、pass 跳过、break 跳出、continue 继续、global 全局、nonlocal 非局部、del 删除、assert 断言、not 非、and 与、or 或、is 是、None 空、True 真、False 假、match 匹配、case 情况。 + +Python 内置类型(仅注解里):str 字符串、int 整数、float 浮点数、bool 布尔、list 列表、dict 字典、set 集合、tuple 元组、bytes 字节串。 + +样例(必须逐字一致,来自原型 `test.js`): + +| 源码 | 译文 | +| --- | --- | +| `export async function findEnvFiles(workspaceRoot: string): Promise {` | `公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>` | +| `async function chmodAll(workspaceRoot: string, mode: number): Promise {` | `异步 函数 chmodAll(workspaceRoot: 字符串, mode: 数值): Promise` | +| `import { promises as fsp } from 'node:fs'` | `导入 { promises 作为 fsp } 来自 'node:fs'` | +| `if (!folder)` | `如果 (非 folder)` | +| `return []` | `返回 空数组` | +| `for (const f of files) {` | `遍历 (常量 f 取自 files)` | +| `failed: { path: string, reason: string }[]` | `failed: { path: 字符串, reason: 字符串 }[]` | +| `const issue = issues.find(i => i.number === issueNumber)` | `常量 issue = issues.find(i => i.number 全等 issueNumber)` | +| `return {⏎ locked: true,⏎ … }`(issueLock.ts 20–24 行) | `返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }` | +| `def find_env_files(root: str) -> list[str]:` | `函数 find_env_files(root: 字符串) -> 列表[字符串]` | + +## 预览数据与消息 + +扩展端把代码块树转成可序列化的视图数据发给 webview: + +```ts +interface BlockView { + id: string // 在树里的位置,例如 '6.1.0' + kind: BlockKind + startLine: number // header 的起止行,0 起 + endLine: number + code: string // header 源码,去掉共同缩进 + codeTokens: Segment[] // 源码着色(拼起来等于 code) + translation: Segment[] + children: BlockView[] +} +``` + +- 扩展 → webview:`{ type: 'document', version, languageId, blocks: BlockView[], translators: { id, label }[], translatorId, error?: string }`,文档每次变化(防抖 150ms)重发。 +- webview → 扩展: + - `{ type: 'ready' }` + - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 + - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 +- 块的编辑 / 折叠状态在 webview 里按块 id 记(`vscode.setState`),文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 + +## 颜色 + +词性(暗色取 VS Code Dark+,亮色取 Light+): + +| 词性 | 暗色 | 亮色 | +| --- | --- | --- | +| keyword | #c586c0 | #af00db | +| storage / operator | #569cd6 | #0000ff | +| variable | #9cdcfe | #001080 | +| function | #dcdcaa | #795e26 | +| type | #4ec9b0 | #267f99 | +| string | #ce9178 | #a31515 | +| number / literal | #b5cea8 | #098658 | +| comment | #6a9955 | #008000 | +| plain | `--vscode-editor-foreground` | 同左 | + +种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框)。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 + +## 代码位置 + +``` +vscode/src/codeReader/ + contract.ts 通用契约 + registry.ts 注册表 + CodeReaderApi + runtime.ts web-tree-sitter 初始化、语法包加载、解析 + blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译) + languages/typescript.ts + languages/python.ts + translators/zhTemplate.ts 模板直译引擎(按语言的表驱动) + translators/typescriptZh.ts TS 表 + translators/pythonZh.ts Python 表 + explorer/fileTree.ts 侧栏文件树 + editor/previewEditor.ts CustomTextEditorProvider + editor/messages.ts 消息类型 + index.ts registerCodeReader(context): CodeReaderApi +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口) +``` + +## 测试 + +- 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式。 +- 翻译:上面样例表逐字一致;属性名不被翻;每个被翻的词单独成段。 +- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确。 +- 行替换:多行块替换后缩进正确。 +- 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 + +## 收尾 + +按仓库约定:`package.json` 版本 patch +1 → `pnpm ext:package` → 版本号改动一并 commit → 本机 `code --install-extension --profile augment`。