diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b848d..d2f96bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,52 @@ 本文件记录 MoonSprite 的用户可见变化。格式参考 Keep a Changelog;开发阶段使用 `DEV`,正式发布时再归入具体 SemVer。 -## DEV.1 +## DEV.2 ### 交互改进 +- 工具栏主按钮和顶部属性栏会立即显示当前选区或形状子工具的图标、名称与描述;全部子工具图标提前解码,并修正矩形、矩形填充、椭圆和椭圆填充图标对应关系。 +- 多边形套索在中键或 `Space` 平移画布期间保持边界预览;选区工具按住 `Shift` 或右键时,顶部组合模式临时显示加选或减选,松开后恢复原设置。 +- 首选项新增独立“工具预览”分类;“使用本地指针”明确为开启时使用系统指针、特殊场景回退 MoonSprite 指针。图层属性颜色预设改用与透明背景颜色一致的普通设置布局。 +- 文件拖入同时监听 Tauri Webview 与 Window 原生通道,单一通道失效时仍可打开 `.moonsprite`、`.ase`、`.aseprite` 和支持的图片格式。 +- 框选四角角标改为固定 CSS 线宽并保持至少 `8 x 8 px` 的视觉范围,视图缩放或悬停画布外时仍清晰显示;多边形套索使用指针源工程第 4 列第 6 行图标,并支持点击起点、双击或 `Enter` 闭合,`Esc` 取消。 +- 图层和组支持混合 `Ctrl`/`Shift` 多选及一次 `Delete` 批量删除;组与普通图层按各自选择上下文粘贴到正确层级,所有粘贴对象置于目标容器顶部。 +- 首选项新增默认开启的“直线算法优化”,Shift 连接画笔两点或建立多边形套索边界时生成长度相近的像素阶梯,预览与最终结果保持一致。 +- 所有主工具、选区子工具和形状子工具统一使用包含名称、简短用法和当前快捷键的自定义悬浮提示;多边形套索默认快捷键改为 `Shift+Q`,并一次性迁移旧版空快捷键。 +- 形状子工具替换为指针源工程第 5 行第 3 至 6 列的新图标;首选项新增全局“图层属性颜色预设”,支持新增、编辑、删除和恢复默认,所有工程共用。 +- 修复未闭合的多边形套索在中键或 `Space` 平移画布后丢失顶点的问题;修复 `Shift` 加选和右键减选期间旧蚂蚁线被隐藏的问题,单击取消只在松开且确认没有拖动后执行。 +- 修复矩形和椭圆框选按住 `Shift` 无法固定等宽高、拖动期间提前显示蚂蚁线、画布外错误显示预览像素及多边形套索双击偶发无法闭合的问题。 +- 形状固定比例保存值最多保留一位小数;大画布预览改为在最终显示阶段按主画布规则绘制棋盘格,避免降采样产生密集纹理。 +- 前景色和背景色改为所有打开工程共享;调色板选色后切换项目不再回退。颜色编辑器标题明确标注前景色/背景色,未移动时失焦关闭,拖动后常驻,并支持独立八向缩放和尺寸记忆。 +- 修复描边弹窗按 `Enter` 时全局选区命令抢先执行而导致未写入像素的问题;非矩形选区内不连续油漆桶不再越界。`Ctrl+H` 只隐藏蚂蚁线并保留八个变换点,选区拖到视口边缘时会自动平移画布。 +- 框选默认只显示当前像素四角角标;首选项可改为同时显示真正的鼠标十字指针。按住 `Shift` 加选时保持角标,进入变换命中区时隐藏角标。选区第一次移动后保持浮动,后续多次移动持续复用首次捕获的内容和背景,不污染经过区域。 +- 复制图层组后粘贴到当前选中对象的父级并与其同层,内部嵌套结构保持;多选后普通单击恢复单选。锁定对象仍可编辑名称、显示颜色和描述,但混合模式与不透明度保持冻结;未锁定组显示正确的解锁图标。 +- 图层栏标题移除“向下合并”和“解组”按钮;最末项为组时,其紧凑下边缘可作为根层最底部落点,蓝线与实际排序一致。 +- 选区工具新增当前像素格四角角标;减选和交集在已有选区内部保持选区指针。新建模式只有真实拖动才建立选区,画布内外单击不再误生成边缘 1 像素选区。 +- 新增 `Ctrl+Shift+I` 反选选区和 `Ctrl+H` 隐藏/显示蚂蚁线,分别加入“编辑”和“图层”菜单;蚂蚁线显隐不删除选区,也不进入撤销历史。 +- 前景色和背景色编辑器可同时常驻、独立拖动与关闭;通道滑条与数值框等高,尺寸限制在合理范围,关闭后重新打开回到默认位置。调整弹窗连续滑块预览按动画帧合并,减少拖动时的重复计算。 +- 修复图层拖到栏目顶部时蓝线仍显示在底部、首项为组时无法置顶、无选择新建组误收纳图层、新图层未继承当前组,以及祖先组锁定后后代仍可单独解锁的问题。 +- 修复半透明图层打开画布大小预览后视觉变亮;快捷键录入框优先接收完整多修饰键组合,不再被 MoonSprite 应用命令提前截断。 +- 图层栏移除“移到最外层”专用放置区;空组与非空组都可和普通图层统一排序并持久保持位置,新建项置顶,拖出栏目上下边界可直接移动到首尾,插入线按最终层级对齐。组和图层使用一致选中样式,眼睛、锁和组文件夹交互不再误开属性。 +- 图层属性改为操作即实时保存,关闭、遮罩或 `Esc` 不再回滚;图层选择改为 `Ctrl` 离散多选、`Shift` 连续范围选择。 +- 选区移动框只命中真实蚂蚁线;新增多边形套索、套索闭合预览偏好和右键减选,按住 `Shift` 开始选择时可绕过已有变换继续加选。 +- 形状工具新增空心矩形、空心椭圆和宽、高双输入的固定比例;自动选层移动在透明位置回退当前选中图层。 +- 亮度/对比度、色相/饱和度、色彩平衡和曲线会动态跟随选区或多选图层变化,预览切换目标时不累积旧结果,并作为一次撤销提交。 +- 所有正式弹窗统一支持拖动、边框外侧八向缩放、合理最小/最大尺寸和独立位置记忆;调整、画布大小和图像大小默认位于视图中心与右边缘之间,状态提示移到视图上方,并移除外壳的多余滚动条。外侧把手不再被弹窗裁切或把拖拽穿透到画布。 +- 修正调整、画布大小和图像大小弹窗的旧位置记忆,使弹窗中心准确落在目标点;弹窗缩小时不再出现外壳滚动条,状态提示下移以避开顶部工具属性栏。 +- 图层栏空白处可清除多选;空图层组支持 `Delete` 删除,锁定图层、锁定组及其后代禁止编辑、删除和拖动。插入预览线按实际目标父组层级缩进,拖到栏目首尾安全带可稳定放到最上方或最下方。 +- 快捷键录入支持完整多修饰键组合,不再在第一个修饰键按下时提前保存;套索新建模式区分单击取消和同像素格拖动,减选、交集可在已有选区内部直接开始,移动图层时选区同步移动。 +- 首选项将“套索选区预览闭合”默认设为关闭,并新增默认关闭的“吸管取色后切回铅笔”;色相/饱和度新增独立 HSL 明度,颜色值弹窗支持拖动和关闭,形状固定比例可交换宽高。 +- 画布最终合成改为单张连续表面输出,小中型文档按脏矩形局部刷新,超大文档按可见区域连续合成,从根源移除非整数缩放、连续绘制和半透明图层中的 128px 分块切割线;更新油漆桶工具图标。 +- 首页工程列表改为先显示文件行,再以最多 3 项并发渐进加载缩略图,并在会话内复用未变化的预览;读取失败的最近记录可单独移除且不会删除原文件,画廊删除工程继续要求二次确认。 +- `Delete`、`Ctrl+C` 和 `Ctrl+V` 现在根据最后点击或键盘聚焦的画布、图层、调色板区域执行,不再被遗留选区抢占;删除嵌套图层组会完整移除组结构并可一次撤销,拒绝删除最后一层时不再误标未保存。 +- 支持跨项目复制和粘贴多个图层及嵌套图层组,保留顺序、偏移、可见性、锁定、透明度、混合模式、显示颜色和描述,整批操作可一次撤销。 +- 弹窗 `Esc` 改为每次只关闭最上层界面;调整弹窗取消时恢复打开前状态,图层属性关闭时保留实时修改,快捷键设置只有点击“完成”才保存草稿。 +- “编辑 > 选择性粘贴”新增粘贴为新项目和粘贴为新图层;新图层默认使用 `Ctrl+Shift+V`,来源位置仍在可见视图时原位粘贴,否则放到当前视图中心。 +- 修复自由套索和多边形套索在非等长路径上进入死循环导致界面卡住的问题,已有选区内部按住 `Shift` 时同步显示组合预览。 +- 非整数缩放改为始终使用一致的分块合成路径,修复 313% 等倍率下落笔瞬间已有像素局部抖动。 +- “另存为”会读取首选项中的默认保存格式,选择 PNG、JPEG 或 WebP 时明确提示不会保留图层和工程结构。 +- 首选项重置增加统一危险确认,只清除软件配置,不删除用户创建的工程、笔刷、色板、工作区或其他磁盘文件;未完成的英语入口暂时隐藏。 +- 自定义笔刷三种模式改用统一主题下拉并补充平铺/盖章说明,详情预览恢复显示笔刷原始多色像素。 - 调色板颜色格恢复为两套明确状态:前景色右上角、背景色左下角角标永久保留;左键或右键点击的颜色格显示临时蓝色描边,颜色格失焦后仅隐藏描边。 - 修复热更新或焦点切换漏掉修饰键释放后,所有工具指针错误保持为吸管的问题;画布指针事件现在会重新校正 `Alt/Ctrl` 状态。 - 修复图层栏目漏掉 `Alt` 释放事件后持续显示复制指针的问题;图层栏现在会用最新鼠标事件校正复制状态。 @@ -66,8 +109,6 @@ - 固定前端依赖版本,避免 `latest` 引入不可预测升级。 - 建立本地 Git 基线和 GitHub 持续集成流程。 - 增加隔离运行的 Windows 桌面回归测试,覆盖启动、工具切换、旋转视图、高级模式、菜单与无效撤销。 -## DEV - ### 维护性改进 - 提取快捷键和编辑器首选项的类型化持久化边界,集中处理默认值、非法值和范围限制。 @@ -93,7 +134,16 @@ - 新增 128、512、1024 画布的自动交互性能基准;统一画布渲染几何、缓存舞台尺寸并减少分块绘制的重复变换,连续绘制开始不再触发冗余全局刷新。 - 性能基准新增按区域 React 提交测量;颜色、色板、图层和预览栏目改为按实际数据变化刷新,1024 画布平移、旋转后缩放和连续绘制的 React 提交耗时中位数分别下降约 24%、33% 和 18%。 - 将文档标签、工具栏、工具属性栏、状态栏、画布主机和编辑器布局外壳从 `App.tsx` 拆分为独立刷新边界;1024 画布平移、旋转后缩放和连续绘制的 React 提交耗时相对上一阶段进一步下降约 82%、93% 和 66%。 -## 未发布 +### 本次补充修复 + +- 修复已经通过 `Ctrl` 复制出的浮动选区无法继续复制移动的问题;每次新复制先确认当前副本,再从当前位置重新捕获内容,支持连续重复复制和逐次撤销。 +- 所有支持 `Delete` 的画布、图层、图层组和调色板删除操作同时支持 `Backspace`,文本输入中的退格不受影响。 +- 文件拖入改为统一平台服务,HTML、Webview、Window 和 Rust 通道独立订阅、统一去重;单通道失败不再拖垮其他入口,并在窗口重新获得焦点时自动重试。 +- 子工具图标改为工具栏挂载时常驻预载,选择子工具后主按钮立即切换;首选项尺寸、倍率和图层颜色预设改用组件库统一删除图标按钮,颜色框与按钮等高。 +- 修复选区描边弹窗焦点仍在画布时按 `Enter` 无响应的问题,并将描边参数作为工程设置随 `.moonsprite` 保存和恢复。 +- 修复多边形套索预览被折成矩形路径的问题,顶点之间恢复为像素直线;选区创建路径只在有效画布内显示,松开前不显示新蚂蚁线。 +- 多选图层创建组时保留最上方成员的原位置和成员顺序;粘贴图层组后同时选中全部新增组与图层。 +- 修复大画布在预览栏目缩小时透明棋盘格产生密集条纹和摩尔纹的问题。 - 所有统一数值输入框支持 `+ - * /` 和括号表达式,提交后自动计算并遵守原字段范围。 - “窗口 > 旋转视图”改为顺时针/逆时针旋转 90°,两项命令可在快捷键设置中自定义。 diff --git a/docs/README.md b/docs/README.md index a96b733..b0c56cb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ - [架构概览](architecture/overview.md):模块职责和依赖方向。 - [状态与历史](architecture/state-history.md):会话、dirty、撤销和视图状态。 - [坐标与渲染](architecture/coordinates-rendering.md):屏幕、视图、画布和图层坐标。 -- [文件格式](file-format.md):`.moonsprite` v1 容器。 +- [文件格式](file-format.md):`.moonsprite` v2 容器。 ## 交互契约 diff --git a/docs/architecture/coordinates-rendering.md b/docs/architecture/coordinates-rendering.md index 84b8578..781334a 100644 --- a/docs/architecture/coordinates-rendering.md +++ b/docs/architecture/coordinates-rendering.md @@ -23,6 +23,7 @@ layer -> canvas -> view rotation -> viewport -> screen - 视图变换的正向顺序固定为“绕同一枢轴旋转,再执行水平/垂直镜像”;缩放锚点补偿必须复用该顺序,不能在组件内自行交换矩阵步骤。 - 像素写入使用向下取整后的文档坐标;选区手柄、边缘和旋转区命中使用未取整的连续文档坐标,禁止先取整再判断屏幕距离。输入命中与预览必须共享同一视图逆变换。 - 画布旋转后不得出现像素内部描边、白线或图块接缝。 +- 高 DPI 工具预览的相邻文档像素必须共享同一设备像素边界;矩形边界按 `devicePixelRatio` 对齐后再换回 CSS 坐标。旋转离屏画布回绘时使用实际 backing 尺寸除以 DPR,不得把向上取整后的设备像素再次压回理论 CSS 尺寸。 - 蚂蚁线和变换手柄使用屏幕像素绘制,缩放后保持清晰,不参与像素图层合成。 - 画布左下角尺寸和镜像提示共用同一前景色;根据提示位置下方的合成像素、透明棋盘或工作区背景亮度选择深色或浅色,不写入文档像素。 - 曲线调整器的直方图只从调整开始时的活动图层快照统计,作为半透明编辑辅助,不写入文档、不参与调整结果。 @@ -34,7 +35,8 @@ layer -> canvas -> view rotation -> viewport -> screen - `core/canvas-render-plan.ts` 统一计算反解旋转与水平/垂直镜像后的可见范围、离屏场景边界、画布原点和可见文档像素;组件不得再复制这些计算。镜像枢轴偏离视口中心时,离屏场景也必须覆盖镜像后的完整视口。 - 调整画布尺寸预览固定按“新增区域棋盘、图层内容、旧画布外黑色遮罩、边界线”绘制,确保原本位于画布外的图层像素可见但仍明确标识为新增画布区域。 - `CanvasStage` 的绘制尺寸由 `ResizeObserver` 缓存。指针命中仍读取实时屏幕边界,避免停靠布局变化后坐标失真。 -- `CanvasCompositeCache` 在一次分块绘制中只应用一次平移和缩放变换,tile 只负责缓存像素和提交 `drawImage`。 +- `CanvasCompositeCache` 的最终显示来源必须是一张连续表面,禁止把 128 px tile 分别缩放后直接提交到主画布。小中型文档复用完整表面并按脏矩形局部更新;超出完整表面上限的文档先把当前可见文档区域合成为单张临时表面,再用一次 `drawImage` 输出。 +- 非整数缩放、连续绘制和半透明图层必须经过同一连续表面路径。缓存分块可以作为内部计算策略,但不得成为最终显示边界,也不得在邻接处重复叠加透明像素。 - 画布性能探针默认不工作,只有自动基准注入 `window.__moonSpriteCanvasProbe` 时才记录主绘制和指针处理耗时。 任何坐标 Bug 修复都应包含未旋转、旋转、不同缩放、图层偏移和画布外内容五类测试。 diff --git a/docs/architecture/state-history.md b/docs/architecture/state-history.md index e2b5759..cd4b1be 100644 --- a/docs/architecture/state-history.md +++ b/docs/architecture/state-history.md @@ -8,13 +8,14 @@ MoonSprite 状态分为四类: | --- | --- | --- | --- | | 文档内容 | 像素、图层、调色板、项目笔刷 | 工程文件 | 是 | | 临时编辑 | 浮动粘贴、正在绘制、变换预览 | 仅会话 | 确认后一次进入 | -| 视图状态 | 平移、缩放、旋转、镜像、网格 | 工作区或本地设置 | 否 | +| 视图状态 | 平移、缩放、旋转、镜像、网格、蚂蚁线显隐 | 工作区或本地设置 | 否 | | 应用状态 | 当前工具、弹窗、栏目布局、首页栏目 | 本地设置或工作区 | 否 | ## 历史规则 - 每个历史条目必须包含恢复前后状态所需的最小数据。 - 连续拖动、画笔笔划、复制并移动等一次手势只产生一个历史条目。 +- 移动工具同时移动图层与已有选区时,两者属于同一次手势和同一个历史条目;Undo/Redo 必须同步恢复图层偏移和选区位置。 - 取消操作不得产生历史条目或 dirty 状态。 - Undo/Redo 仅改变文档历史,不能撤销平移、缩放、旋转、镜像或工具切换。 - 保存成功后记录当前历史位置为保存点;回到保存点时 dirty 应恢复为 false。 @@ -23,9 +24,14 @@ MoonSprite 状态分为四类: ## 临时状态提交 - 浮动粘贴在切换文档、保存或开始不兼容操作前必须明确提交或取消。 -- 实时预览只改变派生显示,不反复写入历史。 +- 实时预览只改变派生显示,不反复写入历史。连续滑块输入按动画帧合并,同一帧最多计算一次,并在应用时使用最后一个输入值。 - 调整弹窗确认时写入一次历史;取消时恢复原数据。 +- 调整快照可以包含多个目标图层及共享调色板;无选区时全部选中图层组成一个复合历史,有选区时只记录活动图层。预览期间切换选择目标必须先恢复旧快照再捕获新快照,禁止在预览结果上重复叠加。 +- 带实时预览或草稿的调整弹窗必须区分提交与取消:`Enter` 或明确确认提交一次,`Esc`、关闭按钮和遮罩取消并恢复打开前状态。图层属性是实时提交面板,每次修改立即进入文档历史,`Esc`、关闭按钮和遮罩只关闭面板并保留当前值。全局 `Esc` 使用定向关闭事件,每次只关闭当前最上层弹窗或浮层;未命中的笔刷、工具、项目标签和栏目浮层不得被顺带关闭。 +- 快捷键设置在弹窗内使用草稿;只有点击“完成”才持久化,关闭或 `Esc` 不得保存未确认修改。首选项的“应用”是唯一保存但不关闭窗口的设置命令。 - 图层 Alt 拖动复制与最终移动共同组成一个事务。 +- 跨文档粘贴多层或嵌套图层组时,整批新增结构组成一个历史条目;一次撤销必须同时移除所有新图层和新图层组并恢复原选择。 +- 所有图层组只要具有有效 `panelOrder`,都优先以该锚点参与图层栏排序,不因组内是否有子图层而改变。拖拽更新与撤销必须同时恢复父组、组顺序、图层顺序和该锚点。 - 从首页恢复草稿成功后必须进入对应文档会话;失败时保持恢复记录并停留在首页。 - 已有本地路径的普通保存直接完成,不显示进度弹窗;首次保存和另存为才显示紧凑进度反馈。 diff --git a/docs/file-format.md b/docs/file-format.md index 7fd71d9..03dbf63 100644 --- a/docs/file-format.md +++ b/docs/file-format.md @@ -2,7 +2,7 @@ `.moonsprite` 是 ZIP 容器,包含: -- `manifest.json`:文档元数据、调色板、图层顺序、动画时间轴和格式版本。 +- `manifest.json`:文档元数据、调色板、图层顺序、动画时间轴、选区描边设置和格式版本。 - `layers/.rgba`:小端序 RGBA 字节,每像素四字节。 - `layers/.idx32`:小端序无符号 32 位稳定调色板 ID。 - `preview.png`:可见图层合成后的预览图。 diff --git a/docs/interactions/brush-color.md b/docs/interactions/brush-color.md index bd34d63..e1c1584 100644 --- a/docs/interactions/brush-color.md +++ b/docs/interactions/brush-color.md @@ -2,19 +2,22 @@ ## 颜色 -- 前景色和背景色是独立状态。 +- 前景色和背景色是应用级独立状态,所有已打开工程共享;在任一工程或调色板修改后,切换到其他工程不得恢复为该工程旧颜色。 - 默认按 `X` 交换前景色与背景色;该命令可在“快捷键设置 > 颜色”中修改或清空。 - 画笔左键使用前景色,右键使用背景色;吸管与调色盘使用同样左右键语义。 - 拖动吸管时对应颜色实时更新。 +- 吸管工具取色后默认保持吸管;“首选项 > 编辑 > 吸管取色后切回铅笔”开启时,只在正式取色结束后切回铅笔,按住修饰键触发的临时吸色始终返回原工具。 - 调色盘和滑条右键调整背景色时,选择点、滑条和色盘显示必须同步,色相不得跳动。 - 色相吸附和颜色级数改变后,选择点归到当前颜色区域中心,同时保持等效颜色意图。 - 调色板颜色格同时反映前景色和背景色:前景色使用右上角角标,背景色使用左下角角标;左键选择前景色或右键选择背景色都会给被点击的颜色格增加临时蓝色描边,颜色格失焦后只隐藏描边,右键不会改变前景色。 +- 前景色和背景色编辑器标题必须明确标注颜色角色。刚打开时属于临时弹窗,失去焦点自动关闭;标题栏发生真实拖动后转为常驻,可与另一颜色编辑器同时存在。两者分别记忆受最小/最大范围约束的尺寸,但关闭后再次打开必须回到触发按钮附近的默认位置;通道滑条与右侧数值输入框等高,并通过统一八向把手缩放。 ## 笔刷类型 - 基础笔刷包括方形、圆形和线条,允许调整尺寸。 - 灰度图笔刷只输出当前颜色,灰度表示覆盖率或像素密度。 - 程序纹理根据各自参数生成,油漆桶填充时纹理按区域规则一次生成,避免重复堆叠。 +- 油漆桶在矩形或非矩形选区中均只修改真实选区掩码包含的像素;连续与不连续模式遵守相同限制。 - 自定义笔刷由 `Ctrl+B` 从选区创建并保存在当前项目中,使用原始尺寸,不显示尺寸参数。 - 画笔、橡皮擦和油漆桶分别保存基础笔刷设置;非基础笔刷和笔刷模式在可用工具间共享。 @@ -26,6 +29,7 @@ ## 图案模式 +- 三种模式使用统一主题下拉,并在选项内直接说明平铺原点或盖章行为;详情预览应显示自定义笔刷保存的原始多色像素,不得无条件改成单一前景色。 - 图案与来源对齐:平铺坐标固定在笔刷来源坐标系,同一画布位置始终得到同一图案。 - 图案与目标对齐:同样平铺,但第一笔落点作为平铺原点;预览必须与实际落点重合。 - 油漆笔刷:以每次笔刷落点独立盖章,不要求跨落点连续平铺。 @@ -35,5 +39,6 @@ - 预览形状和实际写入掩码必须来自同一数据。 - 笔刷预览样式由“首选项 > 光标”控制,可选择无、仅显示边缘、完整预览或完整预览并显示边缘;是否在按住绘制时继续显示预览独立控制。 - 画笔和橡皮擦的直线连接修饰键在快捷键设置中配置;“首选项 > 编辑”的实时预览开关只控制按住修饰键时是否显示临时线段,不改变松开后实际绘制结果。翻转内容等已确认命令必须清除旧直线锚点和预览。 +- “首选项 > 编辑 > 直线算法优化”默认开启。开启时,直线连接和多边形套索边界把主轴像素均匀分配到长度相近的阶梯段;反向连接必须得到同一像素集合,预览和最终结果必须复用同一算法。关闭后回退传统栅格直线。 - 非基础笔刷调整参数时不得闪烁基础圆形或方形预览。 - 指针黑白对比依据指针下最终合成画面,包括预览颜色,而不是当前图层或准备绘制的颜色。 diff --git a/docs/interactions/pointer-modifiers.md b/docs/interactions/pointer-modifiers.md index 55246ff..c9388a0 100644 --- a/docs/interactions/pointer-modifiers.md +++ b/docs/interactions/pointer-modifiers.md @@ -19,6 +19,8 @@ 下列修饰键和笔刷尺寸命令必须在“快捷键设置”中可见。修改后,指针、提示和实际操作必须读取同一份配置。 +快捷键录入以一次按键会话为单位:先按下的 `Ctrl`、`Alt`、`Shift`、`Meta` 只更新预览,普通键按下后再提交完整组合;只绑定修饰键时在修饰键松开或输入框失焦时提交。录入框拥有高于应用命令分发的优先级,`Ctrl+Shift+V` 等组合不得被 MoonSprite 自身的粘贴命令提前截断。`Escape` 放弃本次录入,`Delete` 或 `Backspace` 清空绑定。 + | 场景 | Alt | Ctrl | Shift | | --- | --- | --- | --- | | 画笔、橡皮擦、油漆桶 | 临时吸色 | 工具定义 | 约束由工具定义 | @@ -42,7 +44,8 @@ - 画笔类指针热点对应实际落笔像素;偶数尺寸按当前产品规则落在右下中心像素。 - 吸管热点位于图形有效内容的左下角。 - 移动和复制指针热点位于视觉图形最左上角。 -- 框选绘制指针热点与预览像素中心一致。 +- 框选的四角角标属于画布预览,不改变鼠标热点;默认隐藏鼠标本体,偏好开启后使用热点位于中心的真正十字鼠标指针,不能把十字画进四角角标内部。 +- 框选工具位于画布内容边界外时仍保持选区创建指针和四角角标,不显示禁止指针;只有真实的图层不可编辑状态才显示禁止指针。 - 旋转手柄使用对应四角指针,命中区不得与缩放手柄重叠。 指针源工程使用 16×16 网格,导出为 32×32 PNG;下表坐标均以导出图片左上角为 `(0,0)`: @@ -67,4 +70,4 @@ 修改指针资源或热点时必须验证 1px、2px、大尺寸笔刷,普通/旋转视图和高 DPI。 -本地指针资源缺少某个工具状态时,保留软件内该状态的内置回退指针,不得因为资源缺失显示空白或禁止指针。位置 5,2 的 16×16 源格为移动指针,导出后使用 32×32 资源。 +“首选项 > 光标 > 使用本地指针”开启时,普通操作使用电脑系统指针;系统没有对应语义的旋转视图和选区旋转等特殊状态继续使用 MoonSprite 内置指针。关闭时所有状态使用 MoonSprite 指针。任何资源加载失败都必须保留有效 CSS 回退,不得显示空白或错误的禁止指针。位置 5,2 的 16×16 源格为移动指针,导出后使用 32×32 资源。 diff --git a/docs/interactions/selection-transform.md b/docs/interactions/selection-transform.md index 7905fa5..2c57536 100644 --- a/docs/interactions/selection-transform.md +++ b/docs/interactions/selection-transform.md @@ -2,29 +2,45 @@ ## 创建选区 -- 矩形、椭圆、套索和魔棒共享新建、加选、减选、交集四种模式。 -- 操作期间原有蚂蚁线保持可见;松开后一次性提交组合结果。 -- 拖动预览允许超出画布,最终选区可在画布外显示蚂蚁线。 +- 矩形、椭圆、自由套索、多边形套索和魔棒共享新建、加选、减选、交集四种模式。 +- 多边形套索单击增加顶点,点击第一个顶点、双击末点或按 `Enter` 闭合并提交,按 `Esc` 取消;未闭合时可用中键或 `Space` 临时平移画布,平移期间和松开后都必须持续显示已有边与指针末段,并保留已有顶点、组合模式和原选区继续建立边界。“首选项 > 编辑 > 直线算法优化”开启时,顶点之间的预览和最终边界都使用长度相近的均匀像素阶梯。自由套索拖动预览是否默认闭合由“首选项 > 编辑”控制。 +- 右键拖动默认执行减选。按住 `Shift` 开始选择时绕过已有选区的移动、缩放和旋转命中,从选区内部也能继续加选;属性栏在按住 `Shift` 或右键时临时切换到加选或减选反馈,松开后恢复用户原本选择的组合模式。变换已经开始后,`Shift` 仍作为比例或角度约束键。 +- 创建矩形、椭圆或套索选区期间只显示画布内的新路径像素预览,不显示新选区蚂蚁线和变换点;已有选区的蚂蚁线在新建、加选、减选和交集期间保持显示,松开后再一次性显示组合结果。 +- 新建模式在已有选区外按下并松开且没有真实拖动时取消选区;按下本身不得提前取消。是否属于单击按 CSS 指针移动距离判断。即使按下与松开仍落在同一个文档像素,只要发生真实拖动,也必须提交该 1 像素选区。 +- 工具栏明确选择加选模式时,已有选区内部仍可移动内容,缩放点、旋转区和边缘移动保持可用;减选与交集从选区内部开始时优先执行组合选择。临时按住 `Shift` 加选或使用右键减选始终绕过变换命中。套索组合期间继续显示像素预览。 +- 指针允许从画布外开始并继续按文档像素网格拖动,但创建路径预览只绘制在有效画布范围内;最终选区掩码不得包含画布外像素。 - 魔棒按住时预览全部将被选中的像素;深色像素使用浅色预览,浅色像素使用深色预览。 - 套索路径经过的像素属于最终选区边界;闭合后的右侧和下侧不得因多边形半开判断固定缺少一列或一行。 - `Ctrl+A` 选择整个画布范围。 +- 选区工具悬停时在当前文档像素格四角绘制紧凑直角角标,包括指针位于画布内容边界外时;此时不得改成禁止指针。角标使用固定 CSS 像素线宽,不随视图缩放变粗或变细;视觉范围最小为 `8 x 8 CSS px`,像素格更小时围绕其中心扩展。默认隐藏鼠标本体,只显示四角角标;“首选项 > 光标 > 框选时显示十字指针”开启后显示真正的鼠标十字指针,不得在角标内部额外绘制十字。按住 `Shift` 加选时保持四角角标;进入缩放、旋转或边缘移动命中区时不显示角标。 +- `Ctrl+Shift+I` 反选画布范围内的当前选区并生成一个可撤销历史步骤;没有选区时只提示用户,不创建历史。 +- `Ctrl+H` 只隐藏或显示蚂蚁线,八个变换控制点保持显示和可交互;真实选区仍参与绘制、调整和移动,该视图状态不进入文档撤销历史。 ## 命中与移动 -- 选区内部默认移动选中内容,边缘扩展命中区用于移动选框。 +- 选区内部默认移动选中内容,只有真实蚂蚁线边界扩展命中区用于移动选框;非矩形选区的包围矩形空边不得触发移动选框。 - 选框边缘的内外命中宽度应视觉对称,四边均可稳定触发;命中距离按连续文档坐标计算,不得因像素取整整体向右或向下偏移。 +- 创建、移动或变换选区时,指针进入或超过画布视口边缘会连续平移视图以继续操作;该平移属于视图状态,不进入文档撤销历史。 - 变换命中优先级固定为八个白色缩放点、四角紧凑旋转区、选框边缘移动带、选区内部和普通套索区域;四个边中点使用 12 CSS px 命中半径。四个角在选区内侧保留 18 CSS px 易点击范围,在外侧象限只保留 5 CSS px(覆盖可见 `8×8` 手柄和紧邻容差),越过手柄外缘后立即旋转。旋转区到角点的最远距离为 28 CSS px,超过后必须恢复普通套索输入。所有半径严格按 CSS 像素随缩放换算,不得强制最小为一个文档像素。 - 只有选区工具显示缩放控制点;切换画笔等绘制工具后只保留蚂蚁线,并继续把绘制限制在选区内。 - 指针进入缩放、边缘移动或旋转交互区时隐藏像素预览,避免两个操作提示重叠。 - 完整选择整个画布时仍允许移动内容。 +- 选区内容第一次开始移动时进入浮动状态并捕获稳定的内容与背景来源;只要选区尚未确认或取消,后续任意次数移动都从第一次来源重建并恢复经过区域的原背景,不得把移动后的目标区域重新采入来源。 +- 移动工具移动当前图层或多选图层时,已有选区与图层使用同一位移;一次撤销或重做必须同时恢复图层偏移和选区位置。 - `Shift` 约束水平或垂直方向,并根据相对位移动态选择主轴。 -- `Ctrl` 拖动选区内容进行复制;一次 Undo 同时撤销复制和移动。 +- `Ctrl` 拖动选区内容进行复制;普通移动或已经复制出的浮动内容都可再次按住 `Ctrl`,先确认当前位置,再从该位置开始新一次复制移动;每次已确认的复制移动由一次 Undo 撤销。 + +## 描边 + +- `Shift+O` 打开选区描边。弹窗打开期间 `Enter` 始终优先应用描边并关闭弹窗,即使键盘焦点仍停留在画布;不得把同一次按键解释为确认选区。 +- 描边颜色、宽度、位置、像素方向、快捷形状和实时预览开关属于当前工程设置,成功应用后随 `.moonsprite` 保存并在下次打开工程时恢复。 - `Alt` 保留临时吸色,不用于选区复制。 ## 缩放与旋转 - 四角缩放手柄始终可见且位于内容上层。 - 拖动缩放点时,`Shift` 固定原始宽高比,`Ctrl` 将宽高分别吸附到原始像素尺寸的整数倍;同时按住两者时按原始比例进行整数硬边缘倍数缩放,不对单位像素做插值模糊。缩放点操作不得因 `Ctrl` 复制选区内容。 +- 使用矩形或椭圆框选工具创建选区时,按住 `Shift` 固定为等宽高;该约束必须同时作用于拖动预览与松开后的选区。 - 旋转命中区位于四角外侧,与缩放手柄分离。 - 旋转命中区不显示额外蓝色控制点。四角旋转区紧邻斜向缩放区,并限制在角点周围 28 CSS px 内;离开外侧 5 CSS px 斜向缩放区后立即进入旋转,不留默认指针空带。超过紧凑旋转区后恢复普通套索输入,使已有选区周边仍可继续框选。 - 旋转选区时按住 `Shift` 将角度吸附到每 45° 一个方向,即 0°、45°、90° 等八向角度;松开后恢复连续旋转。 diff --git a/docs/product/behavior.md b/docs/product/behavior.md index 90232cc..0a74e52 100644 --- a/docs/product/behavior.md +++ b/docs/product/behavior.md @@ -6,11 +6,12 @@ - 用户可同时打开多个项目,并在顶部项目栏切换或复制视图。 - 顶部“窗口”菜单集中管理视图辅助、栏目显示和工具栏位置;栏目布局修改实时保存到当前工作区。 -- `.moonsprite` 保存完整项目状态,并可通过文件选择、Windows 文件关联或从资源管理器拖入打开;拖入成功后必须直接进入对应编辑器,不得仍停留在首页。 +- `.moonsprite` 保存完整项目状态,并可通过文件选择、Windows 文件关联或从资源管理器拖入打开;拖入统一经过平台服务,HTML、Tauri Webview、Window 与 Rust 窗口事件互为兜底,单个通道失效时在窗口重新获得焦点后重试。所有通道共用格式过滤和路径去重,拖入成功后必须直接进入对应编辑器,不得仍停留在首页。 - `.ase` 与 `.aseprite` 可通过文件选择、拖放和文件关联打开;保存或导出必须遵循用户明确选择的格式。 - PNG、JPEG、WebP、BMP 与 GIF 可通过文件选择或拖放作为单层像素文档打开;GIF 当前读取第一帧。图片导入后保留来源路径,但不会覆盖原图片,首次保存仍按用户选择的工程或图片格式执行。 - 从文件关联启动时应直接进入目标工程,不应只停留在首页。 -- 打开或保存的 `.moonsprite`、`.ase`、`.aseprite`、PNG、JPEG、WebP、BMP 与 GIF 进入最近列表;首页和“文件 > 最近打开文件”始终显示真实文件名及后缀,找不到或无法解析的记录在用户打开失败后移除。 +- 打开或保存的 `.moonsprite`、`.ase`、`.aseprite`、PNG、JPEG、WebP、BMP 与 GIF 进入最近列表;首页和“文件 > 最近打开文件”始终显示真实文件名及后缀。找不到或无法解析的文件保留原文件和最近记录,并显示“从最近移除”供用户明确处理;画廊中的“删除工程”是独立危险操作,必须二次确认后才删除磁盘文件。 +- 首页先显示完整文件列表,再以最多 3 个任务并发渐进读取缩略图;单项读取失败不得阻塞其他项目。已成功解码的缩略图在当前软件会话内按文件路径和记录时间缓存,栏目刷新时复用未变化结果。 - 打开新建工程弹窗时,如果系统剪贴板包含图片,应立即读取图片头部宽高并填入尺寸;此步骤不得解码或跨进程传输整张像素数据。 - 异常关闭工程进入恢复栏目;用户明确放弃后不得反复提示或自动恢复。 @@ -20,15 +21,26 @@ - 画布平移、缩放、旋转视图、面板布局和工具切换不是文档编辑,不得改变 dirty 状态。 - 无可撤销操作时按 `Ctrl+Z` 不得把工程标记为未保存。 - 一个用户动作应对应一个历史步骤,例如复制图层并移动后一次撤销应整体撤销。 +- `Delete`、`Backspace`、`Ctrl+C` 和 `Ctrl+V` 根据用户最后点击或键盘聚焦的画布、图层或调色板区域执行;`Backspace` 与当前可用的删除操作等价,文本输入中仍保留正常退格。遗留选区不得抢占图层和调色板命令。跨项目复制单层、多层或图层组时保留顺序、偏移、图层属性和父子组结构。粘贴图层组时放到当前选中对象的父级;粘贴普通图层时,选中组则放入组内,选中图层则与该图层同级,没有选择则放到根层。所有粘贴对象位于目标容器最上方,粘贴完成后整批图层和图层组都进入选中状态,整批粘贴或删除只产生一个撤销步骤。 ## 图层与选区 -- 图层和文件夹支持嵌套、排序、拖入与拖出;插入预览必须准确表示最终层级。 +- 图层和文件夹支持嵌套、统一排序、拖入与拖出;所有组都使用持久排序锚点,允许夹在普通图层之间并在保存重开后保持。新建图层和图层组位于当前容器顶部;多选图层创建组时,新组占据所选图层中最上方图层的原位置,并保留成员原有上下顺序;选中组或组内图层时,新图层创建在对应组内;没有选择任何图层或组时,新建组必须为空。拖出图层栏上方或下方时分别落到根层最上方或最下方,顶部和底部蓝线与最终落点必须使用同一目标,即使首项或末项为组也允许放到组外。插入预览线的左边必须与最终层级对齐。图层与图层组使用相同的选中背景;眼睛、锁和组文件夹拥有独立交互区,不得误触属性面板。栏目标题只保留新建图层、新建组和删除,合并与解组从菜单执行。 +- 图层选择使用 Photoshop 式修饰键:`Ctrl` 在图层和组之间离散增减选,`Shift` 从最近选择锚点到目标对象按当前完整图层树的可见行执行连续范围选择,范围内的图层和组都进入选择。多选后普通单击任意图层或组会清除多选并只选择该项;`Delete` 一次删除全部选中图层、选中组及组内后代,并由一次撤销完整恢复。图层属性修改实时提交并可撤销,关闭按钮、遮罩或 `Esc` 只关闭面板,不回滚已经操作的值。 +- 点击图层栏目中不属于任何图层行的空白区域会清除多选和组选择,但保留当前活动图层。空图层组允许使用 `Delete` 删除;锁定图层、锁定组及其所有后代不得修改像素、混合模式、不透明度、结构、排序或删除,但仍允许编辑名称、显示颜色和描述。组锁定后,其所有后代显示有效锁定状态且不能单独解锁;包含任意显式锁定后代的祖先组也不得删除。 - 调色板颜色格同时反映前景色和背景色:前景色保留右上角角标,背景色保留左下角角标;左键或右键点击颜色格时额外显示蓝色临时选中描边,颜色格失焦后只隐藏描边,角色角标和颜色角色本身不变。 -- 图层和图层组默认不带显示颜色;属性底部可选择无颜色、预设色或自定义色,设置后仅在图层行最左侧显示竖条,不改变画布像素。描述使用统一的固定尺寸多行输入,并通过自定义悬浮提示展示;两者属于可撤销并随工程保存的图层属性。 +- 前景色和背景色由整个应用共享,而不是分别存入项目标签;切换、新建或打开工程后继续使用最近选择的两种颜色。 +- 图层和图层组默认不带显示颜色;属性底部可选择无颜色、预设色或自定义色,设置后仅在图层行最左侧显示竖条,不改变画布像素。预设色由“首选项 > 图层 > 图层属性颜色预设”全局管理,支持 1 至 12 个有效且不重复的 RGB 色,应用后所有工程的图层属性面板实时使用同一组预设。描述使用统一的固定尺寸多行输入,并通过自定义悬浮提示展示;两者属于可撤销并随工程保存的图层属性。 - 隐藏或锁定图层不可绘制,画布应显示禁止操作指针。 - 选区可以超出画布显示,但最终像素写入遵守文档和图层边界数据规则。 -- 粘贴内容在确认前保持完整,可通过选区移动到合适位置,不应因当前画布视口而提前裁剪。 +- 主工具、选区子工具和形状子工具都使用统一自定义悬浮提示,包含工具名称、简短用法与用户当前快捷键。主工具按钮与顶部属性栏始终显示当前选中的子工具图标和名称;所有子工具图片在工具栏挂载时以不可见 DOM 图像常驻并同步解码,不得在首次选择后延迟出现。选区工具栏以单行固定显示矩形框选、椭圆框选、套索、多边形套索和魔棒;多边形套索默认使用 `Shift+Q`。五种工具共享选区组合规则;右键默认减选,按住 `Shift` 开始选择时绕过已有选区的内容变换并执行加选,属性栏在按住期间临时显示对应的加选或减选模式,松开后恢复原模式。 +- “套索选区预览闭合”默认关闭;吸管工具默认保持当前工具,只有开启“吸管取色后切回铅笔”后,正式取色才在松开时返回铅笔,临时吸色不受该偏好影响。 +- 形状工具栏以单行固定显示矩形描边、矩形填充、椭圆描边和椭圆填充,并使用指针源工程第 5 行第 3 至 6 列的对应图标。“固定比例”使用宽、高两个数值定义比例,持久值最多保留一位小数,开启后横向或纵向拖动都按主轴保持指定宽高比。 +- 移动工具开启自动选择图层后,命中其他图层的不透明像素时移动对应图层;命中透明位置时回退到当前选中图层,多选图层保持整体移动。 +- “编辑 > 调整”有选区时只作用当前图层选区,没有选区时作用全部选中图层。弹窗打开期间改变选区或图层选择,实时预览必须先恢复旧目标基线,再对新目标重新计算;最终只生成一个撤销步骤。 +- 色相/饱和度调整同时提供色相、饱和度和 HSL 明度;明度 `+100` 收敛到白色,`-100` 收敛到黑色,不得与 RGB 亮度复用同一计算语义。 +- 粘贴内容和第一次移动后的选区内容在确认前保持浮动,可继续移动到合适位置,不应因当前画布视口而提前裁剪或覆盖经过区域的原背景。“编辑 > 选择性粘贴”提供“粘贴为新项目”和“粘贴为新图层”;新图层默认使用 `Ctrl+Shift+V`。来源坐标与当前可见视图相交时保留原位,否则以当前视图中心放置。 +- 预览栏目先缓存带透明通道的作品像素,再按最终显示比例独立绘制透明棋盘格;规则与主画布一致,格子小于 `2 CSS px` 时退化为统一浅色,不得把高频棋盘纹理降采样进作品缓存而产生条纹或摩尔纹。 ## 工作区与设置 @@ -39,9 +51,14 @@ - 数值输入组件允许输入数字、括号及 `+ - * /` 表达式;回车或失焦后按标准运算优先级计算,再应用字段本身的最小值和最大值。 - “文件”首选项使用一个“自动保存恢复数据”时间预设控制恢复写入;选择关闭时不得注册定时或失焦自动保存。 - 首选项底部提供“应用”,保存当前设置但保持首选项窗口打开;“确定”保存并关闭,“取消”放弃本次未应用修改。 +- “恢复所有初始设置”必须二次确认,且只清除 MoonSprite 的本地软件配置;不得删除用户创建的工程、笔刷、色板、工作区文件或其他磁盘文件。语言切换在完整翻译资源落地前只显示简体中文,不提供无法实际生效的入口。 - 颜色值按钮显示颜色样本、八位 HEX 和颜色角色;打开后默认进入 RGB,可切换 RGB、HSV、HSL、Gray。模式、HEX 与当前色在顶部同一行,每个通道使用一条彩色滑轨和右侧直接数值输入,所有模式均支持透明度。调色盘底部前景色/背景色与背景首选项复用同一组件。 -- “光标”首选项控制本地像素指针、光标比例、笔刷预览样式及绘制期间是否保持预览;启用本地指针时,本地没有对应操作指针则回退到软件内置指针。“背景”首选项统一控制透明棋盘格大小和并排显示的两种颜色,颜色区使用普通设置布局而不是独立栏目。“编辑”首选项控制滚轮缩放与铅笔直线实时预览。“默认缩放模式”控制缩放工具拖动时使用平滑或百分比缩放。 -- 同一表单或弹窗内存在多个文本、数字输入框时,`Tab` 和 `Shift+Tab` 分别按界面顺序向前、向后切换输入焦点。 +- 前景色和背景色的颜色值弹窗标题显示“颜色编辑 前景色/背景色”,可通过关闭按钮关闭。未移动时失焦自动关闭,标题栏真实拖动后转为常驻;两者拥有独立尺寸记忆、统一最小/最大尺寸,可八向缩放并同时打开,关闭后再次打开回到各自触发按钮附近的默认位置。调整高度时通道色条随内容区自适应,色条与数值输入框保持等高。 +- “光标”首选项中的“使用本地指针”开启时优先使用电脑系统指针,系统没有对应语义的旋转等特殊操作回退到 MoonSprite 指针;关闭时所有场景使用 MoonSprite 指针。光标比例保留在该分类。“工具预览”单独控制笔刷预览样式、绘制期间是否保持预览以及框选时是否显示真正的鼠标十字指针;十字默认关闭,此时框选只显示当前像素四角角标。“背景”首选项统一控制透明棋盘格大小和并排显示的两种颜色;“图层属性颜色预设”使用相同的普通颜色设置布局,不得伪装成独立栏目。“编辑”首选项控制滚轮缩放、铅笔直线实时预览和默认开启的“直线算法优化”;该算法同时用于画笔直线和多边形套索边界。“默认缩放模式”控制缩放工具拖动时使用平滑或百分比缩放。 +- 首选项中的尺寸预设、导出倍率预设与图层属性颜色预设统一使用组件库 `DeleteIconButton`;紧凑预设行使用 `28 px`,颜色预设与删除按钮统一使用 `34 px` 高度,并共享危险悬浮与禁用状态。 +- 同一表单或弹窗内存在多个文本、数字输入框时,`Tab` 和 `Shift+Tab` 分别按界面顺序向前、向后切换输入焦点,并默认全选新输入框内容。 +- 状态提示位于顶部工具属性栏下方的画布视图上部,与属性栏保留明确间距且不遮挡视图中央;调整、画布大小和图像大小弹窗默认位于视图中心与右侧边缘之间的中点。所有正式模态弹窗复用统一外壳,可拖动、通过边框外侧的八向把手调整大小,并记忆各自的位置和尺寸;每类弹窗设置能保持内容完整的最小尺寸与适合当前视口的最大尺寸,外壳不产生横向或重复滚动条,默认尺寸以 1080p 下不压迫画布为准。 +- 整数与非整数缩放必须使用稳定一致的画布几何和取样原点;在 313% 等非整数缩放下开始或继续绘制时,不得因切换缓存绘制路径而使已有像素发生局部跳动。 - 顶部任一主菜单打开后,鼠标横向移入其他主菜单应直接切换并展开;没有菜单打开时,普通悬停不得自行展开菜单。 ## 兼容与安全 @@ -63,3 +80,5 @@ ## 保存 尚未保存到本地的工程执行普通保存时直接进入系统保存流程,不先显示 MoonSprite 保存进度反馈。已有路径的普通保存也直接完成。显式另存为和导出都必须先等待 Windows 文件选择器返回;用户确认路径后才分别显示“正在另存为”和“正在导出”的紧凑进度反馈,取消系统弹窗时不显示进度,完成后自动关闭。 + +“另存为”默认选择“首选项 > 文件 > 默认保存格式”。选择 PNG、JPEG 或 WebP 时必须明确提示该格式不会保留图层和工程结构;用户确认的扩展名、Windows 保存类型和实际编码格式必须一致。 diff --git a/docs/testing/performance-history.md b/docs/testing/performance-history.md index 093ee4a..526f58a 100644 --- a/docs/testing/performance-history.md +++ b/docs/testing/performance-history.md @@ -11,6 +11,138 @@ 5. 没有自动基准覆盖的交互必须明确写“未覆盖”,禁止用主观流畅度代替数据。 6. 性能优化不能改变像素结果、快捷键、撤销语义或文件兼容性。 +## 2026-08-04 选区浮动复制、多边形预览与预览棋盘格 + +代码基线:`f6eb1c6` 后的当前工作树。性能等级为 P3,完整运行一次 `pnpm bench:canvas -- --full`。本轮涉及选区创建预览的画布外绘制、多边形套索直线预览、浮动选区二次复制,以及大画布预览栏的棋盘格降采样。 + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 x 128 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 6.00 ms | 14.30 ms | +| 128 x 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.10 ms | 8.20 ms | +| 128 x 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.20 ms | 4.00 ms | +| 128 x 128 | 连续绘制 | 3.70 ms | 0.141% | 0.60 ms | 0.40 ms | 8.10 ms | 49.90 ms | +| 512 x 512 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 5.40 ms | 14.30 ms | +| 512 x 512 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.60 ms | 7.00 ms | +| 512 x 512 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.30 ms | 1.00 ms | 3.90 ms | +| 512 x 512 | 连续绘制 | 3.70 ms | 0.141% | 0.80 ms | 0.40 ms | 11.40 ms | 92.90 ms | +| 1024 x 1024 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.30 ms | 4.50 ms | 10.80 ms | +| 1024 x 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 2.90 ms | 4.00 ms | +| 1024 x 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.30 ms | 1.70 ms | 7.20 ms | +| 1024 x 1024 | 连续绘制 | 3.70 ms | 0.140% | 0.70 ms | 0.50 ms | 7.90 ms | 74.90 ms | + +所有场景帧 `p95` 均为约 3.70 ms,超过 25 ms 的帧比例最高为 0.141%,主绘制 `p95` 最高 0.80 ms,指针处理 `p95` 最高 0.50 ms,均满足当前门禁。连续绘制各出现一次 49.9-92.9 ms 的环境长任务,但高频绘制、输入和帧分位数没有同步持续升高,不判定为本轮退化。基准尚未自动覆盖多边形套索逐点点击、画布外选区预览和预览栏极大画布棋盘格的视觉正确性,这三项仍需桌面使用观察。 + +## 2026-08-04 选区角标、混合图层选择与均匀阶梯直线 + +代码基线:`f6eb1c6` 后的当前工作树。性能等级为 P3,完整运行一次 `pnpm bench:canvas -- --full`。本轮涉及选区角标高频预览、图层与组的混合选择和批量删除、分类型粘贴层级、多边形套索,以及 Shift 直线连接的均匀阶梯算法。 + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 x 128 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.70 ms | 7.20 ms | +| 128 x 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 1.80 ms | 7.50 ms | +| 128 x 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.10 ms | 3.80 ms | +| 128 x 128 | 连续绘制 | 3.70 ms | 0.140% | 0.70 ms | 0.40 ms | 7.20 ms | 39.20 ms | +| 512 x 512 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 6.90 ms | 10.70 ms | +| 512 x 512 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.10 ms | 1.80 ms | 4.10 ms | +| 512 x 512 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.10 ms | 4.00 ms | +| 512 x 512 | 连续绘制 | 3.70 ms | 0.142% | 0.60 ms | 0.30 ms | 6.90 ms | 60.90 ms | +| 1024 x 1024 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 4.70 ms | 14.40 ms | +| 1024 x 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 1.70 ms | 3.90 ms | +| 1024 x 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.40 ms | 0.10 ms | 1.90 ms | 3.90 ms | +| 1024 x 1024 | 连续绘制 | 3.70 ms | 0.142% | 0.60 ms | 0.50 ms | 7.70 ms | 67.90 ms | + +所有场景帧 `p95` 均约为 3.70 ms,超过 25 ms 的帧比例最高为 0.142%,主绘制 `p95` 不超过 0.70 ms,指针处理 `p95` 不超过 0.50 ms。连续绘制中的单次长帧伴随 54-63 ms 环境长任务,高频分位数未同步升高,不判定为稳定退化。最终主入口为 725.95 kB、gzip 219.39 kB,较上一条完整记录 723.15 kB / 218.72 kB 分别增加约 0.39% / 0.31%,低于 5% 门禁。均匀阶梯直线的像素集合由自动测试覆盖,但实际作品中的阶梯观感、多边形套索点击节奏和高 DPI 下角标绝对粗细仍需 Windows WebView2 实机确认。 + +## 2026-08-04 连续画布合成与浮动选区缓存修复 + +代码基线:当前工作树。性能等级为 P3,完整运行一次 `pnpm bench:canvas -- --full`。本轮将最终画布显示改为单张连续表面,消除 128px 分块分别缩放产生的采样接缝;绘制仍按脏矩形局部刷新。同时补齐浮动选区背景缓存、选区指针、描边确认、颜色弹窗和图层组粘贴交互。 + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 x 128 | 平移 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 6.60 ms | 17.90 ms | +| 128 x 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.10 ms | 2.20 ms | 8.00 ms | +| 128 x 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.70 ms | 14.30 ms | +| 128 x 128 | 连续绘制 | 3.70 ms | 0.140% | 0.70 ms | 0.40 ms | 8.60 ms | 53.60 ms | +| 512 x 512 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.90 ms | 17.90 ms | +| 512 x 512 | 缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 2.40 ms | 3.90 ms | +| 512 x 512 | 旋转后缩放 | 3.70 ms | 0% | 0.60 ms | 0.10 ms | 1.20 ms | 4.20 ms | +| 512 x 512 | 连续绘制 | 3.70 ms | 0.140% | 0.60 ms | 0.50 ms | 7.80 ms | 71.40 ms | +| 1024 x 1024 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 6.00 ms | 14.30 ms | +| 1024 x 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.50 ms | 7.00 ms | +| 1024 x 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.50 ms | 4.10 ms | +| 1024 x 1024 | 连续绘制 | 3.70 ms | 0.140% | 0.60 ms | 0.40 ms | 9.40 ms | 85.70 ms | + +所有场景帧 `p95` 均为约 3.70 ms,超过 25 ms 的帧比例最高为 0.140%,主绘制 `p95` 不超过 0.70 ms,指针处理 `p95` 不超过 0.50 ms。连续绘制中的单次长帧与 67-78 ms 环境长任务同时出现,高频分位数未同步升高,不判定为稳定退化。最终主入口为 723.15 kB、gzip 218.72 kB,较上一条完整记录 722.75 kB / 218.53 kB 分别增加约 0.06% / 0.09%,低于 5% 门禁。浏览器已在 1600% 纯色大画布上确认不再出现规律分块接缝;4K、半透明多图层和 Windows WebView2 长时间连续绘制仍需发布前实机回归。 + +## 2026-08-04 跨工程颜色、选区与图层交互回归 + +代码基线:当前工作树。性能等级为 P3,完整运行一次 `pnpm bench:canvas -- --full`。本批修改覆盖跨工程共享前景色/背景色、选区边缘自动平移与隐藏轮廓、选区模式命中、非矩形选区填充、可缩放颜色弹窗,以及图层组复制粘贴和根层排序。 + +所有场景帧 `p95` 均为约 3.70 ms,超过 25 ms 的比例为 0%-0.2%,主绘制 `p95` 最高 1.40 ms,指针处理 `p95` 最高 0.50 ms。1024 连续绘制出现一次 75.1 ms 偶发长任务,但高频分位数未同步升高,不判定为稳定退化。最终主入口为 722.75 kB、gzip 218.53 kB;仍存在既有的主包超过 500 kB 构建警告。选区边缘停留时的持续自动平移、颜色弹窗在 Windows WebView2 中的缩放手感,以及跨项目颜色同步的长期内存影响仍未被自动基准覆盖。 + +## 2026-08-04 选区指针、图层落点与调整预览 + +代码基线:当前工作树。性能等级为 P3,完整运行一次 `pnpm bench:canvas -- --full`。本轮涉及选区指针和覆盖层、图层拖放、独立颜色弹窗,以及调整滑块实时预览按动画帧合并。 + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 6.40 ms | 14.30 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.50 ms | 0.30 ms | 2.80 ms | 7.10 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.30 ms | 1.40 ms | 7.10 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.183% | 1.00 ms | 0.50 ms | 9.30 ms | 50.00 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 6.00 ms | 17.80 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.50 ms | 0.30 ms | 2.80 ms | 7.20 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 0.60 ms | 0.20 ms | 1.80 ms | 10.70 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.154% | 1.60 ms | 0.50 ms | 10.20 ms | 92.80 ms | +| 1024 × 1024 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 5.90 ms | 21.40 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.50 ms | 0.30 ms | 2.10 ms | 7.20 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.30 ms | 3.80 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.172% | 1.40 ms | 0.50 ms | 9.90 ms | 89.40 ms | + +所有场景帧 `p95` 均为约 3.70 ms,超过 25 ms 的帧比例最高为 0.183%,主绘制 `p95` 不超过 1.60 ms,指针处理 `p95` 不超过 0.50 ms。连续绘制中的单次长帧与 80 ms 环境长任务同时出现,高频分位数没有同步升高,不判定为稳定退化。最终 Web 构建主入口为 719.54 kB、gzip 217.86 kB,较上一条完整记录 715.77 kB / 216.88 kB 分别增加约 0.53% / 0.45%,低于 5% 门禁。1024 × 640 已确认选区角标、单击空操作、蚂蚁线显隐和前后景颜色弹窗同时常驻;实际指针拖拽已确认普通图层可越过带持久锚点的组到达顶端。50% 半透明画布大小预览仍需 Windows WebView2 实机确认。 + +## 2026-08-03 高 DPI 预览与半透明分块合成修复 + +代码基线:当前工作树。性能等级为 P3,完整运行一次 `pnpm bench:canvas -- --full`。本轮修正设备像素对齐、旋转离屏画布回绘尺寸,以及 tile gutter 重复合成,同时包含选区、图层、弹窗、快捷键和调整交互更新。 + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.50 ms | 0.40 ms | 10.50 ms | 25.00 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.50 ms | 0.30 ms | 4.90 ms | 4.20 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.70 ms | 0.20 ms | 1.80 ms | 10.70 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.131% | 1.20 ms | 0.50 ms | 16.40 ms | 85.70 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 11.00 ms | 21.50 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.60 ms | 0.30 ms | 3.00 ms | 10.80 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 1.00 ms | 0.20 ms | 1.60 ms | 3.90 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.129% | 1.70 ms | 0.50 ms | 12.10 ms | 103.50 ms | +| 1024 × 1024 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.70 ms | 14.30 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.70 ms | 3.80 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.30 ms | 7.20 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.140% | 1.30 ms | 0.40 ms | 11.00 ms | 85.70 ms | + +所有场景帧 `p95` 均低于 16.7 ms,超过 25 ms 的帧比例最高为 0.140%,主绘制 `p95` 不超过 1.70 ms,指针处理 `p95` 不超过 0.50 ms。连续绘制各有一次 72-96 ms 环境长任务,但帧、绘制和输入分位数没有同步持续升高,不判定为稳定退化。Web 构建主入口为 715.77 kB、gzip 216.88 kB,较最近完整记录 711.01 kB / 215.84 kB 分别增加约 0.67% / 0.48%,低于 5% 门禁。自动桌面回归因已有热更新实例触发单实例机制、隔离进程未暴露 WebView2 调试端口而未执行;4K 实机预览和半透明图层跨 128px 分块绘制仍需在真实屏幕与作品中确认。 + +## 2026-08-03 首页、命令分发与跨项目图层剪贴板 + +代码基线:当前工作树。性能等级为 P3,完整运行一次 `pnpm bench:canvas -- --full`。本轮主要调整首页渐进缩略图、弹窗草稿语义、编辑区域命令分发和跨项目多图层/图层组剪贴板;画布像素算法未改变。 + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 6.80 ms | 21.50 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 5.50 ms | 14.30 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.80 ms | 3.70 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.140% | 1.00 ms | 0.50 ms | 11.30 ms | 42.80 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.30 ms | 14.30 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 1.90 ms | 8.00 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.40 ms | 3.90 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.140% | 1.20 ms | 0.40 ms | 11.20 ms | 67.80 ms | +| 1024 × 1024 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 5.20 ms | 14.30 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 1.90 ms | 7.20 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 2.10 ms | 5.70 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.141% | 1.30 ms | 0.40 ms | 10.70 ms | 71.50 ms | + +所有场景帧 `p95` 均低于 16.7 ms,超过 25 ms 的帧比例低于 1%。512 和 1024 连续绘制各出现一次环境长任务,但与最近 `DEV.1` 候选记录的 75.0 ms / 71.3 ms 相当,且主绘制与指针处理分位数没有同步升高,不判定为稳定退化。最终 Web 构建主入口为 694.24 kB、gzip 210.87 kB,较最近发布候选的 686.74 kB / 208.83 kB 分别增加约 1.09% / 0.98%,低于 5% 门禁。首页缩略图吞吐、多图层跨项目复制峰值内存和 Windows WebView2 实机交互仍未由画布基准覆盖。 + ## 2026-08-03 选区修饰键与视图预览生命周期 代码基线:当前工作树。性能等级为 P3,完整运行一次 `pnpm bench:canvas -- --full`。本轮将选区比例、整数倍与旋转吸附接入统一快捷键配置,并在 Undo/Redo 前同步提交尚未落入 Store 的视图预览。 @@ -273,8 +405,134 @@ 选区算法在无并发负载下连续三次运行,中位数为魔棒连续区域 17.42 ms、复杂选区边界 8.59 ms,较 `8c58f9b` 同机基线分别改善 4.4% 和 7.6%。主入口为 686.74 kB、gzip 208.83 kB,较上一条完整记录 686.14 kB / 208.60 kB 分别增加 0.09% / 0.11%,低于 5% 门禁。Tauri WebView2 冒烟测试与 Windows 桌面回归均通过;镜像视图、程序纹理笔刷、调色盘连续拖动、多图层合成和长期内存占用仍未被自动画布基准覆盖。 +### 编辑器交互体验批次验证 + +2026-08-03 在当前工作树运行一次 P3 完整画布基准。本批修改覆盖图层排序与拖放、选区和形状工具、动态多图层调整、移动工具回退、统一数值输入,以及弹窗拖动、八向缩放和位置尺寸记忆: + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 6.40 ms | 21.50 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 2.20 ms | 14.30 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.70 ms | 0.70 ms | 1.50 ms | 7.10 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.128% | 1.20 ms | 0.50 ms | 15.50 ms | 50.00 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.70 ms | 17.70 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.20 ms | 7.10 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.60 ms | 4.00 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.141% | 1.20 ms | 0.40 ms | 12.70 ms | 75.00 ms | +| 1024 × 1024 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.10 ms | 14.40 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.70 ms | 5.33 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.60 ms | 0.20 ms | 1.60 ms | 4.00 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.140% | 1.10 ms | 0.40 ms | 10.50 ms | 71.60 ms | + +所有场景帧 `p95` 均低于 16.7 ms,卡顿帧比例低于 1%,主绘制 `p95` 不超过 1.20 ms,指针处理 `p95` 不超过 0.70 ms。连续绘制出现 50-75 ms 的一次性长任务,但高频绘制、输入和 React 提交分位数没有形成持续升高,不判定为本批代码退化。主入口为 701.93 kB、gzip 213.05 kB,较上一条完整记录 686.74 kB / 208.83 kB 分别增加 2.21% / 2.02%,低于 5% 门禁。1024 × 640 与 1920 × 1080 浏览器视口已检查调整、画布大小、图像大小、首选项、快捷键和组件库弹窗;Windows WebView2 实机的弹窗拖动和缩放手感仍未被自动基准覆盖。 + +### 选区工具与预览棋盘格修复 + +2026-08-04 在当前工作树运行一次 P3 完整画布基准。本批修改覆盖选区创建预览、多边形套索均匀阶梯边界、工具弹出栏,以及预览栏目透明棋盘格的最终显示绘制: + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 5.80 ms | 14.30 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.70 ms | 7.20 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.70 ms | 4.00 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.140% | 0.70 ms | 0.40 ms | 9.20 ms | 53.60 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.30 ms | 10.70 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 2.20 ms | 7.60 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.60 ms | 4.00 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.141% | 0.60 ms | 0.40 ms | 9.40 ms | 78.50 ms | +| 1024 × 1024 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.00 ms | 14.40 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.10 ms | 2.40 ms | 3.90 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.60 ms | 6.30 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.141% | 0.60 ms | 0.40 ms | 7.50 ms | 82.00 ms | + +所有场景帧 `p95` 均低于 16.7 ms,卡顿帧比例最高为 0.141%,主绘制 `p95` 不超过 0.70 ms,指针处理 `p95` 不超过 0.40 ms。512 和 1024 连续绘制各出现一次 73 ms 环境长任务,但帧、绘制和输入分位数没有同步持续升高,不判定为稳定退化。最终主入口为 728.55 kB、gzip 220.68 kB,较上一条完整记录 711.01 kB / 215.84 kB 分别增加约 2.47% / 2.24%,低于 5% 门禁。1024 × 640 浏览器视口已确认选区五项和形状四项单行显示、顺序正确、无溢出,并验证工具提示读取当前快捷键;多边形双击闭合、选区松开前隐藏蚂蚁线及大画布棋盘格仍需 Windows WebView2 实际操作观察。 + +### 多边形套索平移、工具提示与图层颜色预设 + +2026-08-04 在当前工作树运行一次 P3 完整画布基准。本批修改覆盖多边形套索平移状态恢复、选区创建期间旧蚂蚁线渲染、主工具提示、形状图标和图层颜色预设: + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 6.50 ms | 17.90 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.60 ms | 0.30 ms | 3.50 ms | 6.00 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.60 ms | 0.20 ms | 1.80 ms | 4.10 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.138% | 0.70 ms | 0.40 ms | 12.80 ms | 64.10 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.40 ms | 6.10 ms | 17.80 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 3.20 ms | 7.10 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 0.60 ms | 0.20 ms | 1.90 ms | 4.20 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.125% | 0.80 ms | 0.60 ms | 13.90 ms | 96.40 ms | +| 1024 × 1024 | 平移 | 3.80 ms | 3.041% | 0.40 ms | 0.20 ms | 7.60 ms | 86.70 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 2.20 ms | 4.00 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.60 ms | 3.80 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.142% | 0.60 ms | 0.40 ms | 7.90 ms | 82.10 ms | + +所有场景帧 `p95` 均低于 16.7 ms,主绘制 `p95` 不超过 0.80 ms,指针处理 `p95` 不超过 0.60 ms。首次 1024 平移遇到一次 89 ms 系统长任务,导致卡顿帧比例暂时达到 3.041%;随后单独复测同场景为帧 `p95` 3.70 ms、卡顿帧 0%、最坏帧 10.70 ms,主绘制和输入分位数均未升高,因此判定为环境离群值而非稳定退化。主入口为 733.30 kB、gzip 221.82 kB,较上一条完整记录 728.55 kB / 220.68 kB 分别增加约 0.65% / 0.52%,低于 5% 门禁。Windows WebView2 中未闭合多边形平移、旧蚂蚁线持续显示以及 1024 × 640 首选项布局仍需实际操作观察。 + +### 子工具呈现、套索平移预览与原生文件拖入修复 + +2026-08-04 在当前工作树运行一次 P3 完整画布基准。本批修改统一当前子工具图标与说明,保持多边形套索在平移期间的预览,修正系统/MoonSprite 指针选择,并为文件拖入增加 Webview 与 Window 双原生来源: + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 6.10 ms | 14.20 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.10 ms | 2.90 ms | 5.93 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.20 ms | 3.90 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.141% | 0.70 ms | 0.40 ms | 7.30 ms | 46.40 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 4.60 ms | 10.70 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 2.20 ms | 3.80 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.60 ms | 3.90 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.142% | 0.60 ms | 0.40 ms | 8.30 ms | 67.90 ms | +| 1024 × 1024 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.20 ms | 10.80 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.40 ms | 4.20 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.60 ms | 6.30 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.142% | 0.60 ms | 0.40 ms | 8.70 ms | 82.20 ms | + +所有场景帧 `p95` 均低于 16.7 ms,卡顿帧比例最高为 0.142%,主绘制 `p95` 不超过 0.70 ms,指针处理 `p95` 不超过 0.40 ms。512 和 1024 连续绘制分别出现一次 61 ms 和 72 ms 系统长任务,但高频帧、绘制和输入分位数没有同步升高,不判定为稳定退化。主入口为 738.26 kB、gzip 222.67 kB,较上一条完整记录 733.30 kB / 221.82 kB 分别增加约 0.68% / 0.38%,低于 5% 门禁。1024 × 640 浏览器视口已确认子工具首次展开时四个形状图标均完成解码,顶部属性栏使用当前子工具说明,“工具预览”和图层颜色预设布局无溢出;Windows 资源管理器真实拖入和按键长按反馈仍需桌面实际操作观察。 + +### DEV.2 发布候选验证 + +2026-08-04 对 `DEV.2` 发布候选工作树执行 P4 验证。选区算法和完整画布基准均连续运行三次,表格记录各项中位数: + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 6.40 ms | 14.30 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 2.50 ms | 3.90 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.30 ms | 4.10 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.140% | 0.60 ms | 0.40 ms | 9.10 ms | 46.50 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 6.10 ms | 14.30 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.00 ms | 3.90 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.40 ms | 4.00 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.141% | 0.60 ms | 0.40 ms | 9.40 ms | 71.50 ms | +| 1024 × 1024 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.10 ms | 10.80 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.30 ms | 3.80 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.60 ms | 3.90 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.142% | 0.60 ms | 0.40 ms | 9.50 ms | 78.30 ms | + +所有场景帧 `p95` 均低于 16.7 ms,卡顿帧比例最高为 0.142%,主绘制 `p95` 不超过 0.60 ms,指针处理 `p95` 不超过 0.40 ms。512 和 1024 连续绘制的最坏帧中位数仍包含提交后的异步长任务,但高频帧、主绘制和输入分位数稳定,不判定为持续退化。选区算法三次中位数为魔棒连续区域 15.07 ms、复杂选区边界 7.76 ms,较 `8c58f9b` 同机基线分别改善约 17.3% 和 16.5%。主入口为 738.85 kB、gzip 223.56 kB,较上一条完整记录 738.26 kB / 222.67 kB 分别增加约 0.08% / 0.40%,低于 5% 门禁。Windows WebView2 启动、原生拖入、文件关联和安装版行为继续由本次桌面与安装验证覆盖。 + ## 开发门禁耗时 +### 图层、选择性粘贴与非整数缩放修复 + +2026-08-03 在当前工作树运行一次 P3 完整画布基准。本批修改覆盖图层组排序与多选、选择性粘贴、套索预览、弹窗统一几何,以及非整数缩放时统一画布合成路径: + +| 画布 | 场景 | 帧 `p95` | >25 ms | 主绘制 `p95` | 指针处理 `p95` | React `p95` | 最坏帧 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 128 × 128 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.30 ms | 6.30 ms | 14.20 ms | +| 128 × 128 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 2.60 ms | 6.30 ms | +| 128 × 128 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.40 ms | 3.80 ms | +| 128 × 128 | 连续绘制 | 3.70 ms | 0.140% | 0.90 ms | 0.40 ms | 9.00 ms | 53.60 ms | +| 512 × 512 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 5.50 ms | 14.40 ms | +| 512 × 512 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.20 ms | 2.40 ms | 3.80 ms | +| 512 × 512 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.20 ms | 1.40 ms | 7.10 ms | +| 512 × 512 | 连续绘制 | 3.70 ms | 0.140% | 1.10 ms | 0.40 ms | 10.10 ms | 75.10 ms | +| 1024 × 1024 | 平移 | 3.70 ms | 0% | 0.30 ms | 0.20 ms | 6.30 ms | 17.80 ms | +| 1024 × 1024 | 缩放 | 3.70 ms | 0% | 0.40 ms | 0.30 ms | 2.40 ms | 4.00 ms | +| 1024 × 1024 | 旋转后缩放 | 3.70 ms | 0% | 0.50 ms | 0.10 ms | 1.30 ms | 4.50 ms | +| 1024 × 1024 | 连续绘制 | 3.70 ms | 0.141% | 1.30 ms | 0.50 ms | 10.90 ms | 85.60 ms | + +所有场景帧 `p95` 均低于 16.7 ms,卡顿帧比例最高为 0.141%,主绘制 `p95` 不超过 1.30 ms,指针处理 `p95` 不超过 0.50 ms。连续绘制各有一次 50 ms 以上环境长任务,但帧分位数、主绘制和输入路径没有同步持续升高,不判定为稳定退化。最终主入口为 711.01 kB、gzip 215.84 kB,较上一条完整记录 701.93 kB / 213.05 kB 分别增加 1.29% / 1.31%,低于 5% 门禁。313% 等非整数倍率下空闲与绘制使用相同合成路径由 `canvas-composite-cache.test.ts` 覆盖;弹窗外侧把手、图层组拖出栏目顶部和自由套索非等长路径已通过 Windows WebView2 实机验证,313% 连续绘制的像素稳定性仍需用户在实际作品中观察。 + 这些数据用于估算每次完整验证成本,不作为用户运行时性能结论。 | 日期 | 代码基线 | TypeScript | 291 项测试 | Web 构建 | Rust check | 结论 | diff --git a/docs/testing/regression-matrix.md b/docs/testing/regression-matrix.md index c421565..f4d67b5 100644 --- a/docs/testing/regression-matrix.md +++ b/docs/testing/regression-matrix.md @@ -86,8 +86,13 @@ | 撤销 | 滚轮缩放预览尚未提交时立即执行 Undo/Redo | 先同步提交当前视图预览,再撤销或重做文档操作;缩放、平移和旋转均保持不变 | `workspace.test.ts`、`view-preview-lifecycle.test.ts` | | 文件 | 将 `.moonsprite` 从资源管理器拖入软件 | 普通 Windows 路径和 `file://` 路径统一规范化、去重并打开工程,不被重复拖入事件吞掉 | `document-drop.test.ts`,待补桌面 E2E | | 文件 | 从资源管理器拖入 `.moonsprite`、`.ase`、`.aseprite` 或受支持图片 | Tauri 官方窗口拖放事件作为主入口,Drop 路径只提交一次并直接进入编辑器 | `document-drop-events.test.ts`、`document-drop.test.ts`,待补桌面 E2E | +| 文件 | 拖入监听中有单个通道订阅失败、组件重挂载或重复上报 | HTML、Webview、Window 与 Rust 通道互为兜底;任一通道可独立工作,重复路径只打开一次,清理后可重新订阅 | `document-drop-service.test.ts`、待补桌面 E2E | | 预览 | 在预览窗口内将鼠标停在作品局部并滚轮缩放 | 缩放前后的同一作品像素保持在鼠标下,不再固定以预览中心放大 | `preview-geometry.test.ts` | | 指针 | 拖动选区的缩放或旋转变换 | 按下拖拽期间统一显示移动指针,松开后恢复命中区域对应指针 | `canvas-visuals.test.ts`,待补桌面场景 | +| 变换 | 已通过 `Ctrl` 复制出的浮动选区再次按住 `Ctrl` 拖动 | 先提交当前副本,再从当前位置创建新副本;可连续重复,原内容与此前副本保持不变 | `canvas-input.test.ts`、Store 测试 | +| 输入 | 当前表面支持 `Delete` 删除时按 `Backspace` | 非文本输入状态下执行相同的选区、图层、图层组或调色板删除;文本输入仍正常退格 | `command-context.test.ts`、Store 测试 | +| 工具栏 | 从子工具弹出栏选择选区或形状工具 | 主工具按钮在同一交互帧切换到对应图标、名称和说明;所有图标在工具栏挂载时已解码 | `EditorToolRail.test.tsx` | +| 组件库 | 首选项预设和图层颜色预设的删除操作 | 使用组件库统一删除图标按钮;默认、悬浮和禁用状态一致,图层颜色按钮与删除按钮等高 | `DeleteIconButton.test.tsx`、`PreferencesDialog.test.tsx` | | 菜单 | 点击打开任一顶部菜单后横向移动鼠标 | 无需再次点击即可切换并展开鼠标当前所在的其他顶部菜单 | `menu-behavior.test.ts` | | 菜单 | 打开“文件 > 最近打开文件” | 子栏列出全部保留的最近工程与图片,名称始终包含真实后缀,点击后使用统一文件打开路径 | `home-history.test.ts`,待补 UI | | 视图 | 从“窗口 > 旋转视图”执行 90° 旋转 | 顺时针与逆时针分别改变视图 90°,不切换工具、不写入文档历史,且可绑定或清空快捷键 | `shortcuts.test.ts`,待补 UI | @@ -106,6 +111,106 @@ | 性能 | 128、512、1024 画布平移、缩放、旋转后缩放和连续绘制 | 固定输入下输出帧分位数、>25ms 比例、主绘制与指针处理耗时;视图操作不进入撤销历史 | `scripts/canvas-performance.mjs`、`canvas-render-plan.test.ts`、`canvas-composite-cache.test.ts` | | 性能工具 | 统一数值输入框支持表达式后运行完整画布基准 | 按“画布宽度/画布高度”的稳定可访问名称填写尺寸,不依赖输入框内部的 `type` 实现 | `pnpm bench:canvas -- --full` | | 安装 | 全新用户安装与首次启动 | 无黑屏、无重复弹窗 | 发布前手动;桌面脚本使用隔离应用数据验证启动路径 | +| 首页 | 画廊工程读取失败后点击项目 | 只重试打开并保留磁盘文件;移除记录与删除文件必须是两个明确操作,删除文件必须二次确认 | `HomeWorkspace.test.tsx` | +| 首页 | 最近和画廊包含多个大型工程 | 文件名列表立即出现,缩略图最多 3 项并发加载并按路径与时间复用缓存;单项失败不阻塞其他项目 | `HomeWorkspace.test.tsx` | +| 弹窗 | 调整实时预览或图层属性修改后按 `Esc` | 调整弹窗取消时恢复打开前状态;图层属性始终实时保存,关闭或 `Esc` 只隐藏属性面板 | `AdjustmentDialog.test.tsx`、`LayersPanel.test.tsx` | +| 删除 | 画布仍有选区时在图层或调色板按 `Delete` | 根据最后操作区域删除图层、完整图层组、颜色或选区内容,不受遗留选区干扰;拒绝删除最后一层时不产生 dirty 或历史 | `command-context.test.ts`、`workspace.test.ts` | +| 首选项 | 点击恢复所有初始设置 | 二次确认后只清除软件本地配置,不删除工程、笔刷、色板、工作区或其他用户文件 | `PreferencesDialog.test.tsx` | +| 保存 | 首选项修改默认保存格式后打开另存为 | 弹窗默认选中配置格式;图片格式明确提示不保留工程结构 | `SaveAsDialog.test.tsx` | +| 笔刷 | 切换三种自定义笔刷模式 | 来源对齐、目标对齐和独立盖章的说明、原始多色预览与实际绘制语义一致,并使用统一下拉组件 | `tools.test.ts`、工具属性栏组件检查 | +| 剪贴板 | 在图层栏目复制单层、多层或图层组并跨项目粘贴 | 保留顺序、偏移、可见性、锁定、透明度、混合模式、显示颜色、描述和嵌套组结构;一次撤销删除整批粘贴内容 | `clipboard-service.test.ts`、`workspace.test.ts` | +| 图层 | 空组、非空组、普通层和嵌套组混合排序并跨层拖拽 | 所有组遵守持久锚点并可与图层自由排序;新建项置顶;插入线与最终目标层级左侧对齐;拖出栏目顶部或底部分别显示并落到首尾 | `layer-panel-layout.test.ts`、`layer-operations.test.ts`、`workspace.test.ts` | +| 图层 | 在眼睛、锁或组文件夹交互区单击或双击 | 只执行对应显示、锁定或折叠操作,不启动拖动、不打开图层属性 | `LayersPanel.test.tsx` | +| 图层 | 依次点击首层与末层并使用 `Ctrl` 或 `Shift` | `Ctrl` 离散增减选;`Shift` 从选择锚点到目标按可见图层行连续范围选择,行为与 Photoshop 一致 | `workspace.test.ts`、`LayersPanel.test.tsx` | +| 选区 | 已有非矩形选区时命中移动选框 | 只在真实蚂蚁线边界附近进入移动选框,不按包围矩形的空白边命中 | `canvas-input.test.ts` | +| 选区 | 套索预览闭合偏好与多边形套索 | 自由套索按偏好显示闭合预览;多边形套索逐点建立边界并可闭合、取消 | `tools.test.ts`、`PreferencesDialog.test.tsx`,待补桌面场景 | +| 选区 | 已有选区内按住 `Shift` 开始新选区,或使用右键拖动 | `Shift` 绕过变换命中并执行组合选择;右键默认减选,不被内容移动或变换抢占 | `canvas-input.test.ts`,待补桌面场景 | +| 形状 | 绘制实心/空心矩形和椭圆并设置固定比例 | 空心模式只绘制边界;固定比例时拖动结果始终符合长短轴比例 | `tools.test.ts`、`canvas-input.test.ts` | +| 调整 | 调整弹窗打开期间修改选区或多选图层 | 有选区时动态作用选区;无选区时动态作用全部选中图层;目标变化后实时预览重新基于原始数据计算 | `workspace.test.ts`、`AdjustmentDialog.test.tsx` | +| 输入 | 使用 `Tab` 切换数值输入并提交四则表达式 | 新输入框内容自动全选;表达式计算结果立即提交并触发实际修改 | `form-focus.test.ts`、`NumberInput.test.tsx` | +| 移动 | 自动选择图层开启时从透明位置拖动 | 命中其他图层内容时移动该层;透明位置回退移动当前选中图层,多选时整体移动 | `workspace.test.ts`、`canvas-input.test.ts`,待补桌面场景 | +| 弹窗 | 打开状态提示、调整、画布大小和图像大小弹窗 | 状态提示位于视图上方;调整类弹窗位于视图中心与右侧边缘之间的中点;弹窗默认紧凑、可调整大小并记忆用户尺寸 | `ModalShell.test.tsx`、各弹窗组件测试,待补桌面视觉检查 | +| 剪贴板 | 从“编辑 > 选择性粘贴”执行新项目或新图层 | 新项目创建独立文档;新图层使用 `Ctrl+Shift+V`,原坐标在当前可见视图内时保持原位,否则居中到可见视图 | `workspace.test.ts`、菜单组件测试 | +| 选区 | 自由套索或多边形套索连续输入并在已有选区内部按住 `Shift` | 输入过程不阻塞界面;内部加选实时显示组合预览,提交后结果与预览一致 | `canvas-input.test.ts`、`tools.test.ts` | +| 形状 | 开启固定比例并输入两个比例值 | 参数显示为“固定比例”,两个数值共同定义宽高比,矩形与椭圆预览和提交都遵守比例 | `tools.test.ts`、工具属性栏组件测试 | +| 弹窗 | 打开、拖动和八向缩放不同内容量的弹窗 | 默认位于视图中心与最右侧之间的中点;把手使用边框外侧命中且不被弹窗外壳裁掉,事件不得穿透到画布;尺寸限制在各弹窗合理最小/最大范围,内部只有必要的内容滚动条 | `ModalShell.test.tsx`、1024 × 640 与 1920 × 1080 视觉检查 | +| 渲染 | 在 313% 等非整数缩放下连续落笔 | 提交像素后画布 CSS 几何、取样原点和已绘制像素保持稳定,不出现局部像素抖动 | `canvas-render-plan.test.ts`、`canvas-visuals.test.ts`,待补桌面场景 | +| 弹窗 | 首次或升级后打开画布大小、图像大小和编辑调整弹窗 | 弹窗中心落在视图中心与视图右边缘之间的中点;八向缩放至允许最小尺寸时外壳和正文均不出现横向或纵向滚动条 | `ModalShell.test.tsx`、1024 x 640 与 1920 x 1080 视觉检查 | +| 偏好 | 首次打开“首选项 > 编辑” | 显示“套索选区预览闭合”且默认关闭;“吸管取色后切回铅笔”默认关闭并可持久化 | `file-preferences.test.ts`、`PreferencesDialog.test.tsx` | +| 图层 | 多选图层后点击图层栏目空白 | 清除多选和组选择,但保留当前活动图层以便继续编辑 | `LayersPanel.test.tsx`、`workspace.test.ts` | +| 图层 | 把图层拖入组、拖到栏目顶部或底部 | 蓝色落点线按最终父组层级缩进;顶部和底部安全带分别落到根层首位和末位,最上方为组时仍可放到组上方 | `layer-panel-layout.test.ts`、`LayersPanel.test.tsx` | +| 图层 | 选中空组后按 `Delete`,或尝试编辑、拖动、删除锁定层与锁定组 | 空组可作为一次历史删除;锁定对象及锁定组后代拒绝内容修改、结构移动和删除,同时保留解锁入口 | `workspace.test.ts`、`LayersPanel.test.tsx` | +| 快捷键 | 依次按住多个修饰键和普通键录入组合 | 修饰键按下期间持续显示完整组合,普通键按下后一次保存;单独修饰键在松开或失焦时保存 | `shortcuts.test.ts`、`ShortcutDialog` 组件场景 | +| 选区 | 新建模式下在已有选区外单击或在同一文档像素内拖动 | 单击取消选区;发生实际 CSS 像素拖动时建立 1 像素选区,不因起止文档坐标相同而误判为单击 | `canvas-input.test.ts`、待补桌面场景 | +| 选区 | 在已有选区内部使用加选、减选或交集 | 组合选择不被内容移动或变换命中抢占;套索拖动期间仍显示实际作用像素预览 | `canvas-input.test.ts`、`tools.test.ts` | +| 移动 | 存在选区时用移动工具移动单层或多层 | 图层内容和选区按同一位移同步移动,并由一次撤销完整恢复 | `workspace.test.ts`、待补桌面场景 | +| 形状 | 固定比例开启后点击交换按钮 | 宽高比例值立即互换,预览和后续绘制使用交换后的比例 | 工具属性栏组件测试、`canvas-input.test.ts` | +| 调整 | 色相/饱和度中调整“明度” | 使用 HSL 明度独立调整;`+100` 趋向白色、`-100` 趋向黑色,不与亮度/对比度的 RGB 亮度混用 | `adjustments.test.ts`、`AdjustmentDialog.test.tsx` | +| 颜色 | 打开前景色或背景色编辑弹窗后拖动标题栏并关闭 | 弹窗可在视口内拖动,右上角关闭按钮立即关闭;重新打开时从对应颜色按钮附近重新定位 | `ColorValueControl` 组件场景 | +| 渲染 | Windows 4K 与非整数 DPR 下显示画笔、橡皮擦和选区预览 | 所有相邻预览像素共享设备像素边界,内部不出现白线、描边或接缝 | `canvas-visuals.test.ts`、DPR 1.25/1.5/2 桌面检查 | +| 渲染 | 半透明图层参与合成时在上层跨越 128px tile 边界绘制 | gutter 仅参与缓存,不重复合成到主画布;绘制中和提交后均无横竖区块边缘闪烁 | `canvas-composite-cache.test.ts`、完整画布性能场景 | +| 提示 | 普通模式和高级模式显示状态提示 | 提示位于工具属性栏下方的画布视图上部,不覆盖顶部属性栏 | 1024 x 640 与 1920 x 1080 视觉检查 | +| 指针 | 选区工具悬停在任意文档像素 | 当前像素格四角显示紧凑直角角标,角标随缩放和视图变换贴合该像素,不与变换指针叠加 | `canvas-visuals.test.ts`、待补桌面场景 | +| 选区 | 新建模式在画布内外单击,或在同一像素格内真实拖动 | 单击只取消已有选区且松开后保持选区指针;没有选区时单击为空操作;画布外不夹取到边缘;超过 CSS 阈值的同格拖动建立 1 像素选区 | `canvas-input.test.ts`、待补桌面场景 | +| 选区 | 已有选区内使用减选或交集 | 保持选区角标指针和像素预览,不出现移动、缩放或旋转指针 | `canvas-input.test.ts`、`canvas-visuals.test.ts` | +| 选区 | 执行反选和隐藏蚂蚁线 | 反选得到画布范围内当前选区的补集并可撤销;`Ctrl+H` 只切换蚂蚁线显示,不删除选区、不进入文档历史 | `workspace.test.ts`、`shortcuts.test.ts` | +| 提示 | 状态提示出现在编辑器中 | 提示与顶部工具属性栏之间保留明确间距,不覆盖属性栏 | 1024 x 640 与 1920 x 1080 视觉检查 | +| 颜色 | 同时打开前景色和背景色编辑器 | 两个独立弹窗均可常驻、拖动和关闭,标题与关闭图标对齐;修改各自颜色互不关闭或覆盖另一个实例 | `ColorValueControl.test.tsx`、待补视觉检查 | +| 调整 | 连续拖动调整弹窗滑块 | 实时预览每帧最多重算一次,拖动保持连贯;松开或应用时使用最后数值,撤销仍只有一个条目 | `AdjustmentDialog.test.tsx`、定向交互性能检查 | +| 图层 | 把任意图层或组拖到栏目顶部 | 顶部蓝线显示并落在根层首位;首项是组时仍可放在组上方;拖动结束后蓝线立即清除 | `layer-panel-layout.test.ts`、`LayersPanel.test.tsx` | +| 图层 | 没有选中图层时新建组 | 创建空组,不自动收纳最上方图层 | `workspace.test.ts` | +| 图层 | 在组或组内图层选中时新建图层 | 新图层创建在该组内并位于该容器顶部 | `workspace.test.ts` | +| 图层 | 子层或子组锁定后删除祖先组 | 拒绝删除包含任意锁定后代的祖先组;解锁后才允许删除 | `workspace.test.ts`、`layer-operations.test.ts` | +| 图层 | 祖先组锁定后尝试操作后代锁图标 | 所有后代有效锁定且不能单独解锁;悬浮提示明确显示锁定祖先组名称 | `LayersPanel.test.tsx`、`workspace.test.ts` | +| 画布大小 | 半透明图层存在时打开调整画布大小预览 | 预览合成保持图层原透明度,与打开前画面一致,不因预览缓存重复合成而变亮 | `canvas-composite-cache.test.ts`、待补桌面场景 | +| 快捷键 | 录入 `Ctrl+Shift+V` 等被系统或其他软件注册的组合 | 弹窗在捕获阶段阻止浏览器和应用命令,只记录完整组合,不被全局快捷键处理截断为修饰键 | `ShortcutDialog.test.tsx` | +| 颜色 | 在项目 A 修改前景色或背景色后切换项目 B | 所有打开工程立即使用同一前景色和背景色;旧工程颜色字段仍可兼容读取但不再造成切换回退 | `workspace.test.ts`、调色盘组件检查 | +| 选区描边 | 在描边弹窗按下 `Enter` | 只提交一次描边并立即关闭弹窗,不被全局快捷键或输入控件吞掉 | `OutlineDialog.test.tsx` | +| 选区 | 框选拖到画布视口边缘,或隐藏蚂蚁线 | 拖动期间视图连续自动平移且不进入文档历史;隐藏后蚂蚁线和八个变换点全部消失,选区本身仍有效 | `canvas-input.test.ts`、`canvas-visuals.test.ts`、待补桌面场景 | +| 选区 | 首次移动选区内容后继续往返移动 | 首次移动建立稳定源缓存,背景只恢复一次,后续位移不会把预览结果重新采入缓存 | `tools.test.ts`、待补桌面场景 | +| 填充 | 非矩形选区内使用连续或不连续油漆桶 | 只修改选区 mask 内像素,并正确处理图层偏移和透明区域 | `tools.test.ts` | +| 选区 | 已有选区时使用加选、减选或交集模式 | 加选模式在选区内部可移动内容;减选和交集仍执行选区运算;各模式不会整体丢失变换与预览能力 | `canvas-input.test.ts`、待补桌面场景 | +| 指针 | 使用框选工具悬停画布像素并切换十字指针偏好 | 默认只显示四角角标;开启偏好后叠加十字,设置可持久化 | `file-preferences.test.ts`、`canvas-visuals.test.ts` | +| 颜色弹窗 | 打开前景色或背景色编辑器并在标题栏拖动、缩放 | 标题明确显示颜色角色;未移动时失焦关闭,发生真实移动后常驻;八向缩放、尺寸记忆和内部高度自适应与统一弹窗一致 | `ColorValueControl.test.tsx`、待补视觉检查 | +| 图层 | 复制嵌套图层组后选中另一对象并粘贴 | 粘贴组位于当前对象的父级并与其同层,复制组内部的子组和图层层级保持不变 | `clipboard-service.test.ts`、`workspace.test.ts` | +| 图层 | 多选后普通单击任意图层或组 | 清除原多选并只选择被点击项;`Ctrl` 和 `Shift` 的多选规则保持不变 | `workspace.test.ts`、`LayersPanel.test.tsx` | +| 图层属性 | 编辑已锁定图层或图层组 | 名称、显示颜色和描述可实时保存;混合模式和不透明度禁用且 Store 拒绝绕过修改 | `workspace.test.ts`、`LayersPanel.test.tsx` | +| 图层 | 最末项为图层组时把根图层拖到栏目底部 | 底部安全带显示根层蓝线并把图层放到组及其成员之后;组与图层共用一致的根层边缘规则 | `layer-operations.test.ts`、`LayersPanel.test.tsx` | +| 选区指针 | 新建/加选时按住 `Shift`,或悬停变换命中区 | 默认只显示当前像素四角角标;偏好开启后显示鼠标十字指针;变换命中区不叠加角标 | `canvas-visuals.test.ts`、待补桌面场景 | +| 选区轮廓 | `Ctrl+H` 隐藏选区轮廓 | 只隐藏蚂蚁线,八个变换点继续显示并可命中 | 待补渲染器测试 | +| 选区描边 | 描边弹窗内按 `Enter` | 描边写入活动图层并生成历史记录,然后弹窗关闭 | `OutlineDialog.test.tsx` | +| 选区浮动 | 同一选区内容连续移动到已有像素上再移开 | 从第一次移动起复用原始内容和背景快照,目标背景在后续移动时完整恢复,直到确认或取消选区 | `tools.test.ts`、`workspace.test.ts` | +| 画布合成 | 非整数缩放、半透明图层和连续绘制 | 最终画面通过单张连续表面输出,不出现 128 px 分块边界或重复透明度 | `canvas-composite-cache.test.ts`、待补 4K 桌面场景 | +| 颜色弹窗 | 缩放颜色编辑器、关闭后重新打开 | 通道滑条与数值框等高;尺寸受美观上下限约束;每次打开恢复触发按钮附近的默认位置 | `ColorValueControl.test.tsx` | +| 图层组粘贴 | 复制组后选中另一对象并粘贴 | 粘贴组位于当前选中对象的父级,与当前对象同层;内部嵌套结构保持 | `workspace.test.ts` | +| 图层组锁定 | 查看未锁定与锁定组 | 未锁定显示解锁图标,锁定或继承锁定显示锁定图标 | `LayersPanel.test.tsx` | +| 选区指针 | 在任意缩放和高 DPI 下悬停画布内外 | 四角角标使用固定 CSS 线宽,视觉范围不小于 `8 x 8 px`;画布外仍显示四角角标而不是禁止指针 | `canvas-visuals.test.ts`、待补桌面场景 | +| 选区描边 | 描边弹窗打开时按 `Enter` | 键盘事件只提交描边弹窗,不触发全局“完成选区”,描边写入后选区保持有效 | `command-context.test.ts`、`OutlineDialog.test.tsx` | +| 图层多选 | 从图层行按 `Shift` 范围选择到图层组,随后按 `Delete` | 范围内图层与组都显示选中;组及后代和直接选中图层一次删除、一次撤销恢复 | `workspace.test.ts`、`LayersPanel.test.tsx` | +| 图层粘贴 | 分别粘贴图层组、单图层到组/图层/空白选择 | 组粘贴到当前对象父级;单图层按组内、图层同级、根层规则放置;所有新对象位于目标层级最顶部 | `workspace.test.ts` | +| 多边形套索 | 连续单击顶点后点击起点、双击末点、按 `Enter` 或 `Esc` | 点击起点、双击或 `Enter` 闭合并提交,`Esc` 取消;移动指针时末段预览实时跟随 | `canvas-input.test.ts`、待补桌面场景 | +| 直线算法 | 开启“直线算法优化”后以 `Shift` 连接两点或绘制多边形套索边界 | 主轴像素被平均分配到各阶梯,最长与最短阶梯相差不超过 1;画笔、多边形套索的预览与最终结果使用同一点列 | `pixel-line.test.ts`、`canvas-input.test.ts`、`tools.test.ts`、`file-preferences.test.ts` | +| 选区浮动 | 移动选区内容后,在未确认的当前位置按住 `Ctrl` 再次拖动 | 先确认上一段移动,再以当前位置为来源创建副本并移动;原位置内容保留,复制拖动可继续调整 | `canvas-input.test.ts`、`workspace.test.ts` | +| 选区描边 | 通过 `Shift+O` 打开描边且焦点仍在画布时按 `Enter` | 描边弹窗接管按键并应用一次,选区保持;成功使用的颜色、宽度、位置、方向、形状和预览开关随工程保存 | `OutlineDialog.test.tsx`、`project-format.test.ts` | +| 多边形套索 | 逐点单击任意斜向顶点并闭合 | 已确认顶点之间显示真实直线,不生成水平/垂直矩形折线;点击起点、双击或 `Enter` 均可正常闭合 | `canvas-input.test.ts` | +| 图层分组 | 多选不连续图层后新建组 | 新组占据所选图层中最上方图层的原显示位置,组内保持原图层顺序,未选图层相对顺序不变 | `layer-operations.test.ts` | +| 图层组粘贴 | 粘贴包含嵌套组和多个图层的集合 | 所有新组和新图层同时显示选中,撤销恢复原选择,重做恢复完整粘贴选择 | `workspace.test.ts`、`LayersPanel.test.tsx` | +| 选区创建 | 从画布内容边界外按住并拖入画布 | 拖动期间只在有效画布内显示像素路径,不在画布外绘制预览;松开前不显示新选区蚂蚁线,最终掩码限制在有效画布 | `canvas-visuals.test.ts`、待补桌面场景 | +| 选区创建 | 使用矩形或椭圆框选并按住 `Shift` 拖动 | 预览和提交均保持等宽高,不因选区模式或拖动方向失效 | `canvas-input.test.ts`、待补桌面场景 | +| 多边形套索 | 连续建立至少三个顶点后双击 | 无论浏览器 `PointerEvent.detail` 是否可靠,双击均闭合并提交;开启直线算法优化时所有边使用均匀阶梯 | `canvas-input.test.ts`、待补桌面场景 | +| 工具栏 | 展开选区工具与形状工具 | 选区五项和形状四项始终单行、顺序与命名固定;悬浮提示包含名称、简短用法和当前快捷键 | `editor-tools.test.ts`、待补 UI | +| 形状比例 | 输入超过一位小数的固定宽高比例 | 保存到工具状态的宽高比例均四舍五入到一位小数,界面不重新出现冗长小数 | `workspace.test.ts` | +| 预览 | 大尺寸透明画布缩小到预览栏目 | 棋盘格与主画布一样按最终显示比例绘制,格子过小时退化为统一浅色,不把高频棋盘纹理预合成进缩略缓存 | `preview-geometry.test.ts` | +| 多边形套索 | 未闭合时按中键或 `Space` 拖动画布 | 平移只改变视图;已建立的顶点、组合模式和原选区保持,松开后继续建立多边形 | `canvas-input.test.ts` | +| 选区创建 | 已有选区时按 `Shift` 加选或右键减选 | 按下和拖动期间旧蚂蚁线持续显示;新路径松开后才合并显示,未拖动的单击只在新建模式取消选区 | `canvas-input.test.ts`、待补桌面场景 | +| 工具栏 | 悬停任意主工具、选区子工具或形状子工具 | 自定义提示统一显示工具名称、简短用法和用户当前快捷键;形状工具使用指针源工程第 5 行第 3 至 6 列图标 | `editor-tools.test.ts`、待补 1024 x 640 视觉检查 | +| 图层属性 | 在首选项中新增、修改、删除或恢复显示颜色预设 | 预设限制为 1 至 12 个有效且不重复的 RGB 色,应用后所有工程的图层属性弹窗实时使用同一组预设 | `file-preferences.test.ts`、`PreferencesDialog.test.tsx` | + +| 工具栏 | 切换选区或形状子工具后查看主按钮与顶部属性栏 | 主按钮立即显示当前子工具图标,名称、说明和快捷键均来自当前子工具;所有子工具图标在弹出菜单首次打开前已预解码 | `editor-tools.test.ts`、待补 UI 场景 | +| 多边形套索 | 未闭合多边形时按中键或 `Space` 平移画布 | 平移期间与松开后都持续显示已建立边和指针末段,平移只改变视图且不清空顶点 | `canvas-input.test.ts`、待补桌面场景 | +| 选区模式 | 选区工具中按住 `Shift` 或鼠标右键 | 属性栏临时跳转到加选或减选反馈,松开后恢复持久模式;实际组合规则与可见反馈一致 | 待补 UI 场景 | +| 文件拖入 | 将 `.moonsprite`、`.ase`、`.aseprite` 或图片拖入桌面窗口 | Webview 与 Window 原生拖放通道任一可用即打开文件,单一通道订阅失败不影响另一通道 | `document-drop-events.test.ts`、`document-drop.test.ts` | +| 光标首选项 | 切换“使用本地指针”并进入旋转等特殊交互 | 开启时普通场景使用系统指针、系统没有对应指针时回退 MoonSprite 指针;关闭时全部使用 MoonSprite 指针 | `cursor-theme.test.ts`、`PreferencesDialog.test.tsx` | ## 新增规则 diff --git a/package.json b/package.json index afc8071..16c6e1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moonsprite", - "version": "0.1.0-dev.1", + "version": "0.1.0-dev.2", "description": "An original, open-source pixel art workstation for Windows.", "author": "MoonSprite contributors", "license": "MIT", diff --git a/scripts/desktop-regression.mjs b/scripts/desktop-regression.mjs index e622bec..c9f713c 100644 --- a/scripts/desktop-regression.mjs +++ b/scripts/desktop-regression.mjs @@ -76,9 +76,9 @@ try { }, label) } - await selectTool('B', '铅笔') - await selectTool('V', '移动') - await selectTool('R', '旋转视图') + await selectTool('B', '铅笔工具') + await selectTool('V', '移动工具') + await selectTool('R', '旋转视图工具') const rotationInput = page.locator('input[aria-label="旋转度数"]') await rotationInput.waitFor({ state: 'visible' }) @@ -113,10 +113,10 @@ try { assert(await relativeLuminanceItem.isEnabled(), 'Relative luminance menu command is unexpectedly disabled.') await windowMenuButton.click() - const selectionToolButton = page.locator('.tool-rail button[aria-label="选区"]') + const selectionToolButton = page.locator('.tool-rail button[aria-label="矩形框选工具"]') await selectionToolButton.click() await page.locator('.selection-flyout[aria-label="选择选区方式"]').waitFor({ state: 'visible' }) - assert(await page.locator('.selection-flyout button').count() === 4, 'Selection tool menu is incomplete.') + assert(await page.locator('.selection-flyout button').count() === 5, 'Selection tool menu is incomplete.') console.log('MoonSprite desktop regression test passed.') } finally { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0f680a4..05a1b10 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2116,7 +2116,7 @@ dependencies = [ [[package]] name = "moonsprite" -version = "0.1.0-dev.1" +version = "0.1.0-dev.2" dependencies = [ "arboard", "rfd", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7fcd59a..95948a1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moonsprite" -version = "0.1.0-dev.1" +version = "0.1.0-dev.2" description = "An original, open-source pixel art workstation for Windows." authors = ["MoonSprite contributors"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e7c6695..6791bf7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "MoonSprite", - "version": "0.1.0-dev.1", + "version": "0.1.0-dev.2", "identifier": "art.moonpx.moonsprite", "build": { "beforeDevCommand": "pnpm dev:web", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 962b0d3..19dce07 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,7 +1,6 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Check, ChevronRight, ExternalLink, FileOutput, FolderOpen, GitFork, Info, LayoutTemplate, Plus, Save, Trash2, X } from 'lucide-react' -import { listen } from '@tauri-apps/api/event' import { PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi' import { availableMonitors, getCurrentWindow } from '@tauri-apps/api/window' import type { ColorMode, ImageResizeInterpolation, StoredWorkspace, WorkspaceLayout } from '@shared/types' @@ -26,12 +25,12 @@ import { PreferencesDialog } from '@/components/dialogs/PreferencesDialog' import { SaveAsDialog } from '@/components/dialogs/SaveAsDialog' import { ShortcutDialog } from '@/components/dialogs/ShortcutDialog' import { NumberInput } from '@/components/NumberInput' +import { ModalShell } from '@/components/ModalShell' import { ThemedSelect } from '@/components/ThemedSelect' -import { isPaletteDeleteKey } from '@/core/palette-layout' +import { resolveCopyCommand, resolveDeleteCommand, shouldHandleGlobalSelectionEnter, shouldTriggerDeleteCommand, type EditorCommandScope } from '@/core/command-context' import { formatBytes } from '@/core/resource-policy' import { adjacentFormInput } from '@/core/form-focus' -import { normalizeDroppedDocumentPaths } from '@/core/document-drop' -import { subscribeToNativeDocumentDrops } from '@/platform/document-drop-events' +import { startDocumentDropService } from '@/platform/document-drop-service' import { APP_CHANNEL_LABEL } from '@/core/app-meta' import { getRecentProjects, type RecentProject } from '@/core/home-history' import { EXPORT_FORMAT_PREFERENCE_KEY, EXPORT_SCALE_PRESETS_KEY, NEW_DOCUMENT_SIZE_PRESETS_KEY, RELATIVE_LUMINANCE_SCOPE_KEY, SAVE_FORMAT_PREFERENCE_KEY, imageExportKindForPreference, loadEditorPreferences, parseDocumentSizePresets, parseExportScalePresets, parseRelativeLuminanceScope, type RelativeLuminanceScope } from '@/core/file-preferences' @@ -39,7 +38,7 @@ import { DEFAULT_SHORTCUTS, deriveShortcutConflicts, keyboardEventKey, loadShort import { readStoredJson, readStoredString, writeStoredJson, writeStoredString } from '@/core/storage' import { applyCursorPreferences } from '@/platform/cursor-theme' import { ACTIVE_WORKSPACE_STORAGE_KEY, BOTTOM_DOCK_HEIGHT_STORAGE_KEY, COLOR_SQUARE_ANCHOR_STORAGE_KEY, COLOR_SQUARE_DOCK_STORAGE_KEY, DEFAULT_PANEL_DOCKS, FLOATING_PANEL_STORAGE_KEYS, INSPECTOR_LAYOUT_STORAGE_KEY, INSPECTOR_WIDTH_STORAGE_KEY, LEFT_DOCK_WIDTH_STORAGE_KEY, PANEL_DOCKS_STORAGE_KEY, TOOL_RAIL_SIDE_STORAGE_KEY, loadBottomDockHeight, loadInspectorWidth, loadLeftDockWidth, loadMainWindowState, loadPanelDocks, loadPanelVisibility, loadToolRailSide, normalizeWorkspaceLayout, readLayoutStorage, saveMainWindowState, savePanelDocks, savePanelVisibility, writeLayoutStorage } from '@/core/workspace-layout-preferences' -import { type ExportOptions, useWorkspace } from '@/store/workspace' +import { type ExportOptions, type SaveAsOptions, useWorkspace } from '@/store/workspace' import './styles.css' const LazyHomeWorkspace = lazy(() => import('@/components/HomeWorkspace').then(({ HomeWorkspace }) => ({ default: HomeWorkspace }))) @@ -52,6 +51,12 @@ const presetStorageKey = 'moonsprite.export-presets.v1' type ToolRailSide = 'left' | 'right' type AdvancedMode = 'tool-options' | 'canvas-only' +const saveAsFormatForPreference = (value: string | null): SaveAsOptions['format'] => { + if (value === 'ase' || value === 'aseprite' || value === 'jpeg' || value === 'webp') return value + if (value === 'png') return 'png-auto' + return 'moonsprite' +} + const defaultPanelDocks: Record = { ...DEFAULT_PANEL_DOCKS } const defaultInspectorLayout = JSON.stringify({ order: ['palette', 'color', 'layers', 'preview'], @@ -183,6 +188,7 @@ export default function App() { const workspaceApplyInProgress = useRef(false) const workspaceAutoSaveTimer = useRef(null) const workspaceAutoSaveQueue = useRef>(Promise.resolve()) + const commandScopeRef = useRef('canvas') const [workspaceLayoutChange, setWorkspaceLayoutChange] = useState(0) const workAreaRef = useRef(null) const inspectorWidthRef = useRef(inspectorWidth) @@ -211,6 +217,19 @@ export default function App() { // The CSS defaults remain usable when a custom cursor image cannot be decoded. }) }, [runtimePreferences.cursorScale, runtimePreferences.useLocalCursors]) + useEffect(() => { + const rememberCommandScope = (event: Event): void => { + const surface = (event.target as HTMLElement | null)?.closest('[data-command-scope], .stage-surface') + const scope = surface?.classList.contains('stage-surface') ? 'canvas' : surface?.dataset.commandScope + if (scope === 'canvas' || scope === 'layers' || scope === 'palette') commandScopeRef.current = scope + } + window.addEventListener('pointerdown', rememberCommandScope, true) + window.addEventListener('focusin', rememberCommandScope, true) + return () => { + window.removeEventListener('pointerdown', rememberCommandScope, true) + window.removeEventListener('focusin', rememberCommandScope, true) + } + }, []) useEffect(() => { const enabled = Boolean(session?.view.relativeLuminance && relativeLuminanceScope === 'app') document.body.classList.toggle('relative-luminance-app', enabled) @@ -672,44 +691,11 @@ export default function App() { }, []) useEffect(() => { - let active = true - let removeNativeListener: (() => void) | null = null - let removeWindowDropListener: (() => void) | null = null - const recentlyOpened = new Set() - const openDroppedPaths = (paths: string[]): void => { - for (const path of normalizeDroppedDocumentPaths(paths)) { - const key = path.toLowerCase() - if (recentlyOpened.has(key)) continue - recentlyOpened.add(key) - window.setTimeout(() => recentlyOpened.delete(key), 1_000) - void useWorkspace.getState().openPath(path).then((opened) => { - if (active && opened) setHomeOpen(false) - }) - } - } - const dragOver = (event: DragEvent): void => event.preventDefault() - const drop = (event: DragEvent): void => { - event.preventDefault() - openDroppedPaths([...event.dataTransfer?.files ?? []].map((file) => window.moonSprite.pathForFile(file)).filter(Boolean)) - } - window.addEventListener('dragover', dragOver, true) - window.addEventListener('drop', drop, true) - if ('__TAURI_INTERNALS__' in window) { - void subscribeToNativeDocumentDrops(getCurrentWindow(), openDroppedPaths).then((remove) => { - removeWindowDropListener = remove - if (!active) remove() - }).catch(() => {}) - void listen('app:file-drop', (event) => { - if (active) openDroppedPaths(event.payload) - }).then((remove) => { removeNativeListener = remove; if (!active) remove() }) - } - return () => { - active = false - removeNativeListener?.() - removeWindowDropListener?.() - window.removeEventListener('dragover', dragOver, true) - window.removeEventListener('drop', drop, true) - } + return startDocumentDropService({ + openPath: (path) => useWorkspace.getState().openPath(path), + pathForFile: (file) => window.moonSprite.pathForFile(file), + onOpened: () => setHomeOpen(false) + }) }, []) useEffect(() => { @@ -792,6 +778,8 @@ export default function App() { useEffect(() => { const keydown = (event: KeyboardEvent): void => { const key = keyboardEventKey(event).toLowerCase() + const target = event.target as HTMLElement | null + if (shortcutOpen && key !== 'escape' && target?.dataset.shortcutRecorder === 'true') return const matches = (action: string): boolean => { if (blockedShortcuts[action as keyof typeof blockedShortcuts]) return false const configured = shortcuts[action] @@ -799,16 +787,30 @@ export default function App() { return expected !== '' && normalizeShortcut(shortcutText(event)).toLowerCase() === normalizeShortcut(expected).toLowerCase() } if (key === 'escape') { + const hasPaletteSurface = Boolean(document.querySelector('.palette-operation-dialog, .palette-library-popover, .palette-actions-popover, .palette-library-context')) const hasOwnedPopover = Boolean(document.querySelector('.document-tab-context-menu, .tool-flyout, .brush-library, .brush-size-popover, .brush-advanced-settings [aria-expanded="true"]')) - const hasTransientSurface = Boolean(workspace.dialog || workspace.saveProgress || newOpen || canvasResizeOpen || imageResizeOpen || outlineOpen || preferencesOpen || shortcutOpen || adjustmentOpen || aboutOpen || componentLibraryOpen || exportOpen || saveAsOpen || workspaceSaveOpen || workspaceManagerOpen || openMenu || hasOwnedPopover) const dialogChoice = workspace.dialog?.choices.find((choice) => choice.id === 'cancel')?.id ?? workspace.dialog?.choices.find((choice) => choice.tone === 'quiet')?.id if (workspace.dialog && dialogChoice) workspace.resolveDialog(dialogChoice) - if (workspace.saveProgress) workspace.dismissSaveProgress() - window.dispatchEvent(new Event('moonsprite:close-dialog')) - setNewOpen(false); setCanvasResizeOpen(false); setImageResizeOpen(false); setOutlineOpen(false); setPreferencesOpen(false); setShortcutOpen(false); setAdjustmentOpen(false); setAboutOpen(false); setComponentLibraryOpen(false); setExportOpen(false); setSaveAsOpen(false); setWorkspaceSaveOpen(false); setWorkspaceManagerOpen(false) - setOpenMenu(null) - if (session?.pendingPaste) workspace.cancelFloatingPaste() - else if (!hasTransientSurface) workspace.setSelection(null) + else if (workspace.saveProgress) workspace.dismissSaveProgress() + else if (adjustmentOpen) window.dispatchEvent(new CustomEvent('moonsprite:close-dialog', { detail: { target: 'adjustment' } })) + else if (document.querySelector('.layer-modal')) window.dispatchEvent(new CustomEvent('moonsprite:close-dialog', { detail: { target: 'layers' } })) + else if (hasPaletteSurface) window.dispatchEvent(new CustomEvent('moonsprite:close-dialog', { detail: { target: 'palette' } })) + else if (newOpen) setNewOpen(false) + else if (canvasResizeOpen) setCanvasResizeOpen(false) + else if (imageResizeOpen) setImageResizeOpen(false) + else if (outlineOpen) setOutlineOpen(false) + else if (preferencesOpen) setPreferencesOpen(false) + else if (shortcutOpen) setShortcutOpen(false) + else if (aboutOpen) setAboutOpen(false) + else if (componentLibraryOpen) setComponentLibraryOpen(false) + else if (exportOpen) setExportOpen(false) + else if (saveAsOpen) setSaveAsOpen(false) + else if (workspaceSaveOpen) setWorkspaceSaveOpen(false) + else if (workspaceManagerOpen) setWorkspaceManagerOpen(false) + else if (openMenu) setOpenMenu(null) + else if (hasOwnedPopover) window.dispatchEvent(new CustomEvent('moonsprite:close-dialog', { detail: { target: 'popover' } })) + else if (session?.pendingPaste) workspace.cancelFloatingPaste() + else workspace.setSelection(null) event.preventDefault() event.stopPropagation() return @@ -819,25 +821,18 @@ export default function App() { event.preventDefault() event.stopPropagation() nextInput.focus() + nextInput.select() return } } + if (key === 'enter' && outlineOpen) return if (key === 'alt') event.preventDefault() const commandKey = event.ctrlKey || event.metaKey - const target = event.target as HTMLElement | null const inputType = target?.tagName === 'INPUT' ? (target as HTMLInputElement).type : '' const isTextEntry = Boolean(target?.isContentEditable) || target?.tagName === 'TEXTAREA' || target?.tagName === 'SELECT' || (target?.tagName === 'INPUT' && !['range', 'number', 'checkbox', 'radio', 'button', 'submit', 'reset'].includes(inputType)) - if (target?.closest('[data-palette-id]') && isPaletteDeleteKey(event.key)) { - event.preventDefault() - event.stopPropagation() - const state = useWorkspace.getState() - const active = state.sessions.find((item) => item.document.id === state.activeId) - if (active?.selectedPaletteIds.length) state.deletePaletteColors(active.selectedPaletteIds) - return - } if (matches('advancedMode')) { event.preventDefault() event.stopPropagation() @@ -894,9 +889,10 @@ export default function App() { state.setTool('selection') state.setSelection({ x: 0, y: 0, width: active.document.width, height: active.document.height }) })) return + if (runCommand('invertSelection', () => workspace.invertSelection())) return if (runCommand('createBrushFromSelection', () => { - if (session?.tool === 'selection' && session.selection) workspace.createBrushFromSelection() - else workspace.setMessage('请先使用选区工具选择要作为笔刷的内容。') + if (session?.selection) workspace.createBrushFromSelection() + else workspace.setMessage('请先创建要作为笔刷的选区。') })) return if (runCommand('deselect', () => { if (session?.pendingPaste) workspace.commitFloatingPaste() @@ -908,9 +904,23 @@ export default function App() { if (runCommand('exportDocument', openExport)) return if (runCommand('closeDocument', () => { if (workspace.activeId) void workspace.closeDocument(workspace.activeId) })) return if (runCommand('openProjectFolder', () => { if (session) openProjectFolder(session.document.id) })) return - if (runCommand('copy', () => { if (session && !session.selection && !session.selectedGroupId && session.selectedLayerIds.length === 1) workspace.copyActiveLayerToClipboard(); else workspace.copySelection() })) return + if (runCommand('copy', () => { + const target = resolveCopyCommand(commandScopeRef.current, Boolean(session?.selection)) + if (target === 'layers') workspace.copySelectedLayersToClipboard() + else if (target === 'selection') workspace.copySelection() + else if (commandScopeRef.current === 'palette') workspace.setMessage('调色板颜色暂不支持复制。') + else if (session?.selectedLayerIds.length || session?.selectedGroupIds.length) workspace.copySelectedLayersToClipboard() + else workspace.setMessage('请先选择要复制的内容。') + })) return if (runCommand('cut', () => workspace.cutSelection())) return - if (runCommand('paste', () => { if (!session?.selection && workspace.pasteLayerFromClipboard()) return; void workspace.pasteSelection() })) return + if (runCommand('paste', () => { + if (commandScopeRef.current === 'layers' && workspace.pasteLayersFromClipboard()) return + if (commandScopeRef.current === 'palette') { workspace.setMessage('调色板区域暂不支持粘贴。'); return } + if (!session?.selection && workspace.pasteLayersFromClipboard()) return + void workspace.pasteSelection() + })) return + if (runCommand('pasteAsNewLayer', () => { void workspace.pasteAsNewLayer() })) return + if (runCommand('pasteAsNewDocument', () => { void workspace.pasteAsNewDocument() })) return if (runCommand('swapForegroundBackground', () => workspace.swapPrimarySecondaryColors())) return const adjustmentShortcuts: Array<[string, AdjustmentKind]> = [ ['adjustmentColorBalance', 'color-balance'], @@ -942,6 +952,7 @@ export default function App() { if (runCommand('mirrorView', () => { if (session) toggleMirrorView('horizontal') })) return if (runCommand('mirrorViewVertical', () => { if (session) toggleMirrorView('vertical') })) return if (runCommand('toggleGrid', () => { if (session) workspace.toggleGrid() })) return + if (runCommand('toggleSelectionOutline', () => { if (session) workspace.toggleSelectionOutline() })) return if (runCommand('rotateViewClockwise90', () => { if (session) workspace.setView({ rotation: (session.view.rotation + 90) % 360 }) })) return if (runCommand('rotateViewCounterClockwise90', () => { if (session) workspace.setView({ rotation: (session.view.rotation + 270) % 360 }) })) return if (runCommand('resetView', () => { if (session) workspace.setView({ zoom: 16, panX: 0, panY: 0, rotation: 0, mirrored: false, mirroredVertical: false }) })) return @@ -955,9 +966,10 @@ export default function App() { if (runCommand('openAbout', () => setAboutOpen(true))) return if (runCommand('magic', () => { workspace.setTool('selection'); workspace.setSelectionKind('magic') })) return if (runCommand('lasso', () => { workspace.setTool('selection'); workspace.setSelectionKind('lasso') })) return + if (runCommand('polygonLasso', () => { workspace.setTool('selection'); workspace.setSelectionKind('polygon-lasso') })) return if (runCommand('tool.selection.ellipse', () => { workspace.setTool('selection'); workspace.setSelectionKind('ellipse') })) return if (runCommand('tool.selection', () => { workspace.setTool('selection'); workspace.setSelectionKind('rectangle') })) return - if (event.key === 'Enter' && session?.selection) { + if (event.key === 'Enter' && session?.selection && shouldHandleGlobalSelectionEnter(outlineOpen, true)) { event.preventDefault() if (session.pendingPaste) workspace.commitFloatingPaste() const active = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) @@ -971,10 +983,13 @@ export default function App() { if (!event.repeat) workspace.setTool(tool.id) return } - if (matches('deleteLayer')) { + if (shouldTriggerDeleteCommand(matches('deleteLayer'), event.key)) { event.preventDefault() - if (session?.selection) workspace.deleteSelection() - else if (session?.selectedLayerIds.length && !session.selectedGroupId) workspace.deleteActiveLayer() + event.stopPropagation() + const target = resolveDeleteCommand(commandScopeRef.current, Boolean(session?.selection)) + if (target === 'palette' && session?.selectedPaletteIds.length) workspace.deletePaletteColors(session.selectedPaletteIds) + else if (target === 'layers' && (session?.selectedLayerIds.length || session?.selectedGroupIds.length)) workspace.deleteSelectedLayers() + else if (target === 'selection' && session?.selection) workspace.deleteSelection() return } if (runCommand('brushSizeDecrease', () => workspace.setBrushSize((session?.brushSize ?? 1) - 1), true)) return @@ -1071,7 +1086,7 @@ export default function App() { const editorOnly = advancedMode !== null && Boolean(session) && !homeOpen return
- {saveAsOpen && session && setSaveAsOpen(false)} onSave={(options) => workspace.saveActive(true, options)} />} + {saveAsOpen && session && setSaveAsOpen(false)} onSave={(options) => workspace.saveActive(true, options)} />} {advancedModeNotice &&
{advancedModeNotice}{advancedModeNotice.startsWith('高级模式') ? `${advancedModeNoticeShortcut} 恢复` : advancedModeNoticeShortcut}
} - {workspace.saveProgress &&
FILE OPERATION

{workspace.saveProgress.title}

{workspace.saveProgress.label}
{workspace.saveProgress.value}%
} - {workspace.dialog &&
MOONSPRITE

{workspace.dialog.title}

{workspace.dialog.message}{workspace.dialog.detail &&

{workspace.dialog.detail}

}
{workspace.dialog.choices.map((choice) => )}
} - {exportOpen &&
{ if (event.target === event.currentTarget) setExportOpen(false) }}>
{ event.preventDefault(); void workspace.exportActive(exportForm).then((exported) => { if (exported) setExportOpen(false) }) }}>
EXPORT IMAGE

导出设置

setPresetName(event.target.value)} />
} + {workspace.saveProgress &&
FILE OPERATION

{workspace.saveProgress.title}

{workspace.saveProgress.label}
{workspace.saveProgress.value}%
} + {workspace.dialog &&
MOONSPRITE

{workspace.dialog.title}

{workspace.dialog.message}{workspace.dialog.detail &&

{workspace.dialog.detail}

}
{workspace.dialog.choices.map((choice) => )}
} + {exportOpen &&
{ if (event.target === event.currentTarget) setExportOpen(false) }}> { event.preventDefault(); void workspace.exportActive(exportForm).then((exported) => { if (exported) setExportOpen(false) }) }}>
EXPORT IMAGE

导出设置

setPresetName(event.target.value)} />
} {adjustmentOpen && setAdjustmentOpen(false)} />} - {aboutOpen &&
{ if (event.target === event.currentTarget) setAboutOpen(false) }}>
MOONSPRITE

关于 MoonSprite

MoonSprite

为像素创作者打造的独立开源 Windows 绘画工作台,专注清晰、快速的像素级编辑体验。

版本
{APP_CHANNEL_LABEL}
作者
MoonPixel Studio 与 MoonSprite Contributors
许可
MIT License
github.com/MoonPixelTeam/moonsprite

MoonSprite 是独立实现,与 Aseprite 无隶属关系,未使用其源码、品牌或视觉资产。

} + {aboutOpen &&
{ if (event.target === event.currentTarget) setAboutOpen(false) }}>
MOONSPRITE

关于 MoonSprite

MoonSprite

为像素创作者打造的独立开源 Windows 绘画工作台,专注清晰、快速的像素级编辑体验。

版本
{APP_CHANNEL_LABEL}
作者
MoonPixel Studio 与 MoonSprite Contributors
许可
MIT License
github.com/MoonPixelTeam/moonsprite

MoonSprite 是独立实现,与 Aseprite 无隶属关系,未使用其源码、品牌或视觉资产。

} {componentLibraryOpen && setComponentLibraryOpen(false)} />} - {workspaceSaveOpen &&
{ if (event.target === event.currentTarget && !workspaceBusy) setWorkspaceSaveOpen(false) }}>
{ event.preventDefault(); void saveWorkspace(workspaceSaveName) }}>
WORKSPACE

保存当前工作区

将保存窗口大小和位置、工具栏、栏目停靠位置、排序、尺寸及浮动栏目位置。

文件夹:{workspaceDirectory || 'workspaces'}

} - {workspaceManagerOpen &&
{ if (event.target === event.currentTarget) setWorkspaceManagerOpen(false) }}>
WORKSPACE

管理工作区

{savedWorkspaces.map((saved) =>
{!saved.builtIn && }
)}
} + {workspaceSaveOpen &&
{ if (event.target === event.currentTarget && !workspaceBusy) setWorkspaceSaveOpen(false) }}> { event.preventDefault(); void saveWorkspace(workspaceSaveName) }}>
WORKSPACE

保存当前工作区

将保存窗口大小和位置、工具栏、栏目停靠位置、排序、尺寸及浮动栏目位置。

文件夹:{workspaceDirectory || 'workspaces'}

} + {workspaceManagerOpen &&
{ if (event.target === event.currentTarget) setWorkspaceManagerOpen(false) }}>
WORKSPACE

管理工作区

{savedWorkspaces.map((saved) =>
{!saved.builtIn && }
)}
} setNewOpen(false)} onCreate={(name, width, height, mode) => void createDocumentAndShow(name, width, height, mode)} /> {session && setCanvasResizeOpen(false)} onResize={workspace.resizeActiveCanvas} onPreview={(preview) => { workspace.setCanvasResizePreview(preview); publishCanvasResizePreview(session.document.id, preview) }} preview={session.canvasResizePreview} />} {session && setImageResizeOpen(false)} onResize={(width, height, interpolation: ImageResizeInterpolation) => workspace.resizeActiveImage(width, height, interpolation)} />} diff --git a/src/renderer/src/assets/tool-icons/selection-polygon-lasso.png b/src/renderer/src/assets/tool-icons/selection-polygon-lasso.png new file mode 100644 index 0000000..9c0cebe Binary files /dev/null and b/src/renderer/src/assets/tool-icons/selection-polygon-lasso.png differ diff --git a/src/renderer/src/assets/tool-icons/shape-ellipse-fill.png b/src/renderer/src/assets/tool-icons/shape-ellipse-fill.png new file mode 100644 index 0000000..4dd14a6 Binary files /dev/null and b/src/renderer/src/assets/tool-icons/shape-ellipse-fill.png differ diff --git a/src/renderer/src/assets/tool-icons/shape-ellipse.png b/src/renderer/src/assets/tool-icons/shape-ellipse.png new file mode 100644 index 0000000..e6af125 Binary files /dev/null and b/src/renderer/src/assets/tool-icons/shape-ellipse.png differ diff --git a/src/renderer/src/assets/tool-icons/shape-rectangle-fill.png b/src/renderer/src/assets/tool-icons/shape-rectangle-fill.png new file mode 100644 index 0000000..26c3a54 Binary files /dev/null and b/src/renderer/src/assets/tool-icons/shape-rectangle-fill.png differ diff --git a/src/renderer/src/assets/tool-icons/shape-rectangle.png b/src/renderer/src/assets/tool-icons/shape-rectangle.png new file mode 100644 index 0000000..78c31c1 Binary files /dev/null and b/src/renderer/src/assets/tool-icons/shape-rectangle.png differ diff --git a/src/renderer/src/assets/tool-icons/tool-fill.png b/src/renderer/src/assets/tool-icons/tool-fill.png index 66e583e..4597065 100644 Binary files a/src/renderer/src/assets/tool-icons/tool-fill.png and b/src/renderer/src/assets/tool-icons/tool-fill.png differ diff --git a/src/renderer/src/components/CanvasResizeDialog.tsx b/src/renderer/src/components/CanvasResizeDialog.tsx index ed9c706..5454ba4 100644 --- a/src/renderer/src/components/CanvasResizeDialog.tsx +++ b/src/renderer/src/components/CanvasResizeDialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { ArrowDown, ArrowDownLeft, ArrowDownRight, ArrowLeft, ArrowRight, ArrowUp, ArrowUpLeft, ArrowUpRight, CircleDot, Info, X } from 'lucide-react' import type { CanvasAnchor } from '@shared/types' import type { CanvasResizePreview } from '@/store/workspace' +import { ModalShell } from './ModalShell' import { NumberInput } from './NumberInput' const anchors: Array<{ id: CanvasAnchor; label: string; icon: typeof ArrowUp }> = [ @@ -94,7 +95,7 @@ export function CanvasResizeDialog({ open, currentWidth, currentHeight, onClose, const right = value.width - currentWidth - left; const bottom = value.height - currentHeight - top return
-
+
CANVAS SIZE

画布大小

大小

{anchors.map((item) => { const Icon = item.icon; return })}
@@ -102,6 +103,6 @@ export function CanvasResizeDialog({ open, currentWidth, currentHeight, onClose,
- +
} diff --git a/src/renderer/src/components/CanvasStage.tsx b/src/renderer/src/components/CanvasStage.tsx index af5908a..d8a6cdd 100644 --- a/src/renderer/src/components/CanvasStage.tsx +++ b/src/renderer/src/components/CanvasStage.tsx @@ -7,12 +7,13 @@ import { appendPerfectPixelSegment, applySelectionTransform, applySelectionTrans import { useWorkspace, type DocumentSession } from '@/store/workspace' import { loadEditorPreferences, type BrushPreviewMode, type CheckerboardPreferences, type RotationIndicatorPosition, type ZoomToolDragMode } from '@/core/file-preferences' import { documentPointFromViewportPoint, documentPointFromViewportPointContinuous, rotationIndicatorFitsCanvas, unrotateViewportPoint, viewPanDeltaFromScreen, viewRotationPivot, zoomViewAroundViewportPoint } from '@/core/view-geometry' -import { createCanvasRenderPlan } from '@/core/canvas-render-plan' -import { cloneSelection, combineSelection, ellipseSelection, lassoSelection, magicWandSelection, rectSelection, selectionContains, transformSelectionMask } from '@/core/selection' +import { createCanvasRenderPlan, deviceAlignedPixelRect } from '@/core/canvas-render-plan' +import { balancedStairLinePoints } from '@/core/pixel-line' +import { cloneSelection, combineSelection, ellipseSelection, lassoSelection, magicWandSelection, rasterLinePoints, rectSelection, selectionContains, shiftSelection, transformSelectionMask } from '@/core/selection' import { CANVAS_RESIZE_PREVIEW_EVENT, drawCanvasResizePreviewLayers } from '@/core/canvas-resize-preview' import { loadShortcuts, modifierShortcutHeld } from '@/core/shortcuts' -import { CanvasInputState, clampCanvasZoom as clampZoom, constrainedTranslation, resizeSelectionBounds, selectionInteractionHit, selectionTransformModifiers, shapeBounds, shouldStartCanvasPan, snapSelectionRotation, steppedCanvasZoom as steppedZoom, zoomDragModeForModifiers, zoomDragTarget, type CanvasDragState as DragState, type CanvasPoint as Point, type SelectionHandle, type SelectionHit, type SelectionRotationHandle } from '@/core/canvas-input' -import { canvasCursors, canvasStatusTextColor, canvasToolCursor, colorLuminance, previewCursorTools, resizeCursors, rotationCursors, selectionPreviewPixels, selectionTransformDragCursor, transparencyColorAt } from '@/core/canvas-visuals' +import { CanvasInputState, appendPolygonLassoVertex, canvasGestureForPreview, clampCanvasZoom as clampZoom, constrainedTranslation, createCanvasPanDrag, finalizeMarqueeSelection, polygonLassoClosedPathPoints, polygonLassoPreviewPoints, resizeSelectionBounds, restoreCanvasDragAfterPan, selectionGestureMoved, selectionInteractionHit, selectionOverlayMaskForDrag, selectionTransformModifiers, shapeBounds, shouldClosePolygonLasso, shouldRestartFloatingSelectionForCopy, shouldStartCanvasPan, snapSelectionRotation, steppedCanvasZoom as steppedZoom, zoomDragModeForModifiers, zoomDragTarget, type CanvasDragState as DragState, type CanvasPoint as Point, type SelectionHandle, type SelectionHit, type SelectionRotationHandle } from '@/core/canvas-input' +import { canvasCursors, canvasStatusTextColor, canvasToolCursor, colorLuminance, previewCursorTools, resizeCursors, rotationCursors, selectionCreationCursor, selectionCursorCornerRects, selectionPathPreviewPixelVisible, selectionPreviewPixels, selectionTransformDragCursor, transparencyColorAt } from '@/core/canvas-visuals' import { CanvasCompositeCache } from '@/components/canvas-composite-cache' import { drawSelectionOutline, selectionScreenBox, type RasterContext2D, type SelectionBoundaryCache } from '@/components/canvas-selection-renderer' import { useCanvasViewPreview } from '@/components/useCanvasViewPreview' @@ -43,6 +44,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const [checkerboard, setCheckerboard] = useState(() => loadEditorPreferences().checkerboard) const [wheelZoomEnabled, setWheelZoomEnabled] = useState(() => loadEditorPreferences().wheelZoomEnabled) const [shiftLinePreviewEnabled, setShiftLinePreviewEnabled] = useState(() => loadEditorPreferences().shiftLinePreviewEnabled) + const [lassoPreviewClosed, setLassoPreviewClosed] = useState(() => loadEditorPreferences().lassoPreviewClosed) + const [eyedropperSwitchToPencil, setEyedropperSwitchToPencil] = useState(() => loadEditorPreferences().eyedropperSwitchToPencil) + const [selectionCrosshair, setSelectionCrosshair] = useState(() => loadEditorPreferences().selectionCrosshair) + const [balancedShiftLineEnabled, setBalancedShiftLineEnabled] = useState(() => loadEditorPreferences().balancedShiftLineEnabled) const [shortcuts, setShortcuts] = useState(loadShortcuts) const inputRef = useRef(new CanvasInputState()) const rafRef = useRef(null) @@ -65,7 +70,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const activeViewDrag = inputRef.current.drag?.kind === 'pan' || inputRef.current.drag?.kind === 'zoom-drag' || inputRef.current.drag?.kind === 'rotate-view' const { pendingViewRef, liveViewRef, zoomPreviewStartRef, applyRotationStyle, finishZoomPreview, scheduleZoomPreview, beginPanPreview, schedulePanPreview, finishPanPreview, cancelViewPreviews } = useCanvasViewPreview({ documentId: session.document.id, sessionView: session.view, activeViewDrag, canvasRef, selectionCanvasRef, drawRef }) const lineAnchor = session.tool === 'eraser' ? session.lastEraserPoint : session.lastPencilPoint - const hasSelectedRasterLayer = !session.selectedGroupId && session.selectedLayerIds.some((id) => session.document.layers.some((layer) => layer.id === id)) + const hasSelectedRasterLayer = session.selectedGroupIds.length === 0 && session.selectedLayerIds.some((id) => session.document.layers.some((layer) => layer.id === id)) const activeLayer = getActiveLayer(session.document) const activeLayerEditable = hasSelectedRasterLayer && isLayerEffectivelyVisible(session.document, activeLayer) && !isLayerEffectivelyLocked(session.document, activeLayer) const brushToolEnabled = session.tool === 'pencil' || session.tool === 'eraser' || session.tool === 'fill' @@ -132,6 +137,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { setCheckerboard(preferences.checkerboard) setWheelZoomEnabled(preferences.wheelZoomEnabled) setShiftLinePreviewEnabled(preferences.shiftLinePreviewEnabled) + setLassoPreviewClosed(preferences.lassoPreviewClosed) + setEyedropperSwitchToPencil(preferences.eyedropperSwitchToPencil) + setSelectionCrosshair(preferences.selectionCrosshair) + setBalancedShiftLineEnabled(preferences.balancedShiftLineEnabled) } window.addEventListener('moonsprite:preferences-changed', syncPreferences) return () => window.removeEventListener('moonsprite:preferences-changed', syncPreferences) @@ -242,12 +251,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const overlay = selectionCanvasRef.current if (!canvas || !overlay) return const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session - const selectionDrag = inputRef.current.drag - const creatingSelection = selectionDrag?.kind === 'marquee' || selectionDrag?.kind === 'lasso' - const previewingSelection = selectionDrag?.kind === 'magic-preview' || selectionDrag?.kind === 'move-selection' || selectionDrag?.kind === 'move-content' || selectionDrag?.kind === 'transform-content' || selectionDrag?.kind === 'rotate-content' - const visibleSelection = previewingSelection ? selectionDrag.previewSelection ?? currentSession.selection : currentSession.selection - const creationSelection = creatingSelection ? selectionDrag.selectionStart : null - if (!visibleSelection && !creationSelection && !selectionOverlayVisibleRef.current) return + const selectionDrag = canvasGestureForPreview(inputRef.current.drag) + const creatingSelection = selectionDrag?.kind === 'marquee' || selectionDrag?.kind === 'lasso' || selectionDrag?.kind === 'polygon-lasso' + const visibleSelection = selectionOverlayMaskForDrag(currentSession.selection, selectionDrag) const rect = stageSize() const dpr = window.devicePixelRatio || 1 const backingWidth = Math.max(1, Math.round(rect.width * dpr)) @@ -257,6 +263,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (!displayContext) return displayContext.setTransform(dpr, 0, 0, dpr, 0, 0) displayContext.clearRect(0, 0, rect.width, rect.height) + const shouldDrawSelection = Boolean(visibleSelection) + selectionOverlayVisibleRef.current = shouldDrawSelection + if (!shouldDrawSelection || !visibleSelection) return const renderPlan = createCanvasRenderPlan(rect.width, rect.height, session.document, liveViewRef.current, rotationIndicatorPosition) const { rotated, sceneLeft, sceneTop, sceneWidth, sceneHeight } = renderPlan let context: RasterContext2D = displayContext @@ -274,7 +283,6 @@ export function CanvasStage({ session }: { session: DocumentSession }) { sceneContext.clearRect(sceneLeft, sceneTop, sceneWidth, sceneHeight) context = sceneContext } - selectionOverlayVisibleRef.current = Boolean(visibleSelection || creationSelection) const drawOverlaySelection = (selection: SelectionMask, showHandles: boolean): void => { selectionBoundaryCacheRef.current = drawSelectionOutline({ context, @@ -285,17 +293,17 @@ export function CanvasStage({ session }: { session: DocumentSession }) { viewportHeight: rect.height, rotationIndicatorPosition, cache: selectionBoundaryCacheRef.current, + showOutline: currentSession.view.showSelectionOutline !== false, showHandles }) } - if (visibleSelection && !creatingSelection) drawOverlaySelection(visibleSelection, currentSession.tool === 'selection') - if (creationSelection) drawOverlaySelection(creationSelection, false) + drawOverlaySelection(visibleSelection, currentSession.tool === 'selection' && !creatingSelection) if (rotated) { displayContext.save() applyViewRotation(displayContext, rect.width, rect.height, liveViewRef.current) displayContext.imageSmoothingEnabled = false const scene = selectionRotationSceneRef.current! - displayContext.drawImage(scene, 0, 0, scene.width, scene.height, sceneLeft, sceneTop, sceneWidth, sceneHeight) + displayContext.drawImage(scene, 0, 0, scene.width, scene.height, sceneLeft, sceneTop, scene.width / dpr, scene.height / dpr) displayContext.restore() } } @@ -307,7 +315,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (!canvas) return const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session const currentActiveLayer = getActiveLayer(currentSession.document) - const currentHasRasterSelection = !currentSession.selectedGroupId && currentSession.selectedLayerIds.some((id) => currentSession.document.layers.some((layer) => layer.id === id)) + const currentHasRasterSelection = currentSession.selectedGroupIds.length === 0 && currentSession.selectedLayerIds.some((id) => currentSession.document.layers.some((layer) => layer.id === id)) const canRenderToolPreview = !canvasResizePreviewRef.current && currentHasRasterSelection && isLayerEffectivelyVisible(currentSession.document, currentActiveLayer) && !isLayerEffectivelyLocked(currentSession.document, currentActiveLayer) const rect = stageSize() const dpr = window.devicePixelRatio || 1 @@ -386,13 +394,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { : paintColor return compositePixelWithLayerColor(document, index, activeLayer.id, replacement) } - const previewPixelRect = (pixelX: number, pixelY: number): { x: number; y: number; width: number; height: number } => { - const left = Math.round(originX + pixelX * view.zoom) - const top = Math.round(originY + pixelY * view.zoom) - const right = Math.round(originX + (pixelX + 1) * view.zoom) - const bottom = Math.round(originY + (pixelY + 1) * view.zoom) - return { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) } - } + const previewPixelRect = (pixelX: number, pixelY: number): { x: number; y: number; width: number; height: number } => + deviceAlignedPixelRect(originX, originY, view.zoom, pixelX, pixelY, dpr) const drawPreviewPixel = (pixelX: number, pixelY: number, color: RgbaColor): { x: number; y: number; width: number; height: number } => { const pixelRect = previewPixelRect(pixelX, pixelY) const transparency = transparencyColorAt(pixelX, pixelY, checkerboard) @@ -412,15 +415,23 @@ export function CanvasStage({ session }: { session: DocumentSession }) { context.clip() for (const value of previewPixels) { const [x, y] = value.split(':').map(Number) - if (x < 0 || y < 0 || x >= document.width || y >= document.height) continue + const pixelRect = previewPixelRect(x, y) + const insideDocument = x >= 0 && y >= 0 && x < document.width && y < document.height + if (!selectionPathPreviewPixelVisible(pixelRect, rect.width, rect.height, insideDocument)) continue const sampled = sampleCompositeColor(document, x, y) const background = sampled.a > 0 ? sampled : transparencyColorAt(x, y, checkerboard) context.fillStyle = colorLuminance(background) > 145 ? '#111318' : '#f7f7f7' - const pixelRect = previewPixelRect(x, y) context.fillRect(pixelRect.x, pixelRect.y, pixelRect.width, pixelRect.height) } context.restore() } + const drawSelectionCursorCorners = (pixelX: number, pixelY: number, color: string): void => { + const pixelRect = previewPixelRect(pixelX, pixelY) + context.save() + context.fillStyle = color + for (const mark of selectionCursorCornerRects(pixelRect, dpr)) context.fillRect(mark.x, mark.y, mark.width, mark.height) + context.restore() + } const drawSelectionFillPreview = (selection: SelectionMask): void => { const left = Math.max(selection.x, fromX) const top = Math.max(selection.y, fromY) @@ -447,11 +458,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const { x: beforeX, y: beforeY } = brushStampAnchor(session.brushSize, session.tool === 'pencil' || session.tool === 'eraser' ? activeBrushImage : null) const patternOrigin = brushPatternOrigin(from) const drawn = new Set() - let x = from.x; let y = from.y - const deltaX = Math.abs(to.x - from.x); const stepX = from.x < to.x ? 1 : -1 - const deltaY = -Math.abs(to.y - from.y); const stepY = from.y < to.y ? 1 : -1 - let error = deltaX + deltaY - while (true) { + const centers = balancedShiftLineEnabled ? balancedStairLinePoints(from, to) : rasterLinePoints(from, to) + for (const center of centers) { + const x = center.x + const y = center.y const texture = session.tool === 'pencil' || session.tool === 'eraser' ? activeBrushTexture : 'solid' const mask = brushMaskOffsets(session.brushSize, session.brushShape, texture, session.brushTextureScale, x - beforeX, y - beforeY, session.tool === 'pencil' || session.tool === 'eraser' ? activeBrushImage : null, session.brushImageSettings, proceduralAntialiasStrength, activeBrushPreviewMode, patternOrigin.x, patternOrigin.y) for (const offset of mask) { @@ -461,10 +471,6 @@ export function CanvasStage({ session }: { session: DocumentSession }) { drawn.add(index) drawPreviewPixel(pixelX, pixelY, previewColorAt(pixelX, pixelY, erase, offset.coverage, offset.color ?? session.primaryColor)) } - if (x === to.x && y === to.y) break - const twice = error * 2 - if (twice >= deltaY) { error += deltaY; x += stepX } - if (twice <= deltaX) { error += deltaX; y += stepY } } } @@ -488,7 +494,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const drag = inputRef.current.drag if (canRenderToolPreview && drag?.kind === 'shape') { - const shape = shapeBounds(drag.start, drag.last, drag.constrain) + const shape = shapeBounds(drag.start, drag.last, drag.constrain, session.shapeRatio) for (const point of shapePixelPoints(shape, session.shapeKind)) { if (point.x < 0 || point.y < 0 || point.x >= document.width || point.y >= document.height) continue const color = previewColorAt(point.x, point.y) @@ -524,10 +530,6 @@ export function CanvasStage({ session }: { session: DocumentSession }) { context.beginPath() context.rect(x, y, previewWidth, previewHeight) context.clip() - context.beginPath() - context.rect(0, 0, rect.width, rect.height) - context.rect(originX, originY, canvasWidth, canvasHeight) - context.clip('evenodd') context.fillStyle = `rgb(${checkerboard.lightColor.r} ${checkerboard.lightColor.g} ${checkerboard.lightColor.b})` context.fillRect(x, y, previewWidth, previewHeight) const previewCheckerCell = checkerboard.size * view.zoom @@ -591,8 +593,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { context.restore() }) } - const selectionDrag = inputRef.current.drag - if (selectionDrag?.kind === 'marquee') { + const selectionDrag = canvasGestureForPreview(inputRef.current.drag) + if (selectionDrag?.kind === 'marquee' && selectionDrag.moved) { const drag = selectionDrag const bounds = shapeBounds(drag.start, drag.last, drag.constrain) const previewSelection = session.selectionKind === 'ellipse' @@ -600,51 +602,50 @@ export function CanvasStage({ session }: { session: DocumentSession }) { : rectSelection(bounds.x, bounds.y, bounds.width, bounds.height) drawSelectionPathPreview(selectionPreviewPixels(previewSelection)) } - if (inputRef.current.drag?.kind === 'lasso' && (inputRef.current.drag.path?.length ?? 0) > 1) { + if ((selectionDrag?.kind === 'lasso' || selectionDrag?.kind === 'polygon-lasso') && (selectionDrag.path?.length ?? 0) > 0) { const previewPixels = new Set() - const addAxisLine = (from: Point, to: Point): void => { - const stepX = Math.sign(to.x - from.x) - const stepY = Math.sign(to.y - from.y) - let x = from.x - let y = from.y - previewPixels.add(`${x}:${y}`) - while (x !== to.x || y !== to.y) { - x += stepX - y += stepY - previewPixels.add(`${x}:${y}`) - } + const addLine = (from: Point, to: Point): void => { + for (const point of rasterLinePoints(from, to)) previewPixels.add(`${point.x}:${point.y}`) } - const path = inputRef.current.drag.path ?? [] - for (let index = 1; index < path.length; index += 1) { - const previous = path[index - 1] - const point = path[index] - if (Math.abs(point.x - previous.x) >= Math.abs(point.y - previous.y)) { - addAxisLine(previous, { x: point.x, y: previous.y }) - addAxisLine({ x: point.x, y: previous.y }, point) - } else { - addAxisLine(previous, { x: previous.x, y: point.y }) - addAxisLine({ x: previous.x, y: point.y }, point) - } + const path = selectionDrag.path ?? [] + if (selectionDrag.kind === 'polygon-lasso') { + for (const point of polygonLassoPreviewPoints(path, selectionDrag.last, lassoPreviewClosed, balancedShiftLineEnabled)) previewPixels.add(`${point.x}:${point.y}`) + } else { + for (let index = 1; index < path.length; index += 1) addLine(path[index - 1], path[index]) + if (lassoPreviewClosed && path.length > 1) addLine(path.at(-1)!, path[0]) } drawSelectionPathPreview(previewPixels) + const mode = selectionDrag.selectionMode ?? session.selectionMode + const point = inputRef.current.pointer.point + if (mode !== 'replace' && point.x >= 0 && point.y >= 0 && point.x < document.width && point.y < document.height) { + const pixelRect = previewPixelRect(point.x, point.y) + const sampled = sampleCompositeColor(document, point.x, point.y) + const background = sampled.a > 0 ? sampled : transparencyColorAt(point.x, point.y, checkerboard) + context.save() + context.strokeStyle = colorLuminance(background) > 145 ? '#111318' : '#f7f7f7' + context.lineWidth = 1 + context.strokeRect(pixelRect.x + 0.5, pixelRect.y + 0.5, Math.max(0, pixelRect.width - 1), Math.max(0, pixelRect.height - 1)) + context.restore() + } } if (inputRef.current.drag?.kind === 'magic-preview' && inputRef.current.drag.previewSelection) { drawSelectionFillPreview(inputRef.current.drag.previewSelection) drawSelectionPathPreview(selectionPreviewPixels(inputRef.current.drag.previewSelection)) } - if (canRenderToolPreview && !inputRef.current.drag && !inputRef.current.spaceHeld && !inputRef.current.sampling && inputRef.current.pointer.visible && session.tool === 'selection') { + const activeSelectionCreation = selectionDrag?.kind === 'marquee' || selectionDrag?.kind === 'lasso' || selectionDrag?.kind === 'polygon-lasso' + if (canRenderToolPreview && (!inputRef.current.drag || activeSelectionCreation) && !inputRef.current.spaceHeld && !inputRef.current.sampling && inputRef.current.pointer.visible && session.tool === 'selection') { const point = inputRef.current.pointer.point const selectionHit = session.selection ? selectionHitAt(inputRef.current.pointer.clientX, inputRef.current.pointer.clientY) : 'outside' - if (point.x >= 0 && point.y >= 0 && point.x < document.width && point.y < document.height && selectionHit === 'outside' && (!session.selection || !selectionContains(session.selection, point.x, point.y))) { - const sampled = sampleCompositeColor(document, point.x, point.y) + const combinationMode = session.selectionMode !== 'replace' + const transformInteraction = selectionHit !== 'inside' && selectionHit !== 'outside' + const addModeInteraction = !inputRef.current.shiftHeld && session.selectionMode === 'add' && selectionHit !== 'outside' + const creatingSelection = activeSelectionCreation || inputRef.current.shiftHeld || combinationMode || (selectionHit === 'outside' && (!session.selection || !selectionContains(session.selection, point.x, point.y))) + if (!transformInteraction && !addModeInteraction && creatingSelection) { + const insideDocument = point.x >= 0 && point.y >= 0 && point.x < document.width && point.y < document.height + const sampled = insideDocument ? sampleCompositeColor(document, point.x, point.y) : { r: 74, g: 74, b: 81, a: 255 } const background = sampled.a > 0 ? sampled : transparencyColorAt(point.x, point.y, checkerboard) - const pixelRect = previewPixelRect(point.x, point.y) - context.save() - context.strokeStyle = colorLuminance(background) > 145 ? '#111318' : '#f7f7f7' - context.lineWidth = 1 - context.strokeRect(pixelRect.x + 0.5, pixelRect.y + 0.5, Math.max(0, pixelRect.width - 1), Math.max(0, pixelRect.height - 1)) - context.restore() + drawSelectionCursorCorners(point.x, point.y, colorLuminance(background) > 145 ? '#111318' : '#f7f7f7') } } @@ -710,7 +711,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { applyViewRotation(displayContext, rect.width, rect.height, view) displayContext.imageSmoothingEnabled = false const scene = rotationSceneRef.current! - displayContext.drawImage(scene, 0, 0, scene.width, scene.height, sceneLeft, sceneTop, sceneWidth, sceneHeight) + displayContext.drawImage(scene, 0, 0, scene.width, scene.height, sceneLeft, sceneTop, scene.width / dpr, scene.height / dpr) displayContext.restore() } const statusBackgroundAt = (viewportX: number, viewportY: number): RgbaColor => { @@ -748,7 +749,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (pointer.visible) { const point = localPointAt(pointer.clientX, pointer.clientY) if (point) inputRef.current.updatePointer({ point, clientX: pointer.clientX, clientY: pointer.clientY, ctrlKey: inputRef.current.ctrlHeld, altKey: inputRef.current.altHeld }) - updateCursorAt(pointer.clientX, pointer.clientY, inputRef.current.ctrlHeld, inputRef.current.altHeld) + updateCursorAt(pointer.clientX, pointer.clientY, inputRef.current.ctrlHeld, inputRef.current.altHeld, inputRef.current.shiftHeld) } drawRef.current() // View rotation is rendered inside the canvas rather than by rotating the viewport element. @@ -766,6 +767,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (event.key === 'Control') { inputRef.current.ctrlHeld = true } + if (event.key === 'Shift') inputRef.current.shiftHeld = true if (session.tool === 'rotate' && modifierActive(event, 'resetViewRotation')) { const drag = inputRef.current.drag if (drag?.kind === 'rotate-view') { @@ -796,10 +798,13 @@ export function CanvasStage({ session }: { session: DocumentSession }) { scheduleDraw() } else if (event.key === 'Alt' && inputRef.current.pointer.visible) { event.preventDefault() - updateCursorAt(inputRef.current.pointer.clientX, inputRef.current.pointer.clientY, inputRef.current.ctrlHeld, true) + updateCursorAt(inputRef.current.pointer.clientX, inputRef.current.pointer.clientY, inputRef.current.ctrlHeld, true, inputRef.current.shiftHeld) scheduleDraw() } else if (event.key === 'Control' && inputRef.current.pointer.visible) { - updateCursorAt(inputRef.current.pointer.clientX, inputRef.current.pointer.clientY, true, inputRef.current.altHeld) + updateCursorAt(inputRef.current.pointer.clientX, inputRef.current.pointer.clientY, true, inputRef.current.altHeld, inputRef.current.shiftHeld) + scheduleDraw() + } else if (event.key === 'Shift' && inputRef.current.pointer.visible) { + updateCursorAt(inputRef.current.pointer.clientX, inputRef.current.pointer.clientY, inputRef.current.ctrlHeld, inputRef.current.altHeld, true) scheduleDraw() } } @@ -812,12 +817,13 @@ export function CanvasStage({ session }: { session: DocumentSession }) { return } if (lineConnectionConfigured && !lineConnectionActive(event)) updateShiftPreview(false) - if (event.key === 'Alt' || event.key === 'Control') { + if (event.key === 'Alt' || event.key === 'Control' || event.key === 'Shift') { if (event.key === 'Alt') { event.preventDefault(); inputRef.current.altHeld = false } if (event.key === 'Control') inputRef.current.ctrlHeld = false + if (event.key === 'Shift') inputRef.current.shiftHeld = false inputRef.current.sampling = session.tool === 'eyedropper' inputRef.current.modifierBrushSize = null - if (inputRef.current.pointer.visible) updateCursorAt(inputRef.current.pointer.clientX, inputRef.current.pointer.clientY, inputRef.current.ctrlHeld, inputRef.current.altHeld) + if (inputRef.current.pointer.visible) updateCursorAt(inputRef.current.pointer.clientX, inputRef.current.pointer.clientY, inputRef.current.ctrlHeld, inputRef.current.altHeld, inputRef.current.shiftHeld) else if (canvasRef.current) canvasRef.current.style.cursor = inputRef.current.sampling ? canvasCursors.eyedropper : canvasToolCursor(session.tool, session.primaryColor) scheduleDraw() } @@ -828,6 +834,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { inputRef.current.sampling = false inputRef.current.altHeld = false inputRef.current.ctrlHeld = false + inputRef.current.shiftHeld = false inputRef.current.modifierBrushSize = null if (canvasRef.current) canvasRef.current.style.cursor = canvasToolCursor(session.tool, session.primaryColor) } @@ -849,12 +856,16 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (session.selection) rafRef.current = window.setTimeout(renderSelection, 160) const observer = new ResizeObserver((entries) => { const entry = entries[0] - if (entry) stageSizeRef.current = { width: entry.contentRect.width, height: entry.contentRect.height } + if (entry) { + stageSizeRef.current = { width: entry.contentRect.width, height: entry.contentRect.height } + useWorkspace.getState().setViewportSize(stageSizeRef.current) + } draw() }) if (stageRef.current) { const bounds = stageRef.current.getBoundingClientRect() stageSizeRef.current = { width: bounds.width, height: bounds.height } + useWorkspace.getState().setViewportSize(stageSizeRef.current) observer.observe(stageRef.current) } return () => { @@ -867,7 +878,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { cancelViewPreviews() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session, session.revision, session.view.showGrid, session.view.relativeLuminance, session.view.mirrored, session.view.mirroredVertical, session.selection, session.outlinePreview, session.brushSize, session.brushShape, session.shapeKind, session.fillMode, drawingBrushPreviewEnabled, brushPreviewMode, checkerboard, shiftLinePreviewEnabled, lineConnectionShortcut, rotationIndicatorPosition]) + }, [session, session.revision, session.view.showGrid, session.view.relativeLuminance, session.view.mirrored, session.view.mirroredVertical, session.view.showSelectionOutline, session.selection, session.outlinePreview, session.brushSize, session.brushShape, session.shapeKind, session.shapeRatio, session.fillMode, drawingBrushPreviewEnabled, brushPreviewMode, checkerboard, shiftLinePreviewEnabled, lassoPreviewClosed, selectionCrosshair, balancedShiftLineEnabled, lineConnectionShortcut, rotationIndicatorPosition]) const unrotatedStagePoint = (clientX: number, clientY: number): Point => { const bounds = stageBounds() @@ -884,11 +895,6 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const localPoint = (event: React.PointerEvent): Point | null => localPointAt(event.clientX, event.clientY) - const clampSelectionPoint = (point: Point): Point => ({ - x: Math.max(0, Math.min(session.document.width - 1, point.x)), - y: Math.max(0, Math.min(session.document.height - 1, point.y)) - }) - const topEditableLayerAt = (point: Point) => { for (let index = session.document.layers.length - 1; index >= 0; index -= 1) { const layer = session.document.layers[index] @@ -951,7 +957,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const canvasResizeContains = (event: React.PointerEvent): boolean => canvasResizeContainsAt(event.clientX, event.clientY) - const updateCursorAt = (clientX: number, clientY: number, ctrlKey: boolean, altKey: boolean): void => { + const updateCursorAt = (clientX: number, clientY: number, ctrlKey: boolean, altKey: boolean, shiftKey = false): void => { const canvas = canvasRef.current if (!canvas) return if (inputRef.current.spaceHeld) { @@ -1001,17 +1007,18 @@ export function CanvasStage({ session }: { session: DocumentSession }) { canvas.style.cursor = transformCursor return } - if (inputRef.current.drag?.kind === 'marquee' || inputRef.current.drag?.kind === 'lasso' || inputRef.current.drag?.kind === 'magic-preview') { + if (inputRef.current.drag?.kind === 'marquee' || inputRef.current.drag?.kind === 'lasso' || inputRef.current.drag?.kind === 'polygon-lasso' || inputRef.current.drag?.kind === 'magic-preview') { inputRef.current.sampling = false - canvas.style.cursor = canvasToolCursor('selection', contrastColor) + canvas.style.cursor = selectionCreationCursor(selectionCrosshair, activeLayerEditable) return } const altActive = inputRef.current.altHeld || altKey const ctrlActive = inputRef.current.ctrlHeld || ctrlKey const modifierSizing = ctrlKey && altActive && (session.tool === 'pencil' || session.tool === 'eraser') - const selectionHit = session.tool === 'selection' ? selectionHitAt(clientX, clientY) : 'outside' - const moveCopyAvailable = session.tool === 'move' && insideDocument && Boolean(session.moveAutoSelect ? (point && topEditableLayerAt(point)) : activeLayerEditable) - const selectionCopyAvailable = session.tool === 'selection' && !altActive && ctrlActive && selectionHit === 'inside' && activeLayerEditable && !session.pendingPaste + const rawSelectionHit = session.tool === 'selection' ? selectionHitAt(clientX, clientY) : 'outside' + const selectionHit = shiftKey ? 'outside' : session.selectionMode === 'replace' || session.selectionMode === 'add' || rawSelectionHit !== 'inside' ? rawSelectionHit : 'outside' + const moveCopyAvailable = session.tool === 'move' && insideDocument && Boolean(session.moveAutoSelect ? (point && (topEditableLayerAt(point) ?? (activeLayerEditable ? getActiveLayer(session.document) : null))) : activeLayerEditable) + const selectionCopyAvailable = session.tool === 'selection' && !altActive && ctrlActive && selectionHit === 'inside' && activeLayerEditable && (!session.pendingPaste || shouldRestartFloatingSelectionForCopy(session.pendingPaste.copy, true)) const copyAvailable = (altActive && moveCopyAvailable) || selectionCopyAvailable const sampling = session.tool === 'eyedropper' || (altActive && !(session.tool === 'move' && moveCopyAvailable) && !modifierSizing) inputRef.current.sampling = sampling @@ -1031,7 +1038,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { ? canvasCursors.move : hit === 'edge' ? canvasCursors.selectionMove - : canvasToolCursor(session.tool, contrastColor, available) + : selectionCreationCursor(selectionCrosshair, activeLayerEditable) } else canvas.style.cursor = canvasToolCursor(session.tool, contrastColor, available) } @@ -1039,20 +1046,31 @@ export function CanvasStage({ session }: { session: DocumentSession }) { inputRef.current.syncModifierKeys(event) const point = localPointAt(event.clientX, event.clientY) if (point) inputRef.current.updatePointer({ point, clientX: event.clientX, clientY: event.clientY, ctrlKey: event.ctrlKey, altKey: event.altKey }) - updateCursorAt(event.clientX, event.clientY, event.ctrlKey, event.altKey) + updateCursorAt(event.clientX, event.clientY, event.ctrlKey, event.altKey, event.shiftKey) } useEffect(() => { const pointer = inputRef.current.pointer if (!pointer.visible) return - updateCursorAt(pointer.clientX, pointer.clientY, inputRef.current.ctrlHeld, inputRef.current.altHeld) + updateCursorAt(pointer.clientX, pointer.clientY, inputRef.current.ctrlHeld, inputRef.current.altHeld, inputRef.current.shiftHeld) scheduleDraw() // Cursor assets and the preview under a stationary pointer must change with the selected tool. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session.tool, session.selectionKind, session.primaryColor.r, session.primaryColor.g, session.primaryColor.b, session.primaryColor.a, activeLayerEditable]) + }, [session.tool, session.selectionKind, session.selectionMode, session.selection, session.primaryColor.r, session.primaryColor.g, session.primaryColor.b, session.primaryColor.a, activeLayerEditable]) const activeColor = (button = 0): RgbaColor => session.tool === 'eraser' ? TRANSPARENT : button === 2 ? session.secondaryColor : session.primaryColor + const commitPolygonLasso = (): void => { + const drag = inputRef.current.drag + if (drag?.kind !== 'polygon-lasso') return + inputRef.current.finish() + const before = drag.selectionStart ?? null + const path = polygonLassoClosedPathPoints(drag.path ?? [], balancedShiftLineEnabled) + const after = combineSelection(before, lassoSelection(session.document, path), drag.selectionMode ?? session.selectionMode) + useWorkspace.getState().commitSelectionChange(before, after, '多边形套索选区') + scheduleDraw() + } + const handlePointerDown = (event: React.PointerEvent): void => { event.currentTarget.tabIndex = -1 event.currentTarget.focus({ preventScroll: true }) @@ -1060,9 +1078,12 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (zoomPreviewStartRef.current) finishZoomPreview() if (state.activeId !== session.document.id) { state.setActive(session.document.id); return } if (event.button === 1 || (event.button === 0 && inputRef.current.spaceHeld)) { - const origin = { x: 0, y: 0 } const view = liveViewRef.current - inputRef.current.drag = { kind: 'pan', start: origin, last: origin, startPan: { x: view.panX, y: view.panY }, startClient: { x: event.clientX, y: event.clientY } } + inputRef.current.drag = createCanvasPanDrag( + { x: view.panX, y: view.panY }, + { x: event.clientX, y: event.clientY }, + inputRef.current.drag ?? undefined + ) beginPanPreview() event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.style.cursor = canvasCursors.grabbing @@ -1071,9 +1092,20 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } const point = localPoint(event) if (!point) return - const selectionPoint = clampSelectionPoint(point) updateCursor(event) event.currentTarget.setPointerCapture(event.pointerId) + const activePolygon = inputRef.current.drag + if (session.tool === 'selection' && activePolygon?.kind === 'polygon-lasso' && (event.button === 0 || event.button === 2)) { + const path = activePolygon.path ?? [] + if (shouldClosePolygonLasso(path, point, event.detail)) { + commitPolygonLasso() + return + } + activePolygon.path = appendPolygonLassoVertex(path, point) + activePolygon.last = point + scheduleDraw() + return + } const resizeEdge = canvasResizeHit(event) const activeResizePreview = canvasResizePreviewRef.current if (activeResizePreview) { @@ -1091,7 +1123,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { && session.tool !== 'hand' && session.tool !== 'zoom' && session.tool !== 'move' - && (session.selectedGroupId !== null || session.selectedLayerIds.length > 1) + && (session.selectedGroupIds.length > 0 || session.selectedLayerIds.length > 1) if (focusesRasterLayer) state.selectLayer(session.document.activeLayerId) const hasRasterFocus = hasSelectedRasterLayer || focusesRasterLayer if (modifierActive(event.nativeEvent, 'brushSizeAdjust') && (session.tool === 'pencil' || session.tool === 'eraser') && !activeBrushImage?.intrinsicSize && event.button === 0) { @@ -1111,20 +1143,20 @@ export function CanvasStage({ session }: { session: DocumentSession }) { draw() } const selectionTool = session.tool === 'selection' - const selectionMode = (): SelectionMode => modifierActive(event.nativeEvent, 'addToSelection') ? 'add' : session.selectionMode + const selectionMode = (): SelectionMode => event.button === 2 ? 'subtract' : (event.shiftKey || modifierActive(event.nativeEvent, 'addToSelection')) ? 'add' : session.selectionMode const editableLayer = getActiveLayer(session.document) const canEditLayer = hasRasterFocus && isLayerEffectivelyVisible(session.document, editableLayer) && !isLayerEffectivelyLocked(session.document, editableLayer) const eyedropperHeld = modifierActive(event.nativeEvent, 'temporaryEyedropper') const copyLayerHeld = modifierActive(event.nativeEvent, 'copyLayerOnDrag') - if (selectionTool && event.button === 0 && !canEditLayer && !eyedropperHeld) return + if (selectionTool && (event.button === 0 || event.button === 2) && !canEditLayer && !eyedropperHeld) return if (session.tool === 'move' && event.button === 0) { - const selectedMovableLayers = (session.selectedGroupId ? [] : session.selectedLayerIds) + const selectedMovableLayers = (session.selectedGroupIds.length > 0 ? [] : session.selectedLayerIds) .map((id) => session.document.layers.find((layer) => layer.id === id)) .filter((layer): layer is RasterLayer => Boolean(layer && isLayerEffectivelyVisible(session.document, layer) && !isLayerEffectivelyLocked(session.document, layer))) const moveAllSelectedLayers = selectedMovableLayers.length > 1 const target = moveAllSelectedLayers ? selectedMovableLayers.find((layer) => layer.id === session.document.activeLayerId) ?? selectedMovableLayers[0] - : session.moveAutoSelect ? topEditableLayerAt(point) : (canEditLayer ? editableLayer : null) + : session.moveAutoSelect ? (topEditableLayerAt(point) ?? (canEditLayer ? editableLayer : null)) : (canEditLayer ? editableLayer : null) if (!target) { if (eyedropperHeld) sampleAtPoint(); return } if (session.moveAutoSelect && !moveAllSelectedLayers) state.selectLayer(target.id) const layerIds = (moveAllSelectedLayers ? selectedMovableLayers.map((layer) => layer.id) : [target.id]).filter((id) => { @@ -1138,7 +1170,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { inputRef.current.drag = { kind: 'move-layer', start: point, last: point, layerId: target.id, layerOffset: { x: target.offsetX, y: target.offsetY }, layerIds, layerOffsets, duplicateOnDrag: copyLayerHeld && layerIds.length === 1, - originalSelectedLayerIds: [...session.selectedLayerIds] + originalSelectedLayerIds: [...session.selectedLayerIds], selectionStart: cloneSelection(session.selection) } event.currentTarget.style.cursor = copyLayerHeld ? canvasCursors.copy : canvasCursors.move return @@ -1158,24 +1190,30 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (lineConnectionActive(event.nativeEvent) && hasRasterFocus && (session.tool === 'pencil' || session.tool === 'eraser') && lineAnchor && event.button === 0) { if (canEditLayer) { const edit = beginPixelEdit(editableLayer.id) - paintLine(session.document, editableLayer, edit, lineAnchor.x, lineAnchor.y, point.x, point.y, session.brushSize, activeColor(), session.selection, session.brushShape, session.tool === 'pencil' || session.tool === 'eraser' ? activeBrushTexture : 'solid', session.brushTextureScale, session.tool === 'pencil' || session.tool === 'eraser' ? activeBrushImage : null, session.brushImageSettings, proceduralAntialiasStrength, activeBrushPaintMode, brushPatternOrigin(lineAnchor)) + paintLine(session.document, editableLayer, edit, lineAnchor.x, lineAnchor.y, point.x, point.y, session.brushSize, activeColor(), session.selection, session.brushShape, session.tool === 'pencil' || session.tool === 'eraser' ? activeBrushTexture : 'solid', session.brushTextureScale, session.tool === 'pencil' || session.tool === 'eraser' ? activeBrushImage : null, session.brushImageSettings, proceduralAntialiasStrength, activeBrushPaintMode, brushPatternOrigin(lineAnchor), balancedShiftLineEnabled ? 'balanced' : 'raster') state.commitPixelEdit(edit, session.tool === 'eraser' ? '橡皮擦直线' : '铅笔直线') if (session.tool === 'eraser') state.setLastEraserPoint(point) else state.setLastPencilPoint(point) } return } - if (selectionTool && event.button === 0) { + if (selectionTool && (event.button === 0 || event.button === 2)) { const mode = selectionMode() - const hit = selectionHit(event) + const rawHit = selectionHit(event) + const transformInteraction = event.button === 0 && !event.shiftKey + const hit = transformInteraction && (mode === 'replace' || mode === 'add' || rawHit !== 'inside') ? rawHit : 'outside' if (eyedropperHeld) { sampleAtPoint(); return } if (hit === 'inside' && session.selection) { - const floating = session.pendingPaste + let floating = session.pendingPaste + const copy = modifierActive(event.nativeEvent, 'copySelectionContent') + if (floating && shouldRestartFloatingSelectionForCopy(floating.copy, copy)) { + state.commitFloatingPaste() + floating = null + } const source = floating?.source ?? captureSelectionTransform(session.document, session.selection) if (source) { const selectionStart = cloneSelection(session.selection) - const copy = modifierActive(event.nativeEvent, 'copySelectionContent') - inputRef.current.drag = { kind: 'move-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, copy: floating ? true : copy, floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart } + inputRef.current.drag = { kind: 'move-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, copy: floating ? floating.copy : copy, floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart } event.currentTarget.style.cursor = !floating && copy ? canvasCursors.copy : canvasCursors.move return } @@ -1184,7 +1222,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (hit === 'edge' && session.selection) { if (session.pendingPaste) { const selectionStart = cloneSelection(session.selection) - inputRef.current.drag = { kind: 'move-content', start: point, last: point, selectionStart, selectionSource: session.pendingPaste.source, previewEdit: session.pendingPaste.previewEdit, copy: true, floatingPaste: true, previewSelection: selectionStart, appliedSelection: selectionStart } + inputRef.current.drag = { kind: 'move-content', start: point, last: point, selectionStart, selectionSource: session.pendingPaste.source, previewEdit: session.pendingPaste.previewEdit, copy: session.pendingPaste.copy, floatingPaste: true, previewSelection: selectionStart, appliedSelection: selectionStart } event.currentTarget.style.cursor = canvasCursors.selectionMove return } @@ -1198,7 +1236,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const source = floating?.source ?? captureSelectionTransform(session.document, session.selection) if (source) { const selectionStart = cloneSelection(session.selection) - inputRef.current.drag = { kind: 'rotate-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, angle: 0, copy: floating ? true : modifierActive(event.nativeEvent, 'copySelectionContent'), floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart } + inputRef.current.drag = { kind: 'rotate-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, angle: 0, copy: floating ? floating.copy : modifierActive(event.nativeEvent, 'copySelectionContent'), floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart } } event.currentTarget.style.cursor = canvasCursors.move return @@ -1209,7 +1247,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (source) { const selectionStart = cloneSelection(session.selection) const modifiers = selectionTransformModifierState(event.nativeEvent) - inputRef.current.drag = { kind: 'transform-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, handle: hit as SelectionHandle, copy: floating ? true : modifiers.copy, floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart } + inputRef.current.drag = { kind: 'transform-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, handle: hit as SelectionHandle, copy: floating ? floating.copy : modifiers.copy, floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart } event.currentTarget.style.cursor = canvasCursors.move } return @@ -1223,12 +1261,16 @@ export function CanvasStage({ session }: { session: DocumentSession }) { draw() return } - if (session.selectionKind === 'lasso') { inputRef.current.drag = { kind: 'lasso', start: point, last: point, selectionStart: cloneSelection(session.selection), selectionMode: mode, path: [point] }; return } + if (session.selectionKind === 'lasso') { inputRef.current.drag = { kind: 'lasso', start: point, last: point, selectionStart: cloneSelection(session.selection), selectionMode: mode, previewSelection: cloneSelection(session.selection), path: [point] }; return } + if (session.selectionKind === 'polygon-lasso') { inputRef.current.drag = { kind: 'polygon-lasso', start: point, last: point, selectionStart: cloneSelection(session.selection), selectionMode: mode, previewSelection: cloneSelection(session.selection), path: [point] }; return } } if (eyedropperHeld && (event.button === 0 || event.button === 2)) { sampleAtPoint(); return } if (shouldStartCanvasPan(session.tool, event.shiftKey, selectionTool)) { const view = liveViewRef.current - inputRef.current.drag = { kind: 'pan', start: point, last: point, startPan: { x: view.panX, y: view.panY }, startClient: { x: event.clientX, y: event.clientY } } + inputRef.current.drag = createCanvasPanDrag( + { x: view.panX, y: view.panY }, + { x: event.clientX, y: event.clientY } + ) beginPanPreview() event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.style.cursor = canvasCursors.grabbing @@ -1241,11 +1283,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } if (session.tool === 'fill') { if (!canEditLayer) return; const edit = floodFill(session.document, editableLayer, point.x, point.y, activeColor(event.button), session.selection, session.fillMode === 'contiguous', activeBrushImage, session.brushSize, session.brushImageSettings, activeBrushTexture, Math.max(1, Math.round(session.brushSize / 8)), proceduralAntialiasStrength, activeBrushPaintMode); if (edit) state.commitPixelEdit(edit, activeBrushImage || activeBrushTexture !== 'solid' ? '笔刷填充' : session.fillMode === 'contiguous' ? '连续填充' : '不连续填充'); return } if (session.tool === 'eyedropper') { sampleAtPoint(false); return } - if (session.tool === 'selection' && (session.selectionKind === 'rectangle' || session.selectionKind === 'ellipse')) { + if (session.tool === 'selection' && (session.selectionKind === 'rectangle' || session.selectionKind === 'ellipse') && (event.button === 0 || event.button === 2)) { const mode = selectionMode() - inputRef.current.drag = { kind: 'marquee', start: point, last: point, selectionStart: cloneSelection(session.selection), selectionMode: mode, constrain: event.shiftKey } - const initial = session.selectionKind === 'ellipse' ? ellipseSelection(selectionPoint.x, selectionPoint.y, 1, 1) : rectSelection(selectionPoint.x, selectionPoint.y, 1, 1) - state.setSelection(combineSelection(session.selection, initial, mode)) + inputRef.current.drag = { kind: 'marquee', start: point, last: point, startClient: { x: event.clientX, y: event.clientY }, selectionStart: cloneSelection(session.selection), selectionMode: mode, constrain: false } return } if (session.tool === 'shape') { if (!canEditLayer) return; inputRef.current.drag = { kind: 'shape', start: point, last: point, constrain: event.shiftKey }; draw(); return } @@ -1271,6 +1311,23 @@ export function CanvasStage({ session }: { session: DocumentSession }) { event.currentTarget.style.cursor = canvasCursors.grabbing return } + const autoPanDrag = inputRef.current.drag + if (autoPanDrag && ['marquee', 'lasso', 'polygon-lasso', 'move-selection', 'move-content', 'transform-content', 'rotate-content'].includes(autoPanDrag.kind)) { + const bounds = stageBounds() + const edge = 28 + const edgeSpeed = (position: number, start: number, end: number): number => position < start + edge + ? -Math.min(16, Math.max(2, (start + edge - position) * 0.4)) + : position > end - edge ? Math.min(16, Math.max(2, (position - (end - edge)) * 0.4)) : 0 + const screenX = edgeSpeed(event.clientX, bounds.left, bounds.right) + const screenY = edgeSpeed(event.clientY, bounds.top, bounds.bottom) + if (screenX !== 0 || screenY !== 0) { + const view = liveViewRef.current + const delta = viewPanDeltaFromScreen(-screenX, -screenY, view.rotation, rotationIndicatorPosition, view.mirrored, view.mirroredVertical) + liveViewRef.current = { ...view, panX: view.panX + delta.x, panY: view.panY + delta.y } + useWorkspace.getState().setView({ panX: liveViewRef.current.panX, panY: liveViewRef.current.panY }) + applyRotationStyle(liveViewRef.current) + } + } updateCursor(event) inputRef.current.shiftLinePreview = Boolean(lineConnectionActive(event.nativeEvent) && !canvasResizePreviewRef.current && !inputRef.current.spaceHeld && (session.tool === 'pencil' || session.tool === 'eraser') && lineAnchor) const point = localPoint(event) @@ -1366,6 +1423,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { active.document.activeLayerId = copy.id active.selectedLayerIds = [copy.id] active.selectedGroupId = null + active.selectedGroupIds = [] drag.duplicatedLayerId = copy.id drag.duplicatedLayer = copy drag.duplicatedLayerIndex = insertionIndex @@ -1380,6 +1438,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { layer.offsetX = offset.x + distanceX layer.offsetY = offset.y + distanceY } + if (drag.selectionStart) active.selection = shiftSelection(drag.selectionStart, distanceX, distanceY, active.document.width, active.document.height) }, false) compositeCacheRef.current.invalidateAll() scheduleDraw() @@ -1409,6 +1468,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } if (drag.kind === 'shape') { drag.constrain = event.shiftKey; scheduleDraw(); return } if (drag.kind === 'marquee') { + drag.moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) + if (!drag.moved) { scheduleDraw(); return } drag.constrain = event.shiftKey const mode = drag.selectionMode ?? session.selectionMode // Generate from the true drag bounds first. Clamping the bounding box @@ -1419,7 +1480,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { ? ellipseSelection(bounds.x, bounds.y, bounds.width, bounds.height) : rectSelection(bounds.x, bounds.y, bounds.width, bounds.height) const incoming = transformSelectionMask(rawIncoming, rawIncoming, session.document.width, session.document.height) - state.setSelection(combineSelection(drag.selectionStart ?? null, incoming, mode)); return + drag.previewSelection = combineSelection(drag.selectionStart ?? null, incoming, mode) + scheduleDraw(); return } if (drag.kind === 'magic-preview') { const mode = drag.selectionMode ?? session.selectionMode @@ -1429,10 +1491,13 @@ export function CanvasStage({ session }: { session: DocumentSession }) { return } if (drag.kind === 'lasso') { - drag.path = [...(drag.path ?? []), point] + const path = drag.path ?? [] + const last = path.at(-1) + if (!last || last.x !== point.x || last.y !== point.y) drag.path = [...path, point] scheduleDraw() return } + if (drag.kind === 'polygon-lasso') { scheduleDraw(); return } if ((drag.kind === 'move-selection' || drag.kind === 'move-content') && drag.selectionStart) { const start = drag.selectionStart // Keep at least one selected pixel on the canvas, while allowing a @@ -1473,6 +1538,11 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } const handlePointerUp = (event: React.PointerEvent): void => { + if (inputRef.current.drag?.kind === 'polygon-lasso') { + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) + scheduleDraw() + return + } const drag = inputRef.current.finish() if (!drag) return if (drag.kind === 'canvas-resize' || drag.kind === 'canvas-move') flushCanvasResizePreview() @@ -1487,6 +1557,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const state = useWorkspace.getState() if (drag.kind === 'pan') { finishPanPreview() + const resumedDrag = restoreCanvasDragAfterPan(drag, localPoint(event) ?? drag.resumeDrag?.last ?? drag.last) + if (resumedDrag) inputRef.current.drag = resumedDrag updateCursor(event) draw() return @@ -1511,7 +1583,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } if (drag.kind === 'sample-color') { inputRef.current.sampling = false - if (!drag.temporarySampling) { + if (!drag.temporarySampling && eyedropperSwitchToPencil) { state.setTool('pencil') event.currentTarget.style.cursor = canvasToolCursor('pencil', session.primaryColor) } else updateCursor(event) @@ -1526,6 +1598,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } if (drag.kind === 'move-layer' && !drag.duplicatedLayer && drag.layerIds && drag.layerIds.length > 1 && drag.layerOffsets) { const before = drag.layerOffsets + const beforeSelection = cloneSelection(drag.selectionStart ?? null) + const afterSelection = cloneSelection(session.selection) const after = Object.fromEntries(drag.layerIds.map((id) => { const layer = session.document.layers.find((candidate) => candidate.id === id) return [id, { x: layer?.offsetX ?? before[id].x, y: layer?.offsetY ?? before[id].y }] @@ -1533,8 +1607,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (drag.layerIds.some((id) => after[id].x !== before[id].x || after[id].y !== before[id].y)) { state.pushHistory({ label: '移动所选图层内容', bytes: drag.layerIds.length * 32, - undo: () => { for (const id of drag.layerIds ?? []) { const layer = session.document.layers.find((candidate) => candidate.id === id); const offset = before[id]; if (layer && offset) { layer.offsetX = offset.x; layer.offsetY = offset.y } } }, - redo: () => { for (const id of drag.layerIds ?? []) { const layer = session.document.layers.find((candidate) => candidate.id === id); const offset = after[id]; if (layer && offset) { layer.offsetX = offset.x; layer.offsetY = offset.y } } } + undo: () => { for (const id of drag.layerIds ?? []) { const layer = session.document.layers.find((candidate) => candidate.id === id); const offset = before[id]; if (layer && offset) { layer.offsetX = offset.x; layer.offsetY = offset.y } } session.selection = cloneSelection(beforeSelection) }, + redo: () => { for (const id of drag.layerIds ?? []) { const layer = session.document.layers.find((candidate) => candidate.id === id); const offset = after[id]; if (layer && offset) { layer.offsetX = offset.x; layer.offsetY = offset.y } } session.selection = cloneSelection(afterSelection) } }) state.mutateActive(() => {}, true) } @@ -1544,6 +1618,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (layer && (layer.offsetX !== drag.layerOffset.x || layer.offsetY !== drag.layerOffset.y)) { const before = { ...drag.layerOffset } const after = { x: layer.offsetX, y: layer.offsetY } + const beforeSelection = cloneSelection(drag.selectionStart ?? null) + const afterSelection = cloneSelection(session.selection) state.pushHistory({ label: '移动图层内容', bytes: 32, undo: () => { @@ -1552,8 +1628,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { session.document.activeLayerId = drag.layerId! session.selectedLayerIds = drag.originalSelectedLayerIds?.length ? [...drag.originalSelectedLayerIds] : [drag.layerId!] session.selectedGroupId = null + session.selectedGroupIds = [] } else { const target = session.document.layers.find((candidate) => candidate.id === layerId); if (target) { target.offsetX = before.x; target.offsetY = before.y } } + session.selection = cloneSelection(beforeSelection) }, redo: () => { if (drag.duplicatedLayer) { @@ -1562,7 +1640,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { session.document.activeLayerId = layerId session.selectedLayerIds = [layerId] session.selectedGroupId = null + session.selectedGroupIds = [] } else { const target = session.document.layers.find((candidate) => candidate.id === layerId); if (target) { target.offsetX = after.x; target.offsetY = after.y } } + session.selection = cloneSelection(afterSelection) } }) state.mutateActive(() => {}, true) @@ -1573,11 +1653,18 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const layer = getActiveLayer(session.document) if (!isLayerEffectivelyLocked(session.document, layer)) { const edit = beginPixelEdit(layer.id) - paintShape(session.document, layer, edit, shapeBounds(drag.start, drag.last, drag.constrain), session.shapeKind, session.primaryColor, session.selection) - state.commitPixelEdit(edit, session.shapeKind === 'ellipse' ? '绘制圆形' : '绘制矩形') + paintShape(session.document, layer, edit, shapeBounds(drag.start, drag.last, drag.constrain, session.shapeRatio), session.shapeKind, session.primaryColor, session.selection) + const ellipse = session.shapeKind === 'ellipse' || session.shapeKind === 'ellipse-outline' + state.commitPixelEdit(edit, ellipse ? '绘制椭圆' : '绘制矩形') } } - if (drag.kind === 'marquee') state.commitSelectionChange(drag.selectionStart ?? null, session.selection, '创建选区') + if (drag.kind === 'marquee') { + const moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) + const after = finalizeMarqueeSelection(drag.selectionStart ?? null, drag.previewSelection ?? session.selection, moved, drag.selectionMode ?? session.selectionMode) + state.commitSelectionChange(drag.selectionStart ?? null, after, '创建选区') + updateCursor(event) + scheduleDraw() + } if (drag.kind === 'lasso') { const mode = drag.selectionMode ?? session.selectionMode const before = drag.selectionStart ?? null @@ -1588,11 +1675,26 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (drag.kind === 'move-selection' && drag.selectionStart && drag.previewSelection) state.commitSelectionChange(drag.selectionStart, drag.previewSelection, '移动选框') if ((drag.kind === 'move-content' || drag.kind === 'transform-content' || drag.kind === 'rotate-content') && drag.selectionStart && drag.previewSelection) { if (drag.floatingPaste && drag.previewEdit) state.updateFloatingPastePreview(drag.previewEdit, drag.previewSelection) + else if (drag.kind === 'move-content' && drag.previewEdit && drag.selectionSource) state.beginFloatingSelectionTransform(drag.selectionSource, drag.previewEdit, drag.selectionStart, drag.previewSelection, Boolean(drag.copy), drag.copy ? '复制选区内容' : '移动选区内容') else state.commitSelectionTransform(drag.previewEdit ?? null, drag.selectionStart, drag.previewSelection, drag.copy ? '复制选区内容' : '移动选区内容') } draw() } + useEffect(() => { + const keyDown = (event: KeyboardEvent): void => { + if (inputRef.current.drag?.kind !== 'polygon-lasso') return + if (event.key === 'Enter') commitPolygonLasso() + else if (event.key === 'Escape') { inputRef.current.finish(); scheduleDraw() } + else return + event.preventDefault() + event.stopImmediatePropagation() + } + window.addEventListener('keydown', keyDown, true) + return () => window.removeEventListener('keydown', keyDown, true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.document.id, session.selectionMode]) + const onWheel = (event: React.WheelEvent): void => { const canvas = canvasRef.current if (!canvas) return @@ -1623,5 +1725,5 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const pointerUp = (event: React.PointerEvent): void => measurePointerInput('pointer-up', () => handlePointerUp(event)) const rotationStyle = { transform: 'none', transformOrigin: '50% 50%' } - return
{ inputRef.current.pointer.visible = false; inputRef.current.shiftLinePreview = false; inputRef.current.sampling = false; inputRef.current.altHeld = false; inputRef.current.ctrlHeld = false; if (!inputRef.current.drag) event.currentTarget.style.cursor = canvasResizePreviewRef.current ? canvasCursors.unavailable : inputRef.current.spaceHeld ? canvasCursors.grab : canvasToolCursor(session.tool, session.primaryColor); draw() }} onPointerEnter={(event) => { updateCursor(event); inputRef.current.shiftLinePreview = lineConnectionActive(event.nativeEvent) && !canvasResizePreviewRef.current && !inputRef.current.sampling && (session.tool === 'pencil' || session.tool === 'eraser') && Boolean(lineAnchor); draw() }} onContextMenu={(event) => event.preventDefault()} onWheel={onWheel} />
+ return
{ if (inputRef.current.drag?.kind !== 'polygon-lasso') return; event.preventDefault(); commitPolygonLasso() }} onPointerLeave={(event) => { inputRef.current.pointer.visible = false; inputRef.current.shiftLinePreview = false; inputRef.current.sampling = false; inputRef.current.altHeld = false; inputRef.current.ctrlHeld = false; inputRef.current.shiftHeld = false; if (!inputRef.current.drag) event.currentTarget.style.cursor = canvasResizePreviewRef.current ? canvasCursors.unavailable : inputRef.current.spaceHeld ? canvasCursors.grab : canvasToolCursor(session.tool, session.primaryColor); draw() }} onPointerEnter={(event) => { updateCursor(event); inputRef.current.shiftLinePreview = lineConnectionActive(event.nativeEvent) && !canvasResizePreviewRef.current && !inputRef.current.sampling && (session.tool === 'pencil' || session.tool === 'eraser') && Boolean(lineAnchor); draw() }} onContextMenu={(event) => event.preventDefault()} onWheel={onWheel} />
} diff --git a/src/renderer/src/components/ColorPicker.tsx b/src/renderer/src/components/ColorPicker.tsx index 45468ef..8a4a426 100644 --- a/src/renderer/src/components/ColorPicker.tsx +++ b/src/renderer/src/components/ColorPicker.tsx @@ -642,8 +642,8 @@ export function ColorPicker({ color, secondaryColor, onChange, onSecondaryChange : event.preventDefault()} onPointerDown={(event) => beginStripPointer(event, 'value')} onPointerMove={(event) => moveStripPointer(event, 'value')} onPointerUp={endStripPointer} onPointerCancel={endStripPointer} onInput={(event) => { if (!pointerInputActiveRef.current) updateStripFromNativeInput('value', Number(event.currentTarget.value), 1000) }} />} event.preventDefault()} onPointerDown={(event) => beginStripPointer(event, 'alpha')} onPointerMove={(event) => moveStripPointer(event, 'alpha')} onPointerUp={endStripPointer} onPointerCancel={endStripPointer} onInput={(event) => { if (!pointerInputActiveRef.current) updateStripFromNativeInput('alpha', Number(event.currentTarget.value), 255) }} /> - - {secondaryColor && onSecondaryChange && } + + {secondaryColor && onSecondaryChange && } } diff --git a/src/renderer/src/components/ColorValueControl.test.tsx b/src/renderer/src/components/ColorValueControl.test.tsx index 54dd733..ab7b74c 100644 --- a/src/renderer/src/components/ColorValueControl.test.tsx +++ b/src/renderer/src/components/ColorValueControl.test.tsx @@ -10,6 +10,7 @@ describe('ColorValueControl', () => { fireEvent.click(screen.getByRole('button', { name: '背景颜色 浅色' })) + expect(screen.getByRole('dialog', { name: '颜色编辑 浅色' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'RGB', selected: true })).toBeInTheDocument() expect(screen.getByLabelText('当前颜色')).toBeInTheDocument() expect(screen.getByRole('spinbutton', { name: '背景颜色 R' })).toHaveValue('41') @@ -34,4 +35,53 @@ describe('ColorValueControl', () => { expect(document.querySelector('.color-editor-preview')).not.toBeInTheDocument() expect(container.querySelector('.color-value-trigger')).toBeInTheDocument() }) + + it('closes the draggable editor from its title bar', () => { + render() + fireEvent.click(screen.getByRole('button', { name: '颜色值' })) + expect(screen.getByRole('dialog', { name: '颜色编辑' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '关闭颜色编辑' })) + expect(screen.queryByRole('dialog', { name: '颜色编辑' })).not.toBeInTheDocument() + }) + + it('closes an unmoved transient editor after clicking outside', () => { + render() + fireEvent.click(screen.getByRole('button', { name: '颜色 前景' })) + fireEvent.pointerDown(document.body) + + expect(screen.queryByRole('dialog', { name: '颜色编辑 前景色' })).not.toBeInTheDocument() + }) + + it('keeps a moved foreground editor resident while opening the background editor', () => { + render(<>) + + fireEvent.click(screen.getByRole('button', { name: '颜色 前景' })) + const foreground = screen.getByRole('dialog', { name: '颜色编辑 前景色' }) + fireEvent.pointerDown(foreground.querySelector('header')!, { button: 0, clientX: 20, clientY: 20 }) + fireEvent.pointerMove(window, { clientX: 40, clientY: 40 }) + fireEvent.pointerUp(window) + fireEvent.click(screen.getByRole('button', { name: '颜色 背景' })) + + expect(screen.getByRole('dialog', { name: '颜色编辑 前景色' })).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: '颜色编辑 背景色' })).toBeInTheDocument() + expect(foreground.querySelectorAll('.floating-resize-handle')).toHaveLength(8) + }) + + it('returns to its default position after closing and reopening', () => { + render() + const trigger = screen.getByRole('button', { name: '颜色 前景' }) + fireEvent.click(trigger) + const first = screen.getByRole('dialog', { name: '颜色编辑 前景色' }) + const defaultLeft = first.style.left + fireEvent.pointerDown(first.querySelector('header')!, { button: 0, clientX: 20, clientY: 20 }) + fireEvent.pointerMove(window, { clientX: 180, clientY: 100 }) + fireEvent.pointerUp(window) + expect(first.style.left).not.toBe(defaultLeft) + + fireEvent.click(screen.getByRole('button', { name: '关闭颜色编辑' })) + fireEvent.click(trigger) + + expect(screen.getByRole('dialog', { name: '颜色编辑 前景色' }).style.left).toBe(defaultLeft) + }) }) diff --git a/src/renderer/src/components/ColorValueControl.tsx b/src/renderer/src/components/ColorValueControl.tsx index 0854db6..952eea1 100644 --- a/src/renderer/src/components/ColorValueControl.tsx +++ b/src/renderer/src/components/ColorValueControl.tsx @@ -1,9 +1,11 @@ import { createPortal } from 'react-dom' import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' import type { RgbaColor } from '@shared/types' import { clampByte } from '@/core/raster' import { colorFromValues, colorToValues, colorValueFields, parseRgbaHex, rgbaHex, type ColorValueMode } from '@/core/color-values' import { NumberInput } from './NumberInput' +import { PanelResizeHandles, type ResizeDirection } from './floating-panel' interface ColorValueControlProps { color: RgbaColor @@ -11,6 +13,7 @@ interface ColorValueControlProps { label: string roleLabel?: string className?: string + storageKey?: string } const modeLabels: Array<{ value: ColorValueMode; label: string }> = [ @@ -20,6 +23,11 @@ const modeLabels: Array<{ value: ColorValueMode; label: string }> = [ { value: 'gray', label: 'Gray' } ] +const COLOR_EDITOR_MIN_WIDTH = 420 +const COLOR_EDITOR_MIN_HEIGHT = 240 +const COLOR_EDITOR_MAX_WIDTH = 720 +const COLOR_EDITOR_MAX_HEIGHT = 480 + const cssColor = (color: RgbaColor): string => `rgb(${color.r} ${color.g} ${color.b} / ${color.a / 255})` const colorGradient = (mode: ColorValueMode, values: Record, fallback: RgbaColor, field: ReturnType[number]): string => { const stops = [0, 0.25, 0.5, 0.75, 1].map((ratio) => { @@ -29,13 +37,22 @@ const colorGradient = (mode: ColorValueMode, values: Record, fal return `linear-gradient(90deg, ${stops.join(', ')})` } -export function ColorValueControl({ color, onChange, label, roleLabel, className = '' }: ColorValueControlProps) { +export function ColorValueControl({ color, onChange, label, roleLabel, className = '', storageKey }: ColorValueControlProps) { const [open, setOpen] = useState(false) const [mode, setMode] = useState('rgb') const [hexText, setHexText] = useState(() => rgbaHex(color)) const [position, setPosition] = useState({ left: 8, top: 8 }) + const [size, setSize] = useState({ width: 468, height: 264 }) + const [resident, setResident] = useState(false) const triggerRef = useRef(null) const popoverRef = useRef(null) + const dragRef = useRef<{ pointerX: number; pointerY: number; left: number; top: number } | null>(null) + const resizeRef = useRef<{ direction: ResizeDirection; pointerX: number; pointerY: number; left: number; top: number; width: number; height: number } | null>(null) + const positionRef = useRef(position) + const sizeRef = useRef(size) + const residentRef = useRef(resident) + const sizeKey = storageKey ? `moonsprite.color-editor-size.${storageKey}` : null + const positionedRef = useRef(false) useEffect(() => setHexText(rgbaHex(color)), [color.r, color.g, color.b, color.a]) @@ -45,26 +62,100 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className const trigger = triggerRef.current?.getBoundingClientRect() const popover = popoverRef.current?.getBoundingClientRect() if (!trigger || !popover) return - const left = Math.max(8, Math.min(window.innerWidth - popover.width - 8, trigger.right - popover.width)) - const top = window.innerHeight - trigger.bottom >= popover.height + 6 + let nextSize = sizeRef.current + if (!positionedRef.current && sizeKey) { + try { + const stored = JSON.parse(localStorage.getItem(sizeKey) ?? 'null') as { width?: number; height?: number } | null + if (stored && Number.isFinite(stored.width) && Number.isFinite(stored.height)) { + nextSize = { + width: Math.max(COLOR_EDITOR_MIN_WIDTH, Math.min(COLOR_EDITOR_MAX_WIDTH, window.innerWidth - 16, stored.width!)), + height: Math.max(COLOR_EDITOR_MIN_HEIGHT, Math.min(COLOR_EDITOR_MAX_HEIGHT, window.innerHeight - 16, stored.height!)) + } + sizeRef.current = nextSize + setSize(nextSize) + } + } catch { localStorage.removeItem(sizeKey) } + } + const left = Math.max(8, Math.min(window.innerWidth - nextSize.width - 8, trigger.right - nextSize.width)) + const top = window.innerHeight - trigger.bottom >= nextSize.height + 6 ? trigger.bottom + 5 - : Math.max(8, trigger.top - popover.height - 5) - setPosition({ left, top }) + : Math.max(8, trigger.top - nextSize.height - 5) + positionRef.current = { left, top } + setPosition(positionRef.current) + positionedRef.current = true } place() window.addEventListener('resize', place) return () => window.removeEventListener('resize', place) - }, [open, mode]) + }, [open, sizeKey]) useEffect(() => { if (!open) return - const close = (event: PointerEvent): void => { + const move = (event: PointerEvent): void => { + const drag = dragRef.current + const popover = popoverRef.current + const resize = resizeRef.current + if (resize) { + const deltaX = event.clientX - resize.pointerX + const deltaY = event.clientY - resize.pointerY + let left = resize.left + let top = resize.top + let width = resize.width + let height = resize.height + if (resize.direction.includes('e')) width = Math.max(COLOR_EDITOR_MIN_WIDTH, Math.min(COLOR_EDITOR_MAX_WIDTH, window.innerWidth - resize.left - 8, resize.width + deltaX)) + if (resize.direction.includes('s')) height = Math.max(COLOR_EDITOR_MIN_HEIGHT, Math.min(COLOR_EDITOR_MAX_HEIGHT, window.innerHeight - resize.top - 8, resize.height + deltaY)) + if (resize.direction.includes('w')) { width = Math.max(COLOR_EDITOR_MIN_WIDTH, Math.min(COLOR_EDITOR_MAX_WIDTH, resize.left + resize.width - 8, resize.width - deltaX)); left = resize.left + resize.width - width } + if (resize.direction.includes('n')) { height = Math.max(COLOR_EDITOR_MIN_HEIGHT, Math.min(COLOR_EDITOR_MAX_HEIGHT, resize.top + resize.height - 8, resize.height - deltaY)); top = resize.top + resize.height - height } + positionRef.current = { left, top } + sizeRef.current = { width, height } + setPosition(positionRef.current) + setSize(sizeRef.current) + return + } + if (!drag || !popover) return + const bounds = popover.getBoundingClientRect() + if (!residentRef.current && Math.hypot(event.clientX - drag.pointerX, event.clientY - drag.pointerY) >= 3) { + residentRef.current = true + setResident(true) + } + const next = { + left: Math.max(8, Math.min(window.innerWidth - bounds.width - 8, drag.left + event.clientX - drag.pointerX)), + top: Math.max(8, Math.min(window.innerHeight - bounds.height - 8, drag.top + event.clientY - drag.pointerY)) + } + positionRef.current = next + setPosition(next) + } + const end = (): void => { + if (resizeRef.current && sizeKey) localStorage.setItem(sizeKey, JSON.stringify(sizeRef.current)) + dragRef.current = null + resizeRef.current = null + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', end) + window.addEventListener('pointercancel', end) + return () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', end) + window.removeEventListener('pointercancel', end) + } + }, [open, sizeKey]) + + useEffect(() => { + residentRef.current = resident + }, [resident]) + + useEffect(() => { + if (!open || resident) return + const closeTransient = (event: PointerEvent): void => { const target = event.target as Node - if (!triggerRef.current?.contains(target) && !popoverRef.current?.contains(target)) setOpen(false) + if (!popoverRef.current?.contains(target) && !triggerRef.current?.contains(target)) { + positionedRef.current = false + setOpen(false) + } } - window.addEventListener('pointerdown', close, true) - return () => window.removeEventListener('pointerdown', close, true) - }, [open]) + window.addEventListener('pointerdown', closeTransient, true) + return () => window.removeEventListener('pointerdown', closeTransient, true) + }, [open, resident]) const commitHex = (): void => { const next = parseRgbaHex(hexText, color.a) @@ -75,13 +166,21 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className } const values = colorToValues(color, mode) + const fields = colorValueFields(mode) const updateValue = (key: string, value: number): void => { const next = colorFromValues(mode, { ...values, [key]: value }, color) onChange(next) setHexText(rgbaHex(next)) } - const editor = open ?
+ const roleTitle = roleLabel ? (roleLabel.endsWith('色') ? roleLabel : `${roleLabel}色`) : '' + const editorLabel = `颜色编辑${roleTitle ? ` ${roleTitle}` : ''}` + const editor = open ?
+
{ + if (event.button !== 0 || (event.target as HTMLElement).closest('button')) return + dragRef.current = { pointerX: event.clientX, pointerY: event.clientY, left: position.left, top: position.top } + event.preventDefault() + }}>颜色编辑{roleTitle ? ` ${roleTitle}` : ''}
{modeLabels.map((option) => )} @@ -89,17 +188,24 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className
-
- {colorValueFields(mode).map((field) => { +
+ {fields.map((field) => { const gradient = colorGradient(mode, values, color, field) const background = field.key === 'a' ? `${gradient}, repeating-conic-gradient(#686d76 0 25%, #aeb3bc 0 50%) 50% / 12px 12px` : gradient return })}
+ { + if (event.button !== 0 || !popoverRef.current) return + const bounds = popoverRef.current.getBoundingClientRect() + resizeRef.current = { direction, pointerX: event.clientX, pointerY: event.clientY, left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height } + event.preventDefault() + event.stopPropagation() + }} />
: null return <> -
} +function DeleteIconButtonPreview() { + return
+} + function ContextMenuPreview() { const [open, setOpen] = useState(true) return
@@ -187,6 +194,7 @@ function DockPreview() { const previewRenderers: Record ReactElement> = { buttons: ButtonsPreview, 'icon-button': IconButtonPreview, + 'delete-icon-button': DeleteIconButtonPreview, 'context-menu': ContextMenuPreview, segmented: SegmentedPreview, 'number-input': NumberInputPreview, @@ -221,7 +229,7 @@ export function ComponentLibrary({ onClose }: { onClose: () => void }) { const Preview = previewRenderers[selectedEntry.id] return
{ if (event.target === event.currentTarget) onClose() }}> -
+
MOONSPRITE UI

组件库

可复用组件与交互状态

{COMPONENT_LIBRARY_ENTRIES.length} 个已登记组件新建 UI 前先从这里选择
- + } diff --git a/src/renderer/src/components/DeleteIconButton.test.tsx b/src/renderer/src/components/DeleteIconButton.test.tsx new file mode 100644 index 0000000..9a895b4 --- /dev/null +++ b/src/renderer/src/components/DeleteIconButton.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { COMPONENT_LIBRARY_ENTRIES } from './ComponentLibrary' +import { DeleteIconButton } from './DeleteIconButton' + +describe('DeleteIconButton', () => { + it('provides unified compact, regular and disabled deletion states', () => { + const onClick = vi.fn() + const { rerender } = render() + expect(screen.getByRole('button', { name: '删除预设' })).toHaveClass('delete-icon-button', 'compact') + + rerender() + expect(screen.getByRole('button', { name: '删除预设' })).toHaveClass('delete-icon-button', 'regular') + expect(screen.getByRole('button', { name: '删除预设' })).toBeDisabled() + }) + + it('is registered in the reusable component library', () => { + expect(COMPONENT_LIBRARY_ENTRIES.some((entry) => entry.id === 'delete-icon-button')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/DeleteIconButton.tsx b/src/renderer/src/components/DeleteIconButton.tsx new file mode 100644 index 0000000..d21390c --- /dev/null +++ b/src/renderer/src/components/DeleteIconButton.tsx @@ -0,0 +1,15 @@ +import { Trash2 } from 'lucide-react' +import type { ButtonHTMLAttributes } from 'react' + +export interface DeleteIconButtonProps extends Omit, 'children'> { + size?: 'compact' | 'regular' +} + +export function DeleteIconButton({ className = '', size = 'compact', title = '删除', ...props }: DeleteIconButtonProps) { + return +} diff --git a/src/renderer/src/components/HomeWorkspace.test.tsx b/src/renderer/src/components/HomeWorkspace.test.tsx new file mode 100644 index 0000000..d1fd20e --- /dev/null +++ b/src/renderer/src/components/HomeWorkspace.test.tsx @@ -0,0 +1,104 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { MoonSpriteApi } from '@shared/types' +import { createDocument } from '@/core/document' +import { getRecentProjects, recordRecentProject } from '@/core/home-history' +import { encodeProject } from '@/core/project-format' +import { HomeWorkspace } from './HomeWorkspace' + +const galleryProject = { + filePath: 'C:\\gallery\\slow.moonsprite', + fileName: 'slow.moonsprite', + modifiedAt: 1 +} + +afterEach(() => { + cleanup() + localStorage.clear() + vi.restoreAllMocks() +}) + +function installApi(overrides: Partial): void { + window.moonSprite = { + ensureBuiltinExample: vi.fn(async () => null), + listGalleryProjects: vi.fn(async () => ({ directoryPath: 'C:\\gallery', projects: [galleryProject] })), + deleteGalleryProject: vi.fn(async () => undefined), + ...overrides + } as unknown as MoonSpriteApi +} + +describe('HomeWorkspace', () => { + it('shows project rows before their previews finish decoding', async () => { + localStorage.setItem('moonsprite.home-section.v1', 'gallery') + installApi({ readBinary: vi.fn(() => new Promise(() => {})) }) + + render( true)} onRestoreRecovery={vi.fn(async () => true)} />) + + expect(await screen.findByText('slow.moonsprite')).toBeInTheDocument() + expect(screen.queryByText('其他')).not.toBeInTheDocument() + }) + + it('limits progressive preview loading to three concurrent files', async () => { + localStorage.setItem('moonsprite.home-section.v1', 'gallery') + const galleryProjects = Array.from({ length: 5 }, (_, index) => ({ filePath: `C:\\gallery\\large-${index}.moonsprite`, fileName: `large-${index}.moonsprite`, modifiedAt: index + 10 })) + const readBinary = vi.fn(() => new Promise(() => {})) + installApi({ + listGalleryProjects: vi.fn(async () => ({ directoryPath: 'C:\\gallery', projects: galleryProjects })), + readBinary + }) + + render( true)} onRestoreRecovery={vi.fn(async () => true)} />) + + expect(await screen.findByText('large-4.moonsprite')).toBeInTheDocument() + await waitFor(() => expect(readBinary).toHaveBeenCalledTimes(3)) + }) + + it('keeps an unreadable gallery file when opening fails', async () => { + localStorage.setItem('moonsprite.home-section.v1', 'gallery') + const deleteGalleryProject = vi.fn(async () => undefined) + const onOpenProject = vi.fn(async () => false) + installApi({ readBinary: vi.fn(async () => { throw new Error('broken preview') }), deleteGalleryProject }) + + render( true)} />) + const openButton = await screen.findByTitle(/点击重新尝试打开/) + fireEvent.click(openButton) + + await waitFor(() => expect(onOpenProject).toHaveBeenCalledWith(galleryProject.filePath, false)) + expect(deleteGalleryProject).not.toHaveBeenCalled() + }) + + it('removes an unreadable recent record without deleting its file', async () => { + const filePath = 'C:\\art\\missing-home-test.moonsprite' + recordRecentProject(filePath) + const deleteGalleryProject = vi.fn(async () => undefined) + installApi({ readBinary: vi.fn(async () => { throw new Error('missing') }), deleteGalleryProject }) + + render( false)} onRestoreRecovery={vi.fn(async () => true)} />) + fireEvent.click(await screen.findByRole('button', { name: '从最近移除 missing-home-test.moonsprite' })) + + expect(getRecentProjects()).toEqual([]) + expect(deleteGalleryProject).not.toHaveBeenCalled() + }) + + it('reuses a decoded preview while its path and timestamp stay unchanged', async () => { + const cachedProject = { ...galleryProject, filePath: 'C:\\gallery\\cached-home-test.moonsprite', fileName: 'cached-home-test.moonsprite', modifiedAt: 73 } + const bytes = encodeProject(createDocument('cached preview', 3, 2, 'rgba')) + const readBinary = vi.fn(async () => bytes) + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => `blob:preview-${Math.random()}`) }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + localStorage.setItem('moonsprite.home-section.v1', 'gallery') + installApi({ + listGalleryProjects: vi.fn(async () => ({ directoryPath: 'C:\\gallery', projects: [cachedProject] })), + readBinary + }) + + render( true)} onRestoreRecovery={vi.fn(async () => true)} />) + const refresh = await screen.findByRole('button', { name: '刷新当前栏目' }) + await waitFor(() => expect(readBinary).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(refresh).not.toBeDisabled()) + fireEvent.click(refresh) + + await waitFor(() => expect(screen.getByText('3 x 2 · RGBA')).toBeInTheDocument()) + expect(readBinary).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/HomeWorkspace.tsx b/src/renderer/src/components/HomeWorkspace.tsx index 81b4676..1b7c484 100644 --- a/src/renderer/src/components/HomeWorkspace.tsx +++ b/src/renderer/src/components/HomeWorkspace.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' -import { Eraser, FileImage, FolderOpen, GripVertical, Images, MoreHorizontal, Pin, Plus, RefreshCw, Trash2, TriangleAlert } from 'lucide-react' +import { Eraser, FileImage, FolderOpen, GripVertical, Images, Pin, Plus, RefreshCw, Trash2, TriangleAlert, X } from 'lucide-react' import type { RecoveryRecord } from '@shared/types' import { APP_CHANNEL_LABEL } from '@/core/app-meta' import { readProjectGalleryMetadata } from '@/core/project-format' @@ -24,11 +24,29 @@ interface HomeWorkspaceProps { onRestoreRecovery(id: string): Promise } -type HomeSection = 'recent' | 'gallery' | 'recovery' | 'other' +type HomeSection = 'recent' | 'gallery' | 'recovery' const homeSectionStorageKey = 'moonsprite.home-section.v1' +interface CachedProjectPreview { + bytes: Uint8Array + width: number + height: number + colorMode: ProjectCard['colorMode'] +} +const projectPreviewCache = new Map() +const maxCachedProjectPreviews = 48 +const previewCacheKey = (record: RecentProject): string => `${record.filePath}\u0000${record.lastOpened}` +const cacheProjectPreview = (key: string, preview: CachedProjectPreview): void => { + projectPreviewCache.delete(key) + projectPreviewCache.set(key, preview) + while (projectPreviewCache.size > maxCachedProjectPreviews) projectPreviewCache.delete(projectPreviewCache.keys().next().value!) +} +const createPreviewUrl = (bytes: Uint8Array): string => { + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer + return URL.createObjectURL(new Blob([buffer], { type: 'image/png' })) +} const loadHomeSection = (): HomeSection => { const stored = localStorage.getItem(homeSectionStorageKey) - return stored === 'gallery' || stored === 'recovery' || stored === 'other' ? stored : 'recent' + return stored === 'gallery' || stored === 'recovery' ? stored : 'recent' } const formatTime = (value: number): string => { @@ -60,21 +78,23 @@ interface ProjectFileRowProps { onOpenInBackground(): void onPin(): void onDelete?(): void + onRemoveFromRecent?(): void onReorderStart(event: ReactPointerEvent, filePath: string): void onReorderMove(event: ReactPointerEvent): void onReorderEnd(event: ReactPointerEvent): void } -function ProjectFileRow({ project, reorderable, dragging, removePending, onOpen, onOpenInBackground, onPin, onDelete, onReorderStart, onReorderMove, onReorderEnd }: ProjectFileRowProps) { +function ProjectFileRow({ project, reorderable, dragging, removePending, onOpen, onOpenInBackground, onPin, onDelete, onRemoveFromRecent, onReorderStart, onReorderMove, onReorderEnd }: ProjectFileRowProps) { const invalid = Boolean(project.error) - return
- {onDelete && } + {onRemoveFromRecent && } {reorderable && }
} @@ -114,31 +134,40 @@ export function HomeWorkspace({ onNew, onOpen, onOpenProject, onRestoreRecovery objectUrls.current = [] } - const readCards = async (records: RecentProject[]): Promise => Promise.all(records.map(async (record): Promise => { + const readCard = async (record: RecentProject): Promise => { try { + const cacheKey = previewCacheKey(record) + const cached = projectPreviewCache.get(cacheKey) + if (cached) { + projectPreviewCache.delete(cacheKey) + projectPreviewCache.set(cacheKey, cached) + return { ...record, name: record.fileName, previewUrl: createPreviewUrl(cached.bytes), width: cached.width, height: cached.height, colorMode: cached.colorMode } + } const bytes = await window.moonSprite.readBinary(record.filePath) if (/\.moonsprite$/i.test(record.filePath)) { const metadata = readProjectGalleryMetadata(bytes) - const buffer = metadata.preview.buffer.slice(metadata.preview.byteOffset, metadata.preview.byteOffset + metadata.preview.byteLength) as ArrayBuffer - const previewUrl = URL.createObjectURL(new Blob([buffer], { type: 'image/png' })) + const previewBytes = metadata.preview.slice() + cacheProjectPreview(cacheKey, { bytes: previewBytes, width: metadata.width, height: metadata.height, colorMode: metadata.colorMode }) + const previewUrl = createPreviewUrl(previewBytes) return { ...record, name: record.fileName, previewUrl, width: metadata.width, height: metadata.height, colorMode: metadata.colorMode } } const document = await decodeDocumentFileAsync(bytes, record.filePath) const preview = await exportDocumentImage(document, 100, 'png-auto') - const buffer = preview.bytes.buffer.slice(preview.bytes.byteOffset, preview.bytes.byteOffset + preview.bytes.byteLength) as ArrayBuffer - const previewUrl = URL.createObjectURL(new Blob([buffer], { type: 'image/png' })) + const previewBytes = preview.bytes.slice() + cacheProjectPreview(cacheKey, { bytes: previewBytes, width: document.width, height: document.height, colorMode: document.colorMode }) + const previewUrl = createPreviewUrl(previewBytes) return { ...record, name: record.fileName, previewUrl, width: document.width, height: document.height, colorMode: document.colorMode } } catch (error) { return { ...record, name: record.fileName, error: error instanceof Error ? error.message : '工程文件无法读取' } } - })) + } const loadSection = async (target: HomeSection): Promise => { const generation = ++loadGeneration.current releaseObjectUrls() setProjects([]) setLoadError('') - if (target === 'other' || target === 'recovery') { setLoading(false); return } + if (target === 'recovery') { setLoading(false); return } setLoading(true) try { let records: RecentProject[] @@ -149,13 +178,30 @@ export function HomeWorkspace({ onNew, onOpen, onOpenProject, onRestoreRecovery records = listing.projects.map((project) => ({ filePath: project.filePath, fileName: project.fileName, name: project.fileName, lastOpened: project.modifiedAt, pinned: pins.has(project.filePath) })) .sort((left, right) => Number(right.pinned) - Number(left.pinned) || right.lastOpened - left.lastOpened) } else records = getRecentProjects() - const cards = await readCards(records) - if (generation !== loadGeneration.current) { - for (const card of cards) if (card.previewUrl) URL.revokeObjectURL(card.previewUrl) - return + if (generation !== loadGeneration.current) return + const initialCards = records.map((record): ProjectCard => ({ ...record, name: record.fileName })) + projectsRef.current = initialCards + setProjects(initialCards) + setLoading(false) + let nextIndex = 0 + const loadNext = async (): Promise => { + while (nextIndex < records.length) { + const index = nextIndex + nextIndex += 1 + const card = await readCard(records[index]) + if (generation !== loadGeneration.current) { + if (card.previewUrl) URL.revokeObjectURL(card.previewUrl) + return + } + if (card.previewUrl) objectUrls.current.push(card.previewUrl) + setProjects((items) => { + const next = items.map((item) => item.filePath === card.filePath ? card : item) + projectsRef.current = next + return next + }) + } } - objectUrls.current = cards.flatMap((card) => card.previewUrl ? [card.previewUrl] : []) - setProjects(cards) + await Promise.all(Array.from({ length: Math.min(3, records.length) }, () => loadNext())) } catch (error) { if (generation === loadGeneration.current) setLoadError(error instanceof Error ? error.message : `无法读取${target === 'gallery' ? '画廊' : '最近文件'}`) } finally { @@ -264,31 +310,18 @@ export function HomeWorkspace({ onNew, onOpen, onOpenProject, onRestoreRecovery } } - const discardUnreadableProject = async (project: ProjectCard, reason?: string): Promise => { - const detail = reason ?? project.error ?? '工程文件不存在或已损坏。' - if (section === 'gallery') { - try { - const fileName = project.filePath.split(/[\\/]/).pop() ?? project.fileName - await window.moonSprite.deleteGalleryProject(fileName) - removeGalleryPin(project.filePath) - setMessage(`${project.name}:${detail},已从画廊删除。`) - } catch (error) { - setMessage(`${project.name}:${detail},无法删除画廊文件,已从当前列表移除。${error instanceof Error ? ` ${error.message}` : ''}`) - } - } else { - removeRecentProject(project.filePath) - setMessage(`${project.name}:${detail},已从最近记录移除。`) - } + const removeFromRecent = (project: ProjectCard): void => { + removeRecentProject(project.filePath) removeProjectFromView(project.filePath) + setMessage(`${project.name}:已从最近记录移除,工程文件未删除。`) } const openProject = async (project: ProjectCard, keepHomeOpen = false): Promise => { - if (project.error) { - await discardUnreadableProject(project) - return - } const opened = await onOpenProject(project.filePath, keepHomeOpen) - if (!opened) await discardUnreadableProject(project, '打开时无法读取工程文件。') + if (opened) return + const error = '打开时无法读取工程文件,原文件仍保留。' + setProjects((items) => items.map((item) => item.filePath === project.filePath ? { ...item, error } : item)) + setMessage(`${project.name}:${error}`) } const startRecentReorder = (event: ReactPointerEvent, filePath: string): void => { @@ -357,8 +390,6 @@ export function HomeWorkspace({ onNew, onOpen, onOpenProject, onRestoreRecovery ? { icon: , title: '画廊中没有工程', detail: '工程文件保存在 MoonSprite 根目录的 gallery 文件夹中。' } : section === 'recovery' ? { icon: , title: '没有待恢复的工程', detail: '非正常关闭的工程会出现在这里。' } - : section === 'other' - ? { icon: , title: '其他栏目', detail: '这个栏目暂时留空。' } : { icon: , title: '没有最近文件', detail: '打开或保存一个工程后,它会显示在这里。' } return
@@ -375,17 +406,16 @@ export function HomeWorkspace({ onNew, onOpen, onOpenProject, onRestoreRecovery
支持:MoonSprite、Aseprite、PNG、JPEG、WebP、BMP、GIF
-
+
{recoveryRecords.length > 0 && } -
{section === 'gallery' && } - + {section === 'recent' && }
@@ -394,7 +424,7 @@ export function HomeWorkspace({ onNew, onOpen, onOpenProject, onRestoreRecovery {!loading && loadError &&
无法读取栏目{loadError}
} {!loading && !loadError && ((section !== 'recovery' && projects.length === 0) || (section === 'recovery' && recoveryRecords.length === 0)) &&
{emptyState.icon}{emptyState.title}{emptyState.detail}
} {!loading && !loadError && section === 'recovery' && recoveryRecords.map((record) => void onRestoreRecovery(record.id)} onDiscard={() => void discardRecovery(record.id)} />)} - {!loading && !loadError && section !== 'recovery' && projects.map((project) => void openProject(project)} onOpenInBackground={() => void openProject(project, true)} onPin={() => pinProject(project.filePath)} onDelete={section === 'gallery' ? () => void deleteGalleryProject(project) : undefined} onReorderStart={startRecentReorder} onReorderMove={moveRecentReorder} onReorderEnd={endRecentReorder} />)} + {!loading && !loadError && section !== 'recovery' && projects.map((project) => void openProject(project)} onOpenInBackground={() => void openProject(project, true)} onPin={() => pinProject(project.filePath)} onDelete={section === 'gallery' ? () => void deleteGalleryProject(project) : undefined} onRemoveFromRecent={section === 'recent' && project.error ? () => removeFromRecent(project) : undefined} onReorderStart={startRecentReorder} onReorderMove={moveRecentReorder} onReorderEnd={endRecentReorder} />)}
diff --git a/src/renderer/src/components/ImageResizeDialog.tsx b/src/renderer/src/components/ImageResizeDialog.tsx index b480396..7c35942 100644 --- a/src/renderer/src/components/ImageResizeDialog.tsx +++ b/src/renderer/src/components/ImageResizeDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type FormEvent } from 'react' import { Lock, LockOpen, X } from 'lucide-react' import type { ImageResizeInterpolation } from '@shared/types' +import { ModalShell } from './ModalShell' import { NumberInput } from './NumberInput' export function ImageResizeDialog({ open, currentWidth, currentHeight, onClose, onResize }: { @@ -55,7 +56,7 @@ export function ImageResizeDialog({ open, currentWidth, currentHeight, onClose, } return
-
+
IMAGE SIZE

调整图像尺寸

@@ -80,6 +81,6 @@ export function ImageResizeDialog({ open, currentWidth, currentHeight, onClose,
- + } diff --git a/src/renderer/src/components/ModalShell.test.tsx b/src/renderer/src/components/ModalShell.test.tsx new file mode 100644 index 0000000..b38b062 --- /dev/null +++ b/src/renderer/src/components/ModalShell.test.tsx @@ -0,0 +1,53 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ModalShell } from './ModalShell' + +beforeEach(() => localStorage.clear()) +afterEach(cleanup) + +describe('ModalShell', () => { + it('restores the saved bounds and exposes eight resize directions', async () => { + localStorage.setItem('moonsprite.modal.test', JSON.stringify({ + x: 40, + y: 50, + width: 500, + height: 300, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight + })) + + render(
测试弹窗
) + const dialog = screen.getByRole('dialog') + + expect(dialog).toHaveStyle({ left: '40px', top: '50px', width: '500px', height: '300px' }) + expect(dialog).toHaveStyle({ overflow: 'hidden' }) + await waitFor(() => expect(document.querySelectorAll('.floating-resize-portal .floating-resize-handle')).toHaveLength(8)) + }) + + it('clamps restored bounds to the modal minimum size', () => { + localStorage.setItem('moonsprite.modal.small', JSON.stringify({ + x: 20, + y: 20, + width: 120, + height: 80, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight + })) + + render(
小弹窗
) + + expect(screen.getByRole('dialog')).toHaveStyle({ width: '300px', height: '220px' }) + }) + + it('places right-side dialogs midway between the stage center and right edge', () => { + const stage = document.createElement('div') + stage.className = 'stage-wrap' + stage.getBoundingClientRect = () => new DOMRect(100, 50, 800, 600) + document.body.appendChild(stage) + + render(
右侧弹窗
) + + expect(screen.getByRole('dialog')).toHaveStyle({ left: '500px' }) + stage.remove() + }) +}) diff --git a/src/renderer/src/components/ModalShell.tsx b/src/renderer/src/components/ModalShell.tsx new file mode 100644 index 0000000..8b6cb74 --- /dev/null +++ b/src/renderer/src/components/ModalShell.tsx @@ -0,0 +1,89 @@ +import { createElement, useMemo, type FormEventHandler, type HTMLAttributes, type ReactElement, type ReactNode } from 'react' +import { PortalResizeHandles, useFloatingPanel } from './floating-panel' + +type ModalPlacement = 'center' | 'right' + +interface ModalShellProps extends Omit, 'onSubmit'> { + as?: 'section' | 'form' + children: ReactNode + className?: string + defaultHeight?: number + defaultWidth?: number + fitContent?: boolean + minHeight?: number + minWidth?: number + maxHeight?: number + maxWidth?: number + onSubmit?: FormEventHandler + placement?: ModalPlacement + storageKey: string +} + +function initialModalPosition(width: number, height: number, placement: ModalPlacement, fitContent: boolean) { + const margin = 12 + const safeWidth = Math.min(width, window.innerWidth - margin * 2) + const safeHeight = Math.min(height, window.innerHeight - margin * 2) + const stage = placement === 'right' ? document.querySelector('.stage-wrap')?.getBoundingClientRect() : null + const targetCenterX = stage && stage.width > 0 ? stage.left + stage.width * 0.75 : window.innerWidth * 0.75 + const x = placement === 'right' + ? Math.max(margin, Math.min(window.innerWidth - safeWidth - margin, Math.round(targetCenterX - safeWidth / 2))) + : Math.max(margin, Math.round((window.innerWidth - safeWidth) / 2)) + return { + x, + y: Math.max(margin, Math.round((window.innerHeight - safeHeight) / 2)), + width: safeWidth, + height: fitContent ? undefined : safeHeight + } +} + +export function ModalShell({ + as = 'section', + children, + className = '', + defaultHeight = 360, + defaultWidth = 420, + fitContent = true, + minHeight = 220, + minWidth = 300, + maxHeight = 760, + maxWidth = 820, + onPointerDown, + placement = 'center', + storageKey, + ...props +}: ModalShellProps): ReactElement { + const layoutMinWidth = className.includes('settings-modal') ? Math.max(minWidth, 620) + : className.includes('component-library') ? Math.max(minWidth, 700) + : className.includes('outline-modal') ? Math.max(minWidth, 500) + : minWidth + const layoutMinHeight = className.includes('settings-modal') ? Math.max(minHeight, 440) + : className.includes('component-library') ? Math.max(minHeight, 500) + : className.includes('outline-modal') ? Math.max(minHeight, 400) + : minHeight + const initialPosition = useMemo( + () => initialModalPosition(defaultWidth, defaultHeight, placement, fitContent), + [defaultHeight, defaultWidth, fitContent, placement] + ) + const floating = useFloatingPanel( + initialPosition, + placement === 'right', + false, + `moonsprite.modal.${storageKey}`, + false, + undefined, + false, + { minWidth: layoutMinWidth, minHeight: layoutMinHeight, maxWidth, maxHeight } + ) + + return createElement(as, { + ...props, + ref: floating.ref, + className: `modal resizable-modal ${placement === 'right' ? 'right-modal' : ''} ${className}`.trim(), + style: { ...floating.style, minWidth: layoutMinWidth, minHeight: layoutMinHeight, maxWidth: `min(${maxWidth}px, calc(100vw - 12px))`, maxHeight: `min(${maxHeight}px, calc(100vh - 12px))`, overflow: 'hidden' }, + onPointerDown: (event: React.PointerEvent) => { + floating.bringToFront() + if ((event.target as HTMLElement).closest('header')) floating.startDrag(event) + onPointerDown?.(event) + } + }, children, createElement(PortalResizeHandles, { targetRef: floating.ref, position: floating.style, onResize: floating.startResize })) +} diff --git a/src/renderer/src/components/NewDocumentDialog.tsx b/src/renderer/src/components/NewDocumentDialog.tsx index dfd9948..f1d971a 100644 --- a/src/renderer/src/components/NewDocumentDialog.tsx +++ b/src/renderer/src/components/NewDocumentDialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { X } from 'lucide-react' import type { ColorMode } from '@shared/types' import { DEFAULT_DOCUMENT_SIZE_PRESETS, type DocumentSizePreset } from '@/core/file-preferences' +import { ModalShell } from './ModalShell' import { NumberInput } from './NumberInput' export function getWindowsFileNameError(value: string): string | null { @@ -48,7 +49,7 @@ export function NewDocumentDialog({ open, presets = DEFAULT_DOCUMENT_SIZE_PRESET onClose() } return
{ if (event.target === event.currentTarget) onClose() }}> -
+
NEW DOCUMENT

新建画布

{nameError &&

{nameError}

}
@@ -63,6 +64,6 @@ export function NewDocumentDialog({ open, presets = DEFAULT_DOCUMENT_SIZE_PRESET

画布尺寸没有预设上限。创建前会根据当前设备资源进行安全检查。

- +
} diff --git a/src/renderer/src/components/NumberInput.test.tsx b/src/renderer/src/components/NumberInput.test.tsx index 2774c11..946d1b6 100644 --- a/src/renderer/src/components/NumberInput.test.tsx +++ b/src/renderer/src/components/NumberInput.test.tsx @@ -1,4 +1,5 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { NumberInput } from './NumberInput' @@ -23,4 +24,17 @@ describe('NumberInput', () => { expect(input).toHaveValue('7') expect(onValueChange).not.toHaveBeenCalled() }) + + it('submits the evaluated value after the controlled state is updated', async () => { + const submitted: number[] = [] + const Form = () => { + const [value, setValue] = useState(4) + return
{ event.preventDefault(); submitted.push(value) }}> + } + render(
) + const input = screen.getByRole('spinbutton', { name: '尺寸' }) + fireEvent.change(input, { target: { value: '8 * 2' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(submitted).toEqual([16])) + }) }) diff --git a/src/renderer/src/components/NumberInput.tsx b/src/renderer/src/components/NumberInput.tsx index d790cbb..3b3ddbf 100644 --- a/src/renderer/src/components/NumberInput.tsx +++ b/src/renderer/src/components/NumberInput.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronUp } from 'lucide-react' -import { useEffect, useState, type CSSProperties, type InputHTMLAttributes } from 'react' +import { useLayoutEffect, useState, type CSSProperties, type InputHTMLAttributes } from 'react' import { evaluateNumericExpression } from '@/core/numeric-expression' interface NumberInputProps extends Omit, 'type' | 'value' | 'onChange' | 'min' | 'max' | 'step'> { @@ -13,7 +13,7 @@ interface NumberInputProps extends Omit, ' export function NumberInput({ value, onValueChange, min, max, step = 1, suffix, className = '', onFocus, onKeyDown, ...inputProps }: NumberInputProps) { const [draft, setDraft] = useState(String(value)) - useEffect(() => setDraft(String(value)), [value]) + useLayoutEffect(() => setDraft(String(value)), [value]) const normalize = (next: number): number => { if (!Number.isFinite(next)) return typeof value === 'number' ? value : min ?? 0 return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, next)) @@ -30,12 +30,12 @@ export function NumberInput({ value, onValueChange, min, max, step = 1, suffix, const control = - setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { onKeyDown?.(event); if (event.defaultPrevented || event.key !== 'Enter') return; event.preventDefault(); commit(); if (event.currentTarget.form) event.currentTarget.form.requestSubmit(); else event.currentTarget.blur() }} /> + setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { onKeyDown?.(event); if (event.defaultPrevented || event.key !== 'Enter') return; event.preventDefault(); const form = event.currentTarget.form; commit(); if (form) window.queueMicrotask(() => form.requestSubmit()); else event.currentTarget.blur() }} /> {suffix && } - - + + return control diff --git a/src/renderer/src/components/OutlineDialog.test.tsx b/src/renderer/src/components/OutlineDialog.test.tsx new file mode 100644 index 0000000..9119784 --- /dev/null +++ b/src/renderer/src/components/OutlineDialog.test.tsx @@ -0,0 +1,48 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDocument, getActiveLayer, readLayerColorAt, writeLayerColor } from '@/core/document' +import { useWorkspace } from '@/store/workspace' +import { OutlineDialog } from './OutlineDialog' + +beforeEach(() => useWorkspace.setState({ sessions: [], activeId: null, message: null, dialog: null })) +afterEach(cleanup) + +describe('OutlineDialog', () => { + it('confirms once and closes when Enter is pressed in the width input', async () => { + const document = createDocument('outline enter', 3, 3, 'rgba') + const layer = getActiveLayer(document) + writeLayerColor(document, layer, 4, { r: 255, g: 255, b: 255, a: 255 }) + useWorkspace.getState().addSession(document) + useWorkspace.getState().setPrimaryColor({ r: 255, g: 0, b: 0, a: 255 }) + useWorkspace.getState().setSelection({ x: 1, y: 1, width: 1, height: 1 }) + const onClose = vi.fn() + const session = useWorkspace.getState().sessions[0] + render() + + const widthInput = screen.getByRole('spinbutton') + fireEvent.change(widthInput, { target: { value: '2' } }) + fireEvent.keyDown(widthInput, { key: 'Enter' }) + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + expect(session.outlinePreview).toBeNull() + expect(readLayerColorAt(document, layer, 1, 0)).toEqual({ r: 255, g: 0, b: 0, a: 255 }) + expect(session.selection).toEqual({ x: 1, y: 1, width: 1, height: 1 }) + expect(session.history.canUndo).toBe(true) + expect(document.outlineSettings?.thickness).toBe(2) + }) + + it('owns Enter while the dialog is open even when focus remains on the canvas', async () => { + const document = createDocument('outline canvas enter', 3, 3, 'rgba') + const layer = getActiveLayer(document) + writeLayerColor(document, layer, 4, { r: 255, g: 255, b: 255, a: 255 }) + useWorkspace.getState().addSession(document) + useWorkspace.getState().setSelection({ x: 1, y: 1, width: 1, height: 1 }) + const onClose = vi.fn() + render() + + fireEvent.keyDown(window, { key: 'Enter' }) + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + expect(useWorkspace.getState().sessions[0].selection).toEqual({ x: 1, y: 1, width: 1, height: 1 }) + }) +}) diff --git a/src/renderer/src/components/OutlineDialog.tsx b/src/renderer/src/components/OutlineDialog.tsx index a752656..fb0ead0 100644 --- a/src/renderer/src/components/OutlineDialog.tsx +++ b/src/renderer/src/components/OutlineDialog.tsx @@ -2,7 +2,9 @@ import { useEffect, useState } from 'react' import { Eye, X } from 'lucide-react' import type { OutlineDirection, OutlineDirections, OutlineKernel, OutlinePosition, RgbaColor } from '@shared/types' import { ColorPicker } from '@/components/ColorPicker' +import { ModalShell } from '@/components/ModalShell' import { NumberInput } from '@/components/NumberInput' +import { defaultOutlineSettings, normalizeOutlineSettings } from '@/core/outline-settings' import { useWorkspace, type DocumentSession } from '@/store/workspace' const directionGrid: Array = ['nw', 'n', 'ne', 'w', 'center', 'e', 'sw', 's', 'se'] @@ -15,6 +17,10 @@ const kernelDirections = (kernel: OutlineKernel): OutlineDirections => { if (kernel === 'vertical') for (const direction of ['nw', 'w', 'sw', 'ne', 'e', 'se'] as OutlineDirection[]) directions[direction] = false return directions } +const matchesKernelDirections = (kernel: OutlineKernel, directions: OutlineDirections): boolean => { + const preset = kernelDirections(kernel) + return (Object.keys(preset) as OutlineDirection[]).every((direction) => preset[direction] === directions[direction]) +} const quickShapes: Array<{ id: OutlineKernel; label: string }> = [ { id: 'round', label: '圆形' }, @@ -50,21 +56,21 @@ export function OutlineDialog({ open, session, onClose }: { open: boolean; sessi setOutlinePreview(null) return } - setColor({ ...session.primaryColor }) - setThickness(1) - setPosition('outside') - setKernel('round') - setActiveQuickShape('round') - setEdgeDirections(kernelDirections('round')) - setPreviewEnabled(true) - }, [open, session.primaryColor.r, session.primaryColor.g, session.primaryColor.b, session.primaryColor.a, setOutlinePreview]) + const settings = normalizeOutlineSettings(session.document.outlineSettings, session.primaryColor) ?? defaultOutlineSettings(session.primaryColor) + setColor({ ...settings.color }) + setThickness(settings.thickness) + setPosition(settings.position) + setKernel(settings.kernel) + setActiveQuickShape(matchesKernelDirections(settings.kernel, settings.directions) ? settings.kernel : null) + setEdgeDirections({ ...settings.directions }) + setPreviewEnabled(settings.previewEnabled) + }, [open, session.document.id, session.primaryColor.r, session.primaryColor.g, session.primaryColor.b, session.primaryColor.a, setOutlinePreview]) useEffect(() => { if (open && previewEnabled) setOutlinePreview({ color, thickness, position, directions: edgeDirections, kernel }) else setOutlinePreview(null) }, [open, previewEnabled, color, thickness, position, edgeDirections, kernel, setOutlinePreview]) - if (!open) return null const close = (): void => { setOutlinePreview(null); onClose() } const applyQuickShape = (nextKernel: OutlineKernel): void => { setKernel(nextKernel) @@ -79,11 +85,32 @@ export function OutlineDialog({ open, session, onClose }: { open: boolean; sessi setEdgeDirections((current) => ({ ...current, [direction]: !current[direction] })) } const submit = (): void => { - if (outlineActiveSelection(color, thickness, position, edgeDirections, kernel)) close() + if (outlineActiveSelection(color, thickness, position, edgeDirections, kernel, previewEnabled)) close() } + useEffect(() => { + if (!open) return + const keydown = (event: KeyboardEvent): void => { + if (event.key !== 'Enter' || event.isComposing || (event.target as HTMLElement | null)?.tagName === 'TEXTAREA') return + if ((event.target as Element | null)?.closest?.('.outline-modal')) return + event.preventDefault() + event.stopPropagation() + submit() + } + window.addEventListener('keydown', keydown) + return () => window.removeEventListener('keydown', keydown) + }, [open, color, thickness, position, edgeDirections, kernel, previewEnabled]) + + if (!open) return null + return
- { event.preventDefault(); submit() }}> + { event.preventDefault(); submit() }} onKeyDown={(event) => { + if (event.key !== 'Enter' || event.nativeEvent.isComposing || (event.target as HTMLElement).tagName === 'TEXTAREA') return + event.stopPropagation() + if (event.defaultPrevented) return + event.preventDefault() + submit() + }}>
OUTLINE

描边

描边颜色
@@ -101,6 +128,6 @@ export function OutlineDialog({ open, session, onClose }: { open: boolean; sessi
- +
} diff --git a/src/renderer/src/components/ThemedSelect.tsx b/src/renderer/src/components/ThemedSelect.tsx index 25a0259..390b447 100644 --- a/src/renderer/src/components/ThemedSelect.tsx +++ b/src/renderer/src/components/ThemedSelect.tsx @@ -4,14 +4,15 @@ import { Check, ChevronDown } from 'lucide-react' export interface ThemedSelectGroup { label: string - options: Array<{ value: T; label: string }> + options: Array<{ value: T; label: string; description?: string }> } -export function ThemedSelect({ value, groups, label, onChange }: { +export function ThemedSelect({ value, groups, label, onChange, disabled = false }: { value: T groups: Array> label: string onChange: (value: T) => void + disabled?: boolean }) { const [open, setOpen] = useState(false) const [position, setPosition] = useState({ left: 8, top: 8, width: 220 }) @@ -59,13 +60,13 @@ export function ThemedSelect({ value, groups, label, onChange } return - - {open && createPortal(
{groups.map((group) =>
{group.label}{group.options.map((option) => )}
)}
, document.body)} + {open && createPortal(
{groups.map((group) =>
{group.label}{group.options.map((option) => )}
)}
, document.body)}
} diff --git a/src/renderer/src/components/app/AppMenuBar.tsx b/src/renderer/src/components/app/AppMenuBar.tsx index be7a3ec..30001d8 100644 --- a/src/renderer/src/components/app/AppMenuBar.tsx +++ b/src/renderer/src/components/app/AppMenuBar.tsx @@ -94,9 +94,9 @@ export function AppMenuBar({