🐛 fix(prototype): 代码阅读原型所有窗口按内容撑高不裁,侧栏统一 340 放得下深层长文件名;画完在 Figma 里跑排版检查写进结束提示,五个菜单页都测放得下;模拟 Figma 同步新版(位置、折行、多行文字、新框默认裁内容)
This commit is contained in:
1 parent
3b42be9002
commit
1a32a51e75
4 files changed
+198
-81
No files matched your search
@@ -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 自带资源管理器一致
|
||||
- [ ] 代码块树:每层左边一条竖线,嵌套的块往右缩进,竖线从这一层第一块连到最后一块
|
||||
|
||||
+86
-38
@@ -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);
|
||||
+25
-27
@@ -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);
|
||||
|
||||
+82
-13
@@ -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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user