From caeeabebb3b6a456d985c73081e8b76e38083be9 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 16:19:33 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(prototype):=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=98=85=E8=AF=BB=E5=8E=9F=E5=9E=8B=E5=8A=A0=20React?= =?UTF-8?q?=20=E7=BB=84=E4=BB=B6=E4=B8=80=E9=A1=B5=EF=BC=88=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=8D=A1=E7=89=87=EF=BC=9A=E5=90=8D=E7=A7=B0=E8=A1=8C?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E3=80=81=E6=8C=89=E5=B1=9E=E6=80=A7=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E7=9A=84=E5=B1=9E=E6=80=A7=E8=A1=A8=E3=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=20/=20=E5=BC=95=E7=94=A8=20/=20=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E7=9A=84=E6=95=B0=E6=8D=AE=20/=20=E6=B4=BE=E7=94=9F=E5=80=BC?= =?UTF-8?q?=20/=20=E5=89=AF=E4=BD=9C=E7=94=A8=20/=20=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=88=86=E5=8C=BA=E3=80=81=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=A4=A7=E7=BA=B2=EF=BC=9B=E7=AE=80=E5=8D=95?= =?UTF-8?q?=E3=80=81=E5=A4=8D=E6=9D=82=E3=80=81=E5=AF=B9=E7=85=A7=E3=80=81?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E8=AF=B4=E6=98=8E=E5=9B=9B=E5=BC=A0=E7=94=BB?= =?UTF-8?q?=E6=9D=BF=EF=BC=89=EF=BC=9B=E6=8F=92=E4=BB=B6=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E5=8A=A0=20React=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../code-reader/figma-plugin/README.md | 24 +- .../code-reader/figma-plugin/code.js | 916 +++++++++++++++++- .../code-reader/figma-plugin/manifest.json | 4 + .../code-reader/figma-plugin/test.js | 194 +++- 4 files changed, 1108 insertions(+), 30 deletions(-) diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md index b64c620..e02bf1e 100644 --- a/docs/prototype/code-reader/figma-plugin/README.md +++ b/docs/prototype/code-reader/figma-plugin/README.md @@ -68,11 +68,28 @@ 交互:点「只看改动」在两个窗口间切换;点删掉的块展开 / 收起(组件里连的,说明板上的实例也能点)。「… N 块没变」和上一处 / 下一处只画样子,没连。 +## React 组件(插件菜单「React 组件」,单独一页 `代码阅读器 · React 组件`) + +TSX 里的 React 函数组件不再画成普通函数卡片(输入参数一格塞整个解构和类型字面量、输出参数「未标注」、钩子和 JSX 都是普通语句),而是一张「React 组件」卡片(原子图标,悬停提示「React 组件」)。示例是 luffy 的真实代码:`frontend/client-nextjs/src/components/desktop/DesktopTitlebar.tsx`(简单)、`components/LegalDocPage.tsx`(复杂)。 + +| 画板 | 对应源码 | 设计点 | +| --- | --- | --- | +| `Prototype / React 组件 · 简单` | `DesktopTitlebar.tsx` | 编辑器全貌,侧栏 `DesktopTitlebar.tsx` 选中。预览里五条导入,然后是组件卡片:名称后面「具名导出」「客户端组件」(第 1 行 `"use client"`、第 11 行文档注释并进卡片,不单独成块);说明;属性表 `platform · DesktopShell["platform"] · — · 是`;读取的数据(`usePathname()` → 当前路径,`useDesktopShell((s) => s.title)` → 从 desktop store 读 title);派生值 center(title;没有就按路径查 PAGE_TITLES;再没有就空);渲染大纲 | +| `Prototype / React 组件 · 复杂` | `LegalDocPage.tsx` | 属性来自 `LegalDocPageProps`(多一列说明);状态两个(渲染时按 doc 重置写进「改它的」);引用 scrollRef;读取的数据(自定义钩子 useSiteConfig、useRouter);派生值(三元取正文、useMemo 抽标题,写什么时候重算);副作用两张子卡片(时机 / 做什么 / 清理,第二张有清理、里面定义的 handleScroll 收着);事件处理表(名称 / 绑定在 / 做什么);渲染大纲有 JSX 注释、列表(对 headings 的每一项 h,key h.id,其中 isActive = …)、三元(如果 markdown.trim() 有内容 → 是 / 否)、随条件切换的类 | +| `Prototype / React 组件 · 对照` | `DesktopTitlebar.tsx` | 左边照画现在的普通函数卡片(输入参数 `{ platform }` / `{ platform: DesktopShell["platform"] }`、输出参数「未标注」、三句变量块、一整段 JSX 译文),右边是新卡片 | +| `Prototype / React 组件 · 标记说明` | 见下 | 名称行五种小标签;属性表的「来自 / 回调 / 子元素」(ShareButton、PosterDialog 的 StepLabel);钩子放进哪个分区、副作用时机的读法;渲染大纲的写法:原生标签和组件、样式小标(收起 / 点开)、条件、三元(DeleteAppDialog 的删除按钮)、列表(DeleteAppDialog)、提前返回和多个 return(DesktopSettingsDialog)、片段(AppVaultSummary)、注释,每样一句短说明 | + +卡片的分区按这个顺序,没有就不画:属性 → 状态(useState / useReducer:名称 / 初始值 / 改它的)→ 引用(useRef)→ 读取的数据(其他钩子:变量 / 来自 / 意思)→ 派生值(常量和 useMemo / useCallback:名称 / 怎么算 / 什么时候算)→ 副作用(每个 useEffect 一张子卡片)→ 事件处理(名称 / 绑定在 / 做什么)→ 渲染。渲染大纲每行一个元素:原生标签灰色,组件用组件色(Cmd+单击跳到定义);长 className 收成「样式 N 个类」,按条件切换的写「随 X 变」;`cond && ` 前缀「当 … 时」;三元「如果 …」下面「是 / 否」两支;`.map` 写「对 … 的每一项」并标 key;提前 `return null` 写「当 … 时 不渲染」,多个 return 分几段;`<>` 写「片段」。子卡片里的语句照常是一块块,一块一行模板直译的译文。 + +组件集:`React / 组件卡片`(`Component=DesktopTitlebar | LegalDocPage`)、`React / 副作用`(`Card=effect-1 | effect-2`、`State=open | closed`)、`React / 事件处理`(`Open=none | handleSectionClick | handlePrint | handleBack`)、`React / StyleChip`(`State=closed | open`)、`React / DesktopTitlebar.tsx`(五条导入,`Line=<行号>`)、`React / 现状`、`Explorer / ReactSidebar`、`Reader / FilterBar`(`File=titlebar`),外加主画板那套窗口外壳和 `Explorer / TreeRow`。卡片外壳、属性行、表格、导入卡片、块种类图标都是主画板那一套。 + +交互(都连在组件里,各处实例都能点):点「样式 N 个类」弹出全部类名、再点收起;点副作用子卡片左上角的箭头展开 / 收起;点事件处理表里的函数名在表下面展开它的子卡片(那一行淡淡高亮),再点同一个名字或子卡片的箭头收起,点别的名字换成那个;窗口侧栏点 `LegalDocPage.tsx` 跳到复杂那张。块右上角的「问 AI / 编辑」这一页只画样子,没连。 + ## 导入与运行 1. 打开 Figma Desktop,打开任意 Design 文件。 2. `Plugins > Development > Import plugin from manifest…`,选本目录的 `manifest.json`(已经导入过就跳过)。 -3. `Plugins > Development > 代码阅读器 Design Lab`,选「主画板」「Shell 脚本」「Dockerfile」或「代码差异」,各自新建一页。改过 `code.js` 后要重启 Figma 才会用新代码。 +3. `Plugins > Development > 代码阅读器 Design Lab`,选「主画板」「Shell 脚本」「Dockerfile」「代码差异」或「React 组件」,各自新建一页。改过 `code.js` 后要重启 Figma 才会用新代码。 ## 运行条件 @@ -162,3 +179,8 @@ node --test test.js - [ ] 代码差异:删掉的块收起时一行、虚线红边半透明;点开后旧内容整张划掉 - [ ] 代码差异:筛选栏两行不挤;总览条贴在预览右边缘,六段刻度的位置大致对得上改动在文件里的位置 - [ ] 代码差异:点「只看改动」两个窗口互跳,折起的细条比卡片矮很多 +- [ ] React 组件:名称后面的小标签和组件名在一行里对齐、比字矮一点;属性表回调 / 子元素小标签贴在名字后面 +- [ ] React 组件:各分区的表格左边和属性表对齐;副作用子卡片、事件处理展开的子卡片也和表格左边对齐,嵌在卡片里不显得挤 +- [ ] React 组件:渲染大纲每层左边一条竖线,原生标签灰、组件青,一眼分得开;「当 … 时」「对 … 的每一项」「是 / 否」小框和后面的元素在一行里对齐 +- [ ] React 组件:点「样式 N 个类」弹出的类名盖在下面的行上面(不被后面的字压住),再点收起 +- [ ] React 组件:点副作用子卡片的箭头能收起成一行、再展开;点事件处理的函数名在表下面展开子卡片,箭头收起 diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js index 46ffc72..62e3d3d 100644 --- a/docs/prototype/code-reader/figma-plugin/code.js +++ b/docs/prototype/code-reader/figma-plugin/code.js @@ -63,6 +63,8 @@ const COLORS = { tokenType: "#4ec9b0", // entity.name.type tokenString: "#ce9178", // string tokenLiteral: "#b5cea8", // constant.numeric + nativeTag: "#808080", // punctuation.definition.tag:JSX 里的原生标签(div、span)用标签尖括号的灰 + component: "#61dafb", // 插件自定:JSX 里的 React 组件,和「React 组件」块同色 ts: "#3178c6", tsx: "#4fc1ff", json: "#cbcb41", md: "#519aba", py: "#4b8bbe", toml: "#c0754a", }; const FONT_FAMILIES = ["PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC"]; @@ -207,6 +209,10 @@ const EXTRA_KINDS = [ { label: "异常", color: "#f14c4c", icon: "triangle-alert" }, { label: "否则", color: "#ce9178", icon: "corner-down-right" }, { label: "最终", color: "#f48771", icon: "shield-check" }, + // React 组件卡片和它里面的两种子卡片(见「React 组件」一节) + { label: "React 组件", color: "#61dafb", icon: "atom" }, + { label: "副作用", color: "#e5c07b", icon: "zap" }, + { label: "事件处理", color: "#dcdcaa", icon: "mouse-pointer-click" }, ]; // 代码块树。at:源码行号(单行,或 [起, 止]);kind:种类;kids:嵌套在它里面的块; // edit:原型里默认停在「编辑」状态,用来展示两种样子。块里显示的译文由 translateBlock 按模板直译生成。 @@ -926,6 +932,9 @@ const LUCIDE = { "layers": [["path", { d: "M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" }], ["path", { d: "M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" }], ["path", { d: "M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" }]], "activity": [["path", { d: "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" }]], "package": [["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }], ["path", { d: "M12 22V12" }], ["polyline", { points: "3.29 7 12 12 20.71 7" }], ["path", { d: "m7.5 4.27 9 5.15" }]], + "atom": [["circle", { cx: "12", cy: "12", r: "1" }], ["path", { d: "M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.52-4.53-9.86-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.52 4.53 9.86 6.54 11.9 4.5Z" }], ["path", { d: "M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.53 4.52-6.54 9.86-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z" }]], + "zap": [["path", { d: "M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" }]], + "mouse-pointer-click": [["path", { d: "M14 4.1 12 6" }], ["path", { d: "m5.1 8-2.9-.8" }], ["path", { d: "m6 12-1.9 2" }], ["path", { d: "M7.2 2.2 8 5.1" }], ["path", { d: "M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" }]], "git-pull-request": [["circle", { cx: "18", cy: "18", r: "3" }], ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M13 6h3a2 2 0 0 1 2 2v7" }], ["path", { d: "M6 9v12" }]], "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" }]], @@ -1781,7 +1790,8 @@ function propTable(ctx, parent, name, columns, rows, o = {}) { cell.strokeRightWeight = 1; cell.strokeTopWeight = cell.strokeBottomWeight = cell.strokeLeftWeight = 0; } - const cellText = richText(ctx, cell, "Text", segments); + // 格子也可以是一个画法(函数):自己往格子里放东西(名字后面带小标签这类),返回里面的文字层 + const cellText = typeof segments === "function" ? segments(cell) : richText(ctx, cell, "Text", segments); if (change === "del") cellText.textDecoration = "STRIKETHROUGH"; }); if (!header) { @@ -1834,7 +1844,11 @@ function functionRows(ctx, content, fileKey, item) { /** 导入:来源 / 仅类型(TypeScript)/ 导入项(每项一行)。 */ function importGridRows(ctx, content, fileKey, item) { - const spec = parseImport(fileKey, item); + return importSpecRows(ctx, content, parseImport(fileKey, item)); +} + +/** 导入卡片的行,spec 是 parseImport 那样的 { source, typeOnly, items }。 */ +function importSpecRows(ctx, content, spec) { propRow(ctx, content, "来源", [{ text: spec.source, color: spec.typeOnly === null ? COLORS.tokenType : COLORS.tokenString }]); if (spec.typeOnly !== null) propRow(ctx, content, "仅类型", yesNo(spec.typeOnly)); propRow(ctx, content, "导入项", countSegments(spec.items), { twisty: spec.items.length > 0 }); @@ -2576,13 +2590,14 @@ const SHELL_KINDS = { command: { label: "命令", color: "#d7ba7d", icon: "square-terminal" }, pipeline: { label: "管道", color: "#d7ba7d", icon: "square-terminal" }, }; -// 片段写法:[k|如果] 流程词、[s|是] 声明词、[v|变量]、[c|命令]、[f|函数]、[t|类型]、[q|字符串]、[n|数字]、[d|淡色说明],其余是正文 +// 片段写法:[k|如果] 流程词、[s|是] 声明词、[v|变量]、[c|命令]、[f|函数]、[t|类型]、[q|字符串]、[n|数字]、[d|淡色说明]、[u|常量]、 +// [h|原生标签]、[r|React 组件],其余是正文 const SHELL_TOKEN_COLORS = { k: "tokenControl", s: "tokenStorage", v: "tokenVariable", c: "tokenFunction", f: "tokenFunction", - t: "tokenType", q: "tokenString", n: "tokenLiteral", d: "desc", u: "tokenConstant" }; + t: "tokenType", q: "tokenString", n: "tokenLiteral", d: "desc", u: "tokenConstant", h: "nativeTag", r: "component" }; function rich(markup) { const segments = []; let last = 0; - const token = /\[([ksvcftqndu])\|([^\]]*)\]/g; + const token = /\[([ksvcftqnduhr])\|([^\]]*)\]/g; let match; while ((match = token.exec(markup))) { if (match.index > last) segments.push({ text: markup.slice(last, match.index) }); @@ -3304,12 +3319,12 @@ const DIFF_SAMPLE = { line: "示例", kind: "function", change: "mod", rows: fnR doc: "只在锁仍归 [c|token] 时把 TTL 续回 {-[c|ttl]}{+[c|ttl_sec]}; 返回是否续上.", params: diffParams("redis", "key", "token", ["del", "ttlOld"], ["add", "ttl"]), returns: "{-[t|无]}{+[t|布尔]}" }) }; -/** 说明板上一条说明:左边一个小色块写标记名,右边一句话。 */ -function diffNote(ctx, parent, change, label, caption) { +/** 说明板上一条说明:左边一个小色块写标记名(color 是色块的颜色,没有就是灰底),右边一句话。 */ +function legendNote(ctx, parent, color, label, caption) { const row = filled(ctx, parent, `Note / ${label}`, { dir: "HORIZONTAL", gap: 10, align: "CENTER" }); - const tag = append(row, frame(ctx, "Tag", { dir: "HORIZONTAL", px: 6, radius: 3, fill: change ? DIFF_COLORS[change] : COLORS.input, - fillOpacity: change ? DIFF_WORD_OPACITY : 1 })); - text(ctx, tag, "Text", label, { hug: true, medium: true, size: 12, lineHeight: 18, color: change ? COLORS.fgStrong : COLORS.fg }); + const tag = append(row, frame(ctx, "Tag", { dir: "HORIZONTAL", px: 6, radius: 3, fill: color || COLORS.input, + fillOpacity: color ? DIFF_WORD_OPACITY : 1 })); + text(ctx, tag, "Text", label, { hug: true, medium: true, size: 12, lineHeight: 18, color: color ? COLORS.fgStrong : COLORS.fg }); text(ctx, row, "Caption", caption, { size: 12.5, lineHeight: 18, color: COLORS.desc }); return row; } @@ -3329,31 +3344,31 @@ function diffLegend(ctx, page, lib) { return instance; }; section("四种卡片状态"); - diffNote(ctx, board, "add", "新增", "左边绿条、底色淡绿、左上角「新增」;整张都是新的,里面不再逐行标,子块同样绿条淡绿"); + legendNote(ctx, board, DIFF_COLORS.add, "新增", "左边绿条、底色淡绿、左上角「新增」;整张都是新的,里面不再逐行标,子块同样绿条淡绿"); place(lib.diffCards.get(23), "State / 新增"); - diffNote(ctx, board, "mod", "修改", "左边蓝条;卡片里只标变了的地方:说明多了一句、参数多了一行、输出参数改了"); + legendNote(ctx, board, DIFF_COLORS.mod, "修改", "左边蓝条;卡片里只标变了的地方:说明多了一句、参数多了一行、输出参数改了"); place(lib.diffCards.get(32), "State / 修改"); - diffNote(ctx, board, "del", "删除", "留在原来的位置(原来在哪两块之间就在哪),红色虚线边、半透明"); + legendNote(ctx, board, DIFF_COLORS.del, "删除", "留在原来的位置(原来在哪两块之间就在哪),红色虚线边、半透明"); place(lib.deleted.get("collapsed").node, "State / 删除"); - diffNote(ctx, board, null, "没变", "照常"); + legendNote(ctx, board, null, "没变", "照常"); place(lib.diffCards.get(15), "State / 没变"); section("卡片里的标记"); - diffNote(ctx, board, "del", "改值", "旧值红底划线,后面紧跟新值绿底(输出参数 无 → 布尔)"); - diffNote(ctx, board, "add", "加行", "整行淡绿底,左边 +(参数 ttl_sec)"); - diffNote(ctx, board, "del", "删行", "整行淡红底、划掉,左边 −(参数 ttl)"); - diffNote(ctx, board, "add", "文字增删", "说明、注释按字或词标:删掉的字红底划线,加的字绿底"); + legendNote(ctx, board, DIFF_COLORS.del, "改值", "旧值红底划线,后面紧跟新值绿底(输出参数 无 → 布尔)"); + legendNote(ctx, board, DIFF_COLORS.add, "加行", "整行淡绿底,左边 +(参数 ttl_sec)"); + legendNote(ctx, board, DIFF_COLORS.del, "删行", "整行淡红底、划掉,左边 −(参数 ttl)"); + legendNote(ctx, board, DIFF_COLORS.add, "文字增删", "说明、注释按字或词标:删掉的字红底划线,加的字绿底"); place(lib.sample.get("sample"), "Marks / 示例"); section("删除卡片的收起 / 展开"); - diffNote(ctx, board, "del", "收起", "默认一行「已删除 · 函数 _is_owner」,点一下展开"); + legendNote(ctx, board, DIFF_COLORS.del, "收起", "默认一行「已删除 · 函数 _is_owner」,点一下展开"); place(lib.deleted.get("collapsed").node, "Deleted / 收起"); - diffNote(ctx, board, "del", "展开", "下面是旧内容,整张划掉、变淡;再点收起"); + legendNote(ctx, board, DIFF_COLORS.del, "展开", "下面是旧内容,整张划掉、变淡;再点收起"); place(lib.deleted.get("expanded").node, "Deleted / 展开"); section("整个文件都是新的"); - diffNote(ctx, board, "add", "新文件", "未跟踪的新文件:顶部一条横幅,卡片不再逐张标绿"); + legendNote(ctx, board, DIFF_COLORS.add, "新文件", "未跟踪的新文件:顶部一条横幅,卡片不再逐张标绿"); place(lib.banner.get("untracked"), "Banner / 新文件"); place(lib.diffCards.get(15), "Banner / 卡片照常"); section("审查工单时的对比基准"); - diffNote(ctx, board, null, "审查", "「选工单」审查 PR 时,对比 PR 要合进去的目标分支"); + legendNote(ctx, board, null, "审查", "「选工单」审查 PR 时,对比 PR 要合进去的目标分支"); place(lib.diffBar.get("pr/off"), "Base / PR"); return board; } @@ -3498,21 +3513,870 @@ async function buildDiffPage(ctx, api, page, stage) { return { clock, page, components: { lib, sets }, board: all.screen, boards: { all, only, legend }, screens, start: all.screen.name }; } +// ─── React 组件 ────────────────────────────────────────────────────────────── +// +// TSX 里的 React 函数组件(大写开头、返回 JSX 的函数,或 memo(...) / forwardRef(...) 包着的)整块画成「React 组件」卡片, +// 不走普通函数卡片的输入参数 / 输出参数,组件体按钩子分区,JSX 画成结构大纲。 +// - 名称行后面跟小标签:具名导出 / 默认导出;文件顶部有 "use client" 加「客户端组件」(这一行不再单独成块);memo 加「记忆化」;forwardRef 加「转发 ref」; +// - 说明:文档注释(紧贴的注释不再单独成块); +// - 属性:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明才加说明列),类型取对应字段,类型里的名字可点; +// props 用接口或类型别名声明时展开它的字段,行尾写「来自 XxxProps」;children 标「子元素」,onXxx 这类函数属性标「回调」、类型写成函数签名; +// - 组件体按钩子分区,按下面的顺序,没有就不画: +// 状态(useState / useReducer:名称 / 初始值 / 改它的)、引用(useRef)、读取的数据(其他钩子:变量 / 来自 / 意思,调用读成中文)、 +// 派生值(组件体里算出来的常量和 useMemo / useCallback,读成中文,写什么时候重算)、 +// 副作用(每个 useEffect / useLayoutEffect 一张子卡片:时机 / 做什么 / 清理)、 +// 事件处理(组件里定义的处理函数:名称 / 绑定在 / 做什么,点名称在表下面展开它的子卡片)、渲染(返回的 JSX 画成缩进的结构大纲); +// 组件体里别的语句(渲染时按条件重置状态这类)算进它改的那个状态的「改它的」里; +// - 渲染大纲:每行一个元素,原生标签灰色、组件用组件色(Cmd+单击跳到定义);长 className 收成「样式 N 个类」,点开看全部; +// data-* / aria-* / 事件这些属性紧凑地写在后面,{x} 插值照写;cond && 前缀「当 … 时」,三元分「是 / 否」两支, +// .map 写「对 … 的每一项」并标 key,提前 return null 写「当 … 时不渲染」,多个 return 分几段、各带条件,<> 写「片段」。 +// 原型的读法按这份设计手写。示例:luffy 的 frontend/client-nextjs/src/components/desktop/DesktopTitlebar.tsx(简单)、 +// components/LegalDocPage.tsx(复杂);说明板的例子另取 DeleteAppDialog、DesktopSettingsDialog、AppVaultSummary、ShareButton、PosterDialog 的真实写法。 +const REACT_PAGE_NAME = "代码阅读器 · React 组件"; +const REACT_KIND = EXTRA_KINDS.find((kind) => kind.label === "React 组件"); +const EFFECT_KIND = EXTRA_KINDS.find((kind) => kind.label === "副作用"); +const HANDLER_KIND = EXTRA_KINDS.find((kind) => kind.label === "事件处理"); +// 卡片内容区的宽度:块宽减去左右留白、折叠箭头、种类图标、右上角按钮和它们之间的空隙;表格、子卡片按这个宽度画 +const REACT_CONTENT_W = TREE_W - 8 - 10 - 14 - 20 - 50 - 3 * 10; +const REACT_ROOT = "luffy-agent-platform"; +const REACT_FILES = { + titlebar: { name: "DesktopTitlebar.tsx", ext: "tsx", lang: "typescript", + crumbs: ["frontend", "client-nextjs", "src", "components", "desktop", "DesktopTitlebar.tsx"] }, + legal: { name: "LegalDocPage.tsx", ext: "tsx", lang: "typescript", crumbs: ["frontend", "client-nextjs", "src", "components", "LegalDocPage.tsx"] }, +}; +// 名称行的小标签:导出方式用「公共」的颜色,其余用组件色 +const REACT_TAG_COLORS = { 具名导出: COLORS.tokenControl, 默认导出: COLORS.tokenControl, 客户端组件: COLORS.component, 记忆化: COLORS.tokenStorage, "转发 ref": COLORS.tokenStorage }; +// luffy 的真实目录,只展开两个示例文件所在的路径(测试文件略去) +const REACT_TREE = [ + { depth: 1, type: "folder", name: "backend" }, + { depth: 1, type: "folder", name: "docs" }, + { depth: 1, type: "folder", name: "frontend", open: true }, + { depth: 2, type: "folder", name: "admin" }, + { depth: 2, type: "folder", name: "client-nextjs", open: true }, + { depth: 3, type: "folder", name: "public" }, + { depth: 3, type: "folder", name: "src", open: true }, + { depth: 4, type: "folder", name: "app" }, + { depth: 4, type: "folder", name: "components", open: true }, + { depth: 5, type: "folder", name: "apps" }, + { depth: 5, type: "folder", name: "auth" }, + { depth: 5, type: "folder", name: "chat" }, + { depth: 5, type: "folder", name: "desktop", open: true }, + { depth: 6, type: "folder", name: "lab" }, + { depth: 6, type: "file", name: "DesktopComposerChips.tsx" }, + { depth: 6, type: "file", name: "DesktopSettingsDialog.tsx" }, + { depth: 6, type: "file", name: "DesktopShellLayer.tsx" }, + { depth: 6, type: "file", name: "DesktopTitlebar.tsx", file: "titlebar" }, + { depth: 6, type: "file", name: "OfflineOverlay.tsx" }, + { depth: 6, type: "file", name: "UpdateBanner.tsx" }, + { depth: 6, type: "file", name: "WindowControls.tsx" }, + { depth: 5, type: "folder", name: "share" }, + { depth: 5, type: "folder", name: "ui" }, + { depth: 5, type: "file", name: "LegalDocPage.tsx", file: "legal" }, + { depth: 4, type: "folder", name: "lib" }, + { depth: 3, type: "file", name: "package.json" }, +]; +const REACT_ROW_VARIANTS = ["folder/closed", "folder/open", "file/default", "file/selected"]; + +// DesktopTitlebar.tsx 的块:第 1 行 "use client" 成了卡片上的「客户端组件」、第 11 行的文档注释成了「说明」,都不再单独成块。 +// 导入照常:[行号, 来源, 仅类型, 导入项] +const TITLEBAR_IMPORTS = [ + [3, '"next/navigation"', false, ["usePathname"]], + [5, '"@/lib/desktop-shell"', true, ["DesktopShell"]], + [6, '"@/lib/desktop/page-titles"', false, ["PAGE_TITLES"]], + [7, '"@/lib/desktop/store"', false, ["useDesktopShell"]], + [9, '"./WindowControls"', false, ["WindowControls"]], +]; +// 组件卡片的写法:各分区的格子用 rich 的片段写法;props.rows 是 { name, type, required, fallback, doc, role },role:callback | children。 +// 渲染大纲的节点: +// 元素 { el, comp(是组件), cls(类名;按条件切换的写 { base, by, when, yes, no }), attrs, text, when(cond && 的条件), kids }; +// 三元 { ternary: 条件, yes: [节点], no: [节点] };列表 { each: 对象, item: 每一项, key, local: [列表里先算的常量], kids }; +// JSX 注释 { note };片段 { frag: true, kids };略去 { more: 说明 }。多个 return 时 returns: [{ when, none }(不渲染)| { when, nodes }]。 +const TITLEBAR_CARD = { + name: "DesktopTitlebar", line: 12, tags: ["具名导出", "客户端组件"], + doc: "窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region=\"deep\"), 按钮除外. 侧栏开关只在侧栏顶部.", + props: { rows: [{ name: "platform", type: 'DesktopShell["platform"]', required: true }] }, + reads: [ + ["[v|pathname]", "[f|usePathname]()", "当前路径"], + ["[v|title]", "[f|useDesktopShell](([v|s]) => [v|s].[v|title])", "从 desktop store 读 [v|title]"], + ], + derived: [["[v|center]", "[v|title][d|;没有就]按路径查 [u|PAGE_TITLES][d|;再没有就]空", "每次渲染"]], + render: [ + { el: "div", cls: "flex h-10 shrink-0 select-none items-center border-b border-border bg-background", attrs: '[v|data-tauri-drag-region]=[q|"deep"]', kids: [ + { el: "div", cls: "flex items-center pl-2", kids: [ + { when: '[v|platform] [k|是] [q|"macos"]', el: "span", cls: "w-[70px]", attrs: '[v|data-testid]=[q|"traffic-light-inset"] [v|aria-hidden]' }, + ] }, + { el: "div", cls: "flex min-w-0 flex-1 items-center justify-center text-[13px]", kids: [ + { el: "span", cls: "max-w-[40vw] truncate font-medium text-foreground", text: "[d|{][v|center][d|}]" }, + ] }, + { when: '[v|platform] [k|是] [q|"windows"]', el: "WindowControls", comp: true }, + ] }, + ], +}; + +// LegalDocPage 的副作用和事件处理:子卡片里的语句照常是一块块(kind 同代码块树,译文由 translateCode 按模板直译), +// folded:收着的块(effect-2 里定义的 handleScroll)。时机按依赖数组读:[a, b] → a、b 变了时,[] → 只在挂载时,不写 → 每次渲染后。 +const LEGAL_EFFECTS = [ + { key: "effect-1", when: "[v|doc] [k|变了时]", does: "正文滚回顶部", body: [{ kind: "statement", code: "scrollRef.current?.scrollTo({ top: 0 });" }] }, + { key: "effect-2", when: "[v|doc]、[v|headings] [k|变了时]", does: "滚动时按标题的位置高亮当前章节", body: [ + { kind: "variable", code: "const container = scrollRef.current;" }, + { kind: "if", code: "if (!container) return;" }, + { kind: "function", code: "const handleScroll = () => {", folded: true }, + { kind: "statement", code: 'container.addEventListener("scroll", handleScroll, { passive: true });' }, + ], cleanup: 'container.removeEventListener("scroll", handleScroll)' }, +]; +const LEGAL_HANDLERS = [ + { name: "handleSectionClick", params: [["id", "string"]], on: "目录里每个 [h|