feat: refine notes workspace and add canvas zoom
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

Simplify sidebar selection and note record framing, compact the empty composer, and emphasize note titles. Replace redundant AI comment headings with an accessible panel toggle.

Add view-only canvas zoom and fit width, correct PDF import/export icons, and allow image files up to 20 MiB. Include regression coverage and updated design documentation.

Release note: Notes have clearer navigation and a more compact writing area. Canvases support zoom and fit width, and image imports accept files up to 20 MiB.
This commit is contained in:
mesalogo
2026-09-19 20:56:03 +08:00
parent df89d96803
commit c98eb69cbd
21 changed files with 677 additions and 120 deletions
+3 -1
View File
@@ -632,7 +632,9 @@ records are listed separately and do not introduce another feature status.
refresh the open workbench while preserving selection and unsaved drafts.
- [x] **Paged canvas notes**: The integrated PeopleLib Fabric + Quill editor
combines flowing body text with pen/highlighter, object selection/transforms,
floating text, images and paper templates. Import PDFs as page backgrounds
floating text, images and paper templates. Editors and read-only viewers support
25%-300% view zoom, 100% reset and responsive Fit width, without changing saved
content or export dimensions. Import PDFs as page backgrounds
with native text extraction, then export a raster PDF of text and annotations;
exported PDFs do not retain a searchable text layer. Manual saves store entry
bodies and binary assets in local note files; SQLite retains metadata, indexes,
+1
View File
@@ -94,6 +94,7 @@
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论。笔记从自适应卡片总览打开;独立待办页提供搜索、状态筛选、分组列表和直接勾选,宽屏列表稳定在左侧,任务说明、来源记录及 AI 评论在右侧同一区域显示,窄屏按需显示详情并提供返回列表。紧凑页签放在页头新建按钮左侧,筛选放在搜索右侧。打开原笔记再返回可恢复任务上下文;切换任务不会混入上一项的来源内容,读取失败使用应用通知并提供就地重试,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。
- [x] **连续记录工作区**:笔记详情连续展示全部记录,左侧索引默认 168px,可拖动右边界或用键盘在 140–320px 内调宽,并记住偏好;索引与右侧 AI 评论独立收起。每条画布记录显示真实首页缩略图、页数和时间,文字记录显示摘要;点击索引只滚动定位,保留当前编辑器和草稿。打开笔记就在标题下方显示空白输入区,保留文字/画布选择和保存操作;新增保存后清空,供下一条记录使用,旧记录通过编辑入口修改。空白输入区和保存后未再修改的记录返回时不提示;保存失败和真实修改仍受保护。窄屏索引使用固定宽度折叠抽屉,不显示调宽分隔条,AI 评论移至底部。
- [x] **分页画布笔记**:已集成 PeopleLib Fabric + Quill 编辑器,支持跨页正文、笔迹、高亮、对象选择及变换、浮动文字、图片和纸张模板。PDF 可作为分页底版导入并提取原生文字;正文和批注可导出为栅格 PDF,导出文件不保留可搜索文字层。用户手动保存后,记录正文与二进制资源存入本地笔记文件,SQLite 保留元数据、索引、修订、待办和评论。备份须协调保存 SQLite 与笔记目录,产品无一键备份入口。撤销/重做限当前批注页或 Quill 模式,不提供全局文档撤销、无限画布或 PNG 下载按钮。草稿、已保存记录和来源为画布的待办按默认模型图像输入能力使用页面图像加文字,或明确标注仅文字降级;纯视觉记录/草稿需要支持图像输入的模型。即时评论模式提供手动画布分析,保存后自动分析失败不阻断或回滚保存。提取文字不变时保留文字评论;布局变化使视觉评论失效,并在保存后自动评论模式下重新分析。MCP 禁止以纯文字覆写画布记录。实现已完成,最终验证状态见[功能进展](./docs/features/magic-notes/progress.md)。
编辑与只读画布均支持 25%–300% 视图缩放、100% 还原及随视口调整的“适应宽度”,不改变保存内容或导出尺寸。
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
+8 -3
View File
@@ -284,7 +284,8 @@
- 浅色侧栏使用冰蓝灰表面,与白色主内容画布形成明确边界;深色侧栏使用比主画布略亮的蓝灰表面。
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
- 当前导航项和当前会话使用一致的淡蓝选中背景、强调色文字和中等字重,不额外描边或添加侧边标记;导航图标随选中状态使用强调色,悬停时保留选中状态。浅深主题遵循相同规则,强制颜色模式保留系统选中边框。
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与最近会话分隔线上下各留 `16px`。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
@@ -727,6 +728,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 文字记录编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套文字记录持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
- 新记录可选择“文字 / 画布”;已保存画布提供分页阅读和“展开编辑画布”。画布使用 PeopleLib 的 Fabric 分页批注与 Quill 跨页正文,支持纸张模板、增删页、笔迹、高亮、整对象橡皮、选择及移动缩放旋转、浮动文字、图片和 PDF 底版。画布有固定页面边界;已保存记录、待办来源和编辑态默认容纳完整当前页及工具栏,高度随当前页实际显示尺寸变化,纵向由外层笔记流滚动。多页仍属于一条记录,通过页码翻页;窄窗口工具栏换行,纸张仅在自身视口内横向滚动,不撑宽外层页面。
- 画布通过“保存记录 / 保存修改”手动持久化,离开或切换记录类型前沿用未保存草稿确认。撤销/重做作用于当前批注页或 Quill 正文模式,增删页和 PDF 导入不属于全局撤销历史。删除非空批注页或 PDF 页需要在五秒内再次点击确认。
- 画布编辑与只读工具栏均提供缩小、当前百分比、放大和“适应宽度”。默认 100%,范围 25%–300%;加减每次调整 25 个百分点,点击百分比还原 100%。画布区域内 Ctrl/Command + 滚轮连续缩放,普通滚轮和 Shift 横滚保持原有方向与边界传递。适应宽度按实际视口计算,窗口或分栏调整后自动更新;翻页保留固定比例或适应宽度策略。缩放仅影响显示,整页高度与横向滚动范围随之变化,不新增内层纵向滚动,也不改变正文分页、保存内容、导出尺寸或未保存状态。控件沿用画布的主题、焦点环与提示,窄屏整组换行,适应宽度通过 `aria-pressed` 和选中样式表达状态。
- “导入 PDF”保留页面并提取原生文字供搜索与 AI 使用,扫描页不自动 OCR。“导出 PDF”将底版、正文和批注逐页合成为栅格 PDF,不保留可搜索文字层;当前 UI 无 PNG 下载按钮,页面 PNG 捕获仅作为程序接口提供。
- 画布新建与编辑态不显示常驻分析提示。操作放在编辑区右上角:新建与类型选择同排,顺序为“分析画布草稿、保存记录”;编辑放在记录头部,顺序为“分析画布草稿、取消、保存修改”。保存为最右侧主按钮,窄屏允许换行;底部不重复操作,新建不增加取消按钮。`immediate` 仍通过按钮手动分析,不随笔画自动请求;`after-save-auto` 在手动保存后分析。设置读取或页面捕获失败不阻断保存,保存成功后通过应用通知说明自动分析失败,可稍后重新分析;模型分析失败也不回滚已保存内容。
- 草稿、已保存画布记录及来源为画布的待办,按默认模型的 `supportsImageInput` 使用全部页面图像与提取文字,或明确标注“仅文字分析(未使用画布图像)”。纯视觉记录/草稿遇到文字模型时提示切换支持图像输入的默认模型,不声称已理解笔迹或布局。待办分析同时保留任务标题、说明和来源画布上下文。
@@ -736,9 +738,12 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 待办内容容器宽于 `700px` 时,任务列表稳定在左侧,右侧显示当前任务说明、来源记录和 AI 评论;未选择时右侧提示选择任务。点击任务只更新右侧,不改变列表宽度、行位置或滚动位置,再次点击取消选择。AI 不增加第三栏。容器不超过 `700px` 时,按需详情替换同一内容区域,并提供“返回待办列表”;列表保持挂载,返回恢复滚动和任务行焦点,不将列表与详情上下堆叠。任务按钮提供 `aria-expanded` 和 `aria-controls`。
- 待办 AI 评论保留方向选择、流式输出、重新分析和已有结果;来源加载失败通过应用通知提示,并提供就地重试。从待办打开原笔记后,“返回待办”恢复任务选择、搜索、筛选和列表滚动;宽屏焦点返回任务行,窄屏返回待办详情中的返回按钮。
- 笔记详情主区按新到旧连续展示全部记录。打开笔记即在标题下、历史记录上显示空白输入区,保留文字/画布类型选择和保存操作,标题栏与索引均不提供“新增记录”按钮。新增保存成功后重建空白输入区,保留所选类型;自动分析结束也不转入旧记录编辑。旧记录通过显式编辑入口修改,保存使用同一 ID 和返回的修订号。点击索引或当前笔记内的 AI 来源只按 entry ID 滚动定位,不卸载编辑器、不清草稿、不询问离开。空白输入区离开不提示,返回总览、打开其他笔记或改为编辑另一条记录时仍保护未保存修改。
- 详情页的一级标题显示当前笔记已保存的标题,不显示模块说明;全局范围、返回导航和索引开关保持可见。原标题输入框使用正文大小,保留原有编辑、失焦保存与键盘操作,不形成第二个醒目标题。新建文字记录的输入区初始约四行,随内容自然增高,保留原有调整大小与滚动方式;此高度不作用于旧记录编辑器或画布。笔记内部列表、记录流、AI 区域和文字编辑区在精细指针设备上使用细原生滚动条,轨道透明;强制颜色与粗指针设备保留系统默认。说明、摘要和输入提示使用 `--font-body` 与 `--text-secondary`,时间和短标签保留 `--font-caption`。
- 左侧记录索引默认 `168px`,可拖动右边界在 `140–320px` 内调整栏宽,缩略图随列宽等比适配。开关独立位于详情标题左侧,提供 `aria-controls`、展开状态和动作提示;收起后完全移除索引列、分隔条、间隙、边框及轨道,不占宽度,隐藏内容不可聚焦。右侧 AI 评论独立显示、滚动和收起,不与索引共享页签。每条记录一个导航项:画布显示真实首页缩略图及总页数,文字显示摘要,两者均显示类型和时间。两侧分别记住桌面展开状态和宽度,共用原有布局偏好;宽布局分隔条共用样式及 `separator` 语义,方向键每次调整 `16px`,Home、End 跳到当前上下限。两侧宽度按可用空间协调限制,为主区保留至少 `300px`,窗口缩小时不覆盖保存的偏好。内容容器不超过 `800px` 时索引默认隐藏,由同一标题栏按钮开闭标题栏下方固定 `168px` 的抽屉,不显示调宽分隔条;标题栏自身保留控件高度,不保留轨道。选择记录或按 Escape 后关闭,Escape 将焦点返回开关。AI 移至底部,高度不超过可用布局的 40% 且最多 `280px`,不显示分隔条。
- 笔记详情的标题、正文和记录流不再包裹额外的大外框,记录流容器不加内边距;保留编辑器自身边框、页面安全边距及 AI 区域分隔。此规则仅作用于笔记详情,不改变卡片总览与待办独立布局。
- 画布新建、编辑和只读态的内框统一保留 `12px` 间距,避免内外边框及圆角重叠。记录头部操作按内容宽度排列,空间不足时自然换行。文字新建的保存区与编辑器属于同一卡片,空白编辑区使用最小高度,不随容器百分比高度拉长。文字工具栏图标、菜单和原生滚动条须跟随主题。
AI 评论的唯一显隐入口位于页面右上角,使用紧凑面板图标按钮;展开时应用共享选中样式,图标随显示或隐藏动作切换,并提供对应的工具提示、可访问名称、`aria-expanded` 和 `aria-controls`。评论面板内部不再显示重复的“AI 评论”标题或隐藏按钮行,保留区域的可访问名称、评论方向选择及各记录的分析操作。
- 笔记详情的内部记录索引使用透明底色,与主内容表面连续,仅保留细调宽分隔线。每条索引记录保留 `1px` 的 `--border-subtle` 淡边框,不加卡片底色或阴影,悬停使用轻微次级底色;选中记录使用淡蓝背景和左侧 `3px` 蓝色内侧标记,悬停时保留选中态。缩略图保留纸张边界,键盘焦点环与强制颜色模式选中边框保持可见。窄屏抽屉使用不透明的主内容表面,避免下层正文透出;深色主题沿用语义令牌。
- 笔记详情的标题、正文和记录流不再包裹额外的大外框,记录流容器不加内边距;每条已保存记录使用一层 `1px` 的 `--border-subtle` 淡边框和 `--radius-control` 圆角,保留透明底色与记录间留白,不加阴影或头部下划线。记录编辑时保留输入区边框、页面安全边距及 AI 区域分隔。此规则仅作用于笔记详情,不改变卡片总览与待办独立布局。
- 画布新建、编辑和只读态统一保留 `12px` 内容间距;详情中已保存画布去掉预览外框和工具栏底色、下划线,保留灰色画布衬底与白色纸张,编辑态保留编辑器边框。记录头部操作按内容宽度排列,空间不足时自然换行。文字新建的保存区与编辑器属于同一卡片,空白编辑区使用最小高度,不随容器百分比高度拉长。文字工具栏图标、菜单和原生滚动条须跟随主题。
- 整篇笔记的置顶、取消置顶和删除放在卡片的“更多”菜单,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;打开菜单不进入详情。
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
+3
View File
@@ -56,6 +56,9 @@ object erasing and selection/transforms, floating text, images, PDF backgrounds
with native text extraction, and raster PDF export. Saves are manual. Undo/redo
belongs to the current annotation page or Quill body mode; it is not a global
document history. There is no infinite canvas or PNG download button.
Editors and saved viewers support view zoom and responsive Fit width without
changing saved content or export dimensions; see the
[canvas zoom reference](../../../src/renderer/src/magic-canvas/README.md#view-zoom).
Drafts, saved entries and todos sourced from canvases support AI comments using
the default model's image-input capability. Image-capable models receive complete
+49 -1
View File
@@ -1,5 +1,22 @@
# Magic Notes Progress
## 2026-09-19: Canvas Image Import Size
- Raised the canvas image file limit to 20 MiB inclusive and updated its error
message and derived restoration bound. The authoritative limit is in the
[canvas integration reference](../../../src/renderer/src/magic-canvas/README.md#editor-limits).
- Real Electron exercised File input, FileReader, Fabric insertion, flush, model
conversion and remount: 2 MiB + 1 byte and exactly 20 MiB were retained;
20 MiB + 1 byte was rejected with the updated message. Fixtures use a valid
one-pixel PNG padded to each byte boundary, not a large decoded pixel image.
- Production preload/registered IPC handlers with mocked Electron transport and
real SQLite/files passed 20 MiB create, update and database reopen checks.
Shared canvas validation and file persistence impose no smaller byte budget.
Rich-text attachment budgets and the PDF import limit are unchanged.
- Validation: `npx vitest run tests/canvas-core.electron.test.ts src/main/magic-notes/canvas-ipc.integration.test.ts src/main/magic-notes/magic-note-storage.test.ts src/main/magic-notes/rich-content.test.ts src/renderer/src/magic-canvas`
passed 66 tests in 8 files. `npm run typecheck` and `npm run lint` passed.
The full suite and a combined native Electron IPC save scenario were not run.
## 2026-09-19: Resizable Record Index
- The record index defaults to 168px and supports pointer resizing from its right
@@ -359,7 +376,8 @@
- The shared canvas viewport explicitly handles horizontal and vertical wheel
input in annotation, flow-text and read-only modes. Shift with vertical-only
input scrolls horizontally. Ctrl input is left to the existing browser path.
input scrolls horizontally. Ctrl input was initially left to the browser;
the View Zoom update below assigns Ctrl/Command + wheel to canvas zoom.
Default scrolling is prevented only when the viewport actually moves; page
scrolling remains available at boundaries or without canvas overflow.
- Removed scroll-chain containment. The Electron baseline reproduced boundary
@@ -370,6 +388,36 @@
- Typecheck and lint passed. The full test run during this fix reported 4,682
passed, 67 skipped and one unrelated Agent package inventory timeout.
## 2026-09-19: Canvas View Zoom
- Implemented editor/read-only zoom using the shared core. Controls and view-only
invariants are documented in the
[canvas reference](../../../src/renderer/src/magic-canvas/README.md#view-zoom)
and root UI design. The persisted model and export dimensions are unchanged.
- Real PDF plus flow validation reproduced an existing cleared-background defect:
automatic pagination reassigned an unchanged canvas width/height, erasing its
bitmap. Layout now retains the bitmap when dimensions are unchanged.
- Focused validation passed 9 files / 140 tests: `canvas-wheel`, `canvas-flow`,
`canvas-note`, canvas `model`, `MagicCanvasEditor`, `MagicCanvasThumbnail`,
`MagicNoteContent`, `MagicNotesWorkspace`, and `tests/canvas-core.electron.test.ts`.
- `node tests/magic-canvas-wheel.electron.mjs`: 41 checks passed in Electron 43.2.0.
Native input covered pen coordinates and object move/scale at 50% and 200%,
second-page Quill click/typing, Ctrl wheel, ordinary/Shift/diagonal wheel and
boundary propagation. Real PDF background pixels, unchanged PNG captures and
794-by-1123 output dimensions, stable flow pagination, zero view-only changes,
readonly controls, displayed page heights and fit-width resize all passed.
Dark narrow layout included a real 560px window resize across the responsive
padding breakpoint while the host width remained 360px.
- `npm run typecheck` and focused ESLint on all six changed JavaScript/test files
passed. Final `npm run lint` encountered two `react-hooks/immutability` errors
at lines 18/27 of concurrently added `src/renderer/src/FloatingPortal.tsx`;
those parallel changes were left intact. `git diff --check` passed.
- No full test suite, production build, physical-device test or macOS session was
run in this change. Meta-wheel is covered by focused tests. The Electron fixture
uses production canvas modules/styles with real Fabric, Quill and PDF.js; it
does not claim complete App-shell or release-package validation. No model calls
or commits were made. Deployed Agent paths are unaffected.
## 2026-09-19: Single Navigation Button and Unframed Note Detail
- Replaced the two header tabs with one destination-labelled button and icon,
@@ -211,6 +211,13 @@ Editor and viewer heights follow the current paper element's displayed height
through natural CSS layout, including wrapped toolbars and viewport padding.
No fixed preview height or viewport-height cap clips the page. The outer note
stream scrolls vertically; the paper viewport retains horizontal scrolling.
View zoom is local to each mounted core instance. A displayed-size outer box and
one transformed logical-size surface keep PDF, flow and Fabric aligned; Quill
screen measurements are converted back to logical coordinates. Neither zoom nor
fit-width resize calls `onChange`, changes the persisted model, invalidates AI
comments or alters PNG/PDF export dimensions. See the
[view zoom implementation](../../../src/renderer/src/magic-canvas/README.md#view-zoom)
for controls, bounds, page-switch policy and observer cleanup.
Paper templates, pen, highlighter, whole-object eraser, selection/transforms,
floating text, images and PDF page backgrounds are integrated. Native PDF text
@@ -219,6 +219,22 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', ()
expect(await api.get(note.id)).toEqual(saved)
})
it('saves and reopens a 20 MiB canvas image through create and update IPC', async () => {
const content = canvas()
const bytes = Buffer.alloc(20 * 1024 * 1024)
Buffer.from(png.split(',')[1]!, 'base64').copy(bytes)
content.assets[0]!.dataUrl = `data:image/png;base64,${bytes.toString('base64')}`
const note = await api.create({ title: 'Large canvas image' })
const created = await api.createEntry({ noteId: note.id, content })
await reopen()
const entry = (await api.get(note.id)).entries.find(item => item.id === created.createdEntryId)!
expect(entry.content).toEqual(content)
content.flow!.ops[0] = { insert: 'Edited large image note' }
await api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content })
await reopen()
expect((await api.get(note.id)).entries.find(item => item.id === entry.id)!.content).toEqual(content)
})
it('preserves a real generated PDF and all page references through three reopen/save cycles', async () => {
const { jsPDF } = await import('jspdf')
const pdf = new jsPDF()
+27 -2
View File
@@ -387,6 +387,9 @@ describe('MagicNotesWorkspace overview navigation', () => {
const card = screen.getByRole('button', { name: /发布笔记.*条记录/ })
await openNote()
expect(overview).not.toBeVisible()
expect(screen.getByRole('heading', { level: 1, name: detail.title })).toBeVisible()
expect(document.querySelector('.page-header__description')).toBeNull()
expect(screen.getByRole('textbox', { name: '笔记标题' })).toHaveValue(detail.title)
expect(screen.queryByRole('button', { name: '切换到待办' })).not.toBeInTheDocument()
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '新建笔记' })).not.toBeInTheDocument()
@@ -398,6 +401,7 @@ describe('MagicNotesWorkspace overview navigation', () => {
await waitFor(() => expect(screen.getByRole('button', { name: '返回总览' })).toHaveFocus())
back()
expect(overview).toBeVisible()
expect(screen.getByRole('heading', { level: 1, name: '魔法笔记' })).toBeVisible()
expect(search).toHaveValue('发布')
expect(overview.scrollTop).toBe(180)
expect(screen.queryByRole('region', { name: '笔记记录' })).not.toBeInTheDocument()
@@ -802,11 +806,25 @@ describe('MagicNotesWorkspace detail behavior', () => {
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote()
showAi()
const toggle = screen.getByRole('button', { name: '隐藏 AI 评论', expanded: true })
expect(toggle).toHaveClass('icon-button', 'icon-button--active')
expect(toggle).toHaveAttribute('title', '隐藏 AI 评论')
expect(toggle).toHaveAttribute('aria-controls', 'magic-notes-ai-pane')
expect(toggle.textContent).toBe('')
const pane = screen.getByRole('complementary', { name: 'AI 评论' })
expect(pane).toHaveAttribute('id', toggle.getAttribute('aria-controls'))
expect(within(pane).queryByText('AI 评论', { exact: true })).not.toBeInTheDocument()
expect(pane.querySelector('.magic-notes-pane-heading')).toBeNull()
expect(screen.queryByRole('group', { name: 'AI 评论形式' })).not.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'AI 评论方向' })).toBeVisible()
fireEvent.keyDown(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' }), { key: 'End' })
const width = screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' }).getAttribute('aria-valuenow')
fireEvent.click(screen.getAllByRole('button', { name: '隐藏 AI 评论' })[0]!)
toggle.focus()
fireEvent.click(toggle)
expect(toggle).toHaveFocus()
expect(screen.getByRole('button', { name: '显示 AI 评论', expanded: false })).toBe(toggle)
expect(toggle).toHaveAttribute('title', '显示 AI 评论')
expect(toggle).not.toHaveClass('icon-button--active')
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
back()
await openNote()
@@ -845,6 +863,13 @@ describe('MagicNotesWorkspace detail behavior', () => {
await openNote()
expect(screen.getByRole('separator')).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByRole('separator')).toHaveAttribute('tabindex', '-1')
const toggle = screen.getByRole('button', { name: '隐藏 AI 评论', expanded: true })
fireEvent.click(toggle)
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '显示 AI 评论', expanded: false })).toBe(toggle)
fireEvent.click(toggle)
expect(screen.getByRole('complementary', { name: 'AI 评论' })).toBeVisible()
expect(screen.getByRole('combobox', { name: 'AI 评论方向' })).toBeVisible()
})
it('resizes the record column, clamps against AI, and preserves preferences across hidden and narrow layouts', async () => {
@@ -1158,7 +1183,7 @@ describe('MagicNotesWorkspace canvas integration', () => {
expect(document.querySelector('.magic-notes-layout')).toHaveClass('magic-notes-layout--index-hidden')
expect(screen.queryByRole('navigation', { name: '记录' })).not.toBeInTheDocument()
expect(screen.getByRole('complementary', { name: 'AI 评论' })).toBeVisible()
fireEvent.click(screen.getAllByRole('button', { name: '隐藏 AI 评论' })[0]!)
fireEvent.click(screen.getByRole('button', { name: '隐藏 AI 评论' }))
fireEvent.click(screen.getByRole('button', { name: '展开记录索引' }))
expect(screen.getByRole('navigation', { name: '记录' })).toBeVisible()
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
+5 -20
View File
@@ -1946,8 +1946,10 @@ export function MagicNotesWorkspace({
<button
aria-controls="magic-notes-ai-pane"
aria-expanded={aiPaneOpen}
className="secondary-button"
aria-label={t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
className={`icon-button${aiPaneOpen ? ' icon-button--active' : ''}`}
onClick={() => setAiPaneOpen((current) => !current)}
title={t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
type="button"
>
{aiPaneOpen ? (
@@ -1955,11 +1957,6 @@ export function MagicNotesWorkspace({
) : (
<PanelRightOpen aria-hidden="true" size={15} />
)}
{t(
aiPaneOpen
? 'actions.hideAiComments'
: 'actions.showAiComments'
)}
</button>
)}
{!detailView && (
@@ -1992,11 +1989,11 @@ export function MagicNotesWorkspace({
)}
</>
}
description={t('page.description')}
description={detailView ? undefined : t('page.description')}
headingId="magic-notes-title"
icon={<Sparkles size={20} />}
scope={{ kind: 'global' }}
title={t('page.title')}
title={detailView && detail ? detail.title : t('page.title')}
/>
{loadStatus === 'error' ? (
@@ -2882,18 +2879,6 @@ export function MagicNotesWorkspace({
hidden={!aiPaneOpen}
id="magic-notes-ai-pane"
>
<div className="magic-notes-pane-heading">
<strong>{t('comments.paneLabel')}</strong>
<button
aria-label={t('actions.hideAiComments')}
className="icon-button"
onClick={() => setAiPaneOpen(false)}
title={t('actions.hideAiComments')}
type="button"
>
<PanelRightClose aria-hidden="true" size={15} />
</button>
</div>
<div className="magic-notes-ai-controls">
<label>
<span>{t('comments.directionLabel')}</span>
+6 -1
View File
@@ -28,7 +28,7 @@
background: var(--surface-subtle);
border-bottom: 1px solid var(--border-default);
}
.magic-canvas-editor .canvas-note-tool-group, .magic-canvas-editor .canvas-note-page-controls {
.magic-canvas-editor .canvas-note-tool-group, .magic-canvas-editor .canvas-note-page-controls, .magic-canvas-editor .canvas-note-zoom-controls {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
@@ -37,6 +37,8 @@
}
.magic-canvas-editor .canvas-note-tool-group { border-right: 1px solid var(--border-subtle); padding-right: var(--space-1); }
.magic-canvas-editor .canvas-note-page-controls { margin-left: auto; }
.magic-canvas-editor .canvas-note-zoom-controls { flex-wrap: nowrap; }
.magic-canvas-editor .canvas-note-zoom-reset { min-width: 56px; font-variant-numeric: tabular-nums; }
.magic-canvas-editor .canvas-note-button, .magic-canvas-editor .canvas-note-toolbar select, .magic-canvas-editor .canvas-note-color, .magic-canvas-open {
height: 32px;
min-width: 32px;
@@ -61,6 +63,7 @@
.magic-canvas-editor :focus-visible, .magic-canvas-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.magic-canvas-editor .canvas-note-viewport { min-width: 0; min-height: 0; flex: 0 0 auto; overflow: auto; padding: var(--space-4); background: var(--surface-canvas); }
.magic-canvas-editor .canvas-note-page { position: relative; margin: 0 auto; flex-shrink: 0; overflow: hidden; box-shadow: var(--shadow-card); }
.magic-canvas-editor .canvas-note-page-surface { position: absolute; top: 0; left: 0; transform-origin: top left; }
.magic-canvas-editor .canvas-note-background { z-index: 0; display: block; }
.magic-canvas-editor .canvas-note-fabric-container { z-index: 2; }
.magic-canvas-editor .canvas-note-fabric-container canvas { display: block; }
@@ -83,6 +86,8 @@
.magic-canvas-editor .canvas-flow-toolbar .ql-picker-options { color: var(--text-primary); background: var(--surface-raised); border-color: var(--border-control); box-shadow: var(--shadow-card); }
.magic-canvas-editor .canvas-flow-toolbar button.ql-active .ql-stroke { stroke: var(--accent); }
.magic-canvas-content .canvas-note-tool-group, .magic-canvas-content .canvas-note-add-page, .magic-canvas-content .canvas-note-delete-page { display: none; }
.magic-note-entry > .magic-canvas-content .canvas-note-root { border: 0; }
.magic-note-entry > .magic-canvas-content .canvas-note-toolbar { background: transparent; border-bottom: 0; }
.magic-canvas-open { align-self: flex-end; margin-bottom: var(--space-2); padding-inline: var(--space-3); }
@media (max-width: 720px) {
.magic-canvas-editor .canvas-note-page-controls { flex-basis: 100%; justify-content: flex-end; }
+28 -2
View File
@@ -63,7 +63,7 @@ type MagicCanvasContentProps = {
}
```
The viewer supports previous/next page. When `onEdit` is supplied, it displays
The viewer supports previous/next page and the same zoom controls as the editor. When `onEdit` is supplied, it displays
an edit action for the workspace to open the editor. Passing a new content
reference refreshes the viewer. It does not own a modal or persistence.
@@ -102,12 +102,15 @@ following limits on input and serialization.
| Flow text | 20,000 UTF-16 code units (`string.length`), excluding exactly one terminal newline. Internal newlines count; page-break embeds do not. |
| Flow operations | 5,000 input Delta operations, including newline and page-break operations. |
| Pages | 50, including automatic flow pages and PDF pages. Initial page normalization currently keeps the first 50. Flow beyond 50 pages reports an error. |
| Images | 2 MiB per JPEG/PNG/GIF/WebP image. |
| Images | 20 MiB (`20 * 1024 * 1024` bytes) per JPEG/PNG/GIF/WebP file, inclusive. The same derived Data URL length bound applies when restoring image objects. |
| PDF file input | 32 MiB per input file; imported pages also count toward the 50-page limit. |
| Page dimensions | Positive dimensions at most 3,000 per side, rounded to integers; invalid dimensions fall back to 794 by 1123 defaults per axis. |
| List indent | Integer levels 1 through 8, or omitted for level zero. |
Input validation and serialization use the same flow text/operation checks.
Canvas assets do not use the rich-text image or aggregate attachment budgets.
The shared canvas schema and Main validation accept this image size; persistence
stores decoded assets in files and hydrates them on read without a smaller byte cap.
Exactly 20,000 body code units plus Quill's terminal newline round-trip intact.
Oversized user edits revert to the preceding Delta and call `onError`.
Oversized initial/API content throws a `RangeError`; `flush()` rejects rather
@@ -130,6 +133,29 @@ saved viewers grow naturally to the current page's displayed height plus their
toolbars and padding; vertical scrolling belongs to the surrounding note stream.
Pagination replaces the current page within the same entry, including its height.
### View Zoom
Editor and viewer toolbars provide minus/plus (25 percentage points), a current
percentage button that resets to 100%, and Fit width. The range is 25%-300%, with
100% as the initial value. Ctrl/Command + wheel continuously zooms over the paper;
ordinary wheel and Shift horizontal scrolling retain their existing behavior.
Fit width follows the actual viewport's width minus its computed padding, within
the same range. Resizing the window or a workspace column updates it automatically.
Page navigation preserves the selected fixed scale or fit policy. Remounting a
document starts at 100%; the view preference is not persisted.
`canvas-note-page` is the displayed-size layout box. Its absolutely positioned
logical-size surface applies one CSS scale to the PDF/template, Fabric and Quill
layers. Fabric's existing client-to-canvas coordinate conversion handles pointer
input; Quill column and selection measurements divide screen coordinates by the
display scale. Page dimensions, object coordinates, flow widths and export
canvases remain logical. Zoom never enters the model, history or `onChange`.
The width observer defers updates to the next animation frame and is disconnected,
with any pending frame cancelled, on destroy. Flow pagination does not reset an
unchanged background bitmap's dimensions, which would clear the rendered PDF.
The UI contract is in [UI-DESIGN.md](../../../../UI-DESIGN.md#137-魔法笔记).
## Lifecycle And Validation
The controller is returned synchronously so React cleanup can immediately abort
@@ -218,11 +218,12 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options =
function pageCount() {
const rootRect = surface.getBoundingClientRect();
const scale = rootRect.width / layout.width || 1;
const span = layout.width + layout.gap;
let maxColumn = 0;
for (const child of surface.children) {
for (const rect of child.getClientRects()) {
const relativeLeft = rect.left - rootRect.left;
const relativeLeft = (rect.left - rootRect.left) / scale;
maxColumn = Math.max(maxColumn, Math.max(0, Math.round(relativeLeft / span)));
}
}
@@ -248,8 +249,9 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options =
if (!range) return layout.pageIndex;
const index = Math.min(Math.max(0, range.index), Math.max(0, quill.getLength() - 1));
const bounds = quill.getBounds(index, Math.max(0, range.length));
const scale = surface.getBoundingClientRect().width / layout.width || 1;
const span = layout.width + layout.gap;
return Math.max(0, Math.round((Number(bounds?.left) || 0) / span));
return Math.max(0, layout.pageIndex + Math.round((Number(bounds?.left) || 0) / scale / span));
}
function followSelection() {
@@ -50,6 +50,16 @@ function mount(initial) {
}
describe('canvas flow limits', () => {
it.each([0.25, 0.5, 2, 3])('measures logical columns and selection at %sx display scale', (scale) => {
const { editor, quill } = mount({ version: 1, ops: [{ insert: 'Body\n' }] });
editor.setLayout(640, 960, 1);
vi.spyOn(quill.root, 'getBoundingClientRect').mockReturnValue({ left: 10, width: 640 * scale });
vi.spyOn(quill.root.firstElementChild, 'getClientRects').mockReturnValue([{ left: 10 + 688 * 2 * scale }]);
vi.spyOn(quill, 'getSelection').mockReturnValue({ index: 0, length: 0 });
vi.spyOn(quill, 'getBounds').mockReturnValue({ left: 688 * scale });
expect(editor.pageCount()).toBe(3);
expect(editor.activePageIndex()).toBe(2);
});
it('round trips exactly 20,000 characters with a separate formatted terminal newline', () => {
const content = { version: 1, ops: [{ insert: '中'.repeat(20000) }, { insert: '\n', attributes: { list: 'checked', indent: 2 } }] };
expect(normalizeFlowContent(content)).toEqual(content);
+89 -12
View File
@@ -14,11 +14,13 @@ import { mountFlowText, normalizeFlowContent } from './canvas-flow.mjs';
const VERSION = 2;
const MAX_PAGES = 50;
const HISTORY_LIMIT = 50;
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
const MAX_IMAGE_DATA_LENGTH = Math.ceil(MAX_IMAGE_BYTES / 3) * 4 + 128;
const DEFAULT_WIDTH = 794;
const DEFAULT_HEIGHT = 1123;
const MAX_DIMENSION = 3000;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 3;
const MAX_CANVAS_PIXELS = 24 * 1024 * 1024;
const SERIAL_PROPS = ['canvasKind'];
const COMMON_OBJECT_KEYS = [
@@ -319,6 +321,8 @@ function drawTemplate(context, width, height, template) {
}
const BUTTON_ICONS = Object.freeze({
'zoom-out': '<path d="M5 12h14"/>',
'zoom-in': '<path d="M5 12h14M12 5v14"/>',
'tool-select': '<path d="m5 3 13 9-6 1.5L9 19Z"/><path d="m13 14 4 6"/>',
'tool-pen': '<path d="m4 20 4.5-1 10-10a2 2 0 0 0-3-3l-10 10Z"/><path d="m14 7 3 3M4 20l1.5-4"/>',
'tool-highlight': '<path d="m7 15 8-11 4 3-8 11H7Z"/><path d="m13 7 4 3M4 20h16"/>',
@@ -328,8 +332,8 @@ const BUTTON_ICONS = Object.freeze({
'tool-flow-text': '<path d="M4 5h16M8 5v14M5 19h6"/><path d="M14 11h6M14 15h6M14 19h6"/>',
undo: '<path d="m9 7-5 5 5 5"/><path d="M5 12h8a6 6 0 0 1 6 6"/>',
redo: '<path d="m15 7 5 5-5 5"/><path d="M19 12h-8a6 6 0 0 0-6 6"/>',
'import-pdf': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M12 10v7M9 14l3 3 3-3"/>',
'export-pdf': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M12 18v-7M9 14l3-3 3 3"/>',
'import-pdf': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M12 18v-7M9 14l3-3 3 3"/>',
'export-pdf': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M12 10v7M9 14l3 3 3-3"/>',
'previous-page': '<path d="m14 6-6 6 6 6"/>',
'next-page': '<path d="m10 6 6 6-6 6"/>',
'add-page': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M9 14h6M12 11v6"/>',
@@ -408,6 +412,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
let destroyPromise = null;
let suggestedPdfName = 'GoodBuddy-笔记.pdf';
let readOnly = Boolean(options.disabled);
let zoom = 1;
let fitWidth = false;
const pdfDocuments = new Map();
const backgroundCache = new Map();
@@ -509,7 +515,16 @@ export function mountCanvasNote(host, initialContent, options = {}) {
const addPageButton = makeButton('加页', '添加页面', 'add-page');
const deletePageButton = makeButton('删页', '删除当前页面', 'delete-page');
pageControls.append(previousButton, pageCounter, nextButton, addPageButton, deletePageButton);
toolbar.append(toolGroup, styleGroup, historyGroup, documentGroup, pageControls);
const zoomControls = document.createElement('span');
zoomControls.className = 'canvas-note-zoom-controls';
zoomControls.setAttribute('role', 'group');
zoomControls.setAttribute('aria-label', '画布缩放');
const zoomOutButton = makeButton('-', '缩小(最低 25%;Ctrl/Command + 滚轮)', 'zoom-out');
const zoomResetButton = makeButton('100%', '还原为 100%', 'zoom-reset');
const zoomInButton = makeButton('+', '放大(最高 300%;Ctrl/Command + 滚轮)', 'zoom-in');
const zoomFitButton = makeButton('适应宽度', '适应宽度(随窗口调整)', 'zoom-fit');
zoomControls.append(zoomOutButton, zoomResetButton, zoomInButton, zoomFitButton);
toolbar.append(toolGroup, styleGroup, historyGroup, documentGroup, zoomControls, pageControls);
const imageInput = document.createElement('input');
imageInput.type = 'file';
@@ -527,6 +542,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
const pageShell = document.createElement('div');
pageShell.className = 'canvas-note-page';
pageShell.style.position = 'relative';
const pageSurface = document.createElement('div');
pageSurface.className = 'canvas-note-page-surface';
const backgroundElement = document.createElement('canvas');
backgroundElement.className = 'canvas-note-background';
@@ -540,7 +557,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
const flowLayer = document.createElement('div');
flowLayer.className = 'canvas-flow-layer';
pageShell.append(backgroundElement, annotationElement, flowLayer);
pageSurface.append(backgroundElement, annotationElement, flowLayer);
pageShell.appendChild(pageSurface);
viewport.appendChild(pageShell);
root.append(toolbar, flowToolbarHost, viewport, imageInput);
host.textContent = '';
@@ -684,9 +702,14 @@ export function mountCanvasNote(host, initialContent, options = {}) {
}
if (readOnly) {
for (const control of toolbar.querySelectorAll('button, input, select')) {
if (control !== previousButton && control !== nextButton && control !== exportButton) control.disabled = true;
if (control !== previousButton && control !== nextButton && control !== exportButton
&& !zoomControls.contains(control)) control.disabled = true;
}
}
zoomOutButton.disabled = busy || zoom <= MIN_ZOOM;
zoomInButton.disabled = busy || zoom >= MAX_ZOOM;
zoomResetButton.disabled = busy;
zoomFitButton.disabled = busy;
}
function pushHistory(emit = true) {
@@ -761,11 +784,39 @@ export function mountCanvasNote(host, initialContent, options = {}) {
if (tool === 'flow-text') flowEditor.focus();
}
function applyZoom() {
if (destroyed) return;
const page = currentPage();
if (fitWidth && viewport.clientWidth > 0) {
const css = globalThis.getComputedStyle(viewport);
const available = viewport.clientWidth - (parseFloat(css.paddingLeft) || 0) - (parseFloat(css.paddingRight) || 0);
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, available / page.width));
}
// Only the outer box participates in layout; all three layers share logical coordinates.
pageShell.style.width = `${page.width * zoom}px`;
pageShell.style.height = `${page.height * zoom}px`;
pageSurface.style.transform = `scale(${zoom})`;
fabricCanvas.calcOffset?.();
zoomResetButton.textContent = `${Math.round(zoom * 100)}%`;
zoomResetButton.setAttribute('aria-label', `当前缩放 ${Math.round(zoom * 100)}%,还原为 100%`);
zoomFitButton.setAttribute('aria-pressed', String(fitWidth));
zoomFitButton.classList.toggle('canvas-note-active', fitWidth);
updateControls();
}
function setZoom(value, fit = false) {
if (destroyed || busy) return;
fitWidth = fit;
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
applyZoom();
}
function setPageDimensions(page) {
pageShell.style.width = `${page.width}px`;
pageShell.style.height = `${page.height}px`;
backgroundElement.width = page.width;
backgroundElement.height = page.height;
pageSurface.style.width = `${page.width}px`;
pageSurface.style.height = `${page.height}px`;
// Flow pagination can update layout without repainting the current background.
if (backgroundElement.width !== page.width) backgroundElement.width = page.width;
if (backgroundElement.height !== page.height) backgroundElement.height = page.height;
fabricCanvas.setDimensions({ width: page.width, height: page.height });
if (fabricContainer) {
fabricContainer.style.width = `${page.width}px`;
@@ -781,6 +832,7 @@ export function mountCanvasNote(host, initialContent, options = {}) {
);
flowLayer.style.top = `${Math.max(0, Math.round((page.height - writableHeight) / 2))}px`;
flowEditor.setLayout(writableWidth, writableHeight, currentIndex);
applyZoom();
}
async function getPdfDocument(background, suppliedBytes = null) {
@@ -1084,7 +1136,7 @@ export function mountCanvasNote(host, initialContent, options = {}) {
throw new Error('请选择 JPEG、PNG、GIF 或 WebP 图片');
}
if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_IMAGE_BYTES) {
throw new Error('单张图片不能超过 2 MB');
throw new Error('单张图片不能超过 20 MB');
}
const dataUrl = await readFileDataUrl(file);
if (!isSafeImageDataUrl(dataUrl)) {
@@ -1475,7 +1527,13 @@ export function mountCanvasNote(host, initialContent, options = {}) {
signal: uiAbort.signal
});
viewport.addEventListener('wheel', (event) => {
if (event.defaultPrevented || event.ctrlKey) return;
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1;
if (event.deltaY) setZoom(zoom * Math.exp(-event.deltaY * unit * 0.002));
return;
}
// Some drivers already map Shift to deltaX; only remap a vertical-only wheel.
const horizontal = event.shiftKey && event.deltaX === 0;
const dx = horizontal ? event.deltaY : event.deltaX;
@@ -1489,6 +1547,23 @@ export function mountCanvasNote(host, initialContent, options = {}) {
// Leave boundary/no-overflow events available for normal ancestor scrolling.
if (viewport.scrollLeft !== left || viewport.scrollTop !== top) event.preventDefault();
}, { passive: false, signal: uiAbort.signal });
zoomOutButton.addEventListener('click', () => setZoom(zoom - 0.25), { signal: uiAbort.signal });
zoomInButton.addEventListener('click', () => setZoom(zoom + 0.25), { signal: uiAbort.signal });
zoomResetButton.addEventListener('click', () => setZoom(1), { signal: uiAbort.signal });
zoomFitButton.addEventListener('click', () => setZoom(zoom, true), { signal: uiAbort.signal });
let resizeFrame = 0;
let viewportWidth = -1;
const resizeObserver = globalThis.ResizeObserver ? new globalThis.ResizeObserver(([entry]) => {
const width = entry.contentRect.width;
if (width === viewportWidth) return;
viewportWidth = width;
if (resizeFrame) globalThis.cancelAnimationFrame(resizeFrame);
resizeFrame = globalThis.requestAnimationFrame(() => {
resizeFrame = 0;
if (fitWidth) applyZoom();
});
}) : null;
resizeObserver?.observe(viewport);
imageInput.addEventListener('change', () => {
const file = imageInput.files?.[0] || null;
imageInput.value = '';
@@ -1616,6 +1691,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
captureCurrentObjects();
destroyed = true;
uiAbort.abort();
resizeObserver?.disconnect();
if (resizeFrame) globalThis.cancelAnimationFrame(resizeFrame);
clearDeleteConfirmation();
flowEditor.destroy();
for (const task of renderTasks) {
@@ -101,11 +101,10 @@ it('clamps at edges and only consumes events that can move the viewport', () =>
expect(wheel().defaultPrevented).toBe(false);
});
it('leaves unscrollable axes, Ctrl zoom and previously handled events alone', () => {
it('leaves unscrollable axes and previously handled events alone', () => {
Object.defineProperty(viewport, 'scrollWidth', { value: 500 });
expect(wheel({ deltaX: 100 }).defaultPrevented).toBe(false);
expect(wheel({ deltaY: 100, shiftKey: true }).defaultPrevented).toBe(false);
expect(wheel({ deltaY: 100, ctrlKey: true }).defaultPrevented).toBe(false);
target.addEventListener('wheel', event => event.preventDefault(), { once: true });
wheel({ deltaY: 100 });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([0, 0]);
@@ -113,6 +112,37 @@ it('leaves unscrollable axes, Ctrl zoom and previously handled events alone', ()
expect(wheel({ deltaY: 100 }).defaultPrevented).toBe(false);
});
it.each(['ctrlKey', 'metaKey'])('zooms with %s without scrolling or changing content, including readonly', (modifier) => {
editor.setDisabled(true);
const before = editor.content();
expect(wheel({ deltaY: -100, [modifier]: true }).defaultPrevented).toBe(true);
expect(parseFloat(host.querySelector('.canvas-note-zoom-reset').textContent)).toBeGreaterThan(100);
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([0, 0]);
expect(editor.content()).toEqual(before);
});
it('clamps toolbar zoom, resets to 100%, and keeps logical sizes and readonly controls', () => {
const before = editor.content();
editor.setDisabled(true);
const click = suffix => host.querySelector(`.canvas-note-zoom-${suffix}`).click();
for (let i = 0; i < 20; i++) click('in');
expect(host.querySelector('.canvas-note-zoom-reset').textContent).toBe('300%');
expect(host.querySelector('.canvas-note-zoom-in').disabled).toBe(true);
expect(host.querySelector('.canvas-note-page').style.height).toBe('3369px');
for (let i = 0; i < 20; i++) click('out');
expect(host.querySelector('.canvas-note-zoom-reset').textContent).toBe('25%');
expect(host.querySelector('.canvas-note-zoom-out').disabled).toBe(true);
click('reset');
expect(host.querySelector('.canvas-note-zoom-reset').textContent).toBe('100%');
expect(host.querySelector('.canvas-note-page-surface').style.width).toBe('794px');
click('fit');
expect(host.querySelector('.canvas-note-zoom-fit').getAttribute('aria-pressed')).toBe('true');
expect(parseFloat(host.querySelector('.canvas-note-page').style.width)).toBeCloseTo(500);
click('in');
expect(host.querySelector('.canvas-note-zoom-fit').getAttribute('aria-pressed')).toBe('false');
expect(editor.content()).toEqual(before);
});
it('removes the wheel listener on destroy', async () => {
await editor.destroy();
expect(wheel({ deltaX: 100, deltaY: 100 }).defaultPrevented).toBe(false);
+110 -68
View File
@@ -1343,6 +1343,10 @@
outline-offset: -2px;
}
.magic-notes-index-resize-handle {
background: transparent;
}
.magic-notes-overview,
.magic-notes-stream-pane,
.magic-notes-ai-pane {
@@ -1355,21 +1359,28 @@
background: var(--surface-subtle);
}
.magic-notes-ai-pane > .magic-notes-pane-heading {
position: sticky;
top: 0;
background: var(--surface-subtle);
z-index: 1;
padding-bottom: var(--space-3);
}
.magic-notes-index-pane {
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--surface-subtle);
background: transparent;
}
.magic-notes-index-pane[hidden] { display: none; }
@media (forced-colors: none) and (pointer: fine) {
.magic-notes-page :where(
.magic-notes-overview,
.magic-notes-index-pane,
.magic-notes-stream-pane,
.magic-notes-ai-pane,
.magic-notes-list,
.magic-todo-detail,
.magic-note-editor__content .ql-editor
) {
scrollbar-width: thin;
scrollbar-color: var(--border-control) transparent;
}
}
.magic-note-records { display: grid; gap: var(--space-3); padding: var(--space-2); }
.magic-note-records[hidden] { display: none; }
.magic-note-record {
@@ -1378,20 +1389,26 @@
gap: var(--space-2);
width: 100%;
padding: var(--space-3);
border: 1px solid var(--border-control);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-raised);
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.magic-note-record[aria-current="true"] { border-color: var(--accent); background: var(--accent-subtle); }
.magic-note-record:hover { border-color: var(--accent); }
.magic-note-record:hover { background: var(--surface-subtle); }
.magic-note-record[aria-current="true"] {
background: var(--accent-subtle);
box-shadow: inset 3px 0 0 var(--accent);
}
@media (forced-colors: active) {
.magic-note-record[aria-current="true"] { border-color: Highlight; }
}
.magic-note-record:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.magic-note-record time { color: var(--text-secondary); font-size: var(--font-caption); }
.magic-note-record__summary { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.magic-note-record__image { display: block; min-width: 0; text-align: center; }
.magic-note-record__image img { display: block; width: 100%; max-height: 180px; object-fit: contain; }
.magic-note-record__image img { display: block; width: auto; max-width: 100%; max-height: 180px; margin-inline: auto; border: 1px solid var(--border-subtle); }
.magic-note-thumbnail-renderer { position: fixed; left: -10000px; top: 0; width: 850px; height: 1200px; visibility: hidden; pointer-events: none; }
.magic-notes-overview[hidden],
@@ -1486,7 +1503,7 @@
.magic-notes-ai-controls label {
display: grid;
color: var(--text-secondary);
font-size: var(--font-caption);
font-size: var(--font-body);
gap: var(--space-2);
}
@@ -1502,8 +1519,8 @@
}
.magic-notes-ai-controls > small {
color: var(--text-muted);
font-size: var(--font-caption);
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.5;
}
@@ -1870,7 +1887,7 @@
.magic-note-list-item__preview {
display: -webkit-box;
overflow: hidden;
font-size: var(--font-caption);
font-size: var(--font-body);
line-height: 1.5;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@@ -1929,8 +1946,8 @@
background: transparent;
color: var(--text-primary);
flex: 1;
font-size: 18px;
font-weight: 600;
font-size: var(--font-body);
font-weight: 400;
}
.magic-note-detail-header > input:hover,
@@ -1968,6 +1985,12 @@
margin-bottom: var(--space-4);
}
.magic-note-entry:not(:has(.magic-note-entry__editor)) {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: transparent;
}
.magic-note-draft-confirmation {
display: grid;
padding: var(--space-3) var(--space-4);
@@ -1995,8 +2018,9 @@
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--border-subtle);
color: var(--text-muted);
font-size: var(--font-caption);
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.5;
gap: var(--space-3);
}
@@ -2027,6 +2051,10 @@
gap: var(--space-2);
}
.magic-note-entry:not(:has(.magic-note-entry__editor)) > header {
border-bottom: 0;
}
.magic-note-entry > header > div {
align-items: center;
flex-wrap: wrap;
@@ -2190,6 +2218,11 @@
line-height: 1.65;
}
.magic-note-composer > .magic-note-editor .magic-note-editor__content,
.magic-note-composer > .magic-note-editor .magic-note-editor__content .ql-editor {
min-height: calc(4 * 1.65em + 2 * var(--space-3));
}
.magic-note-editor__content .ql-editor.ql-blank::before {
color: var(--text-muted);
}
@@ -2385,7 +2418,7 @@
width: 168px;
overflow: auto;
align-content: start;
background: var(--surface-subtle);
background: var(--surface-raised);
border-right: 1px solid var(--border-default);
box-shadow: var(--shadow-card);
}
@@ -3428,14 +3461,14 @@ textarea:focus-visible {
display: flex;
min-width: 248px;
align-items: center;
padding: 11px 12px;
padding: var(--space-3);
border: 1px solid var(--accent-solid);
border-radius: var(--radius-control);
margin-bottom: 11px;
margin-bottom: var(--space-3);
background: var(--accent-solid);
color: var(--text-on-accent);
cursor: pointer;
gap: 9px;
gap: var(--space-2);
text-align: left;
}
@@ -3452,9 +3485,9 @@ textarea:focus-visible {
.new-chat kbd {
padding: 2px 5px;
border: 1px solid rgb(255 255 255 / 35%);
border: 1px solid transparent;
border-radius: 5px;
background: rgb(255 255 255 / 12%);
background: transparent;
color: var(--text-on-accent);
font-size: var(--font-caption);
}
@@ -3468,10 +3501,15 @@ textarea:focus-visible {
padding-right: 40px;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
margin-bottom: 15px;
margin-bottom: var(--space-4);
background: var(--surface-raised);
color: var(--text-muted);
gap: 8px;
gap: var(--space-2);
}
.sidebar-search:focus-within {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.sidebar-search input {
@@ -3481,7 +3519,7 @@ textarea:focus-visible {
outline: 0;
background: transparent;
color: var(--text-primary);
font-size: 12px;
font-size: var(--font-body);
}
.sidebar-search input::placeholder {
@@ -3505,38 +3543,47 @@ textarea:focus-visible {
display: flex;
min-width: 248px;
flex-direction: column;
padding-bottom: 13px;
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border-default);
gap: 3px;
gap: var(--space-1);
}
.nav-item {
display: flex;
min-height: var(--control-height);
align-items: center;
padding: 9px 10px;
padding: var(--space-2) var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
gap: 10px;
gap: var(--space-3);
text-align: left;
transition: background var(--motion-fast) ease-out,
color var(--motion-fast) ease-out;
}
.nav-item > svg {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-muted);
}
.nav-item:hover {
background: var(--surface-subtle);
color: var(--text-primary);
}
.nav-item--active,
.nav-item--active:hover {
background: var(--accent-subtle);
color: var(--accent);
}
.nav-item--active {
border-color: var(--accent-selected);
background: color-mix(
in srgb,
var(--accent-selected) 34%,
var(--accent-subtle)
);
.nav-item--active > svg {
color: var(--accent);
font-weight: 650;
}
.nav-item span:nth-child(2) {
@@ -3545,6 +3592,10 @@ textarea:focus-visible {
font-weight: 500;
}
.nav-item--active span:nth-child(2) {
font-weight: 600;
}
.nav-item__hint {
color: var(--text-muted);
font-size: var(--font-caption);
@@ -3552,14 +3603,15 @@ textarea:focus-visible {
.nav-item__badge {
display: grid;
min-width: 17px;
height: 17px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--danger-solid);
color: var(--text-on-accent);
font-size: 10px;
font-weight: 700;
background: var(--surface-muted);
color: var(--text-secondary);
font-size: var(--font-caption);
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
place-items: center;
}
@@ -3567,7 +3619,7 @@ textarea:focus-visible {
.conversation-list {
min-width: 248px;
flex: 1;
padding-top: 14px;
padding-top: var(--space-4);
overflow: auto;
}
@@ -3576,8 +3628,8 @@ textarea:focus-visible {
}
.section-label {
padding: 0 9px;
margin: 0 0 7px;
padding: 0 var(--space-3);
margin: 0 0 var(--space-2);
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 600;
@@ -3600,19 +3652,15 @@ textarea:focus-visible {
}
.conversation-item:hover {
background: var(--accent-subtle);
color: var(--accent);
background: var(--surface-subtle);
color: var(--text-primary);
}
.conversation-item--active {
border-color: var(--accent-selected);
background: color-mix(
in srgb,
var(--accent-selected) 34%,
var(--accent-subtle)
);
.conversation-item--active,
.conversation-item--active:hover {
background: var(--accent-subtle);
color: var(--accent);
font-weight: 650;
font-weight: 600;
}
.conversation-item__primary {
@@ -16184,8 +16232,6 @@ details.settings-section > :not(summary) + :not(summary) {
}
:root[data-theme='dark'] :where(
.nav-item,
.conversation-item,
.user-card,
.icon-button,
.assistant-sidebar__row,
@@ -16234,9 +16280,6 @@ details.settings-section > :not(summary) + :not(summary) {
:root[data-theme='dark'] :where(
.icon-button:hover,
.icon-button--active,
.nav-item:hover,
.conversation-item:hover,
.conversation-item--active,
.user-card:hover,
.secondary-button:hover:not(:disabled),
.settings-tabs button[aria-selected='true']
@@ -16252,7 +16295,6 @@ details.settings-section > :not(summary) + :not(summary) {
}
:root[data-theme='dark'] :where(
.nav-item--active,
.brand__mark
) {
border-color: var(--accent-selected);
+5
View File
@@ -68,6 +68,11 @@ it('preserves annotations when flow removes the active page and renders scoped t
expect(result.flowTransform).toBe('translateX(0px)')
expect(result.thumbnailStyles).toEqual(result.editorStyles)
expect(result.thumbnailMatchesEditor).toBe(true)
expect(result.imageImports).toEqual([
{ size: 2 * 1024 * 1024 + 1, imported: 1, restored: 1, errors: [] },
{ size: 20 * 1024 * 1024, imported: 1, restored: 1, errors: [] },
{ size: 20 * 1024 * 1024 + 1, imported: 0, restored: 0, errors: ['单张图片不能超过 20 MB'] }
])
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
+79 -2
View File
@@ -44,6 +44,8 @@ if (!process.env.CANVAS_WHEEL_DRIVER) {
import '/node_modules/quill/dist/quill.snow.css';
import '/src/renderer/src/magic-canvas.css';
import { mountCanvasNote } from '/src/renderer/src/magic-canvas/canvas-note.mjs';
import { setupZoom } from '/tests/support/canvas-zoom-regression.mjs';
window.setupZoom = setupZoom;
window.setupLayout = async (width, saved) => {
await window.editor?.destroy();
document.querySelector('#outer').style.display = 'none';
@@ -180,13 +182,15 @@ if (!process.env.CANVAS_WHEEL_DRIVER) {
const diagonal = await wheel(70, 60);
assert.ok(diagonal.x > 0 && diagonal.y > vertical.y, JSON.stringify(diagonal));
});
await check(`${mode}: Ctrl wheel is not consumed`, async () => {
await check(`${mode}: Ctrl wheel zooms without scrolling or editing`, async () => {
const before = await run('snapshot()');
const state = await wheel(0, 60, ['control']);
assert.equal(state.wheels.at(-1).ctrl, true);
assert.equal(state.wheels.at(-1).prevented, false);
assert.equal(state.wheels.at(-1).prevented, true);
assert.equal(state.x, before.x);
assert.equal(state.y, before.y);
assert.ok(await run('parseFloat(document.querySelector(".canvas-note-zoom-reset").textContent) < 100'));
await run('document.querySelector(".canvas-note-zoom-reset").click()');
});
await check(`${mode}: right edge chains to outer page`, async () => {
await run('viewport.scrollLeft = viewport.scrollWidth');
@@ -245,6 +249,79 @@ if (!process.env.CANVAS_WHEEL_DRIVER) {
});
}
}
for (const scale of [0.5, 2]) {
await run(`setupZoom(${scale})`);
await check(`zoom ${scale}: native pen coordinates and selection transforms`, async () => {
const point = async (x, y) => run(`zoomFixture.point(${x}, ${y})`);
const drag = async (from, to) => {
win.webContents.sendInputEvent({ type: 'mouseMove', ...from });
win.webContents.sendInputEvent({ type: 'mouseDown', ...from, button: 'left', clickCount: 1 });
for (let step = 1; step <= 5; step++) {
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(from.x + (to.x - from.x) * step / 5),
y: Math.round(from.y + (to.y - from.y) * step / 5), button: 'left' });
await sleep(20);
}
win.webContents.sendInputEvent({ type: 'mouseUp', ...to, button: 'left', clickCount: 1 });
await sleep(80);
};
await drag(await point(50, 50), await point(100, 80));
const pen = await run('editor.flush().then(content => content.pages[0].objects.at(-1))');
assert.equal(pen.canvasKind, 'pen');
assert.ok(Math.abs(pen.path[0][1] - 50) < 2 && Math.abs(pen.path[0][2] - 50) < 2, JSON.stringify(pen));
assert.ok(Math.abs(pen.width - 50) < 2 && Math.abs(pen.height - 30) < 2, JSON.stringify(pen));
await run('zoomFixture.tool("select")');
await drag(await point(200, 130), await point(220, 150));
const moved = await run('editor.flush().then(content => content.pages[0].objects[0])');
assert.ok(Math.abs(moved.left - 180) < 2 && Math.abs(moved.top - 120) < 2, JSON.stringify(moved));
await drag(await point(261, 181), await point(281, 201));
const resized = await run('editor.flush().then(content => content.pages[0].objects[0])');
assert.ok(resized.scaleX > moved.scaleX && resized.scaleY > moved.scaleY, JSON.stringify(resized));
});
await check(`zoom ${scale}: PDF, flow, export, pagination and no dirty`, async () => {
const result = await run('zoomFixture.verify()');
assert.deepEqual(result.errors, []);
assert.equal(result.sameContent, true, JSON.stringify(result));
assert.equal(result.changes, 0);
assert.equal(result.samePng, true, JSON.stringify(result));
assert.deepEqual(result.pngSize, [794, 1123]);
assert.equal(result.pdfInk, true, JSON.stringify(result));
assert.equal(result.maxY, 0);
assert.ok(Math.abs(result.pageHeight - 1123 * scale) < 1);
assert.equal(result.pageCount, 2);
assert.equal(result.followedPage, '2 / 2');
assert.equal(result.retainedZoom, true, JSON.stringify(result));
});
await check(`zoom ${scale}: native flow click and typing on page two`, async () => {
const point = await run('zoomFixture.flowPoint()');
win.webContents.sendInputEvent({ type: 'mouseMove', ...point });
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 });
await win.webContents.insertText('Typed zoom ');
await run('editor.flush()');
await sleep(100);
assert.equal(await run('editor.content().flow.ops.some(op => typeof op.insert === "string" && op.insert.includes("Typed zoom "))'), true);
assert.equal(await run('document.querySelector("#layout .canvas-note-page-counter").textContent'), '2 / 2');
});
}
await check('zoom: readonly, fit resize, different page widths, narrow dark and full height', async () => {
const result = await run('zoomFixture.verifyLayout()');
assert.deepEqual(result.errors, []);
for (const state of result.states) {
assert.equal(state.maxY, 0, JSON.stringify(state));
assert.equal(state.maxX, 0, JSON.stringify(state));
assert.equal(state.outerX, 0, JSON.stringify(state));
assert.ok(Math.abs(state.pageWidth - state.available) < 1, JSON.stringify(state));
assert.ok(Math.abs(state.pageHeight - state.logicalHeight * state.scale) < 1, JSON.stringify(state));
assert.equal(state.enabled, true);
assert.equal(state.visible, true);
}
assert.equal(result.sameContent, true);
assert.equal(result.changes, 0);
win.setSize(560, 800);
await sleep(220);
assert.equal(await run('zoomFixture.resizeCheck()'), true, 'Fit must follow responsive padding even at an unchanged host width');
await run('editor.destroy()');
});
console.log(JSON.stringify({ electron: process.versions.electron, passed, failures }));
win.destroy();
app.exit(failures.length ? 1 : 0);
+31 -2
View File
@@ -35,6 +35,11 @@ it('resizes record columns with native Electron input and preserves desktop widt
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
const js = code => win.webContents.executeJavaScript(code).catch(error => { throw new Error(code + ': ' + error.message); });
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const screenshot = async name => {
if (process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR) {
fs.writeFileSync(require('node:path').join(process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR, name + '.png'), (await win.webContents.capturePage()).toPNG());
}
};
const wait = async code => { for (let i = 0; i < 400; i++) {
if (await js(code)) return;
await new Promise(resolve => setTimeout(resolve, 25));
@@ -61,9 +66,19 @@ it('resizes record columns with native Electron input and preserves desktop widt
await click('[id^=magic-note-select-]'); await wait('!!document.querySelector(".magic-note-record")'); await settle(); };
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'layout.html')});
await open(); await js('document.fonts.ready'); win.focus(); win.webContents.focus();
const editor = '.magic-note-composer .magic-note-editor__content .ql-editor';
const emptyHeight = (await rect(editor)).height;
assert(emptyHeight >= 110 && emptyHeight <= 125, 'Empty composer height: ' + emptyHeight);
assert.equal(await js('document.querySelector("#magic-notes-title").textContent'), 'Layout note');
assert.equal(await js('document.querySelector(".page-header__description")'), null);
await screenshot('magic-notes-light');
await js('document.documentElement.dataset.theme = "dark"'); await settle();
assert.equal((await rect(editor)).height, emptyHeight);
await screenshot('magic-notes-dark');
await js('document.documentElement.dataset.theme = "light"'); await settle();
const index = '.magic-notes-index-pane', handle = '.magic-notes-index-resize-handle', stream = '.magic-notes-stream-pane';
assert.equal((await rect(index)).width, 168);
await drag(handle, 80); const dragged = (await rect(index)).width; assert(dragged > 240 && dragged < 260);
await drag(handle, 80); const dragged = (await rect(index)).width; assert(dragged > 240 && dragged < 260, 'Dragged index width: ' + dragged);
await key(handle, 'Home'); assert.equal((await rect(index)).width, 140);
await key(handle, 'Right'); assert.equal((await rect(index)).width, 156);
await key(handle, 'End'); assert.equal((await rect(index)).width, 320);
@@ -89,9 +104,23 @@ it('resizes record columns with native Electron input and preserves desktop widt
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
const narrowStream = (await rect(stream)).width;
await click('#magic-notes-index-toggle'); assert.equal((await rect(stream)).width, narrowStream);
await screenshot('magic-notes-narrow');
win.setContentSize(1280, 800); await settle(); await settle();
assert.equal((await rect(index)).width, withoutAi);
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify({ dragged, withoutAi, constrained, narrowStream, persisted: true }));
await js('document.querySelector(' + JSON.stringify(editor) + ').focus()');
await win.webContents.insertText('Composer growth check\\n'.repeat(30)); await settle();
const grownHeight = (await rect(editor)).height;
assert(grownHeight > emptyHeight + 400, 'Composer must grow with content');
assert(await js('(() => { const el = document.querySelector(' + JSON.stringify(editor) + '); return el.scrollHeight <= el.clientHeight + 1; })()'), 'Quill content must not be clipped');
assert(await js('(() => { const el = document.querySelector(".magic-notes-stream-pane"); el.scrollTop = el.scrollHeight; return el.scrollTop > 0; })()'), 'Long draft must remain reachable through the note stream');
await settle();
await screenshot('magic-notes-long-draft');
win.webContents.debugger.attach('1.3');
await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'forced-colors', value: 'active' }] });
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-stream-pane")).scrollbarWidth'), 'auto');
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-stream-pane")).scrollbarColor'), 'auto');
win.webContents.debugger.detach();
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify({ emptyHeight, grownHeight, dragged, withoutAi, constrained, narrowStream, persisted: true }));
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
+24 -2
View File
@@ -2,7 +2,7 @@ import Quill from 'quill'
import { createRoot } from 'react-dom/client'
import { mountCanvasNote } from '../../src/renderer/src/magic-canvas/canvas-note.mjs'
import { MagicCanvasThumbnail } from '../../src/renderer/src/MagicCanvasThumbnail'
import { fromCoreContent } from '../../src/renderer/src/magic-canvas/model'
import { fromCoreContent, toCoreContent } from '../../src/renderer/src/magic-canvas/model'
import '../../src/renderer/src/i18n'
import 'quill/dist/quill.snow.css'
import '../../src/renderer/src/styles.css'
@@ -65,11 +65,33 @@ async function canvasRegression() {
root.unmount()
observer.disconnect()
thumbnailHost.remove()
const imageImports = []
const pixel = document.createElement('canvas')
pixel.width = pixel.height = 1
const png = await new Promise<Blob>((resolve) => pixel.toBlob(blob => resolve(blob!), 'image/png'))
for (const size of [2 * 1024 * 1024 + 1, 20 * 1024 * 1024, 20 * 1024 * 1024 + 1]) {
const importErrors: string[] = []
editor = mountCanvasNote(host, undefined, { onError: error => importErrors.push(error) })
await editor.flush()
const input = host.querySelector<HTMLInputElement>('.canvas-note-image-input')!
const transfer = new DataTransfer()
transfer.items.add(new File([png, new Uint8Array(size - png.size)], 'boundary.png', { type: 'image/png' }))
input.files = transfer.files
input.dispatchEvent(new Event('change'))
const imported = await editor.flush()
const persisted = fromCoreContent(imported, [])
await editor.destroy()
editor = mountCanvasNote(host, toCoreContent(persisted), { onError: error => importErrors.push(error) })
const restored = await editor.flush()
imageImports.push({ size, imported: imported.pages[0].objects.length,
restored: restored.pages[0].objects.length, errors: importErrors })
await editor.destroy()
}
host.remove()
return { errors, initialPages: initial.pages.length, initialObjects: initial.pages[0].objects,
beforeShrink, afterShrink, savedPages: saved.pages.length, savedObjects: saved.pages[0].objects,
reopenedObjects: reopened.pages[0].objects, visibleInkBefore, visibleInkAfter, reopenedInk, flowTransform,
editorStyles, thumbnailStyles, thumbnailMatchesEditor }
editorStyles, thumbnailStyles, thumbnailMatchesEditor, imageImports }
}
Object.assign(window, { canvasRegression })
+140
View File
@@ -0,0 +1,140 @@
/* global window, document, getComputedStyle, requestAnimationFrame, Image */
import Quill from 'quill';
import { jsPDF as JsPdf } from 'jspdf';
import { mountCanvasNote } from '../../src/renderer/src/magic-canvas/canvas-note.mjs';
const settle = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
export async function setupZoom(scale) {
await window.editor?.destroy();
document.querySelector('#outer').style.display = 'none';
const layout = document.querySelector('#layout');
layout.style.cssText = 'width:900px;height:650px;overflow:auto';
layout.innerHTML = '<div class="magic-canvas-editor"></div>';
const host = layout.firstElementChild;
const errors = [];
let changes = 0;
const pdf = new JsPdf({ unit: 'px', format: [794, 1123], hotfixes: ['px_scaling'] });
pdf.setFillColor(255, 0, 0);
pdf.rect(30, 30, 100, 40, 'F');
const bytes = new Uint8Array(pdf.output('arraybuffer'));
const editor = mountCanvasNote(host, { version: 2, pages: [{
id: 'pdf-page', width: 794, height: 1123,
background: { type: 'pdf', assetId: 'pdf-zoom', page: 1 },
objects: [{ type: 'Rect', canvasKind: 'rectangle', left: 160, top: 100,
width: 80, height: 60, fill: '#0000ff', strokeWidth: 1, originX: 'left', originY: 'top' }]
}], flow: { version: 1, ops: [{ insert: 'Flow zoom body\n'.repeat(55) }] } }, {
readPdf: async () => bytes, onError: message => errors.push(message), onChange: () => changes++
});
window.editor = editor;
await editor.flush();
const click = suffix => host.querySelector(`.canvas-note-${suffix}`).click();
const setScale = value => {
click('zoom-reset');
for (let i = 0; i < Math.abs(value - 1) * 4; i++) click(value < 1 ? 'zoom-out' : 'zoom-in');
};
setScale(scale);
const tool = name => host.querySelector(`[data-tool="${name}"]`).click();
tool('pen');
window.zoomFixture = {
tool,
async flowPoint() {
click('next-page');
await editor.flush();
tool('flow-text');
layout.scrollTop = 0;
const layer = host.querySelector('.canvas-flow-layer').getBoundingClientRect();
const surface = host.querySelector('.canvas-flow-surface');
const rect = [...surface.children].flatMap(child => [...child.getClientRects()])
.find(rect => rect.left >= layer.left - 1 && rect.left < layer.right && rect.top >= layer.top && rect.top < layer.top + 50);
return { x: Math.round(rect.left + 8 * scale), y: Math.round(rect.top + 10 * scale) };
},
point(x, y) {
const rect = host.querySelector('.upper-canvas').getBoundingClientRect();
return { x: Math.round(rect.left + x * scale), y: Math.round(rect.top + y * scale) };
},
async verify() {
tool('select');
setScale(1);
const before = JSON.stringify(await editor.flush());
const captures = await editor.capturePages();
changes = 0;
setScale(scale);
await editor.flush();
const scaled = await editor.capturePages();
const image = new Image();
image.src = scaled[0].dataUrl;
await image.decode();
const background = host.querySelector('.canvas-note-background');
const pixel = background.getContext('2d').getImageData(40, 40, 1, 1).data;
const paper = background.getContext('2d').getImageData(200, 40, 1, 1).data;
const viewport = host.querySelector('.canvas-note-viewport');
const pageHeight = host.querySelector('.canvas-note-page').getBoundingClientRect().height;
const maxY = viewport.scrollHeight - viewport.clientHeight;
click('next-page');
await editor.flush();
const retainedZoom = host.querySelector('.canvas-note-zoom-reset').textContent === `${scale * 100}%`;
tool('flow-text');
const quill = Quill.find(host.querySelector('.canvas-flow-quill'));
quill.setSelection(quill.getLength() - 2, 0, 'user');
await settle();
await editor.flush();
const followedPage = host.querySelector('.canvas-note-page-counter').textContent;
click('previous-page');
await editor.flush();
return { errors, changes, sameContent: JSON.stringify(editor.content()) === before,
samePng: JSON.stringify(captures) === JSON.stringify(scaled), pngSize: [image.width, image.height],
pdfInk: pixel[0] > 240 && pixel[1] < 10 && pixel[2] < 10 && paper[0] > 240,
pageHeight, maxY,
pageCount: editor.content().pages.length, retainedZoom, followedPage };
},
async verifyLayout() {
await editor.destroy();
host.classList.add('magic-canvas-content');
document.documentElement.dataset.theme = 'dark';
const viewer = mountCanvasNote(host, { version: 2, pages: [
{ id: 'portrait', width: 794, height: 1123, objects: [] },
{ id: 'landscape', width: 1123, height: 794, objects: [] }
] }, { disabled: true, onChange: () => changes++, onError: message => errors.push(message) });
window.editor = viewer;
const before = JSON.stringify(await viewer.flush());
changes = 0;
click('zoom-fit');
const states = [];
for (const width of [900, 360, 600]) {
layout.style.width = `${width}px`;
await settle();
for (let page = 0; page < 2; page++) {
if (page) click('next-page');
else click('previous-page');
await viewer.flush();
await settle();
const viewport = host.querySelector('.canvas-note-viewport');
const css = getComputedStyle(viewport);
const rect = host.querySelector('.canvas-note-page').getBoundingClientRect();
const logical = viewer.content().pages[page];
const controls = host.querySelector('.canvas-note-zoom-controls');
states.push({ pageWidth: rect.width, pageHeight: rect.height, logicalHeight: logical.height,
scale: rect.width / logical.width,
available: viewport.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight),
maxX: viewport.scrollWidth - viewport.clientWidth, maxY: viewport.scrollHeight - viewport.clientHeight,
outerX: layout.scrollWidth - layout.clientWidth,
enabled: !host.querySelector('.canvas-note-zoom-in').disabled,
visible: controls.getBoundingClientRect().width > 0 && controls.getBoundingClientRect().right <= layout.getBoundingClientRect().right });
}
}
const sameContent = JSON.stringify(await viewer.flush()) === before;
layout.style.width = '360px';
await settle();
window.zoomFixture.resizeCheck = async () => {
await settle();
const viewport = host.querySelector('.canvas-note-viewport');
const css = getComputedStyle(viewport);
const available = viewport.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight);
return Math.abs(host.querySelector('.canvas-note-page').getBoundingClientRect().width - available) < 1
&& JSON.stringify(await viewer.flush()) === before && changes === 0;
};
return { errors, states, changes, sameContent };
}
};
}