diff --git a/plugins/calc-draft/.gitignore b/plugins/calc-draft/.gitignore new file mode 100644 index 000000000..253e77f73 --- /dev/null +++ b/plugins/calc-draft/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +src-ztools/dist/ +.idea +.DS_Store +*.local + +# agent/tool process artifacts +.catpaw/ +.claude/ +.codeium/ +.codex/ +.copilot/ +.dsh/ +.gemini/ +.opencode/ +.qoder/ +.trae-cn/ +.zcode/ +.superpowers/ +.zcodeignore + diff --git a/plugins/calc-draft/CHANGELOG.md b/plugins/calc-draft/CHANGELOG.md new file mode 100644 index 000000000..cc30bb13e --- /dev/null +++ b/plugins/calc-draft/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 1.0.0 - 2026-09-26 + +- **插件定位**: 类似`计算稿纸`插件,提供更加现代化的UI和交互设计以及更多的高级特性 +- **公式行引用**: 编写计算公式时可以通过`#id`引用公式行,动态联动 +- **跨草稿引用**: 可以通过`@id#id`引用其他草稿中的公式行,动态联动 +- **多步计算**: 每行写一个表达式,自动出结果。支持四则运算、幂、取模、常用函数(sqrt/sin/cos/log/abs/round 等)和常量(pi/e) +- **快捷操作**: 几乎所有操作都可以通过键盘来编辑,例如`Ctrl + 单击`跳转引用位置符合`JB家族 Coder`的使用习惯 +- **自动补全**: 草稿引用/公式引用都可以自动补全,并且支持备注搜索,拼音搜索,非常丝滑 diff --git a/plugins/calc-draft/README.md b/plugins/calc-draft/README.md new file mode 100644 index 000000000..e85e4a3bc --- /dev/null +++ b/plugins/calc-draft/README.md @@ -0,0 +1,78 @@ +# Calc-Draft + +

Calc-Draft Logo

+ +> 计算草稿纸,记录多步计算,友好且现代的交互界面 + +- 当前项目是ZTools插件,目的是为了作为`计算稿纸`插件的"现代化平替"并且提供`公式行引用`和`跨草稿引用`等特性 +- 在`Vibe Coding`时代,现代化的界面和交互体验是必须的,因此本项目提供了现代化界面和优雅的交互体验,符合`Coder`的使用习惯 +- 精雕细琢每个快捷键设计,欢迎体验和反馈 + + +## 应用速览 + + + + + + +
基本用法
basic-usage +
跨草稿公式引用
more-usage +
科学计算
more-usage-2 +
明亮模式
snapshot-1 +
+ +## ✨ 功能特性 + +- **多步计算**:每行写一个表达式,自动出结果。支持四则运算、幂、取模、常用函数(sqrt/sin/cos/log/abs/round 等)和常量(pi/e) +- **行引用**:用 `#N` 引用任意行的计算结果,像草稿纸上的"由第 N 步可知"——但更可靠:增删行后引用自动跟随,不会错位 +- **跨草稿引用**:`@页序号#行序号` 引用其他草稿的行(如 `@2#6` = 第 2 个草稿第 6 行),页签重引用不变;输入 `@` 自动补全草稿和行,零记忆负担 +- **点击即用**:单击行内任意数字复制结果;Alt+点击数字可把数值插入当前编辑光标处,续写下一行零停顿 +- **直观反馈**:每行固定颜色,引用处着色联动、上游行描边高亮,关系一目了然;循环引用、未找到、除零等错误以红字短句行内提示,不打断其他计算 +- **草稿管理**:多页草稿切换、双击重命名、一键删除,自动保存到本地,刷新不丢 +- **快捷键至上**:从选中、编辑、复制、移行到翻页全键盘可完成,具体可点击右上角键盘图标查看完整快捷键清单 + +# 开发指引 +## 📁 项目结构 + +``` +. +├── src-ztools/ # ZTools 插件目录 +│ ├── logo.png # 插件图标 +│ ├── plugin.json # 插件配置(code: calc) +│ ├── preload/services.js # Preload 扩展点(当前为空) +├── src/ +│ ├── main.tsx / main.css # 入口与全局样式 +│ ├── App.tsx # 路由:calc → CalcDraft +│ └── CalcDraft/ +│ ├── index.tsx # 页面骨架:顶栏 + 行列表 + 底栏 +│ ├── index.css # macOS 风格样式 +│ ├── state.ts # reducer、DraftDoc、localStorage 持久化 +│ ├── palette.ts # seq → 颜色映射 +│ ├── engine/ # jsep 解析 + 自研拓扑求值器(含单测) +│ └── components/ # CalcRow / NumberCell / PageTabs / Toast +├── docs/superpowers/ # 设计 spec 与实现计划 +├── vitest.config.ts +└── package.json +``` + +## 🚀 快速开始 + +```bash +npm install # 安装依赖 +npm run dev # 开发模式(http://localhost:5173) +npm test # 运行单测(Vitest) +``` + +将 `src-ztools/` 目录作为完整插件目录复制到 ZTools 插件目录即可测试;ZTools 会通过触发词 `计算` / `草稿` / `calc` 打开插件。 + +## 📖 开发说明 + +- **表达式管线**:`expr → jsep() → AST → evaluator → number | error`(`src/CalcDraft/engine/`) +- **状态**:顶层 `useReducer` 管理 `DraftDoc { pages, activePageId }`,每次变更 debounce 500ms 写入 `localStorage` 键 `calc-draft:doc` +- **测试**:引擎与状态层 Vitest 单测覆盖引用稳定性、环检测、拓扑重算、损坏存储回退、浮点格式化 + + +## 📄 开源协议 + +MIT License diff --git a/plugins/calc-draft/docs/basic-usage.gif b/plugins/calc-draft/docs/basic-usage.gif new file mode 100644 index 000000000..a71197370 Binary files /dev/null and b/plugins/calc-draft/docs/basic-usage.gif differ diff --git a/plugins/calc-draft/docs/more-usage-2.gif b/plugins/calc-draft/docs/more-usage-2.gif new file mode 100644 index 000000000..a09b760af Binary files /dev/null and b/plugins/calc-draft/docs/more-usage-2.gif differ diff --git a/plugins/calc-draft/docs/more-usage.gif b/plugins/calc-draft/docs/more-usage.gif new file mode 100644 index 000000000..622bb365c Binary files /dev/null and b/plugins/calc-draft/docs/more-usage.gif differ diff --git a/plugins/calc-draft/docs/snapshot-1.png b/plugins/calc-draft/docs/snapshot-1.png new file mode 100644 index 000000000..419bb6566 Binary files /dev/null and b/plugins/calc-draft/docs/snapshot-1.png differ diff --git a/plugins/calc-draft/docs/superpowers/plans/2026-09-23-calc-draft.md b/plugins/calc-draft/docs/superpowers/plans/2026-09-23-calc-draft.md new file mode 100644 index 000000000..fff482c63 --- /dev/null +++ b/plugins/calc-draft/docs/superpowers/plans/2026-09-23-calc-draft.md @@ -0,0 +1,2241 @@ +# 计算草稿(calc-draft)实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 构建 ZTools 插件"计算草稿"——单栏可编辑计算列表,`#seq` 稳定引用 + 点击复制/注入,多页持久化,macOS Spotlight 风格界面。 + +**Architecture:** jsep 解析表达式为 AST,自研 evaluator 做拓扑求值与环检测;顶层 `useReducer` 管理 `DraftDoc`(pages/rows),localStorage debounce 持久化;UI 为单栏卡片行 + 毛玻璃页签栏,颜色由 `seq` 映射调色板。 + +**Tech Stack:** React 19、TypeScript、Vite 6、jsep、Vitest + +**Spec:** `docs/superpowers/specs/2026-09-23-calc-draft-design.md` + +## Global Constraints + +- `#N` 的 N 是创建顺序 `seq`,插入/删除/排序不重新编号;删除后 `seq` 不复用 +- 每个 `seq` 颜色固定:`colorForSeq(seq)` 从调色板循环取色 +- 表达式能力:四则 `+ - * /`、`^ %`、括号、函数 `sqrt sin cos tan log ln abs round floor ceil min max pow`、常量 `pi e` +- 点击数字 = 复制剪贴板 + toast;`Alt+点击` = 注入当前聚焦输入框光标处 +- 错误仅行内呈现(红字短文案),不弹全局弹窗,不阻塞其他行 +- 持久化键 `calc-draft:doc`,debounce 500ms,损坏回退空文档 +- UI:无边框、大圆角(16-20px)、阴影层次、卡片感;非活动行 `opacity: 0.55`,上游依赖描边高亮 +- ZTools 窗口由宿主接管;`plugin.json` 仅保留 `code: "calc"`,`cmds: ["计算", "草稿", "calc"]`,`pluginSetting.height: 840`(schema 无 width 字段) +- 空表达式行显示占位、不报错 +- 运行时仅新增依赖 `jsep`;开发依赖新增 `vitest` +- 每任务结束 commit(首个任务先 `git init`) + +## Review Focus + +以下输入/失败模式最可能咬人,各自由所属任务的测试钉住: + +1. **删除 seq 最大的行后再新建行** → seq 不得复用(否则颜色与引用错乱)→ Task 3 `state.test.ts` +2. **循环引用**(A→B→A)→ 整组标错且不挂起、不栈溢出 → Task 2 `evaluator.test.ts` +3. **编辑中游行** → 仅下游拓扑序重算,无关行结果不变 → Task 2 `evaluator.test.ts` +4. **localStorage 损坏 / 非法 JSON** → 回退空文档、应用不崩溃 → Task 3 `state.test.ts` +5. **浮点误差 `0.1+0.2`** → 显示 `0.3` 而非 `0.30000000000000004` → Task 2 `evaluator.test.ts` + +--- + +### Task 1: 表达式解析 parser + +**Files:** +- Create: `src/CalcDraft/engine/parser.ts` +- Test: `src/CalcDraft/engine/parser.test.ts` +- Modify: `package.json`(scripts、devDependencies) +- Create: `vitest.config.ts` + +**Interfaces:** +- Consumes: 无(首任务) +- Produces: + - `parseExpression(expr: string): { refs: number[] }` — 语法错误 `throw Error(message)` + - `tokenizeForDisplay(expr: string): Array<{ type: 'number' | 'ref' | 'ident' | 'other'; text: string; seq?: number }>` + +- [ ] **Step 1: 初始化 git 与依赖** + +```bash +git init +npm install jsep +npm install -D vitest +``` + +在 `package.json` scripts 中加入: + +```json +"test": "vitest run" +``` + +- [ ] **Step 2: 创建 vitest 配置** + +创建 `vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'] + } +}) +``` + +- [ ] **Step 3: 写失败测试** + +创建 `src/CalcDraft/engine/parser.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { parseExpression, tokenizeForDisplay } from './parser' + +describe('parseExpression', () => { + it('返回空 refs(纯算术)', () => { + expect(parseExpression('1 + 2 * 3').refs).toEqual([]) + }) + + it('提取 #ref 的 seq', () => { + expect(parseExpression('#1 + #12 * 2').refs).toEqual([1, 12]) + }) + + it('同一 ref 重复出现只记录一次', () => { + expect(parseExpression('#3 + #3').refs).toEqual([3]) + }) + + it('语法错误时 throw', () => { + expect(() => parseExpression('1 +')).toThrow() + expect(() => parseExpression('sqrt(')).toThrow() + }) + + it('支持函数与常量', () => { + expect(() => parseExpression('sqrt(4) + pi * e')).not.toThrow() + }) + + it('支持 ^ 与 % 以及括号', () => { + expect(() => parseExpression('(2 + 3) ^ 2 % 7')).not.toThrow() + }) +}) + +describe('tokenizeForDisplay', () => { + it('拆分 ref/数字/标识符/其他', () => { + expect(tokenizeForDisplay('#1+2.5*sqrt')).toEqual([ + { type: 'ref', text: '#1', seq: 1 }, + { type: 'other', text: '+' }, + { type: 'number', text: '2.5' }, + { type: 'other', text: '*' }, + { type: 'ident', text: 'sqrt' } + ]) + }) + + it('保留空白为 other', () => { + expect(tokenizeForDisplay('1 + 2')).toEqual([ + { type: 'number', text: '1' }, + { type: 'other', text: ' ' }, + { type: 'other', text: '+' }, + { type: 'other', text: ' ' }, + { type: 'number', text: '2' } + ]) + }) +}) +``` + +- [ ] **Step 4: 运行确认失败** + +Run: `npm test` +Expected: FAIL(`./parser` 不存在) + +- [ ] **Step 5: 最小实现** + +创建 `src/CalcDraft/engine/parser.ts`: + +```ts +import jsep from 'jsep' + +jsep.addIdentifierChar('#') +jsep.addBinaryOp('^', 11, true) +jsep.addBinaryOp('%', 10) + +export type ParseResult = { refs: number[] } + +export function parseExpression(expr: string): ParseResult { + const ast = jsep(expr) + const refs: number[] = [] + walk(ast, (node: any) => { + if (node.type === 'Identifier' && typeof node.name === 'string' && node.name.startsWith('#')) { + const seq = Number(node.name.slice(1)) + if (Number.isInteger(seq) && seq >= 1 && !refs.includes(seq)) refs.push(seq) + } + }) + return { refs } +} + +function walk(node: any, visit: (n: any) => void): void { + if (!node || typeof node !== 'object') return + visit(node) + for (const key of Object.keys(node)) { + if (key === 'type') continue + const value = node[key] + if (Array.isArray(value)) value.forEach((child) => walk(child, visit)) + else if (value && typeof value === 'object') walk(value, visit) + } +} + +export type DisplayToken = { + type: 'number' | 'ref' | 'ident' | 'other' + text: string + seq?: number +} + +const TOKEN_RE = /#(\d+)|(\d+\.?\d*)|([A-Za-z_][A-Za-z0-9_]*)|(\s+)|([\s\S])/g + +export function tokenizeForDisplay(expr: string): DisplayToken[] { + const tokens: DisplayToken[] = [] + let m: RegExpExecArray | null + TOKEN_RE.lastIndex = 0 + while ((m = TOKEN_RE.exec(expr))) { + if (m[1] !== undefined) tokens.push({ type: 'ref', text: m[0], seq: Number(m[1]) }) + else if (m[2] !== undefined) tokens.push({ type: 'number', text: m[0] }) + else if (m[3] !== undefined) tokens.push({ type: 'ident', text: m[0] }) + else tokens.push({ type: 'other', text: m[0] }) + } + return tokens +} +``` + +- [ ] **Step 6: 运行确认通过** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add package.json package-lock.json vitest.config.ts src/CalcDraft/engine/ +git commit -m "feat: expression parser with #ref extraction and display tokenizer" +``` + +--- + +### Task 2: 求值器 evaluator + +**Files:** +- Create: `src/CalcDraft/engine/evaluator.ts` +- Test: `src/CalcDraft/engine/evaluator.test.ts` + +**Interfaces:** +- Consumes: `parseExpression(expr): { refs: number[] }`(Task 1) +- Produces: + - `type CellResult = { status: 'ok'; value: number } | { status: 'error'; message: string } | { status: 'empty' }` + - `evaluatePage(rows: Array<{ id: string; seq: number; expr: string }>): { results: Map; directDeps: Map }` + - `upstreamRowIds(directDeps: Map, rowId: string): Set` + - `formatNumber(n: number): string` + +- [ ] **Step 1: 写失败测试** + +创建 `src/CalcDraft/engine/evaluator.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { evaluatePage, upstreamRowIds, formatNumber } from './evaluator' + +const row = (id: string, seq: number, expr: string) => ({ id, seq, expr }) + +describe('evaluatePage', () => { + it('四则与优先级', () => { + const { results } = evaluatePage([row('a', 1, '1 + 2 * 3')]) + expect(results.get('a')).toEqual({ status: 'ok', value: 7 }) + }) + + it('解析 #ref 并求值', () => { + const { results } = evaluatePage([ + row('a', 1, '10'), + row('b', 2, '#1 * 2 + 1') + ]) + expect(results.get('b')).toEqual({ status: 'ok', value: 21 }) + }) + + it('引用顺序与拓扑无关(后行先引用不依赖书写顺序以外的环)', () => { + const { results } = evaluatePage([ + row('a', 1, '#2 + 1'), + row('b', 2, '5') + ]) + expect(results.get('a')).toEqual({ status: 'ok', value: 6 }) + }) + + it('缺失 ref → 未找到', () => { + const { results } = evaluatePage([row('a', 1, '#9 + 1')]) + expect(results.get('a')).toEqual({ status: 'error', message: '未找到 #9' }) + }) + + it('除零 → 除以零', () => { + const { results } = evaluatePage([row('a', 1, '1 / 0')]) + expect(results.get('a')).toEqual({ status: 'error', message: '除以零' }) + }) + + it('循环引用 → 整组标错且不挂起', () => { + const { results } = evaluatePage([ + row('a', 1, '#2 + 1'), + row('b', 2, '#1 + 1') + ]) + expect(results.get('a')).toEqual({ status: 'error', message: '循环引用' }) + expect(results.get('b')).toEqual({ status: 'error', message: '循环引用' }) + }) + + it('自引用 → 循环引用', () => { + const { results } = evaluatePage([row('a', 1, '#1 + 1')]) + expect(results.get('a')).toEqual({ status: 'error', message: '循环引用' }) + }) + + it('编辑中游行后仅下游受影响(模拟改 expr 重新 evaluatePage)', () => { + const before = evaluatePage([ + row('a', 1, '2'), + row('b', 2, '#1 * 10'), + row('c', 3, '100') + ]) + expect(before.results.get('b')).toEqual({ status: 'ok', value: 20 }) + const after = evaluatePage([ + row('a', 1, '3'), + row('b', 2, '#1 * 10'), + row('c', 3, '100') + ]) + expect(after.results.get('b')).toEqual({ status: 'ok', value: 30 }) + expect(after.results.get('c')).toEqual({ status: 'ok', value: 100 }) + }) + + it('函数与常量', () => { + const { results } = evaluatePage([ + row('a', 1, 'sqrt(9)'), + row('b', 2, 'abs(-4)'), + row('c', 3, 'max(1, 7, 3)'), + row('d', 4, 'round(2.6)'), + row('e', 5, 'pi') + ]) + expect(results.get('a')).toEqual({ status: 'ok', value: 3 }) + expect(results.get('b')).toEqual({ status: 'ok', value: 4 }) + expect(results.get('c')).toEqual({ status: 'ok', value: 7 }) + expect(results.get('d')).toEqual({ status: 'ok', value: 3 }) + expect((results.get('e') as any).value).toBeCloseTo(Math.PI) + }) + + it('^ 右结合', () => { + const { results } = evaluatePage([row('a', 1, '2 ^ 3 ^ 2')]) + expect(results.get('a')).toEqual({ status: 'ok', value: 512 }) + }) + + it('空表达式 → empty 而非错误', () => { + const { results } = evaluatePage([row('a', 1, ' ')]) + expect(results.get('a')).toEqual({ status: 'empty' }) + }) + + it('语法错误 → 行内 error', () => { + const { results } = evaluatePage([row('a', 1, '1 +')]) + expect(results.get('a')!.status).toBe('error') + }) + + it('无关行不受错误行影响', () => { + const { results } = evaluatePage([ + row('a', 1, '1 +'), + row('b', 2, '2 + 2') + ]) + expect(results.get('a')!.status).toBe('error') + expect(results.get('b')).toEqual({ status: 'ok', value: 4 }) + }) + + it('上游 error 时下游传播消息', () => { + const { results } = evaluatePage([ + row('a', 1, '1 / 0'), + row('b', 2, '#1 + 1') + ]) + expect(results.get('b')).toEqual({ status: 'error', message: '除以零' }) + }) + + it('directDeps 记录上游行 id', () => { + const { directDeps } = evaluatePage([ + row('a', 1, '1'), + row('b', 2, '#1 + 1') + ]) + expect(directDeps.get('b')).toEqual(['a']) + expect(directDeps.get('a')).toEqual([]) + }) +}) + +describe('upstreamRowIds', () => { + it('计算传递上游', () => { + const deps = new Map([ + ['a', []], + ['b', ['a']], + ['c', ['b']], + ['d', []] + ]) + expect(upstreamRowIds(deps, 'c')).toEqual(new Set(['a', 'b'])) + expect(upstreamRowIds(deps, 'a')).toEqual(new Set()) + }) + + it('环状依赖不死循环', () => { + const deps = new Map([ + ['a', ['b']], + ['b', ['a']] + ]) + expect(upstreamRowIds(deps, 'a')).toEqual(new Set(['b'])) + }) +}) + +describe('formatNumber', () => { + it('消除 0.1+0.2 浮点噪声', () => { + expect(formatNumber(0.1 + 0.2)).toBe('0.3') + }) + + it('整数不带小数点', () => { + expect(formatNumber(7)).toBe('7') + }) + + it('保留有效小数', () => { + expect(formatNumber(1 / 3)).toBe('0.333333333333') + }) +}) +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `npm test` +Expected: FAIL(`./evaluator` 不存在) + +- [ ] **Step 3: 最小实现** + +创建 `src/CalcDraft/engine/evaluator.ts`: + +```ts +import { parseExpression } from './parser' + +export type CellResult = + | { status: 'ok'; value: number } + | { status: 'error'; message: string } + | { status: 'empty' } + +export type EvalRow = { id: string; seq: number; expr: string } + +export type PageEvaluation = { + results: Map + directDeps: Map +} + +type Fn = (...args: number[]) => number + +const FUNCTIONS: Record = { + sqrt: Math.sqrt, + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + log: (x) => Math.log10(x), + ln: Math.log, + abs: Math.abs, + round: Math.round, + floor: Math.floor, + ceil: Math.ceil, + min: (...args) => Math.min(...args), + max: (...args) => Math.max(...args), + pow: (a, b) => Math.pow(a, b) +} + +const CONSTANTS: Record = { + pi: Math.PI, + e: Math.E +} + +export function evaluatePage(rows: EvalRow[]): PageEvaluation { + const results = new Map() + const directDeps = new Map() + const bySeq = new Map() + for (const r of rows) bySeq.set(r.seq, r) + + const parsed = new Map() + for (const r of rows) { + if (!r.expr.trim()) { + parsed.set(r.id, null) + directDeps.set(r.id, []) + results.set(r.id, { status: 'empty' }) + continue + } + try { + const { refs } = parseExpression(r.expr) + parsed.set(r.id, { refs }) + const upstream: string[] = [] + for (const seq of refs) { + const up = bySeq.get(seq) + if (up && !upstream.includes(up.id)) upstream.push(up.id) + } + directDeps.set(r.id, upstream) + } catch { + parsed.set(r.id, { refs: [] }) + directDeps.set(r.id, []) + results.set(r.id, { status: 'error', message: '表达式语法错误' }) + } + } + + const rowById = new Map(rows.map((r) => [r.id, r])) + const pending = rows.filter((r) => !results.has(r.id)) + const inDegree = new Map() + const dependents = new Map() + for (const r of pending) { + inDegree.set(r.id, 0) + dependents.set(r.id, []) + } + for (const r of pending) { + for (const upId of directDeps.get(r.id) || []) { + if (!inDegree.has(upId)) continue + inDegree.set(r.id, (inDegree.get(r.id) || 0) + 1) + dependents.set(upId, [...(dependents.get(upId) || []), r.id]) + } + } + + const queue = pending.filter((r) => (inDegree.get(r.id) || 0) === 0).map((r) => r.id) + let processed = 0 + while (queue.length) { + const id = queue.shift()! + processed++ + const r = rowById.get(id)! + const parsedInfo = parsed.get(id) + if (parsedInfo) { + results.set(id, evalAst(r, parsedInfo.refs, results, bySeq)) + } + for (const depId of dependents.get(id) || []) { + const next = (inDegree.get(depId) || 0) - 1 + inDegree.set(depId, next) + if (next === 0) queue.push(depId) + } + } + + for (const r of pending) { + if (!results.has(r.id)) results.set(r.id, { status: 'error', message: '循环引用' }) + } + + return { results, directDeps } +} + +function evalAst( + row: EvalRow, + refs: number[], + results: Map, + bySeq: Map +): CellResult { + try { + const ast = (jsep as any)(row.expr) + const value = evalNode(ast, refs, results, bySeq) + if (typeof value !== 'number' || !Number.isFinite(value)) { + if (Number.isNaN(value)) return { status: 'error', message: '无效数值' } + if (value === Infinity || value === -Infinity) return { status: 'error', message: '除以零' } + return { status: 'error', message: '表达式语法错误' } + } + return { status: 'ok', value } + } catch (e: any) { + return { status: 'error', message: e?.message || '表达式语法错误' } + } +} + +function evalNode( + node: any, + refs: number[], + results: Map, + bySeq: Map +): number { + switch (node.type) { + case 'Literal': + return Number(node.value) + case 'Identifier': { + const name = String(node.name) + if (name.startsWith('#')) { + const seq = Number(name.slice(1)) + const target = bySeq.get(seq) + if (!target) throw new Error(`未找到 #${seq}`) + const res = results.get(target.id) + if (!res || res.status === 'empty') throw new Error(`未找到 #${seq}`) + if (res.status === 'error') throw new Error(res.message) + return res.value + } + if (name in CONSTANTS) return CONSTANTS[name] + if (name in FUNCTIONS) throw new Error(`${name} 需要调用`) + throw new Error(`未知标识符 ${name}`) + } + case 'CallExpression': { + const callee = node.callee?.name + if (!callee || !(callee in FUNCTIONS)) throw new Error(`未知函数 ${callee || ''}`) + const fn = FUNCTIONS[callee] + const args = node.arguments.map((a: any) => evalNode(a, refs, results, bySeq)) + const value = fn(...args) + if (Number.isNaN(value)) throw new Error('无效数值') + return value + } + case 'BinaryExpression': { + const left = evalNode(node.left, refs, results, bySeq) + const right = evalNode(node.right, refs, results, bySeq) + return applyBinary(node.operator, left, right) + } + case 'UnaryExpression': { + const argument = evalNode(node.argument, refs, results, bySeq) + if (node.operator === '-') return -argument + if (node.operator === '+') return argument + throw new Error('不支持的一元运算') + } + case 'ConditionalExpression': { + const test = evalNode(node.test, refs, results, bySeq) + return test + ? evalNode(node.consequent, refs, results, bySeq) + : evalNode(node.alternate, refs, results, bySeq) + } + default: + throw new Error('表达式语法错误') + } +} + +function applyBinary(op: string, a: number, b: number): number { + switch (op) { + case '+': return a + b + case '-': return a - b + case '*': return a * b + case '/': + if (b === 0) throw new Error('除以零') + return a / b + case '%': + if (b === 0) throw new Error('除以零') + return a % b + case '^': return Math.pow(a, b) + default: + throw new Error('不支持的运算符') + } +} + +import jsep from 'jsep' + +export function upstreamRowIds(directDeps: Map, rowId: string): Set { + const seen = new Set() + const stack = [...(directDeps.get(rowId) || [])] + while (stack.length) { + const id = stack.pop()! + if (seen.has(id)) continue + seen.add(id) + for (const up of directDeps.get(id) || []) stack.push(up) + } + return seen +} + +export function formatNumber(n: number): string { + if (!Number.isFinite(n)) return String(n) + return String(Number(n.toPrecision(12))) +} +``` + +注意:`import jsep from 'jsep'` 放在文件顶部(实现时整理 import 顺序至文件头)。 + +- [ ] **Step 4: 运行确认通过** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/CalcDraft/engine/ +git commit -m "feat: evaluator with topo sort, cycle detection, functions, formatNumber" +``` + +--- + +### Task 3: 状态模型 state + 调色板 palette + +**Files:** +- Create: `src/CalcDraft/state.ts` +- Create: `src/CalcDraft/palette.ts` +- Test: `src/CalcDraft/state.test.ts` + +**Interfaces:** +- Consumes: 无 +- Produces: + - `type Row = { id: string; seq: number; expr: string }` + - `type Page = { id: string; name: string; rows: Row[]; createdAt: number; seqCounter: number }` + - `type DraftDoc = { pages: Page[]; activePageId: string }` + - `type Action =` 见实现(init/addRow/editRow/deleteRow/moveRow/addPage/deletePage/renamePage/setActivePage) + - `createEmptyDoc(): DraftDoc` + - `docReducer(doc: DraftDoc, action: Action): DraftDoc` + - `loadDoc(raw: string | null): DraftDoc` + - `serializeDoc(doc: DraftDoc): string` + - `STORAGE_KEY = 'calc-draft:doc'` + - `colorForSeq(seq: number): string`、`PALETTE: string[]` + +- [ ] **Step 1: 写失败测试** + +创建 `src/CalcDraft/state.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { + createEmptyDoc, + docReducer, + loadDoc, + serializeDoc, + STORAGE_KEY, + type DraftDoc +} from './state' +import { colorForSeq, PALETTE } from './palette' + +function docWithRows(): DraftDoc { + let doc = createEmptyDoc() + const pageId = doc.activePageId + doc = docReducer(doc, { type: 'addRow', pageId, rowId: 'r1', expr: '1' }) + doc = docReducer(doc, { type: 'addRow', pageId, rowId: 'r2', expr: '2' }) + doc = docReducer(doc, { type: 'addRow', pageId, rowId: 'r3', expr: '3' }) + return doc +} + +describe('docReducer rows', () => { + it('addRow 按创建顺序分配 seq', () => { + const doc = docWithRows() + const rows = doc.pages[0].rows + expect(rows.map((r) => r.seq)).toEqual([1, 2, 3]) + expect(doc.pages[0].seqCounter).toBe(3) + }) + + it('删除最大 seq 行后再添加,seq 不复用', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'deleteRow', pageId: doc.activePageId, rowId: 'r3' }) + doc = docReducer(doc, { type: 'addRow', pageId: doc.activePageId, rowId: 'r4', expr: '9' }) + const rows = doc.pages[0].rows + expect(rows.find((r) => r.id === 'r4')!.seq).toBe(4) + expect(rows.map((r) => r.seq)).toEqual([1, 2, 4]) + }) + + it('删除中间行不影响其他 seq', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'deleteRow', pageId: doc.activePageId, rowId: 'r2' }) + const rows = doc.pages[0].rows + expect(rows.map((r) => r.seq)).toEqual([1, 3]) + }) + + it('moveRow 只改顺序不改 seq', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'moveRow', pageId: doc.activePageId, rowId: 'r3', toIndex: 0 }) + const rows = doc.pages[0].rows + expect(rows.map((r) => r.id)).toEqual(['r3', 'r1', 'r2']) + expect(rows.map((r) => r.seq)).toEqual([3, 1, 2]) + }) + + it('editRow 只改 expr', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'editRow', pageId: doc.activePageId, rowId: 'r1', expr: '42' }) + expect(doc.pages[0].rows[0]).toEqual({ id: 'r1', seq: 1, expr: '42' }) + }) +}) + +describe('docReducer pages', () => { + it('addPage 新建页并激活', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'addPage', pageId: 'p2', name: '第二页' }) + expect(doc.pages).toHaveLength(2) + expect(doc.activePageId).toBe('p2') + }) + + it('deletePage 切到相邻页', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'addPage', pageId: 'p2', name: '第二页' }) + doc = docReducer(doc, { type: 'deletePage', pageId: doc.activePageId }) + expect(doc.pages).toHaveLength(1) + expect(doc.activePageId).toBe(doc.pages[0].id) + }) + + it('不能删除最后一页', () => { + const doc = createEmptyDoc() + const next = docReducer(doc, { type: 'deletePage', pageId: doc.activePageId }) + expect(next.pages).toHaveLength(1) + }) + + it('renamePage 改名', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'renamePage', pageId: doc.activePageId, name: '工资' }) + expect(doc.pages[0].name).toBe('工资') + }) + + it('setActivePage 切页', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'addPage', pageId: 'p2', name: 'x' }) + doc = docReducer(doc, { type: 'setActivePage', pageId: doc.pages[0].id }) + expect(doc.activePageId).toBe(doc.pages[0].id) + }) +}) + +describe('持久化', () => { + it('serialize/load 往返', () => { + const doc = docWithRows() + const restored = loadDoc(serializeDoc(doc)) + expect(restored).toEqual(doc) + }) + + it('null → 空文档', () => { + const doc = loadDoc(null) + expect(doc.pages).toHaveLength(1) + expect(doc.pages[0].rows).toEqual([]) + }) + + it('损坏 JSON → 空文档不抛异常(Review Focus #4)', () => { + const doc = loadDoc('{not json') + expect(doc.pages).toHaveLength(1) + expect(doc.activePageId).toBe(doc.pages[0].id) + }) + + it('结构非法(缺 pages)→ 空文档', () => { + const doc = loadDoc('{"foo":1}') + expect(doc.pages).toHaveLength(1) + }) + + it('STORAGE_KEY 固定', () => { + expect(STORAGE_KEY).toBe('calc-draft:doc') + }) +}) + +describe('colorForSeq', () => { + it('同 seq 颜色稳定', () => { + expect(colorForSeq(1)).toBe(colorForSeq(1)) + }) + + it('超出调色板长度循环', () => { + expect(colorForSeq(PALETTE.length + 1)).toBe(colorForSeq(1)) + }) +}) +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `npm test` +Expected: FAIL(`./state`、`./palette` 不存在) + +- [ ] **Step 3: 最小实现** + +创建 `src/CalcDraft/palette.ts`: + +```ts +export const PALETTE = [ + '#7AA2F7', + '#9ECE6A', + '#E0AF68', + '#F7768E', + '#BB9AF7', + '#7DCFFF', + '#C0CAF5', + '#FF9E64', + '#73DACA', + '#FF757F' +] + +export function colorForSeq(seq: number): string { + const index = ((seq - 1) % PALETTE.length + PALETTE.length) % PALETTE.length + return PALETTE[index] +} +``` + +创建 `src/CalcDraft/state.ts`: + +```ts +export type Row = { id: string; seq: number; expr: string } + +export type Page = { + id: string + name: string + rows: Row[] + createdAt: number + seqCounter: number +} + +export type DraftDoc = { + pages: Page[] + activePageId: string +} + +export type Action = + | { type: 'addRow'; pageId: string; rowId: string; expr?: string; afterRowId?: string } + | { type: 'editRow'; pageId: string; rowId: string; expr: string } + | { type: 'deleteRow'; pageId: string; rowId: string } + | { type: 'moveRow'; pageId: string; rowId: string; toIndex: number } + | { type: 'addPage'; pageId: string; name?: string } + | { type: 'deletePage'; pageId: string } + | { type: 'renamePage'; pageId: string; name: string } + | { type: 'setActivePage'; pageId: string } + +export const STORAGE_KEY = 'calc-draft:doc' + +export function createEmptyDoc(): DraftDoc { + const page: Page = { + id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()), + name: '未命名草稿', + rows: [], + createdAt: Date.now(), + seqCounter: 0 + } + return { pages: [page], activePageId: page.id } +} + +function updatePage(doc: DraftDoc, pageId: string, fn: (page: Page) => Page): DraftDoc { + return { + ...doc, + pages: doc.pages.map((p) => (p.id === pageId ? fn(p) : p)) + } +} + +export function docReducer(doc: DraftDoc, action: Action): DraftDoc { + switch (action.type) { + case 'addRow': + return updatePage(doc, action.pageId, (page) => { + const seqCounter = page.seqCounter + 1 + const row: Row = { id: action.rowId, seq: seqCounter, expr: action.expr ?? '' } + if (action.afterRowId) { + const index = page.rows.findIndex((r) => r.id === action.afterRowId) + if (index >= 0) { + const rows = [...page.rows] + rows.splice(index + 1, 0, row) + return { ...page, rows, seqCounter } + } + } + return { ...page, rows: [...page.rows, row], seqCounter } + }) + case 'editRow': + return updatePage(doc, action.pageId, (page) => ({ + ...page, + rows: page.rows.map((r) => (r.id === action.rowId ? { ...r, expr: action.expr } : r)) + })) + case 'deleteRow': + return updatePage(doc, action.pageId, (page) => ({ + ...page, + rows: page.rows.filter((r) => r.id !== action.rowId) + })) + case 'moveRow': + return updatePage(doc, action.pageId, (page) => { + const from = page.rows.findIndex((r) => r.id === action.rowId) + if (from < 0) return page + const to = Math.max(0, Math.min(action.toIndex, page.rows.length - 1)) + if (from === to) return page + const rows = [...page.rows] + const [moved] = rows.splice(from, 1) + rows.splice(to, 0, moved) + return { ...page, rows } + }) + case 'addPage': { + const page: Page = { + id: action.pageId, + name: action.name ?? `草稿 ${doc.pages.length + 1}`, + rows: [], + createdAt: Date.now(), + seqCounter: 0 + } + return { pages: [...doc.pages, page], activePageId: page.id } + } + case 'deletePage': { + if (doc.pages.length <= 1) return doc + const pages = doc.pages.filter((p) => p.id !== action.pageId) + if (pages.length === doc.pages.length) return doc + const activePageId = + doc.activePageId === action.pageId ? pages[0].id : doc.activePageId + return { pages, activePageId } + } + case 'renamePage': + return updatePage(doc, action.pageId, (page) => ({ ...page, name: action.name })) + case 'setActivePage': + return doc.pages.some((p) => p.id === action.pageId) + ? { ...doc, activePageId: action.pageId } + : doc + default: + return doc + } +} + +function isDraftDoc(value: any): value is DraftDoc { + return ( + value && + Array.isArray(value.pages) && + value.pages.length > 0 && + typeof value.activePageId === 'string' && + value.pages.every( + (p: any) => + p && + typeof p.id === 'string' && + Array.isArray(p.rows) && + typeof p.seqCounter === 'number' + ) && + value.pages.some((p: any) => p.id === value.activePageId) + ) +} + +export function loadDoc(raw: string | null): DraftDoc { + if (!raw) return createEmptyDoc() + try { + const parsed = JSON.parse(raw) + if (isDraftDoc(parsed)) return parsed as DraftDoc + return createEmptyDoc() + } catch { + return createEmptyDoc() + } +} + +export function serializeDoc(doc: DraftDoc): string { + return JSON.stringify(doc) +} +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `npm test` +Expected: PASS(含 Review Focus #1、#4) + +- [ ] **Step 5: Commit** + +```bash +git add src/CalcDraft/state.ts src/CalcDraft/palette.ts src/CalcDraft/state.test.ts +git commit -m "feat: draft state model with stable seq and palette" +``` + +--- + +### Task 4: 插件接入与 CalcDraft 空骨架 + +**Files:** +- Modify: `src-ztools/plugin.json` +- Modify: `src/App.tsx` +- Create: `src/CalcDraft/index.tsx` +- Create: `src/CalcDraft/index.css` +- Modify: `src/main.css` +- Modify: `src/env.d.ts` +- Modify: `src-ztools/preload/services.js` +- Delete: `src/Hello/`、`src/Read/`、`src/Write/` + +**Interfaces:** +- Consumes: `createEmptyDoc`、`docReducer`、`loadDoc`、`serializeDoc`、`STORAGE_KEY`(Task 3) +- Produces: 默认导出 `CalcDraft`:React 组件,挂载后展示页签栏 + 空状态 + 底栏;后续任务在其上加行列表 + +- [ ] **Step 1: 收敛 plugin.json** + +将 `src-ztools/plugin.json` 的 `features` 替换为单一 calc 功能,并加 `pluginSetting`: + +```json +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "calc-draft", + "title": "计算草稿", + "description": "计算草稿纸,记录多步计算,友好且现代的交互界面", + "author": "Agitator", + "version": "0.0.1", + "main": "dist/index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://localhost:5173" + }, + "pluginSetting": { + "height": 840, + "single": true + }, + "features": [ + { + "code": "calc", + "explain": "计算草稿:多步计算,中间结果可引用", + "icon": "logo.png", + "cmds": ["计算", "草稿", "calc"] + } + ] +} +``` + +- [ ] **Step 2: 清空模板服务与类型** + +`src-ztools/preload/services.js` 整文件替换为: + +```js +// 通过 window 对象向渲染进程注入 nodejs 能力(当前无服务,保留扩展点) +window.services = {} +``` + +`src/env.d.ts` 整文件替换为: + +```ts +/// +/// + +declare global { + interface Window { + services: Record + } +} + +export {} +``` + +- [ ] **Step 3: 删除模板组件** + +```bash +git rm -r src/Hello src/Read src/Write +``` + +- [ ] **Step 4: 路由接入** + +`src/App.tsx` 整文件替换为: + +```tsx +import { useEffect, useState } from 'react' +import CalcDraft from './CalcDraft' + +export default function App() { + const [route, setRoute] = useState('') + + useEffect(() => { + window.ztools.onPluginEnter((action: any) => { + setRoute(action.code) + }) + window.ztools.onPluginOut(() => { + setRoute('') + }) + }, []) + + if (route === 'calc') return + + return null +} +``` + +- [ ] **Step 5: 空骨架组件与样式** + +创建 `src/CalcDraft/index.tsx`: + +```tsx +import { useEffect, useReducer, useState } from 'react' +import './index.css' +import { createEmptyDoc, docReducer, loadDoc, serializeDoc, STORAGE_KEY } from './state' + +export default function CalcDraft() { + const [doc, dispatch] = useReducer(docReducer, undefined, () => + loadDoc(typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null) + ) + const [ready, setReady] = useState(false) + + useEffect(() => { + setReady(true) + }, []) + + useEffect(() => { + if (!ready) return + const timer = setTimeout(() => { + try { + localStorage.setItem(STORAGE_KEY, serializeDoc(doc)) + } catch { + // 存储失败不阻塞使用 + } + }, 500) + return () => clearTimeout(timer) + }, [doc, ready]) + + const page = doc.pages.find((p) => p.id === doc.activePageId) ?? doc.pages[0] ?? createEmptyDoc().pages[0] + + return ( +
+
+
+ {doc.pages.map((p) => ( + + ))} + +
+
+ {page.name} · {page.rows.length} 行 +
+
+ +
+
+ 输入表达式,回车开始计算 · 支持 #1 引用上文结果 +
+
+ +
+ Enter 新行 · 点击数字复制 · Alt+点击 注入 +
+
+ ) +} +``` + +创建 `src/CalcDraft/index.css`: + +```css +.calc-draft { + box-sizing: border-box; + height: 100vh; + display: flex; + flex-direction: column; + padding: 12px; + gap: 10px; + background: + radial-gradient(1200px 600px at 50% -10%, rgba(122, 162, 247, 0.12), transparent 60%), + rgba(24, 24, 32, 0.92); + color: #c0caf5; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + backdrop-filter: blur(20px); +} + +.cd-topbar { + border-radius: 16px; + padding: 12px 16px; + background: rgba(30, 30, 44, 0.75); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.cd-tabs { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.cd-tab { + border: none; + background: rgba(255, 255, 255, 0.06); + color: rgba(192, 202, 245, 0.55); + border-radius: 999px; + padding: 6px 14px; + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; +} + +.cd-tab:hover { + background: rgba(255, 255, 255, 0.1); + color: #c0caf5; +} + +.cd-tab-active { + background: rgba(255, 255, 255, 0.12); + color: #fff; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 16px rgba(122, 162, 247, 0.25); +} + +.cd-tab-add { + border: none; + background: transparent; + color: rgba(192, 202, 245, 0.55); + font-size: 16px; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; +} + +.cd-tab-add:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.cd-meta { + margin-top: 8px; + font-size: 12px; + color: rgba(192, 202, 245, 0.45); +} + +.cd-body { + flex: 1; + min-height: 0; + overflow-y: auto; + border-radius: 20px; + padding: 16px; + background: rgba(18, 18, 26, 0.55); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.cd-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(192, 202, 245, 0.35); + font-size: 14px; + letter-spacing: 0.02em; +} + +.cd-statusbar { + border-radius: 14px; + padding: 8px 16px; + font-size: 12px; + color: rgba(192, 202, 245, 0.45); + background: rgba(30, 30, 44, 0.65); + border: 1px solid rgba(255, 255, 255, 0.05); +} +``` + +修改 `src/main.css`:将全局 `button` 的 `background: none var(--blue)` 改为避免覆盖插件按钮样式: + +```css +button { + border: none; + background: none; + color: var(--light); + line-height: 2.5; + cursor: pointer; + transition: opacity 0.2s; + font-family: inherit; +} +``` + +- [ ] **Step 6: 类型检查 + 测试** + +Run: `npx tsc --noEmit && npm test` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat: plugin wiring with calc route and empty shell" +``` + +--- + +### Task 5: 行列表渲染与求值接线 + +**Files:** +- Create: `src/CalcDraft/components/CalcRow.tsx` +- Create: `src/CalcDraft/components/NumberCell.tsx` +- Create: `src/CalcDraft/components/Toast.tsx` +- Modify: `src/CalcDraft/index.tsx` +- Modify: `src/CalcDraft/index.css` + +**Interfaces:** +- Consumes: + - `evaluatePage`、`CellResult`、`formatNumber`、`upstreamRowIds`(Task 2) + - `colorForSeq`(Task 3) + - `tokenizeForDisplay`(Task 1) + - `docReducer` actions(Task 3) +- Produces: + - `CalcDraft` 默认导出增强:可见行列表、结果、编辑、复制/注入、聚光灯 + - ` void} onInject={(text) => void} />` + - `` + - `` props 见实现 + +- [ ] **Step 1: 创建 NumberCell** + +创建 `src/CalcDraft/components/NumberCell.tsx`: + +```tsx +interface NumberCellProps { + text: string + onCopy: (text: string) => void + onInject: (text: string) => void + className?: string +} + +export default function NumberCell({ text, onCopy, onInject, className }: NumberCellProps) { + return ( + + ) +} +``` + +- [ ] **Step 2: 创建 Toast** + +创建 `src/CalcDraft/components/Toast.tsx`: + +```tsx +export default function Toast({ message }: { message: string | null }) { + if (!message) return null + return
{message}
+} +``` + +- [ ] **Step 3: 创建 CalcRow** + +创建 `src/CalcDraft/components/CalcRow.tsx`: + +```tsx +import { tokenizeForDisplay } from '../engine/parser' +import type { CellResult } from '../engine/evaluator' +import { formatNumber } from '../engine/evaluator' +import { colorForSeq } from '../palette' +import NumberCell from './NumberCell' + +export type CalcRowProps = { + rowId: string + seq: number + expr: string + result: CellResult + color: string + isEditing: boolean + isActive: boolean + isUpstream: boolean + onChange: (expr: string) => void + onFocus: () => void + onBlur: () => void + onEnter: () => void + onEscape: () => void + onCopy: (text: string) => void + onInject: (text: string) => void + onRequestEdit: () => void +} + +export default function CalcRow(props: CalcRowProps) { + const { rowId, seq, expr, result, color, isEditing, isActive, isUpstream } = props + const classes = [ + 'cd-row', + isActive ? 'cd-row-active' : '', + isUpstream ? 'cd-row-upstream' : '', + isEditing ? 'cd-row-editing' : '' + ] + .filter(Boolean) + .join(' ') + + return ( +
+ + #{seq} + + +
+ {isEditing ? ( + props.onChange(e.target.value)} + onFocus={props.onFocus} + onBlur={props.onBlur} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + props.onEnter() + } else if (e.key === 'Escape') { + e.preventDefault() + props.onEscape() + } + }} + /> + ) : ( + + {tokenizeForDisplay(expr).map((t, i) => { + if (t.type === 'number') { + return ( + + ) + } + if (t.type === 'ref') { + return ( + + {t.text} + + ) + } + return {t.text} + })} + {expr.trim() === '' && 输入表达式…} + + )} +
+ +
+ {result.status === 'ok' && ( + + )} + {result.status === 'error' && {result.message}} + {result.status === 'empty' && =} +
+
+ ) +} +``` + +- [ ] **Step 4: 接线 index.tsx** + +用以下内容替换 `src/CalcDraft/index.tsx`(在 Task 4 版本上扩展;`doc`/`dispatch`/持久化逻辑不变,body 区改为行列表;页签可先保持简单按钮版,Task 8 再增强): + +```tsx +import { useEffect, useMemo, useReducer, useRef, useState } from 'react' +import './index.css' +import { createEmptyDoc, docReducer, loadDoc, serializeDoc, STORAGE_KEY } from './state' +import { evaluatePage, upstreamRowIds, type CellResult } from './engine/evaluator' +import { colorForSeq } from './palette' +import CalcRow from './components/CalcRow' +import Toast from './components/Toast' + +export default function CalcDraft() { + const [doc, dispatch] = useReducer(docReducer, undefined, () => + loadDoc(typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null) + ) + const [ready, setReady] = useState(false) + const [editingRowId, setEditingRowId] = useState(null) + const [activeRowId, setActiveRowId] = useState(null) + const [toast, setToast] = useState(null) + const [pendingFocusId, setPendingFocusId] = useState(null) + const toastTimer = useRef(null) + + const page = + doc.pages.find((p) => p.id === doc.activePageId) ?? doc.pages[0] ?? createEmptyDoc().pages[0] + + const evaluation = useMemo(() => evaluatePage(page.rows), [page.rows]) + + useEffect(() => { + setReady(true) + }, []) + + useEffect(() => { + if (!ready) return + const timer = setTimeout(() => { + try { + localStorage.setItem(STORAGE_KEY, serializeDoc(doc)) + } catch { + // 忽略存储失败 + } + }, 500) + return () => clearTimeout(timer) + }, [doc, ready]) + + useEffect(() => { + if (!pendingFocusId) return + setEditingRowId(pendingFocusId) + setPendingFocusId(null) + }, [pendingFocusId, page.rows]) + + const showToast = (message: string) => { + setToast(message) + if (toastTimer.current) window.clearTimeout(toastTimer.current) + toastTimer.current = window.setTimeout(() => setToast(null), 1500) + } + + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + showToast(`已复制 ${text}`) + } catch { + showToast('复制失败') + } + } + + const injectRef = useRef(null) + document.addEventListener('focusin', (e) => { + if (e.target instanceof HTMLInputElement && e.target.classList.contains('cd-input')) { + injectRef.current = e.target + } + }) + + const handleInject = (text: string) => { + const input = injectRef.current + if (!input || !document.contains(input)) { + showToast('请先聚焦一行输入框') + return + } + input.focus() + const start = input.selectionStart ?? input.value.length + const end = input.selectionEnd ?? start + const next = input.value.slice(0, start) + text + input.value.slice(end) + const cursor = start + text.length + const rowId = input.closest('[data-row-id]')?.getAttribute('data-row-id') + if (rowId) { + dispatch({ type: 'editRow', pageId: page.id, rowId, expr: next }) + requestAnimationFrame(() => { + input.setSelectionRange(cursor, cursor) + }) + } + } + + const addRow = (afterRowId?: string) => { + const rowId = + typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()) + dispatch({ type: 'addRow', pageId: page.id, rowId, expr: '', afterRowId }) + setPendingFocusId(rowId) + return rowId + } + + const upstream = activeRowId ? upstreamRowIds(evaluation.directDeps, activeRowId) : new Set() + + return ( +
+
+
+ {doc.pages.map((p) => ( + + ))} + +
+
+ {page.name} · {page.rows.length} 行 +
+
+ +
+ {page.rows.length === 0 ? ( +
输入表达式,回车开始计算 · 支持 #1 引用上文结果
+ ) : ( +
+ {page.rows.map((row) => { + const result: CellResult = evaluation.results.get(row.id) ?? { status: 'empty' } + return ( + + dispatch({ type: 'editRow', pageId: page.id, rowId: row.id, expr }) + } + onFocus={() => setActiveRowId(row.id)} + onBlur={() => { + setActiveRowId((current) => (current === row.id ? null : current)) + setEditingRowId((current) => (current === row.id ? null : current)) + }} + onEnter={() => addRow(row.id)} + onEscape={(undefined as any)} + onCopy={handleCopy} + onInject={handleInject} + onRequestEdit={() => { + setEditingRowId(row.id) + setActiveRowId(row.id) + }} + /> + ) + })} +
+ )} +
+ +
+ + Enter 新行 · 点击数字复制 · Alt+点击 注入 +
+ + +
+ ) +} +``` + +注意:实现时将 `onEscape` 正确传为 `() => { setEditingRowId(null) }`(上方 `undefined as any` 仅为避免复制遗漏,编码时必须写成真实回调);`focusin` 监听放入 `useEffect` 并在卸载时移除,不得在 render 期间直接 `document.addEventListener`。 + +- [ ] **Step 5: 行样式追加到 index.css** + +在 `src/CalcDraft/index.css` 末尾追加: + +```css +.cd-rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cd-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: center; + padding: 10px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); + transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.2s ease; +} + +.cd-body.has-focus .cd-row:not(.cd-row-active):not(.cd-row-upstream) { + opacity: 0.55; +} + +.cd-row:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28); +} + +.cd-row-upstream { + box-shadow: 0 0 0 1px var(--row-color), 0 4px 12px rgba(0, 0, 0, 0.22); +} + +.cd-row-active { + background: rgba(255, 255, 255, 0.07); +} + +.cd-seq { + font-size: 11px; + font-weight: 600; + color: #1a1b26; + border-radius: 999px; + padding: 3px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.cd-expr { + min-width: 0; + cursor: text; +} + +.cd-input { + width: 100%; + border: none; + outline: none; + background: transparent; + color: #c0caf5; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 14px; + line-height: 1.5; + padding: 0; +} + +.cd-expr-view { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 14px; + line-height: 1.5; + word-break: break-all; +} + +.cd-ref { + font-weight: 600; +} + +.cd-placeholder { + color: rgba(192, 202, 245, 0.3); +} + +.cd-result { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 18px; + font-weight: 600; + text-align: right; + min-width: 72px; +} + +.cd-num { + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 2px 6px; + border-radius: 8px; + cursor: pointer; + line-height: 1.4; +} + +.cd-num:hover { + background: rgba(255, 255, 255, 0.1); +} + +.cd-num-result { + color: #9ece6a; + font-size: 18px; + font-weight: 600; +} + +.cd-num-inline { + color: #e0af68; + font-size: 14px; +} + +.cd-error { + color: #f7768e; + font-size: 12px; + font-weight: 500; +} + +.cd-result-empty { + color: rgba(192, 202, 245, 0.3); +} + +.cd-add-btn { + border: none; + background: rgba(122, 162, 247, 0.18); + color: #7aa2f7; + border-radius: 999px; + padding: 4px 12px; + font-size: 12px; + cursor: pointer; + line-height: 1.6; +} + +.cd-add-btn:hover { + background: rgba(122, 162, 247, 0.3); +} + +.cd-statusbar { + display: flex; + align-items: center; + gap: 12px; +} + +.cd-toast { + position: fixed; + top: 20px; + right: 20px; + background: rgba(40, 44, 60, 0.95); + color: #c0caf5; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + padding: 8px 16px; + font-size: 13px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + animation: cd-toast-in 0.18s ease; + z-index: 100; +} + +@keyframes cd-toast-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +同时给 `.cd-body` 的聚焦态:在 `index.tsx` 的 `
` 上改为 `className={`cd-body${activeRowId ? ' has-focus' : ''}`}`。 + +- [ ] **Step 6: 类型检查 + 测试** + +Run: `npx tsc --noEmit && npm test` +Expected: PASS + +手动验证(`npm run dev` + ZTools 或浏览器): +- 输入 `1+2` 回车 → 显示 `= 3` +- 第二行输入 `#1*10` → `= 30`,`#1` 着色 +- 点击结果 `3` → toast「已复制 3」 +- 点击某行进入编辑,其余行变暗,上游行描边 + +- [ ] **Step 7: Commit** + +```bash +git add src/CalcDraft/ +git commit -m "feat: row list with evaluation, copy cells, spotlight" +``` + +--- + +### Task 6: 行操作、页签增强与快捷键 + +**Files:** +- Modify: `src/CalcDraft/components/CalcRow.tsx` +- Modify: `src/CalcDraft/index.tsx` +- Modify: `src/CalcDraft/index.css` +- Create: `src/CalcDraft/components/PageTabs.tsx` + +**Interfaces:** +- Consumes: Task 3 `docReducer`(deleteRow/moveRow/renamePage/deletePage/addPage)、Task 5 的 CalcDraft 状态 +- Produces: + - `` + - CalcRow 增加:hover 操作(复制表达式/复制结果/删除)、HTML5 拖拽 + - CalcDraft 全局快捷键:`Cmd/Ctrl+D`、`Cmd/Ctrl+Shift+N`、`Cmd/Ctrl+Enter`、`Alt+↑/↓` + +- [ ] **Step 1: 创建 PageTabs** + +创建 `src/CalcDraft/components/PageTabs.tsx`: + +```tsx +import { useState } from 'react' +import type { Page } from '../state' + +type PageTabsProps = { + pages: Page[] + activePageId: string + onCreate: () => void + onRename: (pageId: string, name: string) => void + onDelete: (pageId: string) => void + onSwitch: (pageId: string) => void +} + +export default function PageTabs(props: PageTabsProps) { + const [menuFor, setMenuFor] = useState(null) + const [editingId, setEditingId] = useState(null) + const [draftName, setDraftName] = useState('') + + const commitRename = (pageId: string) => { + const name = draftName.trim() + if (name) props.onRename(pageId, name) + setEditingId(null) + } + + return ( +
+ {props.pages.map((p) => ( +
+ {editingId === p.id ? ( + setDraftName(e.target.value)} + onBlur={() => commitRename(p.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(p.id) + if (e.key === 'Escape') setEditingId(null) + }} + /> + ) : ( + + )} +
+ ))} + +
+ ) +} +``` + +- [ ] **Step 2: CalcRow 增加操作与拖拽** + +在 `src/CalcDraft/components/CalcRow.tsx` 的 `CalcRowProps` 增加: + +```ts +onDelete: () => void +onMove: (rowId: string, toIndex: number, position: 'before' | 'after') => void +onCopyExpr: () => void +onDragStateChange?: (dragging: boolean) => void +``` + +在行根 `div` 上增加拖拽属性与 hover 操作条(`cd-row-actions`): + +```tsx +
{ + e.dataTransfer.setData('text/row-id', rowId) + e.dataTransfer.effectAllowed = 'move' + props.onDragStateChange?.(true) + }} + onDragEnd={() => props.onDragStateChange?.(false)} + onDragOver={(e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }} + onDrop={(e) => { + e.preventDefault() + const fromId = e.dataTransfer.getData('text/row-id') + if (!fromId || fromId === rowId) return + const rect = e.currentTarget.getBoundingClientRect() + const position = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' + props.onMove(fromId, index, position) + }} +> +``` + +为支持 `onMove` 的 before/after 语义,CalcRow 需接收 `index: number`;在 `CalcDraft` 中换算:`toIndex = position === 'before' ? index : index + 1`(若 from < to 则减 1,由 reducer 按 id 重插实现时注意:reducer `moveRow` 按目标下标插入,调用前先删除再插入的语义已在 reducer 中通过 splice 实现,`toIndex` 传最终期望下标即可——实现时在 CalcDraft 内计算:先找 fromIndex,若 from < to 目标则 to 调整)。 + +hover 操作条 JSX(放在 `.cd-result` 之后): + +```tsx +
+ + +
+``` + +对应样式: + +```css +.cd-row-actions { + display: none; + gap: 4px; + position: absolute; + right: 10px; + top: -10px; +} + +.cd-row { + position: relative; +} + +.cd-row:hover .cd-row-actions { + display: flex; +} + +.cd-row-actions button { + border: none; + background: rgba(40, 44, 60, 0.95); + color: #c0caf5; + width: 24px; + height: 24px; + border-radius: 8px; + font-size: 12px; + cursor: pointer; + line-height: 1; + padding: 0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +.cd-tab-wrap { + position: relative; +} + +.cd-tab-menu { + margin-left: 6px; + opacity: 0.7; +} + +.cd-tab-input { + border: none; + outline: none; + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-radius: 999px; + padding: 6px 12px; + font-size: 13px; + width: 110px; +} +``` + +- [ ] **Step 3: CalcDraft 接入页签、行操作与快捷键** + +修改 `src/CalcDraft/index.tsx`: + +1. 顶部栏改用 `PageTabs`: + +```tsx + + dispatch({ + type: 'addPage', + pageId: crypto.randomUUID() + }) + } + onRename={(pageId, name) => dispatch({ type: 'renamePage', pageId, name })} + onDelete={(pageId) => dispatch({ type: 'deletePage', pageId })} + onSwitch={(pageId) => dispatch({ type: 'setActivePage', pageId })} +/> +``` + +2. CalcRow 传入删除/移动/复制表达式: + +```tsx +const rows = page.rows +const handleMove = (fromId: string, toIndexBase: number, position: 'before' | 'after') => { + const fromIndex = rows.findIndex((r) => r.id === fromId) + if (fromIndex < 0) return + let toIndex = position === 'before' ? toIndexBase : toIndexBase + 1 + if (fromIndex < toIndex) toIndex -= 1 + if (fromIndex === toIndex) return + dispatch({ type: 'moveRow', pageId: page.id, rowId: fromId, toIndex }) +} +``` + +在 map 中补: + +```tsx +index={index} +onDelete={() => { + dispatch({ type: 'deleteRow', pageId: page.id, rowId: row.id }) +}} +onMove={handleMove} +onCopyExpr={() => handleCopy(row.expr)} +``` + +(`page.rows.map((row, index) => ...)`) + +3. 全局快捷键(`useEffect` 挂载/卸载): + +```tsx +useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey + if (mod && !e.shiftKey && e.key.toLowerCase() === 'd') { + e.preventDefault() + const row = page.rows.find((r) => r.id === activeRowId) || page.rows[page.rows.length - 1] + if (!row) return + const res = evaluation.results.get(row.id) + if (res && res.status === 'ok') handleCopy(String(res.value)) + else showToast('无结果可复制') + } else if (mod && e.shiftKey && e.key.toLowerCase() === 'n') { + e.preventDefault() + dispatch({ + type: 'addPage', + pageId: crypto.randomUUID() + }) + } else if (mod && e.key === 'Enter') { + e.preventDefault() + addRow(page.rows[page.rows.length - 1]?.id) + } else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + const rowId = activeRowId + if (!rowId) return + e.preventDefault() + const fromIndex = page.rows.findIndex((r) => r.id === rowId) + if (fromIndex < 0) return + const toIndex = e.key === 'ArrowUp' ? fromIndex - 1 : fromIndex + 1 + if (toIndex < 0 || toIndex >= page.rows.length) return + dispatch({ type: 'moveRow', pageId: page.id, rowId, toIndex }) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) +}, [page, activeRowId, evaluation]) +``` + +- [ ] **Step 4: 类型检查 + 测试** + +Run: `npx tsc --noEmit && npm test` +Expected: PASS + +手动验证: +- 页签双击重命名、hover ⋯ 删除(剩一页不可删) +- 行 hover 出现 ⧉/⌦;拖拽换序后 `#seq` 不变 +- `Cmd/Ctrl+D` 复制、`Alt+↓` 下移、`Cmd/Ctrl+Enter` 末尾加行、`Cmd/Ctrl+Shift+N` 新页 + +- [ ] **Step 5: Commit** + +```bash +git add src/CalcDraft/ +git commit -m "feat: page tabs, row actions, drag reorder, shortcuts" +``` + +--- + +### Task 7: 构建与验收清单 + +**Files:** +- Modify: `README.md`(仅当需要同步功能描述时) +- 无新增业务代码(发现缺陷则修复并回归相关任务测试) + +**Interfaces:** +- Consumes: 全部前序任务 +- Produces: 可发布的 `src-ztools/dist/` 构建产物 + +- [ ] **Step 1: 全量测试与类型检查** + +Run: `npm test && npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 2: 生产构建** + +Run: `npm run build` +Expected: 成功输出 `src-ztools/dist/` + +- [ ] **Step 3: 对照规格走查(Success Criteria)** + +逐项勾验 `docs/superpowers/specs/2026-09-23-calc-draft-design.md` §12: + +1. 多行 `#N` 引用正确;中间插入/删除行引用不断链、结果正确 +2. 同 `seq` 颜色一致,链路可追踪 +3. 单击结果复制 + toast;`Alt+点击` 注入聚焦输入框 +4. 多页独立保存,刷新/重开恢复 +5. 界面:无边框、大圆角、阴影、卡片感、聚光灯(非活动行 0.55 透明度、上游描边) +6. 循环引用/未找到/除零行内红字,且不破坏其他行 + +另验 Review Focus 五条手工场景(删最大 seq 行再新建、环引用、中游编辑、损坏存储恢复、`0.1+0.2` 显示 `0.3`——最后一条以单测为准,UI 抽查)。 + +- [ ] **Step 4: 修复发现的问题(如有)** + +每修复一处:补/改对应测试 → `npm test` → commit: + +```bash +git add -A +git commit -m "fix: " +``` + +- [ ] **Step 5: 最终 Commit(如有 README 更新)** + +```bash +git add README.md docs/ +git commit -m "docs: align readme with calc-draft features" +``` + +--- + +## Self-Review 记录 + +1. **Spec coverage:** 解析/求值/引用(T1-T2)、状态/持久化/多页(T3-4,6)、UI 布局/聚光灯/卡片(T4-5)、复制/注入/Toast(T5)、页签/行操作/快捷键(T6)、plugin.json/preload 清理(T4)、测试与构建(各任务+T7)——均有任务对应。`Shift+Enter` 编辑确认在 T5 CalcRow `onKeyDown` 覆盖。 +2. **Placeholder scan:** T5 Step 4 曾留 `undefined as any` 占位 → 已在同段以文字强制要求实现为真实回调;无 TBD/TODO。 +3. **Type consistency:** `CellResult`/`evaluatePage`/`colorForSeq`/`docReducer` action 形状跨任务一致;T6 新增 props(`index`/`onDelete`/`onMove`/`onCopyExpr`)在 T6 内定义并在 CalcDraft 同任务接线。 +4. **Review Focus:** 五条均在所属任务有对应测试(T3:seq 不复用、损坏存储;T2:环、中游编辑、formatNumber)。 diff --git a/plugins/calc-draft/docs/superpowers/specs/2026-09-23-calc-draft-design.md b/plugins/calc-draft/docs/superpowers/specs/2026-09-23-calc-draft-design.md new file mode 100644 index 000000000..06d582339 --- /dev/null +++ b/plugins/calc-draft/docs/superpowers/specs/2026-09-23-calc-draft-design.md @@ -0,0 +1,216 @@ +# 计算草稿(calc-draft)ZTools 插件设计 + +日期:2026-09-23 +状态:已与需求方逐节确认 + +## 1. 目标与范围 + +做一个 ZTools 插件"计算草稿":在一个界面里输入多步计算,中间结果被保留并可被后续计算引用,像在草稿纸上做计算一样总览全局、随时拷贝任意计算结果。 + +### 已确认的需求 + +- 单栏流式列表,每行是一个可编辑的数学表达式 +- 四则运算 + 括号 + 常用数学函数(`sqrt sin cos tan log ln abs round floor ceil min max pow` 等) +- 中间结果通过稳定 ID 自动引用(`#1`、`#2`…),也可点击将结果值注入当前编辑行 +- 每个 ID 配色,依赖链路在草稿纸中一目了然 +- 每个数字都是独立单元,单击即拷贝 +- 多页草稿,每页独立持久化,像笔记本 +- UI 采用 macOS 设计语言:无边框、大圆角、阴影层次感、卡片感、聚光灯式高亮 +- 窗口本身由 ZTools 接管,插件只负责内容区 + +### 非目标(本期不做) + +- 显式变量命名(`a = 5`)——仅 `#seq` 引用与点击注入 +- 多栏看板、minimap 缩略图 +- 可编程表达式(if / 赋值语句) +- 会话之外的导出(导出文本/图片)留作后续 + +## 2. 方案选型 + +选定 **方案三:jsep 轻量解析库 + 自研引用/求值**。 + +- 用 `jsep`(约 3KB)做表达式 → AST 解析 +- 自研 evaluator 遍历 AST,解析 `#ref`、调用内置函数表、处理常量与运算符 +- 对比自研完整 parser(工作量大)与 mathjs(包体 150KB+、错误信息不完全可控),jsep 在包体与掌控力之间最合适 + +## 3. 数据模型 + +```ts +type DraftDoc = { + pages: Page[] + activePageId: string +} + +type Page = { + id: string // 稳定 UUID + name: string // 页签名,如 "未命名草稿" + rows: Row[] + createdAt: number +} + +type Row = { + id: string // 稳定 UUID,创建后永不变 + seq: number // 创建顺序序号(1,2,3…),用于显示 #N、配色 + expr: string // 原始表达式文本 +} +``` + +### 关键约定 + +- **`#N` 中的 N 是 `seq`(创建顺序),不是视觉行位置。** 插入、删除、拖拽排序均不重新编号 `seq`,已有引用不断链。 +- 每个 `seq` 对应一个固定色(从调色板循环取色)。行的 `#N` 标签、该行结果、表达式中出现的 `#N` 引用使用同色渲染。 +- 行间依赖不落库,求值时从 AST 动态解析 `#ref` 得出。 +- 删除行后其 `seq` 不复用;引用已删除 `seq` 的下游行显示"未找到"错误。 +- 编辑某行后,找到所有直接/间接引用它的行,按拓扑序重算;检测到环时该行组标记错误,不影响其他行。 + +## 4. 状态管理与持久化 + +- 顶层 `useReducer`,action 覆盖:增删行、编辑表达式、新建/重命名/删除页、切换页、行排序。 +- 每次状态变更后对当前页做一次全页求值(预期几十到几百行,性能无忧)。 +- 持久化:`localStorage` 单键 `calc-draft:doc` 存整个 `DraftDoc`,状态变更后 debounce 500ms 写入。 +- 启动时读取;JSON 损坏则回退为空文档。 + +## 5. 表达式解析与求值 + +### 解析管线 + +``` +expr 文本 → jsep() → AST → 自研 evaluator → number | EvalError +``` + +### jsep 配置 + +- 追加标识符字符 `#`,使 `#1`、`#12` 被 lex 成单个 name token +- 二元运算:`+ - * / ^ %`,其中 `^` 右结合 +- 一元运算:`-`(负号) + +### Evaluator 规则 + +| 节点 | 行为 | +|---|---| +| 数字字面量 | 直接返回数值 | +| `#N` | 按 `seq` 查当前页已求值结果;不存在或未算出 → `Error('未找到 #N')` | +| 函数调用 | 查内置函数表;参数个数不符 → `Error` | +| 常量 | `pi`、`e` | +| 二元/一元运算 | 浮点运算;除零 → `Error('除以零')` | +| 环 | 求值维护 `visiting` 集合,重复 → `Error('循环引用')` | + +内置函数表:`sqrt sin cos tan log ln abs round floor ceil min max pow`(后续可扩)。 + +### 求值调度(每页) + +1. 构建依赖图:解析每行 AST,收集 `#ref` 边。 +2. 拓扑排序;有环的节点整组标记 `Error`。 +3. 按拓扑序求值,缓存 `Map`。 + +### 错误呈现 + +- 行内结果显示红色短文案(如 `循环引用`、`未找到 #3`),不弹全局弹窗。 +- 出错的行不阻塞无关行的正常显示。 + +## 6. 界面布局与交互(Spotlight / macOS 风格) + +### 整体框架 + +- 内容区满铺 ZTools 窗口:深色半透明磨砂背景(`backdrop-filter: blur` + 深色底),大圆角 16–20px,柔和多层阴影。 +- 若 ZTools 窗口本身无边框,内部再做一层内圆角卡片,确保"无边框 + 大圆角 + 阴影"层次成立。 + +### 纵向结构(单栏) + +``` +┌─ 顶部栏(毛玻璃,sticky)────────────┐ +│ 页签横向列表: [草稿1] [草稿2] [+] │ ← 多页切换/新建,双击重命名 +│ 当前页名 · 行数 · 依赖链图例色点 │ +├──────────────────────────────────────┤ +│ 行1 #1│ expr_input… │ = 12 │ +│ 行2 #2│ expr+#1… │ = 24 │ ← #1 引用着色 +│ 行3 #3│ … │ = 5 │ +│ … │ +├──────────────────────────────────────┤ +│ 底部快捷条:+ 新行 · 快捷键提示 · 状态 │ +└──────────────────────────────────────┘ +``` + +### 行的视觉(卡片感 + 聚光灯) + +- 每行是一张浅色半透明圆角卡片,行间距 8px;hover 轻微抬起(`translateY` + 阴影加深)。 +- `#N` 标签:胶囊形色块,背景为该 seq 的专属色(低饱和 pastel),文字深色。 +- 表达式输入:无边框透明输入框,等宽字体;输入中的 `#N` 自动染成对应色。 +- 结果区:右对齐、大字号;每个数字是独立 `button`。 +- **聚光灯**:聚焦/编辑中的行全亮,其余行 `opacity: 0.55`;被当前行依赖(直接或间接)的上游行用色环/描边微微高亮。 + +### 页签 + +- 横向胶囊页签;当前页高亮(白色文字 + 微光下划线),非当前页灰色弱化。 +- 右侧 `+` 新建页;页签 hover 出现 `⋯` 菜单(重命名 / 删除)。 + +### 交互细节 + +- `Enter`:确认当前行编辑并新建下一行(焦点移至新行);`Shift+Enter`:仅确认编辑,不新建行。 +- 行 hover 行级操作:`⧉ 复制表达式`、`⎘ 复制结果`、`⌦ 删除`、`↕` 拖拽排序(只改视觉顺序,`seq`/ID 不变)。 +- **点击结果数字 = 复制**到剪贴板 + toast;**`Alt+点击`(或结果旁小箭头)= 注入**到当前聚焦输入框光标处。 +- Toast:macOS 风格小胶囊,1.5s 消失。 +- 空状态:居中淡色引导"输入表达式,回车开始计算 · 支持 #1 引用上文结果"。 + +## 7. ZTools 接入 + +- `plugin.json` 移除模板功能 `hello` / `read` / `write`,仅保留主功能: + - `code: "calc"`,`cmds: ["计算", "草稿", "calc"]`(触发词可再商定) + - 窗口尺寸建议约 720×840、居中(若 ZTools 侧可配) +- `App.tsx` 路由简化为 `calc` → ``;保留 `onPluginEnter/Out`,进入时加载持久化文档。 +- 移除 `Hello/Read/Write` 组件与对应 preload 服务逻辑。 + +## 8. 快捷键 + +| 快捷键 | 行为 | +|---|---| +| `Enter` | 确认当前行并新建下一行 | +| `Shift+Enter` | 仅确认编辑,不新建行 | +| `Esc` | 取消当前行编辑焦点 | +| `Cmd/Ctrl+D` | 复制当前行结果 | +| `Cmd/Ctrl+Shift+N` | 新建草稿页 | +| `Cmd/Ctrl+Enter` | 在末尾插入新行 | +| `Alt+↑/↓` | 当前行上/下移动(视觉排序) | + +## 9. 文件结构 + +``` +src/ +├── main.tsx / main.css / App.tsx / env.d.ts +├── CalcDraft/ +│ ├── index.tsx # 页面骨架:顶栏 + 列表 + 底栏 +│ ├── index.css # 全局布局样式(macOS 风格 tokens) +│ ├── state.ts # reducer、DraftDoc 类型、localStorage 持久化 +│ ├── palette.ts # seq → 颜色映射 +│ ├── engine/ +│ │ ├── parser.ts # jsep 配置 + AST → 依赖提取 +│ │ ├── evaluator.ts # 求值、函数表、环检测、拓扑调度 +│ │ └── engine.test.ts # 引擎单测 +│ └── components/ +│ ├── PageTabs.tsx # 页签 +│ ├── CalcRow.tsx # 单行卡片(输入、#标签、结果) +│ ├── NumberCell.tsx # 可点击复制/注入的数字单元 +│ └── Toast.tsx # 复制提示 +└── ... +``` + +## 10. 测试策略 + +- **引擎层(Vitest 单测)**:四则/优先级/函数/常量、`#ref` 解析、缺失引用、循环引用、拓扑顺序、插入删除行后引用稳定性。 +- **状态层**:reducer 增删行 seq 不变、持久化序列化往返。 +- **UI 手测清单**:点击复制、注入、聚光灯高亮、多页切换、刷新后恢复。 + +## 11. 依赖 + +- 运行时新增:`jsep` +- 开发新增:`vitest` +- 既有:React 19、Vite、TypeScript(不变更) + +## 12. 成功标准 + +1. 输入多行表达式,`#N` 引用正确求值;中间插入/删除行后既有引用不断链、结果正确。 +2. 每个 `seq` 配色一致,依赖链路肉眼可追踪。 +3. 单击任意结果数字可复制并出现 toast;`Alt+点击` 可注入当前编辑行。 +4. 多页草稿独立保存,关闭重开后内容恢复。 +5. 界面呈现 macOS 风格:无边框、大圆角、阴影层次、卡片感、聚光灯高亮。 +6. 循环引用、缺失引用、除零等错误在行内呈现且不破坏其他行。 diff --git a/plugins/calc-draft/index.html b/plugins/calc-draft/index.html new file mode 100644 index 000000000..15376195f --- /dev/null +++ b/plugins/calc-draft/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/plugins/calc-draft/package-lock.json b/plugins/calc-draft/package-lock.json new file mode 100644 index 000000000..f4755bfff --- /dev/null +++ b/plugins/calc-draft/package-lock.json @@ -0,0 +1,2177 @@ +{ + "name": "calc-draft", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "calc-draft", + "version": "1.0.0", + "dependencies": { + "jsep": "^1.4.0", + "pinyin-pro": "^3.29.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "typescript": "^5.3.0", + "vite": "^6.0.11", + "vitest": "^5.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@ztools-center/ztools-api-types": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@ztools-center/ztools-api-types/-/ztools-api-types-1.0.3.tgz", + "integrity": "sha512-dv1eOAIasAupqKaQL/gESk1i2+RebdM/1gvZhrvH2D/bo3enCUsAGJ8nrHnlCLBSOGB81eC/SU0IH8BNsUlmvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001812", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", + "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.438", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinyin-pro": { + "version": "3.29.4", + "resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.29.4.tgz", + "integrity": "sha512-SPXpDT2cHEy+d26V1RXYMlVzXN42hotFAak1fzyWPi4o2dKXb61UqD4pzxDJHwk6gbv8vQ6EfErd+hYX0Qhzug==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/plugins/calc-draft/package.json b/plugins/calc-draft/package.json new file mode 100644 index 000000000..8533730e7 --- /dev/null +++ b/plugins/calc-draft/package.json @@ -0,0 +1,26 @@ +{ + "name": "calc-draft", + "version": "1.0.0", + "description": "计算草稿纸,记录多步计算,友好且现代的交互界面", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "test": "vitest run" + }, + "dependencies": { + "jsep": "^1.4.0", + "pinyin-pro": "^3.29.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "typescript": "^5.3.0", + "vite": "^6.0.11", + "vitest": "^5.0.1" + } +} diff --git a/plugins/calc-draft/src-ztools/logo.png b/plugins/calc-draft/src-ztools/logo.png new file mode 100644 index 000000000..25d6d98da Binary files /dev/null and b/plugins/calc-draft/src-ztools/logo.png differ diff --git a/plugins/calc-draft/src-ztools/plugin.json b/plugins/calc-draft/src-ztools/plugin.json new file mode 100644 index 000000000..e1462a521 --- /dev/null +++ b/plugins/calc-draft/src-ztools/plugin.json @@ -0,0 +1,26 @@ +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "calc-draft", + "title": "计算草稿", + "description": "计算草稿纸,记录多步计算,友好且现代的交互界面", + "author": "Agitator", + "version": "1.0.0", + "main": "dist/index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://localhost:5173" + }, + "pluginSetting": { + "height": 840, + "single": true + }, + "features": [ + { + "code": "calc", + "explain": "计算草稿:多步计算,中间结果可引用", + "icon": "logo.png", + "cmds": ["计算", "草稿", "calc"] + } + ] +} diff --git a/plugins/calc-draft/src-ztools/preload/package.json b/plugins/calc-draft/src-ztools/preload/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/plugins/calc-draft/src-ztools/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/calc-draft/src-ztools/preload/services.js b/plugins/calc-draft/src-ztools/preload/services.js new file mode 100644 index 000000000..62a54ab5f --- /dev/null +++ b/plugins/calc-draft/src-ztools/preload/services.js @@ -0,0 +1,2 @@ +// 通过 window 对象向渲染进程注入 nodejs 能力(当前无服务,保留扩展点) +window.services = {} diff --git a/plugins/calc-draft/src/App.tsx b/plugins/calc-draft/src/App.tsx new file mode 100644 index 000000000..ae8105f45 --- /dev/null +++ b/plugins/calc-draft/src/App.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' +import CalcDraft from './CalcDraft' + +export default function App() { + const [route, setRoute] = useState('') + + useEffect(() => { + // 非 ZTools 环境(如浏览器直接打开)没有插件 API,直接渲染便于调试预览 + if (!window.ztools) { + setRoute('calc') + return + } + window.ztools.onPluginEnter((action: any) => { + setRoute(action.code) + }) + window.ztools.onPluginOut(() => { + setRoute('') + }) + }, []) + + if (route === 'calc') return + + return null +} diff --git a/plugins/calc-draft/src/CalcDraft/components/CalcRow.tsx b/plugins/calc-draft/src/CalcDraft/components/CalcRow.tsx new file mode 100644 index 000000000..dec0cf42f --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/components/CalcRow.tsx @@ -0,0 +1,534 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { pinyin } from 'pinyin-pro' +import { tokenizeForDisplay } from '../engine/parser' +import type { CellResult } from '../engine/evaluator' +import { formatNumber } from '../engine/evaluator' +import { colorForSeq } from '../palette' +import { CONSTANT_NAMES, FUNCTION_NAMES } from '../engine/evaluator' +import NumberCell from './NumberCell' + +export type CalcRowProps = { + rowId: string + index: number + seq: number + expr: string + subtitle?: string + result: CellResult + color: string + refColorFor?: (pos: number) => string + refSubtitleFor?: (pos: number) => string | undefined + isEditing: boolean + isActive: boolean + isUpstream: boolean + inboundFrom?: number + onChange: (expr: string) => void + onFocus: () => void + onBlur: () => void + onEnter: () => void + onEscape: () => void + onCopy: (text: string) => void + onInject: (text: string) => void + onRequestEdit: () => void + onNavigate: (direction: 'up' | 'down') => void + onInputTab: (fromInput: 'expr' | 'subtitle', direction: 'up' | 'down') => void + subtitleFocusPending: boolean + isFlashed?: boolean + onRefClick?: (pos: number) => void + refOptions?: Array<{ pos: number; subtitle?: string }> + onAtClick?: (page: string, ref: string) => void + atSubtitleFor?: (page: string, ref: string) => string | undefined + atRowSubtitleFor?: (page: string, ref: string) => string | undefined + pageOptions?: Array<{ id: string; name: string; rows: Array<{ pos: number; subtitle?: string }> }> + onSubtitleChange: (subtitle: string) => void + onDelete: () => void + onMove: (rowId: string, toIndex: number, position: 'before' | 'after') => void + onDragStateChange?: (dragging: boolean) => void +} + +export default function CalcRow(props: CalcRowProps) { + const { rowId, index, seq, expr, result, color, isEditing, isActive, isUpstream, inboundFrom } = + props + const [editingSubtitle, setEditingSubtitle] = useState(false) + const [subtitleDraft, setSubtitleDraft] = useState('') + const subtitleSizerRef = useRef(null) + const escSkipBlurRef = useRef(false) + const [subtitleInputWidth, setSubtitleInputWidth] = useState(null) + + // 引用自动补全:# 引用本行所在草稿;@ 进入跨草稿模式(先选草稿再选行) + const exprInputRef = useRef(null) + const [ac, setAc] = useState<{ + start: number + mode: 'ref' | 'at' | 'atRow' | 'func' + page?: string + query: string + } | null>(null) + const [acIndex, setAcIndex] = useState(0) + const [acPos, setAcPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }) + const [acFlip, setAcFlip] = useState(false) + const acItemRefs = useRef>(new Map()) + const acRootRef = useRef(null) + + const matchPy = (sub: string, rest: string) => { + const lower = sub.toLowerCase() + if (lower.includes(rest)) return true + const full = pinyin(sub, { pattern: 'pinyin', toneType: 'none', type: 'array' }).join('') + if (full.includes(rest)) return true + const first = pinyin(sub, { pattern: 'first', toneType: 'none', type: 'array' }).join('') + return first.includes(rest) + } + + type AcCandidate = { label: string; note: string; insert: string } + const acCandidates: AcCandidate[] = (() => { + if (!ac) return [] + if (ac.mode === 'at') { + return (props.pageOptions ?? []) + .map((p, i) => ({ p, idx: i + 1 })) + .filter(({ p, idx }) => !ac.query || String(idx).startsWith(ac.query) || matchPy(p.name, ac.query)) + .map(({ p, idx }) => ({ + label: `@${idx}`, + note: `${p.name} · ${p.rows.length} 行`, + insert: `@${idx}#` + })) + } + if (ac.mode === 'func') { + const q = ac.query.toLowerCase() + return [ + ...FUNCTION_NAMES.filter((n) => n.startsWith(q)).map((n) => ({ + label: `${n}(`, + note: '函数', + insert: `${n}(` + })), + ...CONSTANT_NAMES.filter((n) => n.startsWith(q)).map((n) => ({ + label: n, + note: '常量', + insert: n + })) + ] + } + const rows = + ac.mode === 'atRow' + ? props.pageOptions?.[Number(ac.page) - 1]?.rows ?? [] + : props.refOptions ?? [] + const digits = /^\d*/.exec(ac.query)![0] + const rest = ac.query.slice(digits.length) + return rows + .filter((o) => String(o.pos).startsWith(digits) && (!rest || matchPy(o.subtitle ?? '', rest))) + .map((o) => ({ + label: `#${o.pos}`, + note: o.subtitle || (ac.mode === 'atRow' ? `草稿「${ac.page}」` : '(无备注)'), + insert: `#${o.pos}` + })) + })() + + const closeAc = () => setAc(null) + + // 方向键在补全列表中移动时,最小滚动让当前选中项可见: + // 向下选对齐容器底部,向上选对齐容器顶部 + useEffect(() => { + const root = acRootRef.current + const el = acItemRefs.current.get(acIndex) + if (!root || !el) return + const top = el.offsetTop + const bottom = top + el.offsetHeight + if (top < root.scrollTop) { + root.scrollTop = top + } else if (bottom > root.scrollTop + root.clientHeight) { + root.scrollTop = bottom - root.clientHeight + } + }, [acIndex, ac, acCandidates.length]) + + const detectAc = (value: string, caret: number) => { + const before = value.slice(0, caret) + let m = /@(\d+)#([^#\s@+\-*/^%(),:]*)$/.exec(before) + if (m) { + setAc({ start: caret - m[0].length, mode: 'atRow', page: m[1], query: m[2] }) + setAcIndex(0) + } else if ((m = /@([^#@]*)$/.exec(before))) { + setAc({ start: caret - m[0].length, mode: 'at', query: m[1] }) + setAcIndex(0) + } else if ((m = /#(\d*)([^#\s@]*)$/.exec(before))) { + setAc({ start: caret - m[0].length, mode: 'ref', query: m[0].slice(1) }) + setAcIndex(0) + } else if ((m = /([A-Za-z][A-Za-z0-9]*)$/.exec(before))) { + // 函数/常量补全:仅当标识符处于表达式位置(行首、运算符或括号之后) + const start = caret - m[0].length + const prev = start > 0 ? before[start - 1] : '' + if (!prev || '+-*/^%(),: '.includes(prev)) { + setAc({ start, mode: 'func', query: m[0] }) + setAcIndex(0) + } else { + closeAc() + return + } + } else { + closeAc() + return + } + const rect = exprInputRef.current?.getBoundingClientRect() + if (rect) { + // 下方空间不足且上方更宽裕时,翻转到输入框上方 + const spaceBelow = window.innerHeight - rect.bottom + const flip = spaceBelow < 300 && rect.top > spaceBelow + setAcFlip(flip) + setAcPos({ left: rect.left, top: flip ? rect.top - 6 : rect.bottom + 6 }) + } + } + + // 页面滚动时关闭补全,避免固定定位的浮层与输入框错位; + // 下拉容器自身的滚动(键盘 scrollIntoView / 鼠标滚轮)不算,忽略之 + useEffect(() => { + if (!ac) return + const close = (e: Event) => { + if (acRootRef.current && e.target instanceof Node && acRootRef.current.contains(e.target)) return + closeAc() + } + window.addEventListener('scroll', close, true) + return () => window.removeEventListener('scroll', close, true) + }, [ac]) + + const selectAc = (cand: AcCandidate) => { + if (!ac) return + const input = exprInputRef.current + const caret = input?.selectionStart ?? ac.start + ac.query.length + 1 + // 跨草稿行引用要保留页前缀:@草稿1#1 + const insertText = ac.mode === 'atRow' ? `@${ac.page}${cand.insert}` : cand.insert + const next = props.expr.slice(0, ac.start) + insertText + props.expr.slice(caret) + props.onChange(next) + if (ac.mode === 'at') { + // 选完草稿后保持补全开启,下一步直接选该草稿的行 + const newCaret = ac.start + cand.insert.length + requestAnimationFrame(() => { + const el = exprInputRef.current + if (el) el.setSelectionRange(newCaret, newCaret) + detectAc(next, newCaret) + }) + } else { + closeAc() + const newCaret = ac.start + insertText.length + requestAnimationFrame(() => { + const el = exprInputRef.current + if (el) el.setSelectionRange(newCaret, newCaret) + }) + } + } + + // 备注输入框宽度:用隐藏镜像 span 测量实际像素宽(中英文都精确),空时量 "[备注]" + useEffect(() => { + const el = subtitleSizerRef.current + if (el) setSubtitleInputWidth(el.getBoundingClientRect().width + 12) + }, [subtitleDraft]) + + // Alt+方向键跨行跳转:父组件指定本行备注获得焦点 + useEffect(() => { + if (props.subtitleFocusPending) { + setSubtitleDraft(props.subtitle ?? '') + setEditingSubtitle(true) + } + }, [props.subtitleFocusPending]) + + const openSubtitleEdit = () => { + setSubtitleDraft(props.subtitle ?? '') + setEditingSubtitle(true) + } + + const commitSubtitleEdit = () => { + props.onSubtitleChange(subtitleDraft) + setEditingSubtitle(false) + } + const classes = [ + 'cd-row', + isActive ? 'cd-row-active' : '', + isUpstream ? 'cd-row-upstream' : '', + isEditing ? 'cd-row-editing' : '', + props.isFlashed ? 'cd-row-flash' : '' + ] + .filter(Boolean) + .join(' ') + + return ( +
{ + e.dataTransfer.setData('text/row-id', rowId) + e.dataTransfer.effectAllowed = 'move' + props.onDragStateChange?.(true) + }} + onDragEnd={() => props.onDragStateChange?.(false)} + onDragOver={(e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }} + onDrop={(e) => { + e.preventDefault() + const fromId = e.dataTransfer.getData('text/row-id') + if (!fromId || fromId === rowId) return + const rect = e.currentTarget.getBoundingClientRect() + const position = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' + props.onMove(fromId, index, position) + }} + onClick={() => { + if (!isEditing) props.onFocus() + }} + > + {index + 1} + {typeof inboundFrom === 'number' && isUpstream && ( + ← #{inboundFrom} 引用 + )} + +
+ + {editingSubtitle ? ( + e.stopPropagation()} + onChange={(e) => setSubtitleDraft(e.target.value)} + onBlur={() => { + props.onSubtitleChange(subtitleDraft) + setEditingSubtitle(false) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + commitSubtitleEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + setEditingSubtitle(false) + } else if (e.key === 'Tab') { + e.preventDefault() + e.stopPropagation() + commitSubtitleEdit() + props.onInputTab('subtitle', e.shiftKey ? 'up' : 'down') + } + }} + /> + ) : ( +
{ + e.stopPropagation() + setSubtitleDraft(props.subtitle ?? '') + setEditingSubtitle(true) + }} + > + {props.subtitle ? ( + <> + {props.subtitle} + : + + ) : ( + [备注] + )} +
+ )} + +
+ {isEditing ? ( + <> + { + props.onChange(e.target.value) + detectAc(e.target.value, e.target.selectionStart ?? e.target.value.length) + }} + onFocus={props.onFocus} + onBlur={() => { + closeAc() + if (escSkipBlurRef.current) { + escSkipBlurRef.current = false + return + } + props.onBlur() + }} + onKeyDown={(e) => { + if (ac && acCandidates.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault() + e.stopPropagation() + setAcIndex((i) => (i + 1) % acCandidates.length) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + e.stopPropagation() + setAcIndex((i) => (i - 1 + acCandidates.length) % acCandidates.length) + return + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + e.stopPropagation() + selectAc(acCandidates[acIndex]) + return + } + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + closeAc() + return + } + } + + if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + props.onEnter() + } else if (e.key === 'Escape') { + e.preventDefault() + // Esc 退回选中模式:跳过随后的 blur,避免取消选中 + escSkipBlurRef.current = true + props.onEscape() + } else if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.altKey) { + // Alt+方向键留给全局的移动行/翻页处理,这里只管裸方向键切行 + e.preventDefault() + props.onNavigate(e.key === 'ArrowUp' ? 'up' : 'down') + } else if (e.key === 'Backspace' && e.altKey) { + e.preventDefault() + props.onDelete() + } else if (e.key === 'Tab') { + e.preventDefault() + e.stopPropagation() + props.onInputTab('expr', e.shiftKey ? 'up' : 'down') + } + }} + /> + + ) : ( + + {tokenizeForDisplay(expr).map((t, i) => { + if (t.type === 'number') { + return ( + + ) + } + if (t.type === 'atref') { + return ( + { + if (!(e.ctrlKey || e.metaKey)) return + e.stopPropagation() + props.onAtClick?.(t.page, t.ref) + }} + > + [ + @{t.page} + + {props.atSubtitleFor?.(t.page, t.ref)} + + #{t.ref} + + {props.atRowSubtitleFor?.(t.page, t.ref)}] + + ) + } + if (t.type === 'ref') { + const note = props.refSubtitleFor?.(t.seq!) + return ( + { + if (!(e.ctrlKey || e.metaKey)) return + e.stopPropagation() + props.onRefClick?.(t.seq!) + }} + > + [{t.text} + {note && {note}}] + + ) + } + return {t.text} + })} + {expr.trim() === '' && ( + + {isActive ? '回车或双击编辑' : '输入表达式,回车起一行'} + + )} + + )} +
+
+ +
+ {result.status === 'ok' && ( + + )} + {result.status === 'error' && {result.message}} + {result.status === 'empty' && =} +
+ + {ac && + createPortal( +
+ {acCandidates.length === 0 && ( +
无匹配
+ )} + {acCandidates.map((c, i) => ( +
{ + if (el) acItemRefs.current.set(i, el) + else acItemRefs.current.delete(i) + }} + className={`cd-ac-item${i === acIndex ? ' is-hot' : ''}`} + onMouseDown={(e) => { + e.preventDefault() + selectAc(c) + }} + > + {c.label} + {c.note} +
+ ))} +
, + document.body + )} +
+ ) +} diff --git a/plugins/calc-draft/src/CalcDraft/components/ConfirmDialog.tsx b/plugins/calc-draft/src/CalcDraft/components/ConfirmDialog.tsx new file mode 100644 index 000000000..c0fa9b6bc --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/components/ConfirmDialog.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' + +export type ConfirmDialogProps = { + open: boolean + title?: string + message: string + confirmText?: string + cancelText?: string + /** 危险操作(删除类):确认按钮为红笔样式 */ + danger?: boolean + onConfirm: () => void + onCancel: () => void +} + +export default function ConfirmDialog({ + open, + title = '确认操作', + message, + confirmText = '确定', + cancelText = '取消', + danger = false, + onConfirm, + onCancel +}: ConfirmDialogProps) { + const confirmRef = useRef(null) + + useEffect(() => { + if (!open) return + confirmRef.current?.focus() + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + onCancel() + } + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [open, onCancel]) + + if (!open) return null + + return createPortal( +
{ + if (e.target === e.currentTarget) onCancel() + }} + > +
+
{title}
+
{message}
+
+ + +
+
+
, + document.body + ) +} diff --git a/plugins/calc-draft/src/CalcDraft/components/NumberCell.tsx b/plugins/calc-draft/src/CalcDraft/components/NumberCell.tsx new file mode 100644 index 000000000..edb4edd75 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/components/NumberCell.tsx @@ -0,0 +1,24 @@ +interface NumberCellProps { + text: string + onCopy: (text: string) => void + onInject: (text: string) => void + className?: string +} + +export default function NumberCell({ text, onCopy, onInject, className }: NumberCellProps) { + return ( + + ) +} diff --git a/plugins/calc-draft/src/CalcDraft/components/PageTabs.tsx b/plugins/calc-draft/src/CalcDraft/components/PageTabs.tsx new file mode 100644 index 000000000..68524cfa5 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/components/PageTabs.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { PAGE_NAME_MAX, type Page } from '../state' +import { colorForSeq } from '../palette' + +type PageTabsProps = { + pages: Page[] + activePageId: string + onCreate: () => void + onRename: (pageId: string, name: string) => void + onSwitch: (pageId: string) => void +} + +export default function PageTabs(props: PageTabsProps) { + const [editingId, setEditingId] = useState(null) + const [draftName, setDraftName] = useState('') + + const commitRename = (pageId: string) => { + const name = draftName.trim().slice(0, PAGE_NAME_MAX) + if (name) props.onRename(pageId, name) + setEditingId(null) + } + + return ( +
+ {props.pages.map((p, i) => ( +
+ {editingId === p.id ? ( + setDraftName(e.target.value)} + onBlur={() => commitRename(p.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + commitRename(p.id) + } + if (e.key === 'Escape') { + e.stopPropagation() + setEditingId(null) + } + }} + /> + ) : ( + + )} +
+ ))} + +
+ ) +} diff --git a/plugins/calc-draft/src/CalcDraft/components/Toast.tsx b/plugins/calc-draft/src/CalcDraft/components/Toast.tsx new file mode 100644 index 000000000..de2961876 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/components/Toast.tsx @@ -0,0 +1,4 @@ +export default function Toast({ message }: { message: string | null }) { + if (!message) return null + return
{message}
+} diff --git a/plugins/calc-draft/src/CalcDraft/components/WorkspaceBar.tsx b/plugins/calc-draft/src/CalcDraft/components/WorkspaceBar.tsx new file mode 100644 index 000000000..a7bb9a0e1 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/components/WorkspaceBar.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from 'react' +import { WORKSPACE_NAME_MAX, type Workspace } from '../state' +import ConfirmDialog from './ConfirmDialog' + +type WorkspaceBarProps = { + workspaces: Workspace[] + activeWorkspaceId: string + onSwitch: (workspaceId: string) => void + onCreate: () => void + onRename: (workspaceId: string, name: string) => void + onDelete: (workspaceId: string) => void +} + +export default function WorkspaceBar(props: WorkspaceBarProps) { + const { workspaces, activeWorkspaceId } = props + const [open, setOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [draftName, setDraftName] = useState('') + const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null) + const containerRef = useRef(null) + + const activeWorkspace = workspaces.find((w) => w.id === activeWorkspaceId) ?? workspaces[0] + + // 点击外部关闭下拉 + useEffect(() => { + if (!open) return + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [open]) + + const startRename = (w: Workspace) => { + setDraftName(w.name) + setEditingId(w.id) + } + + const commitRename = (workspaceId: string) => { + const name = draftName.trim().slice(0, WORKSPACE_NAME_MAX) + if (name) props.onRename(workspaceId, name) + setEditingId(null) + } + + const handleDelete = (workspaceId: string, name: string) => { + if (workspaces.length === 1) return + setConfirmDelete({ id: workspaceId, name }) + } + + return ( +
+ + + {open && ( +
+
+ {workspaces.map((w) => ( +
+ {editingId === w.id ? ( + e.stopPropagation()} + onChange={(e) => setDraftName(e.target.value)} + onBlur={() => commitRename(w.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + commitRename(w.id) + } + if (e.key === 'Escape') { + e.stopPropagation() + setEditingId(null) + } + }} + /> + ) : ( + + )} + + {editingId !== w.id && w.id === activeWorkspaceId && ( + 当前 + )} + +
+ + {workspaces.length > 1 && ( + + )} +
+
+ ))} +
+ +
+ +
+
+ )} + + { + if (confirmDelete) props.onDelete(confirmDelete.id) + setConfirmDelete(null) + }} + onCancel={() => setConfirmDelete(null)} + /> +
+ ) +} diff --git a/plugins/calc-draft/src/CalcDraft/engine/evaluator.test.ts b/plugins/calc-draft/src/CalcDraft/engine/evaluator.test.ts new file mode 100644 index 000000000..113994047 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/engine/evaluator.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest' +import { evaluatePage, upstreamRowIds, formatNumber } from './evaluator' + +const row = (id: string, seq: number, expr: string) => ({ id, seq, expr }) + +describe('evaluatePage', () => { + it('四则与优先级', () => { + const { results } = evaluatePage([row('a', 1, '1 + 2 * 3')]) + expect(results.get('a')).toEqual({ status: 'ok', value: 7 }) + }) + + it('解析 #ref 并求值', () => { + const { results } = evaluatePage([ + row('a', 1, '10'), + row('b', 2, '#1 * 2 + 1') + ]) + expect(results.get('b')).toEqual({ status: 'ok', value: 21 }) + }) + + it('引用顺序与拓扑无关(后行先引用不依赖书写顺序以外的环)', () => { + const { results } = evaluatePage([ + row('a', 1, '#2 + 1'), + row('b', 2, '5') + ]) + expect(results.get('a')).toEqual({ status: 'ok', value: 6 }) + }) + + it('缺失 ref → 未找到', () => { + const { results } = evaluatePage([row('a', 1, '#9 + 1')]) + expect(results.get('a')).toEqual({ status: 'error', message: '未找到 #9' }) + }) + + it('除零 → 除以零', () => { + const { results } = evaluatePage([row('a', 1, '1 / 0')]) + expect(results.get('a')).toEqual({ status: 'error', message: '除以零' }) + }) + + it('循环引用 → 整组标错且不挂起', () => { + const { results } = evaluatePage([ + row('a', 1, '#2 + 1'), + row('b', 2, '#1 + 1') + ]) + expect(results.get('a')).toEqual({ status: 'error', message: '循环引用' }) + expect(results.get('b')).toEqual({ status: 'error', message: '循环引用' }) + }) + + it('自引用 → 循环引用', () => { + const { results } = evaluatePage([row('a', 1, '#1 + 1')]) + expect(results.get('a')).toEqual({ status: 'error', message: '循环引用' }) + }) + + it('编辑中游行后仅下游受影响(模拟改 expr 重新 evaluatePage)', () => { + const before = evaluatePage([ + row('a', 1, '2'), + row('b', 2, '#1 * 10'), + row('c', 3, '100') + ]) + expect(before.results.get('b')).toEqual({ status: 'ok', value: 20 }) + const after = evaluatePage([ + row('a', 1, '3'), + row('b', 2, '#1 * 10'), + row('c', 3, '100') + ]) + expect(after.results.get('b')).toEqual({ status: 'ok', value: 30 }) + expect(after.results.get('c')).toEqual({ status: 'ok', value: 100 }) + }) + + it('函数与常量', () => { + const { results } = evaluatePage([ + row('a', 1, 'sqrt(9)'), + row('b', 2, 'abs(-4)'), + row('c', 3, 'max(1, 7, 3)'), + row('d', 4, 'round(2.6)'), + row('e', 5, 'pi') + ]) + expect(results.get('a')).toEqual({ status: 'ok', value: 3 }) + expect(results.get('b')).toEqual({ status: 'ok', value: 4 }) + expect(results.get('c')).toEqual({ status: 'ok', value: 7 }) + expect(results.get('d')).toEqual({ status: 'ok', value: 3 }) + expect((results.get('e') as any).value).toBeCloseTo(Math.PI) + }) + + it('^ 右结合', () => { + const { results } = evaluatePage([row('a', 1, '2 ^ 3 ^ 2')]) + expect(results.get('a')).toEqual({ status: 'ok', value: 512 }) + }) + + it('空表达式 → empty 而非错误', () => { + const { results } = evaluatePage([row('a', 1, ' ')]) + expect(results.get('a')).toEqual({ status: 'empty' }) + }) + + it('语法错误 → 行内 error', () => { + const { results } = evaluatePage([row('a', 1, '1 +')]) + expect(results.get('a')!.status).toBe('error') + }) + + it('无关行不受错误行影响', () => { + const { results } = evaluatePage([ + row('a', 1, '1 +'), + row('b', 2, '2 + 2') + ]) + expect(results.get('a')!.status).toBe('error') + expect(results.get('b')).toEqual({ status: 'ok', value: 4 }) + }) + + it('上游 error 时下游传播消息', () => { + const { results } = evaluatePage([ + row('a', 1, '1 / 0'), + row('b', 2, '#1 + 1') + ]) + expect(results.get('b')).toEqual({ status: 'error', message: '除以零' }) + }) + + it('directDeps 记录上游行 id', () => { + const { directDeps } = evaluatePage([ + row('a', 1, '1'), + row('b', 2, '#1 + 1') + ]) + expect(directDeps.get('b')).toEqual(['a']) + expect(directDeps.get('a')).toEqual([]) + }) +}) + +describe('upstreamRowIds', () => { + it('计算传递上游', () => { + const deps = new Map([ + ['a', []], + ['b', ['a']], + ['c', ['b']], + ['d', []] + ]) + expect(upstreamRowIds(deps, 'c')).toEqual(new Set(['a', 'b'])) + expect(upstreamRowIds(deps, 'a')).toEqual(new Set()) + }) + + it('环状依赖不死循环', () => { + const deps = new Map([ + ['a', ['b']], + ['b', ['a']] + ]) + expect(upstreamRowIds(deps, 'a')).toEqual(new Set(['b'])) + }) +}) + +describe('formatNumber', () => { + it('消除 0.1+0.2 浮点噪声', () => { + expect(formatNumber(0.1 + 0.2)).toBe('0.3') + }) + + it('整数不带小数点', () => { + expect(formatNumber(7)).toBe('7') + }) + + it('保留有效小数', () => { + expect(formatNumber(1 / 3)).toBe('0.333333333333') + }) +}) diff --git a/plugins/calc-draft/src/CalcDraft/engine/evaluator.ts b/plugins/calc-draft/src/CalcDraft/engine/evaluator.ts new file mode 100644 index 000000000..1c97ef246 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/engine/evaluator.ts @@ -0,0 +1,308 @@ +import jsep from 'jsep' +import { parseExpression, type AtRef } from './parser' + +export type CellResult = + | { status: 'ok'; value: number } + | { status: 'error'; message: string } + | { status: 'empty' } + +export type EvalRow = { id: string; seq: number; expr: string; subtitle?: string } + +export type PageEvaluation = { + results: Map + directDeps: Map +} + +export type EvalPageInfo = { id: string; name: string; rows: EvalRow[] } + +/** 跨草稿引用的求值上下文:页名解析、占位符映射、懒求值缓存 */ +export type PageCtx = { + getPageByIndex: (index: number) => EvalPageInfo | undefined + /** 行值缓存(跨草稿懒求值共享) */ + rowMemo: Map + /** 正在求值的行 id 栈:行级环检测 */ + stack: Set +} + +type Fn = (...args: number[]) => number + +const FUNCTIONS: Record = { + sqrt: Math.sqrt, + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + log: (x) => Math.log10(x), + ln: Math.log, + abs: Math.abs, + round: Math.round, + floor: Math.floor, + ceil: Math.ceil, + min: (...args) => Math.min(...args), + max: (...args) => Math.max(...args), + pow: (a, b) => Math.pow(a, b) +} + +const CONSTANTS: Record = { + pi: Math.PI, + e: Math.E +} + +export const FUNCTION_NAMES = Object.keys(FUNCTIONS) +export const CONSTANT_NAMES = Object.keys(CONSTANTS) + +export function evaluatePage(rows: EvalRow[], ctx?: PageCtx): PageEvaluation { + const results = new Map() + const directDeps = new Map() + // #N 是行号引用:指向当前顺序的第 N 行 + const byPos = new Map() + rows.forEach((r, i) => byPos.set(i + 1, r)) + + const parsed = new Map() + for (const r of rows) { + if (!r.expr.trim()) { + parsed.set(r.id, null) + directDeps.set(r.id, []) + results.set(r.id, { status: 'empty' }) + continue + } + try { + const { refs, atRefs, replaced } = parseExpression(r.expr) + parsed.set(r.id, { refs, atRefs, replaced }) + const upstream: string[] = [] + for (const seq of refs) { + const up = byPos.get(seq) + if (up && !upstream.includes(up.id)) upstream.push(up.id) + } + directDeps.set(r.id, upstream) + } catch { + parsed.set(r.id, { refs: [], atRefs: [], replaced: r.expr }) + directDeps.set(r.id, []) + results.set(r.id, { status: 'error', message: '表达式语法错误' }) + } + } + + const rowById = new Map(rows.map((r) => [r.id, r])) + const pending = rows.filter((r) => !results.has(r.id)) + const inDegree = new Map() + const dependents = new Map() + for (const r of pending) { + inDegree.set(r.id, 0) + dependents.set(r.id, []) + } + for (const r of pending) { + for (const upId of directDeps.get(r.id) || []) { + if (!inDegree.has(upId)) continue + inDegree.set(r.id, (inDegree.get(r.id) || 0) + 1) + dependents.set(upId, [...(dependents.get(upId) || []), r.id]) + } + } + + const queue = pending.filter((r) => (inDegree.get(r.id) || 0) === 0).map((r) => r.id) + let processed = 0 + while (queue.length) { + const id = queue.shift()! + processed++ + const r = rowById.get(id)! + const parsedInfo = parsed.get(id) + if (parsedInfo) { + if (ctx) ctx.stack.add(id) + try { + results.set(id, evalAst(r, parsedInfo, results, byPos, ctx, rows, id)) + } finally { + if (ctx) ctx.stack.delete(id) + } + } + for (const depId of dependents.get(id) || []) { + const next = (inDegree.get(depId) || 0) - 1 + inDegree.set(depId, next) + if (next === 0) queue.push(depId) + } + } + + for (const r of pending) { + if (!results.has(r.id)) results.set(r.id, { status: 'error', message: '循环引用' }) + } + + return { results, directDeps } +} + +function evalAst( + row: EvalRow, + parsedInfo: { refs: number[]; atRefs: AtRef[]; replaced: string }, + results: Map, + byPos: Map, + ctx?: PageCtx, + pageRows?: EvalRow[], + currentRowId?: string +): CellResult { + try { + const ast = (jsep as any)(parsedInfo.replaced) + const value = evalNode(ast, parsedInfo, results, byPos, ctx, pageRows, currentRowId) + if (typeof value !== 'number' || !Number.isFinite(value)) { + if (Number.isNaN(value)) return { status: 'error', message: '无效数值' } + if (value === Infinity || value === -Infinity) return { status: 'error', message: '除以零' } + return { status: 'error', message: '表达式语法错误' } + } + return { status: 'ok', value } + } catch (e: any) { + return { status: 'error', message: e?.message || '表达式语法错误' } + } +} + +function evalNode( + node: any, + parsedInfo: { refs: number[]; atRefs: AtRef[]; replaced: string }, + results: Map, + byPos: Map, + ctx?: PageCtx, + pageRows?: EvalRow[], + currentRowId?: string +): number { + switch (node.type) { + case 'Literal': + return Number(node.value) + case 'Identifier': { + const name = String(node.name) + if (name.startsWith('#')) { + const seq = Number(name.slice(1)) + const target = byPos.get(seq) + if (!target) throw new Error(`未找到 #${seq}`) + const res = results.get(target.id) + if (res) { + if (res.status === 'empty') throw new Error(`未找到 #${seq}`) + if (res.status === 'error') throw new Error(res.message) + return res.value + } + if (ctx && pageRows) return resolveRowValue(target, pageRows, ctx) + throw new Error(`未找到 #${seq}`) + } + const atMatch = /^_at(\d+)_$/.exec(name) + if (atMatch) { + const atRef = parsedInfo.atRefs[Number(atMatch[1])] + if (!atRef) throw new Error('表达式语法错误') + return resolveAtRef(atRef, ctx, currentRowId) + } + if (name in CONSTANTS) return CONSTANTS[name] + if (name in FUNCTIONS) throw new Error(`${name} 需要调用`) + throw new Error(`未知标识符 ${name}`) + } + case 'CallExpression': { + const callee = node.callee?.name + if (!callee || !(callee in FUNCTIONS)) throw new Error(`未知函数 ${callee || ''}`) + const fn = FUNCTIONS[callee] + const args = node.arguments.map((a: any) => + evalNode(a, parsedInfo, results, byPos, ctx, pageRows, currentRowId) + ) + const value = fn(...args) + if (Number.isNaN(value)) throw new Error('无效数值') + return value + } + case 'BinaryExpression': { + const left = evalNode(node.left, parsedInfo, results, byPos, ctx, pageRows, currentRowId) + const right = evalNode(node.right, parsedInfo, results, byPos, ctx, pageRows, currentRowId) + return applyBinary(node.operator, left, right) + } + case 'UnaryExpression': { + const argument = evalNode(node.argument, parsedInfo, results, byPos, ctx, pageRows, currentRowId) + if (node.operator === '-') return -argument + if (node.operator === '+') return argument + throw new Error('不支持的一元运算') + } + case 'ConditionalExpression': { + const test = evalNode(node.test, parsedInfo, results, byPos, ctx, pageRows, currentRowId) + return test + ? evalNode(node.consequent, parsedInfo, results, byPos, ctx, pageRows, currentRowId) + : evalNode(node.alternate, parsedInfo, results, byPos, ctx, pageRows, currentRowId) + } + default: + throw new Error('表达式语法错误') + } +} + +/** 解析 @页序号#行序号:按行懒求值(行级 memo + 行级环检测) */ +function resolveAtRef(atRef: AtRef, ctx: PageCtx | undefined, currentRowId?: string): number { + if (!ctx) throw new Error(`未找到第 ${atRef.page} 个草稿`) + const target = ctx.getPageByIndex(Number(atRef.page)) + if (!target) throw new Error(`未找到第 ${atRef.page} 个草稿`) + if (atRef.ref === '') throw new Error('引用未完成') + let row + if (/^\d+$/.test(atRef.ref)) row = target.rows[Number(atRef.ref) - 1] + else row = target.rows.find((r) => r.subtitle === atRef.ref) + if (!row) throw new Error(`草稿「${target.name}」中没有第 ${atRef.ref} 行`) + return resolveRowValue(row, target.rows, ctx) +} + +/** 按行懒求值:memo 复用、in-progress 环检测 */ +function resolveRowValue(row: EvalRow, pageRows: EvalRow[], ctx: PageCtx): number { + const memo = ctx.rowMemo.get(row.id) + if (memo) { + if (memo.status === 'error') throw new Error(memo.message) + if (memo.status === 'empty') throw new Error('引用未完成') + return memo.value + } + if (ctx.stack.has(row.id)) throw new Error('循环引用') + ctx.stack.add(row.id) + const byPos = new Map(pageRows.map((r, i) => [i + 1, r] as const)) + const results = new Map() + try { + if (!row.expr.trim()) throw new Error('引用未完成') + const parsed = parseExpression(row.expr) + const value = evalNode( + (jsep as any)(parsed.replaced), + { refs: parsed.refs, atRefs: parsed.atRefs, replaced: parsed.replaced }, + results, + byPos, + ctx, + pageRows, + row.id + ) + if (typeof value !== 'number' || !Number.isFinite(value)) { + if (Number.isNaN(value)) throw new Error('无效数值') + if (value === Infinity || value === -Infinity) throw new Error('除以零') + throw new Error('表达式语法错误') + } + ctx.rowMemo.set(row.id, { status: 'ok', value }) + return value + } catch (e: any) { + const msg = e?.message || '表达式语法错误' + ctx.rowMemo.set(row.id, { status: 'error', message: msg }) + throw new Error(msg) + } finally { + ctx.stack.delete(row.id) + } +} + +function applyBinary(op: string, a: number, b: number): number { + switch (op) { + case '+': return a + b + case '-': return a - b + case '*': return a * b + case '/': + if (b === 0) throw new Error('除以零') + return a / b + case '%': + if (b === 0) throw new Error('除以零') + return a % b + case '^': return Math.pow(a, b) + default: + throw new Error('不支持的运算符') + } +} + +export function upstreamRowIds(directDeps: Map, rowId: string): Set { + const seen = new Set() + const stack = [...(directDeps.get(rowId) || [])] + while (stack.length) { + const id = stack.pop()! + if (seen.has(id)) continue + seen.add(id) + for (const up of directDeps.get(id) || []) stack.push(up) + } + seen.delete(rowId) + return seen +} + +export function formatNumber(n: number): string { + if (!Number.isFinite(n)) return String(n) + return String(Number(n.toPrecision(12))) +} diff --git a/plugins/calc-draft/src/CalcDraft/engine/parser.test.ts b/plugins/calc-draft/src/CalcDraft/engine/parser.test.ts new file mode 100644 index 000000000..02dbbe8a1 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/engine/parser.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { parseExpression, tokenizeForDisplay } from './parser' +import jsep from 'jsep' + +describe('parseExpression', () => { + it('返回空 refs(纯算术)', () => { + expect(parseExpression('1 + 2 * 3').refs).toEqual([]) + }) + + it('提取 #ref 的 seq', () => { + expect(parseExpression('#1 + #12 * 2').refs).toEqual([1, 12]) + }) + + it('同一 ref 重复出现只记录一次', () => { + expect(parseExpression('#3 + #3').refs).toEqual([3]) + }) + + it('语法错误时 throw', () => { + expect(() => parseExpression('1 +')).toThrow() + expect(() => parseExpression('sqrt(')).toThrow() + }) + + it('支持函数与常量', () => { + expect(() => parseExpression('sqrt(4) + pi * e')).not.toThrow() + }) + + it('支持 ^ 与 % 以及括号', () => { + expect(() => parseExpression('(2 + 3) ^ 2 % 7')).not.toThrow() + }) +}) + +describe('运算符优先级回归(jsep 高数字=高优先级)', () => { + it('运算符优先级:^ 高于 +(回归:jsep 高数字=高优先级)', () => { + const { refs } = parseExpression('2 + 3 ^ 2') + expect(refs).toEqual([]) + }) + + it('2 + 3 ^ 2 结构为 2 + (3 ^ 2)', () => { + const ast: any = jsep('2 + 3 ^ 2') + expect(ast.operator).toBe('+') + expect(ast.right.operator).toBe('^') + }) + + it('2 ^ 3 ^ 2 右结合', () => { + const ast: any = jsep('2 ^ 3 ^ 2') + expect(ast.operator).toBe('^') + expect(ast.right.operator).toBe('^') + }) + + it('5 % 3 * 2 中 % 与 * 同级左结合', () => { + const ast: any = jsep('5 % 3 * 2') + expect(ast.operator).toBe('*') + expect(ast.left.operator).toBe('%') + }) +}) + +describe('tokenizeForDisplay', () => { + it('拆分 ref/数字/标识符/其他', () => { + expect(tokenizeForDisplay('#1+2.5*sqrt')).toEqual([ + { type: 'ref', text: '#1', seq: 1 }, + { type: 'other', text: '+' }, + { type: 'number', text: '2.5' }, + { type: 'other', text: '*' }, + { type: 'ident', text: 'sqrt' } + ]) + }) + + it('保留空白为 other', () => { + expect(tokenizeForDisplay('1 + 2')).toEqual([ + { type: 'number', text: '1' }, + { type: 'other', text: ' ' }, + { type: 'other', text: '+' }, + { type: 'other', text: ' ' }, + { type: 'number', text: '2' } + ]) + }) +}) diff --git a/plugins/calc-draft/src/CalcDraft/engine/parser.ts b/plugins/calc-draft/src/CalcDraft/engine/parser.ts new file mode 100644 index 000000000..c50f741b4 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/engine/parser.ts @@ -0,0 +1,76 @@ +import jsep from 'jsep' + +jsep.addIdentifierChar('#') +jsep.addBinaryOp('^', 11, true) +jsep.addBinaryOp('%', 10) + +export type AtRef = { page: string; ref: string } // 均为 1-based 序号字符串 + +export type ParseResult = { + refs: number[] + /** @页名#序号或备注 形式的跨草稿引用(已替换为 ASCII 占位标识符) */ + atRefs: AtRef[] + /** @ 引用替换成占位符后的表达式,jsep 用它解析 */ + replaced: string +} + +// 先把 @引用 预替换为纯 ASCII 占位标识符,jsep 不需要认识中文和 @。 +// 完整形式 @页序号#行序号;输入中的裸 @查询(尚未选完)也占位,求值时报“引用未完成” +const AT_RE = /@(\d+)#([^#\s@+\-*/^%(),:]+)|@([^#\s@]*)$/g + +export function parseExpression(expr: string): ParseResult { + const atRefs: AtRef[] = [] + const replaced = expr.replace(AT_RE, (_m, page: string, ref: string, bare: string) => { + const key = `_at${atRefs.length}_` + atRefs.push({ page: bare !== undefined ? bare : page, ref: bare !== undefined ? '' : ref }) + return key + }) + + const ast = jsep(replaced) + const refs: number[] = [] + walk(ast, (node: any) => { + if (node.type === 'Identifier' && typeof node.name === 'string' && node.name.startsWith('#')) { + const seq = Number(node.name.slice(1)) + if (Number.isInteger(seq) && seq >= 1 && !refs.includes(seq)) refs.push(seq) + } + }) + return { refs, atRefs, replaced } +} + +function walk(node: any, visit: (node: any) => void): void { + if (!node || typeof node !== 'object') return + visit(node) + for (const key of Object.keys(node)) { + if (key === 'type') continue + const value = node[key] + if (Array.isArray(value)) value.forEach((child) => walk(child, visit)) + else if (value && typeof value === 'object') walk(value, visit) + } +} + +export type DisplayToken = + | { type: 'number' | 'ident' | 'other'; text: string } + | { type: 'ref'; text: string; seq: number } + | { type: 'atref'; text: string; page: string; ref: string } + +const TOKEN_RE = /@(\d+)#([^#\s@+\-*/^%(),:]*)|#(\d+)|(\d+\.?\d*)|([A-Za-z_][A-Za-z0-9_]*)|(\s+)|([\s\S])/g + +export function tokenizeForDisplay(expr: string): DisplayToken[] { + const tokens: DisplayToken[] = [] + let m: RegExpExecArray | null + TOKEN_RE.lastIndex = 0 + while ((m = TOKEN_RE.exec(expr))) { + if (m[1] !== undefined) { + tokens.push({ type: 'atref', text: m[0], page: m[1], ref: m[2] }) + } else if (m[3] !== undefined) { + tokens.push({ type: 'ref', text: `#${m[3]}`, seq: Number(m[3]) }) + } else if (m[4] !== undefined) { + tokens.push({ type: 'number', text: m[0] }) + } else if (m[5] !== undefined) { + tokens.push({ type: 'ident', text: m[0] }) + } else { + tokens.push({ type: 'other', text: m[0] }) + } + } + return tokens +} diff --git a/plugins/calc-draft/src/CalcDraft/index.css b/plugins/calc-draft/src/CalcDraft/index.css new file mode 100644 index 000000000..81316dd46 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/index.css @@ -0,0 +1,1162 @@ +/* ============================================================ + calc-draft · 蓝墨手稿 + 信纸上的钢笔算式:横线信纸 + 红色页边线, + 霞鹜文楷书写体,结果用墨水色重描,批注为红笔旁注。 + ============================================================ */ + +/* 调色板放 :root,Portal(body 直挂)的浮层也能取到变量 */ +:root { + --ink: #1e2c56; /* 深蓝墨 */ + --ink-soft: #6a76a0; /* 淡墨 */ + --ink-faint: #8b94b5; /* 极淡墨(注脚) */ + --paper: #fbf9f2; /* 纸面 */ + --paper-desk: #f6f3ea; /* 桌面 */ + --rule: rgba(30, 88, 180, 0.16); /* 信纸横线 */ + --margin-red: rgba(200, 70, 70, 0.45); /* 红页边线 */ + --error: #c8464a; /* 红笔批改 */ + --rule-strong: rgba(30, 88, 180, 0.3); + --ink-body: #24325e; /* 公式正文 */ + --ink-note: #46537f; /* 次要内容 */ + --wash-ink: rgba(30, 88, 180, 0.07); /* 蓝墨晕染 */ + --wash-red: rgba(200, 70, 70, 0.08); /* 红笔晕染 */ + --shadow-ink: rgba(30, 88, 180, 0.2); /* 常规偏移阴影 */ + --shadow-ink-strong: rgba(30, 88, 180, 0.25); + --paper-shadow-a: rgba(30, 44, 86, 0.12); + --paper-shadow-b: rgba(30, 44, 86, 0.14); + --mask: rgba(30, 44, 86, 0.28); + --paper-solid: #fbf9f2; + --ink-hover: #2a3c74; + --error-hover: #a83a3e; +} + +.calc-draft { + box-sizing: border-box; + height: 100vh; + display: flex; + flex-direction: column; + background: var(--paper-desk); + color: var(--ink); + font-family: 'Source Han Serif SC', 'Noto Serif SC', 'STSong', 'SimSun', serif; +} + +/* ---- 工作空间悬浮签:右下角墨戳,点开向上展开换一本 ---- */ + +.cd-workspace-bar { + position: absolute; + right: 26px; + bottom: 74px; + z-index: 80; +} + +.cd-workspace-current { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--paper-solid); + border: 1.5px solid var(--ink-soft); + border-radius: 2px; + padding: 7px 13px; + font-family: inherit; + font-size: 14px; + color: var(--ink); + cursor: pointer; + box-shadow: 2px 2px 0 var(--shadow-ink); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.cd-workspace-current:hover { + border-color: var(--ink); + box-shadow: 2px 2px 0 var(--shadow-ink-strong); +} + +.cd-workspace-name { + font-weight: 600; + letter-spacing: 0.04em; + max-width: 6.8em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cd-workspace-caret { + font-size: 11px; + color: var(--ink-faint); + transition: transform 0.2s ease; +} + +.cd-workspace-caret.is-open { + transform: rotate(180deg); +} + +/* 工作空间下拉:与 toast 同款的墨框纸签 */ +.cd-workspace-dropdown { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + min-width: 240px; + background: var(--paper-solid); + border: 1.5px solid var(--ink); + border-radius: 2px; + box-shadow: 4px 4px 0 var(--shadow-ink-strong); + z-index: 90; + overflow: hidden; +} + +.cd-workspace-list { + max-height: 320px; + overflow-y: auto; + padding: 4px 0; +} + +.cd-workspace-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background 0.12s ease; +} + +.cd-workspace-item:hover { + background: var(--wash-ink); +} + +.cd-workspace-item.is-active { + background: var(--wash-ink); +} + +/* 当前工作空间:页边线外的红笔指示 */ +.cd-workspace-item.is-active::before { + content: '▸'; + color: var(--error); + font-size: 11px; + margin-left: -4px; +} + +.cd-workspace-item-name { + flex: 1; + text-align: left; + font-family: inherit; + font-size: 14px; + color: var(--ink); + background: none; + border: none; + padding: 0; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cd-workspace-active-badge { + font-size: 11px; + color: var(--error); + border: 1px solid var(--margin-red); + border-radius: 2px; + padding: 1px 6px; + letter-spacing: 0.08em; +} + +.cd-workspace-item-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity 0.12s ease; +} + +.cd-workspace-item:hover .cd-workspace-item-actions, +.cd-workspace-item.is-active .cd-workspace-item-actions { + opacity: 1; +} + +.cd-workspace-action-btn { + background: none; + border: none; + color: var(--ink-faint); + font-family: inherit; + font-size: 14px; + cursor: pointer; + padding: 2px 5px; + border-radius: 2px; + line-height: 1; +} + +.cd-workspace-action-btn:hover { + color: var(--ink); + background: var(--wash-ink); +} + +.cd-workspace-delete-btn:hover { + color: var(--error); + background: var(--wash-red); +} + +.cd-workspace-rename-input { + flex: 1; + font-family: inherit; + font-size: 14px; + border: none; + border-bottom: 1.5px dashed var(--error); + background: transparent; + color: var(--ink); + padding: 2px 0; + outline: none; + caret-color: var(--error); +} + +.cd-workspace-dropdown-footer { + border-top: 1.5px dashed var(--rule); + padding: 8px 12px; +} + +.cd-workspace-add { + display: block; + width: 100%; + text-align: left; + font-family: inherit; + font-size: 13px; + color: var(--ink-soft); + background: none; + border: none; + cursor: pointer; + padding: 4px 0; + letter-spacing: 0.04em; +} + +.cd-workspace-add:hover { + color: var(--ink); +} + +/* ---- 稿纸 ---- */ + +.cd-sheet { + flex: 1; + min-height: 0; + padding: 0 20px; + display: flex; + flex-direction: column; + background: var(--paper); + box-shadow: + 0 1px 3px var(--paper-shadow-a), + 0 14px 44px var(--paper-shadow-b); + position: relative; + overflow: hidden; +} + +.cd-sheet::before { + /* 红色页边线 */ + content: ''; + position: absolute; + left: 13px; + top: 0; + bottom: 0; + width: 1.5px; + background: var(--margin-red); + pointer-events: none; + z-index: 1; + opacity: 70%; +} + +/* ---- 页头 ---- */ + +.cd-topbar { + padding: 24px 0 12px; + border-bottom: 1.5px solid var(--ink); + display: flex; + align-items: baseline; + gap: 14px; + position: relative; + z-index: 1; +} + +.cd-title { + font-size: 28px; + font-weight: 700; + line-height: 1; + letter-spacing: 0.04em; + margin-right: 6px; + /* 固定宽度:切换草稿时页签不左右跑动 */ + width: 200px; + flex: 0 0 auto; + position: relative; +} + +/* 省略号在内层做,外层 overflow 放开让 hover 浮层能溢出容器 */ +.cd-title-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* hover 展开全名:真实子元素浮层,覆盖页签但不挤压布局,过宽自动换行。 + 浮层是 .cd-title 的子节点,鼠标移入浮层时 :hover 沿祖先链保持成立, + 因此离开标题区域、只要还在浮层上,浮层就不消失;移出浮层才隐藏。 */ +.cd-title-pop { + display: none; +} + +.cd-title.is-truncated:hover .cd-title-pop { + display: block; + position: absolute; + left: 0; + top: -8px; + z-index: 60; + width: max-content; + max-width: min(560px, 80vw); + white-space: normal; + word-break: break-all; + font-size: 24px; + line-height: 1.6; + color: var(--ink); + background: var(--paper-solid); + box-shadow: 0 2px 18px var(--shadow-ink-strong), 0 0 0 1.5px var(--ink); + padding: 8px 18px; + cursor: default; +} + +.cd-meta { + order: 3; + margin-left: auto; + font-size: 12px; + color: var(--ink-faint); + letter-spacing: 0.1em; + white-space: nowrap; +} + +/* ---- 页签 ---- */ + +.cd-tabs { + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; + min-width: 0; +} + +.cd-tab-wrap { + position: relative; +} + +.cd-tab { + font-family: inherit; + font-size: 14px; + color: var(--ink-soft); + padding: 4px 10px 6px; + cursor: pointer; + white-space: nowrap; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + /* 每个草稿一个身份色下划线,未选中时淡化 */ + border-bottom: 2px solid color-mix(in srgb, var(--tab-color, var(--ink)) 30%, transparent); +} + +.cd-tab:hover { + color: var(--ink); +} + +.cd-tab-active { + color: var(--ink); + font-weight: 600; + /* 选中:下划线全色 + 同色阴影 */ + border-bottom-color: var(--tab-color, var(--ink)); + box-shadow: 0 3px 10px color-mix(in srgb, var(--tab-color, var(--ink)) 40%, transparent); +} + +/* 删除草稿:页面右上角 */ +.cd-page-delete { + order: 4; + margin-left: 14px; + font-family: inherit; + font-size: 12px; + color: var(--ink-faint); + background: none; + padding: 3px 8px; + cursor: pointer; + border: 1px solid transparent; + border-radius: 2px; + white-space: nowrap; +} + +.cd-page-delete:hover { + color: var(--error); + border-color: var(--margin-red); +} + +.cd-shortcuts-btn { + order: 5; + margin-left: 4px; + align-self: center; +} + +.cd-tab-add { + font-family: inherit; + color: var(--ink-faint); + font-size: 15px; + padding: 2px 8px; + cursor: pointer; +} + +.cd-tab-add:hover { + color: var(--ink); +} + +.cd-tab-input { + font-family: inherit; + border: none; + outline: none; + background: transparent; + color: var(--ink); + font-size: 14px; + padding: 4px 10px; + width: 110px; + border-bottom: 1.5px dashed var(--error); +} + +/* ---- 正文 ---- */ + +.cd-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 12px; + position: relative; + z-index: 1; + display: flex; + flex-direction: column; +} + +.cd-empty { + flex: 0 0 auto; + min-height: 132px; + display: flex; + align-items: center; + justify-content: center; + color: var(--ink-faint); + font-size: 15px; + letter-spacing: 0.04em; +} + +.cd-rows { + display: grid; + /* 行号列由全表最宽的行号决定,公式列弹开,结果列贴右 */ + grid-template-columns: max-content minmax(0, 1fr) max-content; + flex: 0 0 auto; +} + +/* 空白补行:真实盒子画线,与内容行同一布局体系,不会漂移;双击新建行 */ +.cd-fill { + flex: 1 0 auto; + min-height: 44px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.cd-fill i { + display: block; + flex: 0 0 auto; + height: 44px; + border-bottom: 1px solid var(--rule); + position: relative; +} + +/* 最后一行下方的次要提示:淡墨小字,与表达式起点对齐 */ +.cd-fill-hint { + position: absolute; + left: 54px; + top: 50%; + transform: translateY(-50%); + font-size: 13px; + color: var(--ink-faint); + opacity: 0.65; + letter-spacing: 0.06em; + user-select: none; +} + +.cd-row { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: baseline; + column-gap: 14px; + padding: 0 8px 0 0; + border-bottom: 1px solid var(--rule); + position: relative; + transition: opacity 0.2s ease, background 0.15s ease; +} + +.cd-row-active { + background: linear-gradient( + 90deg, + transparent 0, + var(--wash-ink) 90px, + transparent 78% + ); +} + +/* 上游行 / 聚焦行:左侧红笔指示 */ +.cd-row-upstream::before, +.cd-row-active::before { + content: '▸'; + position: absolute; + left: -18px; + top: 10px; + color: var(--error); + font-size: 12px; +} + +/* 被引用的上游行:整行按其墨色晕染,左侧透明渐入,避免生硬切边 */ +.cd-row-upstream { + background: linear-gradient( + 90deg, + transparent 0, + color-mix(in srgb, var(--row-color, var(--ink)) 10%, transparent) 90px, + transparent 78% + ); +} + +/* 聚焦行内的引用 token:加重,表明数据正是从这些地方流入 */ +.cd-row-active .cd-ref { + font-weight: 600; + text-decoration: underline; + text-decoration-color: currentColor; + text-decoration-thickness: 2px; + text-underline-offset: 4px; +} + +.cd-row-editing { + background: linear-gradient(90deg, var(--wash-ink), transparent 70%); +} + +/* ---- 序号 ---- */ + +.cd-seq { + font-family: inherit; + font-size: 14px; + line-height: 44px; + color: var(--row-color, var(--ink)); + background: none; + padding: 0; +} + +.cd-seq::before { + content: '#'; + font-size: 11px; + opacity: 0.6; +} + +/* 上游行直接被聚焦行引用:红笔旁注 */ +.cd-inbound { + position: absolute; + right: 56px; + top: 8px; + font-size: 12px; + color: var(--error); + letter-spacing: 0.05em; + pointer-events: none; +} + +/* ---- 表达式 ---- */ + +/* 行内容文本流:备注 + 公式连成一段,超宽横向滚动 */ +.cd-flow { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: baseline; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + gap: 4px; +} + +.cd-flow::-webkit-scrollbar { + height: 4px; +} + +.cd-flow::-webkit-scrollbar-thumb { + background: var(--rule-strong); +} + +.cd-expr { + flex: 1 0 auto; + min-width: 10ch; + cursor: default; + position: relative; +} + +/* # 引用自动补全:纸签候选列表 */ +.cd-ac { + position: fixed; + z-index: 200; + transform-origin: top left; + min-width: 200px; + max-height: 264px; + overflow-y: auto; + background: var(--paper-solid); + border: 1.5px solid var(--ink); + border-radius: 2px; + box-shadow: 4px 4px 0 var(--shadow-ink-strong); +} + +.cd-ac.is-flipped { + transform: translateY(-100%); +} + +.cd-ac-item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + font-size: 14px; +} + +.cd-ac-item.is-empty-hint { + color: var(--ink-faint); + cursor: default; +} + +.cd-ac-item.is-hot, +.cd-ac-item:hover { + background: var(--wash-ink); +} + +.cd-ac-num { + color: var(--ink); + font-weight: 600; +} + +.cd-ac-note { + color: var(--ink-faint); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 行备注:宽度严格随内容;空时不出现在文本流里 */ +.cd-subtitle { + flex: 0 0 auto; + min-width: 0; + font-size: 14px; + line-height: 44px; + color: var(--ink-note); + cursor: text; + white-space: nowrap; +} + +/* 空备注:槽位宽度 = "[备注]" 占位文字的自然宽度(在流内撑开) */ +.cd-subtitle.is-empty { + width: auto; +} + +.cd-subtitle-colon { + color: var(--ink-faint); +} + +.cd-subtitle-placeholder { + opacity: 0; + transition: opacity 0.15s ease; + font-size: 12px; +} + +.cd-row:hover .cd-subtitle-placeholder, +.cd-row-active .cd-subtitle-placeholder { + opacity: 0.55; +} + +/* 隐藏镜像:测量备注文本实际宽度,中英文都精确 */ +.cd-subtitle-sizer { + position: absolute; + visibility: hidden; + white-space: pre; + font-size: 15px; + pointer-events: none; +} + +.cd-subtitle-input { + flex: 0 0 auto; + border: none; + outline: none; + background: transparent; + color: var(--ink-soft); + font-family: inherit; + font-size: 15px; + line-height: 44px; + height: 44px; + padding: 0; + caret-color: var(--error); +} + +.cd-input { + width: 100%; + border: none; + outline: none; + background: transparent; + color: var(--ink); + font-family: inherit; + font-size: 17px; + line-height: 44px; + height: 44px; + padding: 0; + caret-color: var(--error); + border-bottom: 1px dashed var(--rule-strong); +} + +.cd-input::placeholder { + color: var(--ink-faint); + opacity: 0.7; +} + +.cd-expr-view { + font-family: inherit; + font-size: 17px; + line-height: 44px; + color: var(--ink-body); + white-space: nowrap; +} + +.cd-ref { + font-weight: 400; + cursor: pointer; +} + +/* @ 跨草稿引用:@页序号 用页签色下划线,#行序号 用行色 */ +.cd-at-page { + font-weight: 600; + border-bottom: 2px solid; + padding-bottom: 1px; +} + +.cd-at-row { + font-weight: 400; +} + +/* 引用内嵌的备注:次要内容,淡墨小字 */ +.cd-ref-note { + font-size: 13px; + color: var(--ink-faint); + margin-left: 3px; +} + +.cd-ref:hover { + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 4px; +} + +.cd-ref::before { + content: '['; +} + +.cd-ref::after { + content: ']'; +} + +.cd-placeholder { + color: var(--ink-faint); + opacity: 0.7; +} + +/* ---- 结果 ---- */ + +.cd-result { + text-align: right; + flex-shrink: 0; + position: relative; +} + +.cd-num { + border: none; + background: transparent; + color: inherit; + font-family: inherit; + padding: 0 2px; + cursor: pointer; + line-height: 44px; + position: relative; +} + +.cd-num:hover { + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 4px; +} + +.cd-num-result { + font-size: 19px; + font-weight: 600; + color: var(--ink); +} + +.cd-num-inline { + font-size: 15px; + color: var(--row-color, var(--ink)); +} + +.cd-error { + color: var(--error); + font-size: 14px; + line-height: 44px; +} + +.cd-error::before { + content: '✗ '; + font-size: 12px; +} + +.cd-result-empty { + color: var(--ink-faint); + font-size: 15px; + line-height: 44px; +} + +/* ---- 确认对话框:墨框纸签 + 遮罩 ---- */ + +.cd-dialog-mask { + position: fixed; + inset: 0; + z-index: 300; + background: var(--mask); + display: flex; + align-items: center; + justify-content: center; +} + +.cd-dialog { + width: min(360px, calc(100vw - 48px)); + background: var(--paper-solid); + border: 1.5px solid var(--ink); + border-radius: 2px; + box-shadow: 6px 6px 0 var(--shadow-ink-strong); + padding: 20px 24px; +} + +.cd-dialog-title { + font-size: 16px; + font-weight: 600; + color: var(--ink); + letter-spacing: 0.04em; +} + +.cd-dialog-message { + margin-top: 10px; + font-size: 14px; + line-height: 1.8; + color: var(--ink-soft); + word-break: break-all; +} + +.cd-dialog-actions { + margin-top: 20px; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.cd-dialog-cancel, +.cd-dialog-confirm { + font-family: inherit; + font-size: 14px; + padding: 7px 18px; + border-radius: 2px; + cursor: pointer; +} + +.cd-dialog-cancel { + color: var(--ink-soft); + background: none; + border: 1.5px solid var(--ink-faint); +} + +.cd-dialog-cancel:hover { + color: var(--ink); + border-color: var(--ink-soft); +} + +.cd-dialog-confirm { + color: var(--paper-solid); + background: var(--ink); + border: 1.5px solid var(--ink); +} + +.cd-dialog-confirm:hover { + background: var(--ink-hover); +} + +.cd-dialog-confirm.is-danger { + background: var(--error); + border-color: var(--error); +} + +.cd-dialog-confirm.is-danger:hover { + background: var(--error-hover); + border-color: var(--error-hover); +} + +.cd-dialog-cancel:focus-visible, +.cd-dialog-confirm:focus-visible { + outline: 2px dashed var(--ink-soft); + outline-offset: 2px; +} + +/* 跳转闪烁:目标行按其墨色闪一下 */ +.cd-row-flash { + animation: cd-flash 1.2s ease; +} + +@keyframes cd-flash { + 0%, + 55% { + background: color-mix(in srgb, var(--row-color, var(--ink)) 20%, transparent); + } + 100% { + background: transparent; + } +} + +/* ---- 页脚 ---- */ + +.cd-statusbar { + padding: 10px 0; + border-top: 1.5px solid var(--ink); + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; +} + +/* ---- 快捷键按钮 ---- */ + +.cd-shortcuts-btn { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border: 1.5px solid var(--ink); + background: transparent; + color: var(--ink-faint); + cursor: pointer; + border-radius: 2px; + transition: color 0.15s, background 0.15s; +} + +.cd-shortcuts-btn svg { + display: block; +} + +.cd-shortcuts-btn:hover { + color: var(--ink); + background: color-mix(in srgb, var(--ink) 6%, transparent); +} + +/* ---- 快捷键弹窗 ---- */ + +.cd-shortcuts-overlay { + position: fixed; + inset: 0; + background: color-mix(in srgb, #000 40%, transparent); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: cd-shortcuts-fade 0.15s ease; +} + +@keyframes cd-shortcuts-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +.cd-shortcuts-modal { + background: var(--paper-solid); + border: 1.5px solid var(--ink); + box-shadow: 4px 4px 0 var(--shadow-ink); + max-width: 520px; + width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.cd-shortcuts-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1.5px solid var(--ink); +} + +.cd-shortcuts-header h2 { + font-size: 16px; + font-weight: 600; + margin: 0; + color: var(--ink); +} + +.cd-shortcuts-close { + font-family: inherit; + font-size: 16px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--ink-faint); + cursor: pointer; + border-radius: 2px; +} + +.cd-shortcuts-close:hover { + color: var(--ink); + background: color-mix(in srgb, var(--ink) 8%, transparent); +} + +.cd-shortcuts-body { + padding: 16px 20px; + overflow-y: auto; +} + +.cd-shortcuts-body section + section { + margin-top: 20px; +} + +.cd-shortcuts-body h3 { + font-size: 13px; + font-weight: 600; + color: var(--ink-soft); + margin: 0 0 10px 0; + padding-bottom: 6px; + border-bottom: 1px solid var(--rule); +} + +.cd-shortcuts-body dl { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 16px; + row-gap: 8px; + margin: 0; +} + +.cd-shortcuts-body dt { + font-size: 12px; + font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; + color: var(--ink); + white-space: nowrap; + align-self: center; +} + +.cd-shortcuts-body dd { + font-size: 12px; + color: var(--ink-faint); + margin: 0; + align-self: center; +} + +/* ---- 提示 ---- */ + +.cd-toast { + position: fixed; + top: 24px; + right: 24px; + font-family: inherit; + font-size: 14px; + color: #1e58b4; + background: var(--paper-solid); + border: 1.5px solid #1e58b4; + padding: 8px 18px; + border-radius: 2px; + box-shadow: 3px 3px 0 var(--shadow-ink); + animation: cd-toast-in 0.18s ease; + z-index: 100; +} + +@keyframes cd-toast-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ---- 其他 ---- */ + +.cd-body.is-dragging { + cursor: grabbing; +} + +.cd-row.cd-row-active.is-dragging-target { + outline: 1.5px dashed var(--error); + outline-offset: -1.5px; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: rgba(30, 88, 180, 0.04); + border-radius: 4px; +} +::-webkit-scrollbar-thumb { + background: var(--shadow-ink-strong); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(30, 88, 180, 0.5); + background-clip: padding-box; +} +::-webkit-scrollbar-corner { + background: transparent; +} + +@media (prefers-reduced-motion: reduce) { + .cd-toast, + .cd-row-flash { + animation: none; + } + .cd-row { + transition: none; + } + .cd-row-flash { + background: color-mix(in srgb, var(--row-color, var(--ink)) 20%, transparent); + } +} + +/* ---- 深色模式:夜抄手稿(由 ztools 主题回调或系统偏好驱动) ---- */ +:root.theme-dark { + --ink: #d9e0f5; + --ink-soft: #97a3cc; + --ink-faint: #626e9c; + --paper: transparent; /* 主背景全透明,透出浏览器原生底色 */ + --paper-solid: #242a3d; /* 浮层实底 */ + --paper-desk: transparent; + --rule: rgba(130, 160, 240, 0.15); + --rule-strong: rgba(130, 160, 240, 0.32); + --margin-red: rgba(235, 125, 125, 0.55); + --error: #e58585; + --ink-body: #cdd6f2; + --ink-note: #8d99c9; + --wash-ink: rgba(130, 160, 240, 0.12); + --wash-red: rgba(235, 125, 125, 0.14); + --shadow-ink: rgba(0, 0, 0, 0.4); + --shadow-ink-strong: rgba(0, 0, 0, 0.5); + --paper-shadow-a: rgba(0, 0, 0, 0.3); + --paper-shadow-b: rgba(0, 0, 0, 0.45); + --mask: rgba(0, 0, 0, 0.55); + --ink-hover: #3d4a80; + --error-hover: #b95c60; + --paper-solid: #242a3d; +} diff --git a/plugins/calc-draft/src/CalcDraft/index.tsx b/plugins/calc-draft/src/CalcDraft/index.tsx new file mode 100644 index 000000000..8b9a890f0 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/index.tsx @@ -0,0 +1,701 @@ +import { useEffect, useMemo, useReducer, useRef, useState } from 'react' +import './index.css' +import { + docReducer, + loadWorkspaceManager, + migrateFromLegacyDoc, + serializeWorkspaceManager, + workspaceReducer, + STORAGE_KEY, + LEGACY_STORAGE_KEY, + type Action +} from './state' +import { evaluatePage, formatNumber, upstreamRowIds, type CellResult } from './engine/evaluator' +import { colorForSeq, setPaletteDark } from './palette' +import CalcRow from './components/CalcRow' +import PageTabs from './components/PageTabs' +import WorkspaceBar from './components/WorkspaceBar' +import Toast from './components/Toast' +import ConfirmDialog from './components/ConfirmDialog' + +function genId(): string { + return typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now() + Math.random()) +} + +export default function CalcDraft() { + const [manager, wsDispatch] = useReducer(workspaceReducer, undefined, () => { + // 工作空间迁移:优先尝试加载新版数据,否则从旧版迁移 + if (typeof localStorage === 'undefined') { + return loadWorkspaceManager(null) + } + const newRaw = localStorage.getItem(STORAGE_KEY) + if (newRaw) return loadWorkspaceManager(newRaw) + // 尝试从旧版迁移 + const legacyRaw = localStorage.getItem(LEGACY_STORAGE_KEY) + if (legacyRaw) { + try { + const legacyDoc = JSON.parse(legacyRaw) + if (legacyDoc && Array.isArray(legacyDoc.pages)) { + return migrateFromLegacyDoc(legacyDoc) + } + } catch { + // 忽略迁移失败 + } + } + return loadWorkspaceManager(null) + }) + + const [ready, setReady] = useState(false) + const [editingRowId, setEditingRowId] = useState(null) + const [activeRowId, setActiveRowId] = useState(null) + const [toast, setToast] = useState(null) + const [flashRowId, setFlashRowId] = useState(null) + const [confirmDeletePage, setConfirmDeletePage] = useState(false) + const [showShortcuts, setShowShortcuts] = useState(false) + const [pendingFocusId, setPendingFocusId] = useState(null) + const [pendingSelectId, setPendingSelectId] = useState(null) + const [pendingSubtitleId, setPendingSubtitleId] = useState(null) + const [dragging, setDragging] = useState(false) + const toastTimer = useRef(null) + + // 主题:使用浏览器 prefers-color-scheme 媒体查询监听深浅色切换 + const [isDark, setIsDark] = useState(false) + useEffect(() => { + const mq = window.matchMedia('(prefers-color-scheme: dark)') + const apply = (matches: boolean) => { + setPaletteDark(matches) + setIsDark(matches) + } + apply(mq.matches) + const fn = (e: MediaQueryListEvent) => apply(e.matches) + mq.addEventListener('change', fn) + return () => mq.removeEventListener('change', fn) + }, []) + useEffect(() => { + document.documentElement.classList.toggle('theme-dark', isDark) + }, [isDark]) + + // 当前工作空间 + const workspace = + manager.workspaces.find((w) => w.id === manager.activeWorkspaceId) ?? manager.workspaces[0] + const doc = workspace.doc + + const page = + doc.pages.find((p) => p.id === doc.activePageId) ?? doc.pages[0] + + // 求值当前页(带跨草稿 @ 引用上下文);任何工作空间变化都会触发重算 + const evaluation = useMemo(() => { + const ctx = { + getPageByIndex: (index: number) => doc.pages[index - 1], + rowMemo: new Map(), + stack: new Set() + } + return evaluatePage(page.rows, ctx) + }, [manager]) + + // 辅助函数:dispatch 草稿 Action 到当前工作空间 + const dispatch = (action: Action) => wsDispatch({ type: 'dispatchToActive', action }) + + useEffect(() => { + setReady(true) + }, []) + + useEffect(() => { + if (!ready) return + const timer = setTimeout(() => { + try { + localStorage.setItem(STORAGE_KEY, serializeWorkspaceManager(manager)) + } catch { + // 忽略存储失败 + } + }, 500) + return () => clearTimeout(timer) + }, [manager, ready]) + + useEffect(() => { + if (!pendingFocusId) return + setEditingRowId(pendingFocusId) + setPendingFocusId(null) + }, [pendingFocusId, page.rows]) + + useEffect(() => { + if (!pendingSelectId) return + setActiveRowId(pendingSelectId) + setPendingSelectId(null) + }, [pendingSelectId, page.rows]) + + useEffect(() => { + setEditingRowId(null) + setActiveRowId(null) + }, [doc.activePageId]) + + // 保证最少有一行:页签为空(如旧数据)时自动补一行 + useEffect(() => { + if (page.rows.length === 0) { + dispatch({ type: 'addRow', pageId: page.id, rowId: genId(), expr: '' }) + } + }, [page.id, page.rows.length]) + + const showToast = (message: string) => { + setToast(message) + if (toastTimer.current) window.clearTimeout(toastTimer.current) + toastTimer.current = window.setTimeout(() => setToast(null), 1500) + } + + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + showToast(`已抄录 ${text}`) + } catch { + showToast('抄录失败') + } + } + + const injectRef = useRef(null) + + useEffect(() => { + const onFocusIn = (e: FocusEvent) => { + if (e.target instanceof HTMLInputElement && e.target.classList.contains('cd-input')) { + injectRef.current = e.target + } + } + document.addEventListener('focusin', onFocusIn) + return () => { + document.removeEventListener('focusin', onFocusIn) + } + }, []) + + const handleInject = (text: string) => { + const input = injectRef.current + if (!input || !document.contains(input)) { + showToast('先落笔一行,再续写') + return + } + input.focus() + const start = input.selectionStart ?? input.value.length + const end = input.selectionEnd ?? start + const next = input.value.slice(0, start) + text + input.value.slice(end) + const cursor = start + text.length + const rowId = input.closest('[data-row-id]')?.getAttribute('data-row-id') + if (rowId) { + dispatch({ type: 'editRow', pageId: page.id, rowId, expr: next }) + requestAnimationFrame(() => { + input.setSelectionRange(cursor, cursor) + }) + } + } + + const addRow = (afterRowId?: string) => { + const rowId = genId() + dispatch({ type: 'addRow', pageId: page.id, rowId, expr: '', afterRowId }) + setPendingFocusId(rowId) + return rowId + } + + const navigateRow = (direction: 'up' | 'down') => { + const index = page.rows.findIndex((r) => r.id === activeRowId) + if (index < 0) return + const next = direction === 'up' ? index - 1 : index + 1 + if (next < 0 || next >= page.rows.length) return + const row = page.rows[next] + setActiveRowId(row.id) + setEditingRowId(row.id) + requestAnimationFrame(() => { + document + .querySelector(`[data-row-id="${row.id}"]`) + ?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }) + } + + // Tab 按输入框顺序切换:#1备注 → #1公式 → #2备注 → #2公式 → ... + const handleInputTab = (fromRowId: string, fromInput: 'expr' | 'subtitle', direction: 'up' | 'down') => { + const rows = page.rows + const currentIndex = rows.findIndex((r) => r.id === fromRowId) + if (currentIndex < 0) return + + let targetRow = currentIndex + let targetInput: 'expr' | 'subtitle' + + if (direction === 'down') { + if (fromInput === 'subtitle') { + targetInput = 'expr' + } else { + // expr → next row's subtitle + if (currentIndex + 1 >= rows.length) return + targetRow = currentIndex + 1 + targetInput = 'subtitle' + } + } else { + if (fromInput === 'expr') { + targetInput = 'subtitle' + } else { + // subtitle → prev row's expr + if (currentIndex - 1 < 0) return + targetRow = currentIndex - 1 + targetInput = 'expr' + } + } + + const row = rows[targetRow] + const sameRow = targetRow === currentIndex + setEditingRowId(null) + setActiveRowId(row.id) + if (targetInput === 'subtitle') { + setPendingFocusId(null) + setPendingSubtitleId(row.id) + } else { + setPendingSubtitleId(null) + setPendingFocusId(row.id) + } + requestAnimationFrame(() => { + document + .querySelector(`[data-row-id="${row.id}"]`) + ?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }) + // 同行切换时,blur 会清空 activeRowId,需要恢复 + if (sameRow) { + requestAnimationFrame(() => { + setActiveRowId(row.id) + if (targetInput === 'subtitle') { + setPendingSubtitleId(row.id) + } else { + setPendingFocusId(row.id) + } + }) + } + } + + // Alt+←/→ 字段切换:跨行跳到相邻行的备注/公式(行内切换由 CalcRow 本地处理) + // Excel 式交互:↑/↓ 选中行(无选中时选视口内第一/最后一行),回车/F2 进入编辑 + useEffect(() => { + if (editingRowId) return + const onKeyDown = (e: KeyboardEvent) => { + if (document.querySelector('.cd-dialog')) return + if (e.metaKey || e.ctrlKey || e.altKey) return + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === 'Escape') { + // 取消选中 + setActiveRowId(null) + return + } + if (e.key === 'Enter' || e.key === 'F2') { + if (!activeRowId) return + e.preventDefault() + setEditingRowId(activeRowId) + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + // 无选中:选视口内可见的第一行(↓)/最后一行(↑) + if (!activeRowId) { + const bodyRect = document.querySelector('.cd-body')?.getBoundingClientRect() + if (!bodyRect) return + const visible = [...document.querySelectorAll('.cd-row')].filter((r) => { + const t = r.getBoundingClientRect() + return t.bottom > bodyRect.top + 1 && t.top < bodyRect.bottom - 1 + }) + if (visible.length === 0) return + const target = e.key === 'ArrowDown' ? visible[0] : visible[visible.length - 1] + setActiveRowId(target.getAttribute('data-row-id') ?? '') + return + } + const idx = page.rows.findIndex((r) => r.id === activeRowId) + const next = e.key === 'ArrowUp' ? idx - 1 : idx + 1 + if (next < 0 || next >= page.rows.length) return + const target = page.rows[next] + setActiveRowId(target.id) + // 选中项滚出可视区时,让视图跟上 + requestAnimationFrame(() => { + document + .querySelector(`[data-row-id="${target.id}"]`) + ?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [activeRowId, editingRowId, page.rows]) + + const rows = page.rows + + const handleMove = (fromId: string, toIndexBase: number, position: 'before' | 'after') => { + const fromIndex = rows.findIndex((r) => r.id === fromId) + if (fromIndex < 0) return + let toIndex = position === 'before' ? toIndexBase : toIndexBase + 1 + if (fromIndex < toIndex) toIndex -= 1 + if (fromIndex === toIndex) return + dispatch({ type: 'moveRow', pageId: page.id, rowId: fromId, toIndex }) + } + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (document.querySelector('.cd-dialog')) return + const mod = e.metaKey || e.ctrlKey + + // 焦点在输入框内时不拦截(行内编辑由 CalcRow 自行处理) + const inInput = + e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement + + if (mod && e.shiftKey && e.key.toLowerCase() === 'w') { + e.preventDefault() + setConfirmDeletePage(true) + } else if (e.key === 'Escape' && showShortcuts) { + e.preventDefault() + setShowShortcuts(false) + } else if (mod && !e.shiftKey && e.key.toLowerCase() === 'c' && !inInput && activeRowId) { + e.preventDefault() + const row = page.rows.find((r) => r.id === activeRowId) || page.rows[page.rows.length - 1] + if (!row) return + const res = evaluation.results.get(row.id) + if (res && res.status === 'ok') handleCopy(formatNumber(res.value)) + else showToast('这一行还没有结果') + } else if (mod && e.shiftKey && e.key.toLowerCase() === 'n') { + e.preventDefault() + dispatch({ + type: 'addPage', + pageId: genId() + }) + } else if (mod && e.key === 'Enter') { + e.preventDefault() + addRow(page.rows[page.rows.length - 1]?.id) + } else if (!mod && e.key === 'Enter' && !inInput && !activeRowId && !editingRowId) { + // 无行选中/编辑时,回车在末尾新建一行 + e.preventDefault() + addRow(page.rows[page.rows.length - 1]?.id) + } else if (e.altKey && !e.metaKey && !e.ctrlKey && /^[0-9]$/.test(e.key)) { + e.preventDefault() + const num = e.key === '0' ? 10 : Number(e.key) + const row = page.rows[num - 1] + if (!row) { + showToast(`#${num} 还没写`) + return + } + setActiveRowId(row.id) + setEditingRowId(row.id) + } else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + const rowId = activeRowId + if (!rowId) return + e.preventDefault() + const fromIndex = page.rows.findIndex((r) => r.id === rowId) + if (fromIndex < 0) return + const toIndex = e.key === 'ArrowUp' ? fromIndex - 1 : fromIndex + 1 + if (toIndex < 0 || toIndex >= page.rows.length) return + dispatch({ type: 'moveRow', pageId: page.id, rowId, toIndex }) + showToast(`已移到 #${toIndex + 1}`) + } else if (e.altKey && !e.metaKey && !e.ctrlKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + // Alt+Left/Right 切换草稿页 + if (doc.pages.length <= 1) return + e.preventDefault() + const currentIndex = doc.pages.findIndex((p) => p.id === page.id) + if (currentIndex < 0) return + const nextIndex = e.key === 'ArrowLeft' ? currentIndex - 1 : currentIndex + 1 + if (nextIndex < 0 || nextIndex >= doc.pages.length) return + dispatch({ type: 'setActivePage', pageId: doc.pages[nextIndex].id }) + } else if ((e.key === 'Backspace' || e.key === 'Delete') && activeRowId && !inInput && !editingRowId) { + // 选中模式下 Backspace / Delete 删除当前行 + e.preventDefault() + const row = page.rows.find((r) => r.id === activeRowId) + if (!row) return + const index = page.rows.findIndex((r) => r.id === row.id) + if (page.rows.length <= 1) { + dispatch({ type: 'editRow', pageId: page.id, rowId: row.id, expr: '' }) + return + } + setEditingRowId(null) + setActiveRowId(null) + const neighbor = page.rows[index + 1] ?? page.rows[index - 1] + if (neighbor && neighbor.id !== row.id) setPendingSelectId(neighbor.id) + dispatch({ type: 'deleteRow', pageId: page.id, rowId: row.id }) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [page, activeRowId, editingRowId, evaluation, showShortcuts]) + + // 点击 #N 跳转到对应行:滚动 + 闪烁高亮,不进入编辑 + const flashTimer = useRef(null) + const jumpToRow = (targetId: string) => { + setFlashRowId(targetId) + if (flashTimer.current) window.clearTimeout(flashTimer.current) + flashTimer.current = window.setTimeout(() => setFlashRowId(null), 1200) + document + .querySelector(`[data-row-id="${targetId}"]`) + ?.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } + + const upstream = activeRowId ? upstreamRowIds(evaluation.directDeps, activeRowId) : new Set() + + // 标题被省略号截断时才启用 hover 浮层 + const titleRef = useRef(null) + const [titleTruncated, setTitleTruncated] = useState(false) + useEffect(() => { + const check = () => { + const el = titleRef.current + setTitleTruncated(!!el && el.scrollWidth > el.clientWidth + 1) + } + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, [page.name]) + + return ( +
+ {/* 工作空间悬浮按钮 */} + wsDispatch({ type: 'switchWorkspace', workspaceId })} + onCreate={() => wsDispatch({ type: 'addWorkspace', workspaceId: genId() })} + onRename={(workspaceId, name) => + wsDispatch({ type: 'renameWorkspace', workspaceId, name }) + } + onDelete={(workspaceId) => + wsDispatch({ type: 'deleteWorkspace', workspaceId }) + } + /> + +
+
+
+ + {page.name} + + {titleTruncated && ( + + )} +
+ + dispatch({ + type: 'addPage', + pageId: genId() + }) + } + onRename={(pageId, name) => dispatch({ type: 'renamePage', pageId, name })} + onSwitch={(pageId) => dispatch({ type: 'setActivePage', pageId })} + /> + + +
+ {page.rows.length} 行 +
+
+ +
+ {page.rows.length === 0 ? ( +
落第一笔:输入表达式,回车起一行 · #1 引用上文结果
+ ) : ( +
{page.rows.map((row, index) => { + const result: CellResult = evaluation.results.get(row.id) ?? { status: 'empty' } + const activeRowNum = activeRowId + ? page.rows.findIndex((r) => r.id === activeRowId) + 1 + : 0 + const inboundFrom = + activeRowId && activeRowNum > 0 && upstream.has(row.id) + ? evaluation.directDeps.get(activeRowId)?.includes(row.id) + ? activeRowNum + : undefined + : undefined + const refColorFor = (pos: number) => { + const target = page.rows[pos - 1] + return target ? colorForSeq(target.seq) : '#8b94b5' + } + return ( + page.rows[pos - 1]?.subtitle} + refOptions={page.rows.map((r, i) => ({ pos: i + 1, subtitle: r.subtitle }))} + pageOptions={doc.pages.map((p) => ({ + id: p.id, + name: p.name, + rows: p.rows.map((r, i) => ({ pos: i + 1, subtitle: r.subtitle })) + }))} + atSubtitleFor={(page) => doc.pages[Number(page) - 1]?.name} + atRowSubtitleFor={(page, ref) => + doc.pages[Number(page) - 1]?.rows[Number(ref) - 1]?.subtitle + } + onAtClick={(page, ref) => { + const targetPage = doc.pages[Number(page) - 1] + if (!targetPage) { + showToast(`未找到第 ${page} 个草稿`) + return + } + const row = targetPage.rows[Number(ref) - 1] + if (!row) { + showToast(`草稿「${targetPage.name}」中没有第 ${ref} 行`) + return + } + const go = () => { + setFlashRowId(row.id) + if (flashTimer.current) window.clearTimeout(flashTimer.current) + flashTimer.current = window.setTimeout(() => setFlashRowId(null), 1200) + document + .querySelector(`[data-row-id="${row.id}"]`) + ?.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } + if (targetPage.id !== doc.activePageId) { + dispatch({ type: 'setActivePage', pageId: targetPage.id }) + window.setTimeout(go, 80) + } else { + go() + } + }} + isEditing={editingRowId === row.id} + isActive={activeRowId === row.id} + isUpstream={activeRowId !== row.id && upstream.has(row.id)} + inboundFrom={inboundFrom} + isFlashed={flashRowId === row.id} + onRefClick={(pos) => { + const target = page.rows[pos - 1] + if (target) jumpToRow(target.id) + else showToast(`#${pos} 还没写`) + }} + onChange={(expr) => + dispatch({ type: 'editRow', pageId: page.id, rowId: row.id, expr }) + } + onFocus={() => setActiveRowId(row.id)} + onBlur={() => { + setActiveRowId((current) => (current === row.id ? null : current)) + setEditingRowId((current) => (current === row.id ? null : current)) + }} + onEnter={() => addRow(row.id)} + onEscape={() => { + // Esc 退回选中模式,保留选中态 + setEditingRowId(null) + }} + onNavigate={navigateRow} + onInputTab={(fromInput, direction) => handleInputTab(row.id, fromInput, direction)} + subtitleFocusPending={pendingSubtitleId === row.id} + onSubtitleChange={(subtitle) => + dispatch({ type: 'editSubtitle', pageId: page.id, rowId: row.id, subtitle }) + } + onCopy={handleCopy} + onInject={handleInject} + onRequestEdit={() => { + setEditingRowId(row.id) + setActiveRowId(row.id) + }} + onDelete={() => { + // 至少保留一行:删最后一行改为清空内容 + if (page.rows.length <= 1) { + dispatch({ type: 'editRow', pageId: page.id, rowId: row.id, expr: '' }) + return + } + setEditingRowId((c) => (c === row.id ? null : c)) + setActiveRowId((c) => (c === row.id ? null : c)) + const neighbor = page.rows[index + 1] ?? page.rows[index - 1] + if (neighbor && neighbor.id !== row.id) setPendingFocusId(neighbor.id) + dispatch({ type: 'deleteRow', pageId: page.id, rowId: row.id }) + }} + onMove={handleMove} + onDragStateChange={setDragging} + /> + ) + })} +
+ )} +
addRow(page.rows[page.rows.length - 1]?.id)} + > + {Array.from({ length: 40 }, (_, i) => ( + + {i === 0 && !activeRowId && !editingRowId && ( + 双击或回车,新建一行 + )} + + ))} +
+
+ +
+ + + + {showShortcuts && ( +
setShowShortcuts(false)}> +
e.stopPropagation()}> +
+

快捷键

+ +
+
+
+

公式行

+
+
Enter / F2 / 双击
进入当前选中行的编辑模式
+
Ctrl/Cmd + C
复制当前选中行(或末行)结果到剪贴板
+
Ctrl/Cmd + Enter
在末尾插入新行并聚焦
+
Alt + ↑ / Alt + ↓
移动公式行并同步引用
+
Tab
切换输入焦点到下一个输入框
+
Alt + Backspace
编辑模式下删除当前行
+
Backspace / Delete
选中模式下删除当前行
+
+
+
+

草稿页

+
+
Ctrl/Cmd + Shift + N
新建草稿页
+
Ctrl/Cmd + Shift + W
删除草稿页
+
Alt + ← / Alt + →
切换草稿标签页
+
+
+
+

鼠标

+
+
Ctrl/Cmd + 单击 @页#行
引用跳转
+
Alt + 单击数字按钮
注入到当前编辑光标处
+
+
+
+
+
+ )} + + { + dispatch({ type: 'deletePage', pageId: page.id }) + setConfirmDeletePage(false) + }} + onCancel={() => setConfirmDeletePage(false)} + /> +
+
+ ) +} diff --git a/plugins/calc-draft/src/CalcDraft/palette.ts b/plugins/calc-draft/src/CalcDraft/palette.ts new file mode 100644 index 000000000..1857603c9 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/palette.ts @@ -0,0 +1,36 @@ +export const PALETTE = [ + '#1E58B4', // 靛蓝 + '#3A7A44', // 墨绿 + '#B8781E', // 赭石 + '#7A4A9A', // 青莲 + '#C0444A', // 朱砂 + '#1E7A78', // 竹月 + '#8A5A2E', // 熟褐 + '#4A5A8A', // 黛蓝 + '#B04A7A', // 胭脂 + '#2E6E4E' // 松绿 +] + +export function colorForSeq(seq: number): string { + // 无效序号(NaN/0/负数,如未完成的 @1# 引用)回退到极淡墨 + if (!Number.isFinite(seq) || seq < 1) return paletteDark ? '#aebadb' : '#8b94b5' + const index = ((seq - 1) % PALETTE.length + PALETTE.length) % PALETTE.length + const color = PALETTE[index] + return paletteDark ? lighten(color) : color +} + +// 深色模式下把墨色系提亮(色板原为浅色纸面设计) +let paletteDark = false +export function setPaletteDark(dark: boolean) { + paletteDark = dark +} + +function lighten(hex: string, amount = 0.55): string { + const n = hex.replace('#', '') + const r = parseInt(n.slice(0, 2), 16) + const g = parseInt(n.slice(2, 4), 16) + const b = parseInt(n.slice(4, 6), 16) + const mix = (c: number) => Math.round(c + (255 - c) * amount) + return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})` +} + diff --git a/plugins/calc-draft/src/CalcDraft/state.test.ts b/plugins/calc-draft/src/CalcDraft/state.test.ts new file mode 100644 index 000000000..e5931ebe8 --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/state.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect } from 'vitest' +import { + createDefaultWorkspaceManager, + createEmptyDoc, + docReducer, + loadDoc, + loadWorkspaceManager, + migrateFromLegacyDoc, + serializeDoc, + serializeWorkspaceManager, + workspaceReducer, + STORAGE_KEY, + LEGACY_STORAGE_KEY, + type DraftDoc +} from './state' +import { colorForSeq, PALETTE } from './palette' + +function docWithRows(): DraftDoc { + let doc = createEmptyDoc() + const pageId = doc.activePageId + doc = docReducer(doc, { type: 'addRow', pageId, rowId: 'r1', expr: '1' }) + doc = docReducer(doc, { type: 'addRow', pageId, rowId: 'r2', expr: '2' }) + doc = docReducer(doc, { type: 'addRow', pageId, rowId: 'r3', expr: '3' }) + return doc +} + +describe('docReducer rows', () => { + it('addRow 按创建顺序分配 seq', () => { + const doc = docWithRows() + const rows = doc.pages[0].rows + expect(rows.map((r) => r.seq)).toEqual([1, 2, 3]) + expect(doc.pages[0].seqCounter).toBe(3) + }) + + it('删除最大 seq 行后再添加,seq 不复用', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'deleteRow', pageId: doc.activePageId, rowId: 'r3' }) + doc = docReducer(doc, { type: 'addRow', pageId: doc.activePageId, rowId: 'r4', expr: '9' }) + const rows = doc.pages[0].rows + expect(rows.find((r) => r.id === 'r4')!.seq).toBe(4) + expect(rows.map((r) => r.seq)).toEqual([1, 2, 4]) + }) + + it('删除中间行不影响其他 seq', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'deleteRow', pageId: doc.activePageId, rowId: 'r2' }) + const rows = doc.pages[0].rows + expect(rows.map((r) => r.seq)).toEqual([1, 3]) + }) + + it('moveRow 只改顺序不改 seq', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'moveRow', pageId: doc.activePageId, rowId: 'r3', toIndex: 0 }) + const rows = doc.pages[0].rows + expect(rows.map((r) => r.id)).toEqual(['r3', 'r1', 'r2']) + expect(rows.map((r) => r.seq)).toEqual([3, 1, 2]) + }) + + it('editSubtitle 设置与清空备注', () => { + let doc = docWithRows() + const pid = doc.activePageId + doc = docReducer(doc, { type: 'editSubtitle', pageId: pid, rowId: 'r2', subtitle: ' 家电预算 ' }) + expect(doc.pages[0].rows.find((r) => r.id === 'r2')!.subtitle).toBe('家电预算') + doc = docReducer(doc, { type: 'editSubtitle', pageId: pid, rowId: 'r2', subtitle: ' ' }) + expect(doc.pages[0].rows.find((r) => r.id === 'r2')!.subtitle).toBeUndefined() + }) + + it('editRow 只改 expr', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'editRow', pageId: doc.activePageId, rowId: 'r1', expr: '42' }) + expect(doc.pages[0].rows[0]).toEqual({ id: 'r1', seq: 1, expr: '42' }) + }) +}) + +describe('行号引用联动(#N 指向当前行号)', () => { + const expr = (id: string, n: number) => `${id}#${n}` // 便于断言引用文本 + + it('在引用行之前插入,引用整体后移', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'editRow', pageId: doc.activePageId, rowId: 'r3', expr: '#2 * 2' }) + // 在第 2 行(r2)之前插入新行:r2 变 #3,引用 #2 应改写为 #3 + doc = docReducer(doc, { + type: 'addRow', + pageId: doc.activePageId, + rowId: 'r0', + expr: '', + afterRowId: 'r1' + }) + expect(doc.pages[0].rows.find((r) => r.id === 'r3')!.expr).toBe('#3 * 2') + }) + + it('在被引用行之前插入后求值仍指向同一行 id', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'editRow', pageId: doc.activePageId, rowId: 'r3', expr: '#2 + 0' }) + doc = docReducer(doc, { + type: 'addRow', + pageId: doc.activePageId, + rowId: 'r0', + expr: '', + afterRowId: 'r1' + }) + // r2 原是 #2,插入后是 #3;r3 的引用应已改写为 #3 + expect(doc.pages[0].rows.map((r) => r.expr)).toEqual(['1', '', '2', '#3 + 0']) + }) + + it('删除被引用行,引用失效为 #0;其余引用前移', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'editRow', pageId: doc.activePageId, rowId: 'r3', expr: '#1 + #2' }) + doc = docReducer(doc, { type: 'deleteRow', pageId: doc.activePageId, rowId: 'r2' }) + expect(doc.pages[0].rows.find((r) => r.id === 'r3')!.expr).toBe('#1 + #0') + }) + + it('拖拽换位后引用跟随行移动', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'editRow', pageId: doc.activePageId, rowId: 'r3', expr: '#1 * #2' }) + // r3 移到最前:r1 从 #1 变 #2,r2 从 #2 变 #3 + doc = docReducer(doc, { type: 'moveRow', pageId: doc.activePageId, rowId: 'r3', toIndex: 0 }) + expect(doc.pages[0].rows.find((r) => r.id === 'r3')!.expr).toBe('#2 * #3') + }) + + it('悬空引用(超出行数)保持原样', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'editRow', pageId: doc.activePageId, rowId: 'r1', expr: '#9 + 1' }) + doc = docReducer(doc, { type: 'addRow', pageId: doc.activePageId, rowId: 'r4', expr: '' }) + expect(doc.pages[0].rows.find((r) => r.id === 'r1')!.expr).toBe('#9 + 1') + }) + + it('数字中的 # 只改引用 token,不动普通数字', () => { + let doc = docWithRows() + doc = docReducer(doc, { type: 'editRow', pageId: doc.activePageId, rowId: 'r3', expr: '12 + #1' }) + // r3 移到最前:#1 指向的 r1 从 #1 变 #2,前导数字 12 不受影响 + doc = docReducer(doc, { type: 'moveRow', pageId: doc.activePageId, rowId: 'r3', toIndex: 0 }) + expect(doc.pages[0].rows.find((r) => r.id === 'r3')!.expr).toBe('12 + #2') + }) +}) + +describe('docReducer pages', () => { + it('addPage 新建页并激活', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'addPage', pageId: 'p2', name: '第二页' }) + expect(doc.pages).toHaveLength(2) + expect(doc.activePageId).toBe('p2') + }) + + it('deletePage 切到相邻页', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'addPage', pageId: 'p2', name: '第二页' }) + doc = docReducer(doc, { type: 'deletePage', pageId: doc.activePageId }) + expect(doc.pages).toHaveLength(1) + expect(doc.activePageId).toBe(doc.pages[0].id) + }) + + it('删除最后一个草稿:重建空页并跳转', () => { + let doc = createEmptyDoc() + const oldId = doc.activePageId + doc = docReducer(doc, { type: 'deletePage', pageId: doc.activePageId }) + expect(doc.pages).toHaveLength(1) + expect(doc.activePageId).not.toBe(oldId) + expect(doc.pages[0].rows).toEqual([]) + }) + + it('删除中间页跳到上一页,删除第一页跳到下一页', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'addPage', pageId: 'p2', name: '第二页' }) + doc = docReducer(doc, { type: 'addPage', pageId: 'p3', name: '第三页' }) + // 删除中间页 p2 → 跳到上一页 p1 + doc = docReducer(doc, { type: 'deletePage', pageId: 'p2' }) + expect(doc.activePageId).toBe(doc.pages[0].id) + // 再删除第一页 p1 → 跳到下一页 p3 + doc = docReducer(doc, { type: 'deletePage', pageId: doc.pages[0].id }) + expect(doc.activePageId).toBe('p3') + }) + + it('renamePage 改名', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'renamePage', pageId: doc.activePageId, name: '工资' }) + expect(doc.pages[0].name).toBe('工资') + }) + + it('renamePage 截断超长标题,空名不生效', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'renamePage', pageId: doc.activePageId, name: '字'.repeat(30) }) + expect(doc.pages[0].name).toBe('字'.repeat(20)) + doc = docReducer(doc, { type: 'renamePage', pageId: doc.activePageId, name: ' ' }) + expect(doc.pages[0].name).toBe('字'.repeat(20)) + }) + + it('setActivePage 切页', () => { + let doc = createEmptyDoc() + doc = docReducer(doc, { type: 'addPage', pageId: 'p2', name: 'x' }) + doc = docReducer(doc, { type: 'setActivePage', pageId: doc.pages[0].id }) + expect(doc.activePageId).toBe(doc.pages[0].id) + }) +}) + +describe('持久化', () => { + it('serialize/load 往返', () => { + const doc = docWithRows() + const restored = loadDoc(serializeDoc(doc)) + expect(restored).toEqual(doc) + }) + + it('null → 空文档', () => { + const doc = loadDoc(null) + expect(doc.pages).toHaveLength(1) + expect(doc.pages[0].rows).toEqual([]) + }) + + it('损坏 JSON → 空文档不抛异常(Review Focus #4)', () => { + const doc = loadDoc('{not json') + expect(doc.pages).toHaveLength(1) + expect(doc.activePageId).toBe(doc.pages[0].id) + }) + + it('结构非法(缺 pages)→ 空文档', () => { + const doc = loadDoc('{"foo":1}') + expect(doc.pages).toHaveLength(1) + }) + + it('STORAGE_KEY 固定', () => { + expect(STORAGE_KEY).toBe('calc-draft:workspaces') + expect(LEGACY_STORAGE_KEY).toBe('calc-draft:doc') + }) +}) + +describe('workspaceReducer', () => { + it('addWorkspace 新建工作空间并激活', () => { + const initial = createDefaultWorkspaceManager() + const action = { type: 'addWorkspace' as const, workspaceId: 'ws-2' } + const next = workspaceReducer(initial, action) + expect(next.workspaces).toHaveLength(2) + expect(next.activeWorkspaceId).toBe('ws-2') + expect(next.workspaces[1].name).toBe('工作空间 2') + }) + + it('deleteWorkspace 删除并切换到相邻工作空间', () => { + let manager = createDefaultWorkspaceManager() + const ws2Id = 'ws-2' + manager = workspaceReducer(manager, { type: 'addWorkspace', workspaceId: ws2Id }) + expect(manager.workspaces).toHaveLength(2) + manager = workspaceReducer(manager, { type: 'deleteWorkspace', workspaceId: manager.activeWorkspaceId }) + expect(manager.workspaces).toHaveLength(1) + expect(manager.activeWorkspaceId).toBe(manager.workspaces[0].id) + }) + + it('deleteWorkspace 最后一个工作空间会重置为默认', () => { + let manager = createDefaultWorkspaceManager() + manager = workspaceReducer(manager, { type: 'deleteWorkspace', workspaceId: manager.workspaces[0].id }) + expect(manager.workspaces).toHaveLength(1) + expect(manager.workspaces[0].name).toBe('默认工作空间') + }) + + it('renameWorkspace 重命名', () => { + let manager = createDefaultWorkspaceManager() + const wsId = manager.workspaces[0].id + manager = workspaceReducer(manager, { type: 'renameWorkspace', workspaceId: wsId, name: '项目A' }) + expect(manager.workspaces[0].name).toBe('项目A') + }) + + it('switchWorkspace 切换活动工作空间', () => { + let manager = createDefaultWorkspaceManager() + manager = workspaceReducer(manager, { type: 'addWorkspace', workspaceId: 'ws-2' }) + const firstId = manager.workspaces[0].id + manager = workspaceReducer(manager, { type: 'switchWorkspace', workspaceId: firstId }) + expect(manager.activeWorkspaceId).toBe(firstId) + }) + + it('dispatchToActive 只影响当前工作空间', () => { + let manager = createDefaultWorkspaceManager() + const firstWsId = manager.workspaces[0].id + manager = workspaceReducer(manager, { type: 'addWorkspace', workspaceId: 'ws-2' }) + // 切换回第一个工作空间 + manager = workspaceReducer(manager, { type: 'switchWorkspace', workspaceId: firstWsId }) + const rowTestId = 'row-test-1' + manager = workspaceReducer(manager, { + type: 'dispatchToActive', + action: { type: 'addRow', pageId: manager.workspaces[0].doc.activePageId, rowId: rowTestId, expr: '1+1' } + }) + expect(manager.workspaces[0].doc.pages[0].rows).toHaveLength(1) + expect(manager.workspaces[1].doc.pages[0].rows).toHaveLength(0) + }) + + it('migrateFromLegacyDoc 将旧文档迁移到新工作空间', () => { + const legacyDoc = createEmptyDoc() + const manager = migrateFromLegacyDoc(legacyDoc) + expect(manager.workspaces).toHaveLength(1) + expect(manager.workspaces[0].name).toBe('我的草稿') + expect(manager.workspaces[0].doc).toBe(legacyDoc) + }) + + it('serialize/loadWorkspaceManager 往返', () => { + let manager = createDefaultWorkspaceManager() + manager = workspaceReducer(manager, { type: 'addWorkspace', workspaceId: 'ws-2', name: '第二空间' }) + const raw = serializeWorkspaceManager(manager) + const loaded = loadWorkspaceManager(raw) + expect(loaded.workspaces).toHaveLength(2) + expect(loaded.workspaces[1].name).toBe('第二空间') + }) + + it('loadWorkspaceManager 损坏 JSON 时不抛异常', () => { + const loaded = loadWorkspaceManager('{invalid json') + expect(loaded.workspaces).toHaveLength(1) + expect(loaded.workspaces[0].name).toBe('默认工作空间') + }) +}) + +describe('colorForSeq', () => { + it('同 seq 颜色稳定', () => { + expect(colorForSeq(1)).toBe(colorForSeq(1)) + }) + + it('超出调色板长度循环', () => { + expect(colorForSeq(PALETTE.length + 1)).toBe(colorForSeq(1)) + }) +}) diff --git a/plugins/calc-draft/src/CalcDraft/state.ts b/plugins/calc-draft/src/CalcDraft/state.ts new file mode 100644 index 000000000..426a2f1ca --- /dev/null +++ b/plugins/calc-draft/src/CalcDraft/state.ts @@ -0,0 +1,448 @@ +export type Row = { id: string; seq: number; expr: string; subtitle?: string } + +export type Page = { + id: string + name: string + rows: Row[] + createdAt: number + seqCounter: number +} + +export type DraftDoc = { + pages: Page[] + activePageId: string +} + +export type Workspace = { + id: string + name: string + doc: DraftDoc + createdAt: number +} + +export type WorkspaceManager = { + workspaces: Workspace[] + activeWorkspaceId: string +} + +export type Action = + | { type: 'addRow'; pageId: string; rowId: string; expr?: string; afterRowId?: string } + | { type: 'editRow'; pageId: string; rowId: string; expr: string } + | { type: 'editSubtitle'; pageId: string; rowId: string; subtitle: string } + | { type: 'deleteRow'; pageId: string; rowId: string } + | { type: 'moveRow'; pageId: string; rowId: string; toIndex: number } + | { type: 'addPage'; pageId: string; name?: string } + | { type: 'deletePage'; pageId: string } + | { type: 'renamePage'; pageId: string; name: string } + | { type: 'setActivePage'; pageId: string } + +/** 工作空间级别的 Action(操作 WorkplaceManager) */ +export type WorkspaceAction = + | { type: 'addWorkspace'; workspaceId: string; name?: string } + | { type: 'deleteWorkspace'; workspaceId: string } + | { type: 'renameWorkspace'; workspaceId: string; name: string } + | { type: 'switchWorkspace'; workspaceId: string } + | { type: 'dispatchToActive'; action: Action } + +export const STORAGE_KEY = 'calc-draft:workspaces' +export const LEGACY_STORAGE_KEY = 'calc-draft:doc' + +export const PAGE_NAME_MAX = 20 + +function genId(): string { + return typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now() + Math.random()) +} + +export function createEmptyDoc(): DraftDoc { + const page: Page = { + id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()), + name: '草稿1', + rows: [], + createdAt: Date.now(), + seqCounter: 0 + } + return { pages: [page], activePageId: page.id } +} + +function updatePage(doc: DraftDoc, pageId: string, fn: (page: Page) => Page): DraftDoc { + return { + ...doc, + pages: doc.pages.map((p) => (p.id === pageId ? fn(p) : p)) + } +} + +// 行号引用联动:#N 指向"改动前的第 N 行",结构变化后按行 id 重写为新行号。 +// 行被删除时其位置记为 0(#0 无法解析,展示为"未找到")。 +function refRemap(oldRows: Row[], newRows: Row[]): Map { + const newPosById = new Map(newRows.map((r, i) => [r.id, i + 1] as const)) + const remap = new Map() + oldRows.forEach((r, i) => remap.set(i + 1, newPosById.get(r.id) ?? 0)) + return remap +} + +/** 跨草稿 @页序号 引用联动:页删除/重排后按页签 id 重写序号(0 = 页已删除) */ +function rewriteAtRefs(expr: string, remap: Map): string { + return expr.replace(/@(\d+)#(\d+)/g, (raw, page: string, row: string) => { + const next = remap.get(Number(page)) + // 页已删除:保留原引用,求值时报“未找到第 N 个草稿”更直观 + if (next === undefined || next === 0) return raw + return `@${next}#${row}` + }) +} + +function rewriteRefs(expr: string, remap: Map): string { + // @页名#N 是跨草稿引用,不参与本页的行号联动 + return expr.replace(/@([^#\s@]*)#(\d+)|#(\d+)/g, (raw, atPage: string, atNum: string, num: string) => { + if (atPage !== undefined) return raw + const next = remap.get(Number(num)) + return next === undefined ? raw : `#${next}` + }) +} + +// 跨草稿联动:目标页行序变化时,重写其他页 @页序号#行序号 中的行序(行删除则保留原文,求值时报错可见) +function rewriteAtRowRefs(doc: DraftDoc, changedPageId: string, rowRemap: Map): DraftDoc { + const changedIdx = doc.pages.findIndex((p) => p.id === changedPageId) + 1 + if (changedIdx <= 0 || rowRemap.size === 0) return doc + return { + ...doc, + pages: doc.pages.map((page) => { + if (page.id === changedPageId) return page + if (!page.rows.some((r) => /@\d+#/.test(r.expr))) return page + return { + ...page, + rows: page.rows.map((r) => + /@\d+#/.test(r.expr) + ? { ...r, expr: r.expr.replace(/@(\d+)#(\d+)/g, (raw, p: string, row: string) => { + if (Number(p) !== changedIdx) return raw + const next = rowRemap.get(Number(row)) + if (next === undefined || next === 0) return raw + return `@${p}#${next}` + }) } + : r + ) + } + }) + } +} + +function renumberRefs(oldRows: Row[], newRows: Row[]): Row[] { + if (oldRows.length === 0) return newRows + const remap = refRemap(oldRows, newRows) + return newRows.map((r) => { + if (!/#\d+/.test(r.expr)) return r + const expr = rewriteRefs(r.expr, remap) + return expr === r.expr ? r : { ...r, expr } + }) +} + +export function docReducer(doc: DraftDoc, action: Action): DraftDoc { + switch (action.type) { + case 'addRow': { + const page = doc.pages.find((p) => p.id === action.pageId) + if (!page) return doc + const seqCounter = page.seqCounter + 1 + const row: Row = { id: action.rowId, seq: seqCounter, expr: action.expr ?? '' } + let rows = [...page.rows] + if (action.afterRowId) { + const index = page.rows.findIndex((r) => r.id === action.afterRowId) + rows.splice(index >= 0 ? index + 1 : rows.length, 0, row) + } else { + rows.push(row) + } + const remap = refRemap(page.rows, rows) + const newRows = renumberRefs(page.rows, rows) + const doc2 = updatePage(doc, action.pageId, (p) => ({ ...p, rows: newRows, seqCounter })) + return rewriteAtRowRefs(doc2, action.pageId, remap) + } + case 'editRow': + return updatePage(doc, action.pageId, (page) => ({ + ...page, + rows: page.rows.map((r) => (r.id === action.rowId ? { ...r, expr: action.expr } : r)) + })) + case 'editSubtitle': + return updatePage(doc, action.pageId, (page) => ({ + ...page, + rows: page.rows.map((r) => + r.id === action.rowId + ? { ...r, subtitle: action.subtitle.trim() || undefined } + : r + ) + })) + case 'deleteRow': { + const page = doc.pages.find((p) => p.id === action.pageId) + if (!page) return doc + const remap = refRemap(page.rows, page.rows.filter((r) => r.id !== action.rowId)) + const doc2 = updatePage(doc, action.pageId, (p) => ({ + ...p, + rows: renumberRefs(page.rows, page.rows.filter((r) => r.id !== action.rowId)) + })) + return rewriteAtRowRefs(doc2, action.pageId, remap) + } + case 'moveRow': { + const page = doc.pages.find((p) => p.id === action.pageId) + if (!page) return doc + const from = page.rows.findIndex((r) => r.id === action.rowId) + if (from < 0) return doc + const to = Math.max(0, Math.min(action.toIndex, page.rows.length - 1)) + if (from === to) return doc + const rows = [...page.rows] + const [moved] = rows.splice(from, 1) + rows.splice(to, 0, moved) + const remap = refRemap(page.rows, rows) + const doc2 = updatePage(doc, action.pageId, (p) => ({ ...p, rows: renumberRefs(page.rows, rows) })) + return rewriteAtRowRefs(doc2, action.pageId, remap) + } + case 'addPage': { + const page: Page = { + id: action.pageId, + name: action.name ?? `草稿 ${doc.pages.length + 1}`, + rows: [], + createdAt: Date.now(), + seqCounter: 0 + } + return { pages: [...doc.pages, page], activePageId: page.id } + } + case 'deletePage': { + const idx = doc.pages.findIndex((p) => p.id === action.pageId) + if (idx < 0) return doc + if (doc.pages.length === 1) { + // 仅剩一页:重建一个空页并跳转 + const page: Page = { + id: genId(), + name: '草稿1', + rows: [], + createdAt: Date.now(), + seqCounter: 0 + } + return { pages: [page], activePageId: page.id } + } + const pages = doc.pages.filter((p) => p.id !== action.pageId) + // @页序号 引用绑定页签 id:删除页后重写所有 @ 序号 + const pageRemap = new Map() + doc.pages.forEach((p, i) => { + const ni = pages.findIndex((np) => np.id === p.id) + pageRemap.set(i + 1, ni >= 0 ? ni + 1 : 0) + }) + const pages2 = pages.map((p) => ({ + ...p, + rows: p.rows.map((r) => + /@\d+#/.test(r.expr) ? { ...r, expr: rewriteAtRefs(r.expr, pageRemap) } : r + ) + })) + // 优先跳到上一页;删的是第一页则跳到下一页 + const nextActive = pages[idx - 1] ?? pages[idx] + return { pages: pages2, activePageId: nextActive.id } + } + case 'renamePage': + return updatePage(doc, action.pageId, (page) => { + const name = action.name.trim().slice(0, PAGE_NAME_MAX) + return { ...page, name: name || page.name } + }) + case 'setActivePage': + return doc.pages.some((p) => p.id === action.pageId) + ? { ...doc, activePageId: action.pageId } + : doc + default: + return doc + } +} + +function isDraftDoc(value: any): value is DraftDoc { + return ( + value && + Array.isArray(value.pages) && + value.pages.length > 0 && + typeof value.activePageId === 'string' && + value.pages.every( + (p: any) => + p && + typeof p.id === 'string' && + Array.isArray(p.rows) && + typeof p.seqCounter === 'number' + ) && + value.pages.some((p: any) => p.id === value.activePageId) + ) +} + +export function loadDoc(raw: string | null): DraftDoc { + if (!raw) return createEmptyDoc() + try { + const parsed = JSON.parse(raw) + if (isDraftDoc(parsed)) return parsed as DraftDoc + return createEmptyDoc() + } catch { + return createEmptyDoc() + } +} + +export function serializeDoc(doc: DraftDoc): string { + return JSON.stringify(doc) +} + +// ==================== Workspace 管理 ==================== + +export const WORKSPACE_NAME_MAX = 20 + +/** 创建空的草稿文档 */ +export function createEmptyWorkspaceDoc(): DraftDoc { + return createEmptyDoc() +} + +/** 创建单工作空间的管理器(首次使用或迁移) */ +export function createDefaultWorkspaceManager(): WorkspaceManager { + const workspace: Workspace = { + id: genId(), + name: '默认工作空间', + doc: createEmptyWorkspaceDoc(), + createdAt: Date.now() + } + return { + workspaces: [workspace], + activeWorkspaceId: workspace.id + } +} + +/** 从旧版 DraftDoc 迁移到工作空间管理器 */ +export function migrateFromLegacyDoc(legacyDoc: DraftDoc): WorkspaceManager { + const workspace: Workspace = { + id: genId(), + name: '我的草稿', + doc: legacyDoc, + createdAt: Date.now() + } + return { + workspaces: [workspace], + activeWorkspaceId: workspace.id + } +} + +/** 工作空间 reducer */ +export function workspaceReducer(manager: WorkspaceManager, action: WorkspaceAction): WorkspaceManager { + switch (action.type) { + case 'addWorkspace': { + const workspace: Workspace = { + id: action.workspaceId, + name: action.name ?? `工作空间 ${manager.workspaces.length + 1}`, + doc: createEmptyWorkspaceDoc(), + createdAt: Date.now() + } + return { + workspaces: [...manager.workspaces, workspace], + activeWorkspaceId: workspace.id + } + } + + case 'deleteWorkspace': { + if (manager.workspaces.length === 1) { + // 最后一个工作空间:重置为默认空工作空间 + return createDefaultWorkspaceManager() + } + const idx = manager.workspaces.findIndex((w) => w.id === action.workspaceId) + if (idx < 0) return manager + const filtered = manager.workspaces.filter((w) => w.id !== action.workspaceId) + const nextActive = + manager.activeWorkspaceId === action.workspaceId + ? (filtered[idx - 1] ?? filtered[idx] ?? filtered[0]).id + : manager.activeWorkspaceId + return { workspaces: filtered, activeWorkspaceId: nextActive } + } + + case 'renameWorkspace': { + return { + ...manager, + workspaces: manager.workspaces.map((w) => + w.id === action.workspaceId + ? { ...w, name: action.name.trim().slice(0, WORKSPACE_NAME_MAX) || w.name } + : w + ) + } + } + + case 'switchWorkspace': { + return manager.workspaces.some((w) => w.id === action.workspaceId) + ? { ...manager, activeWorkspaceId: action.workspaceId } + : manager + } + + case 'dispatchToActive': { + return { + ...manager, + workspaces: manager.workspaces.map((w) => + w.id === manager.activeWorkspaceId + ? { ...w, doc: docReducer(w.doc, action.action) } + : w + ) + } + } + + default: + return manager + } +} + +function isWorkspaceManager(value: any): value is WorkspaceManager { + return ( + value && + Array.isArray(value.workspaces) && + value.workspaces.length > 0 && + typeof value.activeWorkspaceId === 'string' && + value.workspaces.every( + (w: any) => + w && + typeof w.id === 'string' && + typeof w.name === 'string' && + w.doc && + Array.isArray(w.doc.pages) && + typeof w.doc.activePageId === 'string' + ) && + value.workspaces.some((w: any) => w.id === value.activeWorkspaceId) + ) +} + +/** 把旧版 @页名#序号或备注 引用迁移为 @页序号#行序号(尽力而为,失败保留原文) */ +function migrateAtRefs(doc: DraftDoc): DraftDoc { + const legacy = /@([^#\d@][^#]*)#([^#\s@]*)/g + return { + ...doc, + pages: doc.pages.map((page) => ({ + ...page, + rows: page.rows.map((row) => { + if (!/@[^#\d]/.test(row.expr)) return row + const expr = row.expr.replace(legacy, (raw, pageName: string, ref: string) => { + const idx = doc.pages.findIndex((p) => p.name === pageName.trim()) + if (idx < 0) return raw + const target = doc.pages[idx] + let rowIdx = -1 + if (/^\d+$/.test(ref)) rowIdx = Number(ref) - 1 + else rowIdx = target.rows.findIndex((r) => r.subtitle === ref) + if (rowIdx < 0) return raw + return `@${idx + 1}#${rowIdx + 1}` + }) + return { ...row, expr } + }) + })) + } +} + +export function loadWorkspaceManager(raw: string | null): WorkspaceManager { + if (!raw) return createDefaultWorkspaceManager() + try { + const parsed = JSON.parse(raw) + if (isWorkspaceManager(parsed)) { + const manager = parsed as WorkspaceManager + return { + ...manager, + workspaces: manager.workspaces.map((w) => ({ ...w, doc: migrateAtRefs(w.doc) })) + } + } + return createDefaultWorkspaceManager() + } catch { + return createDefaultWorkspaceManager() + } +} + +export function serializeWorkspaceManager(manager: WorkspaceManager): string { + return JSON.stringify(manager) +} diff --git a/plugins/calc-draft/src/env.d.ts b/plugins/calc-draft/src/env.d.ts new file mode 100644 index 000000000..4efc0916f --- /dev/null +++ b/plugins/calc-draft/src/env.d.ts @@ -0,0 +1,10 @@ +/// +/// + +declare global { + interface Window { + services: Record + } +} + +export {} diff --git a/plugins/calc-draft/src/main.css b/plugins/calc-draft/src/main.css new file mode 100644 index 000000000..a905cf789 --- /dev/null +++ b/plugins/calc-draft/src/main.css @@ -0,0 +1,61 @@ +:root { + --blue: rgb(88, 164, 246); + --light: #fff; +} + +html, +body { + margin: 0; + padding: 0; +} + +button { + border: none; + background: none; + color: inherit; + line-height: 1.5; + cursor: pointer; + transition: opacity 0.2s; + font-family: inherit; +} + +button:disabled { + filter: grayscale(1); + cursor: not-allowed; +} + +button:not(:disabled):active { + opacity: 0.6; +} + +textarea { + display: block; + margin: 0; +} + +@media (prefers-color-scheme: light) { + body { + background-color: #f4f4f4; + } + ::-webkit-scrollbar-track-piece { + background-color: #f4f4f4; + } + ::-webkit-scrollbar-thumb { + border-color: #f4f4f4; + } +} + +@media (prefers-color-scheme: dark) { + /* 深色模式主背景透明,透出宿主(ZTools)原生底色 */ + &::-webkit-scrollbar-track-piece { + background-color: transparent; + } + &::-webkit-scrollbar-thumb { + background-color: #666; + border-color: transparent; + } + body { + background-color: transparent; + color: #fff; + } +} diff --git a/plugins/calc-draft/src/main.tsx b/plugins/calc-draft/src/main.tsx new file mode 100644 index 000000000..73fb20139 --- /dev/null +++ b/plugins/calc-draft/src/main.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import './main.css' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/plugins/calc-draft/tsconfig.json b/plugins/calc-draft/tsconfig.json new file mode 100644 index 000000000..d49c64dec --- /dev/null +++ b/plugins/calc-draft/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": false, + "noImplicitAny": false, + "types": ["@ztools-center/ztools-api-types"] + }, + "include": ["src"] +} diff --git a/plugins/calc-draft/vite.config.js b/plugins/calc-draft/vite.config.js new file mode 100644 index 000000000..dc92d1ec7 --- /dev/null +++ b/plugins/calc-draft/vite.config.js @@ -0,0 +1,12 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + base: './', + build: { + outDir: 'src-ztools/dist', + emptyOutDir: true + } +}) diff --git a/plugins/calc-draft/vitest.config.ts b/plugins/calc-draft/vitest.config.ts new file mode 100644 index 000000000..5bbb30e7f --- /dev/null +++ b/plugins/calc-draft/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'] + } +})