From 1a32a51e7515bec3041105a4040085ab6d2a246f Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 17:04:26 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(prototype):=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=98=85=E8=AF=BB=E5=8E=9F=E5=9E=8B=E6=89=80=E6=9C=89?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=8C=89=E5=86=85=E5=AE=B9=E6=92=91=E9=AB=98?= =?UTF-8?q?=E4=B8=8D=E8=A3=81=EF=BC=8C=E4=BE=A7=E6=A0=8F=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=20340=20=E6=94=BE=E5=BE=97=E4=B8=8B=E6=B7=B1=E5=B1=82=E9=95=BF?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=90=8D=EF=BC=9B=E7=94=BB=E5=AE=8C=E5=9C=A8?= =?UTF-8?q?=20Figma=20=E9=87=8C=E8=B7=91=E6=8E=92=E7=89=88=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=86=99=E8=BF=9B=E7=BB=93=E6=9D=9F=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E4=BA=94=E4=B8=AA=E8=8F=9C=E5=8D=95=E9=A1=B5=E9=83=BD?= =?UTF-8?q?=E6=B5=8B=E6=94=BE=E5=BE=97=E4=B8=8B=EF=BC=9B=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=20Figma=20=E5=90=8C=E6=AD=A5=E6=96=B0=E7=89=88=EF=BC=88?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E3=80=81=E6=8A=98=E8=A1=8C=E3=80=81=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=E6=96=87=E5=AD=97=E3=80=81=E6=96=B0=E6=A1=86=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E8=A3=81=E5=86=85=E5=AE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../code-reader/figma-plugin/README.md | 8 +- .../code-reader/figma-plugin/code.js | 124 ++++++++++++------ .../code-reader/figma-plugin/test.js | 52 ++++---- docs/prototype/figma-lab/fake-figma.js | 95 ++++++++++++-- 4 files changed, 198 insertions(+), 81 deletions(-) diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md index 7e6b1af..f431c37 100644 --- a/docs/prototype/code-reader/figma-plugin/README.md +++ b/docs/prototype/code-reader/figma-plugin/README.md @@ -74,7 +74,7 @@ TSX 里的 React 函数组件不再画成普通函数卡片(输入参数一格 | 画板 | 对应源码 | 设计点 | | --- | --- | --- | -| `Prototype / React 组件 · 简单` | `DesktopTitlebar.tsx` | 编辑器全貌,侧栏 `DesktopTitlebar.tsx` 选中(侧栏拉宽到 340,第 6 层的长文件名放得下)。窗口按内容撑高、预览区不滚动,块树占满预览区宽度,整张卡片都看得到。预览里五条导入,然后是组件卡片:名称后面「具名导出」「客户端组件」(第 1 行 `"use client"`、第 11 行文档注释并进卡片,不单独成块);说明;属性表 `platform · DesktopShell["platform"] · — · 是`;读取的数据(`usePathname()` → 当前路径,`useDesktopShell((s) => s.title)` → 从 desktop store 读 title);派生值 center(title;没有就按路径查 PAGE_TITLES;再没有就空);渲染大纲 | +| `Prototype / React 组件 · 简单` | `DesktopTitlebar.tsx` | 编辑器全貌,侧栏 `DesktopTitlebar.tsx` 选中(第 6 层的长文件名放得下)。块树占满预览区宽度,整张卡片都看得到。预览里五条导入,然后是组件卡片:名称后面「具名导出」「客户端组件」(第 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)、注释,每样一句短说明 | @@ -143,7 +143,6 @@ TSX 里的 React 函数组件不再画成普通函数卡片(输入参数一格 - 从「筛选」起点开始:点「访问级别」「种类」→ 打开各自的下拉菜单并按勾选筛过;点「清除」→ 回到不筛。 - 从「审查工单」起点(`routes.py · 预览`)开始:点侧栏标题栏第一个按钮「选工单」→ 弹框;点第一张工单 → 树里只剩改过的文件;侧栏标题栏的「×」(显示全部)→ 退出审查。 - 从「文件注释」起点开始:点 `campaign` 行尾的「添加注释」→ 顶部输入框;点输入框 → 保存后的侧栏。 -- 编辑区和侧栏可以上下滚动。 ## 本地检查 @@ -152,11 +151,14 @@ node --check code.js node --test test.js ``` -测试跑在假的 Figma 文档上,只检查结构、组件复用、连线、排布不重叠和图层数量,不量真实字体、不渲染。**测试通过不等于视觉验收**,要在 Figma 里看。 +测试跑在假的 Figma 文档上,只检查结构、组件复用、连线、排布不重叠、放不放得下和图层数量,字宽是估的、不渲染。**测试通过不等于视觉验收**,要在 Figma 里看。 + +插件在 Figma 里画完会用真实字宽再查一遍放不下的地方(裁内容的框里有东西伸出去、子项伸出容器),结果写在结束提示里:「排版检查:没有放不下的」才算过;有问题时提示里列头几处,全部明细在 Figma 的开发者控制台(Plugins → Development → Show/Hide console)。 ## 桌面验收清单 - [ ] 运行几秒内完成,新页面出现,已有页面没被改动 +- [ ] 结束提示里写「排版检查:没有放不下的」;每个窗口按内容撑高,状态栏在最后一块之后,侧栏最长的文件名不贴边 - [ ] 活动栏小火箭下面的书本图标是选中状态 - [ ] 侧栏看起来和 VS Code 自带资源管理器一致 - [ ] 代码块树:每层左边一条竖线,嵌套的块往右缩进,竖线从这一层第一块连到最后一块 diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js index 9aae098..18e9d54 100644 --- a/docs/prototype/code-reader/figma-plugin/code.js +++ b/docs/prototype/code-reader/figma-plugin/code.js @@ -14,7 +14,8 @@ 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; +// 侧栏宽度按最深、最长的那行定:React 页 desktop 目录第 6 层的 DesktopSettingsDialog.tsx、luffy 第 5 层的 topup_bonus_routes.py 都放得下 +const SIDEBAR_W = 340; const EDITOR_W = WINDOW_W - ACTIVITY_W - SIDEBAR_W - 1; const TREE_W = 880; const QUICK_INPUT_W = 600; @@ -2298,15 +2299,13 @@ function sidebarRows(selected, o) { * Explorer / ReviewSidebar — File=<文件>:审查工单时的侧栏,标题栏写工单号,下面一行工单标题和 PR(视图的 message),树里只有 PR 改过的文件。 */ function sidebarComponent(ctx, lib, selected, o = {}, name = `File=${selected}`) { - const node = frame(ctx, name, { component: true, width: SIDEBAR_W, height: MAIN_H, fill: COLORS.side }); + const node = frame(ctx, name, { component: true, width: SIDEBAR_W, fill: COLORS.side }); sidebarContent(ctx, node, lib, selected, o); return node; } function sidebarContent(ctx, pane, lib, selected, o) { const workspace = WORKSPACES[FILES[selected].workspace]; - pane.clipsContent = true; - pane.overflowDirection = "VERTICAL"; const header = lib.explorerHeader.get(o.review ? "review" : "default").createInstance(); header.name = "Header"; append(pane, header, true); @@ -2368,9 +2367,6 @@ const SOURCE_CHUNK_LINES = 8; /** 编辑模式: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 }); @@ -2393,9 +2389,6 @@ function codeView(ctx, editor, fileKey) { /** 预览模式:编辑区换成代码块树。 */ 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); @@ -2409,29 +2402,27 @@ function windowBoard(ctx, page, lib, name, o) { /** * 窗口外壳:标题栏、活动栏、侧栏、编辑器(标签页 + 面包屑)、状态栏,编辑区里放什么由调用方往 editor 里接着放。 - * o:width / mode / file(FILES 里那种文件描述)/ root(工作区名)/ crumbs(Editor / Breadcrumb 的变体)/ sidebar(main) 往 main 里放侧栏 / - * sidebarWidth(侧栏拉宽时的宽度)/ grow:窗口按编辑区的内容撑高,不裁内容(活动栏、侧栏跟着拉到一样高),不给就是固定一屏高、编辑区里滚动。 + * o:width / mode / file(FILES 里那种文件描述)/ root(工作区名)/ crumbs(Editor / Breadcrumb 的变体)/ sidebar(main) 往 main 里放侧栏。 + * 窗口按编辑区的内容撑高、不裁内容:画布不会滚动,照真窗口画成一屏高、里面滚动,超出一屏的部分就看不到了。 + * 活动栏、侧栏跟着拉到一样高;活动栏是一屏高,内容少的窗口也有一屏高。 */ function windowShell(ctx, page, lib, name, o) { const width = o.width || WINDOW_W; - const editorWidth = width - ACTIVITY_W - (o.sidebarWidth || SIDEBAR_W) - 1; - const screen = frame(ctx, `Prototype / ${name}`, { width, height: o.grow ? undefined : WINDOW_H, fill: COLORS.editor }); - screen.clipsContent = !o.grow; + const editorWidth = width - ACTIVITY_W - SIDEBAR_W - 1; + const screen = frame(ctx, `Prototype / ${name}`, { width, fill: COLORS.editor }); page.appendChild(screen); const title = lib.windowTitle.get("active").createInstance(); title.name = "Title bar"; append(screen, title, true); setText(title, "Title", `${o.file.name} — ${o.root}`); - const main = append(screen, frame(ctx, "Main", { dir: "HORIZONTAL", width, height: o.grow ? undefined : MAIN_H })); + const main = append(screen, frame(ctx, "Main", { dir: "HORIZONTAL", width })); const activity = lib.activity.get("reader").createInstance(); activity.name = "Activity bar"; main.appendChild(activity); activity.layoutSizingVertical = "FILL"; const side = o.sidebar(main); divider(ctx, main, { vertical: true }); - // 撑高的窗口由编辑区的内容定高:编辑区自己按内容撑,别的跟着它;固定高的窗口编辑区占满、里面滚动 const editor = append(main, frame(ctx, "Editor", { width: editorWidth, fill: COLORS.editor })); - if (!o.grow) editor.layoutSizingVertical = "FILL"; const titleBar = lib.editorTitle.get(o.mode).createInstance(); titleBar.name = "Editor title"; setText(titleBar, "Name", o.file.name); @@ -3299,9 +3290,7 @@ function newFileBanner(ctx) { * 照源代码管理视图分「暂存的更改」「更改」两组(组名后面淡色写几个文件),每组下面仓库根和改动的文件,行和审查侧栏同一套。 */ function uncommittedSidebar(ctx, lib) { - const node = frame(ctx, "File=mutex", { component: true, width: SIDEBAR_W, height: MAIN_H, fill: COLORS.side }); - node.clipsContent = true; - node.overflowDirection = "VERTICAL"; + const node = frame(ctx, "File=mutex", { component: true, width: SIDEBAR_W, fill: COLORS.side }); const header = lib.explorerHeader.get("uncommitted").createInstance(); header.name = "Header"; append(node, header, true); @@ -3438,9 +3427,6 @@ function diffWindow(ctx, page, lib, name, only) { bar.name = "Diff bar"; append(row, bar); const view = filled(ctx, editor, "Preview view", { align: "CENTER", px: VIEW_PAD }); - view.layoutSizingVertical = "FILL"; - view.clipsContent = true; - view.overflowDirection = "VERTICAL"; const tree = append(view, frame(ctx, `Block tree / ${DIFF_FILE.name}`, { width: TREE_W, py: 20, fill: COLORS.editor })); diffLevel(ctx, tree, lib, DIFF_BLOCKS, only, "Level 0"); const ruler = lib.ruler.get("mutex").createInstance(); @@ -3542,8 +3528,6 @@ 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"; -// 侧栏拉宽一点:desktop 目录在第 6 层,里面最长的文件名(DesktopSettingsDialog.tsx)放得下,不被侧栏边裁掉 -const REACT_SIDEBAR_W = 340; const REACT_FILES = { titlebar: { name: "DesktopTitlebar.tsx", ext: "tsx", lang: "typescript", crumbs: ["frontend", "client-nextjs", "src", "components", "desktop", "DesktopTitlebar.tsx"] }, @@ -4078,9 +4062,7 @@ function currentTitlebar(ctx) { /** Explorer / ReactSidebar — File=titlebar:侧栏,luffy 的目录展开到两个示例文件,DesktopTitlebar.tsx 选中;行和主画板同一套。 */ function reactSidebar(ctx, lib) { - const node = frame(ctx, "File=titlebar", { component: true, width: REACT_SIDEBAR_W, height: MAIN_H, fill: COLORS.side }); - node.clipsContent = true; - node.overflowDirection = "VERTICAL"; + const node = frame(ctx, "File=titlebar", { component: true, width: SIDEBAR_W, fill: COLORS.side }); const header = lib.explorerHeader.get("default").createInstance(); header.name = "Header"; append(node, header, true); @@ -4107,9 +4089,8 @@ function reactFilterBar(ctx, lib) { function reactWindow(ctx, page, lib) { const name = "React 组件 · 简单"; return timed(`Prototype / ${name}`, () => { - // 窗口按内容撑高,整张卡片都看得到;块树和筛选栏占满预览区的宽度,长字在卡片里折行 + // 块树和筛选栏占满预览区的宽度,长字在卡片里折行 const { screen, editor, side } = windowShell(ctx, page, lib, name, { mode: "preview", file: REACT_FILES.titlebar, root: REACT_ROOT, crumbs: "titlebar", - grow: true, sidebarWidth: REACT_SIDEBAR_W, sidebar: (main) => { const pane = lib.reactSidebar.get("titlebar").createInstance(); pane.name = "Sidebar"; @@ -4447,7 +4428,7 @@ async function build(api, stage = () => {}, command = "main") { const routeInstance = lib.route.get("preview").createInstance(); routeInstance.name = `Block / ${ROUTE_SAMPLE.name}`; append(routeBoard, routeInstance, true); - // 调用与构造:块树里这几块在画板下方被截掉,单独摆出来看 + // 调用与构造:跨多行的调用、构造和嵌套的调用,三种画法摆在一起对照 const callBoard = frame(ctx, "Prototype / 调用 · 构造", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 8, fill: COLORS.editor }); page.appendChild(callBoard); for (const [key, component] of [["routes:83", lib.card.get("routes:83").get("preview")], ["routes:45", lib.callBlockAt.get("routes:45")], @@ -4544,6 +4525,69 @@ async function build(api, stage = () => {}, command = "main") { return { start: start.name, clock, page, components, boards, narrow, notes, filters, review, iteration, tableBoard, enumBoard, celeryBoard, routeBoard, callBoard, kindBoard, askBoard, screens }; } +// ─── 排版检查 ──────────────────────────────────────────────────────────────── + +/** inner 伸出 outer 多少;pad 把 outer 缩到内边距以内。放得下就是 0。 */ +function overhang(outer, inner, pad = [0, 0, 0, 0]) { + const [top, right, bottom, left] = pad; + return Math.max(0, + outer.x + left - inner.x, + inner.x + inner.width - (outer.x + outer.width - right), + outer.y + top - inner.y, + inner.y + inner.height - (outer.y + outer.height - bottom)); +} + +/** + * 画完的页面上所有放不下的地方,一处一行: + * - 裁内容的框里有东西伸出框外:Figma 把那部分藏掉,画布又不会滚动,看的人就看不到; + * - 自动排版的子项伸出容器留给它的地方(比一栏宽、比定高的容器高)。 + * 真 Figma 里按真实字宽量,模拟 Figma 只能估字宽。全部画完、不再写之后读一轮几何:边写边读每读一次就重排一次,写完再读只排一次。 + */ +function layoutCheck(roots) { + const found = []; + const boxes = new Map(); + const box = (node) => { + if (!boxes.has(node)) boxes.set(node, node.absoluteBoundingBox); + return boxes.get(node); + }; + const shown = (node) => (node.children || []).filter((child) => child.visible); + const report = (root, node, what, px) => found.push(`${node === root ? root.name : `${root.name} › ${node.name}`} ${what} ${Math.round(px)}px`); + const cut = (frame) => { + let worst = 0; + const walk = (node) => { + for (const child of shown(node)) { + worst = Math.max(worst, overhang(box(frame), box(child))); + if (!child.clipsContent) walk(child); + } + }; + walk(frame); + return worst; + }; + const visit = (root, node) => { + const kids = shown(node); + if (node.clipsContent) { + const px = cut(node); + if (px > 0.5) report(root, node, "被裁", px); + } else if (node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL") { + const pad = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]; + for (const child of kids) { + if (child.layoutPositioning === "ABSOLUTE") continue; + const px = overhang(box(node), box(child), pad); + if (px > 0.5) report(root, child, `超出 ${node.name}`, px); + } + } + for (const child of kids) visit(root, child); + }; + for (const root of roots) if (root.visible) visit(root, root); + return found; +} + +/** 结束提示里排版检查那一句:几处、头几处;每一处都打到控制台。 */ +function layoutText(misfits) { + if (!misfits.length) return "排版检查:没有放不下的"; + return `排版检查:${misfits.length} 处放不下(${misfits.slice(0, 3).join(";")}${misfits.length > 3 ? " …" : ""}),全部明细在开发者控制台`; +} + /** 各阶段用时,写进结束提示,慢了看得出慢在哪一步。 */ function timingText(marks, end, spent) { const seconds = (ms) => (ms / 1000).toFixed(1); @@ -4560,13 +4604,17 @@ async function run(api) { const marks = []; const notice = api.notify("正在生成原型,请稍候…", { timeout: Infinity }); await new Promise((resolve) => setTimeout(resolve, 50)); + const stage = (name) => { + currentStage = name; + marks.push([name, Date.now()]); + }; try { - const { clock: spent, start } = await build(api, (stage) => { - currentStage = stage; - marks.push([stage, Date.now()]); - }, api.command || "main"); + const { clock: spent, start, page } = await build(api, stage, api.command || "main"); + stage("排版检查"); + const misfits = layoutCheck(page.children); + for (const line of misfits) console.warn(`排版检查:${line}`); notice.cancel(); - api.closePlugin(`已生成「代码阅读」的组件与演示画板,${timingText(marks, Date.now(), spent)}。从「${start}」开始看。`); + api.closePlugin(`已生成「代码阅读」的组件与演示画板,${layoutText(misfits)}。${timingText(marks, Date.now(), spent)}。从「${start}」开始看。`); } catch (error) { const message = error instanceof Error ? error.message : String(error); notice.cancel(); @@ -4575,6 +4623,6 @@ async function run(api) { } if (typeof module !== "undefined") { - module.exports = { REACT_PAGE_NAME, REACT_CARDS, TITLEBAR_CARD, LEGAL_CARD, LEGAL_EFFECTS, LEGAL_HANDLERS, TITLEBAR_IMPORTS, REACT_TREE, EXTRA_KINDS, DIFF_PAGE_NAME, DIFF_BLOCKS, DIFF_COLORS, DIFF_SAMPLE, UNCOMMITTED_GROUPS, diffParts, diffStatsText, NEXT_DOCKERFILE, BACKEND_DOCKERFILE, DOCKER_PAGE_NAME, SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments }; + module.exports = { REACT_PAGE_NAME, REACT_CARDS, TITLEBAR_CARD, LEGAL_CARD, LEGAL_EFFECTS, LEGAL_HANDLERS, TITLEBAR_IMPORTS, REACT_TREE, EXTRA_KINDS, DIFF_PAGE_NAME, DIFF_BLOCKS, DIFF_COLORS, DIFF_SAMPLE, UNCOMMITTED_GROUPS, diffParts, diffStatsText, NEXT_DOCKERFILE, BACKEND_DOCKERFILE, DOCKER_PAGE_NAME, SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, layoutCheck, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments }; } if (typeof figma !== "undefined") void run(figma); diff --git a/docs/prototype/code-reader/figma-plugin/test.js b/docs/prototype/code-reader/figma-plugin/test.js index 6b79c3a..3f15652 100644 --- a/docs/prototype/code-reader/figma-plugin/test.js +++ b/docs/prototype/code-reader/figma-plugin/test.js @@ -2,7 +2,7 @@ const assert = require("node:assert/strict"); const { test } = require("node:test"); const fs = require("node:fs"); const path = require("node:path"); -const { REACT_PAGE_NAME, TITLEBAR_CARD, LEGAL_CARD, LEGAL_EFFECTS, TITLEBAR_IMPORTS, EXTRA_KINDS, DIFF_PAGE_NAME, DIFF_BLOCKS, DIFF_COLORS, DIFF_SAMPLE, UNCOMMITTED_GROUPS, diffParts, diffStatsText, NEXT_DOCKERFILE, BACKEND_DOCKERFILE, DOCKER_PAGE_NAME, SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js"); +const { REACT_PAGE_NAME, TITLEBAR_CARD, LEGAL_CARD, LEGAL_EFFECTS, TITLEBAR_IMPORTS, EXTRA_KINDS, DIFF_PAGE_NAME, DIFF_BLOCKS, DIFF_COLORS, DIFF_SAMPLE, UNCOMMITTED_GROUPS, diffParts, diffStatsText, NEXT_DOCKERFILE, BACKEND_DOCKERFILE, DOCKER_PAGE_NAME, SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, layoutCheck, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js"); const { fakeFigma } = require("../../figma-lab/fake-figma"); function action(node) { return node.reactions[0].actions[0]; } @@ -788,34 +788,9 @@ test("the React 组件 menu item builds its own page with four boards, under the } }); -// 排得下:自动排版的容器装得下自己的内容(竖排加起来不比容器高、每个不比容器宽;横排不折行的一排加起来不比容器宽; -// 折行的每一项不比容器宽);文字要么占满父容器,要么不比父容器可用的宽度宽。返回放不下的地方。 -function misfits(root) { - const found = []; - const walk = (node) => { - if (!node.visible) return; - if (node.layoutMode !== "NONE") { - const flow = node.children.filter((child) => child.visible && child.layoutPositioning !== "ABSOLUTE"); - const innerW = node.width - node.paddingLeft - node.paddingRight + 0.5; - const innerH = node.height - node.paddingTop - node.paddingBottom + 0.5; - const gaps = Math.max(0, flow.length - 1) * node.itemSpacing; - const sum = (key) => flow.reduce((total, child) => total + child[key], 0) + gaps; - if (node.layoutMode === "VERTICAL" && sum("height") > innerH) found.push(`${node.name} 高度不够`); - if (node.layoutMode === "HORIZONTAL" && node.layoutWrap !== "WRAP" && sum("width") > innerW) found.push(`${node.name} 一排放不下`); - for (const child of flow) { - if (child.width > innerW) found.push(`${child.name} 比 ${node.name} 宽`); - if (child.type === "TEXT" && child.layoutSizingHorizontal !== "FILL" && child.width > innerW) found.push(`文字 ${child.name} 超出 ${node.name}`); - } - } - node.children.forEach(walk); - }; - walk(root); - return found; -} - test("React boards show everything: windows grow with the preview, cards fit the preview width, nothing is cut", async () => { const { result } = await reactPage(); - for (const screen of result.screens) assert.deepEqual(misfits(screen), [], `${screen.name} has nothing cut off`); + assert.deepEqual(layoutCheck(result.page.children), []); const { simple } = result.boards; // 窗口不裁:按内容撑高,预览区不滚动、装得下整棵块树;块树占满预览区的宽度 assert.equal(simple.screen.clipsContent, false); @@ -1066,6 +1041,29 @@ test("boards and sets sit on the page without overlapping", async () => { assert.equal(result.page.flowStartingPoints[0].nodeId, result.boards.envLock.preview.screen.id); }); +// 插件在真 Figma 里画完就跑这一遍排版检查(layoutCheck),结果写进结束提示;模拟 Figma 只估字宽,这里抓的是结构上的放不下: +// 定高的框装不下内容、一排加起来比容器宽、裁内容的框里有东西伸出去 +test("every page fits: nothing is clipped, no child sticks out, windows grow with their content, and the closing message says so", async () => { + // 每页有几个 VS Code 窗口:shell、docker 两页只有卡片画板 + const windowCount = { main: 16, shell: 0, docker: 0, diff: 2, react: 1 }; + for (const [command, count] of Object.entries(windowCount)) { + const api = fakeFigma(); + api.command = command; + await run(api); + assert.deepEqual(layoutCheck(api.currentPage.children), [], `${command} page`); + assert.match(api.messages[0], /排版检查:没有放不下的/, command); + const windows = api.currentPage.children.filter((node) => node.children.some((child) => child.name === "Main")); + assert.equal(windows.length, count, `${command} windows`); + for (const screen of windows) { + const main = named(screen, "Main"); + assert.equal(screen.clipsContent, false, `${screen.name} does not clip`); + assert.equal(main.counterAxisSizingMode, "AUTO", `${screen.name} grows with the editor`); + assert.equal(named(main, "Activity bar").height, main.height); + assert.equal(named(main, "Sidebar").height, main.height); + } + } +}); + test("the plugin stays small enough for Figma to build quickly", async () => { const api = fakeFigma(); const result = await build(api); diff --git a/docs/prototype/figma-lab/fake-figma.js b/docs/prototype/figma-lab/fake-figma.js index 8d3ed18..69c1cda 100644 --- a/docs/prototype/figma-lab/fake-figma.js +++ b/docs/prototype/figma-lab/fake-figma.js @@ -4,17 +4,19 @@ const LAYOUT_KEYS = [ "layoutMode", "layoutPositioning", "visible", "primaryAxisSizingMode", "counterAxisSizingMode", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "itemSpacing", + "layoutWrap", "counterAxisSpacing", "primaryAxisAlignItems", "counterAxisAlignItems", "fontSize", "lineHeight", "textAutoResize", "maxLines", ]; // 真 Figma 里这些属性只有自动排版容器有、或只有文字有;给别的图层设会抛 "object is not extensible"。 const FRAME_ONLY = new Set(["layoutMode", "primaryAxisSizingMode", "counterAxisSizingMode", - "paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "itemSpacing"]); + "paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "itemSpacing", + "layoutWrap", "counterAxisSpacing", "primaryAxisAlignItems", "counterAxisAlignItems"]); const TEXT_ONLY = new Set(["fontSize", "lineHeight", "textAutoResize", "maxLines"]); const FRAME_TYPES = new Set(["FRAME", "COMPONENT", "INSTANCE", "COMPONENT_SET"]); const SKIP_COPY = new Set(["id", "parent", "children", "_rev", "_cachedRev", "_val", "_mark", "_flowRev", "_flowCache"]); -// 模拟 Figma 文档树与自动排版的尺寸推算, 检查结构、变体配对、实例复用与连线; -// 不模拟真实字体度量、绘制和演示引擎. +// 模拟 Figma 文档树与自动排版的尺寸、位置推算(含折行), 检查结构、变体配对、实例复用与连线; +// 不模拟真实字体度量、绘制和演示引擎: 字宽按中文一个字号、其余 0.55 个字号估算. // 尺寸记在节点上. 改一个节点只作废它、祖先、以及祖先底下靠父尺寸吃饭的 FILL 孩子. function fakeFigma(options = {}) { let sequence = 0; @@ -33,11 +35,14 @@ function fakeFigma(options = {}) { const dirty = (node) => { mark(node, ++stamp); }; const fontKey = (font) => `${font.family}/${font.style}`; const cjk = /[\u3000-\u9fff\uff00-\uffef]/; - const textWidth = (node) => { + // 每个换行符另起一行:宽取最长的一行,高按行数算 + const lineWidths = (node) => node._characters.split("\n").map((line) => { let width = 0; - for (const char of node._characters) width += cjk.test(char) ? node.fontSize : node.fontSize * 0.55; + for (const char of line) width += cjk.test(char) ? node.fontSize : node.fontSize * 0.55; return Math.max(1, Math.round(width)); - }; + }); + const textWidth = (node) => Math.max(...lineWidths(node)); + const textLines = (node, width) => lineWidths(node).reduce((sum, line) => sum + Math.max(1, Math.ceil(line / Math.max(1, width))), 0); const padX = (node) => node.paddingLeft + node.paddingRight; const padY = (node) => node.paddingTop + node.paddingBottom; const flow = (node) => { @@ -47,6 +52,53 @@ function fakeFigma(options = {}) { return node._flowCache; }; const gaps = (node) => Math.max(0, flow(node).length - 1) * node.itemSpacing; + // 定宽的横排设了折行才折:从左往右排,放不下的换到下一行 + const wraps = (node) => node.layoutMode === "HORIZONTAL" && node.layoutWrap === "WRAP" && node.primaryAxisSizingMode === "FIXED"; + const wrapLines = (node) => { + const limit = node.width - padX(node); + const lines = []; + let line = []; + let used = 0; + for (const child of flow(node)) { + if (line.length && used + node.itemSpacing + child.width > limit + 0.01) { + lines.push(line); + line = []; + used = 0; + } + used += (line.length ? node.itemSpacing : 0) + child.width; + line.push(child); + } + if (line.length) lines.push(line); + return lines; + }; + const shift = (align, free) => (align === "CENTER" ? free / 2 : align === "MAX" ? free : 0); + // 自动排版容器里每个跟着排的子项相对容器左上角的位置:内边距、间距、主轴 / 交叉轴对齐、折行 + const offsets = (node) => node._memo("pos", () => { + const horizontal = node.layoutMode === "HORIZONTAL"; + const along = (child) => (horizontal ? child.width : child.height); + const across = (child) => (horizontal ? child.height : child.width); + const mainStart = horizontal ? node.paddingLeft : node.paddingTop; + const crossStart = horizontal ? node.paddingTop : node.paddingLeft; + const mainRoom = horizontal ? node.width - padX(node) : node.height - padY(node); + const crossRoom = horizontal ? node.height - padY(node) : node.width - padX(node); + const lines = wraps(node) ? wrapLines(node) : [flow(node)]; + const placed = new Map(); + let cross = crossStart; + for (const line of lines) { + const size = line.reduce((sum, child) => sum + along(child), 0); + const spread = node.primaryAxisAlignItems === "SPACE_BETWEEN" && line.length > 1; + const gap = spread ? Math.max(0, (mainRoom - size) / (line.length - 1)) : node.itemSpacing; + const lineCross = lines.length > 1 ? Math.max(...line.map(across)) : crossRoom; + let at = mainStart + (spread ? 0 : shift(node.primaryAxisAlignItems, mainRoom - size - gap * (line.length - 1))); + for (const child of line) { + const side = cross + shift(node.counterAxisAlignItems, lineCross - across(child)); + placed.set(child, horizontal ? { x: at, y: side } : { x: side, y: at }); + at += along(child) + gap; + } + cross += lineCross + node.counterAxisSpacing; + } + return placed; + }); class Node { constructor(type) { @@ -61,6 +113,9 @@ function fakeFigma(options = {}) { this.primaryAxisSizingMode = this.counterAxisSizingMode = "FIXED"; this.paddingLeft = this.paddingRight = this.paddingTop = this.paddingBottom = 0; this.itemSpacing = 0; + this.layoutWrap = "NO_WRAP"; + this.counterAxisSpacing = 0; + this.primaryAxisAlignItems = this.counterAxisAlignItems = "MIN"; this.x = this.y = 0; this._width = this._height = 100; this._characters = ""; @@ -76,8 +131,8 @@ function fakeFigma(options = {}) { 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._cachedRev = { w: -1, h: -1, iw: -1, ih: -1, mw: -1, mh: -1, pos: -1 }; + this._val = { w: 0, h: 0, iw: 0, ih: 0, mw: 0, mh: 0, pos: null }; this._flowRev = -1; this._flowCache = []; for (const key of LAYOUT_KEYS) { @@ -138,15 +193,17 @@ function fakeFigma(options = {}) { 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.textAutoResize === "WIDTH_AND_HEIGHT") return Math.min(textLines(this, Infinity), this.maxLines || Infinity) * this.lineHeight.value; + if (this.textAutoResize === "HEIGHT") return Math.min(textLines(this, this.width), 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 (wraps(this) && this.counterAxisSizingMode === "AUTO") { + const lines = wrapLines(this); + return padY(this) + lines.reduce((sum, line) => sum + Math.max(...line.map((child) => child.measuredHeight)), 0) + + Math.max(0, lines.length - 1) * this.counterAxisSpacing; + } if (this.layoutMode === "HORIZONTAL" && this.counterAxisSizingMode === "AUTO") { return padY(this) + Math.max(0, ...flow(this).map((child) => child.measuredHeight)); } @@ -178,6 +235,16 @@ function fakeFigma(options = {}) { if (this._vertical !== value) { this._vertical = value; dirty(this); } } get layoutSizingVertical() { return this._vertical; } + // 在页面上的位置和大小:跟着自动排版排的子项由容器算位置,其余用自己的 x / y(相对父节点) + get absoluteBoundingBox() { + const parent = this.parent; + const box = { width: this.width, height: this.height }; + if (!parent || parent.type === "PAGE" || parent.type === "DOCUMENT") return { x: this.x, y: this.y, ...box }; + const origin = parent.absoluteBoundingBox; + const laid = parent.layoutMode !== "NONE" && this.visible && this.layoutPositioning !== "ABSOLUTE"; + const at = laid ? offsets(parent).get(this) : this; + return { x: origin.x + at.x, y: origin.y + at.y, ...box }; + } 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); } @@ -304,6 +371,8 @@ function fakeFigma(options = {}) { for (const type of ["Frame", "Component", "Text", "Vector", "Ellipse", "Rectangle"]) { api[`create${type}`] = () => { const node = new Node(type.toUpperCase()); + // 真 Figma 新建的框默认裁掉超出的内容 + if (type === "Frame" || type === "Component") node.clipsContent = true; api.currentPage.appendChild(node); return node; };