diff --git a/.gitattributes b/.gitattributes index 21c45b1..728222f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,6 @@ *.ase binary *.aseprite binary *.moonsprite binary +*.ttf binary +*.woff binary +*.woff2 binary diff --git a/.gitignore b/.gitignore index 137a48f..c8111e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ .pnpm-store/ out/ +website/dist/ /release/ coverage/ .vite/ diff --git a/AGENTS.md b/AGENTS.md index e74de9d..7a14065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,8 @@ - 项目默认始终处于当前 `dev.X` 的连续开发阶段,直到用户明确要求“发布 dev.X”“准备发布”或“生成开发版”。 - 工作流只有“dev.X 开发中”和“dev.X 发布”两个阶段,不设置中间稳定批次,也不因累计若干需求自动升级验证。 - 当前开发周期起点提交 SHA 是本周期的审计基线;发布时只审计该基线之后的累计变化。日常任务只验证 AI 本次实际修改的文件。 -- `$moonsprite-code-architect` 只在用户明确要求整项目架构审计、重大里程碑架构复查或大型跨模块重构规划时调用,默认只读。普通需求、UI 修改和连续 Debug 不得自动调用,也不得恢复按需求自动分派子 Agent 的工作流。 +- 除用户明确调用 `$moonsprite-code-architect` 外,所有普通需求、UI 修改、Debug、审查、测试和发布工作都由当前主 Agent 直接完成,禁止创建、分派或保留任何子 Agent。 +- `$moonsprite-code-architect` 只在用户明确要求整项目架构审计、重大里程碑架构复查或大型跨模块重构规划时调用,默认只读;每次只允许创建一个架构师实例,且该实例禁止继续创建或分派后代 Agent。普通需求、UI 修改和连续 Debug 不得自动调用,也不得恢复按需求自动分派子 Agent 的工作流。 ## 修改流程 diff --git a/CHANGELOG.md b/CHANGELOG.md index ba11a45..3cb4503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ | 版本 | 完整记录 | | --- | --- | +| `DEV.5` | [查看 DEV.5 完整更新记录](docs/changelog/DEV.5.md) | | `DEV.4` | [查看 DEV.4 完整更新记录](docs/changelog/DEV.4.md) | | `DEV.3` | [查看 DEV.3 完整更新记录](docs/changelog/DEV.3.md) | | `DEV.2` | [查看 DEV.2 完整更新记录](docs/changelog/DEV.2.md) | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7605971..406aced 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,10 @@ MoonSprite 是原创实现。禁止提交复制自 Aseprite 或其他项目的源码、图标、主题和受保护资源。 +## 贡献许可 + +除非另有书面协议,提交贡献即表示你有权提供该内容,并同意贡献内容按仓库当前的 MoonSprite Source-Available License 1.0 发布。你同时授予 MoonSprite 版权持有人永久、全球、非独占、免版税的权利,用于使用、修改、分发以及在官方商业二进制中销售包含该贡献的版本。你仍保留自己贡献内容的版权。 + ## 开发流程 1. 从 `main` 创建短期分支,名称使用 `feature/`、`fix/`、`refactor/` 或 `docs/` 前缀。 diff --git a/EULA.md b/EULA.md new file mode 100644 index 0000000..348cdcd --- /dev/null +++ b/EULA.md @@ -0,0 +1,72 @@ +# MoonSprite Official Binary End User License Agreement 1.0 + +Effective date: August 16, 2026 + +This agreement applies only to official MoonSprite binaries obtained from +MoonSprite's copyright holders or an authorized distribution channel. Steam or +another authorized store may impose additional platform terms. + +## 1. License Grant + +MoonSprite grants the licensed user a limited, non-exclusive, +non-transferable license to install and use the official binary on devices +controlled by that user. The licensed user may use MoonSprite for personal, +educational, and commercial creative work. + +## 2. Seats and Organizations + +Unless an organization, education, or site license says otherwise, one license +seat is required for each person who actively uses MoonSprite. A personal +license may not be shared between users. Contact the MoonSprite copyright +holders for classroom, lab, studio, or organization-wide licensing. + +## 3. Restrictions + +You may not redistribute, resell, sublicense, rent, lease, lend, share, or make +the official binary available to unlicensed users. You may not bypass store +access controls or present a modified build as an official MoonSprite release. +Reverse engineering is not restricted where applicable law expressly grants +that right. + +## 4. Backups and Devices + +You may keep reasonable backup copies and install the official binary on +multiple devices you control, provided only the licensed user uses those +copies. Authorized-store account rules continue to apply. + +## 5. Artwork and Other Output + +You retain all rights you otherwise hold in artwork, animation, documents, +exports, and other content created with MoonSprite. MoonSprite claims no +ownership of that output, and commercial use of the output is permitted. + +## 6. Updates + +Updates may be provided through authorized channels. This agreement does not +guarantee support, compatibility, future features, or free access to every +future major version. + +## 7. Source Code and Third-party Components + +Source code is licensed separately under LICENSE. Third-party components remain +under their own licenses as described in THIRD_PARTY_NOTICES.md and bundled +notices. + +## 8. Ownership and Trademarks + +MoonSprite and its original assets remain the property of their respective +copyright holders. This agreement grants no ownership interest or trademark +license. + +## 9. Termination + +This license terminates automatically if you materially violate this +agreement. On termination, you must stop using and delete unlicensed copies of +the official binary. Rights in user-created output are unaffected. + +## 10. Disclaimer and Liability + +THE OFFICIAL BINARY IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. TO THE +MAXIMUM EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDERS SHALL NOT BE LIABLE FOR +INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM +USE OF THE SOFTWARE. diff --git a/LICENSE b/LICENSE index 77c9127..f656c2e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,98 @@ -MIT License +MoonSprite Source-Available License 1.0 +Effective date: August 16, 2026 Copyright (c) 2026 MoonSprite contributors -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +This license applies to MoonSprite source code first published under this +license. It is a source-available license and is not an open-source license as +defined by the Open Source Initiative. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Definitions + +"Software" means the MoonSprite source code, documentation, and original +assets distributed with this license. + +"Source Form" means the preferred form for reading or modifying the Software. + +"Binary Form" means any compiled, packaged, executable, installer, portable, +hosted, or otherwise directly usable form of the Software. + +"Official Binary" means a Binary Form distributed by MoonSprite's copyright +holders or through a channel they have authorized, including an authorized +Steam release. + +2. Source Form permissions + +Subject to this license, you may: + +a. inspect, study, and evaluate the Source Form; +b. modify the Source Form; +c. compile and run your own Binary Form for personal, educational, evaluation, + contribution, or internal development and testing purposes; and +d. redistribute the Source Form or patches to it, with or without changes, if + you include this license, preserve copyright and attribution notices, mark + material changes, provide the complete corresponding Source Form, and apply + this same license to the redistributed Software. + +3. Binary Form restrictions + +Except for private use allowed by Section 2(c), or under a separate written +commercial distribution agreement, you may not: + +a. distribute, sell, sublicense, rent, lease, lend, or publicly provide any + Binary Form to another person or organization; +b. offer the Software as a hosted, streamed, portable, or managed application + service for another person or organization; +c. bypass an authorized distribution channel, payment requirement, license + check, or access control; or +d. present an unofficial build as an Official Binary. + +Source availability does not grant a right to redistribute compiled builds. + +4. Official Binaries + +Official Binaries are licensed separately under the MoonSprite Official Binary +End User License Agreement (EULA.md) and any applicable authorized-store terms. +The EULA permits licensed users to create personal and commercial artwork. + +5. User-created output + +You retain all rights you otherwise hold in artwork, animation, documents, +exports, and other content created with the Software. This license does not +claim ownership of that output. + +6. Trademarks + +This license grants no right to use the MoonSprite name, logo, trade dress, or +other brand identifiers to endorse or identify a redistributed or modified +build. Factual attribution is permitted. + +7. Third-party material + +Third-party software, fonts, libraries, formats, and assets remain governed by +their own licenses. See THIRD_PARTY_NOTICES.md and the notices distributed with +those components. + +8. Termination + +Your rights under this license terminate automatically if you violate it. They +may be reinstated only when the violation is cured and the copyright holders +confirm reinstatement in writing. + +9. Disclaimer of warranty THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. + +10. Limitation of liability + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDERS SHALL NOT BE +LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN +CONNECTION WITH THE SOFTWARE OR ITS USE. + +11. Previously published MIT versions + +Code and releases already published under the MIT License remain available +under those historical terms. This license does not revoke permissions already +granted for those versions. See LICENSE-MIT. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..1f28d14 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,27 @@ +Historical MIT License Notice + +MoonSprite versions and commits already published under the MIT License before +August 16, 2026 remain available under the following terms. This file does not +apply to later source unless that source is explicitly marked as MIT licensed. + +MIT License + +Copyright (c) 2026 MoonSprite contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e6f5850..031235e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MoonSprite -MoonSprite 是面向 Windows 的原创开源像素画工作台,使用 Tauri 2、React、TypeScript、Zustand 和 Canvas 构建。项目采用 MIT License,与 Aseprite 无隶属关系,也不使用其源码、品牌或视觉资产。 +MoonSprite 是面向 Windows 的原创源码可见像素画工作台,使用 Tauri 2、React、TypeScript、Zustand 和 Canvas 构建。项目与 Aseprite 无隶属关系,也不使用其源码、品牌或视觉资产。 当前界面渠道标识为 `DEV`;构建系统继续使用合法的内部 SemVer `0.1.0`。 @@ -51,6 +51,13 @@ pnpm build 运行时用户目录不会提交到仓库。内置示例工程位于 `src-tauri/resources/示例.moonsprite`。 +## 许可 + +- 当前源码使用 [MoonSprite Source-Available License 1.0](LICENSE):允许查看、修改、个人编译与源码形式再分发,但未经书面授权不得分发编译后的 MoonSprite。 +- Steam 及其他授权渠道的官方二进制使用 [MoonSprite Official Binary EULA](EULA.md),允许个人与商业创作,按用户席位授权。 +- 已经以 MIT License 发布的历史版本继续保留原有权利,见 [LICENSE-MIT](LICENSE-MIT)。 +- 第三方字体与依赖遵循各自许可,见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 + ## 维护入口 - [文档索引](docs/README.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..bd4294e --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,20 @@ +# Third-party Notices + +MoonSprite includes third-party components that are not relicensed under the +MoonSprite Source-Available License or the MoonSprite Official Binary EULA. + +## Bundled Fonts + +- Fusion Pixel Font: SIL Open Font License 1.1. The license text is stored at + `src-tauri/resources/fonts/licenses/Fusion-Pixel-Font-OFL-1.1.txt`. +- Silkscreen: SIL Open Font License 1.1. The license text is stored at + `src-tauri/resources/fonts/licenses/Silkscreen-OFL-1.1.txt`. +- Tiny5: SIL Open Font License 1.1. The license text is stored at + `src-tauri/resources/fonts/licenses/Tiny5-OFL-1.1.txt`. + +## Software Dependencies + +JavaScript and Rust dependencies remain under the licenses declared by their +respective authors. Dependency versions are recorded in `pnpm-lock.yaml` and +`src-tauri/Cargo.lock`. Release packages must preserve all notices required by +those licenses. diff --git a/docs/README.md b/docs/README.md index b8c08e8..a70ed9f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,11 +5,12 @@ ## 产品与架构 - [产品行为契约](product/behavior.md):用户可见能力和稳定规则。 +- [UI 设计规范](ui-design-system.md):颜色、字号、间距、控件密度、像素图标和组件复用规则。 - [架构概览](architecture/overview.md):模块职责和依赖方向。 - [状态与历史](architecture/state-history.md):会话、dirty、撤销和视图状态。 - [坐标与渲染](architecture/coordinates-rendering.md):屏幕、视图、画布和图层坐标。 - [多语言架构](architecture/localization.md):语言资源、回退、持久化和新增语言门禁。 -- [文件格式](file-format.md):`.moonsprite` v2 容器。 +- [文件格式](file-format.md):`.moonsprite` v12 容器。 ## 交互契约 diff --git a/docs/adr/0008-sparse-raster-project-storage.md b/docs/adr/0008-sparse-raster-project-storage.md new file mode 100644 index 0000000..4bedb4d --- /dev/null +++ b/docs/adr/0008-sparse-raster-project-storage.md @@ -0,0 +1,58 @@ +# ADR-0008: Sparse Raster Project Storage + +## Status + +Accepted + +## Context + +Large MoonSprite projects may contain dozens of canvas-sized layer and cel +surfaces whose meaningful pixels occupy only a small fraction of each surface. +ZIP Deflate keeps the file compact on disk, but opening still expands every raw +surface before the editor can use it. A 4596 x 1767 project can therefore expand +hundreds of megabytes even when most tiles are transparent. + +## Decision + +- Project schema v5 may store each layer or cel as one `sparse-tiles-v1` + resource containing only nonzero 64 x 64 tiles. It does not create thousands + of ZIP entries. +- The writer compares sparse and raw byte counts per resource and keeps the + smaller representation. RGBA tile occupancy checks all four bytes so hidden + RGB values under zero alpha remain lossless; indexed occupancy treats ID 0 as + transparent. +- The reader validates the complete tile directory and reconstructs one + contiguous raster surface covering the stored tile bounds. Its canvas offset + and stable storage origin preserve document coordinates, while tools, + compositing, history, Store, and platform APIs continue to use the existing + contiguous-surface contract. +- Schema v1-v4 resources migrate as raw data in memory. Their first save writes + schema v5 and may choose sparse storage. Unknown encodings and malformed tile + containers are rejected. +- Incremental save continues to track one CRC and revision per raster resource. + Unchanged v5 resources reuse their original path, encoding, dimensions, + offsets, and compressed ZIP entry; changed compact surfaces are encoded from + their current runtime geometry. +- Opening registers the incremental-save baseline from the decoded manifest, + resource references, and ZIP CRCs without re-encoding all pixels. Worker + routing estimates expanded raster bytes from the manifest so a compact v5 ZIP + cannot move a large allocation back onto the UI thread. + +## Consequences + +Sparse large projects expand far fewer bytes from ZIP, can avoid allocating +transparent space outside each resource's stored tile bounds, and avoid a +second full raster scan after decoding. Projects whose content spans distant +tiles inside most layer bounding boxes may see little runtime-memory reduction; +this design is not a permanently tiled runtime. Runtime rendering and editing +preserve the existing pixel representation and visual output. Dense resources +can remain raw, and a saved v5 file requires a v5-aware reader even though v5 +readers remain backward compatible with v1-v4. + +## Alternatives + +One ZIP entry per tile was rejected because thousands of central-directory +entries add metadata, lookup, and merge overhead. Keeping only raw resources was +rejected because Deflate reduces disk size but not expanded decode work. A +permanently tiled runtime model was deferred because it would cross drawing, +selection, undo, compositing, and cache ownership boundaries. diff --git a/docs/adr/0009-editable-text-style-runs.md b/docs/adr/0009-editable-text-style-runs.md new file mode 100644 index 0000000..c578075 --- /dev/null +++ b/docs/adr/0009-editable-text-style-runs.md @@ -0,0 +1,25 @@ +# ADR-0009: Editable Text Style Runs + +## Status + +Accepted + +## Context + +Editable text needs different sizes, spacing, and colors inside one cel while remaining editable after save and reopen. Raster-only formatting would lose the source selection ranges and could not be reapplied after content edits. + +## Decision + +- Project schema v8 stores optional ordered, non-overlapping `styleRuns` on each editable text cel. +- Each run uses UTF-16 text offsets and may override font size, line spacing, letter spacing, and RGBA color. Unspecified properties inherit the cel defaults. +- Text edits reconcile runs around the changed range. Rendering resolves each character's effective style before glyph rasterization. +- Actual spacing uses final visible alpha bounds horizontally and vertically. Zero spacing places adjacent visible glyph or line pixels directly next to each other. +- Schema v7 text migrates without synthetic runs and therefore preserves its original whole-text formatting. + +## Consequences + +Mixed formatting remains editable and round-trips with the project. Character-level glyph rasterization costs more than one `fillText` call per line, so glyph alpha results are cached within each rasterization pass. Font family and antialias mode remain cel-wide properties. + +## Alternatives + +Embedding markup in the text string was rejected because it would corrupt selection offsets and copied text. Saving only the raster output was rejected because later text edits could not preserve local formatting. diff --git a/docs/adr/0010-editable-text-boxes.md b/docs/adr/0010-editable-text-boxes.md new file mode 100644 index 0000000..c0e50ba --- /dev/null +++ b/docs/adr/0010-editable-text-boxes.md @@ -0,0 +1,25 @@ +# ADR-0010: Editable Text Boxes + +## Status + +Accepted + +## Context + +Point text grows with its content, but paragraph text needs a persistent canvas area so long content wraps predictably and remains editable after reopening a project. + +## Decision + +- Project schema v9 stores optional `boxWidth` and `boxHeight` values on editable text cels. +- A click creates point text without box metadata. A pointer drag creates boxed text using the dragged canvas-pixel rectangle. +- Wrapping is visual layout only: source text and style-run character offsets are not modified. +- Boxed text raster surfaces use the exact saved box dimensions, wrap by character when the next glyph would exceed the inner width, and clip pixels outside the saved height. +- Schema v1-v8 text migrates without synthetic box dimensions and retains point-text behavior. + +## Consequences + +Text boxes survive editing, transforms, copying, and project round trips without inserting artificial newline characters. Character-level wrapping also supports CJK and long words, while point text remains backward compatible. + +## Alternatives + +Writing automatic newlines into source text was rejected because resizing or restyling could not recover the original content. Storing the rectangle only in the raster surface was rejected because editing would lose the intended wrapping boundary. diff --git a/docs/adr/0011-document-color-modes.md b/docs/adr/0011-document-color-modes.md new file mode 100644 index 0000000..025be4d --- /dev/null +++ b/docs/adr/0011-document-color-modes.md @@ -0,0 +1,26 @@ +# ADR-0011: Document Color Modes And Raster Formats + +## Status + +Accepted + +## Context + +The document color mode previously doubled as the physical raster format. That worked for RGBA and indexed storage, but adding grayscale would make runtime code treat every non-RGBA mode as indexed. Indexed editing also created new palette entries from arbitrary paint colors, so the canvas was not actually constrained to the visible palette. + +## Decision + +- Project schema v10 stores document modes `rgba`, `indexed`, and `grayscale`. +- Physical raster data remains either `rgba` or `indexed`. Grayscale documents use RGBA surfaces with equal RGB channels and independent alpha. +- Indexed canvas writes resolve only against colors listed in the current `paletteOrder`. Exact matches are reused; other colors map to the nearest visible RGBA entry without adding palette entries. +- Removing or replacing visible palette colors remaps affected indexed layer and animation-cel storage as part of the same undoable palette operation. +- RGBA-to-indexed conversion preserves the current palette and maps every layer and animation cel. Conversion to grayscale uses perceptual luminance and preserves alpha. +- Schema v1-v9 projects keep their existing RGBA or indexed interpretation and migrate to v10 without synthetic grayscale data. + +## Consequences + +Document intent is no longer confused with byte layout. Runtime raster encoding remains backward compatible, grayscale survives project round trips, and indexed pixels cannot silently depend on hidden historical palette entries. + +## Alternatives + +Adding a third physical sparse-raster encoding was rejected because grayscale still needs alpha and gains no storage or compositor benefit from a separate byte layout. Automatically adding every painted color to an indexed palette was rejected because it defeats indexed-color constraints and makes the visible palette an unreliable source of truth. diff --git a/docs/adr/0012-non-destructive-layer-styles.md b/docs/adr/0012-non-destructive-layer-styles.md new file mode 100644 index 0000000..5843732 --- /dev/null +++ b/docs/adr/0012-non-destructive-layer-styles.md @@ -0,0 +1,27 @@ +# ADR-0012: Non-Destructive Layer Styles + +## Status + +Accepted + +## Context + +Stroke, shadow, inner glow, color overlay, and gradient overlay must remain editable across animation frames without rewriting cel pixels. MoonSprite already treats layer opacity, blending, masks, clipping, and color modes as compositor concerns, while the project format stores cel surfaces separately from shared layer metadata. + +## Decision + +- Project schema v11 stores an optional normalized `layerStyles` object on raster layers and layer groups. A layer property is shared by every frame of that layer, while a group property is shared by the group's per-frame composite; individual cels do not own independent layer styles. +- The compositor evaluates styles after the owner's layer or group mask. Shadow and outside stroke form the backdrop, then color overlay, gradient overlay, inner glow, and any inside stroke modify visible source content before owner opacity, blend mode, and clipping are applied. Stroke position can be inside, outside, or both; stroke kernels and eight-direction filters reuse the same normalized outline geometry as the destructive Outline command. Smart Hue resolves each outside stroke pixel from the nearest visible source pixel and each inside stroke pixel from the current visible source, then uniformly darkens RGB channels so hue is preserved. Smart Shadow expresses the darkness coefficient as a black alpha overlay; normal compositing therefore scales every live background RGB channel uniformly and follows background changes without persisting a sampled color. +- Gradient overlays reuse the Gradient tool's pure color interpolation and dithering core, including the same smooth, Bayer, and directional modes. UI selectors and pixel previews are shared rather than maintaining a separate layer-style mode list. +- Indexed and grayscale documents resolve stored and dynamically generated style colors through the same document-color rules used by normal canvas writes. Style evaluation never adds indexed palette entries or mutates source raster data. +- Dialog changes use a reversible preview path that refreshes derived compositing without dirtying the document or adding history. Applying restores the original baseline and commits the final settings as one undoable content operation; canceling restores the baseline. +- Layer and group rows with enabled effects show a style indicator that reopens the dialog. Layer/group duplication, internal layer clipboard operations, animation previews, and project encoding deep-copy style settings. Schema v1-v10 projects migrate without styles and do not interpret unknown draft fields as v11 data. +- Interactive canvas invalidation expands edited source regions through the owner's effects and every styled ancestor group, so drawing previews update the complete affected output without forcing an unrelated full-canvas redraw. + +## Consequences + +One style edit updates every animation frame while cel pixels remain independently editable. Styled layers and groups leave the ordinary compositor fast path and require style-aware composition for affected output. Rasterizing a text-only layer drops editable text while retaining its existing raster surfaces and masks. Rasterizing a styled layer bakes each frame's current style and mask result into independent cel pixels, removes those editable properties, and remains a single undoable operation. + +## Alternatives + +Baking effects into every cel by default was rejected because it destroys editability, multiplies history and storage cost, and makes later parameter changes inconsistent across frames. Storing styles per cel was rejected because the user-facing feature belongs to the layer or group and should follow other shared owner properties. Representing each effect as hidden helper layers was rejected because it would complicate ordering, clipping, masks, and project structure. diff --git a/docs/adr/0013-background-layers.md b/docs/adr/0013-background-layers.md new file mode 100644 index 0000000..f569e6f --- /dev/null +++ b/docs/adr/0013-background-layers.md @@ -0,0 +1,26 @@ +# ADR-0013: Editable Background Layers + +## Status + +Accepted + +## Context + +Background artwork must remain editable while behaving differently from ordinary raster layers in two places: color sampling from foreground work must treat it as transparent, and canvas enlargement must repeat its previous visible canvas instead of exposing empty space. The same behavior has to survive animation frame changes, undo, and project reopen without introducing a second compositor or a procedural-only layer type. + +## Decision + +- Project schema v12 stores optional normalized `background` metadata on raster layers. `mode: "preset"` also records one of the built-in pattern identifiers; `mode: "canvas"` marks a converted layer. The raster and animation cel surfaces remain the editable source of truth. +- Built-in solid and patterned presets are generated as integer-pixel RGBA or indexed tiles and materialized to the current canvas. In indexed documents, their gray colors are added to the visible palette so the pattern cannot collapse to one nearest color. New preset layers are inserted at the root bottom and their existing animation cels are linked to one shared preset surface. +- Expanding either canvas dimension repeats the old visible canvas at the resize anchor phase into the new canvas for every background cel, including inactive frames. This is a one-time raster operation for each resize; later edits modify ordinary pixels. Reducing a canvas keeps the existing independent-layer behavior until a later expansion samples the then-visible old canvas. +- Composite color sampling receives the active layer ID. When an ordinary layer is active, all background layers are omitted from the sampling document, preserving foreground alpha; when a background layer is active, normal full-document sampling applies. Rendering and export always include background layers normally. +- Conversion only changes shared layer metadata and leaves pixels, offsets, animation cels, masks, styles, and ordering intact. Text layers cannot convert directly because their source remains non-raster-editable. New, convert, and resize operations are undoable document edits. +- Schema v1-v11 projects migrate without background metadata. Unknown same-named fields in older versions and invalid v12 settings are discarded. Internal layer clipboard operations preserve a deep copy of valid background metadata. + +## Consequences + +Background layers use the existing raster compositor and editing tools, so they need no separate rendering path. Canvas enlargement allocates a target-sized raster for each unique background cel surface and can therefore be expensive on very large animated documents; linked preset cels avoid multiplying that cost. Foreground eyedropper results no longer depend on decorative background colors, including when foreground pixels are translucent. + +## Alternatives + +A procedural infinite pattern layer was rejected because converted artwork and later pixel edits would not have one consistent source of truth. Hiding backgrounds in the main compositor was rejected because they must remain visible in editing and export. Applying the sampling rule only to fully opaque foreground pixels was rejected because it would still contaminate translucent sampled colors. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0a05199..d3b919a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,5 +17,11 @@ ADR 用于记录会长期影响实现的决定,避免后续只看到代码而 - [0005:项目元数据与缩时快照持久化](0005-project-metadata-and-timelapse.md) - [0006:自适应调色板槽位](0006-fixed-palette-slots.md) - [0007:逐单元格可编辑图层蒙版](0007-attached-layer-masks.md) +- [0008:工程文件稀疏栅格存储](0008-sparse-raster-project-storage.md) +- [0009:可编辑文本局部样式区间](0009-editable-text-style-runs.md) +- [0010:可编辑区域文本](0010-editable-text-boxes.md) +- [0011:文档颜色模式与栅格格式分离](0011-document-color-modes.md) +- [0012:非破坏性图层样式](0012-non-destructive-layer-styles.md) +- [0013:可编辑背景图层](0013-background-layers.md) 新增 ADR 时按编号递增;废弃旧决定时保留原文件并指向替代 ADR。 diff --git a/docs/architecture/coordinates-rendering.md b/docs/architecture/coordinates-rendering.md index 93f6d51..0e40e2d 100644 --- a/docs/architecture/coordinates-rendering.md +++ b/docs/architecture/coordinates-rendering.md @@ -25,7 +25,7 @@ layer -> canvas -> view rotation -> viewport -> screen - 像素写入使用向下取整后的文档坐标;选区手柄、边缘和旋转区命中使用未取整的连续文档坐标,禁止先取整再判断屏幕距离。输入命中与预览必须共享同一视图逆变换。 - 画布旋转后不得出现像素内部描边、白线或图块接缝。 - 高 DPI 工具预览的相邻文档像素必须共享同一设备像素边界;矩形边界按 `devicePixelRatio` 对齐后再换回 CSS 坐标。旋转离屏画布回绘时使用实际 backing 尺寸除以 DPR,不得把向上取整后的设备像素再次压回理论 CSS 尺寸。 -- 蚂蚁线和变换手柄使用屏幕像素绘制,缩放后保持清晰,不参与像素图层合成。 +- 蚂蚁线和变换手柄使用屏幕像素绘制,缩放后保持清晰,不参与像素图层合成。蚂蚁线固定使用 `1 CSS px` 线宽和 `6 CSS px` 明暗节距;短边界段按屏幕位置错开动画相位,禁止在低缩放的大画布上同步闪烁或随画布倍率变细。 - 画布左下角尺寸和镜像提示共用同一前景色;根据提示位置下方的合成像素、透明棋盘或工作区背景亮度选择深色或浅色,不写入文档像素。 - 曲线调整器的直方图只从调整开始时的活动图层快照统计,作为半透明编辑辅助,不写入文档、不参与调整结果。 @@ -35,7 +35,7 @@ layer -> canvas -> view rotation -> viewport -> screen - `core/canvas-render-plan.ts` 统一计算反解旋转与水平/垂直镜像后的可见范围、离屏场景边界、画布原点和可见文档像素;组件不得再复制这些计算。镜像枢轴偏离视口中心时,离屏场景也必须覆盖镜像后的完整视口。 - 调整画布尺寸预览固定按“新增区域棋盘、图层内容、旧画布外黑色遮罩、边界线”绘制,确保原本位于画布外的图层像素可见但仍明确标识为新增画布区域。 -- `CanvasStage` 的绘制尺寸由 `ResizeObserver` 缓存。指针命中仍读取实时屏幕边界,避免停靠布局变化后坐标失真。 +- `CanvasStage` 的绘制尺寸由 `ResizeObserver` 缓存。Canvas 元素的 CSS 显示尺寸必须固定为最后一次实际绘制使用的 CSS 像素尺寸,直到下一次重绘再同步更新,禁止在分栏或浮窗缩放期间把旧 backing bitmap 非等比拉伸。指针命中仍读取实时屏幕边界,避免停靠布局变化后坐标失真。 - `CanvasCompositeCache` 的最终显示来源必须是一张连续表面,禁止把 128 px tile 分别缩放后直接提交到主画布。小中型文档复用完整表面并按脏矩形局部更新;超出完整表面上限的文档先把当前可见文档区域合成为单张临时表面,再用一次 `drawImage` 输出。 - 非整数缩放、连续绘制和半透明图层必须经过同一连续表面路径。缓存分块可以作为内部计算策略,但不得成为最终显示边界,也不得在邻接处重复叠加透明像素。 - 画布性能探针默认不工作,只有自动基准注入 `window.__moonSpriteCanvasProbe` 时才记录主绘制和指针处理耗时。 diff --git a/docs/architecture/state-history.md b/docs/architecture/state-history.md index d6d5054..9ea5fea 100644 --- a/docs/architecture/state-history.md +++ b/docs/architecture/state-history.md @@ -6,17 +6,20 @@ MoonSprite 状态分为四类: | 类型 | 示例 | 持久化 | 进入撤销 | | --- | --- | --- | --- | -| 文档内容 | 像素、图层、图层蒙版、调色板、项目笔刷 | 工程文件 | 是 | +| 文档内容 | 像素、图层、图层样式、图层蒙版、调色板、项目笔刷 | 工程文件 | 是 | | 临时编辑 | 浮动粘贴、正在绘制、变换预览 | 仅会话 | 确认后一次进入 | | 项目显示元数据 | 像素网格开关、自定义网格开关与参数 | 工程文件 | 否 | -| 视图状态 | 平移、缩放、旋转、镜像、蚂蚁线显隐 | 工作区或本地设置 | 否 | +| 视图状态 | 平移、缩放、旋转、镜像、蚂蚁线显隐、快捷指令栏水平比例与展开状态 | 工作区或本地设置 | 否 | | 应用状态 | 当前工具、弹窗、栏目布局、首页栏目 | 本地设置或工作区 | 否 | 动画帧、cel、每帧时长和循环方式属于文档内容;当前帧、播放/暂停、播放倍率、开始播放时的帧和停止返回开关属于会话状态。切帧与播放状态变化不得改变 dirty 或写入撤销历史,只有帧增删复制、帧时长和循环方式变化进入文档历史。 +图层可见性与锁定属于文档内容;活动图层、图层栏多选、选择锚点和组展开状态属于不进入撤销的界面上下文。界面上下文按工程路径即时写入本地存储,并在正常保存工程时作为可选元数据写入文件;本地记录优先恢复最近一次会话,失效图层或组 ID 必须过滤,且恢复过程不得改变 dirty。 图层蒙版是附着在单个动画 cel 上的独立灰度文档表面,图层组蒙版则以 `groupId + frameId` 附着在图层组当前帧的合成结果上;透明像素表示未绘制且不改变所有者的显示。活动蒙版 ID 只属于会话选择状态。蒙版像素编辑进入文档历史并绑定所属 `frameId`,撤销、重做和渲染失效只针对该帧。新建和删除蒙版分别形成一个结构历史条目;切换帧或普通选择图层、图层组、cel 时退出蒙版编辑。 -项目显示元数据和缩时录制设置会随工程保存并改变 dirty,但不进入撤销历史。编辑统计和缩时快照只在已提交文档操作后更新;缩时导出、清空前的预览、视图导航和栏目操作不得增加笔画数或生成缩时帧。 +图层样式是跨帧共享的图层或图层组文档属性。弹窗实时预览可以临时替换当前样式并刷新合成,但不得改变 dirty 或写入历史;应用时先恢复打开前基线,再把最终样式作为一个内容历史条目提交,取消或意外卸载必须恢复基线。撤销和重做只切换样式参数并触发完整合成失效,不修改 cel 表面,也不触发活动帧表面同步。交互绘制的局部失效区域必须按当前所有者样式及祖先图层组样式逐级扩展。仅栅格化文本时沿用现有 cel 表面和蒙版,只移除文本元数据;栅格化启用了样式的图层时,以完整工程快照保存一次历史,先为每帧生成包含当前蒙版结果的样式外观,再解除 cel 链接并移除文本、样式和蒙版数据,避免链接帧被重复烘焙。 + +项目显示元数据和缩时录制设置会随工程保存并改变 dirty,但不进入撤销历史。编辑统计和缩时快照只在已提交文档操作后更新;开启录制时,每个已提交的内容操作对应一个等权帧,画面必须在操作提交时冻结,PNG 编码可以排队完成,但不得按空闲延迟合并操作或按现实时间加权。缩时导出、清空前的预览、视图导航和栏目操作不得增加笔画数或生成缩时帧。 ## 历史规则 @@ -42,7 +45,7 @@ MoonSprite 状态分为四类: - 图层 Alt 拖动复制与最终移动共同组成一个事务。 - 跨文档粘贴多层或嵌套图层组时,整批新增结构组成一个历史条目;一次撤销必须同时移除所有新图层和新图层组并恢复原选择。 - 所有图层组只要具有有效 `panelOrder`,都优先以该锚点参与图层栏排序,不因组内是否有子图层而改变。拖拽更新与撤销必须同时恢复父组、组顺序、图层顺序和该锚点。 -- 从首页恢复草稿成功后必须进入对应文档会话;失败时保持恢复记录并停留在首页。 +- 从首页恢复草稿成功后必须进入对应文档会话;失败时保持恢复记录并停留在首页。恢复保留天数属于应用首选项,平台在每次启动读取恢复列表前按恢复记录更新时间成对清理过期元数据与工程数据;该清理与文档 dirty、撤销历史和上次会话是否异常退出无关。 - 普通保存、首次保存和另存为在文件路径确认后都显示紧凑进度反馈;保存成功后进度弹窗自动关闭,取消原生文件选择器时不显示进度。 修改历史模型时必须覆盖 dirty 保存点、跨文档隔离、Undo/Redo 边界和失败回滚测试。 diff --git a/docs/changelog/DEV.5.md b/docs/changelog/DEV.5.md new file mode 100644 index 0000000..b2d8639 --- /dev/null +++ b/docs/changelog/DEV.5.md @@ -0,0 +1,161 @@ +# MoonSprite DEV.5 完整更新记录 + +[返回更新日志索引](../../CHANGELOG.md) + +本文件永久保存 `DEV.5` 的完整更新条目。除修正事实错误外不再改写。 + +## DEV.5 + +### 新增 + +- 图层设置新增“隐藏时间轴”。开启后保留工程中的帧与 cel 数据,但隐藏时间轴网格和动画按钮,并禁用播放、切帧、洋葱皮及帧增删等动画操作。 +- 图层栏始终保留有效活动图层;活动图层、多选、组展开、可见性和锁定等状态按工程保存并在重启后恢复。 +- 首选项与快捷键设置新增左侧全局搜索,重新整理分类、可配置命令和栏目开关;弹窗式栏目命令置顶并默认使用 `1`、`2`、`3`、`4`。 +- 新增弹窗式栏目模式。颜色、图层、调色板和预览栏目可通过快捷键在指针附近呼出,支持移动、缩放、位置与尺寸记忆,点击外部后关闭。 +- 新增悬浮快捷指令栏,每个画布独立保存折叠状态和水平相对位置,可配置显示、半透明、命令内容与排序,并支持右键直接打开相关设置。 +- 快捷指令栏提供选区镜像、画布镜像、全选、取消选择、反选、网格、相对明暗、重置视图、删除选区、旋转视图等命令及专用 SVG 图标。 +- 新增喷枪工具,提供喷枪半径、粒子半径、粒子密度、粒子频率、散布单位和笔刷预设,并支持连续绘制、对称轴及笔刷尺寸快捷调整。 +- 新增自由形状和多边形形状工具,可分别复用套索路径和多边形路径直接创建填充像素形状。 +- 新增直线与曲线工具。曲线支持 `1-8` 个锚点、实时弯曲预览、独立笔刷和完美像素路径清理,直线与曲线分别支持快捷键。 +- 新增切片工具,切片随工程保存,可创建、选择、多选、复制、移动、缩放、命名、全选、删除和撤销,并在导出时批量输出。 +- 切片工具新增自动布局弹窗,可输入切片尺寸、初始位置与横纵间距,实时预览并一次生成画布内的规则切片。 +- 新增文本工具和可编辑文本图层。文本支持多行内容、字号、行距、字距、实际/字体间距、颜色、锯齿/平滑渲染、实时预览、重新编辑与栅格化。 +- 文本工具支持拖拽固定文本区域、自动换行、区域移动和八点缩放;单击创建的自适应文本可通过 `Ctrl+T` 缩放、旋转和倾斜。 +- 文本编辑支持字符范围样式,同一文本内可分别设置字号、行距、字距和颜色;混合值显示为空并可统一覆盖选区。 +- 新增字体管理:读取系统字体、搜索、导入本地字体到应用 `Font` 目录、删除用户字体、按确认插入次数在下次启动排序,并内置多款像素字体及语言说明。 +- 新建文本默认填入 `TEXT`,记忆最近字号;Fusion Pixel、Silkscreen 和 Tiny5 使用针对像素字形校准的默认字号。 +- 新增 RGBA、索引和灰度三种文档颜色模式,新建文件和颜色模式转换均可选择;工具预览、绘制、粘贴与文本栅格化遵循当前模式。 +- 索引模式只允许使用当前调色板中的颜色;从 RGBA 转换、替换或移除颜色时自动映射到最近可见色板颜色。 +- 颜色编辑器新增 `PLT` 色板模式和右侧临时吸色按钮;默认模式整理为 HSV、RGB、LAB、GRAY、PLT。 +- 调色板新增反向、渐变、色调渐变,以及色调、饱和度、明度、亮度、红、绿、蓝、Alpha 的升序与降序排序。 +- 调色板新增“同步颜色”,修改色板颜色时可同步替换画布中已使用的对应颜色,默认关闭。 +- 调色板支持 `Alt+S+点击` 将每次吸取的颜色加入色板;吸色工具也可从调色板和色盘直接取色。 +- 新增图像放大倍率识别,可分析最近邻放大的主导像素块倍率并自动填写缩小后的图像尺寸。 +- 新增图层样式,普通图层与图层组可非破坏性应用描边、阴影、内发光、颜色叠加和渐变叠加,图层行显示可点击的样式图标。 +- 图层样式描边支持内侧、居中、外侧和智能色相;阴影支持自动识别背景的智能阴影;渐变叠加支持像素抖动。 +- 带图层样式的图层和可编辑文本图层可通过统一的“栅格化图层”命令固化当前外观。 +- 新增选区变换轴心。轴心可拖动、按单像素移动,并可从九点预设快速定位;旋转、缩放和倾斜均以轴心为基准。 +- 新增旋转对称轴,与水平、垂直和斜向对称共同提供实时预览、去重绘制和项目级设置。 +- 新增背景图层。背景图层可由纯色、网格、条纹、两种菱形或圆形预设创建,也可由普通图层转换;扩大画布时自动平铺扩展。 +- 背景图层对上方普通图层吸色表现为透明,选择背景图层后仍可正常吸色和编辑;图层行使用专用图像图标与下划线名称。 +- 背景图层预设改为本地资源目录,内置图案和用户放入目录的图像会自动出现在创建弹窗,可直接打开预设文件夹管理。 +- 新增项目标签分栏停靠。拖动项目标签到画布上下左右区域时显示磁吸预览,并按三分之一比例创建并排画布。 +- 项目可从合并视图中继续拖动重新停靠;右键项目标签可浮出独立项目窗口,窗口支持调整尺寸、置顶和拖回主标签栏。 +- 新增首页小、中、大三种项目布局,通过 `Ctrl+滚轮` 切换并持久化;小号显示紧凑标题与路径,大号使用四列放大封面。 +- 首页新增自定义文件夹栏目,可选择任意目录展示其中的支持文件,并与最近、画廊、恢复栏目统一拖动排序。 +- 首页栏目超过可用宽度时只把溢出的栏目收进下拉菜单,当前栏目始终保留在可见标签区。 +- 首页新增 QQ、Steam、GitHub 和语言按钮;语言按钮打开中英文选择弹窗,外部链接使用系统浏览器。 +- 首页最近项目新增隐私显示开关,可隐藏项目封面和标题等信息但保留项目占位与操作。 +- 恢复文件新增自动清理策略,默认保留 7 天,可在首选项设置 `1-365` 天,并在首页显示剩余天数。 +- 新增 MoonSprite 官网项目,包含中英文产品页面、真实界面展示、功能说明、许可信息和响应式布局。 +- 授权方案改为与商业发行兼容的源代码可用许可,同时保留单独的 MIT 许可部分、最终用户许可协议和第三方声明。 +- 新增“导出所有帧”,按时间轴顺序把每一帧合成为独立图片并连续编号输出。 +- 每个工程单独记忆最近一次成功导出的名称、路径、格式、缩放、目标、GIF 选项和预设。 +- 普通图片作为单图层单帧文档编辑时,`Ctrl+S` 可直接覆盖原 PNG、JPEG、WebP、BMP 或 GIF;加入工程结构后自动要求保存为 `.moonsprite`。 +- 新建文件弹窗新增“记录绘画过程”开关,默认关闭;缩时记录改为每个已提交操作对应一个视频帧,并支持 MP4、WebM 导出。 +- 动画新增 `Enter` 播放/暂停快捷键和左右方向键循环切帧;快捷键只在没有输入、弹窗、选区或其他冲突上下文时生效。 +- `.moonsprite` 文件格式升级到 v12,依次加入稀疏分块栅格、切片、可编辑文本、字符样式、文本区域、灰度模式、图层样式和背景图层,并完整迁移 v1-v11。 +- 新增稀疏栅格工程存储和增量保存,未修改的 cel、图层、蒙版与缩时快照可直接复用原压缩块,失败时自动回退完整保存。 + +### 优化 + +- 建立 MoonSprite UI 设计规范,将应用字号收敛为两档,统一紧凑间距、控件高度、栏目标题、弹窗标题、按钮、输入框、复选框、开关和分段控件。 +- 组件库补充表单、设置导航、弹窗标题、分段控件、滚动条、颜色值、进度图标和新工具资源,并覆盖默认、选中、禁用与交互预览。 +- 工具属性栏移除重复工具名称和“对称轴”等说明文字,统一数值输入长度、按钮密度和图标分隔;笔刷动态文案改为“压感”。 +- 图层栏紧凑模式重新对齐图标、文本、分隔线和行高,图层名称使用 14px 字号;隐藏时间轴时标题留在左侧、操作图标移到右侧。 +- 图层栏在左右停靠时隐藏新增帧、删除帧、新增图层、新增组和删除图层等横向空间不足的按钮;新建图层、图层组和背景图层统一收进“新建”菜单。 +- 首选项重新分类并收紧留白,主题独立成类,图层颜色预设移入预设分类,自定义网格颜色改为自适应换行布局。 +- 快捷键设置按功能重新归类,覆盖工具、栏目、视图、动画、编辑和弹窗式栏目等所有可配置命令。 +- 主题顺序统一为黑、暗灰、灰、亮,灰色主题融入品牌色倾向;浅色主题加强层次,暗灰主题补齐控件与栏目状态。 +- 全主题统一使用 `#0000FF` 作为对称轴、自定义网格、文本框、切片线和画布尺寸辅助线;透明背景默认使用 `#D7D7D9` 与 `#9B9B9F`。 +- 所有栏目标题背景统一为 `#20242D`,弹窗标题栏高度、内边距和按钮位置与栏目标题保持一致。 +- 下拉框和各类滚动区域统一使用组件库滚动条与 SVG 滑块资源,移除 Windows 原生上下按钮并跟随主题变色。 +- 正常和大号工具图标补齐直线、曲线、喷枪、切片、自由形状、多边形形状、文本、抓手和颜色模式资源,像素图标只使用整数倍缩放。 +- 记录绘画过程、颜色模式、文件操作进度和应用 Logo 改用可随主题或尺寸稳定显示的 SVG 资源;桌面、移动端和安装包图标同步更新。 +- 默认工具栏改为正常尺寸;默认工作区使用小尺寸调色板、紧凑图层栏和饱和度/明度色盘。 +- 窗口缩放时栏目尺寸、分栏比例、弹窗位置和快捷指令栏位置按画布中心到两侧的百分比重算,控件与弹窗自身尺寸保持不变。 +- 项目磁吸判定改为完全使用当前指针点,每次移动都可在上下左右区域间直接切换,快速跨越边界时也会补齐判定。 +- 项目停靠预览恢复为先挪动原画布并显示目标区域,落下后按主画布三分之二、新画布三分之一布局。 +- 浮动项目拖回标签栏时直接显示原标签形态并隐藏窗口,不切换当前主画布;多标签合并视图遵循同一规则。 +- 调色板内置资源替换为四套覆盖完整色相的 48 色通用专业色板,并按色相关系排列;导入和提取颜色默认按感知亮度排序。 +- 调色板选择框减细并提高亮度,框选、按钮操作、空槽渐变和解锁编辑期间保持稳定焦点与选择状态。 +- 颜色模式排序、快捷指令排序和首页栏目排序统一使用同一套拖动手柄、按住反馈和插入预览。 +- 打开文件进度改为基于真实阶段即时更新,小文件只短暂闪现并立即关闭,不再为动画等待;进度条数值和填充长度严格同步。 +- 保存、打开、导出等文件操作统一使用 SVG 旋转图标和紧凑进度弹窗,减少无意义留白与过渡时间。 +- 首页缩略图覆盖工程和普通图片格式,大工程缺少预览时后台串行生成并写入磁盘缓存,避免读取超大画布阻塞首页。 +- 首页透明缩略图保留棋盘格,像素作品使用硬边缘显示;恢复大号卡片改为画廊式排版并统一删除、加载和剩余天数信息。 +- 首页栏目导航、底部许可信息、关于 MoonSprite、语言选择和恢复管理界面重新排版,减少空白、遮挡和拥挤。 +- 文本栅格化按像素字体实际边界和基线对齐,实际间距忽略空格占位并让 `0px` 字符或行的可见像素相接。 +- 文本编辑弹窗加宽字体区并压缩输入框高度,字体搜索、导入、删除和本地选择按钮统一尺寸与间距。 +- 喷枪、直线和曲线复用铅笔的笔刷选择与尺寸输入样式;喷枪半径范围统一为 `1-64px`。 +- 画笔圆形印章使用更稳定的像素圆生成规则,改善小尺寸圆边缘和对称性。 +- 半透明直线预览与最终绘制共用单次合成路径,转折区域不再因重复盖章产生额外加深。 +- 选区蚂蚁线改为屏幕绝对 `1px`,在大画布和不同缩放下保持可见;八个变换交互点和轴心使用统一 SVG/像素资产。 +- 所有变换指针根据画布旋转、选框边角和对角线实际角度动态选择;倾斜指针在接近水平或垂直时及时切换方向。 +- 旋转指向针位置改为指针按下点与画布中心的中点,并根据所在象限自动放到相反方向,画布始终围绕指向针旋转。 +- 125% 和 150% 非整数缩放使用平滑采样,其他像素检查倍率继续保持硬边缘。 +- 预览栏目恢复硬边缘、清晰缩略图和正确分栏比例;栏目在窗口缩放时保持原相对位置而不自动平分。 +- 图层样式面板沿用现有描边控件,压缩左右留白、隐藏无效说明,并在智能颜色开启时自动隐藏手动颜色区域。 +- 图层样式在绘制和编辑期间实时重新合成,图层组样式作用于每帧组内最终结果而不改写源像素。 +- 图像和工程解码、工程编码、首页缩略图及缩时视频编码迁移到专用 Worker,减少主线程长时间阻塞。 +- `.moonsprite` 稀疏资源按 64px 分块选择更小编码,空白占比高的大画布不再强制写入完整连续像素数据。 +- 版本性能门禁扩展复杂动画、800/2048/4000 多内容大画布、缩时绘制、React profiling、选区、工程格式和桌面回归场景。 + +### 修复 + +- 修复隐藏时间轴后图层帧区仍可点击、快捷键仍可新建帧、动画按钮未隐藏或禁用状态不一致的问题。 +- 修复图层选择可变为空、切换图层时高亮不实时更新,以及重启后展开、眼睛、锁和活动图层状态重置的问题。 +- 修复图层蒙版 `Alt+单击` 必须等待 Alt 松开才进入、普通单击出现多余选框,以及吸色后选中状态消失的问题。 +- 修复替换颜色按钮和快捷键无法打开弹窗、吸色完成后弹窗未关闭、默认颜色不一致及编辑颜色时实时预览闪烁的问题。 +- 修复颜色值按钮、吸管按钮、实时预览和组件库样式不一致、透明色缺少棋盘格以及多个弹窗移动互相牵连的问题。 +- 修复调色板删除颜色后不能再次加入、解锁后选中跳转或失焦、选择空槽后命中已有颜色会自动改选的问题。 +- 修复 `Alt+S+点击` 吸色未逐次加入调色板,以及单独按 `Alt+S` 误保存颜色的问题。 +- 修复框选调色板空槽后无法创建渐变、点击调色板命令导致选择消失,以及解锁状态新增颜色后未取消旧选择的问题。 +- 修复导入文件调色板出现大量重复白色,改为从当前文件实际颜色提取、去重并排序。 +- 修复大文件首页不显示缩略图、非工程图片没有封面、恢复项目封面错位或项目过多时卡片黏连的问题。 +- 修复打开文件进度长期停在 58%、数值先到 100% 但进度条未走满,以及极小工程仍被强制播放完整动画的问题。 +- 修复调节软件窗口时栏目和弹窗位置错乱、拖动栏目分隔线造成画布弹性拉伸,以及左右调整浮窗时画面内容被拉伸的问题。 +- 修复喷枪半径滑条点击后消失、无法连续滑动、预览线被粒子示例遮挡,以及开启对称轴时镜像侧不能实时预览的问题。 +- 修复曲线绘制预览与提交结果不一致、按下时抖动、锚点不跟随指针和曲率过圆的问题。 +- 修复切片单击误创建、缩放时残留旧线框、取消创建后选框残留、八点交互区过宽和完成后线框消失的问题。 +- 修复缩时视频预览与最终时长不一致、帧动画只推进时间轴不更新画面,以及记录过程开关使用旧复选框样式的问题。 +- 修复文本 `Ctrl+T` 无法拖动或变换、编辑后位置跳回原点、文本框调整时文字消失和区域变化后不重新换行的问题。 +- 修复文本实际间距包含空格占位、像素字体显示变粗或模糊、字号与预期像素高度不一致,以及默认间距错误的问题。 +- 修复文本输入换行后光标仍停留首行、数值输入后光标不前进、调整样式时文本选区高亮消失和末尾插入颜色错误的问题。 +- 修复新建文本必须点击插入才生成图层、取消后文本框不消失、框选未输入文本时选框提前消失的问题。 +- 修复选择文本图层后其他栅格编辑工具仍可修改内容、文本框交互点在非文本工具下显示,以及切换图层后必须移动指针才刷新选框的问题。 +- 修复文本框缩放或移动期间遗留旧线条,实时编辑未确认内容被临时框操作覆盖,以及取消新文本后对应临时图层或选框残留的问题。 +- 修复临时 `Ctrl` 移动工具在选区、切片、吸色、缩放和旋转工具中失效,或与 `Ctrl+滚轮`、选区复制及中心创建冲突的问题。 +- 修复按住 `Ctrl` 临时移动时工具栏未切换、松开后未恢复原工具,以及提前选择切片工具后 `Alt` 临时行为错误的问题。 +- 修复移动工具自动选择时无法区分同一组内重叠图层、长按闪烁不消失,以及点击内容闪烁时间过短的问题。 +- 修复形状和形状选区先按 `Alt` 旋转再按 `Ctrl` 时错误变成 1:1、松开 `Ctrl` 不能恢复原效果的问题。 +- 修复选区创建时 `Shift`、`Ctrl`、`Shift+Ctrl` 造成指针闪烁,已有选区的加选、减选和交集模式无法继续在其他区域操作的问题。 +- 修复双击网格单元快速框选时第二次按住没有大格预览、拖动不能连续扩展,以及最终松开前提前显示蚂蚁线的问题。 +- 修复选区复制后第一次旋转只有蚂蚁线转动、内部内容不转,以及移动后镜像再移动产生遗留像素的问题。 +- 修复连续倾斜不同边会恢复上一边、倾斜不围绕轴心,以及轴心在缩放后未实时更新的问题。 +- 修复选择变换的缩放、旋转和倾斜指针未随画布旋转或倾斜角度变化,以及接近垂直时仍显示斜向指针的问题。 +- 修复对称轴在新建图层上另一侧断续或缺失、预览正确但最终画布不完整,以及镜像选区无法从任一侧正常移动的问题。 +- 修复铅笔和橡皮擦 `Ctrl+Shift` 直线预览与绘制失效、绘制中误按 Shift 会拉出上一次直线预览的问题。 +- 修复按住 Shift 绘制半透明直线时转折像素重复叠加,预览仍比最终结果更深,以及撤销后遗留单点的问题。 +- 修复 `Ctrl+Alt` 鼠标移动和 `Ctrl+滚轮` 不显示铅笔或橡皮擦尺寸预览,并统一使用铅笔尺寸指针。 +- 修复软件指针与本地指针默认值和命名不一致,以及本地指针模式下缩放工具显示空指针的问题。 +- 修复 TourBox 等外设滚轮在鼠标滚轮首次使用前没有响应的问题。 +- 修复索引模式实际仍可写入任意 RGBA、从 RGBA 转换后颜色未映射到色板,以及工具预览不遵循颜色模式的问题。 +- 修复图像倍率识别无法识别 3 倍、4 倍、非整除尺寸和少量异常像素混入的最近邻放大图像。 +- 修复项目磁吸第一次拖入可能不触发、触发一侧后无法直接切换到相邻侧、快速移动跨过检测区和提示显示与磁吸条实际状态不一致的问题。 +- 修复项目标签落到画布瞬间闪烁、磁吸条提前出现或方位错误,以及落下后错误平均分配窗口的问题。 +- 修复浮动项目拖回标签栏不生效、最右侧插入线位置错误、窗口已隐藏但未生成标签,以及拖回后意外切换当前画布的问题。 +- 修复浮动项目调整宽高时画布被 CSS 拉伸、属性栏层级低于浮窗,以及多个合并标签返回主栏时状态不一致的问题。 +- 修复左侧色盘切换方形后拖动只出现蓝线、尺寸回弹和停靠位置未改变的问题。 +- 修复快捷指令栏位于边缘时新增按钮向屏幕外扩展、切换画布后展开状态丢失,以及合并或缩放后相对位置漂移的问题。 +- 修复图层样式绘制后显示不完整、图层或图层组右侧不显示样式图标,以及样式面板左右内边距不一致的问题。 +- 修复图层属性不透明度输入后按回车未应用,以及样式智能色相或智能阴影开启后仍显示手动颜色控件的问题。 +- 修复调整画布尺寸后保存并重开时图层偏移和扩展区域错误,保证负原点和扩大后的画布几何随工程保存。 +- 修复预览窗口在部分缩放或缓存路径下整体变糊,以及 125%/150% 缩放破坏像素排列的问题。 +- 修复蚂蚁线在大画布上因随画布缩放而过粗、闪烁或看不清的问题。 +- 修复首选项主题和快捷键设置仍显示原生滚动条、主题滚动条位置与其他弹窗不一致及滑块分段显示异常的问题。 +- 修复新建背景图层列表多余勾选标识、纯色默认值错误、图层名称下划线与文本分离,以及扩大画布后未按原画布单位平铺的问题。 +- 修复直接导入普通图片后 `Ctrl+S` 总是要求保存工程,现仅在图片无法表达新增工程结构时才切换工程保存流程。 +- 修复首页大号项目置顶按钮和操作按钮破坏封面布局;中小号置顶后改为仅图标变色,大号移除悬浮按钮。 +- 修复首页栏目标题和底部分隔线被遮挡、项目不足时仍显示空白背景框、社区按钮未与 Logo 垂直居中和语言弹窗排版异常的问题。 +- 修复首页项目右键不能打开所在文件夹、恢复项目标题缺少“恢复”标识、恢复加载图标不居中和关于弹窗链接无法跳转的问题。 diff --git a/docs/changelog/README.md b/docs/changelog/README.md index 61378e0..a6fc040 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -4,6 +4,7 @@ ## 版本 +- [DEV.5 完整更新记录](DEV.5.md) - [DEV.4 完整更新记录](DEV.4.md) - [DEV.3 完整更新记录](DEV.3.md) - [DEV.2 完整更新记录](DEV.2.md) diff --git a/docs/file-format.md b/docs/file-format.md index 0506aef..5d3253d 100644 --- a/docs/file-format.md +++ b/docs/file-format.md @@ -1,25 +1,45 @@ -# MoonSprite 文件格式 v4 +# MoonSprite 文件格式 v12 + +v12 为普通栅格图层新增可选 `background` 元数据。`mode: "preset"` 同时保存纯色或内置图案 ID,`mode: "canvas"` 表示由普通图层转换;实际可编辑像素仍保存在图层与各帧 cel 的原栅格资源中。背景图层扩大画布时以扩大前可见画布为单位一次性平铺全部帧,普通图层吸色时排除背景图层,选择背景图层后恢复完整合成吸色。v1-v11 工程迁移时不创建背景标记,旧版本中同名未知字段不采纳。 + +v11 为普通图层和图层组新增可选 `layerStyles`,保存描边、阴影、内发光、颜色叠加和渐变叠加的启用状态与参数。图层样式跨帧共享,图层组样式作用于每帧组内合成结果,两者都不写入 cel 像素;v1-v10 工程迁移时保持无图层样式,v10 及更早版本中即使存在同名未知字段也不采纳。 + +v10 将文档 `colorMode` 扩展为 `rgba`、`indexed`、`grayscale`。灰度文档的图层和 cel 继续使用 `.rgba` 或 `.rgba.tiles` 保存四通道像素,RGB 三通道必须保持相等,透明度独立保存;v1-v9 文件继续只包含 RGBA 或索引模式并按原语义迁移。索引文档的栅格像素只能引用 `paletteOrder` 中当前可见的颜色 ID,打开旧工程或替换、移除色板颜色时,隐藏 ID 自动映射到当前可见色板中最接近的 RGBA 颜色。 + +v9 为区域文本新增可选的 `boxWidth`、`boxHeight`,用于保存画布像素坐标下的固定文本区域。缺少这两个字段的 v1-v8 文本继续按单击创建的自适应尺寸文本处理;同时存在时,文本按区域宽度自动换行并裁切到区域高度。 + +v8 为可编辑文本新增可选的 `styleRuns`。每个区间以 UTF-16 字符索引保存 `start`、`end`,并可覆盖默认字号、行间距、字间距和 RGBA 颜色;区间规范化为有序、非重叠数据。缺少 `styleRuns` 的 v7 文本继续使用整段默认样式。 + +v7 新增可编辑文本图层。图层使用 `kind: "text"` 标记,每个动画单元格可保存 `text` 源数据,包括文本、字体、字号、行距、字距、间距模式、抗锯齿模式与颜色;间距模式缺失时使用字体自带间距。`originX`、`originY` 保存初始插入点,`transforms` 按顺序保存 `Ctrl+T` 的源矩形、目标矩形、旋转角度和可选倾斜参数。对应 cel 的栅格 surface 仍作为显示和兼容缓存;缺少新增字段时按未变换文本处理。v1-v6 项目继续作为普通栅格图层迁移。 + +v6 新增项目级 `slices` 数组;每个切片保存稳定 ID、名称和画布像素坐标下的矩形。v1-v5 缺失该字段时迁移为空数组,v5 的 `sparse-tiles-v1` 栅格语义保持不变。 `.moonsprite` 是 ZIP 容器,包含: - `manifest.json`:文档元数据、调色板、图层顺序、动画时间轴、项目显示设置、编辑统计、缩时设置、选区描边设置和格式版本。 -- `layers/.rgba`:小端序 RGBA 字节,每像素四字节。 -- `layers/.idx32`:小端序无符号 32 位稳定调色板 ID。 +- `layers/.rgba` / `layers/.idx32`:密集图层的原始 RGBA 或小端序无符号 32 位稳定调色板 ID。 +- `layers/.rgba.tiles` / `layers/.idx32.tiles`:稀疏图层的 64 x 64 分块容器。 - `masks/.rgba`:单元格或图层组蒙版的灰度 RGBA 字节,每像素四字节;透明像素表示尚未绘制。 -- `cels/.rgba`:RGBA cel 的独立像素字节。 -- `cels/.idx32`:索引色 cel 的独立稳定调色板 ID。 +- `cels/.rgba` / `cels/.idx32`:密集 cel 的原始像素字节。 +- `cels/.rgba.tiles` / `cels/.idx32.tiles`:稀疏 cel 的 64 x 64 分块容器。 - `timelapse/.png`:开启缩时录制后,在已提交编辑边界生成的压缩合成快照。 -- `preview.png`:可见图层合成后的预览图。 +- `preview.png`:可见图层合成后的预览图;恢复快照等低延迟写入可以省略,首页会在后台生成有界缩略图并单独缓存,不改写原工程。 + +v5 动画元数据至少包含一帧、当前帧、帧持续时间、循环状态、cel 与图层/帧的稳定关联,以及可选的逐帧图层组蒙版。`layers/` 保留活动帧兼容位图和图层属性,动画 cel 像素独立写入 `cels/`;解码后活动帧图层表面引用对应 cel,画布工具不直接解析时间轴。早期 v2 文件没有 `cels/` 时,使用 `layers/` 位图补成活动帧 cel。 + +图层和 cel 清单项通过可选 `dataEncoding` 声明资源编码。缺失或为 `raw` 时按原始连续像素读取;`sparse-tiles-v1` 时按稀疏分块读取。写入器逐资源比较原始字节数与分块容器字节数,仅在分块更小时使用 `.tiles`,因此小型或密集表面仍保持原始表示。v5 稀疏资源打开后恢复为覆盖已存分块包围范围的连续 `Uint8ClampedArray` 或 `Uint32Array`,并通过图层偏移和稳定存储原点保持画布坐标;画笔、合成、撤销和 Store 不直接感知文件分块。若非空块在图层内相距很远,连续包围范围仍可能较大,这不是运行时永久分块模型。 -v4 动画元数据至少包含一帧、当前帧、帧持续时间、循环状态、cel 与图层/帧的稳定关联,以及可选的逐帧图层组蒙版。`layers/` 保留活动帧兼容位图和图层属性,动画 cel 像素独立写入 `cels/`;解码后活动帧图层表面引用对应 cel,画布工具不直接解析时间轴。早期 v2 文件没有 `cels/` 时,使用 `layers/` 位图补成活动帧 cel。 +`sparse-tiles-v1` 使用 24 字节小端序头:magic `0x3154534d`、块尺寸 `u16`(固定 64)、格式 `u8`(RGBA 为 1,索引色为 2)、保留字节、宽 `u32`、高 `u32`、块数 `u32`、payload 字节数 `u32`。随后每块使用 16 字节目录项:`x u32`、`y u32`、宽 `u16`、高 `u16`、数据偏移 `u32`;payload 按目录顺序保存每块连续原始像素。未列出的块按全零恢复。读取器拒绝 magic、格式、尺寸、块边界、目录顺序、重复槽位、偏移或 payload 长度不一致的资源。 -图层和图层组元数据可选保存 `displayColor`、`description` 与 `clippingMask: true`。`displayColor` 是 RGBA 列表标记,不参与像素合成;`description` 是悬停说明;`clippingMask` 表示显示内容受同级紧邻下方对象的最终透明度限制。每个动画 cel 可选保存独立 `mask`,图层组则在动画元数据中按 `groupId + frameId` 保存独立蒙版;两者都包含蒙版 ID、本地尺寸、偏移和 `.rgba` 数据文件,并可通过 `linkedMaskId` 独立引用另一蒙版。透明像素表示未绘制且不改变显示,非透明像素必须为灰度且完全不透明,`255` 完全显示、`0` 完全隐藏,中间值按比例缩放所有者的最终透明度。蒙版引用缺失、自引用或形成循环时工程无效。图层组还可选保存 `cumulativeBlend: true`,表示先将组内内容与外部背景合成,再应用一次组混合模式。 +图层和图层组元数据可选保存 `displayColor`、`description`、`clippingMask: true` 与完整 `layerStyles`:`stroke` 保存 `enabled`、RGBA `color`、`size`、`position`(`inside`、`outside` 或 `both`)、`kernel`(`round`、`square`、`horizontal` 或 `vertical`)、八方向布尔值 `directions`、`smartHue` 和 `smartHueDarkness`;`shadow` 保存 `enabled`、RGBA `color`、`offsetX`、`offsetY`、`blur`、`smartShadow` 和 `smartShadowDarkness`;`innerGlow` 保存 `enabled`、RGBA `color` 和 `size`;`colorOverlay` 保存 `enabled` 和 RGBA `color`;`gradientOverlay` 保存 `enabled`、RGBA `from`、RGBA `to`、`angle` 和 `dither`。描边尺寸限制为 `1-64 px`,智能色相与智能阴影的深色系数限制为 `0-100%`,内发光尺寸限制为 `1-32 px`,阴影偏移限制为 `-64-64 px`,阴影模糊限制为 `0-32 px`,角度规范化为 `0-359`;渐变抖动值与渐变工具共用 `none`、Bayer 和方向抖动枚举。非法或缺失参数使用默认值,旧数据缺少智能色相或智能阴影字段时保持关闭并使用 `45%` 深色系数,缺少渐变抖动时使用 `none`,缺少描边位置时使用 `outside`,缺少形状或方向时使用圆形四方向描边。`displayColor` 是 RGBA 列表标记,不参与像素合成;`description` 是悬停说明;`clippingMask` 表示显示内容受同级紧邻下方对象的最终透明度限制。每个动画 cel 可选保存独立 `mask`,图层组则在动画元数据中按 `groupId + frameId` 保存独立蒙版;两者都包含蒙版 ID、本地尺寸、偏移和 `.rgba` 数据文件,并可通过 `linkedMaskId` 独立引用另一蒙版。透明像素表示未绘制且不改变显示,非透明像素必须为灰度且完全不透明,`255` 完全显示、`0` 完全隐藏,中间值按比例缩放所有者的最终透明度。蒙版引用缺失、自引用或形成循环时工程无效。图层组还可选保存 `cumulativeBlend: true`,表示先将组内内容与外部背景合成,再应用一次组混合模式。 -项目显示设置只保存像素网格、自定义网格开关及自定义网格原点和尺寸;缩放、平移、旋转、镜像等临时视图导航不写入工程。项目统计保存笔画数、已提交编辑数和有效绘画时长。缩时设置保存开关、画质、导出帧率、缩时倍速和快照清单,快照像素独立存放在 `timelapse/`,旧工程缺少这些字段时使用关闭录制、空统计和默认网格。 +项目显示设置只保存像素网格、自定义网格开关及自定义网格原点和尺寸;缩放、平移、旋转、镜像等临时视图导航不写入工程。工程可选保存图层栏上下文,包括活动图层、图层与组选择、选择锚点和组展开状态;缺少字段或引用已删除对象时自动回退到有效活动图层。项目统计保存笔画数、已提交编辑数和有效绘画时长。缩时设置保存开关、画质、导出帧率、缩时倍速和快照清单,快照像素独立存放在 `timelapse/`,旧工程缺少这些字段时使用关闭录制、空统计和默认网格。 -v1 工程打开时先补成单帧时间轴再迁移到 v4;v2 工程保留原图层、组和动画数据并按“无蒙版”迁移,v3 工程保留原有 cel 蒙版并按“无图层组蒙版”迁移到 v4。cel、图层组或蒙版引用无效、像素文件缺失、格式与字节长度不匹配时必须拒绝打开;`app` 不是 `MoonSprite`、尺寸非法、图层数据缺失或字节长度异常时同样拒绝。未知未来版本不得静默降级读取。 +`timelapse/` PNG 已经自行压缩,当前写入使用 ZIP Store。打开工程时只扫描一次 ZIP 中央目录,再按清单路径直接读取 Layer、Cel、蒙版和其他必需资源;Store 快照以只读归档视图接入文档,不再逐张二次解压或复制,旧工程中的 Deflate 条目仍按原方式兼容解压。任何编辑产生的新快照使用独立字节数组,保存时两类快照保持相同文件语义。 -索引颜色文档保留颜色 ID `0` 表示透明;其他颜色 ID 在调色板重排后保持稳定。导出 PNG 时,只有合成结果不超过 256 个唯一颜色才保留索引输出,否则导出等效 RGBA 图像。 +v1 工程打开时先补成单帧时间轴;v2 工程保留原图层、组和动画数据并按“无蒙版”迁移;v3 工程保留原有 cel 蒙版并按“无图层组蒙版”迁移;v4 资源显式按 `raw` 读取。v1-v4 都迁移到内存 v5,首次保存时按当前稀疏度重写为 v5。cel、图层组或蒙版引用无效、像素文件缺失、编码未知、格式与字节长度不匹配时必须拒绝打开;`app` 不是 `MoonSprite`、尺寸非法、图层数据缺失或字节长度异常时同样拒绝。未知未来版本不得静默降级读取。 + +索引颜色文档保留颜色 ID `0` 表示透明;其他颜色 ID 在调色板重排后保持稳定。绘制、粘贴、文本栅格化和 RGBA 转换都不得自动扩张调色板,而是精确匹配或映射到 `paletteOrder` 中最接近的可见颜色。导出 PNG 时,只有合成结果不超过 256 个唯一颜色才保留索引输出,否则导出等效 RGBA 图像。 `paletteOrder` 继续保存可见颜色的稳定顺序,`paletteColumns` 与 `paletteSlots` 额外保存调色板的二维列数以及按行排列的颜色 ID 或空槽 `null`。旧 v2 工程缺少这些字段时使用 8 列并按 `paletteOrder` 从左到右填入完整行;非法、重复或已隐藏的颜色 ID 会被忽略,缺失的可见颜色补入第一个空槽。栏目尺寸变化只在显示时安全增减空白边缘,用户拖动颜色后才把当前二维布局写回工程。 @@ -28,3 +48,5 @@ v1 工程打开时先补成单帧时间轴再迁移到 v4;v2 工程保留原 用户保存到软件的 `*.palette.json` 使用 `schemaVersion: 2`。`colors` 保存独立 RGBA 颜色,`columns` 保存二维列数,`slots` 按行保存颜色在 `colors` 中的索引或空槽 `null`;每个颜色索引必须且只能出现一次。读取时拒绝未知版本、非法列数、越界或重复索引。旧 `schemaVersion: 1` 色板仍可读取,其仅包含紧凑颜色列表,应用后按当前默认列数顺序排列。 修改格式时必须新增迁移策略、兼容测试和 ADR,不得直接改变已有版本的读取语义。 + +普通保存可以只重新编码发生变化的 cel、图层或蒙版数据,并从当前磁盘工程直接复制未修改 ZIP 条目的既有压缩块。未修改 v5 栅格复用时必须同时沿用源条目的路径、编码、宽高、偏移与 CRC,不能用运行时紧凑表面的几何覆盖清单;资源发生修改后才按当前表面重新选择 `raw` 或 `sparse-tiles-v1`。复用前必须核对条目路径与 CRC,合并失败时回退为完整编码;增量合并使用的内部保存计划不得写入最终工程。资源在 `raw` 与 `sparse-tiles-v1` 之间切换时路径随之变化,因此必须写入新资源,不得复用旧路径。 diff --git a/docs/interactions/brush-color.md b/docs/interactions/brush-color.md index 6b557e6..3630771 100644 --- a/docs/interactions/brush-color.md +++ b/docs/interactions/brush-color.md @@ -2,21 +2,24 @@ ## 颜色 +- 文档颜色模式固定为 RGBA、索引和灰度。RGBA 允许任意颜色和透明度;索引模式的画布像素只能引用当前调色板中的可见颜色,所有外部颜色按 RGBA 距离映射到最近色板颜色且不得隐式新增颜色;灰度模式保留透明度,并在所有绘制、粘贴、文本和转换写入时按感知亮度转成相等的 RGB 通道。 - 前景色和背景色是应用级独立状态,所有已打开工程共享;在任一工程或调色板修改后,切换到其他工程不得恢复为该工程旧颜色。 - 默认按 `X` 交换前景色与背景色;该命令可在“快捷键设置 > 颜色”中修改或清空。 - 画笔左键使用前景色,右键使用背景色;吸管与调色盘使用同样左右键语义。 - 橡皮擦左键保持透明擦除;右键拖动时仅将当前图层笔刷范围内与前景色 RGBA 完全一致的像素替换为背景色,其他颜色保持不变。该行为继续遵守选区、笔刷纹理、对称轴和单笔撤销规则,并支持 RGBA 与索引色图层。 -- 拖动吸管时对应颜色实时更新。 +- 拖动吸管时对应颜色实时更新。吸管工具也可以从调色板颜色格和调色盘/颜色滑条取色,左键写入前景色、右键写入背景色。 +- “按住并点击添加颜色”快捷键默认是 `Alt+S`。单独按下快捷键不添加颜色;保持按住并用左键在画布、调色板或调色盘完成一次取色后,才把最终前景色加入调色板,右键背景色取样不添加。 - 吸管工具属性栏显示当前前景色和背景色,并提供“当前图层替换”和“全局替换”。替换按前景色到背景色的方向进行,只匹配 RGBA 完全一致的像素;全局范围包含全部未锁定图层和动画帧,整次操作只产生一个撤销步骤。 - 吸管工具取色后默认保持吸管;“首选项 > 编辑 > 吸管取色后切回铅笔”开启时,只在正式取色结束后切回铅笔,按住修饰键触发的临时吸色始终返回原工具。 - 调色盘和滑条右键调整背景色时,选择点、滑条和色盘显示必须同步,色相不得跳动。 - 色相吸附和颜色级数改变后,选择点归到当前颜色区域中心,同时保持等效颜色意图。 - 调色板颜色格同时反映前景色和背景色:前景色使用右上角角标,背景色使用左下角角标;左键选择前景色或右键选择背景色都会给被点击的颜色格增加临时蓝色描边,颜色格失焦后只隐藏描边,右键不会改变前景色。 -- 前景色和背景色编辑器标题必须明确标注颜色角色。刚打开时属于临时弹窗,失去焦点自动关闭;标题栏发生真实拖动后转为常驻,可与另一颜色编辑器同时存在。两者分别记忆受最小/最大范围约束的尺寸,但关闭后再次打开必须回到触发按钮附近的默认位置;通道滑条与右侧数值输入框等高,并通过统一八向把手缩放。右键 HEX 输入框时读取系统文本剪贴板,合法的六位或八位 HEX 立即写入并应用,非法内容保持当前颜色不变。 +- “调色板操作 > 同步颜色”默认关闭。开启后,编辑色板颜色会按 RGBA 完全一致匹配,替换全部未锁定图层和动画帧中的对应像素,并与色板修改合并为一次撤销;索引模式只修改色板颜色,不改写画布像素 ID。 +- 前景色和背景色编辑器标题必须明确标注颜色角色。刚打开时属于临时弹窗,失去焦点自动关闭;标题栏发生真实拖动后转为常驻,可与另一颜色编辑器同时存在。两者分别记忆受最小/最大范围约束的尺寸,但关闭后再次打开必须回到触发按钮附近的默认位置;通道滑条与右侧数值输入框等高,并通过统一八向把手缩放。默认模式顺序为 HSV、RGB、LAB、GRAY、PLT,PLT 使用当前项目调色板;HSL 与 CMYK 可在首选项中启用。右侧吸管按钮取色期间保持弹窗打开,取色结束后恢复进入吸色前的工具。右键 HEX 输入框时读取系统文本剪贴板,合法的六位或八位 HEX 立即写入并应用,非法内容保持当前颜色不变。 ## 笔刷类型 -- 基础笔刷包括方形、圆形和线条,允许调整尺寸。 +- 基础笔刷包括方形、圆形和线条,允许调整尺寸。圆形笔刷使用整数硬边缘椭圆扫描线生成掩码,预览与实际落笔复用同一行跨度结果,不使用会让小尺寸圆变成实心方块的连续距离阈值。 - 灰度图笔刷只输出当前颜色,灰度表示覆盖率或像素密度。 - 程序纹理根据各自参数生成,油漆桶填充时纹理按区域规则一次生成,避免重复堆叠。 - 油漆桶在矩形或非矩形选区中均只修改真实选区掩码包含的像素;连续与不连续模式遵守相同限制。 @@ -60,6 +63,13 @@ - 图案与目标对齐:同样平铺,但第一笔落点作为平铺原点;预览必须与实际落点重合。 - 油漆笔刷:以每次笔刷落点独立盖章,不要求跨落点连续平铺。 +## 喷枪 + +- 喷枪按下时立即产生一批粒子,持续按住时按“粒子频率”设定的毫秒间隔继续产生;指针静止时也必须持续喷涂。 +- “粒子半径”控制单个粒子的像素半径,“散布半径”控制粒子围绕指针均匀随机出现的圆形范围,“粒子密度”控制每批粒子数量。 +- 喷枪沿用前景色/背景色、选区、图层蒙版、索引色和对称轴规则。一次完整按住只提交一个像素编辑和一个撤销步骤;取消、窗口失焦或切换窗口时撤销尚未提交的整次喷涂。 +- 定时喷涂使用单一帧调度器,不得按指针移动事件频率产生粒子,也不得在卡顿后无限补发积压批次。 + ## 预览 - 预览形状和实际写入掩码必须来自同一数据。 @@ -69,5 +79,6 @@ - 直线连接期间触发“约束直线方向”组合(默认 `Shift+Ctrl`)时,终点吸附到离指针最近的允许方向。首选项中的“直线斜向阶梯数值”为 `1` 时提供水平、垂直和 45° 共 8 个方向;大于 `1` 时按 `N:1` 与 `1:N` 像素阶梯提供 12 个方向,其中数值 `2` 对应约 30°、60°斜向。约束终点必须落在完整阶梯周期上,使起始阶、所有中间阶和结束阶都严格包含 `N` 个主轴像素;预览与最终落笔使用同一个约束终点和点列。 - 铅笔工具选项栏中的“阶梯”输入框直接编辑同一个“直线斜向阶梯数值”,范围为 `1–16`;修改后立即作用于画布并持久化,与首选项中的设置双向同步。 - 已经绘制的直线锚点算作起始阶的第一个像素;例如阶梯数值为 `6` 时,锚点与其后五个主轴像素共同组成第一阶,不得额外生成第七格。 +- 连续使用直线连接绘制半透明颜色时,各段共享同一笔触前的像素底色;连接点和后续交叉处不得因新线段再次叠加透明度,同时每段仍保持正确的撤销范围。 - 非基础笔刷调整参数时不得闪烁基础圆形或方形预览。 - 指针黑白对比依据指针下最终合成画面,包括预览颜色,而不是当前图层或准备绘制的颜色。 diff --git a/docs/interactions/pointer-modifiers.md b/docs/interactions/pointer-modifiers.md index 0df0ebc..81eba3a 100644 --- a/docs/interactions/pointer-modifiers.md +++ b/docs/interactions/pointer-modifiers.md @@ -19,15 +19,17 @@ 下列修饰键和笔刷尺寸命令必须在“快捷键设置”中可见。修改后,指针、提示和实际操作必须读取同一份配置。 +临时工具生效期间,工具栏必须同步显示对应工具为选中状态;原工具只被暂时覆盖,不写入工具设置,也不进入撤销历史。未开始拖动时,任何当前工具都可以进入临时移动工具;进入框选、形状创建或变换等拖动过程后,该过程自身的组合修饰键语义优先。只要临时移动修饰键仍被按住,后续按下其他修饰键不得退出临时移动,除非完整命中当前工具更高优先级的组合操作。修饰键松开、窗口失焦或页面隐藏后立即恢复原工具的选中状态与行为。 + 快捷键录入以一次按键会话为单位:先按下的 `Ctrl`、`Alt`、`Shift`、`Meta` 只更新预览,普通键按下后再提交完整组合;只绑定修饰键时在修饰键松开或输入框失焦时提交。录入框拥有高于应用命令分发的优先级,`Ctrl+Shift+V` 等组合不得被 MoonSprite 自身的粘贴命令提前截断。`Escape` 放弃本次录入,`Delete` 或 `Backspace` 清空绑定。 | 场景 | Alt | Ctrl | Shift | | --- | --- | --- | --- | -| 画笔、橡皮擦、油漆桶 | 临时吸色 | 工具定义 | 约束由工具定义 | +| 所有工具(未开始拖动) | 由当前工具定义 | 临时移动工具;拖动开始后由当前过程的组合修饰键优先 | 约束由工具定义 | | 鼠标按住新建矩形、椭圆框选或形状 | 调整当前框选或形状的旋转方向 | 以首次按下位置为中心向两侧创建 | 保持 1:1 比例;可与 Ctrl 同时使用 | | 选区内部移动内容 | 临时吸色 | 复制选区内容 | 水平或垂直约束 | | 选区边缘移动选框 | 临时吸色 | 不复制像素 | 水平或垂直约束 | -| 选区缩放点变换 | 以选区中心为轴向两侧同步变换 | 整数硬边缘倍数缩放 | 固定原始宽高比;与 Ctrl 同时使用时按原比例整数倍缩放 | +| 选区缩放点变换 | 以当前轴心为原点向两侧同步变换 | 整数硬边缘倍数缩放 | 固定原始宽高比;与 Ctrl 同时使用时按原比例整数倍缩放 | | 选区旋转 | 临时吸色 | 不改变旋转语义 | 每 45° 吸附到八个方向 | | 移动工具点击或拖动图层 | 复制图层 | 工具定义 | 点击其他图层内容时加选;继续拖动时水平或垂直约束 | | 图层栏目拖动 | 显示复制意图并复制 | 无 | 保持排序语义 | @@ -40,8 +42,12 @@ “直线连接模式”是画笔和橡皮擦的可配置修饰键行为,默认由 `Shift` 触发。它位于“快捷键设置 > 修饰键”,不再放在首选项中。关闭后,`Shift` 只保留其他工具定义的约束行为,不会连接上一落笔点。 +画笔或橡皮擦已有上一落笔锚点时,完整命中的直线连接组合键优先于临时移动工具。默认设置下,`Ctrl+Shift` 必须继续显示受方向约束的直线预览并完成绘制,工具栏也不得临时切换为移动工具。直线连接预览只在没有正在进行的画布拖动时显示;已经按住指针连续绘制后再误按 `Shift`,不得显示上一落笔锚点的直线预览或打断当前笔画。 + 按键松开后必须立即恢复原行为和指针。窗口失焦、指针离开窗口或释放捕获时必须结束临时状态;每次画布或图层栏目指针事件都要用事件自身的 `Alt/Ctrl` 状态校正缓存,避免热更新或焦点切换漏掉 `keyup` 后永久进入吸色或复制状态。 +画布滚轮输入统一兼容标准 `wheel`、旧式 `mousewheel` 和仅提供横向增量的外设事件。TourBox 等外设不需要先由普通鼠标滚轮激活;事件只作用于指针所在的活动画布,输入框、栏目滚动区和正式弹窗保持自身滚动语义。 + ## 热点规则 - 画笔类指针热点对应实际落笔像素;偶数尺寸按当前产品规则落在右下中心像素。 @@ -73,4 +79,4 @@ 修改指针资源或热点时必须验证 1px、2px、大尺寸笔刷,普通/旋转视图和高 DPI。 -“首选项 > 光标 > 使用本地指针”开启时,普通操作使用电脑系统指针;系统没有对应语义的旋转视图和选区旋转等特殊状态继续使用 MoonSprite 内置指针。关闭时所有状态使用 MoonSprite 指针。任何资源加载失败都必须保留有效 CSS 回退,不得显示空白或错误的禁止指针。位置 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 43de815..989816f 100644 --- a/docs/interactions/selection-transform.md +++ b/docs/interactions/selection-transform.md @@ -4,12 +4,12 @@ - 矩形、椭圆、自由套索、多边形套索和魔棒共享新建、加选、减选、交集四种模式。 - 五种选区工具共享上下、左右、右上到左下、左上到右下四个对称轴。每次新建的原始掩码先按全部启用轴和当前可移动轴心计算闭包并去重,再与旧选区执行新建、加选、减选或交集;路径预览、魔棒预览和提交结果必须一致。矩形画布中斜向镜像越界像素不进入掩码。 -- 开启对称后,移动、缩放、旋转和倾斜选区时只变换每个对称轨道中的一个代表区域,再按当前轴心生成镜像闭包;选框、像素预览、实际写入和一次撤销必须一致,不得把已经对称的源内容重复复制成额外副本。 +- 开启对称后,移动、缩放、旋转和倾斜选区时只变换每个对称轨道中的一个代表区域,再按当前轴心生成镜像闭包;移动可以从任意镜像区域开始,按下的区域必须跟随指针,其他区域实时执行对应方向的镜像移动。选框、像素预览、实际写入和一次撤销必须一致,不得把已经对称的源内容重复复制成额外副本。 - 多边形套索单击增加顶点,点击第一个顶点、双击末点或按 `Enter` 闭合并提交,按 `Esc` 取消;未闭合时可用中键或 `Space` 临时平移画布,平移期间和松开后都必须持续显示已有边与指针末段,并保留已有顶点、组合模式和原选区继续建立边界。“首选项 > 编辑 > 直线算法优化”开启时,顶点之间的预览和最终边界都使用长度相近的均匀像素阶梯。自由套索拖动预览是否默认闭合由“首选项 > 编辑”控制。 - 右键拖动默认执行减选。按住 `Shift` 开始选择时绕过已有选区的移动、缩放和旋转命中,从选区内部也能继续加选;属性栏在按住 `Shift` 或右键时临时切换到加选或减选反馈,松开后恢复用户原本选择的组合模式。变换已经开始后,`Shift` 仍作为比例或角度约束键。 - 创建矩形、椭圆或套索选区期间只显示画布内的新路径像素预览,不显示新选区蚂蚁线和变换点;已有选区的蚂蚁线在新建、加选、减选和交集期间保持显示,松开后再一次性显示组合结果。 - 新建模式在已有选区外按下并松开且没有真实拖动时取消选区;按下本身不得提前取消。是否属于单击按 CSS 指针移动距离判断。即使按下与松开仍落在同一个文档像素,只要发生真实拖动,也必须提交该 1 像素选区。 -- 使用矩形框选工具双击画布时快速选择指针所在单元:自定义网格开启时使用当前网格原点与单元宽高,未开启时使用透明棋盘格尺寸;画布边缘的不完整单元裁切到有效画布范围,组合结果沿用当前选区模式。 +- 使用矩形框选工具双击画布时快速选择指针所在单元:第二次按下时立即显示完整单元的实线选择预览,不等待鼠标松开,预览阶段不显示蚂蚁线;保持第二次按下并拖动时,预览范围按指针经过的完整单元扩展并吸附到单元边界,不创建像素级自由矩形;新增、加选、减选和交集模式始终以操作前的选区为组合基底,松开提交后才显示最终选区蚂蚁线。自定义网格开启时使用当前网格原点与单元宽高,未开启时使用透明棋盘格尺寸;画布边缘的不完整单元裁切到有效画布范围。 - 工具栏明确选择加选模式时,已有选区内部仍可移动内容,缩放点、旋转区和边缘移动保持可用;减选与交集从选区内部开始时优先执行组合选择。临时按住 `Shift` 加选或使用右键减选始终绕过变换命中。套索组合期间继续显示像素预览。 - 指针允许从画布外开始并继续按文档像素网格拖动,但创建路径预览只绘制在有效画布范围内;最终选区掩码不得包含画布外像素。 - 魔棒按住时预览全部将被选中的像素;深色像素使用浅色预览,浅色像素使用深色预览。 @@ -47,8 +47,10 @@ ## 缩放与旋转 - 四角缩放手柄始终可见且位于内容上层。 -- 拖动已有选区的缩放点时,`Alt` 固定选区中心并让相对两侧同步变换,跨过中心后才翻转对应轴;`Shift` 固定原始宽高比,`Ctrl` 将宽高分别吸附到原始像素尺寸的整数倍,三者可以组合使用。修饰键在鼠标按住期间按下或松开都必须实时更新预览;缩放点操作不得因 `Ctrl` 复制选区内容,也不得因 `Alt` 切换为临时吸色。 -- 使用矩形或椭圆框选工具时,鼠标按住并开始拖动后,`Ctrl` 以首次按下位置为中心向两侧框选,`Shift` 保持 1:1 比例,`Ctrl+Shift` 执行居中等比例框选;`Alt` 按住时只调整当前框选的旋转方向,保持鼠标左键不松并松开 `Alt` 后,鼠标立即恢复控制当前旋转框选的宽高,并以松开 `Alt` 瞬间的旋转中心为固定中心,将单侧半径的像素变化同步应用到相对的另一侧,不得因斜角投影取整导致中心漂移;`Space` 以按下时的指针为基点移动当前框选,且指针不切换为抓手,松开 `Space` 后继续调整时不得跳变。松开鼠标后的最终选区必须与最后预览一致。 +- 所有工具下都为有效普通选区显示一个固定 `18 x 18 CSS px` 的黑白轴心标识,由 `9 x 9` SVG 像素图标按 `2x` 整数倍率绘制,默认位于当前变换后选区中心对应的单个文档像素中心;偶数宽或高取右侧或下侧的中心像素。标识使用独立于缩放倍率的屏幕尺寸,并在视图旋转或镜像后仍精确贴合轴心位置。悬停标识保持软件默认指针,按住并开始移动后切换为移动指针;拖动按单个文档像素步进,只修改会话交互状态,不编辑像素、不创建文档撤销历史。选区属性栏提供左上、上、右上、左、中、右、左下、下、右下九个预设,每个预设都落在选区内对应位置的单个像素中心,不得落在像素边界交点;并可单独隐藏轴心标识,隐藏只影响显示与命中,当前轴心仍参与旋转、缩放和倾斜,且该开关不进入文档撤销历史。 +- 手动设置的轴心用于后续选区旋转与倾斜,变换期间轴心固定,选框、八个控制点、遮罩和像素共同围绕该点变换。整体移动选区或随图层移动选区时,自定义轴心按同一位移移动;普通缩放时轴心保持在选区中的相对位置并随实时预览移动,跨边翻转时同步翻转,不得重置为中心;`Alt` 轴心缩放期间轴心固定在原文档坐标。新建、替换、清除选区或切换动画帧后恢复为新选区中心。 +- 拖动已有选区的缩放点时,`Alt` 固定当前轴心并让相对两侧围绕该点同步变换,跨过轴心后才翻转对应轴;未手动调整时轴心就是默认选区中心。`Shift` 固定原始宽高比,`Ctrl` 将宽高分别吸附到原始像素尺寸的整数倍,三者可以组合使用。修饰键在鼠标按住期间按下或松开都必须实时更新预览;缩放点操作不得因 `Ctrl` 复制选区内容,也不得因 `Alt` 切换为临时吸色。 +- 使用矩形或椭圆框选工具时,鼠标按住并开始拖动后,`Ctrl` 以首次按下位置为中心向两侧框选,`Shift` 保持 1:1 比例,`Ctrl+Shift` 执行居中等比例框选;`Alt` 按住时只调整当前框选的旋转方向,保持鼠标左键不松并松开 `Alt` 后,鼠标立即恢复控制当前旋转框选的宽高,并以松开 `Alt` 瞬间的旋转中心为固定中心,将单侧半径的像素变化同步应用到相对的另一侧,不得因斜角投影取整导致中心漂移。已经用 `Alt` 改变角度后再按 `Ctrl` 时,必须保留当前角度和精确宽高,只把中心临时移回首次按下点并从当前指针继续双向调整;此时单独的 `Ctrl` 不得额外触发 1:1,只有 `Shift` 或形状工具显式启用的固定比例继续约束宽高。松开 `Ctrl` 后必须恢复按下 `Ctrl` 前的边界、方向和中心,并以松开时的当前指针作为后续调整基准,不得永久保留临时居中的结果。`Space` 以按下时的指针为基点移动当前框选,且指针不切换为抓手,松开 `Space` 后继续调整时不得跳变。松开鼠标后的最终选区必须与最后预览一致。 - 矩形与椭圆形状工具复用上述 `Ctrl`、`Shift`、`Alt` 和 `Space` 交互;旋转后的填充与轮廓使用同一像素栅格结果完成预览和提交,矩形不得重新出现孤立角点,椭圆轮廓不得填实中心。 - 已有选区且画布没有文本输入或弹窗控件焦点时,方向键每次把选区内容和边界向对应方向移动 1 个文档像素。第一次移动进入与鼠标拖动相同的浮动状态,连续按键始终从首次捕获的内容与背景重建;确认后整段连续移动生成一次文档历史,取消则完整恢复原内容与经过区域的背景。 - 旋转命中区位于四角外侧,与缩放手柄分离。 @@ -57,7 +59,7 @@ - 选区内没有任何非透明图层像素时仍允许移动、缩放、旋转和倾斜选区边界;这类操作不创建像素编辑,但确认后必须作为一次可撤销的选区变化记录。 - 拖动旋转期间暂时隐藏蚂蚁线,八个缩放控制点按当前变换矩形和角度同步旋转;松开后恢复蚂蚁线,但八个控制点继续停留在最终旋转位置并沿旋转后的局部轴命中,直到确认或取消浮动变换。四角旋转指针按控制点相对变换中心的当前显示方向动态选择,不与控制点原始的左上、右上等语义固定绑定;控制点靠近垂直中轴时分别使用上方或下方旋转指针。 - 确认旋转、缩放或倾斜后,最终选区必须裁掉掩码四周完全未选中的空白行列;八个控制点、命中区域和下一次变换使用该紧贴范围,不得继续包围变换矩形产生的空白区域。 -- 四边中点缩放区外侧提供紧凑倾斜命中区,最远距离与旋转一致为 28 CSS px,且不显示额外控制点。上、下边使用横向倾斜指针,只改变变换后的宽度而保持高度;左、右边使用纵向倾斜指针,只改变变换后的高度而保持宽度。拖动期间指针切换为移动,选区遮罩、像素预览、最终内容和一次撤销必须共享同一倾斜映射。 +- 四边中点缩放区外侧提供紧凑倾斜命中区,最远距离与旋转一致为 28 CSS px,且不显示额外控制点。倾斜方向始终沿当前四边形的对应边:上、下边沿当前顶边方向,左、右边沿当前侧边方向;指针按实际显示方向在水平、垂直、左上右下和右上左下四种倾斜图标之间切换。连续拖动不同边时,新倾斜以当前四边形和轴心为基准累计,不得退回原始矩形坐标或让先前倾斜自动复位。拖动期间指针切换为移动,选区遮罩、像素预览、最终内容和一次撤销必须共享同一倾斜映射。 - 变换的选框、内容和预览必须使用同一个变换矩阵。 - 缩放手柄可以跨过对侧边界;水平跨越时切换水平镜像,垂直跨越时切换垂直镜像,双轴跨越时同时切换。翻转轴取被拖动的边;角点斜向跨越时,水平和垂直轴分别取被拖动角点的两个坐标,不退回使用目标矩形中心。预览、提交和一次撤销使用同一个翻转状态。 - 视图旋转只改变显示,不改变文档选区和变换坐标。 diff --git a/docs/interactions/workspace-docking.md b/docs/interactions/workspace-docking.md index 3e3fb86..8f41c47 100644 --- a/docs/interactions/workspace-docking.md +++ b/docs/interactions/workspace-docking.md @@ -5,6 +5,8 @@ - 调色板、图层、预览、笔刷等独立窗口统称栏目。 - 栏目可停靠到左侧、右侧和底部,也可成为可调整尺寸的悬浮窗口。 - 颜色、调色板、图层和预览栏目的显示状态彼此独立;隐藏栏目不占停靠空间,可从“窗口 > 栏目”重新显示。 +- 颜色、调色板、图层和预览栏目分别提供可配置的“弹窗式栏目”快捷键。触发后临时显示真实栏目实例,但不得修改原显隐、停靠、顺序或尺寸状态;在栏目及其内部弹层中操作保持显示,点击栏目外部、再次触发同一快捷键或按 `Escape` 后关闭。 +- 弹窗式栏目可拖动并使用统一的八向交互区调整尺寸,各栏目独立保存自己的临时窗口尺寸。每次呼出都以最近鼠标指针为窗口中心生成,并在靠近软件边缘时约束到可见区域;本次打开期间可自由移动,下次呼出沿用最后调整的尺寸并重新跟随当前指针。 - 右键栏目标题打开统一栏目菜单,可隐藏当前栏目或直接移动到左侧、右侧、底部和悬浮区;失去焦点后菜单关闭。 - 工具栏始终占据软件可用高度,只允许左右移动,不参与上下堆叠。 - 工具栏位置统一从“窗口 > 工具栏位置”或标题拖动修改,不允许停靠到底部或悬浮。 @@ -15,8 +17,11 @@ - 左右停靠区中的栏目上下排列,分隔线调整相邻高度。 - 底部停靠区中的栏目左右排列,分隔线调整相邻宽度。 +- 左侧、右侧和底部停靠区分别保存其相对编辑布局宽高的比例;软件窗口改变尺寸时按新的父容器尺寸恢复比例,并继续遵守画布与栏目的最小可用尺寸。 +- 同一停靠区内部的栏目保存相对权重。窗口改变尺寸后,各栏目按拖动分隔线时形成的比例共同增减,不得由最后一个栏目独占新增空间。 +- 旧工作区仅保存像素尺寸时,首次读取会以当前父容器换算为比例;迁移不得改变首次显示时的实际尺寸。 - 移出栏目后,其余栏目立即填补空位。 -- 悬浮窗口尺寸和位置在重启后保持。 +- 悬浮窗口尺寸和位置在重启后保持;悬浮栏目与正式弹窗不随软件窗口按比例缩放,只在必要时约束位置以保持可见。 - 所有悬浮栏目和正式弹窗共用同一套八向调整尺寸行为;四边和四角使用以可见边框为中心的紧凑连续命中带,方向一致,不得覆盖附近的正文控件。 - 拖动或缩放期间应捕获当前指针,并暂停尺寸观察器对交互状态的反向同步;指针到达软件窗口边缘或短暂移出时,窗口不得闪烁、跳回或丢失最终位置。 - 固定高度弹窗的正文使用剩余高度,快捷键设置的分类栏与列表同步向下伸展,操作栏固定在底部,不保留无用途空白。 @@ -39,3 +44,25 @@ - 复位当前工作区仅把 `currentLayout` 恢复为 `initialLayout`。 - 默认工作区不可删除,但可修改和复位。 - 旧工作区没有 `panelVisibility` 时默认显示颜色、调色板和图层,并使用旧 `previewOpen` 迁移预览栏目的显示状态。 + +## 项目画布磁吸 + +- 从顶部拖出项目标签时,只以鼠标指针点判断;原画布内部完整划分为上、下、左、右四个磁吸区域,不保留中心无效区,指针仍在画布外时不得提前显示磁吸条。 +- 指针在四个区域之间移动时,每次都按当前位置重新判断方向,已有磁吸方向不得阻止其他方向生效。 +- 首次快速拖入时,如果相邻两次指针位置之间的路径真实穿过画布内部磁吸区域,即使事件端点跳到画布中间也应识别经过的方向;该补偿不得使用画布外扩区域。 +- 磁吸条出现后继续使用首次命中的原画布矩形判断,预览导致的临时布局变化不得缩小或移动命中区域。 +- 拖动中的磁吸条占据原画布对应边缘的预览空间,画布应平滑让出位置;预览不得重建画布组件或临时改写正式分栏树,只有松开指针确认后才提交真实分栏。 +- 提交停靠后,新放入的项目默认占目标画布的三分之一,原画布保留三分之二;左、右、上、下方向使用相同的新项目占比。 +- 已合并项目在画布内部移动时显示与顶部项目标签一致的拖拽标签;源画布在拖动期间保持原位,只有松开确认后才移动到新位置。 +- 已合并项目拖回顶部标签栏时不自动切换当前主画布;原主画布仍在剩余分栏中时继续保持,只有原主画布自身被移出时才提升一个剩余项目承接工作区。 +- 已有顶部主标签的项目不重复显示分栏小标题;仅保留合并后隐藏于顶部标签栏的项目小标题,作为其拖拽入口。 +- 每个项目的快捷指令栏记录其中心点相对所属画布宽度的水平比例;项目分栏变换、合并、浮出或回停靠后按该比例恢复,仅在防止越出画布时追加临时像素校正。水平拖动进入画布中心线附近时吸附到 50%,离开吸附范围后恢复自由移动。已展开的快捷栏在所属画布失去焦点时临时折叠,焦点返回后恢复原展开状态。 + +## 项目浮出窗口 + +- 顶部项目标签和合并项目的小标题都可通过右键菜单将项目浮出为独立窗口;浮出是移动当前项目视图,不复制项目或画布实例。 +- 浮出项目从顶部标签栏和分栏布局中移除;若移除的是分栏主项目,剩余项目自动提升为新的主标签,单个剩余项目自动恢复为普通画布。 +- 浮出窗口复用栏目的标题拖动、层级置顶和八向尺寸调整交互,点击窗口后该项目成为当前活动项目,但中央工作区不得重复显示同一项目。 +- 普通项目浮窗始终显示在工具栏和属性栏之上,但不遮挡正式弹窗与上下文菜单;启用置顶后,该项目浮窗保持在所有普通工作区窗口之上,直到用户取消置顶。 +- 将浮窗标题拖入顶部项目标签栏时,浮窗本体立即隐藏,只显示与合并项目一致的项目标签拖拽预览和插入位置线,不显示顶部选中样式;离开标签栏时浮窗恢复,松开后项目按该位置返回标签栏,但当前工作区继续停留在原画布,不自动切换到返回项目。也可从标题按钮或右键菜单直接返回。 +- 关闭操作沿用项目未保存确认流程,取消关闭时窗口保持原状。 diff --git a/docs/product/behavior.md b/docs/product/behavior.md index b2b5a82..ad6f33c 100644 --- a/docs/product/behavior.md +++ b/docs/product/behavior.md @@ -6,34 +6,38 @@ - 用户可同时打开多个项目,并在顶部项目栏切换或复制视图。 - 顶部“窗口”菜单集中管理视图辅助、栏目显示和工具栏位置;栏目布局修改实时保存到当前工作区。 -- “帮助 > 未来计划”以中英文列表展示已经确定的长期方向和完成状态;当前计划包括动画、剪贴蒙版、蒙版、抖动笔刷、对称轴、压感、文本、渐变、切片、网格、瓦片、图层样式、铅笔平滑、索引模式、缩时动画、自定义笔刷、导出精灵表和动画标签,状态必须随实际完成情况同步更新,已完成项目自动排在最上方。 +- “帮助 > 未来计划”以中英文列表展示已经确定的长期方向和完成状态;抖动笔刷、主题修改与切片工具标记完成,关联图层列为计划中,铅笔平滑不再列入计划,已完成项目自动排在最上方。 - “帮助 > 更新日志”只展示最近一次正式打包版本的版本号和更新摘要;内容来源于对应的 `docs/changelog/<版本>.md` 归档,不得混入根 `CHANGELOG.md` 的“未发布”内容。 - `.moonsprite` 保存完整项目状态,并可通过文件选择、Windows 文件关联或从资源管理器拖入打开;拖入统一经过平台服务,HTML、Tauri Webview、Window 与 Rust 窗口事件互为兜底,单个通道失效时在窗口重新获得焦点后重试。所有通道共用格式过滤和路径去重,拖入成功后必须直接进入对应编辑器,不得仍停留在首页。 - MoonSprite 程序根目录下默认创建 `gallery` 与 `exports` 文件夹:新项目首次保存或另存为默认打开 `gallery`,图片、GIF、缩时视频和调色板图片默认打开 `exports`。首选项可分别覆盖默认保存位置和默认导出位置;清除覆盖后重新跟随根目录默认文件夹,已有文件的普通保存始终写回原路径。 - `.ase` 与 `.aseprite` 可通过文件选择、拖放和文件关联打开;保存或导出必须遵循用户明确选择的格式。 -- PNG、JPEG、WebP、BMP 与 GIF 可通过文件选择或拖放作为单层像素文档打开;GIF 当前读取第一帧。图片导入后保留来源路径,但不会覆盖原图片,首次保存仍按用户选择的工程或图片格式执行。 +- PNG、JPEG、WebP、BMP 与 GIF 可通过文件选择或拖放作为单层像素文档打开;GIF 当前读取第一帧。图片导入后保留来源路径;只在原始单图层、单帧内继续绘制时,普通保存直接按原格式原路径覆盖来源图片。新增图层、图层组、帧、蒙版、文本、图层样式、切片、项目画笔或缩时记录等图片无法保存的工程结构后,普通保存必须改为 `.moonsprite` 工程;“另存为”仍遵循用户明确选择的格式和路径。 - 从文件关联启动时应直接进入目标工程,不应只停留在首页。 - 打开或保存的 `.moonsprite`、`.ase`、`.aseprite`、PNG、JPEG、WebP、BMP 与 GIF 进入最近列表;首页和“文件 > 最近打开文件”始终显示真实文件名及后缀。最近记录的原路径不存在时提示用户并自动移除该记录;路径存在但无法解析的文件继续保留原文件和最近记录,并显示“从最近移除”供用户明确处理。画廊中的“删除工程”是独立危险操作,必须二次确认后才删除磁盘文件。 -- 首页先显示完整文件列表,再以最多 3 个任务并发渐进读取缩略图;单项读取失败不得阻塞其他项目。已成功解码的缩略图在当前软件会话内按文件路径和记录时间缓存,栏目刷新时复用未变化结果。 -- 打开新建工程弹窗时,如果系统剪贴板包含图片,应立即读取图片头部宽高并填入尺寸;此步骤不得解码或跨进程传输整张像素数据。 -- 异常关闭工程进入恢复栏目;用户明确放弃后不得反复提示或自动恢复。 +- 首页先显示完整文件列表,再以最多 3 个任务并发渐进读取缩略图;单项读取失败不得阻塞其他项目。`.moonsprite`、`.ase`、`.aseprite`、PNG、JPEG、WebP、BMP 与 GIF 都必须显示缩略图;普通图片直接生成最大边长 512 像素的平滑预览,工程缺少内嵌预览图时在后台串行生成相同上限的缩略图,成功结果按文件指纹写入磁盘缓存并在当前会话复用,不得同时解码多张大图拖慢首页。恢复记录同样渐进生成封面,但不得因此把完整预览重新写入自动保存文件。首页封面统一使用平滑缩放;右键任一普通项目卡片直接打开对应文件所在文件夹。首页按 `Ctrl+滚轮` 在小、中、大三档项目布局间切换并持久化:小号使用只保留标题和路径的紧凑单行列表,中号保持标准缩略图列表,大号使用每行四项的放大封面卡片并显示标题、文件信息、路径和时间。首页栏目管理允许选择任意文件夹作为同级自定义栏目,只读取该文件夹第一层中软件支持的工程与图片;最近、画廊、恢复和全部自定义栏目可统一拖动排序并持久化,自定义栏目可从首页移除但不得删除磁盘内容。首页右上角提供 QQ 群、Steam、GitHub 和语言四个像素按钮;前三项使用系统浏览器打开对应页面,语言按钮打开正式选择弹窗,确认后持久化简体中文或 English。 +- 打开新建工程弹窗时,如果系统剪贴板包含图片,应立即读取图片头部宽高并填入尺寸;此步骤不得解码或跨进程传输整张像素数据。新建成功后焦点必须进入新项目对应画布,不得落到调色板或其他栏目控件。 +- 异常关闭工程进入恢复栏目;用户明确放弃后不得反复提示或自动恢复。恢复数据默认保留 7 天,用户可在“首选项 > 文件”设置 `1-365` 天;软件启动时即使上次正常关闭,也必须成对清理超过保留期的恢复描述与工程数据文件。 - 像素网格和自定义网格的开关以及自定义网格参数属于当前项目显示设置;保存并重新打开项目后恢复原状态,不复用其他项目的网格状态。缩放、平移、旋转和镜像仍只属于当前会话。 -- “文件 > 缩时视频”按项目控制自动录制、画质、导出帧率和缩时倍速;开启后在每次已提交编辑结束时记录合成快照,不记录缩放、平移和栏目操作。记录随 `.moonsprite` 保存,可清空并导出 WebM。 +- 新建画布弹窗提供“记录绘画过程”开关,其默认状态由首选项控制且初始默认关闭。“文件 > 缩时视频”按项目控制录制、画质、导出帧率和播放倍率;开启后每个已提交的绘画或内容编辑操作记录为一个等权视频帧,不按现实时间计时,也不记录缩放、平移和栏目操作。记录随 `.moonsprite` 保存,可清空并导出 MP4 或 WebM。 - “文件 > 项目信息”显示画布、颜色模式、图层/组/帧数量、笔画数、编辑操作数、有效绘画时长、创建与修改时间以及缩时记录帧数。 -- “文件 > 导出”提供“导出为”和“导出精灵表”两个选项。“导出为”打开现有导出设置;“导出精灵表”将时间轴中的每一帧完整合成,按时间轴顺序从左到右排列,并打开一个未保存的 RGBA 单图层文档供继续编辑或常规导出,不直接写入磁盘。 +- “文件 > 导出”提供“导出为”“导出所有帧”和“导出精灵表”。“导出为”打开现有导出设置;“导出所有帧”复用现有格式、缩放和导出目录设置,将时间轴各帧按顺序合成为独立图像并以连续序号命名;“导出精灵表”将时间轴中的每一帧完整合成,按时间轴顺序从左到右排列,并打开一个未保存的 RGBA 单图层文档供继续编辑或常规导出,不直接写入磁盘。 ## 编辑与历史 +- 可编辑文本支持选择字符后单独设置字号、行间距、字间距和颜色;选择范围内存在混合值时对应控件显示为空,输入新值后统一覆盖该范围。字体间距使用字体字面框与默认行高,实际间距按最终可见像素边界计算,`0px` 时相邻字符或行的可见像素直接相接。 + ### 动画时间轴 - 动画采用 Aseprite 式图层/cel 模型:图层栏中的每个普通图层是一条跨帧轨道,图层右侧的每个帧格是该图层在对应帧的 cel;图层名称、可见性、锁定、透明度、混合模式、分组和顺序跨帧共享,像素、位图尺寸与偏移属于 cel。 - 播放控制、帧增删复制、帧时长、循环设置和帧列全部集成在图层栏目内,不提供重复的独立时间轴栏目。第一帧紧邻图层名称区域左侧,名称区与帧列之间的分隔线允许拖动并持久化宽度;帧从左到右排列并与图层行严格对齐,选中帧以覆盖帧头和全部 cel 行的整列蓝色外描边反馈,边框四边必须连续。当前图层与当前帧交叉格只保留深色活动背景,不显示额外内部蓝框;帧数超出栏目宽度时在同一区域横向滚动。 +- 图层设置提供“隐藏时间轴”。开启后保留工程中的全部帧与 cel 数据,但图层栏目只显示普通图层列表,时间轴网格和图层栏动画按钮不再显示;帧切换、播放、洋葱皮、帧增删及对应快捷键和预览播放同时停用并显示禁用状态。关闭后恢复原时间轴及其数据。 - 图层栏目中的“新增帧”默认复制当前帧并创建互不影响的独立 cel;右键帧头可打开帧属性、复制当前帧、新建默认 `100 ms` 的空白帧或删除帧。每一帧独立保存毫秒时长并直接显示在帧头,不在栏目工具栏提供全局时长输入。动画至少保留一帧。 - 帧头和 cel 使用一致的外描边多选反馈,并均使用与图层相同的指针拖拽语义完成排序或跨位置移动:移动超过阈值后全部源对象半透明,帧显示插入位置,cel 显示目标格,松手后只产生一次撤销。双击帧头打开该帧时长属性,双击有内容 cel 打开单元格不透明度属性;无内容 cel 的属性、复制和删除命令禁用,但已有 cel 剪贴板时仍可粘贴。单元格不透明度复用图层属性的滑块、数值输入和百分比布局,属性弹窗使用 `Enter` 提交并关闭、`Esc` 放弃并关闭。播放期间隐藏帧与 cel 的选择描边,但保留选择状态。 - 复制帧时复制所选帧的时长及全部图层 cel,粘贴到最末选中帧之后;副本像素必须独立。复制 cel 只处理所选单元格。帧与 cel 的右键菜单必须在复制、粘贴和删除命令中明确写出操作对象。 -- 图层栏和预览栏共用同一套播放状态与右键设置菜单,可设置 `0.25x`、`0.5x`、`1x`、`1.5x`、`2x` 或 `3x` 播放速度、单次或循环播放,以及停止时是否返回开始播放的帧;左键继续执行播放或暂停。播放计时器只在编辑器公共层运行一个实例,两个栏目同时显示时不得重复推进帧。播放速度、开始帧和停止返回开关属于会话状态,不改变工程 dirty,不进入撤销历史。 +- 图层栏和预览栏共用同一套播放状态与右键设置菜单,可设置 `0.25x`、`0.5x`、`1x`、`1.5x`、`2x` 或 `3x` 播放速度、单次或循环播放,以及停止时是否返回开始播放的帧;左键继续执行播放或暂停。默认按 `Enter` 播放或暂停,但只有在焦点不属于输入控件、没有正式弹窗或菜单、没有选区或文本变换、且当前画布操作未占用 `Enter` 时才触发。无修饰键的左右方向键切换上一帧或下一帧,到达首尾后循环到另一端。播放计时器只在编辑器公共层运行一个实例,两个栏目同时显示时不得重复推进帧。切帧、播放速度、开始帧和停止返回开关属于会话状态,不改变工程 dirty,不进入撤销历史。 - 鼠标位于图层栏目时,`Ctrl+滚轮`在紧凑、标准、详细、扩展、大型和巨型六档显示密度间切换并持久化:紧凑档保留按钮、图层名称和帧;标准档显示混合模式与不透明度;详细及更大三档显示缩略图并逐级放大图层行和帧格。cel 缩略图始终使用方形容器,整个容器以透明棋盘格填充;可见内容按自身边界等比放大到合适尺寸并居中,文档边界外像素仍被裁掉。`Alt+滚轮`横向滚动帧区。图层栏目停靠在左侧或右侧时必须为帧区保留可操作宽度,普通绘画不会在非详细档生成缩略图。 - 绘画、填充、选区变换和图层像素操作只修改当前帧的当前图层 cel;图层的新建、复制和删除同步维护全部帧轨道。复制图层复制全部帧 cel,删除图层删除全部对应 cel。 +- 文本工具单击画布后按原方式创建自适应尺寸文本;按住左键拖拽会先实时框出固定区域,松开后打开文本编辑弹窗,尚未输入文本时该区域仍持续显示。文字超过区域宽度时自动换行并裁切在区域高度内;实际间距模式让可见文字紧贴区域边缘。固定区域文本只要其图层被选择,就持续显示统一的区域轮廓和八个交互点;文本工具悬停在区域内部时显示移动指针,单击打开当前文本编辑,按住拖动移动区域和文字,拖动交互点则调整区域位置或大小并实时重新换行,不缩放字形,也不写入栅格变换。文本框轮廓和交互点颜色默认是 `#0000FF`,可在首选项的画布颜色中修改。固定区域文本不响应 `Ctrl+T`,自适应尺寸文本仍使用 `Ctrl+T` 进入与普通图层相同的缩放、旋转和倾斜变换。字体在左侧按行选择,“导入”使用组件库下拉并自动读取系统已安装字体,“选择”用于从本地选择字体文件并复制到 MoonSprite 的 `fonts` 目录。右侧可设置多行文本、字号、行间距、字间距及锯齿或平滑渲染;输入区保持紧凑,实时预览默认开启,关闭预览或取消时恢复原画面,确认只产生一次正式历史。确认后在点击位置创建独立文本图层,图层行显示文本标识;每个文本 cel 同时保存可编辑文本参数和用于合成的栅格缓存。双击文本图层或其 cel 可重新编辑,空白文本 cel 也可通过双击创建内容;选择文本图层时,文本、移动、吸色、抓手、缩放和旋转工具保持可用,框选、画笔、形状、直线、喷枪和填充等栅格编辑工具会禁用,必须先转换为普通图层。文本 cel 只能与文本图层互相复制、粘贴或移动,普通像素工具和普通像素粘贴不得直接破坏其可编辑源。之后重新编辑文本仍保留已完成的变换;右键“转换为普通图层”会保留当前栅格结果并删除可编辑文本数据。上述创建、编辑、变换和转换均可撤销,并随 `.moonsprite` 工程保存;工程只记录字体名称,跨电脑打开时需安装或导入同名字体才能保持一致。 - cel 右键菜单可新建或删除“图层蒙版”,图层或图层组行的右键菜单会为该对象所有已有内容的帧批量创建对应蒙版;图层组使用“图层组蒙版”名称,两者都按帧独立保存。新建蒙版是一张透明空白的独立灰度表面:透明像素表示未绘制且等效为白色,白色完全显示、黑色完全隐藏、灰色按比例降低所有者的最终透明度;绘制任意颜色时统一转换为不透明黑白灰,橡皮擦恢复透明未绘制状态。创建后在所有者正上方显示独立蒙版轨道,轨道使用普通图层行高,蒙版单元格与普通 cel 使用相同尺寸、间距和边框;详细及以上档位显示实际黑白灰缩略图。普通点击会选择蒙版单元格并将其设为当前绘制目标,主画布仍显示应用蒙版后的合成结果;编辑期间单元格高亮保持,但只有进入独立蒙版视图时持续显示变换边框。按住 `Alt` 悬停时使用项目打开指针,`Alt+左键`进入独立蒙版视图,`Shift+Alt+左键`加选并进入最后点击的蒙版。进入独立视图后主画布切换为白底灰度画面,只显示蒙版本身;吸色、绘制预览和粘贴都读取同一灰度视图。点击任意图层或图层组行、切换帧或普通选择 cel 会退出蒙版编辑。蒙版单元格可独立多选、复制、粘贴、链接、断开链接和拖动,目标始终限制在蒙版轨道,不得改写普通 cel 像素;断开链接时保留当前解析后的灰度内容。复制帧、复制图层或图层组时,其逐帧蒙版独立复制,不与原对象共享像素。 - 新增、复制、删除帧和修改帧时长属于可撤销文档编辑。切帧、播放、暂停和播放位置属于会话状态,不改变 dirty,不进入撤销历史;在其他帧执行撤销时,历史仍必须修改原操作所属帧。 - 播放严格使用每帧保存的毫秒持续时间和会话播放倍率;循环开启时末帧后回到首帧。单次播放无论从哪一帧启动,都从第一帧播放到最后一帧,最后一帧完整显示自身时长后停止;开启“停止时返回开始帧”时回到播放前选择帧,关闭时自动回到第一帧,人工暂停则停在当前帧。播放期间不接受绘画输入,但画布平移与缩放继续可用;暂停后继续编辑当前帧。 @@ -42,20 +46,23 @@ - 画布平移、缩放、旋转视图、面板布局和工具切换不是文档编辑,不得改变 dirty 状态。 - 无可撤销操作时按 `Ctrl+Z` 不得把工程标记为未保存。 - 一个用户动作应对应一个历史步骤,例如复制图层并移动后一次撤销应整体撤销。 -- `Delete`、`Backspace`、`Ctrl+C` 和 `Ctrl+V` 根据用户最后点击或键盘聚焦的画布、图层或调色板区域执行;`Backspace` 与当前可用的删除操作等价,文本输入中仍保留正常退格。遗留选区不得抢占图层和调色板命令。跨项目复制单层、多层或图层组时保留顺序、偏移、图层属性和父子组结构。粘贴图层、图层组或组合时,有选择则放到当前最上方选中对象同级的正上方,没有选择则放到根层最上方;复制组内部的父子结构保持不变。粘贴完成后整批图层和图层组都进入选中状态,整批粘贴或删除只产生一个撤销步骤。 +- `Delete`、`Backspace`、`Ctrl+C` 和 `Ctrl+V` 根据用户最后点击或键盘聚焦的画布、图层或调色板区域执行;`Backspace` 与当前可用的删除操作等价,文本输入中仍保留正常退格。遗留选区不得抢占图层和调色板命令。跨项目复制单层、多层或图层组时保留顺序、偏移、图层属性和父子组结构。粘贴图层、图层组或组合时,放到当前最上方选中对象同级的正上方;复制组内部的父子结构保持不变。粘贴完成后整批图层和图层组都进入选中状态,整批粘贴或删除只产生一个撤销步骤。 - 复制图层组时同时保存组的展开或折叠状态;粘贴后恢复相同状态,并选中新组、嵌套组及全部后代图层,使批量拖拽、删除和属性设置立即作用于完整副本。 ## 图层与选区 - 根层顶部和底部拖放目标按实际可见行坐标计算,即使全部可见行都是拖动源,仍允许按住 `Alt` 复制到最底部;底部蓝线必须紧贴最后一个可见图层或图层组下方,不得落在栏目容器底边,并与普通插入线一样在左右两端显示三角端点。 - 图层拖拽悬浮预览最多逐行展示四个可见选中对象;未逐行展示或位于折叠组内的选中对象以 `+N` 准确汇总,结构操作仍只处理未被父组选中覆盖的顶层对象。 -- 图层和文件夹支持嵌套、统一排序、拖入与拖出;所有组都使用持久排序锚点,允许夹在普通图层之间并在保存重开后保持。新建图层和空图层组在有选择时位于最上方选中对象同级的正上方,无选择时位于根层最上方;多选图层创建组时,新组仍占据所选图层中最上方图层的原位置,并保留成员原有上下顺序;选中组内图层时新图层与该图层同组,直接选中图层组时新图层位于该组外并与其同级;没有选择任何图层或组时,新建组必须为空。拖出图层栏上方或下方时分别落到根层最上方或最下方,顶部和底部蓝线与最终落点必须使用同一目标,即使首项或末项为组也允许放到组外。组标题行上、下四分之一区域分别生成同级插入线,中部才显示拖入组的整行蓝框;指针命中组内子层时不得提前提升为父组蓝框。该规则适用于任意嵌套深度,蓝线左边必须与最终层级对齐。只选中一个组拖动时,结束后仍保持单组选中语义,不把后代改成显式选中。图层与图层组使用相同的选中背景;眼睛、锁和组文件夹拥有独立交互区,不得误触属性面板。按住眼睛或锁拖动时,以按下图标为起点、当前指针所在同类图标为终点形成连续动态范围;范围内统一应用起点点击后的状态,往回划缩小范围时,离开范围的对象实时恢复按下前状态,整段手势合并为一次撤销。普通单击松开后立即结束手势,后续悬浮不得继续修改。`Alt+眼睛/锁`一次作用于当前对象所在层级的全部图层和图层组。普通图层行只有在 `Alt` 拖动实际开始后才显示复制指针,单独按住 `Alt` 不改变指针。栏目标题只保留新建图层、新建组和删除,合并与解组从菜单执行。 +- 图层和文件夹支持嵌套、统一排序、拖入与拖出;所有组都使用持久排序锚点,允许夹在普通图层之间并在保存重开后保持。新建图层位于最上方选中对象同级的正上方;多选图层创建组时,新组占据所选图层中最上方图层的原位置,并保留成员原有上下顺序;选中组内图层时新图层与该图层同组,直接选中图层组时新图层位于该组外并与其同级,创建新组时则生成该组的空同级组。拖出图层栏上方或下方时分别落到根层最上方或最下方,顶部和底部蓝线与最终落点必须使用同一目标,即使首项或末项为组也允许放到组外。组标题行上、下四分之一区域分别生成同级插入线,中部才显示拖入组的整行蓝框;指针命中组内子层时不得提前提升为父组蓝框。该规则适用于任意嵌套深度,蓝线左边必须与最终层级对齐。只选中一个组拖动时,结束后仍保持单组选中语义,不把后代改成显式选中。图层与图层组使用相同的选中背景;眼睛、锁和组文件夹拥有独立交互区,不得误触属性面板。按住眼睛或锁拖动时,以按下图标为起点、当前指针所在同类图标为终点形成连续动态范围;范围内统一应用起点点击后的状态,往回划缩小范围时,离开范围的对象实时恢复按下前状态,整段手势合并为一次撤销。普通单击松开后立即结束手势,后续悬浮不得继续修改。`Alt+眼睛/锁`一次作用于当前对象所在层级的全部图层和图层组。普通图层行只有在 `Alt` 拖动实际开始后才显示复制指针,单独按住 `Alt` 不改变指针。栏目标题只保留新建图层、新建组和删除,合并与解组从菜单执行。 - 普通且不透明度为 `100%` 的图层组使用穿透合成,组内图层继续按各自混合模式与组外下方内容逐层计算,创建或解除图层组不得改变画面;组自身使用非正常混合模式或低于 `100%` 不透明度时,才先隔离合成组内内容,再以组属性合成到外部。图层组可单独开启“累积混合”:先让组内成员直接与组外下方内容合成,再将得到的完整结果按组混合模式和组不透明度与同一外部背景合成一次;该开关默认关闭,并作为可撤销的工程属性保存。 - 图层和图层组可从右键菜单开启剪贴蒙版。开启后,该对象的显示内容受同一层级紧邻下方对象的形状、像素透明度和对象不透明度限制,图层栏名称左侧显示剪贴蒙版图标;没有同级下方对象时保持原显示,移动到有效基底上方后自动生效。剪贴蒙版属于可撤销的工程属性,随复制粘贴和 `.moonsprite` 保存。 +- 图层和图层组的右键菜单提供“图层样式”,可独立启用描边、阴影、内发光、颜色叠加和渐变叠加;弹窗标题使用“对象名称 图层样式”,效果内容区不重复显示效果标题或说明。描边复用普通描边功能的宽度、内部/外部位置、圆形/方形/水平/垂直快捷形状及八方向像素开关,并额外支持“两侧”。描边开启“智能色相”后隐藏手动颜色字段:外部描边逐像素读取最近的可见源色,内部描边读取当前位置的可见源色,并按统一 RGB 系数压暗以保留原色相。阴影开启“智能阴影”后同样隐藏手动颜色字段,并将阴影下方的实时背景按统一 RGB 系数压暗,因此背景变化时阴影颜色同步变化。两种深色系数均为 `0%` 保持原色、`100%` 变为黑色。渐变叠加复用渐变工具的平滑、Bayer 和方向抖动模式及像素预览。图层样式跨帧共享,图层组样式作用于每帧的组内合成结果;两者都基于应用自身蒙版后的可见轮廓实时计算,不改写原像素。阴影和外部描边位于内容后方,颜色叠加、渐变叠加、内发光及内部描边作用于内容内部,之后再应用所有者的不透明度、混合模式和剪贴蒙版。索引和灰度文档中的固定及智能生成样式颜色都按当前文档颜色规则解析。弹窗支持实时预览,取消恢复打开前状态,应用只生成一个撤销步骤;启用样式后,图层或图层组右侧显示可点击的图层样式图标。复制、粘贴、重复和保存工程都必须深拷贝并保留样式。文本图层或启用了样式的普通图层可执行“栅格化图层”:仅含文本时保留现有栅格表面与蒙版并移除可编辑文本;启用了样式时,把每一帧当前样式及蒙版结果合并为独立像素,同时移除样式、蒙版与 cel 链接。两种情况都由一次撤销完整恢复。 +- 普通栅格图层可从右键菜单转换为背景图层并随时转换回普通图层;文本图层必须先栅格化。右键菜单把新建图层、新建图层组和新建背景图层统一归入“新建”悬浮子菜单;选择新建背景图层后打开正式弹窗,从纯色、网格、条纹、菱形 1、菱形 2 或圆形预设中选择,纯色固定排在首位、默认使用 `#e4e4e4` 并作为默认选项。新背景在根层最底部创建,已有动画帧默认共享同一预设内容。背景图层名称始终带贴近文字的下划线,右侧使用 `image` 像素图标标识。背景图层仍可使用普通像素工具编辑并正常参与显示和导出;选择普通图层吸色时,所有背景图层都视为透明,半透明前景色保留自身透明度,选择背景图层后则恢复完整合成吸色。扩大任意画布边界时,每个背景图层的活动帧与非活动帧都以扩大前可见画布为平铺单位、按当前锚点相位一次性填满新画布;转换时不会重复当前内容,后续编辑直接修改已经生成的普通像素。新建、转换和扩大画布都可撤销,背景属性随复制、粘贴和 `.moonsprite` 保存。 - 图层选择使用 Photoshop 式修饰键:`Ctrl` 在图层和组之间离散增减选,`Shift` 从最近选择锚点到目标对象按当前完整图层树的可见行执行连续范围选择,范围内的图层和组都进入选择。混合选中的图层与图层组是一个操作单元,可统一拖拽、按住 `Alt` 复制、删除和右键批量设置名称、混合模式、不透明度、显示颜色及描述;拖拽、复制和删除等结构操作只处理未被父组选中覆盖的顶层对象,批量属性则必须作用于用户明确选中的每个图层和组,不得因父组同时选中而漏掉后代。拖放前后严格保持所有顶层选中对象从上到下的相对顺序和完整选择状态,每个用户动作只生成一次撤销。拖拽悬浮预览按图层栏顺序列出全部可见选中行;复制模式允许把源行自身作为上方或下方插入锚点并显示蓝线,普通移动仍拒绝无意义的自身落点。多选后普通单击任意图层或组会清除多选并只选择该项;`Delete` 一次删除全部选中图层、选中组及组内后代,并由一次撤销完整恢复。单对象图层属性实时预览;批量属性的表单值立即反馈,文档预览按约 40 ms 合并到最后一次输入,停止操作或关闭时强制应用最终值。批量属性只覆盖用户实际修改过的字段,关闭按钮、遮罩或 `Esc` 保存预览结果并关闭面板,整批属性变化只生成一次撤销。 -- 点击图层栏目中不属于任何图层行的空白区域会清除多选和组选择,但保留当前活动图层。空图层组允许使用 `Delete` 删除;锁定图层、锁定组及其所有后代不得修改像素、混合模式、不透明度、结构、排序或删除,但仍允许编辑名称、显示颜色和描述。组锁定后,其所有后代显示有效锁定状态且不能单独解锁;包含任意显式锁定后代的祖先组也不得删除。 +- 只要文档中仍有图层,图层栏就始终保留一个有效选择,不允许进入“未选择图层”状态。点击图层栏空白区域会清除多选、组选择和蒙版选择并回到当前活动图层;减选最后一个图层时继续保留该图层,删除活动图层后自动选择相邻的剩余图层,撤销和重做后同样保证选择有效。空图层组允许使用 `Delete` 删除;锁定图层、锁定组及其所有后代不得修改像素、混合模式、不透明度、结构、排序或删除,但仍允许编辑名称、显示颜色和描述。组锁定后,其所有后代显示有效锁定状态且不能单独解锁;包含任意显式锁定后代的祖先组也不得删除。 +- 图层和图层组的可见性、锁定等内容属性随工程正常保存;图层栏的活动图层、多选行、选择锚点和组展开状态按工程路径即时记忆,关闭并重新打开软件后恢复。图层栏选择或展开变化不改变工程 dirty,也不进入撤销历史;工程文件在下次正常保存时同时携带当时的图层栏上下文,失效引用自动过滤并回退到有效活动图层。 - 帧和 cel 的显式选区互斥:选择帧会清除 cel 选区,选择 cel 会清除帧选区;点击任意图层或图层组行时两类显式多选统一清除。当前图层与活动帧交叉位置仍作为当前 cel,但不得额外显示另一类多选框。多选图层自动映射出的当前帧单元格只显示图层多选背景,不显示额外蓝色单元格外框;用户继续跨帧显式加选单元格后才显示外框。长按范围选择只维护当前操作类型的实时临时预览;帧范围拖选进入 cel 行后仍按指针所在列继续扩展。 -- 调色板槽位按栏目可见宽高自适应填满:栏目变宽或变高时补充右侧和下方空槽,缩小时只移除完全空白的边缘;若右侧或下方已有颜色则保留其二维坐标并启用组件库统一的横向、纵向滚动条,不得重新换行打乱用户排列。`Alt + 滚轮` 用于横向滚动,`Ctrl + 滚轮` 在“较小尺寸 / 小尺寸 / 中尺寸 / 大尺寸 / 较大尺寸”五档(`22 / 30 / 40 / 52 / 64 px`)之间调整颜色格尺寸,向上放大、向下缩小。颜色格之间不留间隔。空槽可点击并作为拖放目标,默认及鼠标悬浮时均不显示背景框;每个已有颜色始终在格子边界显示一像素主题对比居中描边,相邻颜色共用同一位置的描边,使内部交界线与最外圈保持相同粗细;浅色主题使用深色描边,深色主题使用较柔和的中性描边。普通单击替换选择,`Ctrl` 离散增减选,`Shift` 按矩形槽位范围选择;点击空槽时使用与颜色相同的选中外框并清除原颜色选择,焦点离开整个调色板后取消当前颜色或空槽选择。当前选择显示一像素深色内线、三像素强调色描边和一像素深色外线。在格子内部按住约 360 ms 后进入框选,按住移动时实时改变外框范围,即使在计时完成前已经移动也不会取消框选。只有指针位于强调色外框边缘时才能拖动整组选区,拖动整组颜色时选中外框同步跟随预览位置并保持二维相对范围;落到空槽时原位留空,落到已有颜色时与被占用槽位交换。双击任意槽位会把当前前景色加入并移动到该槽位。槽位列数、位置与空位属于可撤销工程数据并随 `.moonsprite` 保存;保存到软件时始终可以“保存为新色板”,当前选择为非内置用户色板时还可明确“保存到当前色板”,两种操作都保留列数、二维位置和空槽,旧版紧凑色板载入时按顺序排列。颜色格同时反映前景色和背景色:前景色保留右上角角标,背景色保留左下角角标,颜色角色本身不随选择变化。 +- 调色板槽位按栏目可见宽高自适应填满:栏目变宽或变高时补充右侧和下方空槽,缩小时只移除完全空白的边缘;若右侧或下方已有颜色则保留其二维坐标并启用组件库统一的横向、纵向滚动条,不得重新换行打乱用户排列。`Alt + 滚轮` 用于横向滚动,`Ctrl + 滚轮` 在“较小尺寸 / 小尺寸 / 中尺寸 / 大尺寸 / 较大尺寸”五档(`22 / 30 / 40 / 52 / 64 px`)之间调整颜色格尺寸,向上放大、向下缩小。颜色格之间不留间隔。空槽可点击并作为拖放目标,默认及鼠标悬浮时均不显示背景框;每个已有颜色始终在格子边界显示一像素主题对比居中描边,相邻颜色共用同一位置的描边,使内部交界线与最外圈保持相同粗细;浅色主题使用深色描边,深色主题使用较柔和的中性描边。普通单击替换选择,`Ctrl` 离散增减选,`Shift` 按矩形槽位范围选择;点击空槽时使用与颜色相同的选中外框并清除原颜色选择,焦点离开整个调色板后取消当前颜色或空槽选择。当前选择显示一像素深色内线、三像素强调色描边和一像素深色外线。在格子内部按住约 360 ms 后进入框选,按住移动时实时改变外框范围,即使在计时完成前已经移动也不会取消框选。只有指针位于强调色外框边缘时才能拖动整组选区,拖动整组颜色时选中外框同步跟随预览位置并保持二维相对范围;落到空槽时原位留空,落到已有颜色时与被占用槽位交换。双击任意槽位会把当前前景色加入并移动到该槽位。槽位列数、位置与空位属于可撤销工程数据并随 `.moonsprite` 保存;保存到软件时始终可以“保存为新色板”,当前选择为非内置用户色板时还可明确“保存到当前色板”,两种操作都保留列数、二维位置和空槽,旧版紧凑色板载入时按顺序排列。颜色格同时反映前景色和背景色:前景色保留右上角角标,背景色保留左下角角标,颜色角色本身不随选择变化。从当前画面提取颜色,或从 PNG、Aseprite 及其他外部图像生成导入调色板时,颜色默认按感知亮度从暗到亮排列;原生 `.moonsprite` 工程中保存的二维槽位顺序保持不变。 - 前景色和背景色由整个应用共享,而不是分别存入项目标签;切换、新建或打开工程后继续使用最近选择的两种颜色。 - 图层和图层组默认不带显示颜色;属性底部可选择无颜色、预设色或自定义色,设置后仅在图层行最左侧显示竖条,不改变画布像素。预设色由“首选项 > 图层 > 图层属性颜色预设”全局管理,支持 1 至 12 个有效且不重复的 RGB 色,应用后所有工程的图层属性面板实时使用同一组预设。描述使用统一的固定尺寸多行输入,并通过自定义悬浮提示展示;两者属于可撤销并随工程保存的图层属性。 - 组的显示颜色覆盖其全部后代组和图层的行标识;嵌套时从最外层祖先开始选择第一个已设置的颜色,因此最外层组一旦设置颜色,其整棵子树始终显示该颜色。只有更外层祖先均未设置颜色时,才使用更内层组或图层自身的颜色。继承色条与普通图层色条同样位于眼睛图标左侧。批量属性面板中的预设显示颜色属于离散操作,第一次点击立即预览,不等待连续数值输入的合并计时器。 @@ -63,10 +70,12 @@ - 选区可以超出画布显示,但最终像素写入遵守文档和图层边界数据规则。 - 主工具、选区子工具和形状子工具都使用统一自定义悬浮提示,包含工具名称、简短用法与用户当前快捷键。主工具按钮与顶部属性栏始终显示当前选中的子工具图标和名称;所有子工具图片在工具栏挂载时以不可见 DOM 图像常驻并同步解码,不得在首次选择后延迟出现。选区工具栏以单行固定显示矩形框选、椭圆框选、套索、多边形套索和魔棒;多边形套索默认使用 `Shift+Q`。五种工具共享选区组合规则;右键默认减选,按住 `Shift` 开始选择时绕过已有选区的内容变换并执行加选,属性栏在按住期间临时显示对应的加选或减选模式,松开后恢复原模式。 - “套索选区预览闭合”默认关闭;吸管工具默认保持当前工具,只有开启“吸管取色后切回铅笔”后,正式取色才在松开时返回铅笔,临时吸色不受该偏好影响。 -- 形状工具栏以单行固定显示矩形描边、矩形填充、椭圆描边和椭圆填充,并使用指针源工程第 5 行第 3 至 6 列的对应图标。“固定比例”使用宽、高两个数值定义比例,持久值最多保留一位小数,开启后横向或纵向拖动都按主轴保持指定宽高比。 -- 铅笔、橡皮擦、五种选区、四种形状和油漆桶的属性栏共享水平、垂直、斜向三个独立复选框,可同时开启。轴线以画布中心为基准并显示蓝色辅助线;多轴组合对结果去重,同一手势只生成一次撤销。斜向轴沿左上至右下方向,矩形画布中超出边界的镜像像素丢弃。 -- 移动工具开启自动选择图层后,命中其他图层的不透明像素时移动对应图层;命中透明位置时回退到当前选中图层。按住 `Shift` 点击画布内其他可编辑图层的不透明内容时,将活动帧中命中的 cel 追加到现有单元格选择,不清除其他图层或其他帧已经选中的 cel;拖动任意已选且当前可见的 cel 时,全部已选 cel 按同一位移统一移动,并在移动期间保持时间轴选中样式。多选后普通单击任意已选或其他图层内容会只保留点击图层及其活动帧 cel,单击透明位置会回到当前活动图层单选;直接拖动已选内容不因按下瞬间提前取消多选。 +- 形状工具弹出栏以单排固定显示矩形描边、矩形填充、椭圆描边、椭圆填充、自由形状和多边形形状。自由形状复用套索路径,松开后直接创建填充形状;多边形形状逐点建立边界,通过双击、点击起点或 `Enter` 闭合并填充。“固定比例”只作用于矩形和椭圆,使用宽、高两个数值定义比例,持久值最多保留一位小数,开启后横向或纵向拖动都按主轴保持指定宽高比。 +- 直线是工具栏主工具,曲线放在直线的双项子工具弹出栏中;主按钮始终显示当前选择的直线或曲线图标。直线拖动创建,按住 `Shift` 约束方向;曲线在首次按住拖动时实时显示起终点基线,松开后依次移动控制锚点即可实时改变弯曲结果,单击固定当前锚点,完成属性栏设定的全部锚点后提交。锚点数可设置为 `1–8`,默认为 `2`。直线与曲线共享独立的线条笔刷预设,支持笔刷尺寸、形状、纹理、图像笔刷和笔刷模式;“完美像素”会清理路径折角处的多余像素。预览与最终提交使用同一套路径清理和笔刷印章规则。两个子工具各自拥有可配置快捷键。 +- 铅笔、橡皮擦、五种选区、六种闭合形状、直线、曲线和油漆桶的属性栏共享水平、垂直、斜向三个独立复选框,可同时开启。轴线以画布中心为基准并显示蓝色辅助线;多轴组合对结果去重,同一手势只生成一次撤销。斜向轴沿左上至右下方向,矩形画布中超出边界的镜像像素丢弃。 +- 移动工具开启自动选择图层后,命中其他图层的不透明像素时移动对应图层;命中透明位置时回退到当前选中图层。按住 `Shift` 点击画布内其他可编辑图层的不透明内容时,将活动帧中命中的 cel 追加到现有单元格选择,不清除其他图层或其他帧已经选中的 cel;拖动任意已选且当前可见的 cel 时,全部已选 cel 按同一位移统一移动,并在移动期间保持时间轴选中样式。多选后普通单击任意已选或其他图层内容会只保留点击图层及其活动帧 cel,单击透明位置会回到当前活动图层单选;直接拖动已选内容不因按下瞬间提前取消多选。未被选区、形状或旋转等上下文占用时,按住快捷键设置中的“临时移动工具”(默认 `Ctrl`)可临时执行相同行为;首选项可控制点击目标图层时是否短暂高亮其内容范围。 - “编辑 > 调整”有选区时只作用当前图层选区,没有选区时作用全部选中图层。弹窗打开期间改变选区或图层选择,实时预览必须先恢复旧目标基线,再对新目标重新计算;最终只生成一个撤销步骤。 +- “图像 > 调整图像尺寸”可识别最终合成画面中由最近邻放大形成的主导整数像素块倍率;识别综合颜色边界周期与局部同色块连续长度,不要求画布宽高能被倍率整除,也不要求不同素材使用相同网格相位。少量未遵循倍率网格的文字、细节或异常像素不得阻止主体倍率识别。识别成功后显示倍率,并将宽高除以该倍率后取最近整数,插值算法切换为最近邻;纯色画布或不存在可靠主导倍率时不得猜测结果。 - 色相/饱和度调整同时提供色相、饱和度和 HSL 明度;明度 `+100` 收敛到白色,`-100` 收敛到黑色,不得与 RGB 亮度复用同一计算语义。 - 粘贴内容和第一次移动后的选区内容在确认前保持浮动,可继续移动到合适位置,不应因当前画布视口而提前裁剪或覆盖经过区域的原背景。“编辑 > 选择性粘贴”提供“粘贴为新项目”和“粘贴为新图层”;新图层默认使用 `Ctrl+Shift+V`。来源坐标与当前可见视图相交时保留原位,否则以当前视图中心放置。 - 预览栏目先缓存带透明通道的作品像素,再按最终显示比例独立绘制透明棋盘格;规则与主画布一致,格子小于 `2 CSS px` 时退化为统一浅色,不得把高频棋盘纹理降采样进作品缓存而产生条纹或摩尔纹。预览栏标题区在关闭按钮左侧提供播放/暂停按钮,左键按当前设置循环或单次播放,右键打开与图层栏完全相同的播放设置菜单。缩时视频弹窗提供逐帧预览、播放控制和进度定位,并将记录导出为用户选择的 MP4 或 WebM 视频;导出支持按目标时长或按缩时倍率两种互斥方式。视频尺寸档位使用接近目标范围的最大整数像素倍率,不拉伸作品比例;编码格式要求偶数尺寸时只在边缘补最多 1 像素。输出概览实时显示实际视频尺寸、记录帧数、速度倍率和预计时长;导出期间复用文件操作进度弹窗,完成状态保持在视觉顶层并等待用户确认。导出前检查运行环境的视频编码能力,不支持时提示失败且不生成伪装格式的文件。 @@ -84,11 +93,12 @@ - 首选项底部提供“应用”,保存当前设置但保持首选项窗口打开;“确定”保存并关闭,“取消”放弃本次未应用修改。 - “恢复所有初始设置”必须二次确认,且只清除 MoonSprite 的本地软件配置;不得删除用户创建的工程、笔刷、色板、工作区文件或其他磁盘文件。软件通过统一语言目录读取可翻译文案,未知语言偏好回退到简体中文;当前可在首选项中选择简体中文或英语,应用后当前界面、Windows 原生文件对话框与后续核心操作提示实时切换,用户输入和工程内容不得被翻译。 - 颜色值按钮显示颜色样本、HEX 和颜色角色;完全不透明的颜色使用六位 HEX,存在透明度时使用八位 HEX。打开后默认进入 RGB,可切换 RGB、HSV、HSL、Gray。模式、HEX 与当前色在顶部同一行,每个通道使用一条彩色滑轨和右侧直接数值输入,所有模式均支持透明度。调色盘底部前景色/背景色与背景首选项复用同一组件。 -- 前景色和背景色的颜色值弹窗标题显示“颜色编辑 前景色/背景色”,可通过关闭按钮关闭。未移动时失焦自动关闭,标题栏真实拖动后转为常驻;两者拥有独立尺寸记忆、统一最小/最大尺寸,可八向缩放并同时打开,关闭后再次打开回到各自触发按钮附近的默认位置。调整高度时通道色条随内容区自适应,色条与数值输入框保持等高。 +- 前景色和背景色的颜色值弹窗标题显示“颜色编辑 前景色/背景色”,可通过关闭按钮关闭。未移动时失焦自动关闭,标题栏真实拖动后转为常驻;两者拥有独立尺寸记忆、统一最小/最大尺寸,可八向缩放并同时打开,关闭后再次打开回到各自触发按钮附近的默认位置。调整高度时通道色条随内容区自适应,色条与数值输入框保持等高。默认显示 HSV、RGB、LAB、GRAY、PLT 五种模式,其中 PLT 按当前项目调色板顺序直接选色;右侧吸管按钮进入临时吸色,完成后恢复原工具。 - “光标”首选项中的“使用本地指针”默认关闭;开启时优先使用电脑系统指针,系统没有对应语义的旋转等特殊操作回退到 MoonSprite 指针,并禁用只作用于 MoonSprite 指针的光标比例。关闭时所有场景使用 MoonSprite 指针。“工具预览”默认使用完整笔刷预览;只有选择“完整预览并显示边缘”时才显示“绘制时显示笔刷边缘”,并可控制落笔期间是否保留边缘轮廓。框选时是否显示真正的鼠标十字指针独立控制且默认关闭,此时框选只显示当前像素四角角标。“背景”首选项统一控制可直接输入 `1-256 px` 的透明棋盘格大小和并排显示的两种颜色;“图层属性颜色预设”使用相同的普通颜色设置布局,不得伪装成独立栏目。“编辑”首选项控制滚轮缩放、铅笔直线实时预览、默认开启的“直线算法优化”和直线斜向阶梯数值;该算法同时用于画笔直线和多边形套索边界,`Shift+Ctrl` 方向约束在阶梯数值为 `1` 时提供 8 个方向,数值大于 `1` 时提供 12 个方向。“默认缩放模式”控制缩放工具拖动时使用平滑或百分比缩放。 - 首选项中的尺寸预设、导出倍率预设与图层属性颜色预设统一使用组件库 `DeleteIconButton`;紧凑预设行使用 `28 px`,颜色预设与删除按钮统一使用 `34 px` 高度,并共享危险悬浮与禁用状态。 - 同一表单或弹窗内存在多个文本、数字输入框时,`Tab` 和 `Shift+Tab` 分别按界面顺序向前、向后切换输入焦点,并默认全选新输入框内容。 - 状态提示位于顶部工具属性栏下方的画布视图上部,与属性栏保留明确间距且不遮挡视图中央;调整、画布大小和图像大小弹窗默认位于视图中心与右侧边缘之间的中点。所有正式模态弹窗复用统一外壳,可拖动、通过边框外侧的八向把手调整大小,并记忆各自的位置和尺寸;每类弹窗设置能保持内容完整的最小尺寸与适合当前视口的最大尺寸,外壳不产生横向或重复滚动条,默认尺寸以 1080p 下不压迫画布为准。 +- 每个项目画布的上方各自悬浮一个默认折叠的快捷指令栏,不占用画布布局高度;多个项目分栏或浮窗同时显示时,每个画布保留自己的快捷栏,切换活动项目后已展开的快捷栏自动折叠。快捷栏默认半透明,可在首选项关闭半透明显示;开启时悬浮或键盘聚焦会恢复完全不透明。折叠按钮在展开前后保持固定高度,指令区使用水平收放与透明度动画。默认以单排提供水平/垂直选区镜像、水平/垂直画布镜像、反选、自定义网格和相对明暗;末尾固定提供设置按钮,可直接打开首选项的独立“快捷指令栏”分类。该分类可隐藏整个快捷栏,并可从撤销、重做、全选、取消选择、像素网格、选区边框、相对明暗、重置视图、填充前景色、删除选区内容、交换前景色与背景色、从选区创建笔刷以及顺/逆时针旋转视图等候选中勾选按钮和拖动排序。快捷指令与颜色模式排序均复用首页项目排序的连续列表、拖动手柄和按住反馈;带有相关设置的按钮可通过右键直接打开对应弹窗,并在悬浮描述中明确提示。选区相关命令在没有选区时禁用,画布镜像与网格按钮显示所属项目的当前开关状态,并与顶部菜单及快捷键调用同一命令。 - 整数与非整数缩放必须使用稳定一致的画布几何和取样原点;在 313% 等非整数缩放下开始或继续绘制时,不得因切换缓存绘制路径而使已有像素发生局部跳动。 - 顶部任一主菜单打开后,鼠标横向移入其他主菜单应直接切换并展开;没有菜单打开时,普通悬停不得自行展开菜单。 @@ -112,6 +122,8 @@ 普通保存、首次保存、另存为和导出都必须在实际编码或写入开始后显示紧凑进度反馈,并在成功后自动关闭。需要 Windows 文件选择器的操作必须先等待选择器返回,用户取消时不显示进度。 +已打开 `.moonsprite` 工程再次保存时,只重新编码发生变化的 cel、图层、蒙版和必须刷新的工程元数据,未修改的像素块与缩时快照直接复用原文件中的压缩数据。增量保存必须继续通过同目录临时文件原子替换;原文件被外部改变、复用校验失败或合并失败时自动完整保存,不得留下不完整工程。 + “另存为”默认选择“首选项 > 文件 > 默认保存格式”。选择 PNG、JPEG 或 WebP 时必须明确提示该格式不会保留图层和工程结构;用户确认的扩展名、Windows 保存类型和实际编码格式必须一致。 -“导出设置”的文件名始终显示当前格式后缀,切换格式时保留名称主体并同步后缀。每次导出可单独选择目录、切换到本地展馆目录,或从最近成功导出的完整文件路径中复用其所在目录;弹窗选择的目录优先于首选项默认导出位置。导出预设必须持久化完整格式、缩放、GIF 范围与方向、文件名和目录设置,读取旧预设时不得丢弃已经支持的导出格式。 +“导出设置”的文件名始终显示当前格式后缀,切换格式时保留名称主体并同步后缀。每次导出可单独选择目录、切换到本地展馆目录,或从最近成功导出的完整文件路径中复用其所在目录;弹窗选择的目录优先于首选项默认导出位置。每个工程只在成功写入后记录自己的最后一次导出名称、实际路径、格式、缩放、目标、GIF 选项和已选择预设,再次打开同一工程时恢复这些设置;取消或失败的导出不得覆盖记录。导出预设必须持久化完整格式、缩放、GIF 范围与方向、文件名和目录设置,读取旧预设时不得丢弃已经支持的导出格式。 diff --git a/docs/release/development-cycle.md b/docs/release/development-cycle.md index f49c38c..d7ff74a 100644 --- a/docs/release/development-cycle.md +++ b/docs/release/development-cycle.md @@ -4,11 +4,11 @@ ## 当前状态 -- 当前目标:`DEV.4` -- 内部技术版本:`0.1.0-dev.4` -- 阶段:`DEV.4` 发布完成 -- 本周期基线提交:`66d8730` -- 最近一次已打包版本:`DEV.4` +- 当前目标:`DEV.5` +- 内部技术版本:`0.1.0-dev.5` +- 阶段:`DEV.5` 发布完成 +- 本周期基线提交:`87bf11e` +- 最近一次已打包版本:`DEV.5` - 正式版本号:待功能完整后由用户确认 ## 版本语义 diff --git a/docs/testing/performance-baseline-data.json b/docs/testing/performance-baseline-data.json index 6df81ea..c243f47 100644 --- a/docs/testing/performance-baseline-data.json +++ b/docs/testing/performance-baseline-data.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "acceptedAt": "2026-08-11T19:53:35.985Z", - "releaseLabel": "DEV.4", - "auditId": "2026-08-11_19-24-14-791", + "acceptedAt": "2026-08-16T14:08:26.361Z", + "releaseLabel": "DEV.5", + "auditId": "2026-08-16_13-51-24-452", "environment": { "platform": "win32 10.0.26200", "arch": "x64", @@ -12,12 +12,12 @@ "gpu": "AMD Radeon(TM) Graphics|30.0.13002.19003; NVIDIA GeForce RTX 3060 Laptop GPU|32.0.16.1047; OrayIddDriver Device|17.1.58.818; Todesk Virtual Display Adapter|16.44.2.509", "powerPlan": "6fecc5ae-f350-48a5-b669-b472cb895ccf", "node": "v22.14.0", - "browser": "C:/Program Files/Google/Chrome/Application/chrome.exe 151.0.7922.76" + "browser": "C:/Program Files/Google/Chrome/Application/chrome.exe 151.0.7922.138" }, "sourceFingerprint": { "algorithm": "sha256", - "value": "8d0d5c0deee94dfa6cbf74606e5b458b0c311a14dbb0106e1fb198f5fd1fa6ee", - "files": 358 + "value": "5de60d5f2fa1d03a0e7018d1bdb71b71f8ffc3e28187a0aec644a2731b16c034", + "files": 439 }, "metrics": [ { @@ -59,7 +59,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 pan 主绘制 p95", - "value": 0.4000000059604645, + "value": 0.6000000014901161, "samples": 3, "noisePercent": 0, "budget": 4, @@ -127,9 +127,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 zoom 主绘制 p95", - "value": 0.7999999970197678, + "value": 1, "samples": 3, - "noisePercent": 0.000001862645156169851, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -146,7 +146,7 @@ "label": "128x128 zoom 指针处理 p95", "value": 1.0999999940395355, "samples": 3, - "noisePercent": 0.0000013546510249628318, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -195,9 +195,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 rotated-zoom 主绘制 p95", - "value": 0.7000000029802322, + "value": 1, "samples": 3, - "noisePercent": 14.285713373398298, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -212,9 +212,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 rotated-zoom 指针处理 p95", - "value": 1.0999999940395355, + "value": 1.1000000014901161, "samples": 3, - "noisePercent": 9.090908598308719, + "noisePercent": 9.090909214059183, "budget": 4, "target": { "kind": "canvas", @@ -263,7 +263,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 draw 主绘制 p95", - "value": 0.4000000059604645, + "value": 0.6000000014901161, "samples": 3, "noisePercent": 0, "budget": 4, @@ -280,9 +280,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 draw 指针处理 p95", - "value": 0.5, + "value": 1.0999999940395355, "samples": 3, - "noisePercent": 19.999998807907104, + "noisePercent": 18.181817873942947, "budget": 4, "target": { "kind": "canvas", @@ -331,9 +331,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 shape 主绘制 p95", - "value": 0.5999999940395355, + "value": 0.7999999970197678, "samples": 3, - "noisePercent": 0.000002483526890312899, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -365,7 +365,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 marquee 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -399,9 +399,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 marquee 主绘制 p95", - "value": 0.5999999940395355, + "value": 0.6000000014901161, "samples": 3, - "noisePercent": 16.666665838824372, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -416,9 +416,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 marquee 指针处理 p95", - "value": 0.699999988079071, + "value": 0.8999999985098839, "samples": 3, - "noisePercent": 0.000002128737349658989, + "noisePercent": 11.111111295076064, "budget": 4, "target": { "kind": "canvas", @@ -450,7 +450,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 bucket-fill >25 ms 比例", - "value": 1.25, + "value": 1.1627906976744187, "samples": 3, "noisePercent": 0, "budget": 1, @@ -467,9 +467,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 bucket-fill 主绘制 p95", - "value": 0.5999999940395355, + "value": 0.8000000044703484, "samples": 3, - "noisePercent": 33.33333416117563, + "noisePercent": 25.00000116415321, "budget": 4, "target": { "kind": "canvas", @@ -484,9 +484,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 bucket-fill 指针处理 p95", - "value": 1, + "value": 1.8999999985098839, "samples": 3, - "noisePercent": 0, + "noisePercent": 15.789473931875255, "budget": 4, "target": { "kind": "canvas", @@ -501,7 +501,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 gradient 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -518,9 +518,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 gradient >25 ms 比例", - "value": 0, + "value": 0.19880715705765406, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.39920159680638123, "budget": 1, "target": { "kind": "canvas", @@ -535,9 +535,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 gradient 主绘制 p95", - "value": 3.7999999970197678, + "value": 3.899999998509884, "samples": 3, - "noisePercent": 10.526315954583504, + "noisePercent": 2.5641024122498366, "budget": 4, "target": { "kind": "canvas", @@ -552,9 +552,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "128x128 gradient 指针处理 p95", - "value": 0.3999999910593033, + "value": 0.7000000029802322, "samples": 3, - "noisePercent": 0, + "noisePercent": 42.8571422489322, "budget": 4, "target": { "kind": "canvas", @@ -603,9 +603,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 pan 主绘制 p95", - "value": 0.4000000059604645, + "value": 0.6000000014901161, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.0000012417634297366852, "budget": 4, "target": { "kind": "canvas", @@ -671,7 +671,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 zoom 主绘制 p95", - "value": 0.7000000029802322, + "value": 1, "samples": 3, "noisePercent": 0, "budget": 4, @@ -688,9 +688,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 zoom 指针处理 p95", - "value": 0.9000000059604645, + "value": 1, "samples": 3, - "noisePercent": 11.11111203093587, + "noisePercent": 10.000000149011612, "budget": 4, "target": { "kind": "canvas", @@ -705,7 +705,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 rotated-zoom 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -739,9 +739,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 rotated-zoom 主绘制 p95", - "value": 0.7000000029802322, + "value": 1, "samples": 3, - "noisePercent": 0, + "noisePercent": 10.000000149011612, "budget": 4, "target": { "kind": "canvas", @@ -756,9 +756,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 rotated-zoom 指针处理 p95", - "value": 0.9000000059604645, + "value": 1.1000000014901161, "samples": 3, - "noisePercent": 0, + "noisePercent": 9.090909214059183, "budget": 4, "target": { "kind": "canvas", @@ -807,7 +807,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 draw 主绘制 p95", - "value": 0.5, + "value": 0.6000000014901161, "samples": 3, "noisePercent": 0, "budget": 4, @@ -824,9 +824,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 draw 指针处理 p95", - "value": 0.5999999940395355, + "value": 1.3999999985098839, "samples": 3, - "noisePercent": 0, + "noisePercent": 7.142857256896643, "budget": 4, "target": { "kind": "canvas", @@ -841,7 +841,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 shape 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -875,9 +875,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 shape 主绘制 p95", - "value": 0.7000000029802322, + "value": 0.7999999970197678, "samples": 3, - "noisePercent": 0, + "noisePercent": 12.500000232830644, "budget": 4, "target": { "kind": "canvas", @@ -892,9 +892,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 shape 指针处理 p95", - "value": 0.5, + "value": 0.6000000014901161, "samples": 3, - "noisePercent": 19.999998807907104, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -909,7 +909,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 marquee 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -943,7 +943,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 marquee 主绘制 p95", - "value": 0.5, + "value": 0.6000000014901161, "samples": 3, "noisePercent": 0, "budget": 4, @@ -962,7 +962,7 @@ "label": "512x512 marquee 指针处理 p95", "value": 0.7000000029802322, "samples": 3, - "noisePercent": 14.285713373398298, + "noisePercent": 0.0000010643686521718815, "budget": 4, "target": { "kind": "canvas", @@ -994,9 +994,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 bucket-fill >25 ms 比例", - "value": 1.2658227848101267, + "value": 1.1904761904761905, "samples": 3, - "noisePercent": 0, + "noisePercent": 2.3255813953488307, "budget": 1, "target": { "kind": "canvas", @@ -1011,9 +1011,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 bucket-fill 主绘制 p95", - "value": 0.699999988079071, + "value": 1, "samples": 3, - "noisePercent": 14.285715806240965, + "noisePercent": 20.000000298023224, "budget": 4, "target": { "kind": "canvas", @@ -1028,9 +1028,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 bucket-fill 指针处理 p95", - "value": 1.2999999970197678, + "value": 4.800000004470348, "samples": 3, - "noisePercent": 7.692308397688105, + "noisePercent": 8.333333294528227, "budget": 4, "target": { "kind": "canvas", @@ -1062,9 +1062,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 gradient >25 ms 比例", - "value": 0.26595744680851063, + "value": 0.3875968992248062, "samples": 3, - "noisePercent": 0.26525198938992967, + "noisePercent": 1.7751479289940812, "budget": 1, "target": { "kind": "canvas", @@ -1079,9 +1079,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 gradient 主绘制 p95", - "value": 3.5999999940395355, + "value": 3.7000000029802322, "samples": 3, - "noisePercent": 5.555555233616887, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -1096,9 +1096,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "512x512 gradient 指针处理 p95", - "value": 0.5, + "value": 0.8000000044703484, "samples": 3, - "noisePercent": 0, + "noisePercent": 12.49999918509275, "budget": 4, "target": { "kind": "canvas", @@ -1147,7 +1147,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 pan 主绘制 p95", - "value": 0.4000000059604645, + "value": 0.5, "samples": 3, "noisePercent": 0, "budget": 4, @@ -1215,9 +1215,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 zoom 主绘制 p95", - "value": 0.7999999970197678, + "value": 1.1999999955296516, "samples": 3, - "noisePercent": 12.499999301508065, + "noisePercent": 6.208817187232836e-7, "budget": 4, "target": { "kind": "canvas", @@ -1232,9 +1232,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 zoom 指针处理 p95", - "value": 1, + "value": 1.4000000059604645, "samples": 3, - "noisePercent": 19.999998807907104, + "noisePercent": 14.285713905582625, "budget": 4, "target": { "kind": "canvas", @@ -1283,9 +1283,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 rotated-zoom 主绘制 p95", - "value": 0.7000000029802322, + "value": 1.1999999955296516, "samples": 3, - "noisePercent": 0, + "noisePercent": 8.333333488553762, "budget": 4, "target": { "kind": "canvas", @@ -1300,9 +1300,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 rotated-zoom 指针处理 p95", - "value": 0.9000000059604645, + "value": 1.3999999985098839, "samples": 3, - "noisePercent": 11.111110375251304, + "noisePercent": 5.321843289181423e-7, "budget": 4, "target": { "kind": "canvas", @@ -1317,7 +1317,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 draw 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -1351,9 +1351,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 draw 主绘制 p95", - "value": 0.5, + "value": 0.6000000014901161, "samples": 3, - "noisePercent": 0, + "noisePercent": 16.66666687362724, "budget": 4, "target": { "kind": "canvas", @@ -1368,9 +1368,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 draw 指针处理 p95", - "value": 0.5, + "value": 3.600000001490116, "samples": 3, - "noisePercent": 0, + "noisePercent": 13.888888883139986, "budget": 4, "target": { "kind": "canvas", @@ -1385,7 +1385,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 shape 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -1402,9 +1402,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 shape >25 ms 比例", - "value": 0, + "value": 0.1953125, "samples": 3, - "noisePercent": 0, + "noisePercent": 1.7892644135188789, "budget": 1, "target": { "kind": "canvas", @@ -1419,9 +1419,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 shape 主绘制 p95", - "value": 0.5999999940395355, + "value": 0.7000000029802322, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.0000010643686521718815, "budget": 4, "target": { "kind": "canvas", @@ -1436,9 +1436,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 shape 指针处理 p95", - "value": 0.4000000059604645, + "value": 0.7999999970197678, "samples": 3, - "noisePercent": 0.0000037252902429507637, + "noisePercent": 12.500000232830644, "budget": 4, "target": { "kind": "canvas", @@ -1487,9 +1487,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 marquee 主绘制 p95", - "value": 0.6000000089406967, + "value": 0.7000000029802322, "samples": 3, - "noisePercent": 0.0000024835268286338427, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -1504,9 +1504,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 marquee 指针处理 p95", - "value": 0.800000011920929, + "value": 0.8999999985098839, "samples": 3, - "noisePercent": 0.0000018626451214753818, + "noisePercent": 11.111111295076064, "budget": 4, "target": { "kind": "canvas", @@ -1538,9 +1538,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 bucket-fill >25 ms 比例", - "value": 1.2658227848101267, + "value": 1.1764705882352942, "samples": 3, - "noisePercent": 0, + "noisePercent": 1.1627906976744162, "budget": 1, "target": { "kind": "canvas", @@ -1555,9 +1555,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 bucket-fill 主绘制 p95", - "value": 0.800000011920929, + "value": 0.8999999985098839, "samples": 3, - "noisePercent": 12.49999906867744, + "noisePercent": 11.111111295076064, "budget": 4, "target": { "kind": "canvas", @@ -1572,9 +1572,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 bucket-fill 指针处理 p95", - "value": 1, + "value": 11.299999997019768, "samples": 3, - "noisePercent": 0, + "noisePercent": 10.61946905572316, "budget": 4, "target": { "kind": "canvas", @@ -1589,7 +1589,7 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 gradient 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -1606,9 +1606,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 gradient >25 ms 比例", - "value": 0.53475935828877, + "value": 0.39920159680638717, "samples": 3, - "noisePercent": 0.8086253369272526, + "noisePercent": 1.571709233791741, "budget": 1, "target": { "kind": "canvas", @@ -1623,9 +1623,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 gradient 主绘制 p95", - "value": 3.9000000059604645, + "value": 3.899999998509884, "samples": 3, - "noisePercent": 5.128204814702723, + "noisePercent": 7.692307809871095, "budget": 4, "target": { "kind": "canvas", @@ -1640,9 +1640,9 @@ "suiteId": "canvas-standard", "kind": "canvas", "label": "1024x1024 gradient 指针处理 p95", - "value": 0.4000000059604645, + "value": 0.5, "samples": 3, - "noisePercent": 0, + "noisePercent": 19.999998807907104, "budget": 4, "target": { "kind": "canvas", @@ -1657,9 +1657,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "512x512 pan React 提交 p95", - "value": 1, + "value": 1.6000000014901161, "samples": 3, - "noisePercent": 9.999999403953552, + "noisePercent": 18.750000261934474, "budget": 16.7, "target": { "kind": "canvas", @@ -1674,9 +1674,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 pan React MoonSprite", - "value": 1, + "value": 1.6000000014901161, "samples": 3, - "noisePercent": 9.999999403953552, + "noisePercent": 18.750000261934474, "budget": 8, "target": { "kind": "canvas", @@ -1688,13 +1688,13 @@ } }, { - "id": "canvas-profile:512:pan:react-region:CanvasStage", + "id": "canvas-profile:512:pan:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React CanvasStage", - "value": 0, + "label": "512x512 pan React EditorWorkspaceShell", + "value": 1.1000000014901161, "samples": 3, - "noisePercent": 0, + "noisePercent": 18.18181775079286, "budget": 8, "target": { "kind": "canvas", @@ -1702,17 +1702,17 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:512:pan:react-region:EditorCanvasHost", + "id": "canvas-profile:512:pan:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React EditorCanvasHost", + "label": "512x512 pan React CanvasStage", "value": 0.09999999403953552, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000007450581041013064, "budget": 8, "target": { "kind": "canvas", @@ -1720,17 +1720,17 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "CanvasStage" } }, { - "id": "canvas-profile:512:pan:react-region:EditorWorkspaceShell", + "id": "canvas-profile:512:pan:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React EditorWorkspaceShell", - "value": 0.4000000059604645, + "label": "512x512 pan React EditorCanvasHost", + "value": 0.29999999701976776, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000002483526890312899, "budget": 8, "target": { "kind": "canvas", @@ -1738,14 +1738,14 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:512:pan:react-region:AppMenuBar", + "id": "canvas-profile:512:pan:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React AppMenuBar", + "label": "512x512 pan React Panel:palette", "value": 0, "samples": 3, "noisePercent": 0, @@ -1756,14 +1756,14 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "Panel:palette" } }, { - "id": "canvas-profile:512:pan:react-region:Panel:layers", + "id": "canvas-profile:512:pan:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React Panel:layers", + "label": "512x512 pan React Panel:color", "value": 0, "samples": 3, "noisePercent": 0, @@ -1774,17 +1774,17 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "Panel:layers" + "region": "Panel:color" } }, { - "id": "canvas-profile:512:pan:react-region:InspectorPanels", + "id": "canvas-profile:512:pan:react-region:Panel:layers", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React InspectorPanels", - "value": 0.10000000894069672, + "label": "512x512 pan React Panel:layers", + "value": 0, "samples": 3, - "noisePercent": 0.000014901159861580145, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -1792,14 +1792,14 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "Panel:layers" } }, { - "id": "canvas-profile:512:pan:react-region:DocumentTabs", + "id": "canvas-profile:512:pan:react-region:Panel:preview", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React DocumentTabs", + "label": "512x512 pan React Panel:preview", "value": 0, "samples": 3, "noisePercent": 0, @@ -1810,17 +1810,17 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "DocumentTabs" + "region": "Panel:preview" } }, { - "id": "canvas-profile:512:pan:react-region:Panel:preview", + "id": "canvas-profile:512:pan:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React Panel:preview", - "value": 0, + "label": "512x512 pan React InspectorPanels", + "value": 0.7000000029802322, "samples": 3, - "noisePercent": 0, + "noisePercent": 14.285713373398298, "budget": 8, "target": { "kind": "canvas", @@ -1828,15 +1828,15 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "Panel:preview" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:512:pan:react-region:Panel:palette", + "id": "canvas-profile:512:pan:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React Panel:palette", - "value": 0, + "label": "512x512 pan React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -1846,14 +1846,14 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:512:pan:react-region:Panel:color", + "id": "canvas-profile:512:pan:react-region:DocumentTabs", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 pan React Panel:color", + "label": "512x512 pan React DocumentTabs", "value": 0, "samples": 3, "noisePercent": 0, @@ -1864,7 +1864,7 @@ "canvasSize": 512, "scenario": "pan", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "DocumentTabs" } }, { @@ -1907,9 +1907,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 zoom React EditorCanvasHost", - "value": 0.20000000298023224, + "value": 0.29999999701976776, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000002483526890312899, "budget": 8, "target": { "kind": "canvas", @@ -1925,7 +1925,7 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 zoom React EditorWorkspaceShell", - "value": 0.4000000059604645, + "value": 0.30000000447034836, "samples": 3, "noisePercent": 0, "budget": 8, @@ -1979,9 +1979,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "512x512 rotated-zoom React 提交 p95", - "value": 0.20000000298023224, + "value": 0.30000000447034836, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.0000024835268286338427, "budget": 16.7, "target": { "kind": "canvas", @@ -2014,9 +2014,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 rotated-zoom React EditorCanvasHost", - "value": 0.09999999403953552, + "value": 0.29999999701976776, "samples": 3, - "noisePercent": 100, + "noisePercent": 0.000002483526890312899, "budget": 8, "target": { "kind": "canvas", @@ -2032,9 +2032,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 rotated-zoom React EditorWorkspaceShell", - "value": 0.10000000894069672, + "value": 0.30000000447034836, "samples": 3, - "noisePercent": 0.000014901159861580145, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -2068,9 +2068,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 rotated-zoom React MoonSprite", - "value": 0.20000000298023224, + "value": 0.30000000447034836, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.0000024835268286338427, "budget": 8, "target": { "kind": "canvas", @@ -2086,9 +2086,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "512x512 draw React 提交 p95", - "value": 1.3999999910593033, + "value": 2.0999999940395355, "samples": 3, - "noisePercent": 0, + "noisePercent": 3.5478955324147304e-7, "budget": 16.7, "target": { "kind": "canvas", @@ -2103,9 +2103,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 draw React MoonSprite", - "value": 1.3999999910593033, + "value": 2.0999999940395355, "samples": 3, - "noisePercent": 0, + "noisePercent": 3.5478955324147304e-7, "budget": 8, "target": { "kind": "canvas", @@ -2117,29 +2117,11 @@ } }, { - "id": "canvas-profile:512:draw:react-region:EditorWorkspaceShell", - "suiteId": "canvas-profile", - "kind": "react-region", - "label": "512x512 draw React EditorWorkspaceShell", - "value": 0.8999999910593033, - "samples": 3, - "noisePercent": 11.111110559216247, - "budget": 8, - "target": { - "kind": "canvas", - "suiteId": "canvas-profile", - "canvasSize": 512, - "scenario": "draw", - "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" - } - }, - { - "id": "canvas-profile:512:draw:react-region:AppMenuBar", + "id": "canvas-profile:512:draw:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 draw React AppMenuBar", - "value": 0, + "label": "512x512 draw React CanvasStage", + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2149,15 +2131,15 @@ "canvasSize": 512, "scenario": "draw", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "CanvasStage" } }, { - "id": "canvas-profile:512:draw:react-region:EditorToolOptions", + "id": "canvas-profile:512:draw:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 draw React EditorToolOptions", - "value": 0.4000000059604645, + "label": "512x512 draw React EditorCanvasHost", + "value": 0.5, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2167,14 +2149,14 @@ "canvasSize": 512, "scenario": "draw", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:512:draw:react-region:CanvasStage", + "id": "canvas-profile:512:draw:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 draw React CanvasStage", + "label": "512x512 draw React Panel:palette", "value": 0, "samples": 3, "noisePercent": 0, @@ -2185,15 +2167,15 @@ "canvasSize": 512, "scenario": "draw", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "Panel:palette" } }, { - "id": "canvas-profile:512:draw:react-region:EditorCanvasHost", + "id": "canvas-profile:512:draw:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 draw React EditorCanvasHost", - "value": 0.20000000298023224, + "label": "512x512 draw React Panel:color", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2203,7 +2185,7 @@ "canvasSize": 512, "scenario": "draw", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "Panel:color" } }, { @@ -2211,7 +2193,7 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 draw React Panel:layers", - "value": 0.20000000298023224, + "value": 0.09999999403953552, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2243,13 +2225,13 @@ } }, { - "id": "canvas-profile:512:draw:react-region:Panel:palette", + "id": "canvas-profile:512:draw:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 draw React Panel:palette", - "value": 0, + "label": "512x512 draw React InspectorPanels", + "value": 0.6999999955296516, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000001064368663500688, "budget": 8, "target": { "kind": "canvas", @@ -2257,17 +2239,17 @@ "canvasSize": 512, "scenario": "draw", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:512:draw:react-region:Panel:color", + "id": "canvas-profile:512:draw:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 draw React Panel:color", - "value": 0, + "label": "512x512 draw React EditorWorkspaceShell", + "value": 1.4000000059604645, "samples": 3, - "noisePercent": 0, + "noisePercent": 5.321843260859408e-7, "budget": 8, "target": { "kind": "canvas", @@ -2275,17 +2257,17 @@ "canvasSize": 512, "scenario": "draw", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:512:draw:react-region:InspectorPanels", + "id": "canvas-profile:512:draw:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 draw React InspectorPanels", - "value": 0.6000000089406967, + "label": "512x512 draw React AppMenuBar", + "value": 0.09999999403953552, "samples": 3, - "noisePercent": 16.66666790843008, + "noisePercent": 0.000007450581041013064, "budget": 8, "target": { "kind": "canvas", @@ -2293,7 +2275,25 @@ "canvasSize": 512, "scenario": "draw", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "AppMenuBar" + } + }, + { + "id": "canvas-profile:512:draw:react-region:EditorToolOptions", + "suiteId": "canvas-profile", + "kind": "react-region", + "label": "512x512 draw React EditorToolOptions", + "value": 0.3999999985098839, + "samples": 3, + "noisePercent": 25.000000465661287, + "budget": 8, + "target": { + "kind": "canvas", + "suiteId": "canvas-profile", + "canvasSize": 512, + "scenario": "draw", + "metric": "reactRegionP95", + "region": "EditorToolOptions" } }, { @@ -2319,9 +2319,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "512x512 shape React 提交 p95", - "value": 1, + "value": 1.3000000044703484, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000001146243164815892, "budget": 16.7, "target": { "kind": "canvas", @@ -2332,13 +2332,13 @@ } }, { - "id": "canvas-profile:512:shape:react-region:AppMenuBar", + "id": "canvas-profile:512:shape:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React AppMenuBar", - "value": 0, + "label": "512x512 shape React CanvasStage", + "value": 0.09999999403953552, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000007450581041013064, "budget": 8, "target": { "kind": "canvas", @@ -2346,15 +2346,15 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "CanvasStage" } }, { - "id": "canvas-profile:512:shape:react-region:EditorToolOptions", + "id": "canvas-profile:512:shape:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React EditorToolOptions", - "value": 0.20000000298023224, + "label": "512x512 shape React EditorCanvasHost", + "value": 0.3999999985098839, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2364,14 +2364,14 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:512:shape:react-region:CanvasStage", + "id": "canvas-profile:512:shape:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React CanvasStage", + "label": "512x512 shape React Panel:palette", "value": 0, "samples": 3, "noisePercent": 0, @@ -2382,17 +2382,17 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "Panel:palette" } }, { - "id": "canvas-profile:512:shape:react-region:EditorCanvasHost", + "id": "canvas-profile:512:shape:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React EditorCanvasHost", - "value": 0.10000000894069672, + "label": "512x512 shape React Panel:color", + "value": 0, "samples": 3, - "noisePercent": 0.000014901159861580145, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -2400,7 +2400,7 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "Panel:color" } }, { @@ -2408,9 +2408,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 shape React Panel:layers", - "value": 0.09999999403953552, + "value": 0.19999999552965164, "samples": 3, - "noisePercent": 0, + "noisePercent": 49.99999813735481, "budget": 8, "target": { "kind": "canvas", @@ -2440,13 +2440,13 @@ } }, { - "id": "canvas-profile:512:shape:react-region:Panel:palette", + "id": "canvas-profile:512:shape:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React Panel:palette", - "value": 0, + "label": "512x512 shape React InspectorPanels", + "value": 0.5, "samples": 3, - "noisePercent": 0, + "noisePercent": 20.000000298023224, "budget": 8, "target": { "kind": "canvas", @@ -2454,17 +2454,17 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:512:shape:react-region:Panel:color", + "id": "canvas-profile:512:shape:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React Panel:color", - "value": 0, + "label": "512x512 shape React EditorWorkspaceShell", + "value": 0.9000000059604645, "samples": 3, - "noisePercent": 0, + "noisePercent": 11.11111203093587, "budget": 8, "target": { "kind": "canvas", @@ -2472,17 +2472,17 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:512:shape:react-region:InspectorPanels", + "id": "canvas-profile:512:shape:react-region:MoonSprite", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React InspectorPanels", - "value": 0.4999999850988388, + "label": "512x512 shape React MoonSprite", + "value": 1.3000000044703484, "samples": 3, - "noisePercent": 0.0000029802323275873758, + "noisePercent": 0.000001146243164815892, "budget": 8, "target": { "kind": "canvas", @@ -2490,15 +2490,15 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "MoonSprite" } }, { - "id": "canvas-profile:512:shape:react-region:EditorWorkspaceShell", + "id": "canvas-profile:512:shape:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React EditorWorkspaceShell", - "value": 0.6000000089406967, + "label": "512x512 shape React AppMenuBar", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2508,15 +2508,15 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:512:shape:react-region:MoonSprite", + "id": "canvas-profile:512:shape:react-region:EditorToolOptions", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 shape React MoonSprite", - "value": 1, + "label": "512x512 shape React EditorToolOptions", + "value": 0.19999999552965164, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2526,7 +2526,7 @@ "canvasSize": 512, "scenario": "shape", "metric": "reactRegionP95", - "region": "MoonSprite" + "region": "EditorToolOptions" } }, { @@ -2552,9 +2552,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "512x512 marquee React 提交 p95", - "value": 0.8999999910593033, + "value": 1.6000000014901161, "samples": 3, - "noisePercent": 11.111108903531653, + "noisePercent": 6.249999621650205, "budget": 16.7, "target": { "kind": "canvas", @@ -2565,11 +2565,11 @@ } }, { - "id": "canvas-profile:512:marquee:react-region:AppMenuBar", + "id": "canvas-profile:512:marquee:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React AppMenuBar", - "value": 0.10000000894069672, + "label": "512x512 marquee React CanvasStage", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2579,17 +2579,17 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "CanvasStage" } }, { - "id": "canvas-profile:512:marquee:react-region:EditorToolOptions", + "id": "canvas-profile:512:marquee:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React EditorToolOptions", - "value": 0.10000000894069672, + "label": "512x512 marquee React EditorCanvasHost", + "value": 0.3999999985098839, "samples": 3, - "noisePercent": 0, + "noisePercent": 25.000000465661287, "budget": 8, "target": { "kind": "canvas", @@ -2597,14 +2597,14 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:512:marquee:react-region:CanvasStage", + "id": "canvas-profile:512:marquee:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React CanvasStage", + "label": "512x512 marquee React Panel:palette", "value": 0, "samples": 3, "noisePercent": 0, @@ -2615,17 +2615,17 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "Panel:palette" } }, { - "id": "canvas-profile:512:marquee:react-region:EditorCanvasHost", + "id": "canvas-profile:512:marquee:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React EditorCanvasHost", - "value": 0.19999998807907104, + "label": "512x512 marquee React Panel:color", + "value": 0, "samples": 3, - "noisePercent": 0.000007450581041013064, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -2633,15 +2633,15 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "Panel:color" } }, { - "id": "canvas-profile:512:marquee:react-region:EditorWorkspaceShell", + "id": "canvas-profile:512:marquee:react-region:Panel:layers", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React EditorWorkspaceShell", - "value": 0.5, + "label": "512x512 marquee React Panel:layers", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2651,14 +2651,14 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "Panel:layers" } }, { - "id": "canvas-profile:512:marquee:react-region:EditorStatusBar", + "id": "canvas-profile:512:marquee:react-region:Panel:preview", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React EditorStatusBar", + "label": "512x512 marquee React Panel:preview", "value": 0, "samples": 3, "noisePercent": 0, @@ -2669,17 +2669,17 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "EditorStatusBar" + "region": "Panel:preview" } }, { - "id": "canvas-profile:512:marquee:react-region:MoonSprite", + "id": "canvas-profile:512:marquee:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React MoonSprite", - "value": 0.8999999910593033, + "label": "512x512 marquee React InspectorPanels", + "value": 0.5, "samples": 3, - "noisePercent": 11.111108903531653, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -2687,17 +2687,17 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "MoonSprite" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:512:marquee:react-region:Panel:layers", + "id": "canvas-profile:512:marquee:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React Panel:layers", - "value": 0, + "label": "512x512 marquee React EditorWorkspaceShell", + "value": 1, "samples": 3, - "noisePercent": 0, + "noisePercent": 19.999999552965164, "budget": 8, "target": { "kind": "canvas", @@ -2705,17 +2705,17 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "Panel:layers" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:512:marquee:react-region:InspectorPanels", + "id": "canvas-profile:512:marquee:react-region:MoonSprite", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React InspectorPanels", - "value": 0, + "label": "512x512 marquee React MoonSprite", + "value": 1.6000000014901161, "samples": 3, - "noisePercent": 0, + "noisePercent": 6.249999621650205, "budget": 8, "target": { "kind": "canvas", @@ -2723,15 +2723,15 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "MoonSprite" } }, { - "id": "canvas-profile:512:marquee:react-region:DocumentTabs", + "id": "canvas-profile:512:marquee:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React DocumentTabs", - "value": 0, + "label": "512x512 marquee React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2741,17 +2741,17 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "DocumentTabs" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:512:marquee:react-region:Panel:preview", + "id": "canvas-profile:512:marquee:react-region:EditorToolOptions", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React Panel:preview", - "value": 0, + "label": "512x512 marquee React EditorToolOptions", + "value": 0.30000000447034836, "samples": 3, - "noisePercent": 0, + "noisePercent": 33.33333581686016, "budget": 8, "target": { "kind": "canvas", @@ -2759,14 +2759,14 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "Panel:preview" + "region": "EditorToolOptions" } }, { - "id": "canvas-profile:512:marquee:react-region:Panel:palette", + "id": "canvas-profile:512:marquee:react-region:EditorStatusBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React Panel:palette", + "label": "512x512 marquee React EditorStatusBar", "value": 0, "samples": 3, "noisePercent": 0, @@ -2777,14 +2777,14 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "EditorStatusBar" } }, { - "id": "canvas-profile:512:marquee:react-region:Panel:color", + "id": "canvas-profile:512:marquee:react-region:DocumentTabs", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 marquee React Panel:color", + "label": "512x512 marquee React DocumentTabs", "value": 0, "samples": 3, "noisePercent": 0, @@ -2795,7 +2795,7 @@ "canvasSize": 512, "scenario": "marquee", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "DocumentTabs" } }, { @@ -2803,9 +2803,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "512x512 bucket-fill React 提交 p95", - "value": 1.0000000149011612, + "value": 1.7000000029802322, "samples": 3, - "noisePercent": 10.000000745058049, + "noisePercent": 0, "budget": 16.7, "target": { "kind": "canvas", @@ -2816,13 +2816,13 @@ } }, { - "id": "canvas-profile:512:bucket-fill:react-region:AppMenuBar", + "id": "canvas-profile:512:bucket-fill:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React AppMenuBar", - "value": 0.10000000894069672, + "label": "512x512 bucket-fill React CanvasStage", + "value": 0.09999999403953552, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000007450581041013064, "budget": 8, "target": { "kind": "canvas", @@ -2830,17 +2830,17 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "CanvasStage" } }, { - "id": "canvas-profile:512:bucket-fill:react-region:EditorToolOptions", + "id": "canvas-profile:512:bucket-fill:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React EditorToolOptions", - "value": 0.29999999701976776, + "label": "512x512 bucket-fill React EditorCanvasHost", + "value": 0.3999999985098839, "samples": 3, - "noisePercent": 33.33333664470252, + "noisePercent": 25.000000465661287, "budget": 8, "target": { "kind": "canvas", @@ -2848,14 +2848,14 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:512:bucket-fill:react-region:CanvasStage", + "id": "canvas-profile:512:bucket-fill:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React CanvasStage", + "label": "512x512 bucket-fill React Panel:palette", "value": 0, "samples": 3, "noisePercent": 0, @@ -2866,15 +2866,15 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "Panel:palette" } }, { - "id": "canvas-profile:512:bucket-fill:react-region:EditorCanvasHost", + "id": "canvas-profile:512:bucket-fill:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React EditorCanvasHost", - "value": 0.20000000298023224, + "label": "512x512 bucket-fill React Panel:color", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -2884,7 +2884,7 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "Panel:color" } }, { @@ -2892,9 +2892,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 bucket-fill React Panel:layers", - "value": 0.10000000894069672, + "value": 0.09999999403953552, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000007450581041013064, "budget": 8, "target": { "kind": "canvas", @@ -2924,13 +2924,13 @@ } }, { - "id": "canvas-profile:512:bucket-fill:react-region:Panel:palette", + "id": "canvas-profile:512:bucket-fill:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React Panel:palette", - "value": 0, + "label": "512x512 bucket-fill React InspectorPanels", + "value": 0.6000000014901161, "samples": 3, - "noisePercent": 0, + "noisePercent": 16.66666687362724, "budget": 8, "target": { "kind": "canvas", @@ -2938,17 +2938,17 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:512:bucket-fill:react-region:Panel:color", + "id": "canvas-profile:512:bucket-fill:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React Panel:color", - "value": 0, + "label": "512x512 bucket-fill React EditorWorkspaceShell", + "value": 1.3999999985098839, "samples": 3, - "noisePercent": 0, + "noisePercent": 7.142857256896643, "budget": 8, "target": { "kind": "canvas", @@ -2956,17 +2956,17 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:512:bucket-fill:react-region:InspectorPanels", + "id": "canvas-profile:512:bucket-fill:react-region:MoonSprite", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React InspectorPanels", - "value": 0.5, + "label": "512x512 bucket-fill React MoonSprite", + "value": 1.7000000029802322, "samples": 3, - "noisePercent": 19.999998807907104, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -2974,17 +2974,17 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "MoonSprite" } }, { - "id": "canvas-profile:512:bucket-fill:react-region:EditorWorkspaceShell", + "id": "canvas-profile:512:bucket-fill:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React EditorWorkspaceShell", - "value": 0.699999988079071, + "label": "512x512 bucket-fill React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, - "noisePercent": 0.000002128737349658989, + "noisePercent": 99.99999254941952, "budget": 8, "target": { "kind": "canvas", @@ -2992,17 +2992,17 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:512:bucket-fill:react-region:MoonSprite", + "id": "canvas-profile:512:bucket-fill:react-region:EditorToolOptions", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 bucket-fill React MoonSprite", - "value": 1.0000000149011612, + "label": "512x512 bucket-fill React EditorToolOptions", + "value": 0.3999999985098839, "samples": 3, - "noisePercent": 10.000000745058049, + "noisePercent": 50.000000931322575, "budget": 8, "target": { "kind": "canvas", @@ -3010,7 +3010,7 @@ "canvasSize": 512, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "MoonSprite" + "region": "EditorToolOptions" } }, { @@ -3036,9 +3036,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "512x512 gradient React 提交 p95", - "value": 1.1000000089406967, + "value": 1.7000000029802322, "samples": 3, - "noisePercent": 0, + "noisePercent": 11.76470559876683, "budget": 16.7, "target": { "kind": "canvas", @@ -3049,13 +3049,13 @@ } }, { - "id": "canvas-profile:512:gradient:react-region:AppMenuBar", + "id": "canvas-profile:512:gradient:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React AppMenuBar", - "value": 0.09999999403953552, + "label": "512x512 gradient React CanvasStage", + "value": 0.10000000149011612, "samples": 3, - "noisePercent": 0.000014901162082026128, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3063,17 +3063,17 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "CanvasStage" } }, { - "id": "canvas-profile:512:gradient:react-region:EditorToolOptions", + "id": "canvas-profile:512:gradient:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React EditorToolOptions", - "value": 0.09999999403953552, + "label": "512x512 gradient React EditorCanvasHost", + "value": 0.4000000059604645, "samples": 3, - "noisePercent": 0, + "noisePercent": 25.00000186264512, "budget": 8, "target": { "kind": "canvas", @@ -3081,14 +3081,14 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:512:gradient:react-region:CanvasStage", + "id": "canvas-profile:512:gradient:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React CanvasStage", + "label": "512x512 gradient React Panel:palette", "value": 0, "samples": 3, "noisePercent": 0, @@ -3099,17 +3099,17 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "Panel:palette" } }, { - "id": "canvas-profile:512:gradient:react-region:EditorCanvasHost", + "id": "canvas-profile:512:gradient:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React EditorCanvasHost", - "value": 0.10000000894069672, + "label": "512x512 gradient React Panel:color", + "value": 0, "samples": 3, - "noisePercent": 0.000014901159861580145, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3117,7 +3117,7 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "Panel:color" } }, { @@ -3125,7 +3125,7 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "512x512 gradient React Panel:layers", - "value": 0.09999999403953552, + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -3157,13 +3157,13 @@ } }, { - "id": "canvas-profile:512:gradient:react-region:Panel:palette", + "id": "canvas-profile:512:gradient:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React Panel:palette", - "value": 0, + "label": "512x512 gradient React InspectorPanels", + "value": 0.5999999940395355, "samples": 3, - "noisePercent": 0, + "noisePercent": 16.666665838824372, "budget": 8, "target": { "kind": "canvas", @@ -3171,17 +3171,17 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:512:gradient:react-region:Panel:color", + "id": "canvas-profile:512:gradient:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React Panel:color", - "value": 0, + "label": "512x512 gradient React EditorWorkspaceShell", + "value": 1.3000000044703484, "samples": 3, - "noisePercent": 0, + "noisePercent": 7.6923072073586605, "budget": 8, "target": { "kind": "canvas", @@ -3189,17 +3189,17 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:512:gradient:react-region:InspectorPanels", + "id": "canvas-profile:512:gradient:react-region:MoonSprite", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React InspectorPanels", - "value": 0.29999999701976776, + "label": "512x512 gradient React MoonSprite", + "value": 1.7000000029802322, "samples": 3, - "noisePercent": 33.333331677648744, + "noisePercent": 11.76470559876683, "budget": 8, "target": { "kind": "canvas", @@ -3207,17 +3207,17 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "MoonSprite" } }, { - "id": "canvas-profile:512:gradient:react-region:EditorWorkspaceShell", + "id": "canvas-profile:512:gradient:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React EditorWorkspaceShell", - "value": 0.5999999940395355, + "label": "512x512 gradient React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, - "noisePercent": 0.000002483526890312899, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3225,17 +3225,17 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:512:gradient:react-region:MoonSprite", + "id": "canvas-profile:512:gradient:react-region:EditorToolOptions", "suiteId": "canvas-profile", "kind": "react-region", - "label": "512x512 gradient React MoonSprite", - "value": 1.1000000089406967, + "label": "512x512 gradient React EditorToolOptions", + "value": 0.09999999403953552, "samples": 3, - "noisePercent": 0, + "noisePercent": 100, "budget": 8, "target": { "kind": "canvas", @@ -3243,7 +3243,7 @@ "canvasSize": 512, "scenario": "gradient", "metric": "reactRegionP95", - "region": "MoonSprite" + "region": "EditorToolOptions" } }, { @@ -3269,9 +3269,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "1024x1024 pan React 提交 p95", - "value": 1.1000000089406967, + "value": 1.3999999985098839, "samples": 3, - "noisePercent": 9.090908475158633, + "noisePercent": 0, "budget": 16.7, "target": { "kind": "canvas", @@ -3286,9 +3286,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 pan React MoonSprite", - "value": 1.1000000089406967, + "value": 1.3999999985098839, "samples": 3, - "noisePercent": 9.090908475158633, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3300,13 +3300,13 @@ } }, { - "id": "canvas-profile:1024:pan:react-region:CanvasStage", + "id": "canvas-profile:1024:pan:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React CanvasStage", - "value": 0, + "label": "1024x1024 pan React EditorWorkspaceShell", + "value": 0.8999999985098839, "samples": 3, - "noisePercent": 0, + "noisePercent": 11.111111295076064, "budget": 8, "target": { "kind": "canvas", @@ -3314,15 +3314,15 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:1024:pan:react-region:EditorCanvasHost", + "id": "canvas-profile:1024:pan:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React EditorCanvasHost", - "value": 0.20000000298023224, + "label": "1024x1024 pan React CanvasStage", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -3332,17 +3332,17 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "CanvasStage" } }, { - "id": "canvas-profile:1024:pan:react-region:EditorWorkspaceShell", + "id": "canvas-profile:1024:pan:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React EditorWorkspaceShell", - "value": 0.5, + "label": "1024x1024 pan React EditorCanvasHost", + "value": 0.20000000298023224, "samples": 3, - "noisePercent": 19.999998807907104, + "noisePercent": 0.0000037252902429507637, "budget": 8, "target": { "kind": "canvas", @@ -3350,15 +3350,15 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:1024:pan:react-region:AppMenuBar", + "id": "canvas-profile:1024:pan:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React AppMenuBar", - "value": 0.09999999403953552, + "label": "1024x1024 pan React Panel:palette", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -3368,14 +3368,14 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "Panel:palette" } }, { - "id": "canvas-profile:1024:pan:react-region:Panel:layers", + "id": "canvas-profile:1024:pan:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React Panel:layers", + "label": "1024x1024 pan React Panel:color", "value": 0, "samples": 3, "noisePercent": 0, @@ -3386,15 +3386,15 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "Panel:layers" + "region": "Panel:color" } }, { - "id": "canvas-profile:1024:pan:react-region:InspectorPanels", + "id": "canvas-profile:1024:pan:react-region:Panel:layers", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React InspectorPanels", - "value": 0.09999999403953552, + "label": "1024x1024 pan React Panel:layers", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -3404,14 +3404,14 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "Panel:layers" } }, { - "id": "canvas-profile:1024:pan:react-region:DocumentTabs", + "id": "canvas-profile:1024:pan:react-region:Panel:preview", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React DocumentTabs", + "label": "1024x1024 pan React Panel:preview", "value": 0, "samples": 3, "noisePercent": 0, @@ -3422,15 +3422,15 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "DocumentTabs" + "region": "Panel:preview" } }, { - "id": "canvas-profile:1024:pan:react-region:Panel:preview", + "id": "canvas-profile:1024:pan:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React Panel:preview", - "value": 0, + "label": "1024x1024 pan React InspectorPanels", + "value": 0.5, "samples": 3, "noisePercent": 0, "budget": 8, @@ -3440,15 +3440,15 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "Panel:preview" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:1024:pan:react-region:Panel:palette", + "id": "canvas-profile:1024:pan:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React Panel:palette", - "value": 0, + "label": "1024x1024 pan React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -3458,14 +3458,14 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:1024:pan:react-region:Panel:color", + "id": "canvas-profile:1024:pan:react-region:DocumentTabs", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 pan React Panel:color", + "label": "1024x1024 pan React DocumentTabs", "value": 0, "samples": 3, "noisePercent": 0, @@ -3476,7 +3476,7 @@ "canvasSize": 1024, "scenario": "pan", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "DocumentTabs" } }, { @@ -3486,7 +3486,7 @@ "label": "1024x1024 zoom React 提交 p95", "value": 0.5, "samples": 3, - "noisePercent": 20.000001788139343, + "noisePercent": 19.999998807907104, "budget": 16.7, "target": { "kind": "canvas", @@ -3521,7 +3521,7 @@ "label": "1024x1024 zoom React EditorCanvasHost", "value": 0.20000000298023224, "samples": 3, - "noisePercent": 0, + "noisePercent": 49.99999627470976, "budget": 8, "target": { "kind": "canvas", @@ -3537,9 +3537,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 zoom React EditorWorkspaceShell", - "value": 0.3999999910593033, + "value": 0.29999999701976776, "samples": 3, - "noisePercent": 24.999999068677404, + "noisePercent": 33.33333416117563, "budget": 8, "target": { "kind": "canvas", @@ -3575,7 +3575,7 @@ "label": "1024x1024 zoom React MoonSprite", "value": 0.5, "samples": 3, - "noisePercent": 20.000001788139343, + "noisePercent": 19.999998807907104, "budget": 8, "target": { "kind": "canvas", @@ -3591,9 +3591,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "1024x1024 rotated-zoom React 提交 p95", - "value": 0.4000000059604645, + "value": 0.30000000447034836, "samples": 3, - "noisePercent": 25.00000186264512, + "noisePercent": 33.3333308498065, "budget": 16.7, "target": { "kind": "canvas", @@ -3626,9 +3626,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 rotated-zoom React EditorCanvasHost", - "value": 0.10000000894069672, + "value": 0.10000000149011612, "samples": 3, - "noisePercent": 100, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3662,9 +3662,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 rotated-zoom React EditorStatusBar", - "value": 0.10000000894069672, + "value": 0.10000000149011612, "samples": 3, - "noisePercent": 0.000014901159861580145, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3680,9 +3680,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 rotated-zoom React MoonSprite", - "value": 0.4000000059604645, + "value": 0.30000000447034836, "samples": 3, - "noisePercent": 25.00000186264512, + "noisePercent": 33.3333308498065, "budget": 8, "target": { "kind": "canvas", @@ -3698,9 +3698,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "1024x1024 draw React 提交 p95", - "value": 1.800000011920929, + "value": 2.100000001490116, "samples": 3, - "noisePercent": 0, + "noisePercent": 9.523809658967448, "budget": 16.7, "target": { "kind": "canvas", @@ -3715,9 +3715,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 draw React MoonSprite", - "value": 1.800000011920929, + "value": 2.100000001490116, "samples": 3, - "noisePercent": 0, + "noisePercent": 9.523809658967448, "budget": 8, "target": { "kind": "canvas", @@ -3729,13 +3729,13 @@ } }, { - "id": "canvas-profile:1024:draw:react-region:EditorWorkspaceShell", + "id": "canvas-profile:1024:draw:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React EditorWorkspaceShell", - "value": 1.2999999970197678, + "label": "1024x1024 draw React CanvasStage", + "value": 0, "samples": 3, - "noisePercent": 7.692307251444934, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3743,17 +3743,17 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "CanvasStage" } }, { - "id": "canvas-profile:1024:draw:react-region:AppMenuBar", + "id": "canvas-profile:1024:draw:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React AppMenuBar", - "value": 0.09999999403953552, + "label": "1024x1024 draw React EditorCanvasHost", + "value": 0.4000000059604645, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.0000018626451214753818, "budget": 8, "target": { "kind": "canvas", @@ -3761,17 +3761,17 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:1024:draw:react-region:EditorToolOptions", + "id": "canvas-profile:1024:draw:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React EditorToolOptions", - "value": 0.4000000059604645, + "label": "1024x1024 draw React Panel:palette", + "value": 0, "samples": 3, - "noisePercent": 0.0000037252902429507637, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3779,14 +3779,14 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "Panel:palette" } }, { - "id": "canvas-profile:1024:draw:react-region:CanvasStage", + "id": "canvas-profile:1024:draw:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React CanvasStage", + "label": "1024x1024 draw React Panel:color", "value": 0, "samples": 3, "noisePercent": 0, @@ -3797,15 +3797,15 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "Panel:color" } }, { - "id": "canvas-profile:1024:draw:react-region:EditorCanvasHost", + "id": "canvas-profile:1024:draw:react-region:Panel:layers", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React EditorCanvasHost", - "value": 0.29999999701976776, + "label": "1024x1024 draw React Panel:layers", + "value": 0.3999999985098839, "samples": 3, "noisePercent": 0, "budget": 8, @@ -3815,17 +3815,17 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "Panel:layers" } }, { - "id": "canvas-profile:1024:draw:react-region:Panel:layers", + "id": "canvas-profile:1024:draw:react-region:Panel:preview", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React Panel:layers", - "value": 0.5999999940395355, + "label": "1024x1024 draw React Panel:preview", + "value": 0, "samples": 3, - "noisePercent": 0.000002483526890312899, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -3833,17 +3833,17 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "Panel:layers" + "region": "Panel:preview" } }, { - "id": "canvas-profile:1024:draw:react-region:Panel:preview", + "id": "canvas-profile:1024:draw:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React Panel:preview", - "value": 0, + "label": "1024x1024 draw React InspectorPanels", + "value": 0.7999999970197678, "samples": 3, - "noisePercent": 0, + "noisePercent": 24.99999953433871, "budget": 8, "target": { "kind": "canvas", @@ -3851,17 +3851,17 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "Panel:preview" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:1024:draw:react-region:Panel:palette", + "id": "canvas-profile:1024:draw:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React Panel:palette", - "value": 0, + "label": "1024x1024 draw React EditorWorkspaceShell", + "value": 1.8999999985098839, "samples": 3, - "noisePercent": 0, + "noisePercent": 10.526315954583504, "budget": 8, "target": { "kind": "canvas", @@ -3869,17 +3869,17 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:1024:draw:react-region:Panel:color", + "id": "canvas-profile:1024:draw:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React Panel:color", - "value": 0, + "label": "1024x1024 draw React AppMenuBar", + "value": 0.09999999403953552, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.000007450581041013064, "budget": 8, "target": { "kind": "canvas", @@ -3887,17 +3887,17 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:1024:draw:react-region:InspectorPanels", + "id": "canvas-profile:1024:draw:react-region:EditorToolOptions", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 draw React InspectorPanels", - "value": 0.8999999910593033, + "label": "1024x1024 draw React EditorToolOptions", + "value": 0.30000000447034836, "samples": 3, - "noisePercent": 11.111110559216247, + "noisePercent": 0.0000024835268286338427, "budget": 8, "target": { "kind": "canvas", @@ -3905,7 +3905,7 @@ "canvasSize": 1024, "scenario": "draw", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "EditorToolOptions" } }, { @@ -3931,9 +3931,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "1024x1024 shape React 提交 p95", - "value": 1.2999999970197678, + "value": 2.0000000074505806, "samples": 3, - "noisePercent": 0, + "noisePercent": 4.9999996833503255, "budget": 16.7, "target": { "kind": "canvas", @@ -3944,31 +3944,13 @@ } }, { - "id": "canvas-profile:1024:shape:react-region:AppMenuBar", - "suiteId": "canvas-profile", - "kind": "react-region", - "label": "1024x1024 shape React AppMenuBar", - "value": 0, - "samples": 3, - "noisePercent": 0, - "budget": 8, - "target": { - "kind": "canvas", - "suiteId": "canvas-profile", - "canvasSize": 1024, - "scenario": "shape", - "metric": "reactRegionP95", - "region": "AppMenuBar" - } - }, - { - "id": "canvas-profile:1024:shape:react-region:EditorToolOptions", + "id": "canvas-profile:1024:shape:react-region:MoonSprite", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 shape React EditorToolOptions", - "value": 0.09999999403953552, + "label": "1024x1024 shape React MoonSprite", + "value": 2.0000000074505806, "samples": 3, - "noisePercent": 0.000014901162082026128, + "noisePercent": 4.9999996833503255, "budget": 8, "target": { "kind": "canvas", @@ -3976,7 +3958,7 @@ "canvasSize": 1024, "scenario": "shape", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "MoonSprite" } }, { @@ -4002,9 +3984,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 shape React EditorCanvasHost", - "value": 0.20000000298023224, + "value": 0.3999999985098839, "samples": 3, - "noisePercent": 0, + "noisePercent": 24.99999860301613, "budget": 8, "target": { "kind": "canvas", @@ -4016,13 +3998,13 @@ } }, { - "id": "canvas-profile:1024:shape:react-region:Panel:layers", + "id": "canvas-profile:1024:shape:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 shape React Panel:layers", - "value": 0.6000000089406967, + "label": "1024x1024 shape React Panel:palette", + "value": 0, "samples": 3, - "noisePercent": 0.0000024835268286338427, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -4030,14 +4012,14 @@ "canvasSize": 1024, "scenario": "shape", "metric": "reactRegionP95", - "region": "Panel:layers" + "region": "Panel:palette" } }, { - "id": "canvas-profile:1024:shape:react-region:Panel:preview", + "id": "canvas-profile:1024:shape:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 shape React Panel:preview", + "label": "1024x1024 shape React Panel:color", "value": 0, "samples": 3, "noisePercent": 0, @@ -4048,17 +4030,17 @@ "canvasSize": 1024, "scenario": "shape", "metric": "reactRegionP95", - "region": "Panel:preview" + "region": "Panel:color" } }, { - "id": "canvas-profile:1024:shape:react-region:Panel:palette", + "id": "canvas-profile:1024:shape:react-region:Panel:layers", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 shape React Panel:palette", - "value": 0, + "label": "1024x1024 shape React Panel:layers", + "value": 0.6000000014901161, "samples": 3, - "noisePercent": 0, + "noisePercent": 0.0000012417634297366852, "budget": 8, "target": { "kind": "canvas", @@ -4066,14 +4048,14 @@ "canvasSize": 1024, "scenario": "shape", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "Panel:layers" } }, { - "id": "canvas-profile:1024:shape:react-region:Panel:color", + "id": "canvas-profile:1024:shape:react-region:Panel:preview", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 shape React Panel:color", + "label": "1024x1024 shape React Panel:preview", "value": 0, "samples": 3, "noisePercent": 0, @@ -4084,7 +4066,7 @@ "canvasSize": 1024, "scenario": "shape", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "Panel:preview" } }, { @@ -4092,9 +4074,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 shape React InspectorPanels", - "value": 0.7999999970197678, + "value": 0.7000000029802322, "samples": 3, - "noisePercent": 0, + "noisePercent": 14.28571443776695, "budget": 8, "target": { "kind": "canvas", @@ -4110,9 +4092,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 shape React EditorWorkspaceShell", - "value": 0.7999999970197678, + "value": 1.5000000074505806, "samples": 3, - "noisePercent": 0, + "noisePercent": 13.3333334657881, "budget": 8, "target": { "kind": "canvas", @@ -4124,11 +4106,11 @@ } }, { - "id": "canvas-profile:1024:shape:react-region:MoonSprite", + "id": "canvas-profile:1024:shape:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 shape React MoonSprite", - "value": 1.2999999970197678, + "label": "1024x1024 shape React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4138,7 +4120,25 @@ "canvasSize": 1024, "scenario": "shape", "metric": "reactRegionP95", - "region": "MoonSprite" + "region": "AppMenuBar" + } + }, + { + "id": "canvas-profile:1024:shape:react-region:EditorToolOptions", + "suiteId": "canvas-profile", + "kind": "react-region", + "label": "1024x1024 shape React EditorToolOptions", + "value": 0.10000000149011612, + "samples": 3, + "noisePercent": 0, + "budget": 8, + "target": { + "kind": "canvas", + "suiteId": "canvas-profile", + "canvasSize": 1024, + "scenario": "shape", + "metric": "reactRegionP95", + "region": "EditorToolOptions" } }, { @@ -4164,9 +4164,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "1024x1024 marquee React 提交 p95", - "value": 1.2000000029802322, + "value": 2, "samples": 3, - "noisePercent": 0.0000012417634297366852, + "noisePercent": 14.999999850988388, "budget": 16.7, "target": { "kind": "canvas", @@ -4177,13 +4177,13 @@ } }, { - "id": "canvas-profile:1024:marquee:react-region:AppMenuBar", + "id": "canvas-profile:1024:marquee:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React AppMenuBar", + "label": "1024x1024 marquee React CanvasStage", "value": 0.09999999403953552, "samples": 3, - "noisePercent": 0.000014901162082026128, + "noisePercent": 0.000007450581041013064, "budget": 8, "target": { "kind": "canvas", @@ -4191,17 +4191,17 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "CanvasStage" } }, { - "id": "canvas-profile:1024:marquee:react-region:EditorToolOptions", + "id": "canvas-profile:1024:marquee:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React EditorToolOptions", - "value": 0.10000000894069672, + "label": "1024x1024 marquee React EditorCanvasHost", + "value": 0.5999999940395355, "samples": 3, - "noisePercent": 0.000014901159861580145, + "noisePercent": 0.0000012417634451564495, "budget": 8, "target": { "kind": "canvas", @@ -4209,15 +4209,15 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:1024:marquee:react-region:CanvasStage", + "id": "canvas-profile:1024:marquee:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React CanvasStage", - "value": 0.09999999403953552, + "label": "1024x1024 marquee React Panel:palette", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4227,15 +4227,15 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "Panel:palette" } }, { - "id": "canvas-profile:1024:marquee:react-region:EditorCanvasHost", + "id": "canvas-profile:1024:marquee:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React EditorCanvasHost", - "value": 0.20000000298023224, + "label": "1024x1024 marquee React Panel:color", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4245,15 +4245,15 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "Panel:color" } }, { - "id": "canvas-profile:1024:marquee:react-region:EditorWorkspaceShell", + "id": "canvas-profile:1024:marquee:react-region:Panel:layers", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React EditorWorkspaceShell", - "value": 0.5999999940395355, + "label": "1024x1024 marquee React Panel:layers", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4263,14 +4263,14 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "Panel:layers" } }, { - "id": "canvas-profile:1024:marquee:react-region:EditorStatusBar", + "id": "canvas-profile:1024:marquee:react-region:Panel:preview", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React EditorStatusBar", + "label": "1024x1024 marquee React Panel:preview", "value": 0, "samples": 3, "noisePercent": 0, @@ -4281,17 +4281,17 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "EditorStatusBar" + "region": "Panel:preview" } }, { - "id": "canvas-profile:1024:marquee:react-region:MoonSprite", + "id": "canvas-profile:1024:marquee:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React MoonSprite", - "value": 1.2000000029802322, + "label": "1024x1024 marquee React InspectorPanels", + "value": 0.5, "samples": 3, - "noisePercent": 0.0000012417634297366852, + "noisePercent": 19.999998807907104, "budget": 8, "target": { "kind": "canvas", @@ -4299,17 +4299,17 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "MoonSprite" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:1024:marquee:react-region:Panel:layers", + "id": "canvas-profile:1024:marquee:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React Panel:layers", - "value": 0, + "label": "1024x1024 marquee React EditorWorkspaceShell", + "value": 1.2000000029802322, "samples": 3, - "noisePercent": 0, + "noisePercent": 8.333334057695334, "budget": 8, "target": { "kind": "canvas", @@ -4317,17 +4317,17 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "Panel:layers" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:1024:marquee:react-region:InspectorPanels", + "id": "canvas-profile:1024:marquee:react-region:MoonSprite", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React InspectorPanels", - "value": 0.10000000894069672, + "label": "1024x1024 marquee React MoonSprite", + "value": 2, "samples": 3, - "noisePercent": 0.000014901159861580145, + "noisePercent": 14.999999850988388, "budget": 8, "target": { "kind": "canvas", @@ -4335,15 +4335,15 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "MoonSprite" } }, { - "id": "canvas-profile:1024:marquee:react-region:DocumentTabs", + "id": "canvas-profile:1024:marquee:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React DocumentTabs", - "value": 0, + "label": "1024x1024 marquee React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4353,15 +4353,15 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "DocumentTabs" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:1024:marquee:react-region:Panel:preview", + "id": "canvas-profile:1024:marquee:react-region:EditorToolOptions", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React Panel:preview", - "value": 0, + "label": "1024x1024 marquee React EditorToolOptions", + "value": 1.7000000029802322, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4371,14 +4371,14 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "Panel:preview" + "region": "EditorToolOptions" } }, { - "id": "canvas-profile:1024:marquee:react-region:Panel:palette", + "id": "canvas-profile:1024:marquee:react-region:EditorStatusBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React Panel:palette", + "label": "1024x1024 marquee React EditorStatusBar", "value": 0, "samples": 3, "noisePercent": 0, @@ -4389,14 +4389,14 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "EditorStatusBar" } }, { - "id": "canvas-profile:1024:marquee:react-region:Panel:color", + "id": "canvas-profile:1024:marquee:react-region:DocumentTabs", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 marquee React Panel:color", + "label": "1024x1024 marquee React DocumentTabs", "value": 0, "samples": 3, "noisePercent": 0, @@ -4407,7 +4407,7 @@ "canvasSize": 1024, "scenario": "marquee", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "DocumentTabs" } }, { @@ -4415,9 +4415,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "1024x1024 bucket-fill React 提交 p95", - "value": 1.0999999940395355, + "value": 1.7999999970197678, "samples": 3, - "noisePercent": 0, + "noisePercent": 11.111110467233773, "budget": 16.7, "target": { "kind": "canvas", @@ -4428,11 +4428,11 @@ } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:AppMenuBar", + "id": "canvas-profile:1024:bucket-fill:react-region:CanvasStage", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React AppMenuBar", - "value": 0.09999999403953552, + "label": "1024x1024 bucket-fill React CanvasStage", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4442,17 +4442,17 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "AppMenuBar" + "region": "CanvasStage" } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:EditorToolOptions", + "id": "canvas-profile:1024:bucket-fill:react-region:EditorCanvasHost", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React EditorToolOptions", - "value": 0.29999999701976776, + "label": "1024x1024 bucket-fill React EditorCanvasHost", + "value": 0.3999999985098839, "samples": 3, - "noisePercent": 0.000004967053780625798, + "noisePercent": 24.99999860301613, "budget": 8, "target": { "kind": "canvas", @@ -4460,14 +4460,14 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "EditorCanvasHost" } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:CanvasStage", + "id": "canvas-profile:1024:bucket-fill:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React CanvasStage", + "label": "1024x1024 bucket-fill React Panel:palette", "value": 0, "samples": 3, "noisePercent": 0, @@ -4478,15 +4478,15 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "CanvasStage" + "region": "Panel:palette" } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:EditorCanvasHost", + "id": "canvas-profile:1024:bucket-fill:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React EditorCanvasHost", - "value": 0.20000000298023224, + "label": "1024x1024 bucket-fill React Panel:color", + "value": 0, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4496,7 +4496,7 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "EditorCanvasHost" + "region": "Panel:color" } }, { @@ -4504,7 +4504,7 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 bucket-fill React Panel:layers", - "value": 0, + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4536,13 +4536,13 @@ } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:Panel:palette", + "id": "canvas-profile:1024:bucket-fill:react-region:InspectorPanels", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React Panel:palette", - "value": 0, + "label": "1024x1024 bucket-fill React InspectorPanels", + "value": 0.4000000059604645, "samples": 3, - "noisePercent": 0, + "noisePercent": 24.99999813735488, "budget": 8, "target": { "kind": "canvas", @@ -4550,17 +4550,17 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "InspectorPanels" } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:Panel:color", + "id": "canvas-profile:1024:bucket-fill:react-region:EditorWorkspaceShell", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React Panel:color", - "value": 0, + "label": "1024x1024 bucket-fill React EditorWorkspaceShell", + "value": 1.4000000059604645, "samples": 3, - "noisePercent": 0, + "noisePercent": 21.428571656650426, "budget": 8, "target": { "kind": "canvas", @@ -4568,17 +4568,17 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "EditorWorkspaceShell" } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:InspectorPanels", + "id": "canvas-profile:1024:bucket-fill:react-region:MoonSprite", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React InspectorPanels", - "value": 0.30000001192092896, + "label": "1024x1024 bucket-fill React MoonSprite", + "value": 1.7999999970197678, "samples": 3, - "noisePercent": 0.000004967053533909578, + "noisePercent": 11.111110467233773, "budget": 8, "target": { "kind": "canvas", @@ -4586,17 +4586,17 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "InspectorPanels" + "region": "MoonSprite" } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:EditorWorkspaceShell", + "id": "canvas-profile:1024:bucket-fill:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React EditorWorkspaceShell", - "value": 0.699999988079071, + "label": "1024x1024 bucket-fill React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, - "noisePercent": 28.571427355007227, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -4604,17 +4604,17 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "EditorWorkspaceShell" + "region": "AppMenuBar" } }, { - "id": "canvas-profile:1024:bucket-fill:react-region:MoonSprite", + "id": "canvas-profile:1024:bucket-fill:react-region:EditorToolOptions", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 bucket-fill React MoonSprite", - "value": 1.0999999940395355, + "label": "1024x1024 bucket-fill React EditorToolOptions", + "value": 0.3999999985098839, "samples": 3, - "noisePercent": 0, + "noisePercent": 25.000000465661287, "budget": 8, "target": { "kind": "canvas", @@ -4622,7 +4622,7 @@ "canvasSize": 1024, "scenario": "bucket-fill", "metric": "reactRegionP95", - "region": "MoonSprite" + "region": "EditorToolOptions" } }, { @@ -4630,7 +4630,7 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 bucket-fill React DocumentTabs", - "value": 0, + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4648,9 +4648,9 @@ "suiteId": "canvas-profile", "kind": "react-root", "label": "1024x1024 gradient React 提交 p95", - "value": 1.0000000149011612, + "value": 1.7999999970197678, "samples": 3, - "noisePercent": 0.0000014901160971803056, + "noisePercent": 22.22222176230984, "budget": 16.7, "target": { "kind": "canvas", @@ -4661,31 +4661,13 @@ } }, { - "id": "canvas-profile:1024:gradient:react-region:AppMenuBar", - "suiteId": "canvas-profile", - "kind": "react-region", - "label": "1024x1024 gradient React AppMenuBar", - "value": 0.09999999403953552, - "samples": 3, - "noisePercent": 0, - "budget": 8, - "target": { - "kind": "canvas", - "suiteId": "canvas-profile", - "canvasSize": 1024, - "scenario": "gradient", - "metric": "reactRegionP95", - "region": "AppMenuBar" - } - }, - { - "id": "canvas-profile:1024:gradient:react-region:EditorToolOptions", + "id": "canvas-profile:1024:gradient:react-region:MoonSprite", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 gradient React EditorToolOptions", - "value": 0.09999999403953552, + "label": "1024x1024 gradient React MoonSprite", + "value": 1.7999999970197678, "samples": 3, - "noisePercent": 0, + "noisePercent": 22.22222176230984, "budget": 8, "target": { "kind": "canvas", @@ -4693,7 +4675,7 @@ "canvasSize": 1024, "scenario": "gradient", "metric": "reactRegionP95", - "region": "EditorToolOptions" + "region": "MoonSprite" } }, { @@ -4719,9 +4701,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 gradient React EditorCanvasHost", - "value": 0.19999998807907104, + "value": 0.5, "samples": 3, - "noisePercent": 0.000007450581041013064, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -4733,10 +4715,10 @@ } }, { - "id": "canvas-profile:1024:gradient:react-region:Panel:layers", + "id": "canvas-profile:1024:gradient:react-region:Panel:palette", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 gradient React Panel:layers", + "label": "1024x1024 gradient React Panel:palette", "value": 0, "samples": 3, "noisePercent": 0, @@ -4747,14 +4729,14 @@ "canvasSize": 1024, "scenario": "gradient", "metric": "reactRegionP95", - "region": "Panel:layers" + "region": "Panel:palette" } }, { - "id": "canvas-profile:1024:gradient:react-region:Panel:preview", + "id": "canvas-profile:1024:gradient:react-region:Panel:color", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 gradient React Panel:preview", + "label": "1024x1024 gradient React Panel:color", "value": 0, "samples": 3, "noisePercent": 0, @@ -4765,15 +4747,15 @@ "canvasSize": 1024, "scenario": "gradient", "metric": "reactRegionP95", - "region": "Panel:preview" + "region": "Panel:color" } }, { - "id": "canvas-profile:1024:gradient:react-region:Panel:palette", + "id": "canvas-profile:1024:gradient:react-region:Panel:layers", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 gradient React Panel:palette", - "value": 0, + "label": "1024x1024 gradient React Panel:layers", + "value": 0.10000000149011612, "samples": 3, "noisePercent": 0, "budget": 8, @@ -4783,14 +4765,14 @@ "canvasSize": 1024, "scenario": "gradient", "metric": "reactRegionP95", - "region": "Panel:palette" + "region": "Panel:layers" } }, { - "id": "canvas-profile:1024:gradient:react-region:Panel:color", + "id": "canvas-profile:1024:gradient:react-region:Panel:preview", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 gradient React Panel:color", + "label": "1024x1024 gradient React Panel:preview", "value": 0, "samples": 3, "noisePercent": 0, @@ -4801,7 +4783,7 @@ "canvasSize": 1024, "scenario": "gradient", "metric": "reactRegionP95", - "region": "Panel:color" + "region": "Panel:preview" } }, { @@ -4809,9 +4791,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 gradient React InspectorPanels", - "value": 0.30000001192092896, + "value": 0.5, "samples": 3, - "noisePercent": 0.000004967053533909578, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -4827,9 +4809,9 @@ "suiteId": "canvas-profile", "kind": "react-region", "label": "1024x1024 gradient React EditorWorkspaceShell", - "value": 0.8999999910593033, + "value": 1.4999999925494194, "samples": 3, - "noisePercent": 0.0000016556845935419327, + "noisePercent": 19.99999940395355, "budget": 8, "target": { "kind": "canvas", @@ -4841,13 +4823,13 @@ } }, { - "id": "canvas-profile:1024:gradient:react-region:MoonSprite", + "id": "canvas-profile:1024:gradient:react-region:AppMenuBar", "suiteId": "canvas-profile", "kind": "react-region", - "label": "1024x1024 gradient React MoonSprite", - "value": 1.0000000149011612, + "label": "1024x1024 gradient React AppMenuBar", + "value": 0.10000000149011612, "samples": 3, - "noisePercent": 0.0000014901160971803056, + "noisePercent": 0, "budget": 8, "target": { "kind": "canvas", @@ -4855,7 +4837,25 @@ "canvasSize": 1024, "scenario": "gradient", "metric": "reactRegionP95", - "region": "MoonSprite" + "region": "AppMenuBar" + } + }, + { + "id": "canvas-profile:1024:gradient:react-region:EditorToolOptions", + "suiteId": "canvas-profile", + "kind": "react-region", + "label": "1024x1024 gradient React EditorToolOptions", + "value": 0.20000000298023224, + "samples": 3, + "noisePercent": 49.99999627470976, + "budget": 8, + "target": { + "kind": "canvas", + "suiteId": "canvas-profile", + "canvasSize": 1024, + "scenario": "gradient", + "metric": "reactRegionP95", + "region": "EditorToolOptions" } }, { @@ -4915,9 +4915,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "800x800 complex-draw 主绘制 p95", - "value": 0.7000000029802322, + "value": 1.1999999955296516, "samples": 3, - "noisePercent": 0, + "noisePercent": 6.208817187232836e-7, "budget": 4, "target": { "kind": "canvas", @@ -4932,9 +4932,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "800x800 complex-draw 指针处理 p95", - "value": 0.7000000029802322, + "value": 1.1000000014901161, "samples": 3, - "noisePercent": 14.285713373398298, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -4949,9 +4949,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "800x800 complex-undo 帧 p95", - "value": 3.800000000000182, + "value": 3.799999999999727, "samples": 3, - "noisePercent": 2.631578947365902, + "noisePercent": 2.631578947366217, "budget": 16.7, "target": { "kind": "canvas", @@ -4983,9 +4983,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "800x800 complex-undo 主绘制 p95", - "value": 0.699999988079071, + "value": 1.2000000029802322, "samples": 3, - "noisePercent": 14.285713677503614, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -5017,9 +5017,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "800x800 complex-playback 帧 p95", - "value": 7.899999999999864, + "value": 53.5, "samples": 3, - "noisePercent": 8.860759493671615, + "noisePercent": 0, "budget": 16.7, "target": { "kind": "canvas", @@ -5034,9 +5034,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "800x800 complex-playback >25 ms 比例", - "value": 4.661016949152542, + "value": 5.851063829787234, "samples": 3, - "noisePercent": 1.2552301255230172, + "noisePercent": 2.5906735751295424, "budget": 1, "target": { "kind": "canvas", @@ -5051,9 +5051,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "800x800 complex-playback 主绘制 p95", - "value": 33.79999999701977, + "value": 59.900000005960464, "samples": 3, - "noisePercent": 6.508875749036131, + "noisePercent": 9.01502505078986, "budget": 4, "target": { "kind": "canvas", @@ -5085,7 +5085,7 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "1024x1024 complex-draw 帧 p95", - "value": 3.699999999999818, + "value": 3.700000000000273, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -5119,9 +5119,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "1024x1024 complex-draw 主绘制 p95", - "value": 0.699999988079071, + "value": 0.9000000059604645, "samples": 3, - "noisePercent": 0.000002128737349658989, + "noisePercent": 11.111110375251304, "budget": 4, "target": { "kind": "canvas", @@ -5136,9 +5136,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "1024x1024 complex-draw 指针处理 p95", - "value": 0.7000000029802322, + "value": 0.9000000059604645, "samples": 3, - "noisePercent": 0, + "noisePercent": 8.278422830645092e-7, "budget": 4, "target": { "kind": "canvas", @@ -5153,9 +5153,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "1024x1024 complex-undo 帧 p95", - "value": 3.800000000000182, + "value": 3.700000000000273, "samples": 3, - "noisePercent": 1.1967035549643218e-11, + "noisePercent": 0, "budget": 16.7, "target": { "kind": "canvas", @@ -5187,9 +5187,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "1024x1024 complex-undo 主绘制 p95", - "value": 0.7999999970197678, + "value": 1.1000000014901161, "samples": 3, - "noisePercent": 12.499999301508065, + "noisePercent": 9.090909214059183, "budget": 4, "target": { "kind": "canvas", @@ -5221,9 +5221,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "1024x1024 complex-playback 帧 p95", - "value": 46.399999999999636, + "value": 92.80000000000018, "samples": 3, - "noisePercent": 9.800589458760078e-13, + "noisePercent": 3.879310344827481, "budget": 16.7, "target": { "kind": "canvas", @@ -5238,9 +5238,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "1024x1024 complex-playback >25 ms 比例", - "value": 5.5, + "value": 5.472636815920398, "samples": 3, - "noisePercent": 4.3062200956937895, + "noisePercent": 0, "budget": 1, "target": { "kind": "canvas", @@ -5255,9 +5255,9 @@ "suiteId": "canvas-complex", "kind": "canvas", "label": "1024x1024 complex-playback 主绘制 p95", - "value": 45.6000000089407, + "value": 131.39999999850988, "samples": 3, - "noisePercent": 4.166666646242926, + "noisePercent": 3.95738204188676, "budget": 4, "target": { "kind": "canvas", @@ -5289,7 +5289,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-pan 帧 p95", - "value": 3.700000000000273, + "value": 3.699999999999818, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -5306,7 +5306,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-pan >25 ms 比例", - "value": 0.45351473922902497, + "value": 0, "samples": 1, "noisePercent": 0, "budget": 1, @@ -5357,7 +5357,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-zoom 帧 p95", - "value": 3.7000000000000455, + "value": 3.700000000000273, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -5374,7 +5374,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-zoom >25 ms 比例", - "value": 0.3003003003003003, + "value": 0.18214936247723132, "samples": 1, "noisePercent": 0, "budget": 1, @@ -5391,7 +5391,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-zoom 主绘制 p95", - "value": 0.8999999910593033, + "value": 0.8999999985098839, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5408,7 +5408,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-zoom 指针处理 p95", - "value": 1.199999988079071, + "value": 1.3999999985098839, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5442,7 +5442,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-draw >25 ms 比例", - "value": 0.19723865877712032, + "value": 0.13679890560875513, "samples": 1, "noisePercent": 0, "budget": 1, @@ -5459,7 +5459,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-draw 主绘制 p95", - "value": 1.4000000059604645, + "value": 1.2999999970197678, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5476,7 +5476,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-draw 指针处理 p95", - "value": 1.0999999940395355, + "value": 1.6000000014901161, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5493,7 +5493,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-shape 帧 p95", - "value": 7.200000000000273, + "value": 7.099999999999909, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -5510,7 +5510,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-shape >25 ms 比例", - "value": 0.26455026455026454, + "value": 0.9881422924901186, "samples": 1, "noisePercent": 0, "budget": 1, @@ -5527,7 +5527,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-shape 主绘制 p95", - "value": 12.400000005960464, + "value": 15.900000005960464, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5544,7 +5544,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-shape 指针处理 p95", - "value": 0.4000000059604645, + "value": 0.6999999955296516, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5595,7 +5595,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-marquee 主绘制 p95", - "value": 3, + "value": 3.899999998509884, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5612,7 +5612,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-marquee 指针处理 p95", - "value": 0.5999999940395355, + "value": 1, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5646,7 +5646,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-bucket-fill >25 ms 比例", - "value": 1.2987012987012987, + "value": 1.2048192771084338, "samples": 1, "noisePercent": 0, "budget": 1, @@ -5663,7 +5663,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-bucket-fill 主绘制 p95", - "value": 1, + "value": 0.8999999985098839, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5680,7 +5680,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-bucket-fill 指针处理 p95", - "value": 2, + "value": 1.1999999955296516, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5697,7 +5697,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-gradient 帧 p95", - "value": 10.800000000000182, + "value": 32.100000000000364, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -5714,7 +5714,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-gradient >25 ms 比例", - "value": 0.5050505050505051, + "value": 9.248554913294797, "samples": 1, "noisePercent": 0, "budget": 1, @@ -5731,7 +5731,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-gradient 主绘制 p95", - "value": 15.799999997019768, + "value": 38.70000000298023, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5748,7 +5748,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-gradient 指针处理 p95", - "value": 0.5, + "value": 0.6000000014901161, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5765,7 +5765,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-detail-pan 帧 p95", - "value": 3.700000000000273, + "value": 3.7000000000000455, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -5799,7 +5799,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-detail-pan 主绘制 p95", - "value": 0.5, + "value": 0.8000000044703484, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5850,7 +5850,7 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-detail-draw >25 ms 比例", - "value": 0.2028397565922921, + "value": 0.13908205841446453, "samples": 1, "noisePercent": 0, "budget": 1, @@ -5867,7 +5867,24 @@ "suiteId": "canvas-large-800", "kind": "canvas", "label": "800x800 large-detail-draw 主绘制 p95", - "value": 0.7000000029802322, + "value": 0.7999999970197678, + "samples": 1, + "noisePercent": 0, + "budget": 4, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-800", + "canvasSize": 800, + "scenario": "large-detail-draw", + "metric": "drawP95" + } + }, + { + "id": "canvas-large-800:800:large-detail-draw:inputP95", + "suiteId": "canvas-large-800", + "kind": "canvas", + "label": "800x800 large-detail-draw 指针处理 p95", + "value": 1.1000000014901161, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5876,15 +5893,66 @@ "suiteId": "canvas-large-800", "canvasSize": 800, "scenario": "large-detail-draw", + "metric": "inputP95" + } + }, + { + "id": "canvas-large-800:800:large-detail-draw-timelapse:p95", + "suiteId": "canvas-large-800", + "kind": "canvas", + "label": "800x800 large-detail-draw-timelapse 帧 p95", + "value": 3.700000000000273, + "samples": 1, + "noisePercent": 0, + "budget": 16.7, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-800", + "canvasSize": 800, + "scenario": "large-detail-draw-timelapse", + "metric": "p95" + } + }, + { + "id": "canvas-large-800:800:large-detail-draw-timelapse:over25Percent", + "suiteId": "canvas-large-800", + "kind": "canvas", + "label": "800x800 large-detail-draw-timelapse >25 ms 比例", + "value": 0.13089005235602094, + "samples": 1, + "noisePercent": 0, + "budget": 1, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-800", + "canvasSize": 800, + "scenario": "large-detail-draw-timelapse", + "metric": "over25Percent" + } + }, + { + "id": "canvas-large-800:800:large-detail-draw-timelapse:drawP95", + "suiteId": "canvas-large-800", + "kind": "canvas", + "label": "800x800 large-detail-draw-timelapse 主绘制 p95", + "value": 0.9000000059604645, + "samples": 1, + "noisePercent": 0, + "budget": 4, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-800", + "canvasSize": 800, + "scenario": "large-detail-draw-timelapse", "metric": "drawP95" } }, { - "id": "canvas-large-800:800:large-detail-draw:inputP95", + "id": "canvas-large-800:800:large-detail-draw-timelapse:inputP95", "suiteId": "canvas-large-800", "kind": "canvas", - "label": "800x800 large-detail-draw 指针处理 p95", - "value": 0.8999999910593033, + "label": "800x800 large-detail-draw-timelapse 指针处理 p95", + "value": 1, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5892,7 +5960,7 @@ "kind": "canvas", "suiteId": "canvas-large-800", "canvasSize": 800, - "scenario": "large-detail-draw", + "scenario": "large-detail-draw-timelapse", "metric": "inputP95" } }, @@ -5901,7 +5969,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-pan 帧 p95", - "value": 7, + "value": 4, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -5918,7 +5986,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-pan >25 ms 比例", - "value": 0.24271844660194172, + "value": 0.4792332268370607, "samples": 1, "noisePercent": 0, "budget": 1, @@ -5935,7 +6003,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-pan 主绘制 p95", - "value": 2.199999988079071, + "value": 2.600000001490116, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5952,7 +6020,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-pan 指针处理 p95", - "value": 0.29999999701976776, + "value": 0.20000000298023224, "samples": 1, "noisePercent": 0, "budget": 4, @@ -5986,7 +6054,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-zoom >25 ms 比例", - "value": 0.3003003003003003, + "value": 0.3656307129798903, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6003,7 +6071,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-zoom 主绘制 p95", - "value": 2.2000000029802322, + "value": 2.2999999970197678, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6020,7 +6088,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-zoom 指针处理 p95", - "value": 1.4000000059604645, + "value": 1.7999999970197678, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6037,7 +6105,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-draw 帧 p95", - "value": 7.099999999999909, + "value": 10.699999999999818, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6054,7 +6122,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-draw >25 ms 比例", - "value": 0.4024144869215292, + "value": 0.4195804195804196, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6071,7 +6139,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-draw 主绘制 p95", - "value": 2.6000000089406967, + "value": 2.899999998509884, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6088,7 +6156,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-draw 指针处理 p95", - "value": 1, + "value": 12, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6105,7 +6173,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-shape 帧 p95", - "value": 7.200000000000273, + "value": 10.599999999999454, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6122,7 +6190,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-shape >25 ms 比例", - "value": 0.2857142857142857, + "value": 0.43668122270742354, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6139,7 +6207,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-shape 主绘制 p95", - "value": 13.599999994039536, + "value": 11.399999998509884, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6156,7 +6224,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-shape 指针处理 p95", - "value": 0.4000000059604645, + "value": 0.3999999985098839, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6190,7 +6258,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-marquee >25 ms 比例", - "value": 0, + "value": 0.4608294930875576, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6207,7 +6275,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-marquee 主绘制 p95", - "value": 13.800000011920929, + "value": 9.100000001490116, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6224,7 +6292,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-marquee 指针处理 p95", - "value": 0.5999999940395355, + "value": 1, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6242,7 +6310,7 @@ "kind": "canvas", "label": "2048x2048 large-bucket-fill 帧 p95", "value": 3.700000000000273, - "samples": 1, + "samples": 3, "noisePercent": 0, "budget": 16.7, "target": { @@ -6258,9 +6326,9 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-bucket-fill >25 ms 比例", - "value": 1.36986301369863, - "samples": 1, - "noisePercent": 0, + "value": 3.389830508474576, + "samples": 3, + "noisePercent": 57.857142857142854, "budget": 1, "target": { "kind": "canvas", @@ -6275,9 +6343,9 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-bucket-fill 主绘制 p95", - "value": 3.7999999970197678, - "samples": 1, - "noisePercent": 0, + "value": 3.5, + "samples": 3, + "noisePercent": 45.714285543986726, "budget": 4, "target": { "kind": "canvas", @@ -6292,9 +6360,9 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-bucket-fill 指针处理 p95", - "value": 1.2999999970197678, - "samples": 1, - "noisePercent": 0, + "value": 20.5, + "samples": 3, + "noisePercent": 7.317073170731707, "budget": 4, "target": { "kind": "canvas", @@ -6309,7 +6377,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-gradient 帧 p95", - "value": 14.300000000000182, + "value": 39.29999999999973, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6326,7 +6394,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-gradient >25 ms 比例", - "value": 0.7853403141361256, + "value": 10.828025477707007, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6343,7 +6411,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-gradient 主绘制 p95", - "value": 17.200000002980232, + "value": 45.69999999552965, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6360,7 +6428,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-gradient 指针处理 p95", - "value": 1, + "value": 0.8999999985098839, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6394,7 +6462,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-detail-pan >25 ms 比例", - "value": 0.2304147465437788, + "value": 0.1597444089456869, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6411,7 +6479,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-detail-pan 主绘制 p95", - "value": 0.4000000059604645, + "value": 2.899999998509884, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6445,7 +6513,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-detail-draw 帧 p95", - "value": 3.700000000000273, + "value": 7, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6462,7 +6530,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-detail-draw >25 ms 比例", - "value": 0.2028397565922921, + "value": 0.1455604075691412, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6496,7 +6564,7 @@ "suiteId": "canvas-large-2048", "kind": "canvas", "label": "2048x2048 large-detail-draw 指针处理 p95", - "value": 0.7999999970197678, + "value": 9.600000001490116, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6508,12 +6576,80 @@ "metric": "inputP95" } }, + { + "id": "canvas-large-2048:2048:large-detail-draw-timelapse:p95", + "suiteId": "canvas-large-2048", + "kind": "canvas", + "label": "2048x2048 large-detail-draw-timelapse 帧 p95", + "value": 7.200000000000728, + "samples": 1, + "noisePercent": 0, + "budget": 16.7, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-2048", + "canvasSize": 2048, + "scenario": "large-detail-draw-timelapse", + "metric": "p95" + } + }, + { + "id": "canvas-large-2048:2048:large-detail-draw-timelapse:over25Percent", + "suiteId": "canvas-large-2048", + "kind": "canvas", + "label": "2048x2048 large-detail-draw-timelapse >25 ms 比例", + "value": 0.6369426751592357, + "samples": 1, + "noisePercent": 0, + "budget": 1, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-2048", + "canvasSize": 2048, + "scenario": "large-detail-draw-timelapse", + "metric": "over25Percent" + } + }, + { + "id": "canvas-large-2048:2048:large-detail-draw-timelapse:drawP95", + "suiteId": "canvas-large-2048", + "kind": "canvas", + "label": "2048x2048 large-detail-draw-timelapse 主绘制 p95", + "value": 1.3000000044703484, + "samples": 1, + "noisePercent": 0, + "budget": 4, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-2048", + "canvasSize": 2048, + "scenario": "large-detail-draw-timelapse", + "metric": "drawP95" + } + }, + { + "id": "canvas-large-2048:2048:large-detail-draw-timelapse:inputP95", + "suiteId": "canvas-large-2048", + "kind": "canvas", + "label": "2048x2048 large-detail-draw-timelapse 指针处理 p95", + "value": 21.799999997019768, + "samples": 1, + "noisePercent": 0, + "budget": 4, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-2048", + "canvasSize": 2048, + "scenario": "large-detail-draw-timelapse", + "metric": "inputP95" + } + }, { "id": "canvas-large-4000:4000:large-pan:p95", "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-pan 帧 p95", - "value": 28.40000000000009, + "value": 3.700000000000273, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6530,7 +6666,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-pan >25 ms 比例", - "value": 8.333333333333332, + "value": 0.301659125188537, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6547,7 +6683,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-pan 主绘制 p95", - "value": 8.599999994039536, + "value": 0.6000000014901161, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6581,7 +6717,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-zoom 帧 p95", - "value": 7.199999999999818, + "value": 3.700000000000273, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6598,7 +6734,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-zoom >25 ms 比例", - "value": 2.380952380952381, + "value": 0.5905511811023622, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6615,7 +6751,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-zoom 主绘制 p95", - "value": 8.700000002980232, + "value": 6.299999997019768, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6632,7 +6768,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-zoom 指针处理 p95", - "value": 2, + "value": 2.1999999955296516, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6649,7 +6785,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-draw 帧 p95", - "value": 32.09999999999991, + "value": 25, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6666,7 +6802,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-draw >25 ms 比例", - "value": 20.714285714285715, + "value": 4.953145917001338, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6683,7 +6819,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-draw 主绘制 p95", - "value": 10.899999991059303, + "value": 1.7000000029802322, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6700,7 +6836,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-draw 指针处理 p95", - "value": 1.7000000029802322, + "value": 35.100000001490116, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6717,7 +6853,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-shape 帧 p95", - "value": 28.699999999999818, + "value": 3.700000000000273, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6734,7 +6870,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-shape >25 ms 比例", - "value": 17.098445595854923, + "value": 0.4008016032064128, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6751,7 +6887,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-shape 主绘制 p95", - "value": 17.700000002980232, + "value": 8.299999997019768, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6768,7 +6904,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-shape 指针处理 p95", - "value": 0.5, + "value": 0.3999999985098839, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6785,7 +6921,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-marquee 帧 p95", - "value": 35.600000000000364, + "value": 7.200000000000273, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6802,7 +6938,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-marquee >25 ms 比例", - "value": 23.557692307692307, + "value": 0.20242914979757085, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6819,7 +6955,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-marquee 主绘制 p95", - "value": 41.30000001192093, + "value": 16.5, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6836,7 +6972,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-marquee 指针处理 p95", - "value": 1, + "value": 1.2999999970197678, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6853,7 +6989,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-bucket-fill 帧 p95", - "value": 10.700000000000273, + "value": 7.199999999999818, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -6870,7 +7006,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-bucket-fill >25 ms 比例", - "value": 5.555555555555555, + "value": 3.7735849056603774, "samples": 1, "noisePercent": 0, "budget": 1, @@ -6887,7 +7023,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-bucket-fill 主绘制 p95", - "value": 11.899999991059303, + "value": 0.7999999970197678, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6904,7 +7040,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-bucket-fill 指针处理 p95", - "value": 1.4000000059604645, + "value": 28.200000002980232, "samples": 1, "noisePercent": 0, "budget": 4, @@ -6921,9 +7057,9 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-gradient 帧 p95", - "value": 32.30000000000018, - "samples": 3, - "noisePercent": 0.30959752321812306, + "value": 21.5, + "samples": 1, + "noisePercent": 0, "budget": 16.7, "target": { "kind": "canvas", @@ -6938,9 +7074,9 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-gradient >25 ms 比例", - "value": 30.53097345132743, - "samples": 3, - "noisePercent": 5.3382856310538225, + "value": 1.8329938900203666, + "samples": 1, + "noisePercent": 0, "budget": 1, "target": { "kind": "canvas", @@ -6955,9 +7091,9 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-gradient 主绘制 p95", - "value": 37.20000000298023, - "samples": 3, - "noisePercent": 1.8817204379681522, + "value": 28, + "samples": 1, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -6972,9 +7108,9 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-gradient 指针处理 p95", - "value": 1.1000000089406967, - "samples": 3, - "noisePercent": 18.18181830496827, + "value": 1.6000000014901161, + "samples": 1, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -7006,7 +7142,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-detail-pan >25 ms 比例", - "value": 0.4784688995215311, + "value": 0.14947683109118087, "samples": 1, "noisePercent": 0, "budget": 1, @@ -7023,7 +7159,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-detail-pan 主绘制 p95", - "value": 0.6000000089406967, + "value": 2.2999999970197678, "samples": 1, "noisePercent": 0, "budget": 4, @@ -7040,7 +7176,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-detail-pan 指针处理 p95", - "value": 0.20000000298023224, + "value": 0.29999999701976776, "samples": 1, "noisePercent": 0, "budget": 4, @@ -7057,7 +7193,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-detail-draw 帧 p95", - "value": 3.700000000000273, + "value": 24.899999999999636, "samples": 1, "noisePercent": 0, "budget": 16.7, @@ -7074,7 +7210,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-detail-draw >25 ms 比例", - "value": 0.1949317738791423, + "value": 3.905160390516039, "samples": 1, "noisePercent": 0, "budget": 1, @@ -7091,7 +7227,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-detail-draw 主绘制 p95", - "value": 1.2000000029802322, + "value": 0.8999999985098839, "samples": 1, "noisePercent": 0, "budget": 4, @@ -7108,7 +7244,7 @@ "suiteId": "canvas-large-4000", "kind": "canvas", "label": "4000x4000 large-detail-draw 指针处理 p95", - "value": 1.300000011920929, + "value": 30.299999997019768, "samples": 1, "noisePercent": 0, "budget": 4, @@ -7120,6 +7256,74 @@ "metric": "inputP95" } }, + { + "id": "canvas-large-4000:4000:large-detail-draw-timelapse:p95", + "suiteId": "canvas-large-4000", + "kind": "canvas", + "label": "4000x4000 large-detail-draw-timelapse 帧 p95", + "value": 21.399999999999636, + "samples": 1, + "noisePercent": 0, + "budget": 16.7, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-4000", + "canvasSize": 4000, + "scenario": "large-detail-draw-timelapse", + "metric": "p95" + } + }, + { + "id": "canvas-large-4000:4000:large-detail-draw-timelapse:over25Percent", + "suiteId": "canvas-large-4000", + "kind": "canvas", + "label": "4000x4000 large-detail-draw-timelapse >25 ms 比例", + "value": 0.9915014164305949, + "samples": 1, + "noisePercent": 0, + "budget": 1, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-4000", + "canvasSize": 4000, + "scenario": "large-detail-draw-timelapse", + "metric": "over25Percent" + } + }, + { + "id": "canvas-large-4000:4000:large-detail-draw-timelapse:drawP95", + "suiteId": "canvas-large-4000", + "kind": "canvas", + "label": "4000x4000 large-detail-draw-timelapse 主绘制 p95", + "value": 0.7000000029802322, + "samples": 1, + "noisePercent": 0, + "budget": 4, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-4000", + "canvasSize": 4000, + "scenario": "large-detail-draw-timelapse", + "metric": "drawP95" + } + }, + { + "id": "canvas-large-4000:4000:large-detail-draw-timelapse:inputP95", + "suiteId": "canvas-large-4000", + "kind": "canvas", + "label": "4000x4000 large-detail-draw-timelapse 指针处理 p95", + "value": 27.30000000447035, + "samples": 1, + "noisePercent": 0, + "budget": 4, + "target": { + "kind": "canvas", + "suiteId": "canvas-large-4000", + "canvasSize": 4000, + "scenario": "large-detail-draw-timelapse", + "metric": "inputP95" + } + }, { "id": "canvas-large-sentinel:2048:large-detail-pan:p95", "suiteId": "canvas-large-sentinel", @@ -7142,9 +7346,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "2048x2048 large-detail-pan >25 ms 比例", - "value": 0.2288329519450801, + "value": 0, "samples": 3, - "noisePercent": 0.4597701149425218, + "noisePercent": 0, "budget": 1, "target": { "kind": "canvas", @@ -7159,9 +7363,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "2048x2048 large-detail-pan 主绘制 p95", - "value": 0.5, + "value": 2.399999998509884, "samples": 3, - "noisePercent": 0, + "noisePercent": 4.166666420900987, "budget": 4, "target": { "kind": "canvas", @@ -7176,7 +7380,7 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "2048x2048 large-detail-pan 指针处理 p95", - "value": 0.20000000298023224, + "value": 0.19999999552965164, "samples": 3, "noisePercent": 0, "budget": 4, @@ -7210,9 +7414,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "2048x2048 large-detail-draw >25 ms 比例", - "value": 0.20325203252032523, + "value": 0.1455604075691412, "samples": 3, - "noisePercent": 0.2036659877800224, + "noisePercent": 0.2902757619738904, "budget": 1, "target": { "kind": "canvas", @@ -7227,9 +7431,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "2048x2048 large-detail-draw 主绘制 p95", - "value": 0.8999999910593033, + "value": 0.6000000014901161, "samples": 3, - "noisePercent": 0.0000016556845935419327, + "noisePercent": 0, "budget": 4, "target": { "kind": "canvas", @@ -7244,9 +7448,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "2048x2048 large-detail-draw 指针处理 p95", - "value": 1, + "value": 9.299999997019768, "samples": 3, - "noisePercent": 20.000000298023224, + "noisePercent": 8.011376988506886e-8, "budget": 4, "target": { "kind": "canvas", @@ -7261,7 +7465,7 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "4000x4000 large-detail-pan 帧 p95", - "value": 3.700000000000273, + "value": 3.699999999999818, "samples": 3, "noisePercent": 0, "budget": 16.7, @@ -7278,9 +7482,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "4000x4000 large-detail-pan >25 ms 比例", - "value": 0.47732696897374705, + "value": 0.15873015873015872, "samples": 3, - "noisePercent": 0.23923444976075922, + "noisePercent": 1.4492753623188421, "budget": 1, "target": { "kind": "canvas", @@ -7295,9 +7499,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "4000x4000 large-detail-pan 主绘制 p95", - "value": 0.6000000089406967, + "value": 1.8999999985098839, "samples": 3, - "noisePercent": 0.0000024835268286338427, + "noisePercent": 5.263157977291752, "budget": 4, "target": { "kind": "canvas", @@ -7329,9 +7533,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "4000x4000 large-detail-draw 帧 p95", - "value": 3.700000000000273, + "value": 21.399999999999636, "samples": 3, - "noisePercent": 0, + "noisePercent": 15.887850467288288, "budget": 16.7, "target": { "kind": "canvas", @@ -7346,9 +7550,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "4000x4000 large-detail-draw >25 ms 比例", - "value": 0.1976284584980237, + "value": 1.4265335235378032, "samples": 3, - "noisePercent": 0.19723865877711472, + "noisePercent": 40.7605633802817, "budget": 1, "target": { "kind": "canvas", @@ -7363,7 +7567,7 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "4000x4000 large-detail-draw 主绘制 p95", - "value": 0.9000000059604645, + "value": 0.7000000029802322, "samples": 3, "noisePercent": 0, "budget": 4, @@ -7380,9 +7584,9 @@ "suiteId": "canvas-large-sentinel", "kind": "canvas", "label": "4000x4000 large-detail-draw 指针处理 p95", - "value": 1, + "value": 27.600000001490116, "samples": 3, - "noisePercent": 9.999999403953552, + "noisePercent": 9.420289859962837, "budget": 4, "target": { "kind": "canvas", @@ -7397,9 +7601,9 @@ "suiteId": "selection", "kind": "algorithm", "label": "src/renderer/src/core/selection-performance.bench.ts > magic-wand hot paths > select contiguous noise background", - "value": 14.519350000000031, - "samples": 34, - "noisePercent": 3.1150857277877293, + "value": 13.944000000000415, + "samples": 37, + "noisePercent": 2.801423446547866, "budget": 22, "target": { "kind": "vitest-benchmark", @@ -7412,9 +7616,9 @@ "suiteId": "selection", "kind": "algorithm", "label": "src/renderer/src/core/selection-performance.bench.ts > magic-wand hot paths > build noisy selection boundary", - "value": 7.60694999999987, - "samples": 64, - "noisePercent": 3.484372944263328, + "value": 6.046300000000883, + "samples": 83, + "noisePercent": 2.884078876761061, "budget": 12, "target": { "kind": "vitest-benchmark", @@ -7427,9 +7631,9 @@ "suiteId": "document-composite", "kind": "algorithm", "label": "src/renderer/src/core/document-performance.bench.ts > complex document compositing > composite 800x800 with 24 grouped layers", - "value": 107.6286, - "samples": 5, - "noisePercent": 5.528826629124246, + "value": 412.1131999999998, + "samples": 3, + "noisePercent": 16.192764373270798, "budget": 1000, "target": { "kind": "vitest-benchmark", @@ -7442,9 +7646,9 @@ "suiteId": "document-composite", "kind": "algorithm", "label": "src/renderer/src/core/document-performance.bench.ts > complex document compositing > composite 64x64 dirty region with 24 grouped layers", - "value": 1.034099999999853, - "samples": 484, - "noisePercent": 1.792548395176096, + "value": 2.694050000000061, + "samples": 180, + "noisePercent": 2.5287926655281443, "budget": 100, "target": { "kind": "vitest-benchmark", @@ -7453,82 +7657,191 @@ } }, { - "id": "bundle:assets/index-CXtQEw9q.js:gzipBytes", + "id": "document-composite:211194537_1_0", + "suiteId": "document-composite", + "kind": "algorithm", + "label": "src/renderer/src/core/document-performance.bench.ts > large document dirty-region compositing > composite 64x64 without scanning the full 4000x2000 layer", + "value": 0.015099999999620195, + "samples": 29529, + "noisePercent": 1.2646059297447423, + "budget": 100, + "target": { + "kind": "vitest-benchmark", + "suiteId": "document-composite", + "benchmarkId": "211194537_1_0" + } + }, + { + "id": "project-format:-1189390439_0_0", + "suiteId": "project-format", + "kind": "algorithm", + "label": "src/renderer/src/core/project-format-performance.bench.ts > large project format > encode 4000x2000 with 12 layers and 12 linked frames", + "value": 698.0106999999998, + "samples": 3, + "noisePercent": 7.7938679877035035, + "budget": 100, + "target": { + "kind": "vitest-benchmark", + "suiteId": "project-format", + "benchmarkId": "-1189390439_0_0" + } + }, + { + "id": "project-format:-1189390439_0_1", + "suiteId": "project-format", + "kind": "algorithm", + "label": "src/renderer/src/core/project-format-performance.bench.ts > large project format > decode 4000x2000 with 12 layers and 12 linked frames", + "value": 126.7883499999989, + "samples": 4, + "noisePercent": 11.509908039656702, + "budget": 100, + "target": { + "kind": "vitest-benchmark", + "suiteId": "project-format", + "benchmarkId": "-1189390439_0_1" + } + }, + { + "id": "bundle:assets/index-CHAtcyZW.js:gzipBytes", + "suiteId": "bundle", + "kind": "bundle", + "label": "assets/index-CHAtcyZW.js gzip", + "value": 243615, + "samples": 1, + "noisePercent": 0, + "budget": 307200, + "target": { + "kind": "bundle", + "suiteId": "bundle", + "file": "assets/index-CHAtcyZW.js", + "metric": "gzipBytes" + } + }, + { + "id": "bundle:assets/I18nProvider-3TuHReJI.js:gzipBytes", + "suiteId": "bundle", + "kind": "bundle", + "label": "assets/I18nProvider-3TuHReJI.js gzip", + "value": 233569, + "samples": 1, + "noisePercent": 0, + "budget": 307200, + "target": { + "kind": "bundle", + "suiteId": "bundle", + "file": "assets/I18nProvider-3TuHReJI.js", + "metric": "gzipBytes" + } + }, + { + "id": "bundle:assets/document-decode.worker-B2tw-9DD.js:gzipBytes", + "suiteId": "bundle", + "kind": "bundle", + "label": "assets/document-decode.worker-B2tw-9DD.js gzip", + "value": 109564, + "samples": 1, + "noisePercent": 0, + "budget": 307200, + "target": { + "kind": "bundle", + "suiteId": "bundle", + "file": "assets/document-decode.worker-B2tw-9DD.js", + "metric": "gzipBytes" + } + }, + { + "id": "bundle:assets/project-gallery.worker-uLWKuLg5.js:gzipBytes", + "suiteId": "bundle", + "kind": "bundle", + "label": "assets/project-gallery.worker-uLWKuLg5.js gzip", + "value": 108534, + "samples": 1, + "noisePercent": 0, + "budget": 307200, + "target": { + "kind": "bundle", + "suiteId": "bundle", + "file": "assets/project-gallery.worker-uLWKuLg5.js", + "metric": "gzipBytes" + } + }, + { + "id": "bundle:assets/CanvasStage-DobZwx8N.js:gzipBytes", "suiteId": "bundle", "kind": "bundle", - "label": "assets/index-CXtQEw9q.js gzip", - "value": 201975, + "label": "assets/CanvasStage-DobZwx8N.js gzip", + "value": 42174, "samples": 1, "noisePercent": 0, "budget": 307200, "target": { "kind": "bundle", "suiteId": "bundle", - "file": "assets/index-CXtQEw9q.js", + "file": "assets/CanvasStage-DobZwx8N.js", "metric": "gzipBytes" } }, { - "id": "bundle:assets/I18nProvider-D7X9V-ta.js:gzipBytes", + "id": "bundle:assets/ComponentLibrary-DopTD4kU.js:gzipBytes", "suiteId": "bundle", "kind": "bundle", - "label": "assets/I18nProvider-D7X9V-ta.js gzip", - "value": 177443, + "label": "assets/ComponentLibrary-DopTD4kU.js gzip", + "value": 12446, "samples": 1, "noisePercent": 0, "budget": 307200, "target": { "kind": "bundle", "suiteId": "bundle", - "file": "assets/I18nProvider-D7X9V-ta.js", + "file": "assets/ComponentLibrary-DopTD4kU.js", "metric": "gzipBytes" } }, { - "id": "bundle:assets/CanvasStage-DKB0qbYm.js:gzipBytes", + "id": "bundle:assets/HomeWorkspace-l9ntZjRZ.js:gzipBytes", "suiteId": "bundle", "kind": "bundle", - "label": "assets/CanvasStage-DKB0qbYm.js gzip", - "value": 32890, + "label": "assets/HomeWorkspace-l9ntZjRZ.js gzip", + "value": 10146, "samples": 1, "noisePercent": 0, "budget": 307200, "target": { "kind": "bundle", "suiteId": "bundle", - "file": "assets/CanvasStage-DKB0qbYm.js", + "file": "assets/HomeWorkspace-l9ntZjRZ.js", "metric": "gzipBytes" } }, { - "id": "bundle:assets/ComponentLibrary-D4eFrjAL.js:gzipBytes", + "id": "bundle:assets/project-encode.worker-CydMhW6S.js:gzipBytes", "suiteId": "bundle", "kind": "bundle", - "label": "assets/ComponentLibrary-D4eFrjAL.js gzip", - "value": 11162, + "label": "assets/project-encode.worker-CydMhW6S.js gzip", + "value": 4536, "samples": 1, "noisePercent": 0, "budget": 307200, "target": { "kind": "bundle", "suiteId": "bundle", - "file": "assets/ComponentLibrary-D4eFrjAL.js", + "file": "assets/project-encode.worker-CydMhW6S.js", "metric": "gzipBytes" } }, { - "id": "bundle:assets/HomeWorkspace-BP6WnYnN.js:gzipBytes", + "id": "bundle:assets/timelapse-encode.worker-BWkmgetg.js:gzipBytes", "suiteId": "bundle", "kind": "bundle", - "label": "assets/HomeWorkspace-BP6WnYnN.js gzip", - "value": 4885, + "label": "assets/timelapse-encode.worker-BWkmgetg.js gzip", + "value": 4125, "samples": 1, "noisePercent": 0, "budget": 307200, "target": { "kind": "bundle", "suiteId": "bundle", - "file": "assets/HomeWorkspace-BP6WnYnN.js", + "file": "assets/timelapse-encode.worker-BWkmgetg.js", "metric": "gzipBytes" } } diff --git a/docs/testing/performance-history.md b/docs/testing/performance-history.md index cc66c13..4fdbe2e 100644 --- a/docs/testing/performance-history.md +++ b/docs/testing/performance-history.md @@ -11,6 +11,14 @@ 5. 没有自动基准覆盖的交互必须明确写“未覆盖”,禁止用主观流畅度代替数据。 6. 性能优化不能改变像素结果、快捷键、撤销语义或文件兼容性。 +## 2026-08-16 DEV.5 自动性能审计 + +审计编号:`2026-08-16_13-51-24-452`。性能等级为 P4,结果为“用户批准不改代码”。候选为大画布油漆桶慢帧比例,测量噪声 57.86%,无法确认稳定退化;像素填充算法属于高风险关键路径,用户明确批准 DEV.5 本次不改代码直接发布。 + +候选热点:2048x2048 large-bucket-fill >25 ms 比例;初始值 3.390,测量噪声 57.86%。 +本次没有执行代码复测。 +执行套件:canvas-standard、canvas-profile、canvas-complex、canvas-large-800、canvas-large-2048、canvas-large-4000、canvas-large-sentinel、selection、document-composite、project-format、bundle、desktop。 + ## 2026-08-11 DEV.4 自动性能审计 审计编号:`2026-08-11_19-24-14-791`。性能等级为 P4,结果为“已采纳优化”。大画布概览复用缩放合成缓存,并将无抖动渐变预览调整为视窗自适应 96000 点采样;定向复测收益超过噪声门槛且相邻指标无稳定退化。 diff --git a/docs/testing/performance-release-receipt.json b/docs/testing/performance-release-receipt.json index e58d5b6..d704938 100644 --- a/docs/testing/performance-release-receipt.json +++ b/docs/testing/performance-release-receipt.json @@ -1,13 +1,13 @@ { "schemaVersion": 1, "status": "complete", - "releaseLabel": "DEV.4", - "auditId": "2026-08-11_19-24-14-791", - "outcome": "adopted", - "acceptedAt": "2026-08-11T19:53:35.985Z", + "releaseLabel": "DEV.5", + "auditId": "2026-08-16_13-51-24-452", + "outcome": "approved-no-change", + "acceptedAt": "2026-08-16T14:08:26.361Z", "sourceFingerprint": { "algorithm": "sha256", - "value": "8d0d5c0deee94dfa6cbf74606e5b458b0c311a14dbb0106e1fb198f5fd1fa6ee", - "files": 358 + "value": "5de60d5f2fa1d03a0e7018d1bdb71b71f8ffc3e28187a0aec644a2731b16c034", + "files": 439 } } diff --git a/docs/testing/regression-matrix.md b/docs/testing/regression-matrix.md index d5ba02a..f550a9a 100644 --- a/docs/testing/regression-matrix.md +++ b/docs/testing/regression-matrix.md @@ -49,6 +49,7 @@ | --- | --- | --- | | 视图逆变换 | 旋转、水平/垂直镜像和非整数缩放后,绘制、吸色、选区与预览命中同一文档像素 | `view-geometry.test.ts`、`canvas-input.test.ts` | | 指针锚点缩放 | 主画布和预览栏缩放前后保持指针下的文档位置,不因旋转或镜像回弹 | `view-geometry.test.ts`、`preview-geometry.test.ts` | +| 视口尺寸重绘 | 分栏和浮窗改变宽高时,旧 Canvas 位图在下一次重绘前保持原像素比例,不被 CSS 横向或纵向拉伸 | `canvas-display-size.test.ts` | | 选区命中对称性 | 八个手柄、边缘移动、旋转和倾斜使用连续文档坐标,左右上下命中无固定偏移或空洞 | `canvas-input.test.ts`、`canvas-visuals.test.ts` | | 套索与组合边界 | 套索闭合不丢右边或下边像素;新建、加选、减选、交集和比例修饰键使用统一规则 | `tools.test.ts`、`selection.test.ts`、`canvas-input.test.ts` | | 画布外选区内容 | 浮动内容移出画布再移回时像素完整保留,确认时才按文档边界写入 | `tools.test.ts`、`workspace.test.ts` | diff --git a/docs/ui-design-system.md b/docs/ui-design-system.md new file mode 100644 index 0000000..33b5964 --- /dev/null +++ b/docs/ui-design-system.md @@ -0,0 +1,141 @@ +# MoonSprite UI 设计规范 + +本文定义 MoonSprite 桌面像素编辑器的界面基础、组件规格和实现约束。组件库与生产界面必须共同使用这里的规格,不得分别维护仅用于展示的仿制样式。 + +## 设计原则 + +- 界面服务于长时间、高频率的像素编辑操作,保持紧凑、安静、清晰。 +- 所有容器保持直角;使用一像素边框和表面明度建立层级。 +- `#2979FF` 是唯一强调色,用于选择、焦点、主要操作和关键状态。 +- 颜色、字号、间距、控件高度必须使用语义 token。局部组件不得凭视觉感觉新增近似数值。 +- UI 图标与鼠标指针属于像素资产,必须按源像素的整数倍显示;文字、间距和普通布局不受整数倍限制。 + +## 颜色 + +继续使用 `--theme-*` 语义变量。禁止在组件中复制主题颜色;作品颜色、色板、棋盘格和颜色模型可使用数据颜色。 + +状态规则: + +- 默认:中性表面和边框。 +- 悬浮:提高一档表面明度,不改变几何尺寸。 +- 选中、展开、焦点:使用 `--theme-accent`。 +- 禁用:使用禁用文本、表面和边框,保留可读性。 +- 危险操作:只在真实破坏性操作中使用危险色。 + +## 字体层级 + +| Token | 用途 | 字号 / 行高 | +| --- | --- | --- | +| `--ui-font-small` | 辅助说明、快捷键、坐标、角标、紧凑标签 | 10px / 14px | +| `--ui-font-regular` | 菜单、字段、按钮、正文及所有标题 | 12px / 18px | + +界面只允许使用以上两档字号,不为标题或局部场景创建额外字号。标题与正文的层级通过字重、颜色、分隔线和紧凑间距表达。正文使用 `Noto Sans SC`/系统无衬线字体;数值、HEX、快捷键和坐标可使用等宽字体。字距固定为 `0`。 + +## 间距 + +| Token | 数值 | 用途 | +| --- | ---: | --- | +| `--ui-space-1` | 2px | 像素级分隔、紧邻状态 | +| `--ui-space-2` | 4px | 图标微间距、菜单内边距 | +| `--ui-space-3` | 6px | 紧凑控件内部间距 | +| `--ui-space-4` | 8px | 默认控件间距 | +| `--ui-space-5` | 12px | 弹窗内容边距和大型内容分组 | + +常规控件、菜单和栏目使用 `2px` 至 `8px`,只有弹窗内容边距和大型内容分组使用 `12px`。禁止新增 `3px`、`5px`、`7px`、`9px`、`10px`、`14px`、`16px`、`20px` 等近似档位。只有确实受像素网格约束的绘图区、时间轴、色板和定位几何可以使用其他整数值。 + +按钮、菜单、导航和工具属性栏的左右内边距统一为 `8px`;组件之间的 `gap` 不得超过 `8px`。需要更明显的层级时使用边框、表面颜色或分组标题,不使用大段空白。 + +## 控件密度 + +| 规格 | 高度 | 使用位置 | +| --- | ---: | --- | +| 紧凑图标按钮 | 26px | 栏目标题、工具属性栏、连续操作组 | +| 紧凑命令按钮 | 30px | 工具设置、行内操作、菜单触发器 | +| 标准字段 | 34px | 输入框、数字输入、下拉选择 | +| 标准弹窗按钮 | 34px | 弹窗底部主要与次要操作 | +| 设置开关 | 35px | 首选项、实时预览、图层设置 | +| 设置导航行 | 32px | 首选项与快捷键左侧导航 | +| 强调颜色/工具字段 | 38px | 需要较大颜色样本或工具图标的字段 | + +同一组件的尺寸由显式密度变体或语义容器决定,不能根据是否包含图标、DOM 后代结构或选择器优先级改变。 + +## 像素图标 + +- `PixelUtilityIcon` 源尺寸为 5x5、6x6、7x7 或 11x11,只允许 `1x`、`2x` 或其他整数倍。 +- 11x11 图标的常用显示尺寸是 11px 和 22px;5x5、6x6、7x7 的常用 2x 尺寸分别为 10px、12px、14px。 +- 工具图标必须使用对应的正常或大号源文件,不得通过浏览器平滑缩放生成中间尺寸。 +- SVG 像素图标使用整数 `width`、`height`、`viewBox` 和 `shape-rendering: crispEdges`。 +- 位图像素图标使用 `image-rendering: pixelated`,禁止 `scale(1.5)`、百分比尺寸和非整数 CSS 像素。 +- 图标按钮可以改变按钮尺寸,但不得把像素图标拉伸到非整数倍。 + +## 组件规范 + +### 按钮 + +- `primary-button`、`quiet-button`、`danger-button` 共享文字、内边距和高度。 +- 图标是否存在不能改变按钮高度、字号或内边距。 +- 图标按钮使用 `icon-button`,默认 26px;需要更大目标时使用明确的尺寸变体。 +- 悬浮、按下、选中和焦点只改变颜色与边框,不改变尺寸。 + +### 表单 + +- `TextInput`、`NumberInput`、`ThemedSelect` 默认使用 34px 标准字段高度。 +- 工具属性栏可显式使用 26px 紧凑变体。 +- 标签、控件、说明与悬浮描述使用 `FormField`,禁止在弹窗和首选项里重复拼装字段布局。 +- 同时包含标签、连续滑块和数字输入的参数使用 `RangeField`,由 `compact` / `regular` 密度决定字号与高度,不得在各弹窗重新实现一套滑块行。 +- 独立复选项使用 `CheckboxField`,底层像素状态由 `PixelCheckbox` 提供。 +- 互斥模式使用 `SegmentedControl`,选项描述统一通过 Tooltip 展示。 +- 颜色值按钮使用 `ColorValueControl` 的 `compact`、`regular`、`emphasized` 显式密度,不允许父容器覆盖按钮高度。 +- 标签和错误信息使用常规字号,辅助说明使用辅助字号。 +- 原生 `select` 和原生数字步进不得直接出现在生产界面。 + +### 菜单 + +- 顶部菜单、右键菜单、栏目菜单共享 30px 行高、22px 图标列、8px 内容间距。 +- 快捷键位于末列并使用 10px 等宽文字。 +- 分隔线只负责分组,不通过额外空白制造不同密度。 + +### 开关与复选框 + +- 独立多选使用 `PixelCheckbox`。 +- 设置开关使用共享的 `PreferenceToggle`,`LivePreviewToggle` 也复用该开关结构,不得复制 input、轨道和滑块结构。 +- 开关整行高度为 35px;较长说明放在 Tooltip,不增加默认行高。 + +### 栏目 + +- 真实栏目标题统一为 47px,右侧操作使用 26px 图标按钮。 +- 组件库必须直接渲染 `.panel > header`,不得维护另一套预览标题。 + +### 设置分组 + +- 设置分组标题与右侧操作统一使用 `SettingsSectionHeader`。 +- 标题使用 12px,操作使用 30px 命令按钮;同一分组中不得混用 27px、28px 和 30px 的近似尺寸。 + +### 弹窗 + +- 所有弹窗使用 `ModalShell`。 +- 标准标题栏使用 `DialogHeader`,统一眉题、标题、关闭按钮和标题栏操作;不得在业务弹窗重复拼装标题结构。 +- 弹窗与栏目标题栏统一为 47px,并统一使用 8px 水平内边距;内容默认间距 8px、内边距 12px,底部操作区使用 8px 间距和 34px 按钮。 +- 特殊编辑器可声明紧凑或宽松内容变体,但不能逐个弹窗任意指定近似数值。 +- 组件库预览必须复用真实标题栏、内容区和底部样式。 + +### 设置导航 + +- 首选项、快捷键和其他分区设置窗口使用 `SettingsNavigation`。 +- 导航行固定使用 32px 高度、12px 正文字号和强调色左侧选中标识。 +- 设置窗口不得各自维护导航行的悬浮、选中和内边距规则。 + +## 组件库门禁 + +- 每个可复用组件必须登记真实源码和生产 class。 +- 预览直接渲染真实组件,覆盖默认、悬浮/选中、禁用和交互状态。 +- 不允许使用 `component-*` 仿制另一套生产组件。 +- 新增局部尺寸前,先确认是否应成为已有组件的密度变体。 + +## 修改检查 + +1. 检查是否使用语义 token,而不是新增近似字号、间距和高度。 +2. 检查图标显示尺寸是否为源像素整数倍。 +3. 检查同一操作在工具栏、栏目、菜单和弹窗中的状态反馈是否一致。 +4. 检查组件库预览与生产界面是否使用同一组件和样式。 +5. 在 1024x640 和主要桌面尺寸下由用户完成最终视觉与手感验收。 diff --git a/package.json b/package.json index bfa45fa..3815e5c 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "moonsprite", - "version": "0.1.0-dev.4", - "description": "An original, open-source pixel art workstation for Windows.", + "version": "0.1.0-dev.5", + "description": "An original, source-available pixel art workstation for Windows.", "author": "MoonSprite contributors", - "license": "MIT", + "license": "SEE LICENSE IN LICENSE", "scripts": { "dev": "tauri dev --config src-tauri/tauri.dev.conf.json", "dev:web": "vite --config vite.config.ts", @@ -31,7 +31,7 @@ "bench:canvas": "pnpm build:web:performance && node scripts/canvas-performance.mjs", "bench:canvas:full": "pnpm bench:canvas -- --full", "bench:canvas:profile": "pnpm build:web:performance-profile && node scripts/canvas-performance.mjs --runtime=profile", - "bench:canvas:large": "pnpm bench:canvas -- --size=800,2048,4000 --scenario=large-pan,large-zoom,large-draw,large-shape,large-marquee,large-bucket-fill,large-gradient,large-detail-pan,large-detail-draw", + "bench:canvas:large": "pnpm bench:canvas -- --size=800,2048,4000 --scenario=large-pan,large-zoom,large-draw,large-shape,large-marquee,large-bucket-fill,large-gradient,large-detail-pan,large-detail-draw,large-detail-draw-timelapse", "bench:selection": "vitest bench src/renderer/src/core/selection-performance.bench.ts --run", "bench:document": "vitest bench src/renderer/src/core/document-performance.bench.ts --run", "check:performance-scope": "node scripts/performance-scope.mjs", @@ -39,7 +39,10 @@ "package": "tauri build && pnpm package:prepare && pnpm package:size", "package:prepare": "node scripts/prepare-release.mjs", "package:size": "node scripts/check-package-size.mjs", - "preview": "vite preview --config vite.config.ts" + "preview": "vite preview --config vite.config.ts", + "website:dev": "pnpm --dir website dev", + "website:build": "pnpm --dir website build", + "website:capture": "node website/scripts/capture-product.mjs" }, "dependencies": {}, "devDependencies": { diff --git a/palettes/deep-spectrum-48.palette.json b/palettes/deep-spectrum-48.palette.json new file mode 100644 index 0000000..77fc7b0 --- /dev/null +++ b/palettes/deep-spectrum-48.palette.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 2, + "id": "deep-spectrum-48", + "name": "深色色相 48", + "colors": ["#D8DAE0FF", "#8A8E99FF", "#444954FF", "#0B0D12FF", "#C99F7CFF", "#8A5C42FF", "#503328FF", "#241A18FF", "#D9868DFF", "#A34354FF", "#672538FF", "#2B1521FF", "#D89562FF", "#9B552FFF", "#5D311FFF", "#291A16FF", "#D1B95FFF", "#8E782FFF", "#55481FFF", "#262216FF", "#A6C258FF", "#657F2CFF", "#3B4C20FF", "#1D2516FF", "#6FC082FF", "#36784CFF", "#244A35FF", "#14251DFF", "#5DB9A4FF", "#2F746AFF", "#204844FF", "#122526FF", "#65B4C8FF", "#347088FF", "#214655FF", "#132632FF", "#789DD5FF", "#4563A0FF", "#2B3D68FF", "#171F3AFF", "#9A7BCEFF", "#654894FF", "#412D61FF", "#231933FF", "#C078A9FF", "#88426FFF", "#572845FF", "#2D1726FF"], + "columns": 13, + "slots": [0, null, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 1, null, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 2, null, 6, 10, 14, 18, 22, 26, 30, 34, 38, 42, 46, 3, null, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47] +} diff --git a/palettes/forest-dusk-12.palette.json b/palettes/forest-dusk-12.palette.json deleted file mode 100644 index 123bf53..0000000 --- a/palettes/forest-dusk-12.palette.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schemaVersion": 1, - "id": "forest-dusk-12", - "name": "林间暮色 12", - "colors": ["#141A1AFF", "#27332BFF", "#3E5540FF", "#607A4EFF", "#8BA35DFF", "#C0CA78FF", "#E9D99AFF", "#553F43FF", "#815054FF", "#B86A5BFF", "#D89467FF", "#F0C985FF"] -} diff --git a/palettes/mono-10.palette.json b/palettes/mono-10.palette.json deleted file mode 100644 index 24b349d..0000000 --- a/palettes/mono-10.palette.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schemaVersion": 1, - "id": "mono-10", - "name": "像素灰阶 10", - "colors": ["#0B0C0FFF", "#1B1D22FF", "#30333AFF", "#484C55FF", "#626771FF", "#7F848DFF", "#9EA2A9FF", "#BEC1C6FF", "#DEDFE2FF", "#F7F7F7FF"] -} diff --git a/palettes/moonlight-12.palette.json b/palettes/moonlight-12.palette.json deleted file mode 100644 index 3190f6d..0000000 --- a/palettes/moonlight-12.palette.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schemaVersion": 1, - "id": "moonlight-12", - "name": "月光 12", - "colors": ["#101014FF", "#24232BFF", "#44404DFF", "#655561FF", "#8D7A8BFF", "#C2B6C0FF", "#F1EDF1FF", "#173A65FF", "#2979FFFF", "#55B6E8FF", "#8DE4D0FF", "#F7D46AFF"] -} diff --git a/palettes/soft-spectrum-48.palette.json b/palettes/soft-spectrum-48.palette.json new file mode 100644 index 0000000..b3914ed --- /dev/null +++ b/palettes/soft-spectrum-48.palette.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 2, + "id": "soft-spectrum-48", + "name": "柔和色相 48", + "colors": ["#EEECE8FF", "#B9B5B2FF", "#706D70FF", "#2F3036FF", "#E7CDB7FF", "#BE9579FF", "#80604FFF", "#463832FF", "#E9B9B8FF", "#C77D7FFF", "#92535FFF", "#4F303AFF", "#EBC7A5FF", "#C89568FF", "#8F654AFF", "#503B32FF", "#E9D9A7FF", "#B9A464FF", "#7E713FFF", "#464126FF", "#D8DFACFF", "#9BA56CFF", "#687444FF", "#3A402AFF", "#BDD7B8FF", "#7DA27AFF", "#507052FF", "#2D4132FF", "#B5D5CAFF", "#70A198FF", "#486F6CFF", "#294044FF", "#B7D6DCFF", "#72A2B0FF", "#496E7DFF", "#293E4CFF", "#BFCCE2FF", "#7F91B5FF", "#536488FF", "#303A55FF", "#D0C3DEFF", "#9882ADFF", "#6A577EFF", "#3D324FFF", "#DFC0D1FF", "#B17D99FF", "#7D536DFF", "#472F43FF"], + "columns": 13, + "slots": [0, null, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 1, null, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 2, null, 6, 10, 14, 18, 22, 26, 30, 34, 38, 42, 46, 3, null, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47] +} diff --git a/palettes/sunset-12.palette.json b/palettes/sunset-12.palette.json deleted file mode 100644 index d94169c..0000000 --- a/palettes/sunset-12.palette.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schemaVersion": 1, - "id": "sunset-12", - "name": "落日余晖 12", - "colors": ["#211629FF", "#43213BFF", "#712B48FF", "#A93B4FFF", "#D95B59FF", "#F48263FF", "#F7B267FF", "#FFE29AFF", "#324267FF", "#426A8CFF", "#58A0A3FF", "#86D1B2FF"] -} diff --git a/palettes/tiny-console-16.palette.json b/palettes/tiny-console-16.palette.json deleted file mode 100644 index 43e231c..0000000 --- a/palettes/tiny-console-16.palette.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schemaVersion": 1, - "id": "tiny-console-16", - "name": "微型主机 16", - "colors": ["#16171DFF", "#2E3039FF", "#555867FF", "#8A8E9CFF", "#D8D9DEFF", "#F7F4EAFF", "#712F45FF", "#C44554FF", "#ED7B5FFF", "#F4C06AFF", "#436B45FF", "#69A14CFF", "#A5D76EFF", "#274A70FF", "#3D79B8FF", "#73C4D8FF"] -} diff --git a/palettes/universal-spectrum-48.palette.json b/palettes/universal-spectrum-48.palette.json new file mode 100644 index 0000000..f5d75ec --- /dev/null +++ b/palettes/universal-spectrum-48.palette.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 2, + "id": "universal-spectrum-48", + "name": "通用色相 48", + "colors": ["#F5F5F5FF", "#B8BCC4FF", "#5A606BFF", "#171A21FF", "#F1D0B0FF", "#C4875DFF", "#7A4933FF", "#35251FFF", "#FFC2C2FF", "#F06464FF", "#B52F45FF", "#4A1824FF", "#FFD0A3FF", "#F28C45FF", "#B94D2BFF", "#4D241CFF", "#FFF1A8FF", "#E9C94AFF", "#9A7727FF", "#40331AFF", "#E0F5A0FF", "#9CCC4AFF", "#577C2CFF", "#26351CFF", "#B7E8B1FF", "#55B86AFF", "#28764BFF", "#173626FF", "#A8E2D2FF", "#42B89CFF", "#247067FF", "#153738FF", "#B5EBF4FF", "#55C1D9FF", "#2A7894FF", "#17394AFF", "#BDD7FFFF", "#619AF0FF", "#355EB7FF", "#1C2D5AFF", "#D7C3FFFF", "#9470E8FF", "#5F3AA3FF", "#2E2055FF", "#F3B9E3FF", "#D95FAFFF", "#91366FFF", "#461D3BFF"], + "columns": 13, + "slots": [0, null, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 1, null, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 2, null, 6, 10, 14, 18, 22, 26, 30, 34, 38, 42, 46, 3, null, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47] +} diff --git a/palettes/vivid-spectrum-48.palette.json b/palettes/vivid-spectrum-48.palette.json new file mode 100644 index 0000000..1f107d0 --- /dev/null +++ b/palettes/vivid-spectrum-48.palette.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 2, + "id": "vivid-spectrum-48", + "name": "鲜亮色相 48", + "colors": ["#FFFFFFFF", "#BFC6D4FF", "#596273FF", "#10131AFF", "#FFD1A3FF", "#F68B3CFF", "#A94B19FF", "#421D10FF", "#FFB6BEFF", "#FF4767FF", "#C20D3DFF", "#4D0A20FF", "#FFC08BFF", "#FF7A24FF", "#C63E0AFF", "#4F1908FF", "#FFF38AFF", "#FFD21FFF", "#B78400FF", "#493500FF", "#DFFF78FF", "#9BE31FFF", "#4D9D0EFF", "#1D4108FF", "#91F59AFF", "#24D65AFF", "#08923EFF", "#073D22FF", "#79F2D0FF", "#18D1A2FF", "#078977FF", "#053B39FF", "#80EDFFFF", "#20C9F2FF", "#057FAFFF", "#073650FF", "#96C8FFFF", "#3284FFFF", "#1551C5FF", "#10265EFF", "#C5A0FFFF", "#8B4DFFFF", "#5721BDFF", "#2B155FFF", "#FF9FE2FF", "#F23CB5FF", "#AD1479FF", "#4D0C38FF"], + "columns": 13, + "slots": [0, null, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 1, null, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 2, null, 6, 10, 14, 18, 22, 26, 30, 34, 38, 42, 46, 3, null, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed42a7d..59f36b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,34 @@ importers: specifier: 5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.8) + website: + dependencies: + lucide-react: + specifier: 1.26.0 + version: 1.26.0(react@19.2.8) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/react': + specifier: 19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: 6.0.4 + version: 6.0.4(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)) + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@26.1.1)(jiti@2.7.0) + packages: '@adobe/css-tools@4.5.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..1483da2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ allowBuilds: esbuild: true +packages: + - website diff --git a/resource/moonsprite-logo.svg b/resource/moonsprite-logo.svg index b135242..fc361ee 100644 --- a/resource/moonsprite-logo.svg +++ b/resource/moonsprite-logo.svg @@ -1,20 +1,4 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/scripts/canvas-performance-options.mjs b/scripts/canvas-performance-options.mjs index 871009c..eb92078 100644 --- a/scripts/canvas-performance-options.mjs +++ b/scripts/canvas-performance-options.mjs @@ -6,7 +6,7 @@ export const CANVAS_PERFORMANCE_SIZES = [...new Set([ ])] export const CANVAS_PERFORMANCE_SCENARIOS = ['pan', 'zoom', 'rotated-zoom', 'draw', 'shape', 'marquee', 'bucket-fill', 'gradient'] export const COMPLEX_CANVAS_PERFORMANCE_SCENARIOS = ['complex-draw', 'complex-undo', 'complex-playback'] -export const LARGE_CANVAS_PERFORMANCE_SCENARIOS = ['large-pan', 'large-zoom', 'large-draw', 'large-shape', 'large-marquee', 'large-bucket-fill', 'large-gradient', 'large-detail-pan', 'large-detail-draw'] +export const LARGE_CANVAS_PERFORMANCE_SCENARIOS = ['large-pan', 'large-zoom', 'large-draw', 'large-shape', 'large-marquee', 'large-bucket-fill', 'large-gradient', 'large-detail-pan', 'large-detail-draw', 'large-detail-draw-timelapse'] const SUPPORTED_SCENARIOS = [...CANVAS_PERFORMANCE_SCENARIOS, ...COMPLEX_CANVAS_PERFORMANCE_SCENARIOS, ...LARGE_CANVAS_PERFORMANCE_SCENARIOS] const SUPPORTED_RUNTIMES = ['production', 'profile'] diff --git a/scripts/canvas-performance.mjs b/scripts/canvas-performance.mjs index 1e5ce74..acad14b 100644 --- a/scripts/canvas-performance.mjs +++ b/scripts/canvas-performance.mjs @@ -207,7 +207,8 @@ async function seedUndoHistory(page, center) { async function benchmarkScenarioPage(page, size, scenario) { const projectKind = scenario.startsWith('complex-') ? 'complex' : scenario.startsWith('large-') ? 'large' : 'simple' const detailView = scenario.startsWith('large-detail-') - const actionKind = scenario.replace(/^complex-/, '').replace(/^large-(?:detail-)?/, '') + const actionKind = scenario.replace(/^complex-/, '').replace(/^large-(?:detail-)?/, '').replace(/-timelapse$/, '') + const timelapseEnabled = scenario.endsWith('-timelapse') let project = { uniquePixelBytes: size * size * 4, layerCount: 1, frameCount: 1 } if (projectKind === 'complex') project = await createComplexDocument(page, size) else if (projectKind === 'large') project = await createLargeDocument(page, size) @@ -270,6 +271,11 @@ async function benchmarkScenarioPage(page, size, scenario) { if (actionKind === 'draw') { if (initialView) await prepareToolScenario(page, initialView, 'pencil') + if (timelapseEnabled) await page.evaluate(() => { + const harness = window.__moonSpritePerformanceHarness + if (!harness) throw new Error('Performance harness is unavailable.') + harness.setTimelapseRecording(true) + }) results.push(await runScenario(page, size, scenario, async () => { await page.mouse.move(center.x - 120, center.y - 70) await page.mouse.down({ button: 'left' }) @@ -279,6 +285,13 @@ async function benchmarkScenarioPage(page, size, scenario) { await page.waitForTimeout(12) } await page.mouse.up({ button: 'left' }) + if (timelapseEnabled) { + try { + await page.waitForFunction(() => (window.__moonSpritePerformanceHarness?.timelapseSnapshotCount() ?? 0) > 0, undefined, { timeout: 10_000 }) + } catch (error) { + throw new Error('Timelapse capture did not finish within 10 seconds.', { cause: error }) + } + } })) } diff --git a/scripts/performance-build-contract.test.mjs b/scripts/performance-build-contract.test.mjs index 1ae7a6d..5bd632c 100644 --- a/scripts/performance-build-contract.test.mjs +++ b/scripts/performance-build-contract.test.mjs @@ -3,11 +3,12 @@ import { readFile } from 'node:fs/promises' import test from 'node:test' test('性能 Canvas 使用独立生产预览构建和应用内 Harness', async () => { - const [canvas, vite, main, profiler, packageSource] = await Promise.all([ + const [canvas, vite, main, profiler, timelapseWorker, packageSource] = await Promise.all([ readFile('scripts/canvas-performance.mjs', 'utf8'), readFile('vite.config.ts', 'utf8'), readFile('src/renderer/src/main.tsx', 'utf8'), readFile('src/renderer/src/components/PerformanceProfiler.tsx', 'utf8'), + readFile('src/renderer/src/workers/timelapse-encode.worker.ts', 'utf8'), readFile('package.json', 'utf8'), ]) const scripts = JSON.parse(packageSource).scripts @@ -21,6 +22,8 @@ test('性能 Canvas 使用独立生产预览构建和应用内 Harness', async ( assert.match(main, /__MOONSPRITE_PERFORMANCE_BUILD__/) assert.match(main, /import\('\.\/performance\/benchmark-harness'\)/) assert.match(profiler, /__MOONSPRITE_REACT_PROFILE__/) + assert.match(timelapseWorker, /core\/png-encode/) + assert.doesNotMatch(timelapseWorker, /core\/png['"]/) assert.match(scripts['build:web:performance'], /performance-production/) assert.match(scripts['build:web:performance-profile'], /performance-profile/) }) diff --git a/scripts/performance-scope-rules.mjs b/scripts/performance-scope-rules.mjs index faa9d88..ebb3c03 100644 --- a/scripts/performance-scope-rules.mjs +++ b/scripts/performance-scope-rules.mjs @@ -14,8 +14,11 @@ const p4Patterns = [ const p3Patterns = [ /\/CanvasStage\.tsx$/, - /\/canvas-(composite-cache|render-plan)\.(ts|tsx)$/, - /\/core\/(animation|animation-thumbnail|document|gif|onion-skin|raster|tools)\.ts$/, + /\/(canvas|onion-skin)-composite-cache\.(ts|tsx)$/, + /\/canvas-render-plan\.(ts|tsx)$/, + /\/core\/(animation|animation-thumbnail|document|gif|onion-skin|raster|timelapse|tools)\.ts$/, + /\/core\/(project-format|document-files)\.ts$/, + /\/workers\/(document-decode|timelapse-encode)\.worker\.ts$/, /\/store\/workspace(-session|-history|-palette)?\.ts$/, /\/components\/(WorkspacePanels|PerformanceProfiler)\.tsx$/, /\/components\/app\/(EditorCanvasHost|EditorWorkspaceShell)\.tsx$/, @@ -66,8 +69,9 @@ export function classifyPerformanceAudit(files, options = {}) { if (levelRank[minimumLevel] > levelRank[level]) level = minimumLevel const selectionAlgorithm = paths.some((file) => /\/core\/selection(?:-performance)?\.(?:ts|tsx)$/.test(file)) + const projectFormat = paths.some((file) => /\/core\/(?:project-format|document-files)\.ts$/.test(file) || /\/workers\/document-decode\.worker\.ts$/.test(file)) const canvasInteraction = paths.some((file) => /\/(canvas-input|view-geometry|canvas-selection-renderer|useCanvasViewPreview)/.test(file)) - const complexDocument = paths.some((file) => /\/(animation|animation-thumbnail|document|layer-operations|onion-skin|workspace|LayersPanel|PreviewPanel)/.test(file)) + const complexDocument = paths.some((file) => /\/(animation|animation-thumbnail|document|layer-operations|onion-skin|timelapse|workspace|LayersPanel|PreviewPanel)/.test(file)) const includeReleaseComplexSuite = options.releaseAudit === true const suites = [] @@ -80,6 +84,7 @@ export function classifyPerformanceAudit(files, options = {}) { largeSentinelSuite(), benchmarkSuite('selection', 'src/renderer/src/core/selection-performance.bench.ts'), benchmarkSuite('document-composite', 'src/renderer/src/core/document-performance.bench.ts'), + benchmarkSuite('project-format', 'src/renderer/src/core/project-format-performance.bench.ts'), { id: 'bundle', kind: 'bundle' }, { id: 'desktop', kind: 'desktop' }, ) @@ -94,10 +99,12 @@ export function classifyPerformanceAudit(files, options = {}) { suites.push(canvasSuite('canvas-complex', [1024], ['complex-draw', 'complex-undo', 'complex-playback'], includeReleaseComplexSuite ? 3 : 1)) } if (complexDocument) suites.push(benchmarkSuite('document-composite', 'src/renderer/src/core/document-performance.bench.ts')) + if (projectFormat || includeReleaseComplexSuite) suites.push(benchmarkSuite('project-format', 'src/renderer/src/core/project-format-performance.bench.ts')) if (selectionAlgorithm) suites.push(benchmarkSuite('selection', 'src/renderer/src/core/selection-performance.bench.ts')) suites.push({ id: 'bundle', kind: 'bundle' }) } else if (level === 'P2') { if (selectionAlgorithm) suites.push(benchmarkSuite('selection', 'src/renderer/src/core/selection-performance.bench.ts')) + else if (projectFormat) suites.push(benchmarkSuite('project-format', 'src/renderer/src/core/project-format-performance.bench.ts')) else if (canvasInteraction) suites.push(canvasSuite('canvas-interaction', [512], ['pan', 'zoom'])) else if (complexDocument) suites.push(canvasSuite('canvas-complex', [512], ['complex-draw', 'complex-undo', 'complex-playback'])) else suites.push({ id: 'uncovered', kind: 'uncovered' }) diff --git a/scripts/performance-scope-rules.test.mjs b/scripts/performance-scope-rules.test.mjs index df5efc8..201cec4 100644 --- a/scripts/performance-scope-rules.test.mjs +++ b/scripts/performance-scope-rules.test.mjs @@ -18,7 +18,7 @@ test('普通 UI、交互热点和高频渲染分别归入 P1、P2、P3', () => { test('依赖与构建配置归入 P4 并覆盖其他级别', () => { const result = classifyPerformanceImpact(['src/renderer/src/components/CanvasStage.tsx', 'pnpm-lock.yaml']) assert.equal(result.level, 'P4') - assert.deepEqual(result.suites.map((suite) => suite.id), ['canvas-standard', 'canvas-profile', 'canvas-complex', 'canvas-large-800', 'canvas-large-2048', 'canvas-large-4000', 'canvas-large-sentinel', 'selection', 'document-composite', 'bundle', 'desktop']) + assert.deepEqual(result.suites.map((suite) => suite.id), ['canvas-standard', 'canvas-profile', 'canvas-complex', 'canvas-large-800', 'canvas-large-2048', 'canvas-large-4000', 'canvas-large-sentinel', 'selection', 'document-composite', 'project-format', 'bundle', 'desktop']) assert.deepEqual(result.suites.find((suite) => suite.id === 'canvas-complex').sizes, [800, 1024]) assert.equal(result.suites.find((suite) => suite.id === 'canvas-profile').runtime, 'profile') }) @@ -30,9 +30,9 @@ test('仅修改 package 脚本不被误判为依赖升级', () => { test('发布审计至少运行 P3 并固定覆盖标准、Profiler、大画布和 1024 复杂工程', () => { const ordinaryRelease = classifyPerformanceAudit(['src/renderer/src/components/AboutDialog.tsx'], { minimumLevel: 'P3', releaseAudit: true }) assert.equal(ordinaryRelease.level, 'P3') - assert.deepEqual(ordinaryRelease.suites.map((suite) => suite.id), ['canvas-standard', 'canvas-profile', 'canvas-large-800', 'canvas-large-2048', 'canvas-large-4000', 'canvas-large-sentinel', 'canvas-complex', 'bundle']) + assert.deepEqual(ordinaryRelease.suites.map((suite) => suite.id), ['canvas-standard', 'canvas-profile', 'canvas-large-800', 'canvas-large-2048', 'canvas-large-4000', 'canvas-large-sentinel', 'canvas-complex', 'project-format', 'bundle']) assert.deepEqual(ordinaryRelease.suites[0].scenarios, ['pan', 'zoom', 'rotated-zoom', 'draw', 'shape', 'marquee', 'bucket-fill', 'gradient']) - assert.deepEqual(ordinaryRelease.suites.find((suite) => suite.id === 'canvas-large-4000').scenarios, ['large-pan', 'large-zoom', 'large-draw', 'large-shape', 'large-marquee', 'large-bucket-fill', 'large-gradient', 'large-detail-pan', 'large-detail-draw']) + assert.deepEqual(ordinaryRelease.suites.find((suite) => suite.id === 'canvas-large-4000').scenarios, ['large-pan', 'large-zoom', 'large-draw', 'large-shape', 'large-marquee', 'large-bucket-fill', 'large-gradient', 'large-detail-pan', 'large-detail-draw', 'large-detail-draw-timelapse']) const complexSuite = ordinaryRelease.suites.find((suite) => suite.id === 'canvas-complex') assert.deepEqual(complexSuite.sizes, [1024]) assert.deepEqual(complexSuite.scenarios, ['complex-draw', 'complex-undo', 'complex-playback']) @@ -40,4 +40,8 @@ test('发布审计至少运行 P3 并固定覆盖标准、Profiler、大画布 const animationRelease = classifyPerformanceAudit(['src/renderer/src/core/animation.ts'], { minimumLevel: 'P3' }) assert.deepEqual(animationRelease.suites.map((suite) => suite.id), ['canvas-standard', 'canvas-profile', 'canvas-large-800', 'canvas-large-2048', 'canvas-large-4000', 'canvas-large-sentinel', 'canvas-complex', 'document-composite', 'bundle']) + + const projectFormatAudit = classifyPerformanceAudit(['src/renderer/src/core/project-format.ts']) + assert.equal(projectFormatAudit.level, 'P3') + assert.deepEqual(projectFormatAudit.suites.map((suite) => suite.id), ['canvas-standard', 'canvas-profile', 'canvas-large-800', 'canvas-large-2048', 'canvas-large-4000', 'canvas-large-sentinel', 'project-format', 'bundle']) }) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1fff84c..2be448a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -63,7 +72,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -755,6 +764,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1080,6 +1100,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2116,7 +2137,7 @@ dependencies = [ [[package]] name = "moonsprite" -version = "0.1.0-dev.4" +version = "0.1.0-dev.5" dependencies = [ "arboard", "rfd", @@ -2126,7 +2147,9 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-single-instance", + "ttf-parser", "windows-sys 0.59.0", + "zip", ] [[package]] @@ -3557,6 +3580,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni", "libc", "log", @@ -4151,6 +4175,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "typeid" version = "1.0.3" @@ -5289,12 +5319,44 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d28c880..c6b590a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "moonsprite" -version = "0.1.0-dev.4" -description = "An original, open-source pixel art workstation for Windows." +version = "0.1.0-dev.5" +description = "An original, source-available pixel art workstation for Windows." authors = ["MoonSprite contributors"] edition = "2021" +license-file = "../LICENSE" [lib] name = "moonsprite_lib" @@ -13,13 +14,15 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["devtools"] } +tauri = { version = "2", features = ["devtools", "image-png"] } tauri-plugin-single-instance = "2" rfd = "0.15" serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = "0.35" arboard = "3.4" +zip = { version = "4", default-features = false, features = ["deflate"] } +ttf-parser = "0.25" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_System_DataExchange", "Win32_System_Memory"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Registry"] } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 9e4d1ad..07c4f5f 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 2192721..b50624c 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/256x256.png b/src-tauri/icons/256x256.png index 2192721..b50624c 100644 Binary files a/src-tauri/icons/256x256.png and b/src-tauri/icons/256x256.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 6d16c82..e431d92 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/512x512.png b/src-tauri/icons/512x512.png index fdb1cf1..70dfd7c 100644 Binary files a/src-tauri/icons/512x512.png and b/src-tauri/icons/512x512.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index f752710..d3b8577 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 03699eb..91d6d60 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index c2d5019..2a1e84a 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index aa133ff..8f310f0 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index f789665..d8da08e 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 3ab56f8..bd536c7 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 2f9577a..1ae0102 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 77d09b6..d9789cc 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index d62c459..03aeba8 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 1d8445f..d49679d 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 3dac5b4..5cbe75a 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index d422b0e..0d56b58 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 5cd0afb..16cf307 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index 1af9ad7..f0921d5 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 223405c..8fddf29 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index dc0264f..29bea44 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 1e2eb99..b53b1b1 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index b0527ad..543b028 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index c9e64a9..e823dc5 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index ba8f133..538661c 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 785cea0..44e142e 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index a0c8961..6e2282b 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 74e24ca..402fb8a 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index fdb1cf1..70dfd7c 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png index b85a1b3..4955886 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index bbc8df9..d7b2a1a 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png index bbc8df9..d7b2a1a 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png index 898dc26..2f914c9 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png index ea66353..3e90d0e 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 1df454a..16a637d 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png index 1df454a..16a637d 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png index d3845cd..65d2664 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png index bbc8df9..d7b2a1a 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 5187909..30251fd 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png index 5187909..30251fd 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png index afdf44d..198a6d0 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png index e889f44..eef09ee 100644 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png index afdf44d..198a6d0 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png index f298797..72f57e1 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png index 1f2c860..96f8ff7 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png index a90fa31..ab5a229 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index d4cf942..56567b8 100644 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/resources/background-presets/circles.png b/src-tauri/resources/background-presets/circles.png new file mode 100644 index 0000000..24aae6f Binary files /dev/null and b/src-tauri/resources/background-presets/circles.png differ diff --git a/src-tauri/resources/background-presets/diamond-nested.png b/src-tauri/resources/background-presets/diamond-nested.png new file mode 100644 index 0000000..3ba0eea Binary files /dev/null and b/src-tauri/resources/background-presets/diamond-nested.png differ diff --git a/src-tauri/resources/background-presets/diamond.png b/src-tauri/resources/background-presets/diamond.png new file mode 100644 index 0000000..bb595c1 Binary files /dev/null and b/src-tauri/resources/background-presets/diamond.png differ diff --git a/src-tauri/resources/background-presets/grid.png b/src-tauri/resources/background-presets/grid.png new file mode 100644 index 0000000..270c538 Binary files /dev/null and b/src-tauri/resources/background-presets/grid.png differ diff --git a/src-tauri/resources/background-presets/stripes.png b/src-tauri/resources/background-presets/stripes.png new file mode 100644 index 0000000..1cb952c Binary files /dev/null and b/src-tauri/resources/background-presets/stripes.png differ diff --git a/src-tauri/resources/fonts/README.md b/src-tauri/resources/fonts/README.md new file mode 100644 index 0000000..09e86ef --- /dev/null +++ b/src-tauri/resources/fonts/README.md @@ -0,0 +1,11 @@ +# Bundled pixel fonts + +MoonSprite seeds these fonts into the executable-root `Font` directory on first use. They are bundled as unmodified font files under the SIL Open Font License 1.1. + +| Font | Bundled file | Upstream | Native pixel size | +| --- | --- | --- | --- | +| Fusion Pixel Font 10px Proportional, Simplified Chinese | `moonsprite-builtin-fusion-pixel-10px-prop-zh-hans.woff2` | https://github.com/TakWolf/fusion-pixel-font | 10px | +| Silkscreen Regular | `moonsprite-builtin-silkscreen-regular.ttf` | https://fonts.google.com/specimen/Silkscreen | 8px | +| Tiny5 Regular | `moonsprite-builtin-tiny5-regular.ttf` | https://fonts.google.com/specimen/Tiny5 | 8px default in MoonSprite | + +The corresponding license texts are in `licenses/`. diff --git a/src-tauri/resources/fonts/licenses/Fusion-Pixel-Font-OFL-1.1.txt b/src-tauri/resources/fonts/licenses/Fusion-Pixel-Font-OFL-1.1.txt new file mode 100644 index 0000000..0427d1d --- /dev/null +++ b/src-tauri/resources/fonts/licenses/Fusion-Pixel-Font-OFL-1.1.txt @@ -0,0 +1,96 @@ +Fusion Pixel Font +https://github.com/TakWolf/fusion-pixel-font + +Copyright (c) 2022, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src-tauri/resources/fonts/licenses/Silkscreen-OFL-1.1.txt b/src-tauri/resources/fonts/licenses/Silkscreen-OFL-1.1.txt new file mode 100644 index 0000000..d26b0ed --- /dev/null +++ b/src-tauri/resources/fonts/licenses/Silkscreen-OFL-1.1.txt @@ -0,0 +1,93 @@ +Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src-tauri/resources/fonts/licenses/Tiny5-OFL-1.1.txt b/src-tauri/resources/fonts/licenses/Tiny5-OFL-1.1.txt new file mode 100644 index 0000000..843b33b --- /dev/null +++ b/src-tauri/resources/fonts/licenses/Tiny5-OFL-1.1.txt @@ -0,0 +1,93 @@ +Copyright 2022-2024 The Tiny5 Project Authors (https://github.com/Gissio/font_tiny5) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src-tauri/resources/fonts/moonsprite-builtin-fusion-pixel-10px-prop-zh-hans.woff2 b/src-tauri/resources/fonts/moonsprite-builtin-fusion-pixel-10px-prop-zh-hans.woff2 new file mode 100644 index 0000000..9bef9b9 Binary files /dev/null and b/src-tauri/resources/fonts/moonsprite-builtin-fusion-pixel-10px-prop-zh-hans.woff2 differ diff --git a/src-tauri/resources/fonts/moonsprite-builtin-silkscreen-regular.ttf b/src-tauri/resources/fonts/moonsprite-builtin-silkscreen-regular.ttf new file mode 100644 index 0000000..8caff81 Binary files /dev/null and b/src-tauri/resources/fonts/moonsprite-builtin-silkscreen-regular.ttf differ diff --git a/src-tauri/resources/fonts/moonsprite-builtin-tiny5-regular.ttf b/src-tauri/resources/fonts/moonsprite-builtin-tiny5-regular.ttf new file mode 100644 index 0000000..50e9fda Binary files /dev/null and b/src-tauri/resources/fonts/moonsprite-builtin-tiny5-regular.ttf differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a3e0bb4..d8015e0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,10 +9,12 @@ use std::{ use tauri::{AppHandle, DragDropEvent, Emitter, Manager, State, WindowEvent}; mod close_coordinator; +mod platform_background_presets; mod platform_brushes; mod platform_clipboard; mod platform_dialogs; mod platform_files; +mod platform_fonts; mod platform_gallery; mod platform_palette; mod platform_paths; @@ -110,10 +112,15 @@ pub fn run() { }) .manage(platform_recovery::RecoveryState::default()) .setup(|app| { + if let Some(window) = app.get_webview_window("main") { + let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png"))?; + window.set_icon(icon)?; + } let recovery_state = app.state::(); platform_recovery::initialize_session_marker(app.handle(), &recovery_state)?; let _ = platform_gallery::ensure_builtin_example(app.handle().clone()); let _ = platform_paths::export_directory(); + let _ = platform_background_presets::ensure_background_preset_folder(); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -128,7 +135,10 @@ pub fn run() { platform_dialogs::choose_directory, platform_files::file_exists, platform_files::read_binary, + platform_files::read_project_preview, + platform_files::cache_project_preview, platform_files::write_binary_atomic, + platform_files::write_project_incremental, platform_clipboard::write_clipboard_image, platform_clipboard::read_clipboard_text, platform_clipboard::read_clipboard_image, @@ -146,13 +156,22 @@ pub fn run() { platform_brushes::save_brush, platform_brushes::delete_brush, platform_brushes::open_brush_folder, + platform_fonts::list_fonts, + platform_fonts::list_system_fonts, + platform_fonts::import_font, + platform_fonts::import_system_font, + platform_fonts::delete_font, + platform_background_presets::list_background_presets, + platform_background_presets::open_background_preset_folder, platform_recovery::list_recoveries, platform_recovery::read_recovery, platform_recovery::write_recovery, platform_recovery::delete_recovery, platform_gallery::list_gallery_projects, + platform_gallery::list_folder_projects, platform_gallery::delete_gallery_project, platform_gallery::open_gallery_folder, + platform_gallery::open_directory, platform_gallery::ensure_builtin_example, platform_gallery::open_project_in_folder, platform_gallery::open_external_url, diff --git a/src-tauri/src/platform_background_presets.rs b/src-tauri/src/platform_background_presets.rs new file mode 100644 index 0000000..d02142a --- /dev/null +++ b/src-tauri/src/platform_background_presets.rs @@ -0,0 +1,185 @@ +use serde::Serialize; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::platform_paths::ensure_executable_subdirectory; +use crate::platform_storage::atomic_write; + +const PRESET_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "bmp", "gif"]; +const BUILTIN_SEED_MARKER: &str = ".moonsprite-background-presets"; +const BUILTIN_PRESETS: &[(&str, &[u8])] = &[ + ( + "grid.png", + include_bytes!("../resources/background-presets/grid.png"), + ), + ( + "stripes.png", + include_bytes!("../resources/background-presets/stripes.png"), + ), + ( + "diamond.png", + include_bytes!("../resources/background-presets/diamond.png"), + ), + ( + "diamond-nested.png", + include_bytes!("../resources/background-presets/diamond-nested.png"), + ), + ( + "circles.png", + include_bytes!("../resources/background-presets/circles.png"), + ), +]; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StoredBackgroundPreset { + id: String, + name: String, + file_path: String, + built_in: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BackgroundPresetListing { + directory_path: String, + presets: Vec, +} + +fn preset_dir() -> Result { + let directory = ensure_executable_subdirectory("BackgroundPresets", "背景图层预设")?; + seed_builtin_presets(&directory)?; + Ok(directory) +} + +fn seed_builtin_presets(directory: &Path) -> Result<(), String> { + let marker = directory.join(BUILTIN_SEED_MARKER); + if marker.is_file() { + return Ok(()); + } + for (name, bytes) in BUILTIN_PRESETS { + let destination = directory.join(name); + if !destination.exists() { + atomic_write(&destination, bytes)?; + } + } + atomic_write(&marker, b"1") +} + +fn is_preset_file(path: &Path) -> bool { + path.is_file() + && path + .metadata() + .map(|metadata| metadata.len() <= 32 * 1024 * 1024) + .unwrap_or(false) + && path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + PRESET_EXTENSIONS + .iter() + .any(|extension| value.eq_ignore_ascii_case(extension)) + }) +} + +fn readable_name(path: &Path) -> String { + path.file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Background preset") + .replace('-', " ") + .replace('_', " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn built_in_order(id: &str) -> usize { + BUILTIN_PRESETS + .iter() + .position(|(name, _)| *name == id) + .unwrap_or(usize::MAX) +} + +pub(crate) fn ensure_background_preset_folder() -> Result { + preset_dir() +} + +#[tauri::command] +pub(crate) fn list_background_presets() -> Result { + let directory = preset_dir()?; + let mut presets = fs::read_dir(&directory) + .map_err(|error| format!("无法读取背景图层预设文件夹:{error}"))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| is_preset_file(path)) + .filter_map(|path| { + let id = path.file_name()?.to_str()?.to_string(); + Some(StoredBackgroundPreset { + name: readable_name(&path), + file_path: path.to_string_lossy().to_string(), + built_in: built_in_order(&id) != usize::MAX, + id, + }) + }) + .collect::>(); + presets.sort_by(|left, right| { + built_in_order(&left.id) + .cmp(&built_in_order(&right.id)) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(BackgroundPresetListing { + directory_path: directory.to_string_lossy().to_string(), + presets, + }) +} + +#[tauri::command] +pub(crate) fn open_background_preset_folder() -> Result<(), String> { + let directory = preset_dir()?; + #[cfg(target_os = "windows")] + let mut command = std::process::Command::new("explorer.exe"); + #[cfg(target_os = "macos")] + let mut command = std::process::Command::new("open"); + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = std::process::Command::new("xdg-open"); + command + .arg(&directory) + .spawn() + .map_err(|error| format!("无法打开背景图层预设文件夹:{error}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{seed_builtin_presets, BUILTIN_PRESETS, BUILTIN_SEED_MARKER}; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn seeds_once_and_respects_user_deletions() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!("moonsprite-background-presets-{stamp}")); + fs::create_dir_all(&directory).unwrap(); + + seed_builtin_presets(&directory).unwrap(); + assert!(BUILTIN_PRESETS + .iter() + .all(|(name, _)| directory.join(name).is_file())); + assert!(directory.join(BUILTIN_SEED_MARKER).is_file()); + + let deleted = directory.join(BUILTIN_PRESETS[0].0); + fs::remove_file(&deleted).unwrap(); + seed_builtin_presets(&directory).unwrap(); + assert!(!deleted.exists()); + + let _ = fs::remove_dir_all(directory); + } +} diff --git a/src-tauri/src/platform_clipboard.rs b/src-tauri/src/platform_clipboard.rs index f18a925..d1d01a1 100644 --- a/src-tauri/src/platform_clipboard.rs +++ b/src-tauri/src/platform_clipboard.rs @@ -1,14 +1,7 @@ use arboard::{Clipboard, ImageData}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClipboardImage { - width: usize, - height: usize, - data: Vec, -} +use tauri::ipc::Response; #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -58,7 +51,7 @@ pub fn read_clipboard_text() -> Result, String> { } #[tauri::command] -pub fn read_clipboard_image() -> Result, String> { +pub fn read_clipboard_image() -> Result { let mut clipboard = Clipboard::new().map_err(|error| format!("无法访问系统剪贴板:{error}"))?; match clipboard.get_image() { Ok(image) => { @@ -66,13 +59,15 @@ pub fn read_clipboard_image() -> Result, String> { if image.bytes.len() != expected { return Err("系统剪贴板图像数据无效。".to_string()); } - Ok(Some(ClipboardImage { - width: image.width, - height: image.height, - data: image.bytes.into_owned(), - })) + let width = u32::try_from(image.width).map_err(|_| "系统剪贴板图像宽度无效。")?; + let height = u32::try_from(image.height).map_err(|_| "系统剪贴板图像高度无效。")?; + let mut payload = Vec::with_capacity(8 + expected); + payload.extend_from_slice(&width.to_le_bytes()); + payload.extend_from_slice(&height.to_le_bytes()); + payload.extend_from_slice(image.bytes.as_ref()); + Ok(Response::new(payload)) } - Err(arboard::Error::ContentNotAvailable) => Ok(None), + Err(arboard::Error::ContentNotAvailable) => Ok(Response::new(Vec::new())), Err(error) => Err(format!("无法读取系统剪贴板图像:{error}")), } } @@ -142,12 +137,18 @@ pub fn read_clipboard_image_size() -> Result, String> #[cfg(not(windows))] #[tauri::command] pub fn read_clipboard_image_size() -> Result, String> { - read_clipboard_image().map(|image| { - image.map(|image| ClipboardImageSize { - width: image.width, - height: image.height, - }) - }) + let mut clipboard = Clipboard::new().map_err(|error| format!("无法访问系统剪贴板:{error}"))?; + match clipboard.get_image() { + Ok(image) => { + image_bytes(image.width, image.height)?; + Ok(Some(ClipboardImageSize { + width: image.width, + height: image.height, + })) + } + Err(arboard::Error::ContentNotAvailable) => Ok(None), + Err(error) => Err(format!("无法读取系统剪贴板图像:{error}")), + } } #[cfg(test)] diff --git a/src-tauri/src/platform_files.rs b/src-tauri/src/platform_files.rs index 0f12be6..45b4c6f 100644 --- a/src-tauri/src/platform_files.rs +++ b/src-tauri/src/platform_files.rs @@ -1,5 +1,236 @@ -use crate::platform_storage::atomic_write; -use std::{fs, path::Path}; +use crate::platform_storage::{atomic_write, atomic_write_with}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + fs, + hash::{Hash, Hasher}, + io::{Cursor, Read, Seek, Write}, + path::{Path, PathBuf}, + time::UNIX_EPOCH, +}; +use tauri::{ + ipc::{Channel, InvokeBody, Request, Response}, + AppHandle, Manager, +}; + +const FILE_PATH_HEADER: &str = "x-moonsprite-file-path"; +const SOURCE_PATH_HEADER: &str = "x-moonsprite-source-path"; +const SAVE_PLAN_ENTRY: &str = ".moonsprite-save-plan.json"; + +#[derive(Debug, Deserialize)] +struct ProjectSaveReuseEntry { + path: String, + crc32: u32, +} + +#[derive(Debug, Deserialize)] +struct ProjectSavePlan { + version: u8, + entries: Vec, +} + +fn decode_file_path_header(value: &str) -> Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + decoded.push(bytes[index]); + index += 1; + continue; + } + if index + 2 >= bytes.len() { + return Err("Invalid encoded file path.".to_string()); + } + let hex = |byte: u8| match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + }; + let high = hex(bytes[index + 1]).ok_or_else(|| "Invalid encoded file path.".to_string())?; + let low = hex(bytes[index + 2]).ok_or_else(|| "Invalid encoded file path.".to_string())?; + decoded.push((high << 4) | low); + index += 3; + } + String::from_utf8(decoded).map_err(|_| "Invalid encoded file path.".to_string()) +} + +fn request_path(request: &Request<'_>, header: &str) -> Result { + let encoded = request + .headers() + .get(header) + .ok_or_else(|| "Missing file path.".to_string())? + .to_str() + .map_err(|_| "Invalid encoded file path.".to_string())?; + decode_file_path_header(encoded) +} + +fn raw_request_data<'a>(request: &'a Request<'_>) -> Result<&'a [u8], String> { + match request.body() { + InvokeBody::Raw(data) => Ok(data), + InvokeBody::Json(_) => Err("Binary file data is required.".to_string()), + } +} + +fn merge_project_archive( + source: R, + patch: &[u8], + output: W, +) -> Result<(), String> { + let mut source_archive = zip::ZipArchive::new(source).map_err(|error| error.to_string())?; + let mut patch_archive = + zip::ZipArchive::new(Cursor::new(patch)).map_err(|error| error.to_string())?; + let plan = { + let mut entry = patch_archive + .by_name(SAVE_PLAN_ENTRY) + .map_err(|error| error.to_string())?; + let mut bytes = Vec::with_capacity(entry.size().min(1024 * 1024) as usize); + entry + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + serde_json::from_slice::(&bytes).map_err(|error| error.to_string())? + }; + if plan.version != 1 || plan.entries.is_empty() { + return Err("Invalid incremental save plan.".to_string()); + } + let mut names = HashSet::new(); + for entry in &plan.entries { + if entry.path.is_empty() + || entry.path == SAVE_PLAN_ENTRY + || !names.insert(entry.path.clone()) + { + return Err("Invalid incremental save entry.".to_string()); + } + } + let mut writer = zip::ZipWriter::new(output); + for index in 0..patch_archive.len() { + let entry = patch_archive + .by_index(index) + .map_err(|error| error.to_string())?; + if entry.name() == SAVE_PLAN_ENTRY { + continue; + } + if names.contains(entry.name()) { + return Err("Incremental save entry conflicts with patch data.".to_string()); + } + writer + .raw_copy_file(entry) + .map_err(|error| error.to_string())?; + } + for reuse in plan.entries { + let entry = source_archive + .by_name(&reuse.path) + .map_err(|error| error.to_string())?; + if entry.crc32() != reuse.crc32 { + return Err("Incremental save source changed.".to_string()); + } + writer + .raw_copy_file(entry) + .map_err(|error| error.to_string())?; + } + writer + .finish() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPreview { + preview: Vec, + width: u32, + height: u32, + color_mode: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryReadProgress { + bytes_read: u64, + total_bytes: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectPreviewCacheMetadata { + source_size: u64, + source_modified_at: u64, + width: u32, + height: u32, + color_mode: String, +} + +fn project_preview_cache_paths( + app: &AppHandle, + file_path: &str, +) -> Result<(PathBuf, PathBuf), String> { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + file_path.to_lowercase().hash(&mut hasher); + let directory = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("project-previews"); + fs::create_dir_all(&directory).map_err(|error| error.to_string())?; + let key = format!("{:016x}", hasher.finish()); + Ok(( + directory.join(format!("{key}.json")), + directory.join(format!("{key}.png")), + )) +} + +fn source_fingerprint(path: &Path) -> Result<(u64, u64), String> { + let metadata = fs::metadata(path).map_err(|error| error.to_string())?; + let modified_at = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or_default(); + Ok((metadata.len(), modified_at)) +} + +fn supports_preview_cache(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "moonsprite" | "ase" | "aseprite" | "png" | "jpg" | "jpeg" | "webp" | "bmp" | "gif" + ) + }) +} + +fn write_project_preview_cache( + app: &AppHandle, + file_path: &str, + preview: &[u8], + width: u32, + height: u32, + color_mode: &str, +) -> Result<(), String> { + if preview.is_empty() + || width == 0 + || height == 0 + || !matches!(color_mode, "rgba" | "indexed" | "grayscale") + { + return Err("工程缩略图数据无效。".to_string()); + } + let path = Path::new(file_path); + let (source_size, source_modified_at) = source_fingerprint(path)?; + let (cache_metadata_path, cache_preview_path) = project_preview_cache_paths(app, file_path)?; + let cache = ProjectPreviewCacheMetadata { + source_size, + source_modified_at, + width, + height, + color_mode: color_mode.to_string(), + }; + let metadata = serde_json::to_vec(&cache).map_err(|error| error.to_string())?; + atomic_write(&cache_preview_path, preview)?; + atomic_write(&cache_metadata_path, &metadata) +} #[tauri::command] pub fn file_exists(file_path: String) -> bool { @@ -7,11 +238,255 @@ pub fn file_exists(file_path: String) -> bool { } #[tauri::command] -pub fn read_binary(file_path: String) -> Result, String> { - fs::read(file_path).map_err(|error| error.to_string()) +pub fn read_binary( + file_path: String, + on_progress: Channel, +) -> Result { + let mut file = fs::File::open(&file_path).map_err(|error| error.to_string())?; + let total_bytes = file.metadata().map_err(|error| error.to_string())?.len(); + let capacity = usize::try_from(total_bytes).unwrap_or(0); + let mut output = Vec::with_capacity(capacity); + let mut chunk = vec![0_u8; 256 * 1024]; + let mut bytes_read = 0_u64; + let _ = on_progress.send(BinaryReadProgress { + bytes_read, + total_bytes, + }); + loop { + let count = file.read(&mut chunk).map_err(|error| error.to_string())?; + if count == 0 { + break; + } + output.extend_from_slice(&chunk[..count]); + bytes_read = bytes_read.saturating_add(count as u64); + let _ = on_progress.send(BinaryReadProgress { + bytes_read, + total_bytes, + }); + } + Ok(Response::new(output)) +} + +#[tauri::command] +pub fn read_project_preview(app: AppHandle, file_path: String) -> Result { + let path = Path::new(&file_path); + if !supports_preview_cache(path) { + return Err("该文件类型不支持首页缩略图缓存。".to_string()); + } + let (source_size, source_modified_at) = source_fingerprint(path)?; + let (cache_metadata_path, cache_preview_path) = project_preview_cache_paths(&app, &file_path)?; + if let Ok(cache_metadata_bytes) = fs::read(&cache_metadata_path) { + if let Ok(cache) = + serde_json::from_slice::(&cache_metadata_bytes) + { + if cache.source_size == source_size && cache.source_modified_at == source_modified_at { + if let Ok(preview) = fs::read(&cache_preview_path) { + if !preview.is_empty() { + return Ok(ProjectPreview { + preview, + width: cache.width, + height: cache.height, + color_mode: cache.color_mode, + }); + } + } + } + } + } + if !path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("moonsprite")) + { + return Err("该文件尚未生成首页缩略图。".to_string()); + } + let file = fs::File::open(path).map_err(|error| error.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| error.to_string())?; + let manifest = { + let mut entry = archive + .by_name("manifest.json") + .map_err(|error| format!("无法读取工程清单:{error}"))?; + let mut bytes = Vec::with_capacity(entry.size().min(256 * 1024) as usize); + entry + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + serde_json::from_slice::(&bytes).map_err(|error| error.to_string())? + }; + let document = manifest + .get("document") + .ok_or_else(|| "工程清单缺少文档信息。".to_string())?; + let width = document + .get("width") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "工程宽度无效。".to_string())?; + let height = document + .get("height") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "工程高度无效。".to_string())?; + let color_mode = document + .get("colorMode") + .and_then(|value| value.as_str()) + .filter(|value| matches!(*value, "rgba" | "indexed" | "grayscale")) + .ok_or_else(|| "工程颜色模式无效。".to_string())? + .to_string(); + let preview = { + let mut entry = archive + .by_name("preview.png") + .map_err(|error| format!("无法读取工程缩略图:{error}"))?; + let mut bytes = Vec::with_capacity(entry.size().min(4 * 1024 * 1024) as usize); + entry + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + bytes + }; + if preview.is_empty() { + return Err("工程缩略图为空。".to_string()); + } + let _ = write_project_preview_cache(&app, &file_path, &preview, width, height, &color_mode); + Ok(ProjectPreview { + preview, + width, + height, + color_mode, + }) +} + +#[tauri::command] +pub fn cache_project_preview( + app: AppHandle, + file_path: String, + preview: Vec, + width: u32, + height: u32, + color_mode: String, +) -> Result<(), String> { + if !supports_preview_cache(Path::new(&file_path)) { + return Err("该文件类型不支持首页缩略图缓存。".to_string()); + } + write_project_preview_cache(&app, &file_path, &preview, width, height, &color_mode) +} + +#[tauri::command] +pub fn write_binary_atomic(request: Request<'_>) -> Result<(), String> { + let file_path = request_path(&request, FILE_PATH_HEADER)?; + let data = raw_request_data(&request)?; + atomic_write(Path::new(&file_path), data) } #[tauri::command] -pub fn write_binary_atomic(file_path: String, data: Vec) -> Result<(), String> { - atomic_write(Path::new(&file_path), &data) +pub fn write_project_incremental(request: Request<'_>) -> Result<(), String> { + let file_path = request_path(&request, FILE_PATH_HEADER)?; + let source_path = request_path(&request, SOURCE_PATH_HEADER)?; + let patch = raw_request_data(&request)?; + let source = fs::File::open(&source_path).map_err(|error| error.to_string())?; + atomic_write_with(Path::new(&file_path), |output| { + merge_project_archive(source, patch, output) + }) +} + +#[cfg(test)] +mod tests { + use super::{ + decode_file_path_header, merge_project_archive, supports_preview_cache, SAVE_PLAN_ENTRY, + }; + use std::io::{Cursor, Read, Write}; + use std::path::Path; + use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter}; + + fn archive(entries: &[(&str, &[u8])]) -> Vec { + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + for (name, data) in entries { + writer + .start_file( + *name, + SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated), + ) + .unwrap(); + writer.write_all(data).unwrap(); + } + writer.finish().unwrap().into_inner() + } + + #[test] + fn decodes_unicode_windows_file_paths() { + assert_eq!( + decode_file_path_header("D%3A%5CMoonSprite%5C%E5%9B%BE%E6%A0%87.moonsprite").unwrap(), + "D:\\MoonSprite\\图标.moonsprite" + ); + } + + #[test] + fn rejects_invalid_path_encoding() { + assert!(decode_file_path_header("D%3A%5Cbroken%ZZ").is_err()); + assert!(decode_file_path_header("D%3A%5Cbroken%").is_err()); + } + + #[test] + fn accepts_every_supported_home_thumbnail_format() { + for file_name in [ + "project.moonsprite", + "sprite.ase", + "sprite.aseprite", + "sprite.png", + "sprite.jpg", + "sprite.jpeg", + "sprite.webp", + "sprite.bmp", + "sprite.gif", + ] { + assert!(supports_preview_cache(Path::new(file_name)), "{file_name}"); + } + assert!(!supports_preview_cache(Path::new("notes.txt"))); + } + + #[test] + fn merges_changed_entries_with_raw_reused_blocks() { + let source = archive(&[ + ("layers/a.rgba", b"unchanged pixels"), + ("manifest.json", b"old"), + ]); + let crc32 = ZipArchive::new(Cursor::new(&source)) + .unwrap() + .by_name("layers/a.rgba") + .unwrap() + .crc32(); + let plan = + format!(r#"{{"version":1,"entries":[{{"path":"layers/a.rgba","crc32":{crc32}}}]}}"#); + let patch = archive(&[ + ("manifest.json", b"new"), + (SAVE_PLAN_ENTRY, plan.as_bytes()), + ]); + + let mut merged = Cursor::new(Vec::new()); + merge_project_archive(Cursor::new(&source), &patch, &mut merged).unwrap(); + let mut output = ZipArchive::new(Cursor::new(merged.into_inner())).unwrap(); + let mut manifest = String::new(); + output + .by_name("manifest.json") + .unwrap() + .read_to_string(&mut manifest) + .unwrap(); + let mut pixels = String::new(); + output + .by_name("layers/a.rgba") + .unwrap() + .read_to_string(&mut pixels) + .unwrap(); + assert_eq!(manifest, "new"); + assert_eq!(pixels, "unchanged pixels"); + assert!(output.by_name(SAVE_PLAN_ENTRY).is_err()); + } + + #[test] + fn rejects_reuse_when_the_source_crc_changed() { + let source = archive(&[("layers/a.rgba", b"changed externally")]); + let plan = br#"{"version":1,"entries":[{"path":"layers/a.rgba","crc32":1}]}"#; + let patch = archive(&[("manifest.json", b"new"), (SAVE_PLAN_ENTRY, plan)]); + assert!( + merge_project_archive(Cursor::new(&source), &patch, Cursor::new(Vec::new())).is_err() + ); + } } diff --git a/src-tauri/src/platform_fonts.rs b/src-tauri/src/platform_fonts.rs new file mode 100644 index 0000000..8d958ab --- /dev/null +++ b/src-tauri/src/platform_fonts.rs @@ -0,0 +1,438 @@ +use rfd::FileDialog; +use serde::Serialize; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::platform_paths::{ensure_executable_subdirectory, executable_directory}; +use crate::platform_storage::atomic_write; + +const FONT_EXTENSIONS: &[&str] = &["ttf", "ttc", "otf", "woff", "woff2"]; +const BUILTIN_FONT_SEED_VERSION: &str = "2"; +const BUILTIN_FONT_SEED_MARKER: &str = ".moonsprite-builtin-fonts"; +const RETIRED_BUILTIN_FONTS: &[&str] = &["moonsprite-builtin-press-start-2p-regular.ttf"]; +const BUILTIN_FONTS: &[(&str, &[u8])] = &[ + ( + "moonsprite-builtin-fusion-pixel-10px-prop-zh-hans.woff2", + include_bytes!( + "../resources/fonts/moonsprite-builtin-fusion-pixel-10px-prop-zh-hans.woff2" + ), + ), + ( + "moonsprite-builtin-silkscreen-regular.ttf", + include_bytes!("../resources/fonts/moonsprite-builtin-silkscreen-regular.ttf"), + ), + ( + "moonsprite-builtin-tiny5-regular.ttf", + include_bytes!("../resources/fonts/moonsprite-builtin-tiny5-regular.ttf"), + ), +]; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StoredFont { + id: String, + family: String, + file_path: String, + imported: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FontListing { + directory_path: String, + fonts: Vec, +} + +fn font_dir() -> Result { + let directory = ensure_executable_subdirectory("Font", "字体")?; + let legacy = executable_directory()?.join("fonts"); + if legacy.is_dir() { + for source in fs::read_dir(&legacy) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| is_font_file(path)) + { + let Some(name) = source.file_name() else { + continue; + }; + let destination = directory.join(name); + if destination.exists() { + continue; + } + if let Ok(bytes) = fs::read(&source) { + let _ = atomic_write(&destination, &bytes); + } + } + } + seed_builtin_fonts(&directory)?; + Ok(directory) +} + +fn seed_builtin_fonts(directory: &Path) -> Result<(), String> { + let marker = directory.join(BUILTIN_FONT_SEED_MARKER); + let marker_version = fs::read_to_string(&marker).ok(); + if marker_version.as_deref() == Some(BUILTIN_FONT_SEED_VERSION) + && BUILTIN_FONTS + .iter() + .all(|(name, _)| directory.join(name).is_file()) + { + return Ok(()); + } + for name in RETIRED_BUILTIN_FONTS { + let path = directory.join(name); + if path.is_file() { + fs::remove_file(&path) + .map_err(|error| format!("Unable to remove retired bundled font: {}", error))?; + } + } + for (name, bytes) in BUILTIN_FONTS { + let destination = directory.join(name); + if !destination.exists() { + atomic_write(&destination, bytes)?; + } + } + atomic_write(&marker, BUILTIN_FONT_SEED_VERSION.as_bytes()) +} + +fn is_font_file(path: &Path) -> bool { + path.is_file() + && path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + FONT_EXTENSIONS + .iter() + .any(|extension| value.eq_ignore_ascii_case(extension)) + }) +} + +fn fallback_font_family(path: &Path) -> String { + path.file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("MoonSprite Font") + .replace('_', " ") + .replace('-', " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn bundled_font_family(path: &Path) -> Option<&'static str> { + match path.file_name()?.to_str()? { + "moonsprite-builtin-fusion-pixel-10px-prop-zh-hans.woff2" => { + Some("Fusion Pixel 10px Prop Zh_hans") + } + "moonsprite-builtin-silkscreen-regular.ttf" => Some("Silkscreen"), + "moonsprite-builtin-tiny5-regular.ttf" => Some("Tiny5"), + _ => None, + } +} + +fn font_family(path: &Path) -> String { + if let Some(family) = bundled_font_family(path) { + return family.to_string(); + } + let bytes = fs::read(path).ok(); + let parsed = bytes.as_deref().and_then(|data| { + let face_count = ttf_parser::fonts_in_collection(data).unwrap_or(1); + (0..face_count).find_map(|index| { + let face = ttf_parser::Face::parse(data, index).ok()?; + face.names() + .into_iter() + .filter(|name| { + name.name_id == ttf_parser::name_id::TYPOGRAPHIC_FAMILY + || name.name_id == ttf_parser::name_id::FAMILY + }) + .filter_map(|name| name.to_string()) + .map(|name| name.trim().to_string()) + .find(|name| !name.is_empty()) + }) + }); + parsed.unwrap_or_else(|| fallback_font_family(path)) +} + +fn stored_font(path: &Path, imported: bool) -> Result { + let id = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("字体文件名无效:{}", path.display()))? + .to_string(); + Ok(StoredFont { + id, + family: font_family(path), + file_path: path.to_string_lossy().to_string(), + imported, + }) +} + +fn font_dialog() -> FileDialog { + FileDialog::new().add_filter("Font files", FONT_EXTENSIONS) +} + +fn copy_font_into_library(source: &Path) -> Result { + if !is_font_file(source) { + return Err("请选择 TTF、TTC、OTF、WOFF 或 WOFF2 字体文件。".to_string()); + } + let directory = font_dir()?; + let original_name = source + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("font.ttf"); + let mut destination = directory.join(original_name); + let bytes = fs::read(source).map_err(|error| format!("无法读取字体:{error}"))?; + if destination.exists() { + if fs::read(&destination).ok().as_deref() == Some(bytes.as_slice()) { + return stored_font(&destination, true); + } + let stem = source + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("font"); + let extension = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("ttf"); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_millis()) + .unwrap_or_default(); + destination = directory.join(format!("{stem}-{stamp}.{extension}")); + } + atomic_write(&destination, &bytes)?; + stored_font(&destination, true) +} + +#[cfg(target_os = "windows")] +fn windows_system_fonts() -> Result, String> { + use std::{ + ffi::OsString, + os::windows::ffi::OsStringExt, + ptr::{null, null_mut}, + }; + use windows_sys::Win32::{ + Foundation::{ERROR_MORE_DATA, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS}, + System::Registry::{ + RegCloseKey, RegEnumValueW, RegOpenKeyExW, HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, + KEY_READ, REG_EXPAND_SZ, REG_SZ, + }, + }; + + let mut fonts = Vec::new(); + let windows_fonts = std::env::var_os("WINDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("C:\\Windows")) + .join("Fonts"); + let key_path = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts" + .encode_utf16() + .chain(Some(0)) + .collect::>(); + for root in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] { + let mut key: HKEY = null_mut(); + let status = unsafe { RegOpenKeyExW(root, key_path.as_ptr(), 0, KEY_READ, &mut key) }; + if status != ERROR_SUCCESS { + continue; + } + for index in 0_u32.. { + let mut name = vec![0_u16; 512]; + let mut name_len = name.len() as u32; + let mut data = vec![0_u16; 2048]; + let mut data_len = (data.len() * size_of::()) as u32; + let mut kind = 0_u32; + let status = unsafe { + RegEnumValueW( + key, + index, + name.as_mut_ptr(), + &mut name_len, + null(), + &mut kind, + data.as_mut_ptr() as *mut u8, + &mut data_len, + ) + }; + if status == ERROR_NO_MORE_ITEMS { + break; + } + if status == ERROR_MORE_DATA { + continue; + } + if status != ERROR_SUCCESS || !matches!(kind, REG_SZ | REG_EXPAND_SZ) { + continue; + } + let family = OsString::from_wide(&name[..name_len as usize]) + .to_string_lossy() + .trim() + .trim_end_matches(" (TrueType)") + .trim_end_matches(" (OpenType)") + .to_string(); + data.truncate(data_len as usize / size_of::()); + let mut file_name = OsString::from_wide(&data) + .to_string_lossy() + .trim_end_matches('\0') + .to_string(); + if kind == REG_EXPAND_SZ { + if let Some(windows) = std::env::var_os("WINDIR") { + file_name = file_name.replace("%WINDIR%", &windows.to_string_lossy()) + } + } + let candidate = PathBuf::from(&file_name); + let path = if candidate.is_absolute() { + candidate + } else { + windows_fonts.join(candidate) + }; + if family.is_empty() || !is_font_file(&path) { + continue; + } + fonts.push(StoredFont { + id: format!("system:{family}"), + family, + file_path: path.to_string_lossy().to_string(), + imported: false, + }); + } + unsafe { RegCloseKey(key) }; + } + fonts.sort_by(|left, right| left.family.to_lowercase().cmp(&right.family.to_lowercase())); + fonts.dedup_by(|left, right| left.family.eq_ignore_ascii_case(&right.family)); + Ok(fonts) +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_fonts() -> Result, String> { + Ok(Vec::new()) +} + +#[tauri::command] +pub(crate) fn list_fonts() -> Result { + let directory = font_dir()?; + let mut fonts = fs::read_dir(&directory) + .map_err(|error| format!("无法读取字体文件夹:{error}"))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| is_font_file(path)) + .filter_map(|path| stored_font(&path, true).ok()) + .collect::>(); + fonts.sort_by(|left, right| { + left.family + .cmp(&right.family) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(FontListing { + directory_path: directory.to_string_lossy().to_string(), + fonts, + }) +} + +#[tauri::command] +pub(crate) fn list_system_fonts() -> Result, String> { + windows_system_fonts() +} + +#[tauri::command] +pub(crate) fn import_font() -> Result, String> { + let Some(source) = font_dialog().pick_file() else { + return Ok(None); + }; + copy_font_into_library(&source).map(Some) +} + +#[tauri::command] +pub(crate) fn import_system_font(id: String) -> Result { + let font = windows_system_fonts()? + .into_iter() + .find(|font| font.id == id) + .ok_or_else(|| "找不到选择的系统字体。".to_string())?; + copy_font_into_library(Path::new(&font.file_path)) +} + +fn safe_font_id(id: &str) -> Result<&str, String> { + if id.is_empty() + || id.contains(['/', '\\']) + || id.contains("..") + || !FONT_EXTENSIONS + .iter() + .any(|extension| id.to_ascii_lowercase().ends_with(&format!(".{extension}"))) + { + return Err("无效的字体文件 ID。".to_string()); + } + Ok(id) +} + +#[tauri::command] +pub(crate) fn delete_font(id: String) -> Result<(), String> { + let path = font_dir()?.join(safe_font_id(&id)?); + if !path.exists() { + return Err("字体文件不存在。".to_string()); + } + fs::remove_file(path).map_err(|error| format!("无法删除字体:{error}")) +} + +#[cfg(test)] +mod tests { + use super::{ + bundled_font_family, fallback_font_family, seed_builtin_fonts, BUILTIN_FONTS, + BUILTIN_FONT_SEED_MARKER, BUILTIN_FONT_SEED_VERSION, RETIRED_BUILTIN_FONTS, + }; + use std::{ + fs, + path::Path, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn derives_a_readable_family_alias_from_the_file_name() { + assert_eq!( + fallback_font_family(Path::new("C:/Fonts/Moon_Pixel-Bold.ttf")), + "Moon Pixel Bold" + ); + } + + #[test] + fn exposes_stable_families_for_bundled_fonts() { + assert_eq!( + bundled_font_family(Path::new(BUILTIN_FONTS[0].0)), + Some("Fusion Pixel 10px Prop Zh_hans") + ); + assert_eq!( + bundled_font_family(Path::new(BUILTIN_FONTS[1].0)), + Some("Silkscreen") + ); + } + + #[test] + fn seeds_bundled_fonts_and_restores_missing_builtins() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!("moonsprite-font-seed-{stamp}")); + fs::create_dir_all(&directory).unwrap(); + + seed_builtin_fonts(&directory).unwrap(); + assert!(BUILTIN_FONTS + .iter() + .all(|(name, _)| directory.join(name).is_file())); + assert_eq!( + fs::read_to_string(directory.join(BUILTIN_FONT_SEED_MARKER)).unwrap(), + BUILTIN_FONT_SEED_VERSION + ); + + let removed = directory.join(BUILTIN_FONTS[0].0); + fs::remove_file(&removed).unwrap(); + seed_builtin_fonts(&directory).unwrap(); + assert!(removed.is_file()); + + let retired = directory.join(RETIRED_BUILTIN_FONTS[0]); + fs::write(&retired, b"retired").unwrap(); + fs::write(directory.join(BUILTIN_FONT_SEED_MARKER), b"1").unwrap(); + seed_builtin_fonts(&directory).unwrap(); + assert!(!retired.exists()); + + let _ = fs::remove_dir_all(directory); + } +} diff --git a/src-tauri/src/platform_gallery.rs b/src-tauri/src/platform_gallery.rs index 92d758f..ed45772 100644 --- a/src-tauri/src/platform_gallery.rs +++ b/src-tauri/src/platform_gallery.rs @@ -27,6 +27,17 @@ const BUILTIN_EXAMPLE_FILE_NAME: &str = "示例.moonsprite"; const BUILTIN_EXAMPLE: &[u8] = include_bytes!("../resources/示例.moonsprite"); const BUILTIN_EXAMPLE_MARKER_VERSION: &str = "3"; const BUILTIN_EXAMPLE_DELETED: &str = "deleted"; +const HOME_FOLDER_FILE_EXTENSIONS: &[&str] = &[ + "moonsprite", + "ase", + "aseprite", + "png", + "jpg", + "jpeg", + "webp", + "bmp", + "gif", +]; fn app_data_dir(app: &AppHandle) -> Result { app.path().app_data_dir().map_err(|error| error.to_string()) @@ -60,21 +71,29 @@ pub(crate) fn ensure_builtin_example(app: AppHandle) -> Result, S Ok(Some(example_path.to_string_lossy().to_string())) } -#[tauri::command] -pub(crate) fn list_gallery_projects() -> Result { - let directory = gallery_dir()?; +fn path_has_extension(path: &Path, extensions: &[&str]) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .map(|value| { + extensions + .iter() + .any(|extension| value.eq_ignore_ascii_case(extension)) + }) + .unwrap_or(false) +} + +fn list_projects_in_directory( + directory: &Path, + extensions: &[&str], + include_extension: bool, + label: &str, +) -> Result, String> { let mut projects = Vec::new(); - for entry in fs::read_dir(&directory).map_err(|error| format!("无法读取图库:{error}"))? + for entry in fs::read_dir(directory).map_err(|error| format!("无法读取{label}:{error}"))? { - let entry = entry.map_err(|error| format!("无法读取图库项目:{error}"))?; + let entry = entry.map_err(|error| format!("无法读取{label}项目:{error}"))?; let path = entry.path(); - if !path.is_file() - || path - .extension() - .and_then(|value| value.to_str()) - .map(|value| !value.eq_ignore_ascii_case("moonsprite")) - .unwrap_or(true) - { + if !path.is_file() || !path_has_extension(&path, extensions) { continue; } let metadata = entry @@ -86,11 +105,13 @@ pub(crate) fn list_gallery_projects() -> Result { .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) .map(|value| value.as_millis().min(u64::MAX as u128) as u64) .unwrap_or_default(); - let file_name = path - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("未命名工程") - .to_string(); + let file_name = if include_extension { + path.file_name().and_then(|value| value.to_str()) + } else { + path.file_stem().and_then(|value| value.to_str()) + } + .unwrap_or("未命名工程") + .to_string(); projects.push(GalleryProject { file_path: path.to_string_lossy().to_string(), file_name, @@ -103,6 +124,27 @@ pub(crate) fn list_gallery_projects() -> Result { .cmp(&left.modified_at) .then_with(|| left.file_name.cmp(&right.file_name)) }); + Ok(projects) +} + +#[tauri::command] +pub(crate) fn list_gallery_projects() -> Result { + let directory = gallery_dir()?; + let projects = list_projects_in_directory(&directory, &["moonsprite"], false, "图库")?; + Ok(GalleryListing { + directory_path: directory.to_string_lossy().to_string(), + projects, + }) +} + +#[tauri::command] +pub(crate) fn list_folder_projects(directory_path: String) -> Result { + let directory = PathBuf::from(directory_path.trim()); + if !directory.is_dir() { + return Err("所选文件夹不存在或无法访问。".to_string()); + } + let projects = + list_projects_in_directory(&directory, HOME_FOLDER_FILE_EXTENSIONS, true, "文件夹")?; Ok(GalleryListing { directory_path: directory.to_string_lossy().to_string(), projects, @@ -137,6 +179,10 @@ pub(crate) fn delete_gallery_project(app: AppHandle, file_name: String) -> Resul #[tauri::command] pub(crate) fn open_gallery_folder() -> Result<(), String> { let directory = gallery_dir()?; + launch_directory(&directory, "图库") +} + +fn launch_directory(directory: &Path, label: &str) -> Result<(), String> { #[cfg(target_os = "windows")] let mut command = std::process::Command::new("explorer.exe"); #[cfg(target_os = "macos")] @@ -144,12 +190,21 @@ pub(crate) fn open_gallery_folder() -> Result<(), String> { #[cfg(all(unix, not(target_os = "macos")))] let mut command = std::process::Command::new("xdg-open"); command - .arg(&directory) + .arg(directory) .spawn() - .map_err(|error| format!("无法打开图库文件夹:{error}"))?; + .map_err(|error| format!("无法打开{label}文件夹:{error}"))?; Ok(()) } +#[tauri::command] +pub(crate) fn open_directory(directory_path: String) -> Result<(), String> { + let directory = PathBuf::from(directory_path.trim()); + if !directory.is_dir() { + return Err("所选文件夹不存在或无法访问。".to_string()); + } + launch_directory(&directory, "所选") +} + #[tauri::command] pub(crate) fn open_project_in_folder(file_path: String) -> Result<(), String> { let path = PathBuf::from(&file_path); @@ -159,17 +214,7 @@ pub(crate) fn open_project_in_folder(file_path: String) -> Result<(), String> { let directory = path .parent() .ok_or_else(|| "无法确定工程文件所在文件夹。".to_string())?; - #[cfg(target_os = "windows")] - let mut command = std::process::Command::new("explorer.exe"); - #[cfg(target_os = "macos")] - let mut command = std::process::Command::new("open"); - #[cfg(all(unix, not(target_os = "macos")))] - let mut command = std::process::Command::new("xdg-open"); - command - .arg(&directory) - .spawn() - .map_err(|error| format!("无法打开工程文件夹:{error}"))?; - Ok(()) + launch_directory(directory, "工程") } #[tauri::command] @@ -193,3 +238,41 @@ pub(crate) fn open_external_url(url: String) -> Result<(), String> { .map_err(|error| format!("无法打开作者链接:{error}"))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::list_folder_projects; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn lists_supported_home_folder_files_without_recursing() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!("moonsprite-home-folder-{stamp}")); + fs::create_dir_all(directory.join("nested")).unwrap(); + fs::write(directory.join("sprite.png"), [1]).unwrap(); + fs::write(directory.join("project.moonsprite"), [2]).unwrap(); + fs::write(directory.join("animation.ASEPRITE"), [3]).unwrap(); + fs::write(directory.join("notes.txt"), [4]).unwrap(); + fs::write(directory.join("nested").join("ignored.png"), [5]).unwrap(); + + let listing = list_folder_projects(directory.to_string_lossy().to_string()).unwrap(); + let mut names = listing + .projects + .into_iter() + .map(|project| project.file_name) + .collect::>(); + names.sort(); + + assert_eq!( + names, + vec!["animation.ASEPRITE", "project.moonsprite", "sprite.png"] + ); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src-tauri/src/platform_palette.rs b/src-tauri/src/platform_palette.rs index 13d3103..7ca84db 100644 --- a/src-tauri/src/platform_palette.rs +++ b/src-tauri/src/platform_palette.rs @@ -52,27 +52,31 @@ pub(crate) struct PaletteListing { const DEFAULT_PALETTES: &[(&str, &str)] = &[ ( - "moonlight-12.palette.json", - include_str!("../../palettes/moonlight-12.palette.json"), + "universal-spectrum-48.palette.json", + include_str!("../../palettes/universal-spectrum-48.palette.json"), ), ( - "tiny-console-16.palette.json", - include_str!("../../palettes/tiny-console-16.palette.json"), + "soft-spectrum-48.palette.json", + include_str!("../../palettes/soft-spectrum-48.palette.json"), ), ( - "forest-dusk-12.palette.json", - include_str!("../../palettes/forest-dusk-12.palette.json"), + "vivid-spectrum-48.palette.json", + include_str!("../../palettes/vivid-spectrum-48.palette.json"), ), ( - "sunset-12.palette.json", - include_str!("../../palettes/sunset-12.palette.json"), - ), - ( - "mono-10.palette.json", - include_str!("../../palettes/mono-10.palette.json"), + "deep-spectrum-48.palette.json", + include_str!("../../palettes/deep-spectrum-48.palette.json"), ), ]; +const LEGACY_DEFAULT_PALETTE_FILES: &[&str] = &[ + "moonlight-12.palette.json", + "tiny-console-16.palette.json", + "forest-dusk-12.palette.json", + "sunset-12.palette.json", + "mono-10.palette.json", +]; + fn chrono_like_timestamp() -> u128 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -87,6 +91,9 @@ fn palette_dir() -> Result { for (file_name, _) in DEFAULT_PALETTES { let _ = fs::remove_file(directory.join(file_name)); } + for file_name in LEGACY_DEFAULT_PALETTE_FILES { + let _ = fs::remove_file(directory.join(file_name)); + } Ok(directory) } diff --git a/src-tauri/src/platform_recovery.rs b/src-tauri/src/platform_recovery.rs index 0137464..8617acb 100644 --- a/src-tauri/src/platform_recovery.rs +++ b/src-tauri/src/platform_recovery.rs @@ -1,9 +1,16 @@ use serde::{Deserialize, Serialize}; -use std::{fs, path::PathBuf, sync::Mutex}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, +}; use tauri::{AppHandle, Manager, State}; use crate::platform_storage::atomic_write; +const MILLIS_PER_DAY: u128 = 86_400_000; + #[derive(Default)] pub(crate) struct RecoveryState { previous_session_crashed: Mutex, @@ -37,12 +44,78 @@ fn chrono_like_timestamp() -> u128 { } fn unix_timestamp_millis() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + SystemTime::now() + .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or_default() } +fn recovery_timestamp_millis(value: &str) -> Option { + let numeric = value.trim().parse::().ok()?; + if numeric >= 100_000_000_000_000_000 { + Some(numeric / 1_000_000) + } else if numeric >= 100_000_000_000_000 { + Some(numeric / 1_000) + } else if numeric > 0 && numeric < 100_000_000_000 { + Some(numeric * 1_000) + } else { + Some(numeric) + } +} + +fn file_modified_millis(path: &Path) -> Option { + fs::metadata(path) + .ok()? + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis()) +} + +fn remove_if_exists(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + +fn purge_expired_recoveries( + directory: &Path, + retention_days: u32, + now_millis: u128, +) -> Result<(), String> { + let retention_days = retention_days.clamp(1, 365) as u128; + let retention_millis = retention_days.saturating_mul(MILLIS_PER_DAY); + for entry in fs::read_dir(directory).map_err(|error| error.to_string())? { + let path = entry.map_err(|error| error.to_string())?.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(id) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + if safe_recovery_id(id).is_err() { + continue; + } + let updated_at = fs::read_to_string(&path) + .ok() + .and_then(|value| serde_json::from_str::(&value).ok()) + .and_then(|record| recovery_timestamp_millis(&record.updated_at)) + .or_else(|| file_modified_millis(&path)); + let Some(updated_at) = updated_at else { + continue; + }; + if now_millis.saturating_sub(updated_at) <= retention_millis { + continue; + } + remove_if_exists(&directory.join(format!("{id}.moonsprite")))?; + remove_if_exists(&path)?; + } + Ok(()) +} + pub(crate) fn mark_session(app: &AppHandle, clean: bool) -> Result<(), String> { let payload = serde_json::json!({ "clean": clean, "updatedAt": chrono_like_timestamp() }); atomic_write(&session_marker(app)?, payload.to_string().as_bytes()) @@ -119,7 +192,11 @@ fn safe_recovery_id(id: &str) -> Result<&str, String> { pub(crate) fn list_recoveries( app: AppHandle, state: State<'_, RecoveryState>, + retention_days: u32, ) -> Result, String> { + let directory = recovery_dir(&app)?; + fs::create_dir_all(&directory).map_err(|error| error.to_string())?; + purge_expired_recoveries(&directory, retention_days, unix_timestamp_millis())?; if !*state .previous_session_crashed .lock() @@ -127,8 +204,6 @@ pub(crate) fn list_recoveries( { return Ok(Vec::new()); } - let directory = recovery_dir(&app)?; - fs::create_dir_all(&directory).map_err(|error| error.to_string())?; let mut records = Vec::new(); for entry in fs::read_dir(directory).map_err(|error| error.to_string())? { let path = entry.map_err(|error| error.to_string())?.path(); @@ -184,3 +259,64 @@ pub(crate) fn delete_recovery(app: AppHandle, id: String) -> Result<(), String> let _ = fs::remove_file(directory.join(format!("{id}.json"))); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn temporary_recovery_directory() -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock should be after epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "moonsprite-recovery-test-{}-{suffix}", + std::process::id() + )); + fs::create_dir_all(&directory).expect("temporary recovery directory should be created"); + directory + } + + fn write_test_recovery(directory: &Path, id: &str, updated_at: u128) { + let record = RecoveryRecord { + id: id.to_string(), + name: id.to_string(), + updated_at: updated_at.to_string(), + }; + fs::write( + directory.join(format!("{id}.json")), + serde_json::to_vec(&record).expect("record should serialize"), + ) + .expect("record should be written"); + fs::write(directory.join(format!("{id}.moonsprite")), [1, 2, 3]) + .expect("recovery data should be written"); + } + + #[test] + fn parses_legacy_recovery_timestamp_units() { + expect_timestamp("1700000000", 1_700_000_000_000); + expect_timestamp("1700000000000", 1_700_000_000_000); + expect_timestamp("1700000000000000", 1_700_000_000_000); + expect_timestamp("1700000000000000000", 1_700_000_000_000); + } + + fn expect_timestamp(value: &str, expected: u128) { + assert_eq!(recovery_timestamp_millis(value), Some(expected)); + } + + #[test] + fn purges_only_recoveries_older_than_the_retention_window() { + let directory = temporary_recovery_directory(); + let now = 1_800_000_000_000_u128; + write_test_recovery(&directory, "expired", now - 8 * MILLIS_PER_DAY); + write_test_recovery(&directory, "recent", now - 6 * MILLIS_PER_DAY); + + purge_expired_recoveries(&directory, 7, now).expect("cleanup should succeed"); + + assert!(!directory.join("expired.json").exists()); + assert!(!directory.join("expired.moonsprite").exists()); + assert!(directory.join("recent.json").exists()); + assert!(directory.join("recent.moonsprite").exists()); + fs::remove_dir_all(directory).expect("temporary recovery directory should be removed"); + } +} diff --git a/src-tauri/src/platform_storage.rs b/src-tauri/src/platform_storage.rs index 5c09c1b..78b0ae4 100644 --- a/src-tauri/src/platform_storage.rs +++ b/src-tauri/src/platform_storage.rs @@ -4,6 +4,28 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +pub fn atomic_write_with( + path: &Path, + write: impl FnOnce(&mut fs::File) -> Result<(), String>, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + + let temporary = path.with_extension(format!("{}.tmp", temporary_suffix())); + let result = (|| { + let mut file = fs::File::create(&temporary).map_err(|error| error.to_string())?; + write(&mut file)?; + file.sync_all().map_err(|error| error.to_string())?; + replace_file(&temporary, path).map_err(|error| error.to_string()) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(error); + } + Ok(()) +} + fn temporary_suffix() -> String { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -59,27 +81,14 @@ fn replace_file(source: &Path, target: &Path) -> io::Result<()> { /// Writes a file through a sibling temporary file and replaces the target in one operation. pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - - let temporary = path.with_extension(format!("{}.tmp", temporary_suffix())); - let result = (|| { - let mut file = fs::File::create(&temporary)?; - std::io::Write::write_all(&mut file, data)?; - file.sync_all()?; - replace_file(&temporary, path) - })(); - if let Err(error) = result { - let _ = fs::remove_file(&temporary); - return Err(error.to_string()); - } - Ok(()) + atomic_write_with(path, |file| { + std::io::Write::write_all(file, data).map_err(|error| error.to_string()) + }) } #[cfg(test)] mod tests { - use super::atomic_write; + use super::{atomic_write, atomic_write_with}; use std::{ fs, time::{SystemTime, UNIX_EPOCH}, @@ -109,4 +118,17 @@ mod tests { assert_eq!(fs::read(&path).unwrap(), b"content"); let _ = fs::remove_dir_all(path.parent().unwrap()); } + + #[test] + fn failed_streaming_write_preserves_the_existing_target() { + let path = test_path("stream-failure.bin"); + atomic_write(&path, b"original").unwrap(); + let result = atomic_write_with(&path, |file| { + std::io::Write::write_all(file, b"partial").map_err(|error| error.to_string())?; + Err("write failed".to_string()) + }); + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), b"original"); + let _ = fs::remove_file(path); + } } diff --git a/src-tauri/src/platform_workspaces.rs b/src-tauri/src/platform_workspaces.rs index 7a4aa91..e77b1a5 100644 --- a/src-tauri/src/platform_workspaces.rs +++ b/src-tauri/src/platform_workspaces.rs @@ -114,14 +114,17 @@ fn workspace_from_file( fn built_in_workspace() -> StoredWorkspace { let layout = serde_json::json!({ - "panelDocks": { "color": "left", "palette": "left", "layers": "right", "preview": "right" }, + "panelDocks": { "color": "left", "palette": "left", "layers": "bottom", "preview": "bottom" }, "panelVisibility": { "color": true, "palette": true, "layers": true, "preview": true }, "inspectorWidth": 300, "leftDockWidth": 280, - "bottomDockHeight": 180, - "toolRailSide": "left", + "bottomDockHeight": 220, + "inspectorWidthRatio": 0.20833333333333334, + "leftDockWidthRatio": 0.19444444444444445, + "bottomDockHeightRatio": 0.275, + "toolRailSide": "right", "previewOpen": true, - "inspectorLayout": "{\"order\":[\"palette\",\"color\",\"layers\",\"preview\"],\"sizes\":{\"color\":330,\"palette\":620,\"layers\":560,\"preview\":220},\"bottomWidths\":{\"color\":280,\"palette\":280,\"layers\":320,\"preview\":280}}", + "inspectorLayout": "{\"order\":[\"palette\",\"color\",\"layers\",\"preview\"],\"verticalWeights\":{\"color\":330,\"palette\":620,\"layers\":560,\"preview\":220},\"bottomWeights\":{\"color\":280,\"palette\":280,\"layers\":720,\"preview\":280}}", "colorSquareDock": "left", "colorSquareAnchor": "end", "floatingPanels": { "color": null, "palette": null, "layers": null, "preview": null }, @@ -171,6 +174,8 @@ fn read_default_workspace(path: &Path) -> Result { } // The built-in workspace is editable, but keeps its stable user-facing name. workspace.name = "默认工作区".to_string(); + // Preserve the current layout while keeping Reset aligned with this build. + workspace.initial_layout = built_in_workspace().initial_layout; Ok(workspace) } @@ -257,13 +262,13 @@ pub(crate) fn save_workspace( } else { directory.join(format!("{workspace_id}.workspace.json")) }; - let initial_layout = if path.is_file() { + let initial_layout = if built_in { + built_in_workspace().initial_layout + } else if path.is_file() { let existing: WorkspaceDiskFile = serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?) .map_err(|error| format!("无法读取现有工作区 {}:{error}", path.display()))?; existing.initial_layout.unwrap_or(existing.layout) - } else if built_in { - built_in_workspace().initial_layout } else { layout.clone() }; @@ -311,3 +316,47 @@ pub(crate) fn open_workspace_folder() -> Result<(), String> { .map_err(|error| format!("无法打开工作区文件夹:{error}"))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn refreshes_built_in_reset_baseline_without_replacing_current_layout() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should follow the Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "moonsprite-default-workspace-{}-{suffix}.json", + std::process::id() + )); + let legacy_layout = serde_json::json!({ + "panelDocks": { "color": "left", "palette": "left", "layers": "right", "preview": "right" }, + "toolRailSide": "left" + }); + let file = WorkspaceDiskFile { + schema_version: 1, + id: "builtin-default".to_string(), + name: "旧默认工作区".to_string(), + updated_at: String::new(), + layout: legacy_layout.clone(), + initial_layout: Some(legacy_layout.clone()), + }; + fs::write( + &path, + serde_json::to_vec(&file).expect("workspace should encode"), + ) + .expect("temporary workspace should be writable"); + + let result = read_default_workspace(&path); + let _ = fs::remove_file(&path); + let workspace = result.expect("default workspace should load"); + + assert_eq!(workspace.layout, legacy_layout); + assert_eq!(workspace.initial_layout["panelDocks"]["layers"], "bottom"); + assert_eq!(workspace.initial_layout["panelDocks"]["preview"], "bottom"); + assert_eq!(workspace.initial_layout["toolRailSide"], "right"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cac2534..97696c1 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.4", + "version": "0.1.0-dev.5", "identifier": "art.moonpx.moonsprite", "build": { "beforeDevCommand": "pnpm dev:web", @@ -39,7 +39,10 @@ "shortDescription": "Windows pixel art editor", "resources": { "thumbnail-provider/target/release/moonsprite_thumbnail.dll": "moonsprite_thumbnail.dll", - "resources/moonsprite-file.ico": "moonsprite-file.ico" + "resources/moonsprite-file.ico": "moonsprite-file.ico", + "resources/fonts/licenses/Fusion-Pixel-Font-OFL-1.1.txt": "font-licenses/Fusion-Pixel-Font-OFL-1.1.txt", + "resources/fonts/licenses/Silkscreen-OFL-1.1.txt": "font-licenses/Silkscreen-OFL-1.1.txt", + "resources/fonts/licenses/Tiny5-OFL-1.1.txt": "font-licenses/Tiny5-OFL-1.1.txt" }, "fileAssociations": [ { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 07add02..c97d387 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,28 +3,34 @@ import { createPortal } from 'react-dom' import { CheckCircle2, ExternalLink, FileOutput, GitFork } from 'lucide-react' 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' +import type { ColorMode, ImageResizeInterpolation, StoredWorkspace, TextCelData, WorkspaceLayout } from '@shared/types' import type { AdjustmentKind } from '@/core/adjustments' import { compositePixelWithLayerColor, getActiveLayer, isLayerEffectivelyVisible, readLayerColorAt } from '@/core/document' import { blendOver, packColor, unpackColor } from '@/core/raster' import type { PanelDock, WorkspacePanelId } from '@/components/WorkspacePanels' import { AppMenuBar } from '@/components/app/AppMenuBar' -import { DocumentTabs } from '@/components/app/DocumentTabs' +import { DocumentTabs, type DocumentTabDockDebugState } from '@/components/app/DocumentTabs' import { EditorStatusBar } from '@/components/app/EditorStatusBar' import { BrushDynamicsTelemetryCapture } from '@/components/app/BrushDynamicsTelemetryCapture' import { EditorWorkspaceShell } from '@/components/app/EditorWorkspaceShell' +import { FloatingDocumentWindow } from '@/components/app/FloatingDocumentWindow' +import { resolveFloatingDocumentReturnTarget } from '@/components/app/floating-document-return' import { preloadCanvasStage } from '@/components/app/EditorCanvasHost' +import type { QuickCommandSettingsTarget } from '@/components/app/quick-command-registry' import { TOOL_DEFINITIONS } from '@/components/app/editor-tools' import { publishCanvasResizePreview } from '@/core/canvas-resize-preview' +import { detectDocumentPixelScale } from '@/core/image-scale-detection' import { appCoordinatorRenderKey } from '@/core/app-render-keys' -import { createDocumentPaneLayout, documentPaneContains, documentPaneLeafIds, insertDocumentPane, moveDocumentPane, removeDocumentPane, resolveDocumentPanePreviewLayout, type DocumentPaneDirection, type DocumentPaneNode, type DocumentPanePlacement } from '@/core/document-pane-layout' +import { detachDocumentPaneWorkspace, documentPaneContains, documentPaneLeafIds, moveDocumentPane, removeDocumentPane, splitDocumentPaneFromTab, type DocumentPaneDirection, type DocumentPaneNode, type DocumentPanePlacement } from '@/core/document-pane-layout' import { NewDocumentDialog } from '@/components/NewDocumentDialog' import { CanvasResizeDialog } from '@/components/CanvasResizeDialog' import { ColorReplacementDialog } from '@/components/ColorReplacementDialog' import { ImageResizeDialog } from '@/components/ImageResizeDialog' import { OutlineDialog } from '@/components/OutlineDialog' +import { OpenProgressOverlay } from '@/components/OpenProgressOverlay' +import { SaveProgressOverlay } from '@/components/SaveProgressOverlay' import { AdjustmentDialog } from '@/components/dialogs/AdjustmentDialog' -import { PreferencesDialog } from '@/components/dialogs/PreferencesDialog' +import { PreferencesDialog, type PreferenceSection } from '@/components/dialogs/PreferencesDialog' import { SaveAsDialog } from '@/components/dialogs/SaveAsDialog' import { ShortcutDialog } from '@/components/dialogs/ShortcutDialog' import { FutureRoadmapDialog } from '@/components/FutureRoadmapDialog' @@ -32,25 +38,36 @@ import { LatestReleaseDialog } from '@/components/LatestReleaseDialog' import { GridSettingsDialog } from '@/components/GridSettingsDialog' import { ProjectInfoDialog } from '@/components/ProjectInfoDialog' import { TimelapseDialog } from '@/components/TimelapseDialog' +import { TextToolDialog } from '@/components/TextToolDialog' +import { WorkspaceManagerDialog } from '@/components/WorkspaceManagerDialog' +import { publishTextToolPreview, TEXT_TOOL_DIALOG_EVENT, type TextToolDialogDetail } from '@/components/text-tool-events' +import { DialogHeader } from '@/components/DialogHeader' +import { FormField } from '@/components/FormField' import { NumberInput } from '@/components/NumberInput' import { ModalShell } from '@/components/ModalShell' import { PixelUtilityIcon } from '@/components/PixelUtilityIcon' +import { TextInput } from '@/components/TextInput' import { ThemedSelect } from '@/components/ThemedSelect' -import { COMMAND_SCOPE_EVENT, resolveCopyCommand, resolveDeleteCommand, shouldHandleGlobalSelectionEnter, shouldTriggerDeleteCommand, type EditorCommandScope } from '@/core/command-context' +import { COMMAND_SCOPE_EVENT, resolveCopyCommand, resolveDeleteCommand, shouldHandleAnimationPlaybackShortcut, shouldHandleGlobalSelectionEnter, shouldTriggerDeleteCommand, type EditorCommandScope } from '@/core/command-context' import { formatBytes } from '@/core/resource-policy' import { adjacentFormInput } from '@/core/form-focus' +import { saveProgress } from '@/core/save-progress' import { startDocumentDropService } from '@/platform/document-drop-service' import { APP_CHANNEL_LABEL } from '@/core/app-meta' +import moonspriteLogo from '@/assets/moonsprite-logo.svg' +import { cloneTextCelData, normalizeTextCelData, rasterizeText } from '@/core/text-raster' import { getRecentProjects, type RecentProject } from '@/core/home-history' -import { RECENT_EXPORTS_CHANGED_EVENT, loadExportPresets, loadRecentExportPaths, parentDirectoryFromPath, saveExportPresets, withExportFileExtension, type ExportPreset } from '@/core/export-settings' -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' +import { RECENT_EXPORTS_CHANGED_EVENT, loadDocumentExportSettings, loadExportPresets, loadRecentExportPaths, parentDirectoryFromPath, saveExportPresets, withExportFileExtension, type ExportPreset } from '@/core/export-settings' +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, saveEditorPreferences, type RelativeLuminanceScope } from '@/core/file-preferences' import { applyThemeToDocument } from '@/core/theme' -import { DEFAULT_SHORTCUTS, deriveShortcutConflicts, keyboardEventKey, loadShortcuts, normalizeShortcut, saveShortcuts as persistShortcuts, shortcutText } from '@/core/shortcuts' +import { DEFAULT_SHORTCUTS, deriveShortcutConflicts, keyboardEventKey, loadShortcuts, normalizeShortcut, saveShortcuts as persistShortcuts, shortcutReleasedByEvent, shortcutText } from '@/core/shortcuts' +import { beginPaletteSamplingShortcut, endPaletteSamplingShortcut } from '@/core/palette-sampling-shortcut' import { readStoredString, writeStoredString } from '@/core/storage' +import type { FloatingPosition } from '@/core/panel-preferences' import { applyCursorPreferences } from '@/platform/cursor-theme' import { applyToolIconScale, applyUiScale } from '@/platform/ui-scale' -import { ACTIVE_WORKSPACE_STORAGE_KEY, BOTTOM_DOCK_HEIGHT_STORAGE_KEY, COLOR_SQUARE_ANCHOR_STORAGE_KEY, COLOR_SQUARE_DOCK_STORAGE_KEY, constrainBottomDockHeight, constrainInspectorWidth, constrainLeftDockWidth, 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, type SaveAsOptions, useWorkspace } from '@/store/workspace' +import { ACTIVE_WORKSPACE_STORAGE_KEY, BOTTOM_DOCK_HEIGHT_RATIO_STORAGE_KEY, BOTTOM_DOCK_HEIGHT_STORAGE_KEY, COLOR_SQUARE_ANCHOR_STORAGE_KEY, COLOR_SQUARE_DOCK_STORAGE_KEY, constrainBottomDockHeight, constrainInspectorWidth, constrainLeftDockWidth, DEFAULT_BOTTOM_DOCK_HEIGHT_RATIO, DEFAULT_INSPECTOR_WIDTH_RATIO, DEFAULT_LEFT_DOCK_WIDTH_RATIO, DEFAULT_PANEL_DOCKS, dockSizeFromRatio, dockSizeRatio, FLOATING_PANEL_STORAGE_KEYS, INSPECTOR_LAYOUT_STORAGE_KEY, INSPECTOR_WIDTH_RATIO_STORAGE_KEY, INSPECTOR_WIDTH_STORAGE_KEY, LEFT_DOCK_WIDTH_RATIO_STORAGE_KEY, LEFT_DOCK_WIDTH_STORAGE_KEY, PANEL_DOCKS_STORAGE_KEY, resolveDockSizeRatio, 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, type SaveAsOptions, type TextCelPreview, type TextLayerDraftTarget, useWorkspace } from '@/store/workspace' import { useI18n } from '@/components/I18nProvider' import './styles.css' @@ -62,17 +79,49 @@ const defaultShortcuts: Record = { ...DEFAULT_SHORTCUTS } type ToolRailSide = 'left' | 'right' type AdvancedMode = 'tool-options' | 'canvas-only' +interface FloatingDocumentEntry { + documentId: string + initialPosition: FloatingPosition + pinned: boolean +} + +const createFloatingDocumentPosition = (documentId: string, anchor: { x: number; y: number }): FloatingPosition => { + const source = [...document.querySelectorAll('[data-document-pane-id]')] + .find((element) => element.dataset.documentPaneId === documentId) + ?? document.querySelector('.stage-wrap') + const sourceBounds = source?.getBoundingClientRect() + const width = Math.min(Math.max(280, window.innerWidth - 16), Math.max(360, Math.min(720, (sourceBounds?.width ?? 720) * 0.72))) + const height = Math.min(Math.max(200, window.innerHeight - 16), Math.max(240, Math.min(560, (sourceBounds?.height ?? 560) * 0.72))) + const maxX = Math.max(4, window.innerWidth - width - 4) + const maxY = Math.max(4, window.innerHeight - height - 4) + return { + x: Math.max(4, Math.min(maxX, anchor.x - 48)), + y: Math.max(4, Math.min(maxY, anchor.y - 14)), + width, + height + } +} + 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 workspaceDockParentSize = (workArea: HTMLElement | null): { width: number; height: number } => { + const editorBounds = workArea?.parentElement?.getBoundingClientRect() + const workBounds = workArea?.getBoundingClientRect() + return { + width: Math.max(1, editorBounds?.width ?? window.innerWidth), + height: Math.max(1, workBounds?.height ?? window.innerHeight - 99) + } +} + const defaultPanelDocks: Record = { ...DEFAULT_PANEL_DOCKS } const defaultInspectorLayout = JSON.stringify({ order: ['palette', 'color', 'layers', 'preview'], - sizes: { color: 330, palette: 620, layers: 560, preview: 220 }, - bottomWidths: { color: 280, palette: 280, layers: 720, preview: 280 } + verticalWeights: { color: 330, palette: 620, layers: 560, preview: 220 }, + bottomWeights: { color: 280, palette: 280, layers: 720, preview: 280 } }) const createBuiltInDefaultWorkspace = (name: string): StoredWorkspace => ({ id: 'builtin-default', @@ -85,8 +134,11 @@ const createBuiltInDefaultWorkspace = (name: string): StoredWorkspace => ({ panelVisibility: { color: true, palette: true, layers: true, preview: true }, inspectorWidth: 300, leftDockWidth: 280, - bottomDockHeight: 180, - toolRailSide: 'left', + bottomDockHeight: 220, + inspectorWidthRatio: DEFAULT_INSPECTOR_WIDTH_RATIO, + leftDockWidthRatio: DEFAULT_LEFT_DOCK_WIDTH_RATIO, + bottomDockHeightRatio: DEFAULT_BOTTOM_DOCK_HEIGHT_RATIO, + toolRailSide: 'right', previewOpen: true, inspectorLayout: defaultInspectorLayout, colorSquareDock: 'left', @@ -99,8 +151,11 @@ const createBuiltInDefaultWorkspace = (name: string): StoredWorkspace => ({ panelVisibility: { color: true, palette: true, layers: true, preview: true }, inspectorWidth: 300, leftDockWidth: 280, - bottomDockHeight: 180, - toolRailSide: 'left', + bottomDockHeight: 220, + inspectorWidthRatio: DEFAULT_INSPECTOR_WIDTH_RATIO, + leftDockWidthRatio: DEFAULT_LEFT_DOCK_WIDTH_RATIO, + bottomDockHeightRatio: DEFAULT_BOTTOM_DOCK_HEIGHT_RATIO, + toolRailSide: 'right', previewOpen: true, inspectorLayout: defaultInspectorLayout, colorSquareDock: 'left', @@ -139,6 +194,7 @@ export default function App() { const [outlineOpen, setOutlineOpen] = useState(false) const [colorReplacementOpen, setColorReplacementOpen] = useState(false) const [preferencesOpen, setPreferencesOpen] = useState(false) + const [preferencesInitialSection, setPreferencesInitialSection] = useState('general') const [shortcutOpen, setShortcutOpen] = useState(false) const [shortcuts, setShortcuts] = useState>(loadShortcuts) const [adjustmentOpen, setAdjustmentOpen] = useState(false) @@ -154,12 +210,24 @@ export default function App() { const [saveAsOpen, setSaveAsOpen] = useState(false) const [workspaceSaveOpen, setWorkspaceSaveOpen] = useState(false) const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false) + const [textToolRequest, setTextToolRequest] = useState(null) + const textPreviewSurfaceRef = useRef(null) + const textLayerDraftRef = useRef<(TextLayerDraftTarget & { documentId: string }) | null>(null) + const openPreferences = useCallback((section: PreferenceSection = 'general'): void => { + setPreferencesInitialSection(section) + setPreferencesOpen(true) + }, []) + const openQuickCommandPreferences = useCallback((): void => openPreferences('quickCommands'), [openPreferences]) + const openQuickCommandSettings = useCallback((target: QuickCommandSettingsTarget): void => { + if (target === 'grid') setGridSettingsOpen(true) + else openPreferences('appearance') + }, [openPreferences]) const [workspaceSaveName, setWorkspaceSaveName] = useState('') const [savedWorkspaces, setSavedWorkspaces] = useState([]) const [activeWorkspaceId, setActiveWorkspaceId] = useState(null) const [workspaceDirectory, setWorkspaceDirectory] = useState('') const [workspaceBusy, setWorkspaceBusy] = useState(false) - const [exportForm, setExportForm] = useState({ name: 'MoonSprite-export.png', format: 'png-auto', scalePercent: 100, directory: 'exports', gifFrameRange: 'all', gifDirection: 'forward' }) + const [exportForm, setExportForm] = useState({ name: 'MoonSprite-export.png', format: 'png-auto', scalePercent: 100, directory: 'exports', target: 'document', gifFrameRange: 'all', gifDirection: 'forward' }) const [presetName, setPresetName] = useState('') const [presets, setPresets] = useState(loadExportPresets) const [exportPathMenuOpen, setExportPathMenuOpen] = useState(false) @@ -171,12 +239,14 @@ export default function App() { const [openMenu, setOpenMenu] = useState(null) const [recentFiles, setRecentFiles] = useState(getRecentProjects) const [panelVisibility, setPanelVisibility] = useState>(loadPanelVisibility) + const [popupPanelId, setPopupPanelId] = useState(null) const [homeOpen, setHomeOpen] = useState(false) const [relativeLuminanceScope, setRelativeLuminanceScope] = useState(() => parseRelativeLuminanceScope(readStoredString(RELATIVE_LUMINANCE_SCOPE_KEY))) const [runtimePreferences, setRuntimePreferences] = useState(loadEditorPreferences) const [advancedMode, setAdvancedMode] = useState(null) const [advancedModeNotice, setAdvancedModeNotice] = useState(null) const [advancedModeNoticeShortcut, setAdvancedModeNoticeShortcut] = useState('') + const initialDockParentSize = workspaceDockParentSize(null) const [inspectorWidth, setInspectorWidth] = useState(() => loadInspectorWidth(window.innerWidth)) const [panelDocks, setPanelDocks] = useState>(loadPanelDocks) const [bottomLayersHeight, setBottomLayersHeight] = useState(loadBottomDockHeight) @@ -187,15 +257,19 @@ export default function App() { const [toolRailDockPreview, setToolRailDockPreview] = useState(null) const [workspaceLayoutRevision, setWorkspaceLayoutRevision] = useState(0) const [documentPaneLayout, setDocumentPaneLayout] = useState(null) - const [documentPanePreview, setDocumentPanePreview] = useState(null) + const [documentPaneDockDebug, setDocumentPaneDockDebug] = useState(null) const [paneOnlyDocumentIds, setPaneOnlyDocumentIds] = useState([]) + const [workspaceDocumentId, setWorkspaceDocumentId] = useState(() => useWorkspace.getState().activeId) + const [floatingDocuments, setFloatingDocuments] = useState([]) const resizeStart = useRef<{ x: number; width: number } | null>(null) const bottomLayersResizeStart = useRef<{ y: number; height: number } | null>(null) const bottomLayersHeightRef = useRef(bottomLayersHeight) const preferredBottomLayersHeightRef = useRef(bottomLayersHeight) + const bottomLayersHeightRatioRef = useRef(resolveDockSizeRatio(readStoredString(BOTTOM_DOCK_HEIGHT_RATIO_STORAGE_KEY), bottomLayersHeight, initialDockParentSize.height, DEFAULT_BOTTOM_DOCK_HEIGHT_RATIO)) const leftDockResizeStart = useRef<{ x: number; width: number } | null>(null) const leftDockWidthRef = useRef(leftDockWidth) const preferredLeftDockWidthRef = useRef(leftDockWidth) + const leftDockWidthRatioRef = useRef(resolveDockSizeRatio(readStoredString(LEFT_DOCK_WIDTH_RATIO_STORAGE_KEY), leftDockWidth, initialDockParentSize.width, DEFAULT_LEFT_DOCK_WIDTH_RATIO)) const toolRailDrag = useRef<{ startX: number; startY: number; moved: boolean; target: ToolRailSide } | null>(null) const activeWorkspaceRef = useRef(null) const workspaceApplyInProgress = useRef(false) @@ -207,12 +281,104 @@ export default function App() { const workAreaRef = useRef(null) const inspectorWidthRef = useRef(inspectorWidth) const preferredInspectorWidthRef = useRef(inspectorWidth) + const inspectorWidthRatioRef = useRef(resolveDockSizeRatio(readStoredString(INSPECTOR_WIDTH_RATIO_STORAGE_KEY), inspectorWidth, initialDockParentSize.width, DEFAULT_INSPECTOR_WIDTH_RATIO)) const closeInProgress = useRef(false) const session = workspace.sessions.find((item) => item.document.id === workspace.activeId) ?? null + const floatingDocumentIds = useMemo(() => floatingDocuments.map((item) => item.documentId), [floatingDocuments]) + const hiddenDocumentIds = useMemo(() => [...new Set([...paneOnlyDocumentIds, ...floatingDocumentIds])], [floatingDocumentIds, paneOnlyDocumentIds]) + + useEffect(() => setPopupPanelId(null), [homeOpen, session?.document.id]) + + useEffect(() => { + const openTextDialog = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.documentId) setTextToolRequest(detail) + } + window.addEventListener(TEXT_TOOL_DIALOG_EVENT, openTextDialog) + return () => window.removeEventListener(TEXT_TOOL_DIALOG_EVENT, openTextDialog) + }, []) + + const textToolInitial = useMemo | undefined>(() => { + if (!textToolRequest) return undefined + const target = workspace.sessions.find((item) => item.document.id === textToolRequest.documentId) + if (!target) return undefined + if (!textToolRequest.layerId || !textToolRequest.frameId) return { + color: { ...target.primaryColor }, + ...(textToolRequest.width ? { boxWidth: textToolRequest.width } : {}), + ...(textToolRequest.height ? { boxHeight: textToolRequest.height } : {}) + } + const cel = target?.document.animation?.cels.find((candidate) => candidate.layerId === textToolRequest.layerId && candidate.frameId === textToolRequest.frameId) + return cel?.text ? cloneTextCelData(cel.text) : { color: { ...target.primaryColor } } + }, [coordinatorRenderKey, textToolRequest, workspace.sessions]) + const textToolBox = textToolRequest && textToolInitial?.boxWidth && textToolInitial.boxHeight ? { + x: textToolInitial.originX ?? textToolRequest.x, + y: textToolInitial.originY ?? textToolRequest.y, + width: textToolInitial.boxWidth, + height: textToolInitial.boxHeight + } : null + const clearTextToolPreview = useCallback((): void => { + const request = textToolRequest + if (!request) return + if (request.layerId && request.frameId && textPreviewSurfaceRef.current) { + useWorkspace.getState().setActive(request.documentId) + useWorkspace.getState().restoreTextCelPreview(request.layerId, request.frameId, textPreviewSurfaceRef.current) + } + textPreviewSurfaceRef.current = null + publishTextToolPreview({ documentId: request.documentId, surface: null, box: null }) + }, [textToolRequest]) + const changeTextTool = useCallback((value: TextCelData): void => { + const request = textToolRequest + if (!request) return + const state = useWorkspace.getState() + state.setActive(request.documentId) + const draft = textLayerDraftRef.current + const x = value.originX ?? request.x + const y = value.originY ?? request.y + const box = value.boxWidth && value.boxHeight ? { x, y, width: value.boxWidth, height: value.boxHeight } : null + if (draft?.documentId === request.documentId) { + state.updateTextLayerDraft(draft.layerId, draft.frameId, value, x, y) + publishTextToolPreview({ documentId: request.documentId, surface: null, box }) + return + } + if (request.layerId || !value.text.length) return + const target = state.beginTextLayerDraft(value, x, y) + if (!target) return + textLayerDraftRef.current = { ...target, documentId: request.documentId } + setTextToolRequest((current) => current?.documentId === request.documentId ? { ...current, ...target } : current) + publishTextToolPreview({ documentId: request.documentId, surface: null, box }) + }, [textToolRequest]) + const previewTextTool = useCallback((value: TextCelData | null): void => { + const request = textToolRequest + if (!request) return + const state = useWorkspace.getState() + state.setActive(request.documentId) + const draft = textLayerDraftRef.current + const x = value?.originX ?? request.x + const y = value?.originY ?? request.y + const boxWidth = value?.boxWidth ?? request.width + const boxHeight = value?.boxHeight ?? request.height + const box = boxWidth && boxHeight ? { x, y, width: boxWidth, height: boxHeight } : null + if (draft?.documentId === request.documentId) { + publishTextToolPreview({ documentId: request.documentId, surface: null, box }) + return + } + if (request.layerId && request.frameId) { + if (textPreviewSurfaceRef.current) state.restoreTextCelPreview(request.layerId, request.frameId, textPreviewSurfaceRef.current) + textPreviewSurfaceRef.current = value ? state.previewTextCel(request.layerId, request.frameId, value, x, y) : null + return + } + const target = state.sessions.find((item) => item.document.id === request.documentId) + const preview = value && target + ? rasterizeText(normalizeTextCelData({ ...value, originX: x, originY: y }, target.primaryColor), x, y).rgba + : null + publishTextToolPreview({ documentId: request.documentId, surface: preview, box }) + }, [textToolRequest]) const visibleDocumentPaneLayout = useMemo(() => { - if (!session) return null - return resolveDocumentPanePreviewLayout(documentPaneLayout, session.document.id, documentPanePreview) - }, [documentPaneLayout, documentPanePreview, session]) + if (!workspaceDocumentId) return null + return documentPaneLayout?.kind === 'split' && documentPaneContains(documentPaneLayout, workspaceDocumentId) + ? documentPaneLayout + : null + }, [documentPaneLayout, workspaceDocumentId]) void coordinatorRenderKey useEffect(() => { setDocumentPaneLayout((current) => { @@ -231,6 +397,23 @@ export default function App() { ? current.filter((documentId) => openIds.has(documentId) && documentPaneContains(documentPaneLayout, documentId)) : []) }, [documentPaneLayout, workspace.sessions]) + useEffect(() => { + const openIds = new Set(workspace.sessions.map((item) => item.document.id)) + setFloatingDocuments((current) => { + const next = current.filter((item) => openIds.has(item.documentId)) + return next.length === current.length ? current : next + }) + }, [workspace.sessions]) + useEffect(() => { + const unavailable = new Set([...paneOnlyDocumentIds, ...floatingDocumentIds]) + const openIds = new Set(workspace.sessions.map((item) => item.document.id)) + const availableIds = workspace.sessions.map((item) => item.document.id).filter((id) => !unavailable.has(id)) + setWorkspaceDocumentId((current) => { + if (workspace.activeId && openIds.has(workspace.activeId) && !unavailable.has(workspace.activeId)) return workspace.activeId + if (current && openIds.has(current) && !unavailable.has(current)) return current + return availableIds.at(-1) ?? null + }) + }, [floatingDocumentIds, paneOnlyDocumentIds, workspace.activeId, workspace.sessions]) const saveShortcuts = (next: Record): void => { setShortcuts(next); persistShortcuts(next) } const blockedShortcuts = useMemo(() => deriveShortcutConflicts(shortcuts).blocked, [shortcuts]) const shortcutFor = useCallback((id: string): string => shortcuts[id] ?? defaultShortcuts[id] ?? '', [shortcuts]) @@ -247,6 +430,18 @@ export default function App() { const openColorReplacement = useCallback((): void => { if (useWorkspace.getState().activeId) setColorReplacementOpen(true) }, []) + const toggleTimelineVisibility = useCallback((): void => { + const next = { ...runtimePreferences, timelineHidden: !runtimePreferences.timelineHidden } + saveEditorPreferences(next) + setRuntimePreferences(next) + window.dispatchEvent(new Event('moonsprite:preferences-changed')) + }, [runtimePreferences]) + const toggleSliceOutlinesVisibility = useCallback((): void => { + const next = { ...runtimePreferences, sliceOutlinesVisible: !runtimePreferences.sliceOutlinesVisible } + saveEditorPreferences(next) + setRuntimePreferences(next) + window.dispatchEvent(new Event('moonsprite:preferences-changed')) + }, [runtimePreferences]) useEffect(() => { let disposed = false void window.moonSprite.getDefaultFileDirectories().then((directories) => { @@ -373,6 +568,9 @@ export default function App() { inspectorWidth: preferredInspectorWidthRef.current, leftDockWidth: preferredLeftDockWidthRef.current, bottomDockHeight: preferredBottomLayersHeightRef.current, + inspectorWidthRatio: inspectorWidthRatioRef.current, + leftDockWidthRatio: leftDockWidthRatioRef.current, + bottomDockHeightRatio: bottomLayersHeightRatioRef.current, toolRailSide, previewOpen, inspectorLayout: readLayoutStorage(INSPECTOR_LAYOUT_STORAGE_KEY), @@ -425,7 +623,8 @@ export default function App() { const applyWorkspaceLayout = async (saved: StoredWorkspace, announce = true): Promise => { workspaceApplyInProgress.current = true const layout = saved.layout - const normalized = normalizeWorkspaceLayout(layout, window.innerWidth) + const dockParentSize = workspaceDockParentSize(workAreaRef.current) + const normalized = normalizeWorkspaceLayout(layout, dockParentSize.width, dockParentSize.height) const nextPanelDocks: Record = normalized.panelDocks const nextInspectorWidth = normalized.inspectorWidth const nextLeftDockWidth = normalized.leftDockWidth @@ -436,6 +635,9 @@ export default function App() { writeStoredString(INSPECTOR_WIDTH_STORAGE_KEY, String(Math.round(nextInspectorWidth))) writeStoredString(LEFT_DOCK_WIDTH_STORAGE_KEY, String(Math.round(nextLeftDockWidth))) writeStoredString(BOTTOM_DOCK_HEIGHT_STORAGE_KEY, String(Math.round(nextBottomHeight))) + writeStoredString(INSPECTOR_WIDTH_RATIO_STORAGE_KEY, String(normalized.inspectorWidthRatio)) + writeStoredString(LEFT_DOCK_WIDTH_RATIO_STORAGE_KEY, String(normalized.leftDockWidthRatio)) + writeStoredString(BOTTOM_DOCK_HEIGHT_RATIO_STORAGE_KEY, String(normalized.bottomDockHeightRatio)) writeStoredString(TOOL_RAIL_SIDE_STORAGE_KEY, nextToolRailSide) writeLayoutStorage(INSPECTOR_LAYOUT_STORAGE_KEY, layout.inspectorLayout) writeLayoutStorage(COLOR_SQUARE_DOCK_STORAGE_KEY, layout.colorSquareDock) @@ -443,10 +645,13 @@ export default function App() { for (const id of Object.keys(FLOATING_PANEL_STORAGE_KEYS) as WorkspacePanelId[]) writeLayoutStorage(FLOATING_PANEL_STORAGE_KEYS[id], layout.floatingPanels?.[id] ?? null) inspectorWidthRef.current = nextInspectorWidth preferredInspectorWidthRef.current = nextInspectorWidth + inspectorWidthRatioRef.current = normalized.inspectorWidthRatio leftDockWidthRef.current = nextLeftDockWidth preferredLeftDockWidthRef.current = nextLeftDockWidth + leftDockWidthRatioRef.current = normalized.leftDockWidthRatio bottomLayersHeightRef.current = nextBottomHeight preferredBottomLayersHeightRef.current = nextBottomHeight + bottomLayersHeightRatioRef.current = normalized.bottomDockHeightRatio setInspectorWidth(nextInspectorWidth) setLeftDockWidth(nextLeftDockWidth) setBottomLayersHeight(nextBottomHeight) @@ -574,15 +779,34 @@ export default function App() { } }, [activeWorkspaceId, bottomLayersHeight, captureWorkspaceLayout, inspectorWidth, leftDockWidth, panelDocks, panelVisibility, previewOpen, toolRailSide, workspaceLayoutChange, workspaceLayoutRevision]) - const openExport = (): void => { + const openExport = (requestedTarget?: NonNullable): void => { if (!session) return const preferences = loadEditorPreferences() + const remembered = loadDocumentExportSettings(session.document) const preferredFormat = imageExportKindForPreference(readStoredString(EXPORT_FORMAT_PREFERENCE_KEY)) - const format = (session.document.animation?.frames.length ?? 1) > 1 ? 'gif' : preferredFormat + const frameCount = session.document.animation?.frames.length ?? 1 + const defaultFormat = requestedTarget === 'frames' ? (preferredFormat === 'gif' ? 'png-auto' : preferredFormat) : frameCount > 1 ? 'gif' : preferredFormat + const format = requestedTarget === 'frames' && remembered?.format === 'gif' ? 'png-auto' : remembered?.format ?? defaultFormat + let target = requestedTarget ?? remembered?.target ?? 'document' + if (format === 'gif') target = 'document' + else if (target === 'frames' && requestedTarget !== 'frames' && frameCount <= 1) target = 'document' + else if (target === 'slices' && !session.document.slices?.length) target = 'document' const defaultScale = format === 'svg' ? 100 : exportScalePresets.includes(100) ? 100 : exportScalePresets[0] ?? 100 const documentName = session.document.name.replace(/\.(moonsprite|aseprite|ase|png|jpe?g|webp|svg|gif)$/i, '') || 'MoonSprite-export' - setExportForm({ name: withExportFileExtension(documentName, format), format, scalePercent: defaultScale, directory: preferences.exportDirectory || defaultFileDirectories.exportDirectory, gifFrameRange: 'all', gifDirection: 'forward' }) - setPresetName('') + const gifFrameLimit = Math.max(1, frameCount) + setExportForm({ + name: withExportFileExtension(remembered?.name ?? documentName, format), + format, + scalePercent: remembered?.scalePercent ?? defaultScale, + directory: remembered?.directory || preferences.exportDirectory || defaultFileDirectories.exportDirectory, + target, + gifFrameRange: remembered?.gifFrameRange ?? 'all', + ...(remembered?.gifFrameStart !== undefined ? { gifFrameStart: Math.min(gifFrameLimit, remembered.gifFrameStart) } : {}), + ...(remembered?.gifFrameEnd !== undefined ? { gifFrameEnd: Math.min(gifFrameLimit, remembered.gifFrameEnd) } : {}), + gifDirection: remembered?.gifDirection ?? 'forward' + }) + const rememberedPresetName = remembered?.presetName ?? '' + setPresetName(presets.some((preset) => preset.presetName === rememberedPresetName && preset.format === format && (preset.target ?? 'document') === target) ? rememberedPresetName : '') setExportPathMenuOpen(false) setExportOpen(true) } @@ -613,7 +837,7 @@ export default function App() { const openGalleryProject = async (filePath: string, keepHomeOpen = false): Promise => { const beforeIds = new Set(useWorkspace.getState().sessions.map((item) => item.document.id)) - const opened = await useWorkspace.getState().openPath(filePath) + const opened = await useWorkspace.getState().openPath(filePath, keepHomeOpen ? undefined : { onBeforeSession: () => setHomeOpen(false) }) const current = useWorkspace.getState() if (!keepHomeOpen && current.sessions.some((item) => !beforeIds.has(item.document.id))) setHomeOpen(false) return opened @@ -639,10 +863,27 @@ export default function App() { }) } - const createDocumentAndShow = async (name: string, width: number, height: number, mode: ColorMode): Promise => { - const beforeCount = useWorkspace.getState().sessions.length - await useWorkspace.getState().newDocument(name, width, height, mode) - if (useWorkspace.getState().sessions.length > beforeCount) setHomeOpen(false) + const createDocumentAndShow = async (name: string, width: number, height: number, mode: ColorMode, recordDrawing: boolean): Promise => { + const beforeIds = new Set(useWorkspace.getState().sessions.map((item) => item.document.id)) + await useWorkspace.getState().newDocument(name, width, height, mode, recordDrawing) + const created = useWorkspace.getState().sessions.find((item) => !beforeIds.has(item.document.id)) + if (!created) return + setHomeOpen(false) + setWorkspaceDocumentId(created.document.id) + const focusCanvas = (attempt = 0): void => { + window.requestAnimationFrame(() => { + if (useWorkspace.getState().activeId !== created.document.id) return + const canvas = [...document.querySelectorAll('.stage-canvas')] + .find((candidate) => candidate.dataset.documentId === created.document.id) + if (canvas && !document.querySelector('.modal-backdrop')) { + canvas.tabIndex = -1 + canvas.focus({ preventScroll: true }) + return + } + if (attempt < 12) focusCanvas(attempt + 1) + }) + } + focusCanvas() } useEffect(() => { @@ -659,7 +900,7 @@ export default function App() { useEffect(() => { void useWorkspace.getState().restoreRecoveries() - }, [t]) + }, []) useEffect(() => { if (!runtimePreferences.recovery) return @@ -826,13 +1067,18 @@ export default function App() { useEffect(() => { const move = (event: PointerEvent): void => { if (!resizeStart.current) return - const next = Math.max(180, Math.min(window.innerWidth - 220, resizeStart.current.width - (event.clientX - resizeStart.current.x))) + const parentWidth = workspaceDockParentSize(workAreaRef.current).width + const next = constrainInspectorWidth(resizeStart.current.width - (event.clientX - resizeStart.current.x), parentWidth) inspectorWidthRef.current = next preferredInspectorWidthRef.current = next + inspectorWidthRatioRef.current = dockSizeRatio(next, parentWidth, DEFAULT_INSPECTOR_WIDTH_RATIO) setInspectorWidth(next) } const up = (): void => { - if (resizeStart.current) writeStoredString(INSPECTOR_WIDTH_STORAGE_KEY, String(Math.round(inspectorWidthRef.current))) + if (resizeStart.current) { + writeStoredString(INSPECTOR_WIDTH_STORAGE_KEY, String(Math.round(inspectorWidthRef.current))) + writeStoredString(INSPECTOR_WIDTH_RATIO_STORAGE_KEY, String(inspectorWidthRatioRef.current)) + } resizeStart.current = null } window.addEventListener('pointermove', move) @@ -844,15 +1090,18 @@ export default function App() { const move = (event: PointerEvent): void => { const drag = leftDockResizeStart.current if (!drag) return - const next = Math.max(180, Math.min(Math.min(520, window.innerWidth - 520), drag.width + event.clientX - drag.x)) + const parentWidth = workspaceDockParentSize(workAreaRef.current).width + const next = constrainLeftDockWidth(drag.width + event.clientX - drag.x, parentWidth) leftDockWidthRef.current = next preferredLeftDockWidthRef.current = next + leftDockWidthRatioRef.current = dockSizeRatio(next, parentWidth, DEFAULT_LEFT_DOCK_WIDTH_RATIO) setLeftDockWidth(next) } const up = (): void => { if (!leftDockResizeStart.current) return leftDockResizeStart.current = null writeStoredString(LEFT_DOCK_WIDTH_STORAGE_KEY, String(Math.round(leftDockWidthRef.current))) + writeStoredString(LEFT_DOCK_WIDTH_RATIO_STORAGE_KEY, String(leftDockWidthRatioRef.current)) } window.addEventListener('pointermove', move) window.addEventListener('pointerup', up) @@ -868,12 +1117,14 @@ export default function App() { const next = Math.max(120, Math.min(maximum, drag.height - (event.clientY - drag.y))) bottomLayersHeightRef.current = next preferredBottomLayersHeightRef.current = next + bottomLayersHeightRatioRef.current = dockSizeRatio(next, workArea.height, DEFAULT_BOTTOM_DOCK_HEIGHT_RATIO) setBottomLayersHeight(next) } const up = (): void => { if (!bottomLayersResizeStart.current) return bottomLayersResizeStart.current = null writeStoredString(BOTTOM_DOCK_HEIGHT_STORAGE_KEY, String(Math.round(bottomLayersHeightRef.current))) + writeStoredString(BOTTOM_DOCK_HEIGHT_RATIO_STORAGE_KEY, String(bottomLayersHeightRatioRef.current)) } window.addEventListener('pointermove', move) window.addEventListener('pointerup', up) @@ -886,13 +1137,16 @@ export default function App() { if (frame !== null) window.cancelAnimationFrame(frame) frame = window.requestAnimationFrame(() => { frame = null - const nextInspector = constrainInspectorWidth(preferredInspectorWidthRef.current, window.innerWidth) - const nextLeft = constrainLeftDockWidth(preferredLeftDockWidthRef.current, window.innerWidth) - const workArea = workAreaRef.current?.getBoundingClientRect() - const nextBottom = constrainBottomDockHeight(preferredBottomLayersHeightRef.current, workArea?.height ?? window.innerHeight) + const parentSize = workspaceDockParentSize(workAreaRef.current) + const nextInspector = constrainInspectorWidth(dockSizeFromRatio(inspectorWidthRatioRef.current, parentSize.width, DEFAULT_INSPECTOR_WIDTH_RATIO), parentSize.width) + const nextLeft = constrainLeftDockWidth(dockSizeFromRatio(leftDockWidthRatioRef.current, parentSize.width, DEFAULT_LEFT_DOCK_WIDTH_RATIO), parentSize.width) + const nextBottom = constrainBottomDockHeight(dockSizeFromRatio(bottomLayersHeightRatioRef.current, parentSize.height, DEFAULT_BOTTOM_DOCK_HEIGHT_RATIO), parentSize.height) inspectorWidthRef.current = nextInspector + preferredInspectorWidthRef.current = nextInspector leftDockWidthRef.current = nextLeft + preferredLeftDockWidthRef.current = nextLeft bottomLayersHeightRef.current = nextBottom + preferredBottomLayersHeightRef.current = nextBottom setInspectorWidth(nextInspector) setLeftDockWidth(nextLeft) setBottomLayersHeight(nextBottom) @@ -921,6 +1175,7 @@ export default function App() { const hasOwnedPopover = Boolean(document.querySelector('.document-tab-context-menu, .tool-flyout, .brush-library, .brush-size-popover, .brush-advanced-settings [aria-expanded="true"]')) 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) + else if (saveProgress.getSnapshot().phase !== 'hidden') saveProgress.dismiss() else if (workspace.saveProgress) { if (!workspace.saveProgress.requiresConfirmation) 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' } })) @@ -944,8 +1199,10 @@ export default function App() { else if (workspaceSaveOpen) setWorkspaceSaveOpen(false) else if (workspaceManagerOpen) setWorkspaceManagerOpen(false) else if (openMenu) setOpenMenu(null) + else if (popupPanelId) setPopupPanelId(null) else if (hasOwnedPopover) window.dispatchEvent(new CustomEvent('moonsprite:close-dialog', { detail: { target: 'popover' } })) else if (session?.pendingPaste) workspace.cancelFloatingPaste() + else if (session?.textBoxTransform) workspace.cancelTextBoxTransform() else workspace.setSelection(null) event.preventDefault() event.stopPropagation() @@ -984,10 +1241,19 @@ export default function App() { if (!event.repeat) workspace.fillForeground() return } + if (matches('addForegroundToPalette') && !isTextEntry) { + event.preventDefault() + event.stopPropagation() + target?.blur() + if (session && !event.repeat) beginPaletteSamplingShortcut() + return + } if (matches('transform') && !isTextEntry) { event.preventDefault() event.stopPropagation() - if (!event.repeat) workspace.beginLayerTransform() + if (!event.repeat) { + workspace.beginLayerTransform() + } return } if (!isTextEntry && (matches('undo') || matches('redo'))) { @@ -997,7 +1263,7 @@ export default function App() { return } if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.tagName === 'SELECT') return - if (session?.document.animation && session.document.animation.frames.length > 1 + if (!runtimePreferences.timelineHidden && session?.document.animation && session.document.animation.frames.length > 1 && !session.selection && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey && !document.querySelector('.modal-backdrop') && !openMenu && (key === 'arrowleft' || key === 'arrowright')) { @@ -1040,16 +1306,22 @@ export default function App() { else workspace.setMessage(t('app.brushSelection.required')) })) return if (session?.selection && runCommand('deselect', () => { - if (session?.pendingPaste) workspace.commitFloatingPaste() - if (session?.selection) workspace.commitSelectionChange({ ...session.selection }, null, t('app.selection.cancelHistory')) + const label = t('app.selection.cancelHistory') + if (session.pendingPaste) workspace.commitFloatingPaste(label) + else workspace.commitSelectionChange({ ...session.selection! }, null, label) })) return if (session?.selectedAnimationCellKeys.length && !selectionCommandOverrideRef.current && runCommand('copyAnimationCel', () => workspace.copySelectedAnimationCels())) return + if (runCommand('openHome', () => setHomeOpen(true))) return if (runCommand('newDocument', () => setNewOpen(true))) return if (runCommand('openDocument', () => { void openFilesAndShowDocument() })) return if (runCommand('save', () => { void workspace.saveActive() })) return if (runCommand('exportDocument', openExport)) return + if (runCommand('exportAllFrames', () => openExport('frames'))) return + if (runCommand('exportSpriteSheet', () => { if (session) void workspace.createSpriteSheetFromActive() })) 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('openTimelapse', () => { if (session) setTimelapseOpen(true) })) return + if (runCommand('openProjectInfo', () => { if (session) setProjectInfoOpen(true) })) return if (session?.selectedAnimationMaskCellKeys.length && !selectionCommandOverrideRef.current && runCommand('copy', () => workspace.copySelectedAnimationMasks())) return if (session?.selectedAnimationCellKeys.length && !selectionCommandOverrideRef.current && runCommand('copy', () => workspace.copySelectedAnimationCels())) return if (session?.selectedAnimationFrameIds.length && runCommand('copy', () => workspace.copySelectedAnimationFrames())) return @@ -1096,16 +1368,44 @@ export default function App() { return } if (runCommand('openShortcutSettings', () => setShortcutOpen(true))) return - if (runCommand('openPreferences', () => setPreferencesOpen(true))) return + if (runCommand('openPreferences', () => openPreferences())) return if (runCommand('canvasResize', () => { if (session) setCanvasResizeOpen(true) })) return if (runCommand('imageResize', () => { if (session) setImageResizeOpen(true) })) return - if (runCommand('convertColorMode', () => { if (session) void workspace.convertColorMode(session.document.colorMode === 'rgba' ? 'indexed' : 'rgba') })) return + if (runCommand('convertColorMode', () => { + if (!session) return + const modes: ColorMode[] = ['rgba', 'indexed', 'grayscale'] + void workspace.convertColorMode(modes[(modes.indexOf(session.document.colorMode) + 1) % modes.length]) + })) return if (runCommand('createLayerGroup', () => workspace.createLayerGroup())) return if (runCommand('toggleClippingMask', () => workspace.toggleActiveClippingMask())) return + if (runCommand('toggleSelectedLayerVisibility', () => { + if (!session) return + const layerIds = session.selectedLayerIds.length > 0 ? session.selectedLayerIds : [session.document.activeLayerId] + for (const layerId of layerIds) workspace.toggleLayerVisibility(layerId) + for (const groupId of session.selectedGroupIds) workspace.toggleGroupVisibility(groupId) + })) return + if (runCommand('toggleSelectedLayerLock', () => { + if (!session) return + const layerIds = session.selectedLayerIds.length > 0 ? session.selectedLayerIds : [session.document.activeLayerId] + for (const layerId of layerIds) { + const layer = session.document.layers.find((candidate) => candidate.id === layerId) + if (layer) workspace.setLayerPropertiesWithBlend(layer.id, layer.name, layer.opacity, layer.blendMode, !layer.locked, layer.displayColor, layer.description) + } + for (const groupId of session.selectedGroupIds) { + const group = session.document.groups.find((candidate) => candidate.id === groupId) + if (group) workspace.setGroupProperties(group.id, group.name, group.opacity, group.blendMode, !group.locked, group.displayColor, group.description, group.cumulativeBlend) + } + })) return + if (runCommand('toggleSelectedGroupCollapsed', () => { + if (!session) return + for (const groupId of session.selectedGroupIds) workspace.toggleGroupCollapsed(groupId) + })) return if (runCommand('newLayer', () => { void workspace.addLayer() })) return - if (runCommand('addAnimationFrame', () => { if (session) workspace.duplicateAnimationFrame() })) return - if (runCommand('addBlankAnimationFrame', () => { if (session) workspace.addAnimationFrame() })) return - if (runCommand('deleteAnimationFrame', () => { if (session) workspace.deleteSelectedAnimationItems() })) return + if (runCommand('previousAnimationFrame', () => { if (session && !runtimePreferences.timelineHidden) workspace.stepAnimationFrame(-1) })) return + if (runCommand('nextAnimationFrame', () => { if (session && !runtimePreferences.timelineHidden) workspace.stepAnimationFrame(1) })) return + if (runCommand('addAnimationFrame', () => { if (session && !runtimePreferences.timelineHidden) workspace.duplicateAnimationFrame() })) return + if (runCommand('addBlankAnimationFrame', () => { if (session && !runtimePreferences.timelineHidden) workspace.addAnimationFrame() })) return + if (runCommand('deleteAnimationFrame', () => { if (session && !runtimePreferences.timelineHidden) workspace.deleteSelectedAnimationItems() })) return if (runCommand('duplicateLayer', () => workspace.duplicateActiveLayer())) return if (runCommand('mergeLayerDown', () => workspace.mergeActiveLayerDown())) return if (runCommand('mergeSelectedLayers', () => workspace.mergeSelectedLayers())) return @@ -1117,6 +1417,7 @@ export default function App() { if (runCommand('mirrorViewVertical', () => { if (session) toggleMirrorView('vertical') })) return if (runCommand('toggleGrid', () => { if (session) workspace.togglePixelGrid() })) return if (runCommand('toggleCustomGrid', () => { if (session) workspace.toggleGrid() })) return + if (runCommand('openGridSettings', () => { if (session) setGridSettingsOpen(true) })) 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 @@ -1125,9 +1426,18 @@ export default function App() { if (runCommand('togglePalettePanel', () => updatePanelVisibility('palette', !panelVisibility.palette))) return if (runCommand('toggleLayersPanel', () => updatePanelVisibility('layers', !panelVisibility.layers))) return if (runCommand('togglePreviewPanel', () => updatePanelVisibility('preview', !panelVisibility.preview))) return + if (runCommand('popupColorPanel', () => setPopupPanelId((current) => current === 'color' ? null : 'color'))) return + if (runCommand('popupPalettePanel', () => setPopupPanelId((current) => current === 'palette' ? null : 'palette'))) return + if (runCommand('popupLayersPanel', () => setPopupPanelId((current) => current === 'layers' ? null : 'layers'))) return + if (runCommand('popupPreviewPanel', () => setPopupPanelId((current) => current === 'preview' ? null : 'preview'))) return + if (runCommand('toggleTimeline', toggleTimelineVisibility)) return if (runCommand('toolRailLeft', () => updateToolRailSide('left'))) return if (runCommand('toolRailRight', () => updateToolRailSide('right'))) return + if (runCommand('saveWorkspaceLayout', () => { setWorkspaceSaveName(''); setWorkspaceSaveOpen(true) })) return + if (runCommand('openWorkspaceManager', () => { void loadSavedWorkspaces(); setWorkspaceManagerOpen(true) })) return if (runCommand('openComponentLibrary', () => setComponentLibraryOpen(true))) return + if (runCommand('openLatestRelease', () => setLatestReleaseOpen(true))) return + if (runCommand('openRoadmap', () => setRoadmapOpen(true))) return 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 @@ -1136,6 +1446,14 @@ export default function App() { if (runCommand('tool.selection', () => { workspace.setTool('selection'); workspace.setSelectionKind('rectangle') })) return if (runCommand('tool.fill.gradient', () => { workspace.setTool('fill'); workspace.setFillKind('gradient') })) return if (runCommand('tool.fill', () => { workspace.setTool('fill'); workspace.setFillKind('bucket') })) return + if (runCommand('tool.shape.rectangleOutline', () => { workspace.setTool('shape'); workspace.setShapeKind('rectangle-outline') })) return + if (runCommand('tool.shape.rectangle', () => { workspace.setTool('shape'); workspace.setShapeKind('rectangle') })) return + if (runCommand('tool.shape.ellipseOutline', () => { workspace.setTool('shape'); workspace.setShapeKind('ellipse-outline') })) return + if (runCommand('tool.shape.ellipse', () => { workspace.setTool('shape'); workspace.setShapeKind('ellipse') })) return + if (runCommand('tool.curve', () => { workspace.setTool('line'); workspace.setLineKind('curve') })) return + if (runCommand('tool.line', () => { workspace.setTool('line'); workspace.setLineKind('line') })) return + if (runCommand('tool.text', () => workspace.setTool('text'))) return + if (runCommand('tool.slice', () => { workspace.setTool('move'); workspace.setMoveKind('slice') })) return if (event.key === 'Enter' && session?.selection && shouldHandleGlobalSelectionEnter(outlineOpen, true)) { event.preventDefault() if (session.pendingPaste) workspace.commitFloatingPaste() @@ -1143,6 +1461,11 @@ export default function App() { if (active?.selection) workspace.commitSelectionChange(active.selection, null, t('app.selection.completeHistory')) return } + if (event.key === 'Enter' && session?.textBoxTransform) { + event.preventDefault() + workspace.cancelTextBoxTransform() + return + } const tool = TOOL_DEFINITIONS.find((item) => matches(`tool.${item.id}`)) if (tool) { event.preventDefault() @@ -1153,11 +1476,12 @@ export default function App() { if (shouldTriggerDeleteCommand(matches('deleteLayer'), event.key)) { event.preventDefault() event.stopPropagation() + if (commandScopeRef.current === 'canvas' && session?.tool === 'move' && session.moveKind === 'slice' && (session.selectedSliceIds?.length || session.selectedSliceId)) { workspace.deleteSlices(session.selectedSliceIds?.length ? session.selectedSliceIds : [session.selectedSliceId!]); return } + const target = resolveDeleteCommand(commandScopeRef.current, Boolean(session?.selection)) + if (target === 'layers' && (session?.selectedLayerIds.length || session?.selectedGroupIds.length)) { workspace.deleteSelectedLayers(); return } if (session?.selectedAnimationMaskCellKeys.length && !selectionCommandOverrideRef.current) { workspace.deleteSelectedLayerMasks(); return } if ((session?.selectedAnimationCellKeys.length || session?.selectedAnimationFrameIds.length) && !selectionCommandOverrideRef.current) { workspace.deleteSelectedAnimationItems(); return } - 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 } @@ -1172,11 +1496,47 @@ export default function App() { event.stopPropagation() } } - const keyup = (event: KeyboardEvent): void => { if (event.key === 'Alt') event.preventDefault() } + const keyup = (event: KeyboardEvent): void => { + if (event.key === 'Alt') event.preventDefault() + const shortcut = shortcuts.addForegroundToPalette ?? defaultShortcuts.addForegroundToPalette + if (shortcutReleasedByEvent(event, shortcut)) endPaletteSamplingShortcut() + } + const blur = (): void => endPaletteSamplingShortcut() window.addEventListener('keydown', keydown, true) window.addEventListener('keyup', keyup, true) - return () => { window.removeEventListener('keydown', keydown, true); window.removeEventListener('keyup', keyup, true) } - }, [adjustmentOpen, advancedMode, aboutOpen, blockedShortcuts, canvasResizeOpen, colorReplacementOpen, componentLibraryOpen, cycleAdvancedMode, exportOpen, gridSettingsOpen, homeOpen, imageResizeOpen, latestReleaseOpen, newOpen, openMenu, openSaveAs, outlineOpen, preferencesOpen, projectInfoOpen, roadmapOpen, saveAsOpen, shortcutOpen, timelapseOpen, toggleMirrorView, updatePanelVisibility, updateToolRailSide, workspace, workspaceManagerOpen, workspaceSaveOpen, session?.brushSize, session?.document.id, session?.selection, shortcuts]) + window.addEventListener('blur', blur) + return () => { window.removeEventListener('keydown', keydown, true); window.removeEventListener('keyup', keyup, true); window.removeEventListener('blur', blur) } + }, [adjustmentOpen, advancedMode, aboutOpen, blockedShortcuts, canvasResizeOpen, colorReplacementOpen, componentLibraryOpen, cycleAdvancedMode, exportOpen, gridSettingsOpen, homeOpen, imageResizeOpen, latestReleaseOpen, loadSavedWorkspaces, newOpen, openMenu, openPreferences, openSaveAs, outlineOpen, panelVisibility, popupPanelId, preferencesOpen, projectInfoOpen, roadmapOpen, runtimePreferences.timelineHidden, saveAsOpen, shortcutOpen, timelapseOpen, toggleMirrorView, toggleTimelineVisibility, updatePanelVisibility, updateToolRailSide, workspace, workspaceManagerOpen, workspaceSaveOpen, session?.brushSize, session?.document.id, session?.moveKind, session?.selectedSliceId, session?.selectedSliceIds, session?.selection, session?.textBoxTransform, session?.tool, shortcuts]) + + useEffect(() => { + const keydown = (event: KeyboardEvent): void => { + if (blockedShortcuts.toggleAnimationPlayback) return + const expected = (shortcuts.toggleAnimationPlayback ?? defaultShortcuts.toggleAnimationPlayback).trim() + if (expected === '' || normalizeShortcut(shortcutText(event)).toLowerCase() !== normalizeShortcut(expected).toLowerCase()) return + const target = event.target instanceof Element ? event.target : null + const state = useWorkspace.getState() + const active = state.sessions.find((item) => item.document.id === state.activeId) ?? null + const isInteractiveTarget = Boolean(target?.closest('input, textarea, select, button, [contenteditable="true"], [role="button"], [role="menuitem"], [role="option"]')) + const hasBlockingSurface = Boolean(openMenu || popupPanelId || document.querySelector('.modal-backdrop, .context-menu, .tool-flyout, .brush-library, .brush-size-popover, .pressure-popover, .themed-select-popover, .palette-operation-dialog, .palette-library-popover, .palette-actions-popover, .workspace-panel-popup-layer')) + if (!shouldHandleAnimationPlaybackShortcut({ + defaultPrevented: event.defaultPrevented, + repeat: event.repeat, + hasSession: Boolean(active), + frameCount: active?.document.animation?.frames.length ?? 0, + homeOpen, + timelineHidden: runtimePreferences.timelineHidden, + hasSelection: Boolean(active?.selection), + hasTextBoxTransform: Boolean(active?.textBoxTransform), + isInteractiveTarget, + hasBlockingSurface + })) return + event.preventDefault() + event.stopPropagation() + state.setAnimationPlaying(!active!.animationPlaying) + } + window.addEventListener('keydown', keydown) + return () => window.removeEventListener('keydown', keydown) + }, [blockedShortcuts.toggleAnimationPlayback, homeOpen, openMenu, popupPanelId, runtimePreferences.timelineHidden, shortcuts.toggleAnimationPlayback]) useEffect(() => { void window.moonSprite.getResourceInfo().then((info) => setResourceLabel(t('app.resource.freeMemory', { value: formatBytes(info.freeBytes) }))) }, [t]) useEffect(() => { @@ -1206,46 +1566,125 @@ export default function App() { const openNewDocumentFromTab = useCallback((): void => setNewOpen(true), []) const activateDocumentTab = useCallback((documentId: string): void => { setHomeOpen(false) + setWorkspaceDocumentId(documentId) useWorkspace.getState().setActive(documentId) }, []) const contextActivateDocumentTab = useCallback((documentId: string): void => { setHomeOpen(false) + setWorkspaceDocumentId(documentId) useWorkspace.getState().setActive(documentId) }, []) - const previewDocumentSplitFromTab = useCallback((placement: DocumentPanePlacement | null): void => { - setDocumentPanePreview((current) => current?.documentId === placement?.documentId && current?.targetPaneId === placement?.targetPaneId && current?.direction === placement?.direction ? current : placement) + const updateDocumentPaneDockDebug = useCallback((next: DocumentTabDockDebugState | null): void => { + setDocumentPaneDockDebug((current) => current?.draggedDocumentId === next?.draggedDocumentId && current?.targetDocumentId === next?.targetDocumentId && current?.direction === next?.direction && current?.magnetVisible === next?.magnetVisible ? current : next) }, []) - const splitDocumentFromTab = useCallback((documentId: string, targetPaneId: string, direction: DocumentPaneDirection): void => { - setDocumentPanePreview(null) - const activeId = useWorkspace.getState().activeId - if (!activeId || activeId === documentId) return - setDocumentPaneLayout((current) => { - const base = current ?? createDocumentPaneLayout(activeId) - return insertDocumentPane(base, targetPaneId || activeId, documentId, direction) + const splitDocumentFromTab = useCallback((placement: DocumentPanePlacement): void => { + const workspace = useWorkspace.getState() + const baseDocumentId = workspaceDocumentId + if (!baseDocumentId || placement.documentId === placement.targetPaneId) return + setDocumentPaneLayout((current) => splitDocumentPaneFromTab(current, baseDocumentId, placement)) + setPaneOnlyDocumentIds((current) => { + const next = current.includes(placement.documentId) ? [...current] : [...current, placement.documentId] + return baseDocumentId === placement.documentId + ? next.filter((id) => id !== placement.targetPaneId) + : next }) - setPaneOnlyDocumentIds((current) => current.includes(documentId) ? current : [...current, documentId]) - }, []) + if (baseDocumentId === placement.documentId) { + setWorkspaceDocumentId(placement.targetPaneId) + if (workspace.activeId === placement.documentId) workspace.setActive(placement.targetPaneId) + } + }, [workspaceDocumentId]) const updateDocumentPaneLayout = useCallback((layout: DocumentPaneNode | null): void => setDocumentPaneLayout(layout), []) const moveDocumentPaneView = useCallback((documentId: string, targetPaneId: string, direction: DocumentPaneDirection): void => { setDocumentPaneLayout((current) => current ? moveDocumentPane(current, documentId, targetPaneId, direction) : current) }, []) + const floatDocument = useCallback((documentId: string, anchor: { x: number; y: number }): void => { + const state = useWorkspace.getState() + if (!state.sessions.some((item) => item.document.id === documentId) || floatingDocumentIds.includes(documentId)) return + const initialPosition = createFloatingDocumentPosition(documentId, anchor) + const nextFloatingIds = new Set([...floatingDocumentIds, documentId]) + const availableDocumentIds = state.sessions + .map((item) => item.document.id) + .filter((id) => !nextFloatingIds.has(id)) + .reverse() + const detached = detachDocumentPaneWorkspace(documentPaneLayout, documentId, workspaceDocumentId, paneOnlyDocumentIds, availableDocumentIds) + setFloatingDocuments((current) => current.some((item) => item.documentId === documentId) + ? current + : [...current, { documentId, initialPosition, pinned: false }]) + setDocumentPaneLayout(detached.layout) + setPaneOnlyDocumentIds(detached.paneOnlyDocumentIds) + setWorkspaceDocumentId(detached.workspaceDocumentId) + setHomeOpen(false) + state.setActive(documentId) + }, [documentPaneLayout, floatingDocumentIds, paneOnlyDocumentIds, workspaceDocumentId]) + const activateFloatingDocument = useCallback((documentId: string): void => { + setHomeOpen(false) + setFloatingDocuments((current) => { + const target = current.find((item) => item.documentId === documentId) + return target && current.at(-1)?.documentId !== documentId + ? [...current.filter((item) => item.documentId !== documentId), target] + : current + }) + useWorkspace.getState().setActive(documentId) + }, []) + const setFloatingDocumentPinned = useCallback((documentId: string, pinned: boolean): void => { + setFloatingDocuments((current) => { + const target = current.find((item) => item.documentId === documentId) + if (!target || target.pinned === pinned) return current + return [...current.filter((item) => item.documentId !== documentId), { ...target, pinned }] + }) + }, []) + const returnFloatingDocumentToTabs = useCallback((documentId: string, visibleIndex?: number): void => { + const state = useWorkspace.getState() + if (!state.sessions.some((item) => item.document.id === documentId)) return + const nextFloatingIds = floatingDocumentIds.filter((id) => id !== documentId) + if (visibleIndex !== undefined) { + const hiddenIds = new Set([...paneOnlyDocumentIds, ...nextFloatingIds]) + const returned = state.sessions.find((item) => item.document.id === documentId) + const visible = state.sessions.filter((item) => item.document.id !== documentId && !hiddenIds.has(item.document.id)) + if (returned) visible.splice(Math.max(0, Math.min(visible.length, visibleIndex)), 0, returned) + const hidden = state.sessions.filter((item) => hiddenIds.has(item.document.id)) + state.reorderSessions([...visible, ...hidden].map((item) => item.document.id)) + } + const targetDocumentId = resolveFloatingDocumentReturnTarget({ + returnedDocumentId: documentId, + workspaceDocumentId, + preserveWorkspace: visibleIndex !== undefined, + openDocumentIds: state.sessions.map((item) => item.document.id), + remainingFloatingDocumentIds: nextFloatingIds + }) + setFloatingDocuments((current) => current.filter((item) => item.documentId !== documentId)) + setPaneOnlyDocumentIds((current) => current.filter((id) => id !== documentId)) + setWorkspaceDocumentId(targetDocumentId) + setHomeOpen(false) + state.setActive(targetDocumentId) + }, [floatingDocumentIds, paneOnlyDocumentIds, workspaceDocumentId]) + const closeFloatingDocument = useCallback((documentId: string): void => { + void useWorkspace.getState().closeDocument(documentId).then(() => { + if (!useWorkspace.getState().sessions.some((item) => item.document.id === documentId)) { + setFloatingDocuments((current) => current.filter((item) => item.documentId !== documentId)) + } + }) + }, []) const returnDocumentPaneToTabs = useCallback((documentId: string, visibleIndex: number): void => { if (!documentPaneLayout) return - const remainingLayout = removeDocumentPane(documentPaneLayout, documentId) - const remainingLeafId = remainingLayout?.kind === 'leaf' ? remainingLayout.documentId : null - const nextLayout = remainingLayout?.kind === 'split' ? remainingLayout : null - const nextPaneOnlyIds = paneOnlyDocumentIds.filter((id) => id !== documentId && id !== remainingLeafId) const sessions = useWorkspace.getState().sessions + const availableDocumentIds = sessions + .map((item) => item.document.id) + .filter((id) => id !== documentId && !floatingDocumentIds.includes(id)) + const detached = detachDocumentPaneWorkspace(documentPaneLayout, documentId, workspaceDocumentId, paneOnlyDocumentIds, availableDocumentIds) + const nextPaneOnlyIds = detached.paneOnlyDocumentIds const returned = sessions.find((item) => item.document.id === documentId) - const visible = sessions.filter((item) => item.document.id !== documentId && !nextPaneOnlyIds.includes(item.document.id)) + const hiddenIds = new Set([...nextPaneOnlyIds, ...floatingDocumentIds]) + const visible = sessions.filter((item) => item.document.id !== documentId && !hiddenIds.has(item.document.id)) if (returned) visible.splice(Math.max(0, Math.min(visible.length, visibleIndex)), 0, returned) - const hidden = sessions.filter((item) => nextPaneOnlyIds.includes(item.document.id)) + const hidden = sessions.filter((item) => hiddenIds.has(item.document.id)) useWorkspace.getState().reorderSessions([...visible, ...hidden].map((item) => item.document.id)) setPaneOnlyDocumentIds(nextPaneOnlyIds) - setDocumentPaneLayout(nextLayout) - const remainingPaneId = remainingLeafId ?? (nextLayout ? documentPaneLeafIds(nextLayout)[0] : documentId) - useWorkspace.getState().setActive(remainingPaneId) - }, [documentPaneLayout, paneOnlyDocumentIds]) + setDocumentPaneLayout(detached.layout) + const targetDocumentId = detached.workspaceDocumentId ?? documentId + setWorkspaceDocumentId(targetDocumentId) + useWorkspace.getState().setActive(targetDocumentId) + }, [documentPaneLayout, floatingDocumentIds, paneOnlyDocumentIds, workspaceDocumentId]) const beginToolRailDrag = useCallback((event: React.PointerEvent): void => { if (event.button !== 0) return toolRailDrag.current = { startX: event.clientX, startY: event.clientY, moved: false, target: toolRailSide } @@ -1284,6 +1723,18 @@ export default function App() { ].join(' ') const editorOnly = advancedMode !== null && Boolean(session) && !homeOpen + const documentPaneDockDebugTargetName = documentPaneDockDebug?.targetDocumentId + ? workspace.sessions.find((item) => item.document.id === documentPaneDockDebug.targetDocumentId)?.document.name ?? documentPaneDockDebug.targetDocumentId + : null + const documentPaneDockDebugDirection = documentPaneDockDebug?.direction === 'left' + ? t('app.documentDockDebug.left') + : documentPaneDockDebug?.direction === 'right' + ? t('app.documentDockDebug.right') + : documentPaneDockDebug?.direction === 'top' + ? t('app.documentDockDebug.top') + : documentPaneDockDebug?.direction === 'bottom' + ? t('app.documentDockDebug.bottom') + : t('app.documentDockDebug.none') return
{saveAsOpen && session && setSaveAsOpen(false)} onSave={(options) => workspace.saveActive(true, options)} />} @@ -1293,6 +1744,8 @@ export default function App() { shortcutFor={shortcutFor} homeOpen={homeOpen} panelVisibility={panelVisibility} + timelineHidden={runtimePreferences.timelineHidden} + sliceOutlinesVisible={runtimePreferences.sliceOutlinesVisible} toolRailSide={toolRailSide} advancedModeActive={advancedMode !== null} recentFiles={recentFiles} @@ -1301,7 +1754,8 @@ export default function App() { onOpen={() => { void openFilesAndShowDocument() }} onOpenRecent={(filePath) => { void openGalleryProject(filePath) }} onSaveAs={openSaveAs} - onExport={openExport} + onExport={() => openExport()} + onExportAllFrames={() => openExport('frames')} onOpenTimelapse={() => setTimelapseOpen(true)} onOpenProjectInfo={() => setProjectInfoOpen(true)} onOpenProjectFolder={openProjectFolder} @@ -1309,12 +1763,14 @@ export default function App() { onOpenColorReplacement={() => setColorReplacementOpen(true)} onOpenAdjustment={(kind) => { setAdjustmentKind(kind); setAdjustmentOpen(true) }} onOpenShortcuts={() => setShortcutOpen(true)} - onOpenPreferences={() => setPreferencesOpen(true)} + onOpenPreferences={() => openPreferences()} onOpenCanvasResize={() => setCanvasResizeOpen(true)} onOpenImageResize={() => setImageResizeOpen(true)} onOpenGridSettings={() => setGridSettingsOpen(true)} onToggleMirror={toggleMirrorView} onTogglePanel={(id) => updatePanelVisibility(id, !panelVisibility[id])} + onToggleTimeline={toggleTimelineVisibility} + onToggleSliceOutlines={toggleSliceOutlinesVisibility} onToolRailSideChange={updateToolRailSide} onCycleAdvancedMode={cycleAdvancedMode} onOpenComponentLibrary={() => setComponentLibraryOpen(true)} @@ -1324,8 +1780,8 @@ export default function App() { />
- - {openMenu === 'workspace' && createPortal(
{savedWorkspaces.map((saved) => )}
, document.body)}
+ + {openMenu === 'workspace' && createPortal(
{savedWorkspaces.map((saved) => )}
, document.body)}
{session && !homeOpen ? setPopupPanelId(null)} onClosePreview={closePreviewPanel} panelDocks={panelDocks} onPanelDockChange={updatePanelDock} onPanelVisibilityChange={updatePanelVisibility} relativeLuminanceInPreview={relativeLuminanceScope === 'app'} onOpenColorReplacement={openColorReplacement} + onOpenPreferences={openQuickCommandPreferences} + onOpenCommandSettings={openQuickCommandSettings} + shortcutFor={shortcutFor} + onToggleMirror={toggleMirrorView} /> : } + {floatingDocuments.map((item, stackIndex) => { + const floatingSession = workspace.sessions.find((candidate) => candidate.document.id === item.documentId) + return floatingSession ? : null + })} + - {advancedModeNotice &&
{advancedModeNotice}{advancedModeNotice === t('app.advanced.enabled') ? `${advancedModeNoticeShortcut} ${t('app.advanced.restore')}` : advancedModeNoticeShortcut}
} - {workspace.saveProgress && createPortal(
{!workspace.saveProgress.requiresConfirmation && }
{workspace.saveProgress.label}
{t(workspace.saveProgress.requiresConfirmation ? 'app.progress.complete' : 'app.progress.processing')}{workspace.saveProgress.value}%
{workspace.saveProgress.requiresConfirmation &&
}
, document.body)} - {workspace.dialog &&
MOONSPRITE

{workspace.dialog.title}

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

{workspace.dialog.detail}

}
{workspace.dialog.choices.map((choice) => )}
} + + + {documentPaneDockDebug ?
{documentPaneDockDebugTargetName ? t('app.documentDockDebug.target', { name: documentPaneDockDebugTargetName, direction: documentPaneDockDebugDirection }) : t('app.documentDockDebug.noTarget', { direction: documentPaneDockDebugDirection })}{t(documentPaneDockDebug.magnetVisible ? 'app.documentDockDebug.magnetVisible' : 'app.documentDockDebug.magnetHidden')}
: advancedModeNotice &&
{advancedModeNotice}{advancedModeNotice === t('app.advanced.enabled') ? `${advancedModeNoticeShortcut} ${t('app.advanced.restore')}` : advancedModeNoticeShortcut}
} + {workspace.saveProgress && createPortal(
{!workspace.saveProgress.requiresConfirmation && }
{workspace.saveProgress.label}
= 100 ? 'is-full' : ''}`} aria-label={t('app.progress.aria', { title: workspace.saveProgress.title, value: workspace.saveProgress.value })}>
{t(workspace.saveProgress.requiresConfirmation ? 'app.progress.complete' : 'app.progress.processing')}{workspace.saveProgress.value}%
{workspace.saveProgress.requiresConfirmation &&
}
, document.body)} + {workspace.dialog &&
{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

{t('app.export.settings')}

-
-
{t('app.export.fileName')}
setExportForm({ ...exportForm, name: event.target.value })} />{exportPathMenuOpen &&
{t('app.export.recentPaths')}{recentExportPaths.length === 0 ? {t('app.export.noRecentPaths')} : recentExportPaths.map((item) => )}
}
{t('app.export.selectedDirectory', { path: exportForm.directory || defaultFileDirectories.exportDirectory })}
- + { + event.preventDefault() + const target = exportForm.format === 'gif' ? 'document' : exportForm.target === 'frames' ? 'frames' : !session?.document.slices?.length ? 'document' : exportForm.target + const selectedPresetName = presets.some((preset) => preset.presetName === presetName) ? presetName : undefined + void workspace.exportActive({ ...exportForm, target, ...(selectedPresetName ? { presetName: selectedPresetName } : {}) }).then((exported) => { if (exported) setExportOpen(false) }) + }}> + setExportOpen(false)} /> +
+ {t('app.export.selectedDirectory', { path: exportForm.directory || defaultFileDirectories.exportDirectory })}}> +
+ setExportForm({ ...exportForm, name: event.target.value })} /> + + {exportPathMenuOpen &&
{t('app.export.recentPaths')}{recentExportPaths.length === 0 ? {t('app.export.noRecentPaths')} : recentExportPaths.map((item) => )}
} +
+
+
+ value={exportForm.format} groups={[{ label: t('app.export.formatGroup'), options: [{ value: 'png-auto', label: t('app.export.pngAuto') }, { value: 'png-rgba', label: t('app.export.pngRgba') }, { value: 'jpeg', label: t('app.export.jpegWhite') }, { value: 'webp', label: t('app.export.webp') }, { value: 'svg', label: t('app.export.svg') }, { value: 'gif', label: t('app.export.gif') }] }]} label={t('app.export.format')} onChange={(format) => setExportForm({ ...exportForm, name: withExportFileExtension(exportForm.name, format), format, target: format === 'gif' ? 'document' : exportForm.target, scalePercent: format === 'svg' ? 100 : exportForm.scalePercent })} /> + > value={exportForm.format === 'gif' ? 'document' : exportForm.target === 'frames' ? 'frames' : !session?.document.slices?.length ? 'document' : exportForm.target ?? 'document'} groups={[{ label: t('app.export.target'), options: [{ value: 'document', label: t('app.export.targetDocument') }, ...((session?.document.animation?.frames.length ?? 1) > 1 && exportForm.format !== 'gif' ? [{ value: 'frames' as const, label: t('app.export.targetFrames') }] : []), ...(exportForm.format !== 'gif' && session?.document.slices?.length ? [{ value: 'slices' as const, label: t('app.export.targetSlices') }] : [])] }]} label={t('app.export.target')} onChange={(target) => setExportForm({ ...exportForm, target })} /> +
{exportForm.format === 'gif' &&
- - {exportForm.gifFrameRange === 'range' &&
} - + setExportForm({ ...exportForm, gifFrameRange: gifFrameRange as 'all' | 'range' })} /> + {exportForm.gifFrameRange === 'range' &&
setExportForm({ ...exportForm, gifFrameStart })} /> setExportForm({ ...exportForm, gifFrameEnd })} />
} + setExportForm({ ...exportForm, gifDirection: gifDirection as NonNullable })} />
} - - -
setPresetName(event.target.value)} />
+
setExportForm({ ...exportForm, scalePercent: exportForm.format === 'svg' ? Math.max(100, Math.round(value * 100)) : value })} />
{exportScalePresets.map((scale) => )}
+ +
+ ({ value: preset.presetName, label: `${preset.presetName} · ${preset.scalePercent}%` }))] }]} label={t('app.export.preset')} onChange={(value) => { const preset = presets.find((item) => item.presetName === value); setPresetName(value); if (preset) { const { presetName: _presetName, ...options } = preset; setExportForm(options) } }} /> +
setPresetName(event.target.value)} />
+
+
} {adjustmentOpen && setAdjustmentOpen(false)} />} {colorReplacementOpen && session && setColorReplacementOpen(false)} />} - {aboutOpen &&
{ if (event.target === event.currentTarget) setAboutOpen(false) }}>
MOONSPRITE

{t('app.about.title')}

MoonSprite

{t('app.about.description')}

{t('app.about.version')}
{APP_CHANNEL_LABEL}
{t('app.about.author')}
MoonPixel Studio & MoonSprite Contributors
{t('app.about.license')}
MIT License
github.com/MoonPixelTeam/moonsprite

{t('app.about.notice')}

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

{t('app.about.description')}

+
+
+
{t('app.about.version')}
{APP_CHANNEL_LABEL}
+
{t('app.about.author')}
MoonPixel Studio & MoonSprite Contributors
+
{t('app.about.license')}
{t('app.about.licenseName')}
+
+ +
+
+
+
} {componentLibraryOpen && setComponentLibraryOpen(false)} />} {roadmapOpen && setRoadmapOpen(false)} />} {latestReleaseOpen && setLatestReleaseOpen(false)} />} {session && gridSettingsOpen && workspace.setView({ grid })} onClose={() => setGridSettingsOpen(false)} />} {session && projectInfoOpen && setProjectInfoOpen(false)} />} {session && timelapseOpen && workspace.setTimelapseSettings(settings)} onClear={() => workspace.clearTimelapse()} onExport={(format, options) => workspace.exportTimelapse(format, options)} onClose={() => setTimelapseOpen(false)} />} - {workspaceSaveOpen &&
{ if (event.target === event.currentTarget && !workspaceBusy) setWorkspaceSaveOpen(false) }}> { event.preventDefault(); void saveWorkspace(workspaceSaveName) }}>
WORKSPACE

{t('app.workspace.saveTitle')}

{t('app.workspace.saveHint')}

{t('app.workspace.folder', { path: workspaceDirectory || 'workspaces' })}

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

{t('app.workspace.managerTitle')}

{savedWorkspaces.map((saved) =>
{!saved.builtIn && }
)}
} - setNewOpen(false)} onCreate={(name, width, height, mode) => void createDocumentAndShow(name, width, height, mode)} /> + {textToolRequest && { + const draft = textLayerDraftRef.current + clearTextToolPreview() + if (draft) { + useWorkspace.getState().setActive(draft.documentId) + useWorkspace.getState().cancelTextLayerDraft(draft.layerId) + textLayerDraftRef.current = null + } + setTextToolRequest(null) + }} onSubmit={(value) => { + const request = textToolRequest + clearTextToolPreview() + useWorkspace.getState().setActive(request.documentId) + const draft = textLayerDraftRef.current + const x = value.originX ?? request.x + const y = value.originY ?? request.y + if (draft?.documentId === request.documentId) { + workspace.updateTextLayerDraft(draft.layerId, draft.frameId, value, x, y) + workspace.commitTextLayerDraft(draft.layerId) + textLayerDraftRef.current = null + } else if (request.layerId && request.frameId) workspace.setTextCel(request.layerId, request.frameId, value, x, y) + else workspace.createTextLayer(value, x, y) + setTextToolRequest(null) + }} />} + {workspaceSaveOpen &&
{ if (event.target === event.currentTarget && !workspaceBusy) setWorkspaceSaveOpen(false) }}> { event.preventDefault(); void saveWorkspace(workspaceSaveName) }}> setWorkspaceSaveOpen(false)} />
setWorkspaceSaveName(event.target.value)} />

{t('app.workspace.saveHint')}

{t('app.workspace.folder', { path: workspaceDirectory || 'workspaces' })}

} + {workspaceManagerOpen && setWorkspaceManagerOpen(false)} onLoad={(saved) => { void applyWorkspaceLayout(saved) }} onDelete={(saved) => { void deleteSavedWorkspace(saved) }} onOpenFolder={() => { void window.moonSprite.openWorkspaceFolder() }} onCreate={() => { setWorkspaceManagerOpen(false); setWorkspaceSaveName(''); setWorkspaceSaveOpen(true) }} />} + setNewOpen(false)} onCreate={(name, width, height, mode, recordDrawing) => void createDocumentAndShow(name, width, height, mode, recordDrawing)} /> {session && { workspace.setCanvasResizePreview(null); setCanvasResizeOpen(false) }} onResize={async (width, height, anchor, offsetX, offsetY, trimOutside) => { await workspace.resizeActiveCanvas(width, height, anchor, offsetX, offsetY, trimOutside); workspace.setCanvasResizePreview(null) }} 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)} />} + {session && setImageResizeOpen(false)} onResize={(width, height, interpolation: ImageResizeInterpolation) => workspace.resizeActiveImage(width, height, interpolation)} onDetectScale={() => detectDocumentPixelScale(session.document)} />} {session && setOutlineOpen(false)} />} - {preferencesOpen && setPreferencesOpen(false)} onPresetChange={(documentSizes, exportScales) => { setDocumentSizePresets(documentSizes); setExportScalePresets(exportScales) }} />} + {preferencesOpen && setPreferencesOpen(false)} onPresetChange={(documentSizes, exportScales) => { setDocumentSizePresets(documentSizes); setExportScalePresets(exportScales) }} />} {shortcutOpen && setShortcutOpen(false)} />}
} diff --git a/src/renderer/src/assets/moonsprite-logo.svg b/src/renderer/src/assets/moonsprite-logo.svg index 231f401..fc361ee 100644 --- a/src/renderer/src/assets/moonsprite-logo.svg +++ b/src/renderer/src/assets/moonsprite-logo.svg @@ -1,20 +1,4 @@ - - - - - - - - - - - - - - - - - - - + + + diff --git a/src/renderer/src/assets/pixel-icons/color-mode-grayscale.svg b/src/renderer/src/assets/pixel-icons/color-mode-grayscale.svg new file mode 100644 index 0000000..570c61f --- /dev/null +++ b/src/renderer/src/assets/pixel-icons/color-mode-grayscale.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/renderer/src/assets/pixel-icons/color-mode-indexed.svg b/src/renderer/src/assets/pixel-icons/color-mode-indexed.svg new file mode 100644 index 0000000..a974975 --- /dev/null +++ b/src/renderer/src/assets/pixel-icons/color-mode-indexed.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/renderer/src/assets/pixel-icons/color-mode-rgba.svg b/src/renderer/src/assets/pixel-icons/color-mode-rgba.svg new file mode 100644 index 0000000..bdfd139 --- /dev/null +++ b/src/renderer/src/assets/pixel-icons/color-mode-rgba.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/renderer/src/assets/pixel-icons/cursor-selection-shear-nesw.png b/src/renderer/src/assets/pixel-icons/cursor-selection-shear-nesw.png new file mode 100644 index 0000000..64e21cf Binary files /dev/null and b/src/renderer/src/assets/pixel-icons/cursor-selection-shear-nesw.png differ diff --git a/src/renderer/src/assets/pixel-icons/cursor-selection-shear-nwse.png b/src/renderer/src/assets/pixel-icons/cursor-selection-shear-nwse.png new file mode 100644 index 0000000..96083e6 Binary files /dev/null and b/src/renderer/src/assets/pixel-icons/cursor-selection-shear-nwse.png differ diff --git a/src/renderer/src/assets/pixel-icons/record-drawing.svg b/src/renderer/src/assets/pixel-icons/record-drawing.svg new file mode 100644 index 0000000..338a15c --- /dev/null +++ b/src/renderer/src/assets/pixel-icons/record-drawing.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/renderer/src/assets/pixel-icons/scrollbar-thumb.svg b/src/renderer/src/assets/pixel-icons/scrollbar-thumb.svg new file mode 100644 index 0000000..e3fc3ad --- /dev/null +++ b/src/renderer/src/assets/pixel-icons/scrollbar-thumb.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/pixel-icons/selection-pivot-presets.png b/src/renderer/src/assets/pixel-icons/selection-pivot-presets.png new file mode 100644 index 0000000..dc0026e Binary files /dev/null and b/src/renderer/src/assets/pixel-icons/selection-pivot-presets.png differ diff --git a/src/renderer/src/assets/pixel-icons/selection-pivot.svg b/src/renderer/src/assets/pixel-icons/selection-pivot.svg new file mode 100644 index 0000000..4705376 --- /dev/null +++ b/src/renderer/src/assets/pixel-icons/selection-pivot.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/src/assets/pixel-icons/symmetry-rotational.svg b/src/renderer/src/assets/pixel-icons/symmetry-rotational.svg new file mode 100644 index 0000000..3af55d2 --- /dev/null +++ b/src/renderer/src/assets/pixel-icons/symmetry-rotational.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/save-progress-frames.png b/src/renderer/src/assets/save-progress-frames.png deleted file mode 100644 index 143d535..0000000 Binary files a/src/renderer/src/assets/save-progress-frames.png and /dev/null differ diff --git a/src/renderer/src/assets/save-progress-frames.svg b/src/renderer/src/assets/save-progress-frames.svg new file mode 100644 index 0000000..68ed09a --- /dev/null +++ b/src/renderer/src/assets/save-progress-frames.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/selection-intersect.svg b/src/renderer/src/assets/tool-icons/selection-intersect.svg index 57f0fed..e8f6eff 100644 --- a/src/renderer/src/assets/tool-icons/selection-intersect.svg +++ b/src/renderer/src/assets/tool-icons/selection-intersect.svg @@ -1,3 +1,3 @@ - + diff --git a/src/renderer/src/assets/tool-icons/shape-curve-normal.svg b/src/renderer/src/assets/tool-icons/shape-curve-normal.svg new file mode 100644 index 0000000..e4e4729 --- /dev/null +++ b/src/renderer/src/assets/tool-icons/shape-curve-normal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/shape-curve.svg b/src/renderer/src/assets/tool-icons/shape-curve.svg new file mode 100644 index 0000000..585c261 --- /dev/null +++ b/src/renderer/src/assets/tool-icons/shape-curve.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/shape-freeform-normal.svg b/src/renderer/src/assets/tool-icons/shape-freeform-normal.svg new file mode 100644 index 0000000..8b06c4b --- /dev/null +++ b/src/renderer/src/assets/tool-icons/shape-freeform-normal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/shape-freeform.svg b/src/renderer/src/assets/tool-icons/shape-freeform.svg new file mode 100644 index 0000000..2713e3c --- /dev/null +++ b/src/renderer/src/assets/tool-icons/shape-freeform.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/shape-line-normal.svg b/src/renderer/src/assets/tool-icons/shape-line-normal.svg new file mode 100644 index 0000000..3002018 --- /dev/null +++ b/src/renderer/src/assets/tool-icons/shape-line-normal.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/src/assets/tool-icons/shape-line.svg b/src/renderer/src/assets/tool-icons/shape-line.svg new file mode 100644 index 0000000..17e97ff --- /dev/null +++ b/src/renderer/src/assets/tool-icons/shape-line.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/shape-polygon-normal.svg b/src/renderer/src/assets/tool-icons/shape-polygon-normal.svg new file mode 100644 index 0000000..85d9339 --- /dev/null +++ b/src/renderer/src/assets/tool-icons/shape-polygon-normal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/shape-polygon.svg b/src/renderer/src/assets/tool-icons/shape-polygon.svg new file mode 100644 index 0000000..f85f690 --- /dev/null +++ b/src/renderer/src/assets/tool-icons/shape-polygon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/tool-airbrush-normal.svg b/src/renderer/src/assets/tool-icons/tool-airbrush-normal.svg new file mode 100644 index 0000000..7497795 --- /dev/null +++ b/src/renderer/src/assets/tool-icons/tool-airbrush-normal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/tool-airbrush.svg b/src/renderer/src/assets/tool-icons/tool-airbrush.svg new file mode 100644 index 0000000..fe26a43 --- /dev/null +++ b/src/renderer/src/assets/tool-icons/tool-airbrush.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/tool-hand-normal.svg b/src/renderer/src/assets/tool-icons/tool-hand-normal.svg index d073290..e07e1f2 100644 --- a/src/renderer/src/assets/tool-icons/tool-hand-normal.svg +++ b/src/renderer/src/assets/tool-icons/tool-hand-normal.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/renderer/src/assets/tool-icons/tool-slice-normal.svg b/src/renderer/src/assets/tool-icons/tool-slice-normal.svg new file mode 100644 index 0000000..54c3dde --- /dev/null +++ b/src/renderer/src/assets/tool-icons/tool-slice-normal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/tool-slice.svg b/src/renderer/src/assets/tool-icons/tool-slice.svg new file mode 100644 index 0000000..c08b5aa --- /dev/null +++ b/src/renderer/src/assets/tool-icons/tool-slice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/tool-text-normal.svg b/src/renderer/src/assets/tool-icons/tool-text-normal.svg new file mode 100644 index 0000000..b54fc91 --- /dev/null +++ b/src/renderer/src/assets/tool-icons/tool-text-normal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/assets/tool-icons/tool-text.svg b/src/renderer/src/assets/tool-icons/tool-text.svg new file mode 100644 index 0000000..3e4147f --- /dev/null +++ b/src/renderer/src/assets/tool-icons/tool-text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/components/BackgroundLayerDialog.tsx b/src/renderer/src/components/BackgroundLayerDialog.tsx new file mode 100644 index 0000000..d5cf247 --- /dev/null +++ b/src/renderer/src/components/BackgroundLayerDialog.tsx @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { BackgroundPatternId, StoredBackgroundPreset } from '@shared/types' +import { renderBackgroundPatternRgba, renderBackgroundTileRgba, type BackgroundPatternTile } from '@/core/background-patterns' +import { decodeBackgroundPresetTile } from '@/core/background-preset-images' +import type { TranslationKey } from '@/core/localization' +import { DialogHeader } from './DialogHeader' +import { ModalShell } from './ModalShell' +import { PixelUtilityIcon } from './PixelUtilityIcon' +import { useI18n } from './I18nProvider' + +const SOLID_PRESET_ID = 'moonsprite:solid' + +const backgroundPatternLabelKeys: Record, TranslationKey> = { + grid: 'layers.backgroundPattern.grid', + stripes: 'layers.backgroundPattern.stripes', + diamond: 'layers.backgroundPattern.diamond', + 'diamond-nested': 'layers.backgroundPattern.diamond-nested', + circles: 'layers.backgroundPattern.circles' +} + +interface LoadedBackgroundPreset { + preset: StoredBackgroundPreset + tile: BackgroundPatternTile +} + +function BackgroundPatternPreview({ source }: { source: BackgroundPatternId | BackgroundPatternTile }) { + const ref = useRef(null) + useEffect(() => { + const canvas = ref.current + if (!canvas) return + try { + const context = canvas.getContext('2d') + if (!context) return + const image = context.createImageData(canvas.width, canvas.height) + image.data.set(typeof source === 'string' + ? renderBackgroundPatternRgba(canvas.width, canvas.height, source) + : renderBackgroundTileRgba(canvas.width, canvas.height, source)) + context.putImageData(image, 0, 0) + } catch { + // Canvas previews are unavailable in a few test environments. + } + }, [source]) + return +} + +export function BackgroundLayerDialog({ onClose, onCreate }: { onClose: () => void; onCreate: (pattern: BackgroundPatternId | BackgroundPatternTile) => Promise }) { + const { t } = useI18n() + const loadVersionRef = useRef(0) + const [selectedId, setSelectedId] = useState(SOLID_PRESET_ID) + const [directoryPath, setDirectoryPath] = useState('BackgroundPresets') + const [presets, setPresets] = useState([]) + const [loading, setLoading] = useState(true) + const [status, setStatus] = useState(null) + const [creating, setCreating] = useState(false) + + const refreshPresets = useCallback(async (): Promise => { + const version = ++loadVersionRef.current + setLoading(true) + setStatus(null) + try { + const listing = await window.moonSprite.listBackgroundPresets() + const results = await Promise.allSettled(listing.presets.map(async (preset): Promise => { + const input = await window.moonSprite.readBinary(preset.filePath) + return { preset, tile: await decodeBackgroundPresetTile(preset, input) } + })) + if (version !== loadVersionRef.current) return + const loaded = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []) + const failedCount = results.length - loaded.length + setDirectoryPath(listing.directoryPath) + setPresets(loaded) + setSelectedId((current) => current === SOLID_PRESET_ID || loaded.some((item) => item.preset.id === current) ? current : SOLID_PRESET_ID) + if (failedCount > 0) setStatus(t('layers.backgroundPresetSomeFailed', { count: failedCount })) + } catch { + if (version !== loadVersionRef.current) return + setPresets([]) + setSelectedId(SOLID_PRESET_ID) + setStatus(t('layers.backgroundPresetReadError')) + } finally { + if (version === loadVersionRef.current) setLoading(false) + } + }, [t]) + + useEffect(() => { + void refreshPresets() + return () => { loadVersionRef.current += 1 } + }, [refreshPresets]) + + const openPresetFolder = async (): Promise => { + try { + await window.moonSprite.openBackgroundPresetFolder() + } catch { + setStatus(t('layers.backgroundPresetOpenError')) + } + } + + const create = async (): Promise => { + if (creating) return + const selected = selectedId === SOLID_PRESET_ID ? 'solid' : presets.find((item) => item.preset.id === selectedId)?.tile + if (!selected) return + setCreating(true) + try { + await onCreate(selected) + setCreating(false) + onClose() + } catch { + setCreating(false) + } + } + return
{ if (event.target === event.currentTarget && !creating) onClose() }}> + { event.preventDefault(); void create() }}> + +
+

{t('layers.backgroundDialogDescription')}

+
+ {directoryPath} +
+ + +
+
+
+ + {presets.map(({ preset, tile }) => { + const label = tile.pattern ? t(backgroundPatternLabelKeys[tile.pattern]) : preset.name + return + })} + {loading && presets.length === 0 &&

{t('layers.backgroundPresetLoading')}

} + {!loading && presets.length === 0 &&

{t('layers.backgroundPresetEmpty')}

} +
+ {status &&

{status}

} +
+
+
+
+} diff --git a/src/renderer/src/components/CanvasResizeDialog.tsx b/src/renderer/src/components/CanvasResizeDialog.tsx index 483e135..363f547 100644 --- a/src/renderer/src/components/CanvasResizeDialog.tsx +++ b/src/renderer/src/components/CanvasResizeDialog.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState } from 'react' import type { CanvasAnchor } from '@shared/types' import type { CanvasResizePreview } from '@/store/workspace' +import { DialogHeader } from './DialogHeader' import { ModalShell } from './ModalShell' import { NumberInput } from './NumberInput' import { useI18n } from './I18nProvider' import { PixelUtilityIcon, type PixelUtilityIconKind } from './PixelUtilityIcon' -import { PixelCheckbox } from './PixelCheckbox' +import { CheckboxField } from './CheckboxField' +import { FormField } from './FormField' import type { TranslationKey } from '@/core/localization' const anchors: Array<{ id: CanvasAnchor; labelKey: TranslationKey; icon: PixelUtilityIconKind }> = [ @@ -141,11 +143,11 @@ export function CanvasResizeDialog({ open, currentWidth, currentHeight, onClose, return
{ if (!(event.ctrlKey || event.metaKey) || event.altKey) return; if (event.key.toLowerCase() === 'z') { event.preventDefault(); restorePreview(event.shiftKey ? 'redo' : 'undo') } else if (event.key.toLowerCase() === 'y') { event.preventDefault(); restorePreview('redo') } }} aria-label={t('canvasResize.aria')}> -
{t('canvasResize.eyebrow')}

{t('canvasResize.title')}

+
-

{t('canvasResize.size')}

{anchors.map((item) => { const label = t(item.labelKey); return })}
-

{t('canvasResize.boundary')}

- +

{t('canvasResize.size')}

{ finishHistoryGroup(); event.currentTarget.select() }} onBlur={finishHistoryGroup} aria-label={t('newDocument.widthAria')} min={1} suffix="px" value={value.width} onValueChange={(next) => setDimension('width', next)} /> setDimension('height', next)} />
{anchors.map((item) => { const label = t(item.labelKey); return })}
+

{t('canvasResize.boundary')}

setEdge('left', next)} /> setEdge('top', next)} /> setEdge('right', next)} /> setEdge('bottom', next)} />
+ {t('canvasResize.trim')}} onChange={setTrimOutside} />
diff --git a/src/renderer/src/components/CanvasStage.tsx b/src/renderer/src/components/CanvasStage.tsx index 99d0c7e..c903c20 100644 --- a/src/renderer/src/components/CanvasStage.tsx +++ b/src/renderer/src/components/CanvasStage.tsx @@ -1,38 +1,47 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { RasterLayer, RgbaColor, SelectionMask, SelectionMode, SelectionRect } from '@shared/types' -import { compositePixelWithLayerColor, compositeRegion, createCompositePointReplacementSampler, createCompositePointSampler, createNormalCompositePointReplacementSampler, getActiveLayer, isLayerEffectivelyLocked, isLayerEffectivelyVisible, layerContentBounds, layerIndexAt, layerMaskDisplayColor, readLayerColor, readLayerColorAt, readLayerMaskDisplayColorAt, renderLayerMaskRegion } from '@/core/document' -import { beginPixelEdit, revertPixelEdit } from '@/core/history' -import { applyRelativeLuminance, blendOver, relativeLuminanceColor, TRANSPARENT } from '@/core/raster' +import { compositePixelWithLayerColor, compositeRegion, createCompositePointReplacementSampler, createCompositePointSampler, createNormalCompositePointReplacementSampler, createNormalCompositePointSampler, getActiveLayer, getPaletteEntry, isLayerEffectivelyLocked, isLayerEffectivelyVisible, layerContentBounds, layerIndexAt, layerMaskDisplayColor, normalCompositeLayers, readLayerColor, readLayerColorAt, readLayerMaskDisplayColorAt, renderLayerMaskRegion, resolveLayerCanvasColor } from '@/core/document' +import { beginPixelEdit, revertPixelEdit, type ContentInvalidationHint, type HistoryEntry } from '@/core/history' +import { applyRelativeLuminance, blendOver, relativeLuminanceColor, TRANSPARENT, unpackColor } from '@/core/raster' import { applyGradient, constrainGradientEndpoint, createGradientColorSampler, gradientRegionSelection } from '@/core/gradient' import { DEFAULT_GRID_SETTINGS, gridCellBoundsAt, gridLinePositions, shouldRenderPixelGrid } from '@/core/grid' -import { appendPerfectPixelSegment, applySelectionTransform, applySelectionTranslationPreview, brushMaskOffsets, brushStampAnchor, brushStampDimensions, captureSelectionTransform, floodFillSymmetric, outlinePixelIndices, paintBrush, paintLine, paintShape, restoreSelectionTranslationPreview, rotatedShapePixelPoints, sampleCompositeColor, shapeContainsPixel, type BrushGradientSample } from '@/core/tools' +import { appendPerfectPixelSegment, applySelectionTransform, applySelectionTranslationPreview, bezierCurvePixelPoints, brushMaskOffsets, brushPathStampPoints, brushStampAnchor, brushStampDimensions, brushStrokeInvalidationRects, captureSelectionTransform, filledShapePathPixelPoints, floodFillSymmetric, inheritBrushPaintBaseline, lineShapePixelPoints, outlinePixelIndices, paintBrush, paintBrushPath, paintLine, paintShape, paintShapePixelPoints, perfectPixelPathPoints, restoreSelectionTranslationPreview, rotatedShapePixelPoints, sampleCompositeColor, shapeContainsPixel, type BrushGradientSample } from '@/core/tools' import { useWorkspace, type DocumentSession } from '@/store/workspace' -import { activeLayerMask, activePaintLayer } from '@/store/workspace-session' +import { activeLayerMask, activePaintLayer, isToolAvailableForSession } from '@/store/workspace-session' import { DEFAULT_GRID_COLOR, loadEditorPreferences, type BrushPreviewMode, type CheckerboardPreferences, type EyedropperMagnifierStyle, type GridColorPreferences, type OnionSkinPreferences, type RotationIndicatorPosition, type SymmetryAxisPreferences, type WheelZoomMode, type ZoomToolDragMode } from '@/core/file-preferences' -import { clampCanvasViewPan, documentPointFromViewportPoint, documentPointFromViewportPointContinuous, mirrorViewportPoint, rotateViewAroundViewportPoint, rotateViewportPoint, rotationIndicatorFitsCanvas, rotationIndicatorPointBesidePointer, unrotateViewportPoint, viewPanDeltaFromScreen, viewRotationPivot, zoomViewAroundViewportPoint } from '@/core/view-geometry' +import { clampCanvasViewPan, displayedCanvasCenter, documentPointFromViewportPoint, documentPointFromViewportPointContinuous, mirrorViewportPoint, rotateViewAroundViewportPoint, rotateViewportPoint, rotationIndicatorFitsCanvas, rotationIndicatorPointBetweenPointerAndCanvasCenter, unrotateViewportPoint, viewPanDeltaFromScreen, viewRotationPivot, zoomViewAroundViewportPoint } from '@/core/view-geometry' import { createCanvasRenderPlan, deviceAlignedPixelRect } from '@/core/canvas-render-plan' import { balancedStairLinePoints, constrainLineEndpoint } from '@/core/pixel-line' -import { cloneSelection, combineSelection, lassoSelection, magicWandSelection, rasterLinePoints, rectSelection, rotatedEllipseSelection, rotatedRectSelection, selectionContains, shiftSelection, transformedSelectionControlPoints, transformSelectionMask } from '@/core/selection' +import { cloneSelection, combineSelection, lassoSelection, magicWandSelection, rasterLinePoints, rectSelection, rotateSelectionTargetAroundPivot, rotatedEllipseSelection, rotatedRectSelection, selectionContains, shearTransformedSelection, shiftSelection, transformedSelectionBounds, transformedSelectionCenter, transformedSelectionControlPoints, transformedSelectionPivotPreset, transformedSelectionShearDirection, transformSelectionMask, type SelectionShearTransform } from '@/core/selection' import { CANVAS_RESIZE_PREVIEW_EVENT, drawCanvasResizePreviewLayers } from '@/core/canvas-resize-preview' +import { SLICE_PREVIEW_EVENT } from '@/core/slice-preview' import { loadShortcuts, modifierShortcutHeld } from '@/core/shortcuts' -import { CanvasInputState, appendPolygonLassoVertex, beginBrushSpeedTracking, canvasGestureForPreview, centeredShapeBounds, clampCanvasZoom as clampZoom, coalescedPointerClientPoints, constrainedTranslation, createCanvasPanDrag, createMarqueeResizeStart, finalizeMarqueeSelection, floatingSelectionCopyMode, polygonLassoClosedPathPoints, polygonLassoPreviewPoints, resizeRotatedMarqueeBounds, resizeTransformedSelectionBounds, restoreCanvasDragAfterPan, revertCancelledCanvasDragPixelChanges, selectionGestureMoved, selectionInteractionHit, selectionOverlayMaskForDrag, selectionRotationAngle, selectionTransformedInteractionHit, selectionTransformModifiers, selectionTransformPreviewChanged, shapeBounds, shouldClosePolygonLasso, shouldRestartFloatingSelectionForCopy, shouldStartCanvasPan, snapSelectionRotation, steppedCanvasZoom as steppedZoom, temporaryTransformOffset, translatedSelectionRect, updateBrushSpeedTracking, wheelCanvasZoom, zoomDragModeForModifiers, zoomDragTarget, type CanvasDragState as DragState, type CanvasPoint as Point, type SelectionHandle, type SelectionHit, type SelectionRotationHandle, type SelectionShearHandle } from '@/core/canvas-input' -import { canvasCursors, canvasStatusTextColor, canvasToolCursor, colorLuminance, previewCursorTools, resizeCursors, rotationCursors, selectionRotationCursorForPosition, shearCursors, selectionCreationCursor, selectionCursorCornerRects, selectionPathPreviewPixelVisible, selectionPreviewPixels, selectionTransformDragCursor, transparencyColorAt } from '@/core/canvas-visuals' -import { defaultSymmetryCenter, hasSymmetry, moveSymmetryCenter, symmetryAxisSegment, symmetryPoints, symmetrySelection, transformSymmetrySelection, type SymmetryAxis } from '@/core/symmetry' -import { beginAdjustmentPreviewEdit, endAdjustmentPreviewEdit, prepareAdjustmentPreviewEdit, renderAdjustmentPreviewEdit } from '@/core/adjustment-preview-lifecycle' +import { paletteSamplingShortcutActive } from '@/core/palette-sampling-shortcut' +import { CanvasInputState, appendPolygonLassoVertex, beginBrushSpeedTracking, beginTemporaryCenteredMarqueeResize, brushLineConnectionOverridesTemporaryMove, canvasGestureForPreview, centerMarqueeBoundsAtCreationPoint, centeredShapeBounds, clampCanvasZoom as clampZoom, coalescedPointerClientPoints, constrainedTranslation, createCanvasPanDrag, createMarqueeResizeStart, deferredSelectionCommitInvalidationRects, deferredSelectionPreviewOwner, floatingSelectionCopyMode, isQuickSelectionSecondPress, marqueeSelectionCommit, normalizeCanvasWheelDelta, paletteSamplingShortcutStartsPrimarySample, polygonLassoClosedPathPoints, polygonLassoPreviewPoints, quickSelectCellDragBounds, quickSelectCellSelection, resizeRotatedMarqueeBounds, resizeSelectionBounds, resizeTransformedSelectionBounds, resolveMarqueeModifierMode, restoreCanvasDragAfterPan, restoreTemporaryCenteredMarqueeResize, revertCancelledCanvasDragPixelChanges, sampledForegroundColorToAdd, selectionGestureMoved, selectionInteractionHit, selectionMarqueeUsesConstraint, selectionOverlayMaskForDrag, selectionPivotAfterResize, selectionPivotAtDragPoint, selectionPivotHit, selectionResizeHit, selectionRotationAngle, selectionTransformedInteractionHit, selectionTransformDeferredPreviewEnabled, selectionTransformModifiers, selectionTransformPreviewChanged, shapeBounds, shouldClosePolygonLasso, shouldRestartFloatingSelectionForCopy, shouldStartCanvasPan, shouldUseTemporaryMoveForCanvasInteraction, shouldUseTemporaryMoveTool, snapSelectionRotation, steppedCanvasZoom as steppedZoom, temporaryMoveSuppressesToolPreview, temporaryTransformOffset, translatedSelectionRect, updateBrushSpeedTracking, wheelCanvasZoom, zoomDragModeForModifiers, zoomDragTarget, type CanvasDragState as DragState, type CanvasPoint as Point, type QuickSelectionPress, type SelectionHandle, type SelectionHit, type SelectionRotationHandle, type SelectionShearHandle } from '@/core/canvas-input' +import { canvasCursors, canvasStatusTextColor, canvasToolCursor, colorLuminance, directionalResizeCursors, directionalShearCursors, previewCursorTools, resizeCursors, rotationCursors, selectionCornerResizeCursorForPoints, selectionResizeCursorForHandle, selectionRotationCursorForPosition, selectionShearCursorForDirection, shearCursors, selectionCreationCursor, selectionCursorCornerRects, selectionPathPreviewPixelVisible, selectionPreviewPixels, selectionTransformDragCursor, transparencyColorAt } from '@/core/canvas-visuals' +import { defaultSymmetryCenter, hasSymmetry, moveSymmetryCenter, symmetryAxisSegment, symmetryPoints, symmetrySelection, symmetrySelectionDragDelta, transformSymmetrySelection, type SymmetryAxes, type SymmetryAxis } from '@/core/symmetry' +import { beginAdjustmentPreviewEdit, endAdjustmentPreviewEdit, hasAdjustmentPreviewController, prepareAdjustmentPreviewEdit, renderAdjustmentPreviewEdit } from '@/core/adjustment-preview-lifecycle' import { CanvasCompositeCache } from '@/components/canvas-composite-cache' +import { OnionSkinCompositeCache } from '@/components/onion-skin-composite-cache' import { resolveCanvasMoveAnimationCellKeys } from '@/components/canvas-move-selection' import { drawSelectionOutline, selectionScreenBox, selectionScreenPoint, type RasterContext2D, type SelectionBoundaryCache } from '@/components/canvas-selection-renderer' import { useCanvasViewPreview } from '@/components/useCanvasViewPreview' import { PerformanceProfiler } from '@/components/PerformanceProfiler' import { useI18n } from '@/components/I18nProvider' -import { compositeAnimationFrame, compositeAnimationFrameRegion, onionSkinFrameRefs, tintOnionSkinPixels } from '@/core/onion-skin' +import { syncCanvasDisplaySize } from '@/components/canvas-display-size' +import { onionSkinFrameRefs } from '@/core/onion-skin' import { resolveTheme } from '@/core/theme' import { pixelSamplingMode } from '@/core/pixel-display' import { finishAnimationCellOperation, revealLayerInPanel } from '@/components/layer-panel-reveal' -import { publishCanvasColorSample } from '@/components/color-sampling-events' +import { layerIdsInVisualStackOrder } from '@/core/layer-panel-layout' +import { publishCanvasColorSample, publishCanvasColorSamplingCompleted } from '@/components/color-sampling-events' import { eyedropperMagnifierPixelScale } from '@/core/eyedropper-magnifier' import { resolveBrushDynamics, smoothBrushSizeEnvelope } from '@/core/pressure' -import { animationCelKey, animationCelOffsetsForKeys, parseAnimationCelKey, setAnimationCelOffsets, setAnimationCelOffsetsForKeys } from '@/core/animation' +import { airbrushParticleSize, airbrushSymmetryPoints, generateAirbrushParticles } from '@/core/airbrush' +import { clampSliceRect, moveSliceRect, moveSliceRects, sliceAtPoint } from '@/core/slices' +import { animationCelKey, animationCelOffsetsForKeys, ensureAnimationDocument, parseAnimationCelKey, resolveAnimationCel, setAnimationCelOffsets, setAnimationCelOffsetsForKeys } from '@/core/animation' +import { cloneLayerStyles, layerStylesHistoryBytes } from '@/core/layer-styles' +import { openTextToolDialog, TEXT_TOOL_PREVIEW_EVENT, type TextToolPreviewDetail } from '@/components/text-tool-events' import rotationBackground1 from '@/assets/rotation-indicator/background-1.png' import rotationBackground2 from '@/assets/rotation-indicator/background-2.png' import rotationBackground3 from '@/assets/rotation-indicator/background-3.png' @@ -45,9 +54,14 @@ import eyedropperPointerLight from '@/assets/pixel-icons/03-Slice-3.png' import eyedropperMagnifierFrame from '@/assets/eyedropper-magnifier-frame.svg?raw' import eyedropperMagnifierSampledMask from '@/assets/eyedropper-magnifier-sampled-mask.svg?raw' import eyedropperMagnifierPreviousMask from '@/assets/eyedropper-magnifier-previous-mask.svg?raw' +import selectionPivotIcon from '@/assets/pixel-icons/selection-pivot.svg' + +const SELECTION_PIVOT_ICON_SIZE = 18 +const SELECTION_PIVOT_ICON_OFFSET = SELECTION_PIVOT_ICON_SIZE / 2 +const symmetryGuideAxisEnabled = (axes: SymmetryAxes, axis: SymmetryAxis): boolean => + Boolean(axes[axis] || (axes.rotational && (axis === 'horizontal' || axis === 'vertical'))) interface CompositeSurface { canvas: OffscreenCanvas; revision: string } -interface OnionSkinSurfaceCache { key: string; revision: number; surfaces: Array<{ frameId: string; distance: number; side: 'previous' | 'next'; canvas: OffscreenCanvas }> } interface GradientPreviewSurface { canvas: OffscreenCanvas; context: OffscreenCanvasRenderingContext2D; imageData: ImageData; pixels: Uint8ClampedArray; width: number; height: number } interface GradientPreviewCoverageCache { selection: SelectionMask | null | undefined @@ -72,14 +86,55 @@ interface GradientPreviewCoverageCache { } interface SymmetryDragState { axis: SymmetryAxis | 'center'; pointerId: number } interface MoveLayerContentPreview { layerId: string; bounds: SelectionRect; layerOffsetX: number; layerOffsetY: number } +interface MoveLayerClickFlash extends MoveLayerContentPreview { expiresAt: number } +interface LineAnchorHistory { + documentId: string + layerId: string + tool: 'pencil' | 'eraser' + point: Point + entry: HistoryEntry + baseline: Map + mergeWithNext: boolean +} +const MOVE_LAYER_CLICK_FLASH_MS = 240 const SMOOTH_GRADIENT_PREVIEW_SAMPLE_LIMIT = 96_000 const DITHERED_GRADIENT_PREVIEW_SAMPLE_LIMIT = 1_500_000 const insideSelection = (selection: SelectionMask, point: Point): boolean => selectionContains(selection, point.x, point.y) +const moveLayerInvalidation = (drag: DragState, frameId?: string): ContentInvalidationHint | undefined => { + if (!drag.layerContentBounds || !drag.layerPreviewOffset) return undefined + const bounds = Object.values(drag.layerContentBounds).filter((candidate): candidate is SelectionRect => Boolean(candidate)) + if (bounds.length === 0) return undefined + const moved = bounds.map((candidate) => translatedSelectionRect(candidate, drag.layerPreviewOffset!)) + const regions = [...bounds, ...moved] + const left = Math.min(...regions.map((region) => region.x)) + const top = Math.min(...regions.map((region) => region.y)) + const right = Math.max(...regions.map((region) => region.x + region.width)) + const bottom = Math.max(...regions.map((region) => region.y + region.height)) + return { kind: 'region', frameId, rect: { x: left, y: top, width: right - left, height: bottom - top } } +} +const selectedTextBoxForSession = (session: DocumentSession): SelectionRect | null => { + if (session.selectedGroupIds.length > 0 || session.selectedLayerIds.length !== 1) return null + const layer = session.document.layers.find((candidate) => candidate.id === session.selectedLayerIds[0] && candidate.kind === 'text') + if (!layer) return null + const timeline = ensureAnimationDocument(session.document) + const cel = timeline.cels.find((candidate) => candidate.layerId === layer.id && candidate.frameId === timeline.activeFrameId) + const source = resolveAnimationCel(timeline, cel ?? null) ?? cel + const text = source?.text + if (!text?.boxWidth || !text.boxHeight) return null + return { + x: text.originX ?? source?.surface?.offsetX ?? layer.offsetX, + y: text.originY ?? source?.surface?.offsetY ?? layer.offsetY, + width: text.boxWidth, + height: text.boxHeight + } +} export function CanvasStage({ session }: { session: DocumentSession }) { const { t } = useI18n() const stageRef = useRef(null) const canvasRef = useRef(null) + const textToolPreviewRef = useRef(null) + const textToolBoxRef = useRef(null) const selectionCanvasRef = useRef(null) const rotationSceneRef = useRef(null) const checkerboardTileRef = useRef<{ key: string; canvas: OffscreenCanvas } | null>(null) @@ -107,6 +162,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const preferences = loadEditorPreferences() return { pixelGridColor: preferences.pixelGridColor, gridColor: preferences.gridColor } }) + const [sliceColor, setSliceColor] = useState(() => loadEditorPreferences().sliceColor) + const [textBoxColor, setTextBoxColor] = useState(() => loadEditorPreferences().textBoxColor) + const [canvasResizeColor, setCanvasResizeColor] = useState(() => loadEditorPreferences().canvasResizeColor) + const [sliceOutlinesVisible, setSliceOutlinesVisible] = useState(() => loadEditorPreferences().sliceOutlinesVisible) const [wheelZoomEnabled, setWheelZoomEnabled] = useState(() => loadEditorPreferences().wheelZoomEnabled) const [wheelZoomMode, setWheelZoomMode] = useState(() => loadEditorPreferences().wheelZoomMode) const [shiftLinePreviewEnabled, setShiftLinePreviewEnabled] = useState(() => loadEditorPreferences().shiftLinePreviewEnabled) @@ -116,14 +175,21 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const [eyedropperMagnifierStyle, setEyedropperMagnifierStyle] = useState(() => loadEditorPreferences().eyedropperMagnifierStyle) const [eyedropperMagnifierDistortionEnabled, setEyedropperMagnifierDistortionEnabled] = useState(() => loadEditorPreferences().eyedropperMagnifierDistortionEnabled) const [moveLayerContentPreviewEnabled, setMoveLayerContentPreviewEnabled] = useState(() => loadEditorPreferences().moveLayerContentPreviewEnabled) + const [moveLayerClickFlashEnabled, setMoveLayerClickFlashEnabled] = useState(() => loadEditorPreferences().moveLayerClickFlashEnabled) const [selectionCrosshair, setSelectionCrosshair] = useState(() => loadEditorPreferences().selectionCrosshair) const [balancedShiftLineEnabled, setBalancedShiftLineEnabled] = useState(() => loadEditorPreferences().balancedShiftLineEnabled) const [lineDirectionStep, setLineDirectionStep] = useState(() => loadEditorPreferences().lineDirectionStep) const [onionSkin, setOnionSkin] = useState(() => loadEditorPreferences().onionSkin) + const [timelineHidden, setTimelineHidden] = useState(() => loadEditorPreferences().timelineHidden) const [symmetryAxisPreferences, setSymmetryAxisPreferences] = useState(() => loadEditorPreferences().symmetryAxis) const [activeTheme, setActiveTheme] = useState(() => resolveTheme(loadEditorPreferences().theme)) const [shortcuts, setShortcuts] = useState(loadShortcuts) const inputRef = useRef(new CanvasInputState()) + const quickSelectionPressRef = useRef(null) + const quickSelectionHandledAtRef = useRef(null) + const wheelBrushSizePreviewRef = useRef(false) + const nativeWheelHandlerRef = useRef<(event: WheelEvent) => void>(() => {}) + const lastNativeWheelRef = useRef<{ at: number; delta: number; type: string } | null>(null) const symmetryDragRef = useRef(null) const rafRef = useRef(null) const drawRequestRef = useRef(null) @@ -131,22 +197,40 @@ export function CanvasStage({ session }: { session: DocumentSession }) { // must therefore resolve the current render function instead of the brush // configuration that was active when the listener was registered. const drawRef = useRef<() => void>(() => {}) + const selectionOverlayDrawRef = useRef<() => void>(() => {}) const stageSizeRef = useRef({ width: 0, height: 0 }) const compositeCacheRef = useRef(new CanvasCompositeCache()) const compositePointSamplerRef = useRef<{ document: DocumentSession['document']; revision: number; sampler: (x: number, y: number) => RgbaColor } | null>(null) const renderDocumentSizeRef = useRef({ width: session.document.width, height: session.document.height }) - const onionSkinCacheRef = useRef(null) + const onionSkinCacheRef = useRef(new OnionSkinCompositeCache()) const canvasResizeSurfaceRef = useRef(null) const outlinePreviewCacheRef = useRef<{ revision: number; layerId: string; selection: SelectionMask; preview: NonNullable; indices: number[] } | null>(null) const selectionBoundaryCacheRef = useRef(null) const selectionOverlayVisibleRef = useRef(false) const selectionPreviewFrameRef = useRef(null) + const airbrushFrameRef = useRef(null) + const sprayAirbrushRef = useRef<(drag: DragState) => void>(() => {}) const adjustmentPreviewEditRef = useRef(false) const canvasResizePreviewRef = useRef(session.canvasResizePreview) + const autoSlicePreviewRef = useRef(null) const pendingCanvasResizeRef = useRef(null) const canvasResizeFrameRef = useRef(null) const moveLayerContentPreviewRef = useRef(null) const moveLayerContentPreviewTimerRef = useRef(null) + const moveLayerClickFlashRef = useRef(null) + const moveLayerClickFlashTimerRef = useRef(null) + const lineAnchorHistoryRef = useRef(null) + const selectionPivotImageRef = useRef(null) + useEffect(() => { + const image = new Image(SELECTION_PIVOT_ICON_SIZE, SELECTION_PIVOT_ICON_SIZE) + image.onload = () => selectionOverlayDrawRef.current() + image.src = selectionPivotIcon + selectionPivotImageRef.current = image + return () => { + image.onload = null + if (selectionPivotImageRef.current === image) selectionPivotImageRef.current = null + } + }, []) canvasResizePreviewRef.current = session.canvasResizePreview 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 } = useCanvasViewPreview({ documentId: session.document.id, sessionView: session.view, activeViewDrag, canvasRef, selectionCanvasRef, drawRef }) @@ -158,9 +242,13 @@ export function CanvasStage({ session }: { session: DocumentSession }) { // exist during hot reload. Resolve that legacy state once per render. const symmetryCenter = session.symmetryCenter ?? defaultSymmetryCenter(session.document.width, session.document.height) const fillKind = session.fillKind ?? 'bucket' + const sliceTool = session.tool === 'move' && session.moveKind === 'slice' const gradientDither = session.gradientDither ?? 'none' - const activeLayerEditable = hasSelectedRasterLayer && isLayerEffectivelyVisible(session.document, activeLayer) && !isLayerEffectivelyLocked(session.document, activeLayer) - const brushToolEnabled = session.tool === 'pencil' || session.tool === 'eraser' || (session.tool === 'fill' && fillKind === 'bucket') + const activeLayerEditable = hasSelectedRasterLayer + && isLayerEffectivelyVisible(session.document, activeLayer) + && !isLayerEffectivelyLocked(session.document, activeLayer) + && isToolAvailableForSession(session, session.tool) + const brushToolEnabled = session.tool === 'pencil' || session.tool === 'eraser' || session.tool === 'line' || (session.tool === 'fill' && fillKind === 'bucket') const activeBrushImage = brushToolEnabled ? session.brushImage : null const activeBrushTexture = brushToolEnabled ? session.brushTexture : 'solid' const activeBrushPaintMode = activeBrushImage?.intrinsicSize ? session.brushPaintMode : 'paint' @@ -172,6 +260,25 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const anchor = brushStampAnchor(session.brushSize, activeBrushImage) return { x: point.x - anchor.x, y: point.y - anchor.y } } + sprayAirbrushRef.current = (drag: DragState): void => { + if (!drag.edit) return + const paintLayer = activePaintLayer(session) + const color = drag.color ?? session.primaryColor + const particleSize = airbrushParticleSize(session.airbrushParticleRadius) + const particles = generateAirbrushParticles(drag.last, { + particleRadius: session.airbrushParticleRadius, + scatterRadius: session.airbrushScatterRadius, + density: session.airbrushDensity + }) + for (const particle of particles) { + paintBrush(session.document, paintLayer, drag.edit, particle.x, particle.y, particleSize, color, session.airbrushParticleShape, session.selection, 'solid', 1, null, session.brushImageSettings, 0, 'paint', particle, session.symmetryAxes, symmetryCenter) + } + const radius = session.airbrushScatterRadius + session.airbrushParticleRadius + for (const center of airbrushSymmetryPoints([drag.last], session.document.width, session.document.height, session.symmetryAxes, symmetryCenter)) { + invalidateCompositeRect({ x: center.x - radius, y: center.y - radius, width: radius * 2 + 1, height: radius * 2 + 1 }) + } + scheduleDraw() + } const stageBounds = (): DOMRect => stageRef.current?.getBoundingClientRect() ?? canvasRef.current?.getBoundingClientRect() ?? new DOMRect() const stageSize = (): { width: number; height: number } => { const cached = stageSizeRef.current @@ -182,6 +289,20 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } const constrainCanvasView = (view: DocumentSession['view'], size = stageSize()): DocumentSession['view'] => clampCanvasViewPan(size.width, size.height, session.document.width, session.document.height, view, rotationIndicatorPosition) const modifierActive = (event: Pick, id: keyof typeof shortcuts): boolean => modifierShortcutHeld(event, shortcuts[id] ?? '') + const brushLineConnectionHasPriority = ( + event: Pick, + targetSession: DocumentSession = session + ): boolean => brushLineConnectionOverridesTemporaryMove( + targetSession.tool, + event, + shortcuts.lineConnectionMode ?? '', + Boolean(targetSession.tool === 'eraser' ? targetSession.lastEraserPoint : targetSession.tool === 'pencil' ? targetSession.lastPencilPoint : null) + ) + const temporaryMoveActive = ( + event: Pick, + targetSession: DocumentSession = session + ): boolean => shouldUseTemporaryMoveTool(targetSession.tool, event, shortcuts.temporaryMove ?? '', targetSession.moveKind) + && !brushLineConnectionHasPriority(event, targetSession) const selectionTransformModifierState = (event: Pick) => selectionTransformModifiers({ ctrlKey: modifierActive(event, 'integerSelectionScale'), altKey: event.altKey, @@ -193,11 +314,17 @@ export function CanvasStage({ session }: { session: DocumentSession }) { altKey: inputRef.current.altHeld, shiftKey: inputRef.current.shiftHeld }) - const selectionMarqueeModifierState = (event: Pick) => ({ - fromCenter: Boolean(event.ctrlKey || event.metaKey), - proportional: event.shiftKey, - rotate: event.altKey - }) + const selectionMarqueeModifierState = (event: Pick) => { + const activeDrag = inputRef.current.drag + const proportional = activeDrag?.kind === 'marquee' + ? selectionMarqueeUsesConstraint(event, Boolean(activeDrag.selectionStart), activeDrag.selectionMode ?? session.selectionMode, activeDrag.marqueeAngle !== undefined) + : event.shiftKey + return { + fromCenter: Boolean(event.ctrlKey || event.metaKey), + proportional, + rotate: event.altKey + } + } const currentSelectionMarqueeModifierState = () => selectionMarqueeModifierState({ ctrlKey: inputRef.current.ctrlHeld, metaKey: false, @@ -207,6 +334,15 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const lineConnectionShortcut = shortcuts.lineConnectionMode ?? '' const lineConnectionConfigured = Boolean(lineConnectionShortcut.trim()) const lineConnectionActive = (event: Pick): boolean => lineConnectionConfigured && modifierActive(event, 'lineConnectionMode') + const lineConnectionPreviewActive = (event: Pick): boolean => Boolean( + !inputRef.current.drag + && !inputRef.current.sampling + && !inputRef.current.spaceHeld + && !canvasResizePreviewRef.current + && (session.tool === 'pencil' || session.tool === 'eraser') + && lineAnchor + && lineConnectionActive(event) + ) const constrainedLineTarget = (point: Point, event: Pick): Point => lineAnchor && modifierActive(event, 'constrainLineDirections') ? constrainLineEndpoint(lineAnchor, point, lineDirectionStep) : point @@ -249,6 +385,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { setBrushPreviewMode(preferences.brushPreviewMode) setCheckerboard(preferences.checkerboard) setGridColors({ pixelGridColor: preferences.pixelGridColor, gridColor: preferences.gridColor }) + setSliceColor(preferences.sliceColor) + setTextBoxColor(preferences.textBoxColor) + setCanvasResizeColor(preferences.canvasResizeColor) + setSliceOutlinesVisible(preferences.sliceOutlinesVisible) setWheelZoomEnabled(preferences.wheelZoomEnabled) setWheelZoomMode(preferences.wheelZoomMode) setShiftLinePreviewEnabled(preferences.shiftLinePreviewEnabled) @@ -258,14 +398,16 @@ export function CanvasStage({ session }: { session: DocumentSession }) { setEyedropperMagnifierStyle(preferences.eyedropperMagnifierStyle) setEyedropperMagnifierDistortionEnabled(preferences.eyedropperMagnifierDistortionEnabled) setMoveLayerContentPreviewEnabled(preferences.moveLayerContentPreviewEnabled) + setMoveLayerClickFlashEnabled(preferences.moveLayerClickFlashEnabled) setSelectionCrosshair(preferences.selectionCrosshair) setBalancedShiftLineEnabled(preferences.balancedShiftLineEnabled) setLineDirectionStep(preferences.lineDirectionStep) setOnionSkin(preferences.onionSkin) + setTimelineHidden(preferences.timelineHidden) setSymmetryAxisPreferences(preferences.symmetryAxis) setActiveTheme(resolveTheme(preferences.theme)) if (preferences.symmetryAxis.locked) symmetryDragRef.current = null - onionSkinCacheRef.current = null + onionSkinCacheRef.current.invalidateAll() if (!preferences.eyedropperMagnifierEnabled && eyedropperMagnifierRef.current) eyedropperMagnifierRef.current.hidden = true if (eyedropperMagnifierRef.current) eyedropperMagnifierRef.current.dataset.style = preferences.eyedropperMagnifierStyle if (!preferences.moveLayerContentPreviewEnabled) { @@ -274,6 +416,12 @@ export function CanvasStage({ session }: { session: DocumentSession }) { moveLayerContentPreviewTimerRef.current = null scheduleDraw() } + if (!preferences.moveLayerClickFlashEnabled) { + moveLayerClickFlashRef.current = null + if (moveLayerClickFlashTimerRef.current !== null) window.clearTimeout(moveLayerClickFlashTimerRef.current) + moveLayerClickFlashTimerRef.current = null + scheduleDraw() + } } window.addEventListener('moonsprite:preferences-changed', syncPreferences) return () => window.removeEventListener('moonsprite:preferences-changed', syncPreferences) @@ -281,13 +429,32 @@ export function CanvasStage({ session }: { session: DocumentSession }) { useEffect(() => { moveLayerContentPreviewRef.current = null + moveLayerClickFlashRef.current = null if (moveLayerContentPreviewTimerRef.current !== null) window.clearTimeout(moveLayerContentPreviewTimerRef.current) moveLayerContentPreviewTimerRef.current = null + if (moveLayerClickFlashTimerRef.current !== null) window.clearTimeout(moveLayerClickFlashTimerRef.current) + moveLayerClickFlashTimerRef.current = null return () => { if (moveLayerContentPreviewTimerRef.current !== null) window.clearTimeout(moveLayerContentPreviewTimerRef.current) + if (moveLayerClickFlashTimerRef.current !== null) window.clearTimeout(moveLayerClickFlashTimerRef.current) } }, [session.document.id]) + useEffect(() => { + const updatePreview = (event: Event): void => { + const detail = (event as CustomEvent<{ documentId: string; slices: SelectionRect[] | null }>).detail + if (detail.documentId !== session.document.id) return + autoSlicePreviewRef.current = detail.slices?.map((slice) => ({ ...slice })) ?? null + scheduleDraw() + } + window.addEventListener(SLICE_PREVIEW_EVENT, updatePreview) + return () => { + window.removeEventListener(SLICE_PREVIEW_EVENT, updatePreview) + autoSlicePreviewRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.document.id]) + useEffect(() => { const updatePreview = (event: Event): void => { const detail = (event as CustomEvent<{ documentId: string; preview: DocumentSession['canvasResizePreview'] }>).detail @@ -301,37 +468,62 @@ export function CanvasStage({ session }: { session: DocumentSession }) { }, [session.document.id]) useEffect(() => { - const releaseModifierSizing = (): void => { inputRef.current.modifierBrushSize = null } + const releaseModifierSizing = (event?: KeyboardEvent): void => { + inputRef.current.modifierBrushSize = null + if (!event || !modifierActive(event, 'brushSizeWheelAdjust')) { + wheelBrushSizePreviewRef.current = false + scheduleDraw() + } + } + const blur = (): void => releaseModifierSizing() window.addEventListener('keyup', releaseModifierSizing) - window.addEventListener('blur', releaseModifierSizing) + window.addEventListener('blur', blur) return () => { window.removeEventListener('keyup', releaseModifierSizing) - window.removeEventListener('blur', releaseModifierSizing) + window.removeEventListener('blur', blur) if (canvasResizeFrameRef.current !== null) window.cancelAnimationFrame(canvasResizeFrameRef.current) } - }, []) + }, [shortcuts]) const invalidateCompositeRect = (selection: SelectionRect | null | undefined): void => { - compositeCacheRef.current.invalidateRect(selection, session.document.width, session.document.height, session.document.animation?.activeFrameId) + const paintTarget = activePaintLayer(session) + compositeCacheRef.current.invalidateDocumentRect(selection, session.document, session.document.animation?.activeFrameId, [paintTarget.id]) } const invalidateStrokeSegment = (from: Point, to: Point): void => { - const stamp = brushStampDimensions(session.brushSize, activeBrushImage) - const { x: beforeX, y: beforeY } = brushStampAnchor(session.brushSize, activeBrushImage) - const afterX = stamp.width - beforeX - 1 - const afterY = stamp.height - beforeY - 1 - const fromPoints = symmetryPoints(from, session.document.width, session.document.height, session.symmetryAxes, symmetryCenter) - const toPoints = symmetryPoints(to, session.document.width, session.document.height, session.symmetryAxes, symmetryCenter) - const segments = fromPoints.length === toPoints.length - ? fromPoints.map((start, index) => ({ start, end: toPoints[index] })) - : fromPoints.flatMap((start) => toPoints.map((end) => ({ start, end }))) - for (const segment of segments) { - const left = Math.min(segment.start.x, segment.end.x) - beforeX - const top = Math.min(segment.start.y, segment.end.y) - beforeY - const right = Math.max(segment.start.x, segment.end.x) + afterX - const bottom = Math.max(segment.start.y, segment.end.y) + afterY - invalidateCompositeRect({ x: left, y: top, width: right - left + 1, height: bottom - top + 1 }) + for (const rect of brushStrokeInvalidationRects( + from, + to, + session.brushSize, + activeBrushImage, + session.document.width, + session.document.height, + session.symmetryAxes, + symmetryCenter + )) invalidateCompositeRect(rect) + } + + const stopAirbrushTimer = (): void => { + if (airbrushFrameRef.current !== null) window.cancelAnimationFrame(airbrushFrameRef.current) + airbrushFrameRef.current = null + } + + const scheduleAirbrushTimer = (): void => { + if (airbrushFrameRef.current !== null) return + const tick = (now: number): void => { + airbrushFrameRef.current = null + const drag = inputRef.current.drag + if (drag?.kind !== 'airbrush' || !drag.edit) return + const interval = Math.max(16, session.airbrushIntervalMs) + let batches = 0 + while (now >= (drag.nextAirbrushAt ?? now) && batches < 4) { + sprayAirbrushRef.current(drag) + drag.nextAirbrushAt = (drag.nextAirbrushAt ?? now) + interval + batches += 1 + } + airbrushFrameRef.current = window.requestAnimationFrame(tick) } + airbrushFrameRef.current = window.requestAnimationFrame(tick) } const flushSelectionPreview = (drag: DragState, render = false): void => { @@ -351,23 +543,27 @@ export function CanvasStage({ session }: { session: DocumentSession }) { : transformSelectionMask(transformSourceSelection, target, session.document.width, session.document.height, angle, drag.previewShear, false) if (drag.kind !== 'move-selection' && drag.selectionSource) { - invalidateCompositeRect(drag.selectionStart) - invalidateCompositeRect(drag.appliedSelection) - invalidateCompositeRect(drag.previewSelection) - const translation = drag.kind === 'move-content' && !hasSymmetry(session.symmetryAxes) && angle % 360 === 0 && !drag.previewShear && !target.flipHorizontal && !target.flipVertical && target.width === drag.selectionSource.selection.width && target.height === drag.selectionSource.selection.height - if (translation) { - if (drag.translationPreview) drag.previewEdit = null - else if (drag.previewEdit) { revertPixelEdit(session.document, drag.previewEdit); drag.previewEdit = null } - drag.translationPreview = applySelectionTranslationPreview(session.document, drag.selectionSource, target, drag.copy, drag.translationPreview, activePaintLayer(session)) + if (drag.deferredSelectionPreview) { + drag.appliedSelection = drag.previewSelection } else { - if (drag.translationPreview) { - restoreSelectionTranslationPreview(session.document, drag.translationPreview) - drag.translationPreview = null + invalidateCompositeRect(drag.selectionStart) + invalidateCompositeRect(drag.appliedSelection) + invalidateCompositeRect(drag.previewSelection) + const translation = drag.kind === 'move-content' && !hasSymmetry(session.symmetryAxes) && angle % 360 === 0 && !drag.previewShear && !target.flipHorizontal && !target.flipVertical && target.width === drag.selectionSource.selection.width && target.height === drag.selectionSource.selection.height + if (translation) { + if (drag.translationPreview) drag.previewEdit = null + else if (drag.previewEdit) { revertPixelEdit(session.document, drag.previewEdit); drag.previewEdit = null } + drag.translationPreview = applySelectionTranslationPreview(session.document, drag.selectionSource, target, drag.copy, drag.translationPreview, activePaintLayer(session)) + } else { + if (drag.translationPreview) { + restoreSelectionTranslationPreview(session.document, drag.translationPreview) + drag.translationPreview = null + } + if (drag.previewEdit) revertPixelEdit(session.document, drag.previewEdit) + drag.previewEdit = applySelectionTransform(session.document, drag.selectionSource, target, angle, drag.copy, drag.previewShear, session.symmetryAxes, symmetryCenter, activePaintLayer(session)) } - if (drag.previewEdit) revertPixelEdit(session.document, drag.previewEdit) - drag.previewEdit = applySelectionTransform(session.document, drag.selectionSource, target, angle, drag.copy, drag.previewShear, session.symmetryAxes, symmetryCenter, activePaintLayer(session)) + drag.appliedSelection = drag.previewSelection } - drag.appliedSelection = drag.previewSelection } if (render) { if (adjustmentPreviewEditRef.current) renderAdjustmentPreviewEdit(session.document.id, drag.previewSelection ?? null) @@ -385,6 +581,43 @@ export function CanvasStage({ session }: { session: DocumentSession }) { }) } + const canUseDeferredSelectionPreview = (layer: RasterLayer): boolean => !activeLayerMask(session) + && !session.view.relativeLuminance + && !hasSymmetry(session.symmetryAxes) + && !hasAdjustmentPreviewController(session.document.id) + && compositeCacheRef.current.supportsSelectionPreview(session.document, session.contentRevision, layer.id) + + const prepareDeferredFloatingSelectionPreview = (drag: DragState): void => { + if (!drag.floatingPaste || !drag.deferredSelectionPreview || !drag.selectionSource || !drag.transformStartTarget) return + drag.deferredSelectionRestoreTarget = { ...drag.transformStartTarget } + drag.deferredSelectionRestoreAngle = drag.startAngle ?? 0 + drag.deferredSelectionRestoreShear = drag.transformStartShear ? { ...drag.transformStartShear } : undefined + drag.deferredSelectionWasMaterialized = Boolean(drag.translationPreview || drag.previewEdit) + if (drag.translationPreview) restoreSelectionTranslationPreview(session.document, drag.translationPreview) + else if (drag.previewEdit) revertPixelEdit(session.document, drag.previewEdit) + invalidateCompositeRect(drag.selectionSource.selection) + invalidateCompositeRect(transformedSelectionBounds(drag.transformStartTarget, drag.startAngle ?? 0, drag.transformStartShear)) + drag.previewEdit = null + drag.translationPreview = null + } + + const restoreDeferredFloatingSelectionPreview = (drag: DragState): void => { + if (!drag.floatingPaste || !drag.deferredSelectionPreview || !drag.deferredSelectionWasMaterialized || !drag.selectionSource || !drag.deferredSelectionRestoreTarget) return + applySelectionTransform( + session.document, + drag.selectionSource, + drag.deferredSelectionRestoreTarget, + drag.deferredSelectionRestoreAngle ?? 0, + Boolean(drag.copy), + drag.deferredSelectionRestoreShear, + session.symmetryAxes, + symmetryCenter, + activePaintLayer(session) + ) + invalidateCompositeRect(drag.selectionSource.selection) + invalidateCompositeRect(drag.selectionStart) + } + const updateRotatableDragGeometry = ( drag: DragState, point: Point, @@ -399,13 +632,18 @@ export function CanvasStage({ session }: { session: DocumentSession }) { ? centeredShapeBounds(drag.start, adjustedPoint, modifiers.proportional, fixedRatio) : shapeBounds(drag.start, adjustedPoint, modifiers.proportional, fixedRatio)) let angle = drag.marqueeAngle ?? 0 + const modifierMode = resolveMarqueeModifierMode(modifiers, drag.marqueeModifierMode) + const rotating = modifierMode === 'rotate' - if (!modifiers.rotate && drag.marqueeRotationStart) { - drag.marqueeResizeStart = createMarqueeResizeStart(drag.marqueeBounds ?? drag.marqueeRotationStart.bounds, drag.marqueeRotationStart.lastPointer) + if (!rotating && drag.marqueeRotationStart) { + const rotationBounds = drag.marqueeBounds ?? drag.marqueeRotationStart.bounds + const resizeBounds = modifiers.fromCenter ? centerMarqueeBoundsAtCreationPoint(rotationBounds, drag.start) : rotationBounds + drag.marqueeBounds = resizeBounds + drag.marqueeResizeStart = createMarqueeResizeStart(resizeBounds, drag.marqueeRotationStart.lastPointer) drag.marqueeRotationStart = undefined } - if (modifiers.rotate) { + if (rotating) { if (!drag.marqueeRotationStart) drag.marqueeRotationStart = { pointer: adjustedPoint, lastPointer: adjustedPoint, angle, bounds: { ...bounds } } const rotationStart = drag.marqueeRotationStart bounds = rotationStart.bounds @@ -443,6 +681,23 @@ export function CanvasStage({ session }: { session: DocumentSession }) { modifiers: ReturnType ): void => { if (drag.kind !== 'marquee') return + if (drag.quickSelectCell) { + const currentCell = quickSelectionCellAt(session, point) + if (!currentCell) return + const target = quickSelectCellDragBounds(drag.quickSelectCell, currentCell) + const incoming = rectSelection(target.x, target.y, target.width, target.height) + drag.last = point + drag.marqueeBounds = target + drag.previewTarget = target + drag.marqueePreviewSelection = incoming + drag.previewSelection = combineSelection( + drag.selectionCommitStart ?? drag.selectionStart ?? null, + incoming, + drag.selectionMode ?? session.selectionMode + ) + scheduleDraw() + return + } const geometry = updateRotatableDragGeometry(drag, point, modifiers) if (!geometry) return const { target, angle } = geometry @@ -472,7 +727,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { point: Point, modifiers: ReturnType ): void => { - if (drag.kind !== 'transform-content' || !drag.selectionStart || !drag.selectionSource || !drag.handle) return + if (drag.kind !== 'transform-content' || !drag.selectionStart || !drag.handle) return const transformStart = drag.transformStartTarget ?? drag.selectionStart const target = resizeTransformedSelectionBounds( transformStart, @@ -481,12 +736,22 @@ export function CanvasStage({ session }: { session: DocumentSession }) { drag.handle, modifiers.proportional, modifiers.integerScale, - modifiers.fromCenter + modifiers.fromCenter, + modifiers.fromCenter ? drag.selectionPivotStart : undefined ) if (drag.previewTarget?.x === target.x && drag.previewTarget.y === target.y && drag.previewTarget.width === target.width && drag.previewTarget.height === target.height && drag.previewTarget.flipHorizontal === target.flipHorizontal && drag.previewTarget.flipVertical === target.flipVertical && drag.previewTarget.flipOriginX === target.flipOriginX && drag.previewTarget.flipOriginY === target.flipOriginY) return + if (!prepareSelectionTransformDrag(drag)) return drag.previewTarget = target drag.previewAngle = drag.startAngle ?? 0 drag.previewShear = drag.transformStartShear ? { ...drag.transformStartShear } : undefined + if (drag.selectionPivotStart) { + drag.previewPivot = selectionPivotAfterResize(transformStart, target, drag.selectionPivotStart, { + angle: drag.startAngle, + shear: drag.transformStartShear, + fromCenter: modifiers.fromCenter, + custom: drag.selectionPivotCustom + }) + } scheduleSelectionPreview(drag) } @@ -502,6 +767,23 @@ export function CanvasStage({ session }: { session: DocumentSession }) { endAdjustmentPreviewEdit(session.document.id) } + const prepareSelectionTransformDrag = (drag: DragState): boolean => { + if (!drag.selectionPreparationPending) return Boolean(drag.selectionSource) + drag.selectionPreparationPending = false + beginSelectionAdjustmentEdit() + const layer = activePaintLayer(session) + if (!drag.selectionSource && drag.selectionStart) { + drag.selectionSource = captureSelectionTransform(session.document, drag.selectionStart, layer, { cacheOpaqueOffsets: !drag.deferredSelectionPreview }) ?? undefined + } + if (!drag.selectionSource) { + endSelectionAdjustmentEdit() + return false + } + prepareDeferredFloatingSelectionPreview(drag) + if (drag.selectionStart) renderAdjustmentPreviewEdit(session.document.id, drag.selectionStart) + return true + } + useEffect(() => () => { if (!adjustmentPreviewEditRef.current) return adjustmentPreviewEditRef.current = false @@ -532,6 +814,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } const cancelActiveCanvasInteraction = (): void => { + stopAirbrushTimer() hideMoveLayerContentPreview() gradientPreviewCoverageCacheRef.current = null const drag = inputRef.current.resetInteraction() @@ -547,7 +830,11 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (adjustmentPreviewEditRef.current) prepareAdjustmentPreviewEdit(session.document.id) const state = useWorkspace.getState() let documentChanged = false - if (drag.floatingPaste) { + if (drag.selectionPreparationPending) { + // A press without a real transform never touched document pixels. + } else if (drag.floatingPaste && drag.deferredSelectionPreview) { + restoreDeferredFloatingSelectionPreview(drag) + } else if (drag.floatingPaste) { const target = drag.previewSelection ?? drag.selectionStart if (target && (drag.previewEdit || drag.translationPreview)) state.updateFloatingPastePreview(drag.previewEdit ?? null, target, drag.translationPreview, drag.previewTarget, drag.previewAngle, drag.previewShear) } else documentChanged = revertCancelledCanvasDragPixelChanges(session.document, drag) @@ -572,6 +859,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { }, false) documentChanged = true } + if (drag.kind === 'transform-text-box') { + state.cancelTextBoxTransform() + documentChanged = true + } if ((drag.kind === 'canvas-resize' || drag.kind === 'canvas-move') && drag.canvasPreview) { if (canvasResizeFrameRef.current !== null) window.cancelAnimationFrame(canvasResizeFrameRef.current) canvasResizeFrameRef.current = null @@ -602,6 +893,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (!canvas || !overlay) return const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session const selectionDrag = canvasGestureForPreview(inputRef.current.drag) + const textBoxTransform = currentSession.textBoxTransform + const activeTextBoxDrag = inputRef.current.drag?.kind === 'transform-text-box' || inputRef.current.drag?.kind === 'create-text-box' ? inputRef.current.drag : null + const selectedTextBox = selectedTextBoxForSession(currentSession) + const visibleTextBox = activeTextBoxDrag?.previewTarget ?? textToolBoxRef.current ?? textBoxTransform?.bounds ?? selectedTextBox const creatingSelection = selectionDrag?.kind === 'marquee' || selectionDrag?.kind === 'lasso' || selectionDrag?.kind === 'polygon-lasso' const transformedDrag = selectionDrag && (selectionDrag.kind === 'move-content' || selectionDrag.kind === 'transform-content' || selectionDrag.kind === 'rotate-content' || selectionDrag.kind === 'shear-content') ? selectionDrag @@ -611,16 +906,14 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const visibleSelection = selectionOverlayMaskForDrag(currentSession.selection, selectionDrag) const rect = stageSize() const dpr = window.devicePixelRatio || 1 - const backingWidth = Math.max(1, Math.round(rect.width * dpr)) - const backingHeight = Math.max(1, Math.round(rect.height * dpr)) - if (overlay.width !== backingWidth || overlay.height !== backingHeight) { overlay.width = backingWidth; overlay.height = backingHeight } + syncCanvasDisplaySize(overlay, rect.width, rect.height, dpr) const displayContext = overlay.getContext('2d') if (!displayContext) return displayContext.setTransform(dpr, 0, 0, dpr, 0, 0) displayContext.clearRect(0, 0, rect.width, rect.height) - const shouldDrawSelection = Boolean(visibleSelection) + const shouldDrawSelection = Boolean(visibleSelection || visibleTextBox) selectionOverlayVisibleRef.current = shouldDrawSelection - if (!shouldDrawSelection || !visibleSelection) return + if (!shouldDrawSelection) return const renderPlan = createCanvasRenderPlan(rect.width, rect.height, session.document, liveViewRef.current, rotationIndicatorPosition) const { rotated, sceneLeft, sceneTop, sceneWidth, sceneHeight } = renderPlan let context: RasterContext2D = displayContext @@ -655,7 +948,13 @@ export function CanvasStage({ session }: { session: DocumentSession }) { transformedShear ).map((point) => selectionScreenPoint(rect.width, rect.height, session.document.width, session.document.height, liveViewRef.current, point)) : undefined - const drawOverlaySelection = (selection: SelectionMask, showHandles: boolean): void => { + const pivotTarget = transformedTarget ?? visibleSelection ?? undefined + const visiblePivot = visibleSelection && !visibleTextBox && currentSession.view.showSelectionPivot !== false && !creatingSelection && pivotTarget + ? selectionDrag?.previewPivot + ?? currentSession.selectionPivot + ?? transformedSelectionPivotPreset(pivotTarget, 'center', transformedTarget ? transformedAngle : 0, transformedTarget ? transformedShear : undefined) + : null + const drawOverlaySelection = (selection: SelectionMask, showHandles: boolean, color?: string): void => { selectionBoundaryCacheRef.current = drawSelectionOutline({ context, selection, @@ -665,14 +964,17 @@ export function CanvasStage({ session }: { session: DocumentSession }) { viewportHeight: rect.height, rotationIndicatorPosition, cache: selectionBoundaryCacheRef.current, - outlineDark: activeTheme.variables['--theme-selection-outline-dark'], - outlineLight: activeTheme.variables['--theme-selection-outline-light'], + outlineDark: color ?? activeTheme.variables['--theme-selection-outline-dark'], + outlineLight: color ?? activeTheme.variables['--theme-selection-outline-light'], showOutline: !rotatingSelection && currentSession.view.showSelectionOutline !== false, showHandles, handlePoints: transformedHandlePoints }) } - drawOverlaySelection(visibleSelection, currentSession.tool === 'selection' && !creatingSelection) + if (visibleTextBox) { + const selection = rectSelection(visibleTextBox.x, visibleTextBox.y, visibleTextBox.width, visibleTextBox.height) + drawOverlaySelection(selection, currentSession.tool === 'text' && Boolean(selectedTextBox), `rgb(${textBoxColor.r} ${textBoxColor.g} ${textBoxColor.b} / ${textBoxColor.a / 255})`) + } else if (visibleSelection) drawOverlaySelection(visibleSelection, currentSession.tool === 'selection' && !creatingSelection) if (rotated) { displayContext.save() applyViewRotation(displayContext, rect.width, rect.height, liveViewRef.current) @@ -681,6 +983,20 @@ export function CanvasStage({ session }: { session: DocumentSession }) { displayContext.drawImage(scene, 0, 0, scene.width, scene.height, sceneLeft, sceneTop, scene.width / dpr, scene.height / dpr) displayContext.restore() } + const pivotImage = selectionPivotImageRef.current + if (visiblePivot && pivotImage?.complete && pivotImage.naturalWidth > 0) { + const point = displayedSelectionPoint(visiblePivot) + displayContext.save() + displayContext.imageSmoothingEnabled = false + displayContext.drawImage( + pivotImage, + Math.round(point.x) - SELECTION_PIVOT_ICON_OFFSET, + Math.round(point.y) - SELECTION_PIVOT_ICON_OFFSET, + SELECTION_PIVOT_ICON_SIZE, + SELECTION_PIVOT_ICON_SIZE + ) + displayContext.restore() + } } const draw = (): void => { @@ -693,18 +1009,25 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const currentLayerMask = activeLayerMask(currentSession) const isolatedLayerMask = currentSession.layerMaskIsolatedView ? currentLayerMask : null const currentHasRasterSelection = Boolean(currentLayerMask) || (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 temporaryMovePreviewActive = temporaryMoveActive({ + ctrlKey: inputRef.current.ctrlHeld, + metaKey: false, + altKey: inputRef.current.altHeld, + shiftKey: inputRef.current.shiftHeld + }, currentSession) + const brushSizeAdjustmentPreviewActive = wheelBrushSizePreviewRef.current || Boolean(inputRef.current.modifierBrushSize) + const canRenderToolPreview = !temporaryMoveSuppressesToolPreview(temporaryMovePreviewActive, brushSizeAdjustmentPreviewActive) && !canvasResizePreviewRef.current && currentHasRasterSelection && isLayerEffectivelyVisible(currentSession.document, currentActiveLayer) && !isLayerEffectivelyLocked(currentSession.document, currentActiveLayer) const rect = stageSize() const dpr = window.devicePixelRatio || 1 - const backingWidth = Math.max(1, Math.round(rect.width * dpr)) - const backingHeight = Math.max(1, Math.round(rect.height * dpr)) - if (canvas.width !== backingWidth || canvas.height !== backingHeight) { canvas.width = backingWidth; canvas.height = backingHeight } + syncCanvasDisplaySize(canvas, rect.width, rect.height, dpr) const displayContext = canvas.getContext('2d') if (!displayContext) return displayContext.setTransform(dpr, 0, 0, dpr, 0, 0) displayContext.clearRect(0, 0, rect.width, rect.height) const document = session.document const view = liveViewRef.current + const activeDrag = inputRef.current.drag + const selectionPreviewOwner = deferredSelectionPreviewOwner(activeDrag, Boolean(currentSession.pendingPaste?.previewDeferred)) const smoothPixelSampling = pixelSamplingMode(view.zoom) === 'smooth' const renderPlan = createCanvasRenderPlan(rect.width, rect.height, document, view, rotationIndicatorPosition) const { rotated, viewport, sceneLeft, sceneTop, sceneWidth, sceneHeight, originX, originY, canvasWidth, canvasHeight, fromX, fromY, toX, toY } = renderPlan @@ -801,56 +1124,30 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } if (toX > fromX && toY > fromY) { - if (!isolatedLayerMask && onionSkin.enabled && !currentSession.animationPlaying) { + if (!isolatedLayerMask && !timelineHidden && onionSkin.enabled && !currentSession.animationPlaying) { const timeline = currentSession.document.animation if (timeline && timeline.frames.length > 1) { const refs = onionSkinFrameRefs(timeline, onionSkin.previousFrames, onionSkin.nextFrames) - const cacheKey = [currentSession.document.id, timeline.activeFrameId, onionSkin.previousFrames, onionSkin.nextFrames, onionSkin.previousOpacity, onionSkin.nextOpacity, onionSkin.previousColor.r, onionSkin.previousColor.g, onionSkin.previousColor.b, onionSkin.previousColor.a, onionSkin.nextColor.r, onionSkin.nextColor.g, onionSkin.nextColor.b, onionSkin.nextColor.a].join(':') - const cachedOnionSkin = onionSkinCacheRef.current - const invalidation = currentSession.contentInvalidation - const canPatch = cachedOnionSkin - && cachedOnionSkin.key === cacheKey - && cachedOnionSkin.revision !== currentSession.contentRevision - && invalidation?.kind === 'region' - && invalidation.fromRevision === cachedOnionSkin.revision - && invalidation.revision === currentSession.contentRevision - if (cachedOnionSkin && canPatch) { - const left = Math.max(0, Math.floor(invalidation.rect.x)) - const top = Math.max(0, Math.floor(invalidation.rect.y)) - const right = Math.min(document.width, Math.ceil(invalidation.rect.x + invalidation.rect.width)) - const bottom = Math.min(document.height, Math.ceil(invalidation.rect.y + invalidation.rect.height)) - if (right > left && bottom > top) for (const surface of cachedOnionSkin.surfaces) { - const tint = surface.side === 'previous' ? onionSkin.previousColor : onionSkin.nextColor - const opacity = surface.side === 'previous' ? onionSkin.previousOpacity : onionSkin.nextOpacity - const pixels = tintOnionSkinPixels(compositeAnimationFrameRegion(currentSession.document, surface.frameId, left, top, right - left, bottom - top), tint, opacity, surface.distance) - surface.canvas.getContext('2d')?.putImageData(new ImageData(pixels as Uint8ClampedArray, right - left, bottom - top), left, top) - } - cachedOnionSkin.revision = currentSession.contentRevision - } else if (!cachedOnionSkin || cachedOnionSkin.key !== cacheKey || cachedOnionSkin.revision !== currentSession.contentRevision) { - onionSkinCacheRef.current = { - key: cacheKey, - revision: currentSession.contentRevision, - surfaces: refs.map((ref) => { - const tint = ref.side === 'previous' ? onionSkin.previousColor : onionSkin.nextColor - const opacity = ref.side === 'previous' ? onionSkin.previousOpacity : onionSkin.nextOpacity - const pixels = tintOnionSkinPixels(compositeAnimationFrame(currentSession.document, ref.frameId), tint, opacity, ref.distance) - const canvas = new OffscreenCanvas(document.width, document.height) - canvas.getContext('2d')?.putImageData(new ImageData(pixels as Uint8ClampedArray, document.width, document.height), 0, 0) - return { ...ref, canvas } - }) - } - } - context.save() - context.beginPath() - context.rect(originX, originY, canvasWidth, canvasHeight) - context.clip() - context.imageSmoothingEnabled = smoothPixelSampling - if (smoothPixelSampling) context.imageSmoothingQuality = 'high' - for (const surface of onionSkinCacheRef.current?.surfaces ?? []) context.drawImage(surface.canvas, originX, originY, canvasWidth, canvasHeight) - context.restore() + onionSkinCacheRef.current.draw({ + context, + document: currentSession.document, + refs, + style: onionSkin, + originX, + originY, + canvasWidth, + canvasHeight, + fromX, + fromY, + toX, + toY, + zoom: view.zoom, + revision: currentSession.contentRevision, + invalidation: currentSession.contentInvalidation, + imageSmoothingEnabled: smoothPixelSampling + }) } } - const activeDrag = inputRef.current.drag?.kind compositeCacheRef.current.draw({ context, document, @@ -868,9 +1165,94 @@ export function CanvasStage({ session }: { session: DocumentSession }) { contentInvalidation: currentSession.contentInvalidation, frameId: document.animation?.activeFrameId, isolatedLayerMask: isolatedLayerMask ?? undefined, - activeDrag, - imageSmoothingEnabled: smoothPixelSampling + imageSmoothingEnabled: smoothPixelSampling, + movingLayerIds: activeDrag?.kind === 'move-layer' && !activeDrag.duplicatedLayer && activeDrag.layerContentBounds + ? activeDrag.animationCellKeys?.length + ? [...new Set(activeDrag.animationCellKeys.map((key) => parseAnimationCelKey(key)?.layerId).filter((id): id is string => Boolean(id)))] + : activeDrag.layerIds + : undefined, + selectionPreview: selectionPreviewOwner === 'active' && activeDrag?.selectionSource && activeDrag.previewTarget + ? { + layerId: currentActiveLayer.id, + source: activeDrag.selectionSource, + target: activeDrag.previewTarget, + angle: activeDrag.previewAngle ?? 0, + shear: activeDrag.previewShear, + copy: Boolean(activeDrag.copy) + } + : selectionPreviewOwner === 'pending' && currentSession.pendingPaste + ? { + layerId: currentSession.pendingPaste.layerId, + source: currentSession.pendingPaste.source, + target: currentSession.pendingPaste.transformTarget ?? currentSession.pendingPaste.target, + angle: currentSession.pendingPaste.transformAngle ?? 0, + shear: currentSession.pendingPaste.transformShear, + copy: currentSession.pendingPaste.copy + } + : undefined }) + const textPreview = textToolPreviewRef.current + if (textPreview?.format === 'rgba') { + const previewCanvas = new OffscreenCanvas(textPreview.width, textPreview.height) + const previewContext = previewCanvas.getContext('2d') + previewContext?.putImageData(new ImageData(textPreview.pixels.slice(), textPreview.width, textPreview.height), 0, 0) + context.save() + context.imageSmoothingEnabled = false + context.drawImage( + previewCanvas, + originX + textPreview.offsetX * view.zoom, + originY + textPreview.offsetY * view.zoom, + textPreview.width * view.zoom, + textPreview.height * view.zoom + ) + context.restore() + } + let moveLayerFlash = moveLayerClickFlashEnabled ? moveLayerClickFlashRef.current : null + if (moveLayerFlash && performance.now() >= moveLayerFlash.expiresAt) { + moveLayerClickFlashRef.current = null + moveLayerFlash = null + } + if (moveLayerFlash) { + const layer = document.layers.find((candidate) => candidate.id === moveLayerFlash.layerId) + if (layer) { + const currentX = moveLayerFlash.bounds.x + layer.offsetX - moveLayerFlash.layerOffsetX + const currentY = moveLayerFlash.bounds.y + layer.offsetY - moveLayerFlash.layerOffsetY + const visibleX = Math.max(0, Math.floor(fromX), currentX) + const visibleY = Math.max(0, Math.floor(fromY), currentY) + const visibleRight = Math.min(document.width, Math.ceil(toX), currentX + moveLayerFlash.bounds.width) + const visibleBottom = Math.min(document.height, Math.ceil(toY), currentY + moveLayerFlash.bounds.height) + const visibleWidth = Math.max(0, visibleRight - visibleX) + const visibleHeight = Math.max(0, visibleBottom - visibleY) + if (visibleWidth > 0 && visibleHeight > 0) { + const flashPixels = new Uint8ClampedArray(visibleWidth * visibleHeight * 4) + for (let y = 0; y < visibleHeight; y += 1) { + for (let x = 0; x < visibleWidth; x += 1) { + const source = readLayerColorAt(document, layer, visibleX + x, visibleY + y) + if (source.a === 0) continue + const value = colorLuminance(source) > 145 ? 0 : 255 + const index = (y * visibleWidth + x) * 4 + flashPixels[index] = value + flashPixels[index + 1] = value + flashPixels[index + 2] = value + flashPixels[index + 3] = source.a + } + } + const flashCanvas = new OffscreenCanvas(visibleWidth, visibleHeight) + flashCanvas.getContext('2d')?.putImageData(new ImageData(flashPixels, visibleWidth, visibleHeight), 0, 0) + context.save() + context.globalCompositeOperation = 'source-over' + context.imageSmoothingEnabled = false + context.drawImage( + flashCanvas, + originX + visibleX * view.zoom, + originY + visibleY * view.zoom, + visibleWidth * view.zoom, + visibleHeight * view.zoom + ) + context.restore() + } + } + } if (view.showPixelGrid && shouldRenderPixelGrid(view.zoom)) drawGrid(0, 0, 1, 1, gridColors.pixelGridColor) } @@ -878,7 +1260,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const cachedPointSampler = compositePointSamplerRef.current const compositePointSampler = cachedPointSampler && cachedPointSampler.document === document && cachedPointSampler.revision === currentSession.contentRevision ? cachedPointSampler.sampler - : createCompositePointSampler(document) + : createNormalCompositePointSampler(document) ?? createCompositePointSampler(document) if (compositePointSampler !== cachedPointSampler?.sampler) compositePointSamplerRef.current = { document, revision: currentSession.contentRevision, sampler: compositePointSampler } const cachedReplacementSampler = compositeReplacementSamplerRef.current const compositePointReplacementSampler = cachedReplacementSampler @@ -894,16 +1276,17 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (isolatedLayerMask) return readLayerMaskDisplayColorAt(isolatedLayerMask, x, y) return compositePointSampler(x, y) } - const previewColorAt = (pixelX: number, pixelY: number, erase = false, coverage = 255, paintColor = session.primaryColor): RgbaColor => { - const layerColor = readLayerColorAt(document, activeLayer, pixelX, pixelY) + const previewColorAt = (pixelX: number, pixelY: number, erase = false, coverage = 255, paintColor = session.primaryColor, baseColor?: RgbaColor): RgbaColor => { + const layerColor = baseColor ?? readLayerColorAt(document, activeLayer, pixelX, pixelY) const replacement = erase ? coverage === 255 ? TRANSPARENT : { ...layerColor, a: Math.round(layerColor.a * (1 - coverage / 255)) } : coverage < 255 || (paintColor.a > 0 && paintColor.a < 255) ? blendOver(layerColor, { ...paintColor, a: Math.round(paintColor.a * coverage / 255) }) : paintColor + const resolvedReplacement = resolveLayerCanvasColor(document, activeLayer, replacement) return isolatedLayerMask - ? layerMaskDisplayColor(replacement) - : compositePointReplacementSampler(pixelX, pixelY, replacement) + ? layerMaskDisplayColor(resolvedReplacement) + : compositePointReplacementSampler(pixelX, pixelY, resolvedReplacement) } const previewPixelRect = (pixelX: number, pixelY: number): { x: number; y: number; width: number; height: number } => deviceAlignedPixelRect(originX, originY, view.zoom, pixelX, pixelY, dpr) @@ -1008,27 +1391,37 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } context.restore() } - const drawStrokePreview = (from: Point, to: Point, erase = false): void => { + const drawBrushPathPreview = (points: readonly Point[], color: RgbaColor, erase = false, baseline?: ReadonlyMap): void => { + if (points.length === 0) return const stamp = brushStampDimensions(session.brushSize, activeBrushImage) const { x: beforeX, y: beforeY } = brushStampAnchor(session.brushSize, activeBrushImage) - const patternOrigin = brushPatternOrigin(from) + const patternOrigin = brushPatternOrigin(points[0]) const drawn = new Set() - const centers = balancedShiftLineEnabled ? balancedStairLinePoints(from, to) : rasterLinePoints(from, to) - for (const center of centers) { + for (const center of brushPathStampPoints(points, session.brushSize, activeBrushImage)) { 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) + const mask = brushMaskOffsets(session.brushSize, session.brushShape, activeBrushTexture, session.brushTextureScale, x - beforeX, y - beforeY, activeBrushImage, session.brushImageSettings, proceduralAntialiasStrength, activeBrushPreviewMode, patternOrigin.x, patternOrigin.y) for (const offset of mask) { for (const { x: pixelX, y: pixelY } of symmetryPoints({ x: x - beforeX + offset.x, y: y - beforeY + offset.y }, document.width, document.height, session.symmetryAxes, symmetryCenter)) { const index = pixelY * document.width + pixelX if (pixelX < fromX || pixelY < fromY || pixelX >= toX || pixelY >= toY || drawn.has(index) || (session.selection && !selectionContains(session.selection, pixelX, pixelY))) continue drawn.add(index) - drawPreviewPixel(pixelX, pixelY, previewColorAt(pixelX, pixelY, erase, offset.coverage, offset.color ?? session.primaryColor)) + const layerIndex = baseline ? layerIndexAt(activeLayer, pixelX, pixelY) : null + const packedBase = layerIndex === null ? undefined : baseline?.get(layerIndex) + const baseColor = packedBase === undefined + ? undefined + : activeLayer.format === 'rgba' + ? unpackColor(packedBase) + : getPaletteEntry(document, packedBase).color + drawPreviewPixel(pixelX, pixelY, previewColorAt(pixelX, pixelY, erase, offset.coverage, offset.color ?? color, baseColor)) } } } } + const drawStrokePreview = (from: Point, to: Point, erase = false, baseline?: ReadonlyMap): void => { + const points = balancedShiftLineEnabled ? balancedStairLinePoints(from, to) : rasterLinePoints(from, to) + drawBrushPathPreview(points, session.primaryColor, erase, baseline) + } if (session.outlinePreview && session.selection) { const outlineLayer = activePaintLayer(currentSession) @@ -1044,7 +1437,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const color = session.outlinePreview.color.a > 0 && session.outlinePreview.color.a < 255 ? blendOver(base, session.outlinePreview.color) : session.outlinePreview.color - drawPreviewPixel(pixelX, pixelY, isolatedLayerMask ? layerMaskDisplayColor(color) : compositePixelWithLayerColor(document, index, outlineLayer.id, color)) + const resolvedColor = resolveLayerCanvasColor(document, outlineLayer, color) + drawPreviewPixel(pixelX, pixelY, isolatedLayerMask ? layerMaskDisplayColor(resolvedColor) : compositePixelWithLayerColor(document, index, outlineLayer.id, resolvedColor)) } } @@ -1063,6 +1457,30 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } } } + if (canRenderToolPreview && drag && (drag.kind === 'freeform-shape' || drag.kind === 'polygon-shape')) { + const color = drag.color ?? session.primaryColor + let points: readonly Point[] = [] + if (drag.kind === 'freeform-shape') points = filledShapePathPixelPoints(document, drag.path ?? []) + else { + const path = drag.path ?? [] + const previewPath = path.length > 0 ? [...path, drag.last] : path + points = previewPath.length >= 3 ? filledShapePathPixelPoints(document, polygonLassoClosedPathPoints(previewPath, balancedShiftLineEnabled)) : polygonLassoPreviewPoints(path, drag.last, false, balancedShiftLineEnabled) + } + for (const sourcePoint of points) { + for (const point of symmetryPoints(sourcePoint, document.width, document.height, session.symmetryAxes, symmetryCenter)) drawPreviewPixel(point.x, point.y, previewColorAt(point.x, point.y, false, 255, color)) + } + } + if (canRenderToolPreview && drag && (drag.kind === 'line-shape' || drag.kind === 'curve-shape')) { + const rawPoints = drag.kind === 'line-shape' + ? lineShapePixelPoints(drag.start, drag.last, balancedShiftLineEnabled) + : bezierCurvePixelPoints( + drag.start, + drag.curveControls ?? curveDefaultControls(drag.start, drag.curveEnd ?? drag.last, drag.curveAnchorCount ?? session.curveAnchorCount), + drag.curveEnd ?? drag.last + ) + const points = session.perfectPixels ? perfectPixelPathPoints(rawPoints) : rawPoints + drawBrushPathPreview(points, drag.color ?? session.primaryColor) + } if (canRenderToolPreview && drag?.kind === 'gradient') { const moved = drag.start.x !== drag.last.x || drag.start.y !== drag.last.y if (moved) { @@ -1133,7 +1551,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const gradientColor = sampledGradientColor ?? sampleGradient(sampleX, sampleY) const layerColor = readLayerColorAt(document, activeLayer, sampleX, sampleY) const replacement = gradientColor.a > 0 && gradientColor.a < 255 ? blendOver(layerColor, gradientColor) : gradientColor - const previewColor = isolatedLayerMask ? layerMaskDisplayColor(replacement) : sampleCompositeReplacement(sampleX, sampleY, replacement) + const resolvedReplacement = resolveLayerCanvasColor(document, activeLayer, replacement) + const previewColor = isolatedLayerMask ? layerMaskDisplayColor(resolvedReplacement) : sampleCompositeReplacement(sampleX, sampleY, resolvedReplacement) const displayColor = view.relativeLuminance ? relativeLuminanceColor(previewColor) : previewColor const transparency = transparencyColorAt(sampleX, sampleY, checkerboard) const composited = displayColor.a > 0 @@ -1338,7 +1757,17 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } if (shiftLinePreviewEnabled && lineConnectionConfigured && canRenderToolPreview && !inputRef.current.spaceHeld && !inputRef.current.sampling && (session.tool === 'pencil' || session.tool === 'eraser') && inputRef.current.shiftLinePreview && inputRef.current.pointer.visible && lineAnchor) { const target = constrainedLineTarget(inputRef.current.pointer.point, { ctrlKey: inputRef.current.ctrlHeld, metaKey: false, altKey: inputRef.current.altHeld, shiftKey: inputRef.current.shiftHeld }) - drawStrokePreview(lineAnchor, target, session.tool === 'eraser') + const anchorHistory = lineAnchorHistoryRef.current + const baseline = anchorHistory + && anchorHistory.documentId === session.document.id + && anchorHistory.layerId === activeLayer.id + && anchorHistory.tool === session.tool + && anchorHistory.point.x === lineAnchor.x + && anchorHistory.point.y === lineAnchor.y + && session.history.latestUndoEntry === anchorHistory.entry + ? anchorHistory.baseline + : undefined + drawStrokePreview(lineAnchor, target, session.tool === 'eraser', baseline) } if (hasSymmetry(session.symmetryAxes)) { @@ -1351,7 +1780,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { context.lineWidth = symmetryAxisPreferences.thickness context.setLineDash([]) for (const axis of (['horizontal', 'vertical', 'diagonalUp', 'diagonalDown'] as SymmetryAxis[])) { - if (!session.symmetryAxes[axis]) continue + if (!symmetryGuideAxisEnabled(session.symmetryAxes, axis)) continue const segment = symmetryAxisSegment(axis, document.width, document.height, symmetryCenter) if (!segment) continue context.beginPath() @@ -1434,10 +1863,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { return } context.save() - context.strokeStyle = activeTheme.definition.seeds.workspace + context.strokeStyle = `rgb(${canvasResizeColor.r} ${canvasResizeColor.g} ${canvasResizeColor.b} / ${canvasResizeColor.a / 255})` context.lineWidth = 1 context.strokeRect(Math.round(originX) + 0.5, Math.round(originY) + 0.5, canvasWidth, canvasHeight) - context.strokeStyle = activeTheme.definition.seeds.accent context.lineWidth = 2 context.setLineDash([]) context.beginPath() @@ -1454,7 +1882,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { }) } const selectionDrag = canvasGestureForPreview(inputRef.current.drag) - if (selectionDrag?.kind === 'marquee' && selectionDrag.moved) { + if (selectionDrag?.kind === 'marquee' && (selectionDrag.moved || selectionDrag.quickSelectCell)) { if (selectionDrag.marqueePreviewSelection) drawSelectionPathPreview(selectionPreviewPixels(selectionDrag.marqueePreviewSelection)) } if ((selectionDrag?.kind === 'lasso' || selectionDrag?.kind === 'polygon-lasso') && (selectionDrag.path?.length ?? 0) > 0) { @@ -1504,6 +1932,22 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } } + const activeSliceCreation = inputRef.current.drag?.kind === 'create-slice' + if (sliceTool && (!inputRef.current.drag || activeSliceCreation) && !inputRef.current.spaceHeld && !inputRef.current.sampling && inputRef.current.pointer.visible) { + const pointer = inputRef.current.pointer + const point = pointer.point + const selectedIds = currentSession.selectedSliceIds?.length ? currentSession.selectedSliceIds : currentSession.selectedSliceId ? [currentSession.selectedSliceId] : [] + const selectedSlice = selectedIds.length === 1 ? currentSession.document.slices?.find((slice) => slice.id === selectedIds[0]) ?? null : null + const handle = selectedSlice ? sliceHandleAt(pointer.clientX, pointer.clientY, selectedSlice) : null + const hit = sliceAtPoint(currentSession.document.slices ?? [], point.x, point.y) + const insideDocument = point.x >= 0 && point.y >= 0 && point.x < document.width && point.y < document.height + if (insideDocument && !handle && !hit) { + const sampled = sampleCompositeForPreview(point.x, point.y) + const background = sampled.a > 0 ? sampled : transparencyColorAt(point.x, point.y, checkerboard) + drawSelectionCursorCorners(point.x, point.y, colorLuminance(background) > 145 ? activeTheme.variables['--theme-selection-outline-dark'] : activeTheme.variables['--theme-selection-outline-light']) + } + } + if (canRenderToolPreview && !inputRef.current.spaceHeld && inputRef.current.pointer.visible && !inputRef.current.sampling && session.tool === 'fill' && fillKind === 'bucket') { const point = inputRef.current.pointer.point if (point.x >= 0 && point.y >= 0 && point.x < document.width && point.y < document.height && (!session.selection || selectionContains(session.selection, point.x, point.y))) { @@ -1511,7 +1955,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } } - if (canRenderToolPreview && !inputRef.current.spaceHeld && inputRef.current.pointer.visible && !inputRef.current.sampling && session.tool === 'shape' && drag?.kind !== 'shape') { + if (canRenderToolPreview && !inputRef.current.spaceHeld && inputRef.current.pointer.visible && !inputRef.current.sampling && (session.tool === 'shape' || session.tool === 'line') && !drag) { const point = inputRef.current.pointer.point const layer = activePaintLayer(currentSession) if (point.x >= 0 && point.y >= 0 && point.x < document.width && point.y < document.height && !isLayerEffectivelyLocked(document, layer) && (!session.selection || selectionContains(session.selection, point.x, point.y))) { @@ -1542,7 +1986,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } } const occupied = new Set(previewPoints.keys()) - const sampled = erasing ? sampleCompositeForPreview(point.x, point.y) : drawing ? drag.color ?? session.primaryColor : session.primaryColor + const sampled = erasing + ? sampleCompositeForPreview(point.x, point.y) + : resolveLayerCanvasColor(document, activeLayer, drawing ? drag.color ?? session.primaryColor : session.primaryColor) const luminance = colorLuminance(sampled) context.strokeStyle = luminance > 145 ? activeTheme.variables['--theme-selection-outline-dark'] : activeTheme.variables['--theme-selection-outline-light'] context.lineWidth = Math.max(1, Math.min(2, view.zoom / 4)) @@ -1569,6 +2015,45 @@ export function CanvasStage({ session }: { session: DocumentSession }) { context.restore() } + if (canRenderToolPreview && !inputRef.current.spaceHeld && inputRef.current.pointer.visible && !inputRef.current.sampling && session.tool === 'airbrush') { + const point = inputRef.current.pointer.point + const spraySize = session.airbrushScatterRadius * 2 + 1 + const sprayAnchor = brushStampAnchor(spraySize, null) + const sprayMask = brushMaskOffsets(spraySize, 'round') + const sprayPoints = new Map() + const spraySourcePoints = sprayMask.map((offset) => ({ x: point.x - sprayAnchor.x + offset.x, y: point.y - sprayAnchor.y + offset.y })) + for (const target of airbrushSymmetryPoints(spraySourcePoints, document.width, document.height, session.symmetryAxes, symmetryCenter)) { + sprayPoints.set(`${target.x}:${target.y}`, target) + } + const sampled = sampleCompositeForPreview(point.x, point.y) + context.save() + context.strokeStyle = colorLuminance(sampled) > 145 ? activeTheme.variables['--theme-selection-outline-dark'] : activeTheme.variables['--theme-selection-outline-light'] + context.lineWidth = Math.max(1, Math.min(2, view.zoom / 4)) + context.beginPath() + for (const sprayPoint of sprayPoints.values()) { + const pixelRect = previewPixelRect(sprayPoint.x, sprayPoint.y) + const left = !sprayPoints.has(`${sprayPoint.x - 1}:${sprayPoint.y}`) + const right = !sprayPoints.has(`${sprayPoint.x + 1}:${sprayPoint.y}`) + const top = !sprayPoints.has(`${sprayPoint.x}:${sprayPoint.y - 1}`) + const bottom = !sprayPoints.has(`${sprayPoint.x}:${sprayPoint.y + 1}`) + if (left) { context.moveTo(pixelRect.x, pixelRect.y); context.lineTo(pixelRect.x, pixelRect.y + pixelRect.height) } + if (right) { context.moveTo(pixelRect.x + pixelRect.width, pixelRect.y); context.lineTo(pixelRect.x + pixelRect.width, pixelRect.y + pixelRect.height) } + if (top) { context.moveTo(pixelRect.x, pixelRect.y); context.lineTo(pixelRect.x + pixelRect.width, pixelRect.y) } + if (bottom) { context.moveTo(pixelRect.x, pixelRect.y + pixelRect.height); context.lineTo(pixelRect.x + pixelRect.width, pixelRect.y + pixelRect.height) } + } + const particleSize = airbrushParticleSize(session.airbrushParticleRadius) + const particleAnchor = brushStampAnchor(particleSize, null) + const particleSourcePoints = brushMaskOffsets(particleSize, session.airbrushParticleShape).map((offset) => ({ + x: point.x - particleAnchor.x + offset.x, + y: point.y - particleAnchor.y + offset.y + })) + for (const target of airbrushSymmetryPoints(particleSourcePoints, document.width, document.height, session.symmetryAxes, symmetryCenter)) { + drawPreviewPixel(target.x, target.y, previewColorAt(target.x, target.y)) + } + context.stroke() + context.restore() + } + if (view.showGrid && toX > fromX && toY > fromY) { const grid = view.grid ?? DEFAULT_GRID_SETTINGS drawGrid(grid.x, grid.y, grid.width, grid.height, gridColors.gridColor) @@ -1593,6 +2078,39 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } } + if (sliceTool || sliceOutlinesVisible) { + const sliceDrag = inputRef.current.drag + const previewSlice = sliceDrag?.kind === 'create-slice' && sliceDrag.moved ? sliceDrag.previewTarget : null + const selectedSliceIds = new Set(sliceTool ? (currentSession.selectedSliceIds?.length ? currentSession.selectedSliceIds : currentSession.selectedSliceId ? [currentSession.selectedSliceId] : []) : []) + const slices = currentSession.document.slices ?? [] + const drawSlice = (slice: SelectionRect, selected: boolean, handles = false): void => { + const left = originX + slice.x * view.zoom + const top = originY + slice.y * view.zoom + const width = slice.width * view.zoom + const height = slice.height * view.zoom + context.save() + const color = `rgb(${sliceColor.r} ${sliceColor.g} ${sliceColor.b} / ${sliceColor.a / 255})` + context.strokeStyle = color + context.lineWidth = selected ? 2 : 1 + context.setLineDash([]) + context.strokeRect(Math.round(left) + 0.5, Math.round(top) + 0.5, Math.max(1, Math.round(width)), Math.max(1, Math.round(height))) + if (selected && handles) { + context.fillStyle = color + const handles = [[left, top], [left + width / 2, top], [left + width, top], [left, top + height / 2], [left + width, top + height / 2], [left, top + height], [left + width / 2, top + height], [left + width, top + height]] + for (const [x, y] of handles) context.fillRect(Math.round(x) - 3, Math.round(y) - 3, 7, 7) + } + context.restore() + } + for (const slice of slices) { + const resizePreview = sliceDrag?.kind === 'resize-slice' && sliceDrag.sliceId === slice.id ? sliceDrag.previewTarget : null + const preview = resizePreview ?? (sliceDrag?.copy ? null : sliceDrag?.slicePreviewTargets?.[slice.id]) + drawSlice(preview ?? slice, selectedSliceIds.has(slice.id), selectedSliceIds.size === 1 && selectedSliceIds.has(slice.id)) + } + if (sliceDrag?.copy && sliceDrag.slicePreviewTargets) for (const slice of Object.values(sliceDrag.slicePreviewTargets)) drawSlice(slice, true) + if (previewSlice) drawSlice(previewSlice, true) + if (autoSlicePreviewRef.current) for (const slice of autoSlicePreviewRef.current) drawSlice(slice, true) + } + // Keep the document boundary above selections, grids, and other previews. context.save() context.globalCompositeOperation = 'source-over' @@ -1643,7 +2161,20 @@ export function CanvasStage({ session }: { session: DocumentSession }) { }) } + useEffect(() => { + const updateTextPreview = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail || detail.documentId !== session.document.id) return + textToolPreviewRef.current = detail.surface + if (detail.box !== undefined) textToolBoxRef.current = detail.box + scheduleDraw() + } + window.addEventListener(TEXT_TOOL_PREVIEW_EVENT, updateTextPreview) + return () => window.removeEventListener(TEXT_TOOL_PREVIEW_EVENT, updateTextPreview) + }, [session.document.id]) + drawRef.current = draw + selectionOverlayDrawRef.current = drawSelectionOverlay useLayoutEffect(() => { const previousSize = renderDocumentSizeRef.current @@ -1651,7 +2182,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (documentSizeChanged) { renderDocumentSizeRef.current = { width: session.document.width, height: session.document.height } compositeCacheRef.current.invalidateAll() - onionSkinCacheRef.current = null + onionSkinCacheRef.current.invalidateAll() canvasResizeSurfaceRef.current = null canvasResizePreviewRef.current = null pendingCanvasResizeRef.current = null @@ -1674,6 +2205,13 @@ export function CanvasStage({ session }: { session: DocumentSession }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.document.width, session.document.height, session.view.rotation, session.view.mirrored, session.view.mirroredVertical, session.view.panX, session.view.panY, session.view.zoom]) + useLayoutEffect(() => { + scheduleDraw() + // Layer selection changes do not increment the document revision, but they + // do change whether an editable text box belongs in the overlay. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.selectedLayerIds.join('\0'), session.selectedGroupIds.join('\0')]) + useEffect(() => { const updateShiftPreview = (active: boolean): void => { if (inputRef.current.shiftLinePreview === active) return @@ -1689,6 +2227,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { scheduleDraw() } const keyDown = (event: KeyboardEvent): void => { + const controlWasHeld = inputRef.current.ctrlHeld if (event.key === 'Alt') inputRef.current.altHeld = true if (event.key === 'Control') { inputRef.current.ctrlHeld = true @@ -1697,6 +2236,31 @@ export function CanvasStage({ session }: { session: DocumentSession }) { inputRef.current.shiftHeld = true updateGradientEndpoint(true) } + const rotatableDrag = inputRef.current.drag + if (rotatableDrag?.kind === 'marquee' || rotatableDrag?.kind === 'shape') { + if (event.key === 'Alt') rotatableDrag.marqueeModifierMode = 'rotate' + if (event.key === 'Control') { + rotatableDrag.marqueeModifierMode = 'resize' + if (!controlWasHeld && rotatableDrag.marqueeAngle !== undefined) { + const bounds = rotatableDrag.marqueeBounds ?? rotatableDrag.marqueeRotationStart?.bounds + if (bounds) { + const offset = rotatableDrag.transformOffset ?? { x: 0, y: 0 } + const pointer = inputRef.current.pointer.visible ? inputRef.current.pointer.point : rotatableDrag.last + const transition = beginTemporaryCenteredMarqueeResize( + bounds, + rotatableDrag.start, + { x: pointer.x - offset.x, y: pointer.y - offset.y }, + rotatableDrag.marqueeDirection, + rotatableDrag.marqueeResizeStart?.fromCenter ?? true + ) + rotatableDrag.marqueeTemporaryCenterRestore = transition.restore + rotatableDrag.marqueeBounds = transition.bounds + rotatableDrag.marqueeResizeStart = transition.resizeStart + rotatableDrag.marqueeRotationStart = undefined + } + } + } + } const selectionNudge = event.key === 'ArrowLeft' ? { x: -1, y: 0 } : event.key === 'ArrowRight' ? { x: 1, y: 0 } : event.key === 'ArrowUp' ? { x: 0, y: -1 } @@ -1746,8 +2310,8 @@ export function CanvasStage({ session }: { session: DocumentSession }) { return } if (inputRef.current.spaceHeld) return - if (lineConnectionActive(event) && (session.tool === 'pencil' || session.tool === 'eraser') && lineAnchor && inputRef.current.pointer.visible) updateShiftPreview(true) - const modifierSizing = modifierActive(event, 'brushSizeAdjust') && (session.tool === 'pencil' || session.tool === 'eraser') + if (lineConnectionConfigured && inputRef.current.pointer.visible) updateShiftPreview(lineConnectionPreviewActive(event)) + const modifierSizing = modifierActive(event, 'brushSizeAdjust') && (session.tool === 'pencil' || session.tool === 'airbrush' || session.tool === 'eraser') if (modifierSizing) { inputRef.current.sampling = false scheduleDraw() @@ -1789,6 +2353,28 @@ export function CanvasStage({ session }: { session: DocumentSession }) { inputRef.current.shiftHeld = false updateGradientEndpoint(false) } + const rotatableDrag = inputRef.current.drag + if (rotatableDrag?.kind === 'marquee' || rotatableDrag?.kind === 'shape') { + if (event.key === 'Alt' && rotatableDrag.marqueeModifierMode === 'rotate') { + rotatableDrag.marqueeModifierMode = inputRef.current.ctrlHeld ? 'resize' : undefined + } + if (event.key === 'Control' && rotatableDrag.marqueeModifierMode === 'resize') { + rotatableDrag.marqueeModifierMode = inputRef.current.altHeld ? 'rotate' : undefined + } + if (event.key === 'Control' && rotatableDrag.marqueeTemporaryCenterRestore) { + const offset = rotatableDrag.transformOffset ?? { x: 0, y: 0 } + const pointer = inputRef.current.pointer.visible ? inputRef.current.pointer.point : rotatableDrag.last + const restored = restoreTemporaryCenteredMarqueeResize( + rotatableDrag.marqueeTemporaryCenterRestore, + { x: pointer.x - offset.x, y: pointer.y - offset.y } + ) + rotatableDrag.marqueeBounds = restored.bounds + rotatableDrag.marqueeResizeStart = restored.resizeStart + rotatableDrag.marqueeDirection = restored.direction + rotatableDrag.marqueeRotationStart = undefined + rotatableDrag.marqueeTemporaryCenterRestore = undefined + } + } inputRef.current.sampling = session.tool === 'eyedropper' inputRef.current.modifierBrushSize = null if (event.key === 'Alt' && (inputRef.current.drag?.kind === 'marquee' || inputRef.current.drag?.kind === 'shape')) { @@ -1796,7 +2382,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const bounds = drag.marqueeBounds ?? drag.marqueeRotationStart?.bounds if (bounds) { const offset = drag.transformOffset ?? { x: 0, y: 0 } - drag.marqueeResizeStart = createMarqueeResizeStart(bounds, { x: drag.last.x - offset.x, y: drag.last.y - offset.y }) + const resizeBounds = inputRef.current.ctrlHeld ? centerMarqueeBoundsAtCreationPoint(bounds, drag.start) : bounds + drag.marqueeBounds = resizeBounds + drag.marqueeResizeStart = createMarqueeResizeStart(resizeBounds, { x: drag.last.x - offset.x, y: drag.last.y - offset.y }) drag.marqueeRotationStart = undefined } } @@ -1832,16 +2420,29 @@ export function CanvasStage({ session }: { session: DocumentSession }) { window.addEventListener('focus', focus) document.addEventListener('visibilitychange', visibilityChange) return () => { window.removeEventListener('keydown', keyDown); window.removeEventListener('keyup', keyUp); window.removeEventListener('blur', blur); window.removeEventListener('focus', focus); document.removeEventListener('visibilitychange', visibilityChange) } - }, [session.tool, lineAnchor, lineConnectionShortcut]) + }, [session.tool, lineAnchor, lineConnectionShortcut, shortcuts.addForegroundToPalette]) useEffect(() => { scheduleDraw() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.document.id, session.revision, session.activeLayerMaskId, session.layerMaskIsolatedView, session.view.showPixelGrid, session.view.showGrid, session.view.grid?.x, session.view.grid?.y, session.view.grid?.width, session.view.grid?.height, session.view.relativeLuminance, session.view.mirrored, session.view.mirroredVertical, session.view.showSelectionOutline, session.view.showSelectionPivot, session.selection, session.selectionPivot?.x, session.selectionPivot?.y, session.outlinePreview, session.brushSize, session.brushShape, session.shapeKind, session.shapeRatio, session.fillMode, fillKind, gradientDither, session.symmetryAxes.horizontal, session.symmetryAxes.vertical, session.symmetryAxes.diagonalUp, session.symmetryAxes.diagonalDown, session.symmetryAxes.rotational, symmetryCenter.x, symmetryCenter.y, drawingBrushPreviewEnabled, brushPreviewMode, checkerboard, gridColors, sliceColor, textBoxColor, canvasResizeColor, sliceOutlinesVisible, shiftLinePreviewEnabled, lassoPreviewClosed, selectionCrosshair, balancedShiftLineEnabled, lineDirectionStep, lineConnectionShortcut, rotationIndicatorPosition, onionSkin, timelineHidden, symmetryAxisPreferences]) + + const selectionOverlayAnimated = Boolean(session.selection || selectedTextBoxForSession(session)) + useEffect(() => { + if (!selectionOverlayAnimated) return const renderSelection = (): void => { - drawSelectionOverlay() + selectionOverlayDrawRef.current() const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) - if (currentSession?.selection) rafRef.current = window.setTimeout(renderSelection, 160) + if (currentSession?.selection || (currentSession && selectedTextBoxForSession(currentSession))) rafRef.current = window.setTimeout(renderSelection, 160) + } + rafRef.current = window.setTimeout(renderSelection, 160) + return () => { + if (rafRef.current) window.clearTimeout(rafRef.current) + rafRef.current = null } - if (session.selection) rafRef.current = window.setTimeout(renderSelection, 160) + }, [selectionOverlayAnimated, session.document.id]) + + useEffect(() => { const observer = new ResizeObserver((entries) => { const entry = entries[0] if (entry) { @@ -1874,14 +2475,14 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } return () => { observer.disconnect() - if (rafRef.current) window.clearTimeout(rafRef.current) + stopAirbrushTimer() if (drawRequestRef.current) window.cancelAnimationFrame(drawRequestRef.current) if (selectionPreviewFrameRef.current) window.cancelAnimationFrame(selectionPreviewFrameRef.current) drawRequestRef.current = null selectionPreviewFrameRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session, session.revision, session.view.showPixelGrid, session.view.showGrid, session.view.grid?.x, session.view.grid?.y, session.view.grid?.width, session.view.grid?.height, 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, fillKind, gradientDither, session.symmetryAxes.horizontal, session.symmetryAxes.vertical, session.symmetryAxes.diagonalUp, session.symmetryAxes.diagonalDown, symmetryCenter.x, symmetryCenter.y, drawingBrushPreviewEnabled, brushPreviewMode, checkerboard, gridColors, shiftLinePreviewEnabled, lassoPreviewClosed, selectionCrosshair, balancedShiftLineEnabled, lineDirectionStep, lineConnectionShortcut, rotationIndicatorPosition, onionSkin, symmetryAxisPreferences]) + }, [session.document.id]) const unrotatedStagePoint = (clientX: number, clientY: number): Point => { const bounds = stageBounds() @@ -1904,13 +2505,27 @@ export function CanvasStage({ session }: { session: DocumentSession }) { return documentPointFromViewportPointContinuous({ x: clientX - bounds.left, y: clientY - bounds.top }, bounds.width, bounds.height, session.document.width, session.document.height, liveViewRef.current, rotationIndicatorPosition) } + const quickSelectionCellAt = (active: DocumentSession, point: Point): SelectionRect | null => { + const grid = active.view.showGrid + ? active.view.grid ?? DEFAULT_GRID_SETTINGS + : { x: 0, y: 0, width: checkerboard.size, height: checkerboard.size } + return gridCellBoundsAt(point, grid, active.document.width, active.document.height) + } + const quickSelectCell = (event: React.MouseEvent): void => { - if (inputRef.current.drag?.kind === 'polygon-lasso') { + if (inputRef.current.drag?.kind === 'polygon-lasso' || inputRef.current.drag?.kind === 'polygon-shape') { event.preventDefault() - commitPolygonLasso() + if (inputRef.current.drag.kind === 'polygon-shape') commitPolygonShape() + else commitPolygonLasso() return } if (session.tool !== 'selection' || session.selectionKind !== 'rectangle') return + const handledAt = quickSelectionHandledAtRef.current + if (handledAt !== null && event.timeStamp - handledAt >= 0 && event.timeStamp - handledAt < 1000) { + quickSelectionHandledAtRef.current = null + event.preventDefault() + return + } const point = localPointAt(event.clientX, event.clientY) if (!point) return const state = useWorkspace.getState() @@ -1919,14 +2534,11 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (!active || (!active.activeLayerMaskId && (active.selectedGroupIds.length > 0 || !active.selectedLayerIds.some((id) => active.document.layers.some((layer) => layer.id === id))))) return const layer = activePaintLayer(active) if (!isLayerEffectivelyVisible(active.document, layer) || isLayerEffectivelyLocked(active.document, layer)) return - const grid = active.view.showGrid - ? active.view.grid ?? DEFAULT_GRID_SETTINGS - : { x: 0, y: 0, width: checkerboard.size, height: checkerboard.size } - const cell = gridCellBoundsAt(point, grid, active.document.width, active.document.height) + const cell = quickSelectionCellAt(active, point) if (!cell) return const before = cloneSelection(active.selection) const mode: SelectionMode = event.shiftKey || modifierActive(event.nativeEvent, 'addToSelection') ? 'add' : active.selectionMode - const after = combineSelection(before, rectSelection(cell.x, cell.y, cell.width, cell.height), mode) + const after = quickSelectCellSelection(before, cell, mode) state.commitSelectionChange(before, after, t('canvas.history.createSelection')) event.preventDefault() scheduleDraw() @@ -2123,7 +2735,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const lineRadius = Math.max(0.25, Math.max(7, symmetryAxisPreferences.thickness / 2 + 4) / liveViewRef.current.zoom) let best: { axis: SymmetryAxis; distance: number } | null = null for (const axis of (['horizontal', 'vertical', 'diagonalUp', 'diagonalDown'] as SymmetryAxis[])) { - if (!session.symmetryAxes[axis]) continue + if (!symmetryGuideAxisEnabled(session.symmetryAxes, axis)) continue const segment = symmetryAxisSegment(axis, session.document.width, session.document.height, symmetryCenter) if (!segment) continue const distance = distanceToSegment(point, segment.start, segment.end) @@ -2133,8 +2745,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } const topEditableLayerAt = (point: Point) => { - for (let index = session.document.layers.length - 1; index >= 0; index -= 1) { - const layer = session.document.layers[index] + const layerById = new Map(session.document.layers.map((layer) => [layer.id, layer])) + for (const layerId of layerIdsInVisualStackOrder(session.document.layers, session.document.groups)) { + const layer = layerById.get(layerId) + if (!layer) continue if (!isLayerEffectivelyVisible(session.document, layer) || isLayerEffectivelyLocked(session.document, layer)) continue if (readLayerColorAt(session.document, layer, point.x, point.y).a > 0) return layer } @@ -2175,15 +2789,43 @@ export function CanvasStage({ session }: { session: DocumentSession }) { scheduleDraw() } + const flashMoveLayer = (layer: RasterLayer): void => { + if (!moveLayerClickFlashEnabled) return + const bounds = layerContentBounds(session.document, layer) + if (!bounds) return + if (moveLayerClickFlashTimerRef.current !== null) window.clearTimeout(moveLayerClickFlashTimerRef.current) + moveLayerClickFlashRef.current = { + layerId: layer.id, + bounds, + layerOffsetX: layer.offsetX, + layerOffsetY: layer.offsetY, + expiresAt: performance.now() + MOVE_LAYER_CLICK_FLASH_MS + } + scheduleDraw() + moveLayerClickFlashTimerRef.current = window.setTimeout(() => { + moveLayerClickFlashRef.current = null + moveLayerClickFlashTimerRef.current = null + scheduleDraw() + }, MOVE_LAYER_CLICK_FLASH_MS) + } + const selectionHitAt = (clientX: number, clientY: number): SelectionHit => { const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session - if (!currentSession.selection || !canvasRef.current) return 'outside' + const textBox = currentSession.textBoxTransform?.bounds ?? selectedTextBoxForSession(currentSession) + if ((!currentSession.selection && !textBox) || !canvasRef.current) return 'outside' const bounds = stageBounds() const point = documentPointFromViewportPointContinuous({ x: clientX - bounds.left, y: clientY - bounds.top }, bounds.width, bounds.height, currentSession.document.width, currentSession.document.height, liveViewRef.current, rotationIndicatorPosition) + if (textBox) { + const resizeHit = selectionResizeHit(textBox, point, 12 / Math.max(0.0001, liveViewRef.current.zoom), 18 / Math.max(0.0001, liveViewRef.current.zoom), 5 / Math.max(0.0001, liveViewRef.current.zoom)) + if (resizeHit) return resizeHit + return point.x >= textBox.x && point.x <= textBox.x + textBox.width && point.y >= textBox.y && point.y <= textBox.y + textBox.height ? 'inside' : 'outside' + } const floating = currentSession.pendingPaste + const selection = currentSession.selection + if (!selection) return 'outside' if (floating?.transformTarget) { return selectionTransformedInteractionHit( - currentSession.selection, + selection, floating.transformTarget, floating.transformAngle ?? 0, floating.transformShear, @@ -2191,7 +2833,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { liveViewRef.current.zoom ) } - return selectionInteractionHit(currentSession.selection, point, liveViewRef.current.zoom) + return selectionInteractionHit(selection, point, liveViewRef.current.zoom) } const selectionHit = (event: React.PointerEvent): SelectionHit => selectionHitAt(event.clientX, event.clientY) @@ -2205,6 +2847,25 @@ export function CanvasStage({ session }: { session: DocumentSession }) { return rotateViewportPoint(mirrored, pivot, view.rotation) } + const selectionPivotForSession = (currentSession: DocumentSession): Point | null => { + const selection = currentSession.selection + if (!selection) return null + const floating = currentSession.pendingPaste + const target = floating?.transformTarget ?? selection + return currentSession.selectionPivot + ? { ...currentSession.selectionPivot } + : transformedSelectionPivotPreset(target, 'center', floating?.transformAngle ?? 0, floating?.transformShear) + } + + const selectionPivotHitAt = (clientX: number, clientY: number): boolean => { + const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session + if (currentSession.view.showSelectionPivot === false) return false + const pivot = selectionPivotForSession(currentSession) + if (!pivot) return false + const bounds = stageBounds() + return selectionPivotHit(displayedSelectionPoint(pivot), { x: clientX - bounds.left, y: clientY - bounds.top }) + } + const rotationCursorForHit = (hit: SelectionRotationHandle): string => { const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session const floating = currentSession.pendingPaste @@ -2212,16 +2873,92 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (!target) return rotationCursors[hit] const points = transformedSelectionControlPoints(target, floating?.transformAngle ?? 0, floating?.transformShear) const cornerIndex: Record = { 'rotate-nw': 0, 'rotate-ne': 2, 'rotate-sw': 5, 'rotate-se': 7 } - const cornerPoints = [points[0], points[2], points[5], points[7]] - const center = { - x: cornerPoints.reduce((sum, point) => sum + point.x, 0) / cornerPoints.length, - y: cornerPoints.reduce((sum, point) => sum + point.y, 0) / cornerPoints.length - } + const center = currentSession.selectionPivot + ?? transformedSelectionPivotPreset(target, 'center', floating?.transformAngle ?? 0, floating?.transformShear) const displayedCorner = displayedSelectionPoint(points[cornerIndex[hit]]) const displayedCenter = displayedSelectionPoint(center) return rotationCursors[selectionRotationCursorForPosition(displayedCorner, displayedCenter)] } + const displayedResizeCursorForHandle = (hit: SelectionHandle, contentRotation = 0): string => { + const view = liveViewRef.current + return directionalResizeCursors[selectionResizeCursorForHandle( + hit, + contentRotation, + view.rotation, + Boolean(view.mirrored), + Boolean(view.mirroredVertical) + )] + } + + const resizeCursorForTransform = ( + hit: SelectionHandle, + target: SelectionRect, + angle = 0, + shear?: SelectionShearTransform + ): string => { + const displayedPoints = transformedSelectionControlPoints(target, angle, shear).map(displayedSelectionPoint) + const cornerCursor = selectionCornerResizeCursorForPoints(hit, displayedPoints) + return cornerCursor ? directionalResizeCursors[cornerCursor] : displayedResizeCursorForHandle(hit, angle) + } + + const resizeCursorForHit = (hit: SelectionHandle): string => { + const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session + const floating = currentSession.pendingPaste + const target = floating?.transformTarget ?? currentSession.selection + return target + ? resizeCursorForTransform(hit, target, floating?.transformAngle ?? 0, floating?.transformShear) + : displayedResizeCursorForHandle(hit) + } + + const shearCursorForTransform = ( + hit: SelectionShearHandle, + target: SelectionRect, + angle = 0, + shear?: SelectionShearTransform + ): string => { + const edge = hit.slice(-1) as 'n' | 'e' | 's' | 'w' + const direction = transformedSelectionShearDirection(target, angle, shear, edge) + if (!direction) return shearCursors[hit] + const origin = transformedSelectionCenter(target, angle, shear) + const displayedOrigin = displayedSelectionPoint(origin) + const displayedDirection = displayedSelectionPoint({ x: origin.x + direction.x, y: origin.y + direction.y }) + return directionalShearCursors[selectionShearCursorForDirection({ + x: displayedDirection.x - displayedOrigin.x, + y: displayedDirection.y - displayedOrigin.y + })] + } + + const shearCursorForHit = (hit: SelectionShearHandle): string => { + const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session + const floating = currentSession.pendingPaste + const target = floating?.transformTarget ?? currentSession.selection + return target + ? shearCursorForTransform(hit, target, floating?.transformAngle ?? 0, floating?.transformShear) + : shearCursors[hit] + } + + const selectionTransformCursorForDrag = (drag: DragState): string | null => { + if (drag.kind === 'transform-content' && drag.handle) { + const target = drag.previewTarget ?? drag.transformStartTarget ?? drag.selectionStart + if (target) return resizeCursorForTransform(drag.handle, target, drag.previewAngle ?? drag.startAngle ?? 0, drag.previewShear ?? drag.transformStartShear) + return displayedResizeCursorForHandle(drag.handle, drag.previewAngle ?? drag.startAngle ?? 0) + } + if (drag.kind === 'shear-content' && drag.shearHandle) { + const target = drag.previewTarget ?? drag.transformStartTarget ?? drag.selectionStart + if (target) return shearCursorForTransform(drag.shearHandle, target, drag.previewAngle ?? drag.startAngle ?? 0, drag.previewShear ?? drag.transformStartShear) + } + if (drag.kind === 'rotate-content') { + const target = drag.previewTarget ?? drag.transformStartTarget ?? drag.selectionStart + if (target) { + const angle = drag.previewAngle ?? drag.startAngle ?? 0 + const pivot = drag.previewPivot ?? drag.selectionPivotStart ?? transformedSelectionPivotPreset(target, 'center', angle, drag.previewShear ?? drag.transformStartShear) + return rotationCursors[selectionRotationCursorForPosition(displayedSelectionPoint(drag.last), displayedSelectionPoint(pivot))] + } + } + return selectionTransformDragCursor(drag.kind) + } + const canvasResizeHitAt = (clientX: number, clientY: number): DragState['canvasEdge'] | null => { const preview = canvasResizePreviewRef.current const canvas = canvasRef.current @@ -2266,6 +3003,17 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const canvasResizeContains = (event: React.PointerEvent): boolean => canvasResizeContainsAt(event.clientX, event.clientY) + const sliceScreenBox = (slice: SelectionRect): SelectionRect => { + const bounds = stageBounds() + const view = liveViewRef.current + const originX = bounds.width / 2 + view.panX - session.document.width * view.zoom / 2 + const originY = bounds.height / 2 + view.panY - session.document.height * view.zoom / 2 + return { x: originX + slice.x * view.zoom, y: originY + slice.y * view.zoom, width: slice.width * view.zoom, height: slice.height * view.zoom } + } + + const sliceHandleAt = (clientX: number, clientY: number, slice: SelectionRect): SelectionHandle | null => + selectionResizeHit(sliceScreenBox(slice), unrotatedStagePoint(clientX, clientY), 6, 8, 3) + const updateCursorAt = (clientX: number, clientY: number, ctrlKey: boolean, altKey: boolean, shiftKey = false): void => { const canvas = canvasRef.current if (!canvas) return @@ -2284,7 +3032,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { inputRef.current.shiftLinePreview = false const drag = inputRef.current.drag const resizeEdge = drag?.kind === 'canvas-resize' ? drag.canvasEdge : canvasResizeHitAt(clientX, clientY) - if (resizeEdge) canvas.style.cursor = resizeEdge === 'n' || resizeEdge === 's' ? canvasCursors.nsResize : resizeEdge === 'e' || resizeEdge === 'w' ? canvasCursors.ewResize : resizeEdge === 'nw' || resizeEdge === 'se' ? canvasCursors.nwseResize : canvasCursors.neswResize + if (resizeEdge) canvas.style.cursor = displayedResizeCursorForHandle(resizeEdge as SelectionHandle) else if (drag?.kind === 'canvas-move') canvas.style.cursor = canvasCursors.move else canvas.style.cursor = canvasResizeContainsAt(clientX, clientY) ? canvasCursors.move : canvasCursors.unavailable return @@ -2310,7 +3058,18 @@ export function CanvasStage({ session }: { session: DocumentSession }) { canvas.style.cursor = drag.duplicateOnDrag ? canvasCursors.copy : canvasCursors.move return } - const transformCursor = drag && selectionTransformDragCursor(drag.kind) + if (drag?.kind === 'create-slice' || drag?.kind === 'move-slice' || drag?.kind === 'resize-slice') { + inputRef.current.sampling = false + canvas.style.cursor = drag.kind === 'resize-slice' && drag.handle ? displayedResizeCursorForHandle(drag.handle) : drag.kind === 'move-slice' ? drag.copy ? canvasCursors.copy : canvasCursors.move : selectionCreationCursor(selectionCrosshair) + return + } + if (drag?.kind === 'transform-text-box') { + inputRef.current.sampling = false + canvas.style.cursor = drag.handle ? displayedResizeCursorForHandle(drag.handle) : canvasCursors.move + return + } + const temporaryMoveRequested = temporaryMoveActive({ ctrlKey, metaKey: false, altKey, shiftKey }) + const transformCursor = drag?.kind === 'move-selection-pivot' ? canvasCursors.move : drag && selectionTransformCursorForDrag(drag) if (transformCursor) { inputRef.current.sampling = false canvas.style.cursor = transformCursor @@ -2330,7 +3089,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const insideDocument = Boolean(point && point.x >= 0 && point.y >= 0 && point.x < session.document.width && point.y < session.document.height) const mask = activeLayerMask(session) let contrastColor = insideDocument && point - ? mask ? readLayerMaskDisplayColorAt(mask, point.x, point.y) : sampleCompositeColor(session.document, point.x, point.y) + ? mask ? readLayerMaskDisplayColorAt(mask, point.x, point.y) : sampleCompositeColor(session.document, point.x, point.y, session.document.activeLayerId) : session.primaryColor if (insideDocument && point && activeLayerEditable && previewCursorTools.has(session.tool) && (!session.selection || selectionContains(session.selection, point.x, point.y))) { const layer = activePaintLayer(session) @@ -2338,34 +3097,58 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const index = point.y * session.document.width + point.x const source = session.tool === 'eraser' ? TRANSPARENT : session.primaryColor const replacement = source.a > 0 && source.a < 255 ? blendOver(readLayerColor(session.document, layer, index), source) : source - contrastColor = mask ? layerMaskDisplayColor(replacement) : compositePixelWithLayerColor(session.document, index, layer.id, replacement) + const resolvedReplacement = resolveLayerCanvasColor(session.document, layer, replacement) + contrastColor = mask ? layerMaskDisplayColor(resolvedReplacement) : compositePixelWithLayerColor(session.document, index, layer.id, resolvedReplacement) } } if (insideDocument && point && contrastColor.a < 255) contrastColor = blendOver(transparencyColorAt(point.x, point.y, checkerboard), contrastColor) const altActive = inputRef.current.altHeld || altKey const ctrlActive = inputRef.current.ctrlHeld || ctrlKey - const modifierSizing = ctrlKey && altActive && (session.tool === 'pencil' || session.tool === 'eraser') - const rawSelectionHit = session.tool === 'selection' ? selectionHitAt(clientX, clientY) : 'outside' + const brushSizeTool = session.tool === 'pencil' || session.tool === 'airbrush' || session.tool === 'eraser' + const modifierSizing = modifierActive({ ctrlKey, metaKey: false, altKey, shiftKey }, 'brushSizeAdjust') && brushSizeTool + const wheelSizing = wheelBrushSizePreviewRef.current && brushSizeTool + const selectedTextBox = selectedTextBoxForSession(session) + const rawSelectionHit = session.tool === 'selection' || selectedTextBox ? selectionHitAt(clientX, clientY) : 'outside' + const selectionPivotHovered = !ctrlActive && !altActive && !shiftKey && selectionPivotHitAt(clientX, clientY) + const addingToSelection = Boolean(session.selection && shiftKey) + const temporaryMove = temporaryMoveRequested && shouldUseTemporaryMoveForCanvasInteraction( + session.tool, + { ctrlKey, metaKey: false, altKey, shiftKey }, + shortcuts.temporaryMove ?? '', + session.moveKind, + rawSelectionHit, + addingToSelection + ) const selectionModifierActive = shiftKey const selectionHit = selectionModifierActive ? 'outside' : session.selectionMode === 'replace' || session.selectionMode === 'add' || rawSelectionHit !== 'inside' ? rawSelectionHit : 'outside' const centeredSelectionResize = session.tool === 'selection' && selectionHit in resizeCursors - const moveCopyAvailable = session.tool === 'move' && insideDocument && Boolean(session.moveAutoSelect ? (point && (topEditableLayerAt(point) ?? (activeLayerEditable ? getActiveLayer(session.document) : null))) : activeLayerEditable) + const moveToolActive = session.tool === 'move' || temporaryMove + const moveCopyAvailable = moveToolActive && 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 && !centeredSelectionResize && !(session.tool === 'move' && moveCopyAvailable) && !modifierSizing) + const sampling = paletteSamplingShortcutActive() || session.tool === 'eyedropper' || (altActive && !centeredSelectionResize && !(moveToolActive && moveCopyAvailable) && !modifierSizing) inputRef.current.sampling = sampling - const available = insideDocument && activeLayerEditable + const moveAvailable = insideDocument && hasSelectedRasterLayer + && isLayerEffectivelyVisible(session.document, activePaintLayer(session)) + && !isLayerEffectivelyLocked(session.document, activePaintLayer(session)) + && isToolAvailableForSession(session, 'move') + const available = temporaryMove ? moveAvailable : insideDocument && activeLayerEditable const resizeEdge = canvasResizeHitAt(clientX, clientY) - if (resizeEdge) { canvas.style.cursor = resizeEdge === 'n' || resizeEdge === 's' ? canvasCursors.nsResize : resizeEdge === 'e' || resizeEdge === 'w' ? canvasCursors.ewResize : resizeEdge === 'nw' || resizeEdge === 'se' ? canvasCursors.nwseResize : canvasCursors.neswResize } - else if (modifierSizing) canvas.style.cursor = canvasCursors.ewResize + if (modifierSizing || wheelSizing) canvas.style.cursor = canvasToolCursor('pencil', contrastColor) + else if (resizeEdge) canvas.style.cursor = displayedResizeCursorForHandle(resizeEdge as SelectionHandle) else if (copyAvailable) canvas.style.cursor = canvasCursors.copy else if (sampling) canvas.style.cursor = canvasCursors.eyedropper + else if (temporaryMove) canvas.style.cursor = available ? canvasCursors.move : canvasCursors.unavailable + else if (session.tool === 'text' && selectedTextBox && rawSelectionHit in resizeCursors) canvas.style.cursor = displayedResizeCursorForHandle(rawSelectionHit as SelectionHandle) + else if (session.tool === 'text' && selectedTextBox && rawSelectionHit === 'inside') canvas.style.cursor = canvasCursors.move else if (session.tool === 'selection') { const hit = selectionHit - canvas.style.cursor = hit in resizeCursors - ? resizeCursors[hit as SelectionHandle] + canvas.style.cursor = selectionPivotHovered + ? canvasCursors.default + : hit in resizeCursors + ? resizeCursorForHit(hit as SelectionHandle) : hit in shearCursors - ? shearCursors[hit as SelectionShearHandle] + ? shearCursorForHit(hit as SelectionShearHandle) : hit in rotationCursors ? rotationCursorForHit(hit as SelectionRotationHandle) : hit === 'inside' @@ -2373,6 +3156,12 @@ export function CanvasStage({ session }: { session: DocumentSession }) { : hit === 'edge' ? canvasCursors.selectionMove : selectionCreationCursor(selectionCrosshair, activeLayerEditable || selectionModifierActive) + } else if (sliceTool) { + const selectedIds = session.selectedSliceIds?.length ? session.selectedSliceIds : session.selectedSliceId ? [session.selectedSliceId] : [] + const selectedSlice = selectedIds.length === 1 ? session.document.slices?.find((slice) => slice.id === selectedIds[0]) : null + const handle = selectedSlice ? sliceHandleAt(clientX, clientY, selectedSlice) : null + const hit = point ? sliceAtPoint(session.document.slices ?? [], point.x, point.y) : null + canvas.style.cursor = handle ? displayedResizeCursorForHandle(handle) : hit ? canvasCursors.move : selectionCreationCursor(selectionCrosshair, insideDocument) } else canvas.style.cursor = canvasToolCursor(session.tool, contrastColor, available) } @@ -2397,7 +3186,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { 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.selectionMode, session.selection, session.primaryColor.r, session.primaryColor.g, session.primaryColor.b, session.primaryColor.a, session.animationPlaying, session.symmetryAxes.horizontal, session.symmetryAxes.vertical, session.symmetryAxes.diagonalUp, session.symmetryAxes.diagonalDown, symmetryCenter.x, symmetryCenter.y, symmetryAxisPreferences.locked, symmetryAxisPreferences.thickness, activeLayerEditable]) + }, [session.tool, session.selectionKind, session.selectionMode, session.selection, session.primaryColor.r, session.primaryColor.g, session.primaryColor.b, session.primaryColor.a, session.animationPlaying, session.symmetryAxes.horizontal, session.symmetryAxes.vertical, session.symmetryAxes.diagonalUp, session.symmetryAxes.diagonalDown, session.symmetryAxes.rotational, symmetryCenter.x, symmetryCenter.y, symmetryAxisPreferences.locked, symmetryAxisPreferences.thickness, activeLayerEditable]) const activeColor = (button = 0): RgbaColor => session.tool === 'eraser' ? button === 2 ? session.secondaryColor : TRANSPARENT @@ -2446,6 +3235,76 @@ export function CanvasStage({ session }: { session: DocumentSession }) { scheduleDraw() } + const commitShapePoints = (drag: DragState, points: readonly Point[], label: string): void => { + const layer = activePaintLayer(session) + if (isLayerEffectivelyLocked(session.document, layer)) return + const edit = beginPixelEdit(layer.id) + paintShapePixelPoints(session.document, layer, edit, points, drag.color ?? session.primaryColor, session.selection, session.symmetryAxes, symmetryCenter) + useWorkspace.getState().commitPixelEdit(edit, label) + } + + const commitBrushPath = (drag: DragState, points: readonly Point[], label: string): void => { + const layer = activePaintLayer(session) + if (isLayerEffectivelyLocked(session.document, layer) || points.length === 0) return + const edit = beginPixelEdit(layer.id) + paintBrushPath( + session.document, + layer, + edit, + points, + session.brushSize, + drag.color ?? session.primaryColor, + session.selection, + session.brushShape, + activeBrushTexture, + session.brushTextureScale, + activeBrushImage, + session.brushImageSettings, + proceduralAntialiasStrength, + activeBrushPaintMode, + brushPatternOrigin(points[0]), + session.symmetryAxes, + symmetryCenter + ) + useWorkspace.getState().commitPixelEdit(edit, label) + } + + const commitPolygonShape = (): void => { + const drag = inputRef.current.drag + if (drag?.kind !== 'polygon-shape') return + inputRef.current.finish() + const path = polygonLassoClosedPathPoints(drag.path ?? [], balancedShiftLineEnabled) + commitShapePoints(drag, filledShapePathPixelPoints(session.document, path), t('canvas.history.drawPolygon')) + scheduleDraw() + } + + const curveDefaultControls = (start: Point, end: Point, count: number): Point[] => Array.from({ length: count }, (_, index) => { + const amount = (index + 1) / (count + 1) + return { + x: Math.round(start.x + (end.x - start.x) * amount), + y: Math.round(start.y + (end.y - start.y) * amount) + } + }) + + const curveShapePixelPoints = (drag: DragState): readonly Point[] => { + const end = drag.curveEnd ?? drag.last + const points = bezierCurvePixelPoints(drag.start, drag.curveControls ?? curveDefaultControls(drag.start, end, drag.curveAnchorCount ?? session.curveAnchorCount), end) + return session.perfectPixels ? perfectPixelPathPoints(points) : points + } + + const lineShapeBrushPoints = (drag: DragState): readonly Point[] => { + const points = lineShapePixelPoints(drag.start, drag.last, balancedShiftLineEnabled) + return session.perfectPixels ? perfectPixelPathPoints(points) : points + } + + const commitCurveShape = (): void => { + const drag = inputRef.current.drag + if (drag?.kind !== 'curve-shape' || !drag.curveEnd) return + inputRef.current.finish() + commitBrushPath(drag, curveShapePixelPoints(drag), t('canvas.history.drawCurve')) + scheduleDraw() + } + const handlePointerDown = (event: React.PointerEvent): void => { event.currentTarget.tabIndex = -1 event.currentTarget.focus({ preventScroll: true }) @@ -2466,6 +3325,19 @@ export function CanvasStage({ session }: { session: DocumentSession }) { return } if (session.animationPlaying) return + const pivotSamplingHeld = paletteSamplingShortcutActive() || modifierActive(event.nativeEvent, 'temporaryEyedropper') + if (event.button === 0 && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && !pivotSamplingHeld && selectionPivotHitAt(event.clientX, event.clientY)) { + const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session + const pivot = selectionPivotForSession(currentSession) + const pointer = localContinuousPointAt(event.clientX, event.clientY) + if (pivot && pointer) { + inputRef.current.drag = { kind: 'move-selection-pivot', start: pointer, last: pointer, selectionPivotStart: pivot, previewPivot: { ...pivot } } + event.currentTarget.setPointerCapture(event.pointerId) + event.currentTarget.style.cursor = canvasCursors.default + event.preventDefault() + return + } + } const symmetryHit = event.button === 0 ? symmetryAxisHitAt(event.clientX, event.clientY) : null if (symmetryHit) { symmetryDragRef.current = { axis: symmetryHit, pointerId: event.pointerId } @@ -2478,6 +3350,68 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (!point) return updateCursor(event) event.currentTarget.setPointerCapture(event.pointerId) + const sampleAtPoint = (temporarySampling = true): void => { + if (point.x < 0 || point.y < 0 || point.x >= session.document.width || point.y >= session.document.height) return + const secondary = event.button === 2 + const setSampledColor = secondary ? state.setSecondaryColor : state.setPrimaryColor + const mask = activeLayerMask(session) + const sampled = mask ? readLayerMaskDisplayColorAt(mask, point.x, point.y) : sampleCompositeColor(session.document, point.x, point.y, session.document.activeLayerId) + const previous = secondary ? session.secondaryColor : session.primaryColor + setSampledColor(sampled) + publishCanvasColorSample(sampled, secondary) + eyedropperOriginalColorRef.current = { ...previous } + inputRef.current.sampling = true + inputRef.current.drag = { kind: 'sample-color', start: point, last: point, sampleSecondary: secondary, temporarySampling, sampledColor: { ...sampled } } + updateEyedropperMagnifier(event.clientX, event.clientY, sampled) + event.currentTarget.style.cursor = canvasCursors.eyedropper + draw() + } + const paletteSamplingHeld = paletteSamplingShortcutActive() + if (paletteSamplingShortcutStartsPrimarySample(paletteSamplingHeld, event.button)) { + sampleAtPoint() + return + } + const selectionPriorityHit = event.button === 0 && session.selection ? selectionHit(event) : 'outside' + const addingToSelection = Boolean(session.selection && (event.shiftKey || modifierActive(event.nativeEvent, 'addToSelection'))) + const temporaryMove = event.button === 0 + && !brushLineConnectionHasPriority(event.nativeEvent) + && shouldUseTemporaryMoveForCanvasInteraction( + session.tool, + event.nativeEvent, + shortcuts.temporaryMove ?? '', + session.moveKind, + selectionPriorityHit, + addingToSelection + ) + const selectedTextBox = selectedTextBoxForSession(session) + const textBoxHit = event.button === 0 && selectedTextBox ? selectionHit(event) : 'outside' + if (!temporaryMove && session.tool === 'text' && selectedTextBox && textBoxHit in resizeCursors) { + state.beginSelectedTextBoxTransform() + inputRef.current.drag = { + kind: 'transform-text-box', + start: point, + last: point, + handle: textBoxHit as SelectionHandle, + transformStartTarget: { ...selectedTextBox }, + previewTarget: { ...selectedTextBox } + } + event.currentTarget.style.cursor = displayedResizeCursorForHandle(textBoxHit as SelectionHandle) + return + } + if (!temporaryMove && session.tool === 'text' && selectedTextBox && textBoxHit === 'inside') { + state.beginSelectedTextBoxTransform() + inputRef.current.drag = { + kind: 'transform-text-box', + start: point, + last: point, + startClient: { x: event.clientX, y: event.clientY }, + moved: false, + transformStartTarget: { ...selectedTextBox }, + previewTarget: { ...selectedTextBox } + } + event.currentTarget.style.cursor = canvasCursors.move + return + } const activePolygon = inputRef.current.drag if (session.tool === 'selection' && activePolygon?.kind === 'polygon-lasso' && (event.button === 0 || event.button === 2)) { const path = activePolygon.path ?? [] @@ -2490,6 +3424,22 @@ export function CanvasStage({ session }: { session: DocumentSession }) { scheduleDraw() return } + if (session.tool === 'shape' && activePolygon?.kind === 'polygon-shape' && (event.button === 0 || event.button === 2)) { + const path = activePolygon.path ?? [] + if (shouldClosePolygonLasso(path, point, event.detail)) { + commitPolygonShape() + return + } + activePolygon.path = appendPolygonLassoVertex(path, point) + activePolygon.last = point + scheduleDraw() + return + } + if (session.tool === 'line' && session.lineKind === 'curve' && activePolygon?.kind === 'curve-shape' && activePolygon.curvePhase === 'anchors' && (event.button === 0 || event.button === 2)) { + // Confirmation is handled on pointer-up. Pointer-down must not change the + // active anchor or the visible curve. + return + } const resizeEdge = canvasResizeHit(event) const activeResizePreview = canvasResizePreviewRef.current if (activeResizePreview) { @@ -2503,36 +3453,24 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } return } + const eyedropperHeld = paletteSamplingHeld || modifierActive(event.nativeEvent, 'temporaryEyedropper') const focusesRasterLayer = event.button === 0 && session.tool !== 'hand' && session.tool !== 'zoom' && session.tool !== 'move' + && session.tool !== 'eyedropper' + && !eyedropperHeld + && !temporaryMove && !session.activeLayerMaskId && (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) { + if (modifierActive(event.nativeEvent, 'brushSizeAdjust') && (session.tool === 'pencil' || session.tool === 'airbrush' || session.tool === 'eraser') && (session.tool === 'airbrush' || !activeBrushImage?.intrinsicSize) && event.button === 0) { inputRef.current.sampling = false - inputRef.current.drag = { kind: 'brush-size', start: point, last: point, startClient: { x: event.clientX, y: event.clientY }, startBrushSize: session.brushSize } - event.currentTarget.style.cursor = canvasCursors.ewResize + inputRef.current.drag = { kind: 'brush-size', start: point, last: point, startClient: { x: event.clientX, y: event.clientY }, startBrushSize: session.tool === 'airbrush' ? session.airbrushScatterRadius : session.brushSize } + event.currentTarget.style.cursor = canvasToolCursor('pencil', session.primaryColor) return } - const sampleAtPoint = (temporarySampling = true): void => { - if (point.x < 0 || point.y < 0 || point.x >= session.document.width || point.y >= session.document.height) return - const secondary = event.button === 2 - const setSampledColor = secondary ? state.setSecondaryColor : state.setPrimaryColor - const mask = activeLayerMask(session) - const sampled = mask ? readLayerMaskDisplayColorAt(mask, point.x, point.y) : sampleCompositeColor(session.document, point.x, point.y) - const previous = secondary ? session.secondaryColor : session.primaryColor - setSampledColor(sampled) - publishCanvasColorSample(sampled, secondary) - eyedropperOriginalColorRef.current = { ...previous } - inputRef.current.sampling = true - inputRef.current.drag = { kind: 'sample-color', start: point, last: point, sampleSecondary: secondary, temporarySampling } - updateEyedropperMagnifier(event.clientX, event.clientY, sampled) - event.currentTarget.style.cursor = canvasCursors.eyedropper - draw() - } const selectionTool = session.tool === 'selection' const selectionMode = (): SelectionMode => event.button === 2 ? 'subtract' : (event.shiftKey || modifierActive(event.nativeEvent, 'addToSelection')) ? 'add' : session.selectionMode const editableLayer = activePaintLayer(session) @@ -2542,10 +3480,52 @@ export function CanvasStage({ session }: { session: DocumentSession }) { && session.selectedLayerIds.includes(movableActiveLayer.id) && isLayerEffectivelyVisible(session.document, movableActiveLayer) && !isLayerEffectivelyLocked(session.document, movableActiveLayer) - const eyedropperHeld = modifierActive(event.nativeEvent, 'temporaryEyedropper') const copyLayerHeld = modifierActive(event.nativeEvent, 'copyLayerOnDrag') - if (selectionTool && (event.button === 0 || event.button === 2) && !canEditLayer && !eyedropperHeld) return - if (session.tool === 'move' && event.button === 0) { + if (session.tool === 'text' && event.button === 0 && !temporaryMove) { + inputRef.current.drag = { + kind: 'create-text-box', + start: point, + last: point, + startClient: { x: event.clientX, y: event.clientY }, + moved: false, + previewTarget: clampSliceRect(shapeBounds(point, point), session.document.width, session.document.height) + } + return + } + if (editableLayer.kind === 'text' && !isToolAvailableForSession(session, temporaryMove ? 'move' : session.tool) && !eyedropperHeld) return + if (!temporaryMove && selectionTool && (event.button === 0 || event.button === 2) && !canEditLayer && !eyedropperHeld) return + if (!temporaryMove && sliceTool && event.button === 0) { + const selectedIds = session.selectedSliceIds?.length ? session.selectedSliceIds : session.selectedSliceId ? [session.selectedSliceId] : [] + const selected = selectedIds.length === 1 ? session.document.slices?.find((slice) => slice.id === selectedIds[0]) ?? null : null + const handle = selected ? sliceHandleAt(event.clientX, event.clientY, selected) : null + if (selected && handle && !event.shiftKey && !event.altKey) { + inputRef.current.drag = { kind: 'resize-slice', start: point, last: point, sliceId: selected.id, sliceStart: { ...selected }, handle, previewTarget: { ...selected } } + event.currentTarget.style.cursor = displayedResizeCursorForHandle(handle) + return + } + const hit = sliceAtPoint(session.document.slices ?? [], point.x, point.y) + if (hit) { + if (event.shiftKey) { + state.selectSlice(hit.id, true) + scheduleDraw() + return + } + const movingIds = selectedIds.includes(hit.id) ? selectedIds : [hit.id] + if (!selectedIds.includes(hit.id)) state.selectSlice(hit.id) + const starts = Object.fromEntries(movingIds.flatMap((id) => { + const slice = session.document.slices?.find((candidate) => candidate.id === id) + return slice ? [[id, { x: slice.x, y: slice.y, width: slice.width, height: slice.height }] as const] : [] + })) + inputRef.current.drag = { kind: 'move-slice', start: point, last: point, startClient: { x: event.clientX, y: event.clientY }, moved: false, sliceId: hit.id, sliceIds: movingIds, sliceStart: { ...hit }, sliceStarts: starts, slicePreviewTargets: starts, previewTarget: { ...hit }, copy: event.altKey, collapseSliceSelectionOnClick: movingIds.length > 1 } + event.currentTarget.style.cursor = event.altKey ? canvasCursors.copy : canvasCursors.move + return + } + state.selectSlice(null) + inputRef.current.drag = { kind: 'create-slice', start: point, last: point, startClient: { x: event.clientX, y: event.clientY }, moved: false, previewTarget: clampSliceRect(shapeBounds(point, point), session.document.width, session.document.height) } + event.currentTarget.style.cursor = selectionCreationCursor(selectionCrosshair) + return + } + if ((session.tool === 'move' || temporaryMove) && event.button === 0) { const additiveSelection = event.shiftKey const hitTarget = session.moveAutoSelect || additiveSelection ? topEditableLayerAt(point) : null let selectedLayerIds = session.selectedGroupIds.length > 0 ? [] : [...session.selectedLayerIds] @@ -2554,7 +3534,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const selectedSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) selectedLayerIds = selectedSession?.selectedLayerIds ?? selectedLayerIds revealLayerInPanel(session.document.id, hitTarget.id) - if (selectedLayerIds.includes(hitTarget.id)) showMoveLayerContentPreview(hitTarget) + if (selectedLayerIds.includes(hitTarget.id)) { + showMoveLayerContentPreview(hitTarget) + flashMoveLayer(hitTarget) + } else hideMoveLayerContentPreview() return } @@ -2572,6 +3555,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { ? selectedMovableLayers.find((layer) => layer.id === session.document.activeLayerId) ?? selectedMovableLayers[0] : session.moveAutoSelect ? (hitTarget ?? (canMoveActiveLayer ? movableActiveLayer : null)) : (canMoveActiveLayer ? movableActiveLayer : null) if (!target) { if (eyedropperHeld) sampleAtPoint(); return } + flashMoveLayer(hitTarget ?? target) if (!additiveSelection && session.moveAutoSelect && !moveAllSelectedLayers) { const currentFrameId = session.document.animation?.activeFrameId const targetKey = currentFrameId ? animationCelKey(target.id, currentFrameId) : null @@ -2608,11 +3592,20 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const layer = session.document.layers.find((candidate) => candidate.id === id)! return [id, { x: layer.offsetX, y: layer.offsetY }] })) + const layerContentBoundsById = normalCompositeLayers(session.document) + ? Object.fromEntries(layerIds.map((id) => { + const layer = session.document.layers.find((candidate) => candidate.id === id)! + return [id, layerContentBounds(session.document, layer)] + })) + : undefined inputRef.current.drag = { kind: 'move-layer', start: point, last: point, layerId: target.id, - layerOffset: { x: target.offsetX, y: target.offsetY }, layerIds, layerOffsets, layerFrameId: currentFrameId, + layerOffset: { x: target.offsetX, y: target.offsetY }, layerIds, layerOffsets, + layerContentBounds: layerContentBoundsById, layerPreviewOffset: { x: 0, y: 0 }, layerFrameId: currentFrameId, animationCellKeys, animationCellOffsets, duplicateOnDrag: copyLayerHeld && layerIds.length === 1 && animationCellKeys.length <= 1, originalSelectedLayerIds: [...selectedLayerIds], selectionStart: cloneSelection(session.selection), + selectionPivotStart: activeSession.selectionPivot ? { ...activeSession.selectionPivot } : undefined, + previewPivot: activeSession.selectionPivot ? { ...activeSession.selectionPivot } : undefined, clickLayerId: hitTarget?.id ?? session.document.activeLayerId, collapseLayerSelectionOnClick: !additiveSelection && (selectedLayerIds.length > 1 || activeSession.selectedAnimationCellKeys.length > 1) } @@ -2627,7 +3620,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const bounds = stageBounds() const pointer = { x: event.clientX - bounds.left, y: event.clientY - bounds.top } const pivot = rotationIndicatorPosition === 'view' - ? rotationIndicatorPointBesidePointer(bounds.width, bounds.height, pointer) + ? rotationIndicatorPointBetweenPointerAndCanvasCenter(bounds.width, bounds.height, pointer, displayedCanvasCenter(bounds.width, bounds.height, liveViewRef.current, rotationIndicatorPosition)) : viewRotationPivot(bounds.width, bounds.height, liveViewRef.current.panX, liveViewRef.current.panY, rotationIndicatorPosition) rotationIndicatorAnchorRef.current = rotationIndicatorPosition === 'view' ? pivot : null const angle = Math.atan2(event.clientY - bounds.top - pivot.y, event.clientX - bounds.left - pivot.x) * 180 / Math.PI @@ -2638,7 +3631,16 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (lineConnectionActive(event.nativeEvent) && hasRasterFocus && (session.tool === 'pencil' || session.tool === 'eraser') && lineAnchor && event.button === 0) { if (canEditLayer) { const target = constrainedLineTarget(point, event.nativeEvent) + const anchorHistory = lineAnchorHistoryRef.current + const reuseAnchorBaseline = Boolean(anchorHistory + && anchorHistory.documentId === session.document.id + && anchorHistory.layerId === editableLayer.id + && anchorHistory.tool === session.tool + && anchorHistory.point.x === lineAnchor.x + && anchorHistory.point.y === lineAnchor.y + && session.history.latestUndoEntry === anchorHistory.entry) const edit = beginPixelEdit(editableLayer.id) + if (reuseAnchorBaseline && anchorHistory) inheritBrushPaintBaseline(edit, anchorHistory.baseline) const dynamics = brushDynamicsAt(event.pointerType, event.pressure, 0) const lineColor = activeColor() const gradient = brushGradientAt(lineColor, dynamics.gradientAmount) @@ -2649,7 +3651,21 @@ export function CanvasStage({ session }: { session: DocumentSession }) { toOpacityScale: dynamics.opacityScale, gradient: brushLineGradient(gradient, gradient) }) - state.commitPixelEdit(edit, session.tool === 'eraser' ? t('canvas.history.eraserLine') : t('canvas.history.pencilLine'), { stroke: true, durationMs: 1 }) + const label = session.tool === 'eraser' ? t('canvas.history.eraserLine') : t('canvas.history.pencilLine') + const lineEntry = state.commitPixelEdit(edit, label, { stroke: true, durationMs: 1 }) + const mergedEntry = reuseAnchorBaseline && anchorHistory?.mergeWithNext && lineEntry ? session.history.mergeLastTwo(label) : null + const currentEntry = mergedEntry ?? lineEntry + const nextBaseline = reuseAnchorBaseline && anchorHistory ? new Map(anchorHistory.baseline) : new Map() + for (const [index, value] of edit.before) if (!nextBaseline.has(index)) nextBaseline.set(index, value) + lineAnchorHistoryRef.current = currentEntry ? { + documentId: session.document.id, + layerId: editableLayer.id, + tool: session.tool, + point: { ...target }, + entry: currentEntry, + baseline: nextBaseline, + mergeWithNext: false + } : null if (session.tool === 'eraser') state.setLastEraserPoint(target) else state.setLastPencilPoint(target) } @@ -2657,12 +3673,62 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } if (selectionTool && (event.button === 0 || event.button === 2)) { const mode = selectionMode() + const currentSelectionSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session + const customSelectionPivot = currentSelectionSession.selectionPivot ? { ...currentSelectionSession.selectionPivot } : undefined + const quickPress: QuickSelectionPress = { + clientX: event.clientX, + clientY: event.clientY, + pointerId: event.pointerId, + timeStamp: event.timeStamp + } + const quickSelectionSecondPress = event.button === 0 + && session.selectionKind === 'rectangle' + && !eyedropperHeld + && isQuickSelectionSecondPress(quickSelectionPressRef.current, quickPress, event.detail) + if (event.button === 0 && session.selectionKind === 'rectangle') { + quickSelectionPressRef.current = quickSelectionSecondPress ? null : quickPress + } + if (quickSelectionSecondPress) { + state.commitFloatingPaste() + const active = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session + const cell = quickSelectionCellAt(active, point) + if (cell) { + const before = cloneSelection(active.selection) + const baseSelection = quickSelectCellSelection(before, cell, mode) + inputRef.current.drag = { + kind: 'marquee', + start: point, + last: point, + startClient: { x: event.clientX, y: event.clientY }, + selectionStart: before, + selectionCommitStart: before, + selectionMode: mode, + previewSelection: baseSelection, + marqueePreviewSelection: rectSelection(cell.x, cell.y, cell.width, cell.height), + marqueeBounds: { ...cell }, + previewTarget: { ...cell }, + quickSelectCell: { ...cell }, + moved: false + } + quickSelectionHandledAtRef.current = event.timeStamp + event.currentTarget.style.cursor = selectionCreationCursor(selectionCrosshair, activeLayerEditable) + scheduleDraw() + return + } + } const rawHit = selectionHit(event) const transformInteraction = event.button === 0 && !event.shiftKey const hit = transformInteraction && (mode === 'replace' || mode === 'add' || rawHit !== 'inside') ? rawHit : 'outside' if (eyedropperHeld && !(hit in resizeCursors)) { sampleAtPoint(); return } + if (session.textBoxTransform && event.button === 0) { + if (hit in resizeCursors) { + const bounds = { ...session.textBoxTransform.bounds } + inputRef.current.drag = { kind: 'transform-text-box', start: point, last: point, handle: hit as SelectionHandle, transformStartTarget: bounds, previewTarget: bounds } + event.currentTarget.style.cursor = displayedResizeCursorForHandle(hit as SelectionHandle) + } + return + } if (hit === 'inside' && session.selection) { - beginSelectionAdjustmentEdit() let floating = session.pendingPaste const copyRequested = modifierActive(event.nativeEvent, 'copySelectionContent') if (floating && shouldRestartFloatingSelectionForCopy(floating.copy, copyRequested)) { @@ -2670,84 +3736,59 @@ export function CanvasStage({ session }: { session: DocumentSession }) { floating = null } const copy = floatingSelectionCopyMode(floating?.copy ?? null, copyRequested) - const source = floating?.source ?? captureSelectionTransform(session.document, session.selection, editableLayer) - if (source) { - const selectionStart = cloneSelection(session.selection)! - const transformTarget = floating?.transformTarget ?? { x: selectionStart.x, y: selectionStart.y, width: selectionStart.width, height: selectionStart.height } - const transformAngle = floating?.transformAngle ?? 0 - const transformShear = floating?.transformShear - inputRef.current.drag = { kind: 'move-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, translationPreview: floating?.translationPreview, copy, floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart, transformStartTarget: { ...transformTarget }, startAngle: transformAngle, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } - renderAdjustmentPreviewEdit(session.document.id, selectionStart) - event.currentTarget.style.cursor = !floating && copy ? canvasCursors.copy : canvasCursors.move - return - } - endSelectionAdjustmentEdit() + const selectionStart = cloneSelection(session.selection)! + const transformTarget = floating?.transformTarget ?? { x: selectionStart.x, y: selectionStart.y, width: selectionStart.width, height: selectionStart.height } + const transformAngle = floating?.transformAngle ?? 0 + const transformShear = floating?.transformShear + inputRef.current.drag = { kind: 'move-content', start: point, last: point, selectionStart, selectionSource: floating?.source, selectionSourceCacheKey: session.selection, selectionPreparationPending: true, previewEdit: floating?.previewEdit, translationPreview: floating?.translationPreview, deferredSelectionPreview: selectionTransformDeferredPreviewEnabled('move-content', canUseDeferredSelectionPreview(editableLayer), transformAngle, transformShear), copy, floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart, selectionPivotStart: customSelectionPivot, previewPivot: customSelectionPivot, transformStartTarget: { ...transformTarget }, startAngle: transformAngle, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } + event.currentTarget.style.cursor = !floating && copy ? canvasCursors.copy : canvasCursors.move return } if (hit === 'edge' && session.selection) { if (session.pendingPaste) { - beginSelectionAdjustmentEdit() const selectionStart = cloneSelection(session.selection) const transformTarget = session.pendingPaste.transformTarget ?? { x: selectionStart!.x, y: selectionStart!.y, width: selectionStart!.width, height: selectionStart!.height } const transformAngle = session.pendingPaste.transformAngle ?? 0 const transformShear = session.pendingPaste.transformShear - inputRef.current.drag = { kind: 'move-content', start: point, last: point, selectionStart, selectionSource: session.pendingPaste.source, previewEdit: session.pendingPaste.previewEdit, translationPreview: session.pendingPaste.translationPreview, copy: session.pendingPaste.copy, floatingPaste: true, previewSelection: selectionStart, appliedSelection: selectionStart, transformStartTarget: { ...transformTarget }, startAngle: transformAngle, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } - renderAdjustmentPreviewEdit(session.document.id, selectionStart) + inputRef.current.drag = { kind: 'move-content', start: point, last: point, selectionStart, selectionSource: session.pendingPaste.source, selectionSourceCacheKey: session.selection, selectionPreparationPending: true, previewEdit: session.pendingPaste.previewEdit, translationPreview: session.pendingPaste.translationPreview, deferredSelectionPreview: selectionTransformDeferredPreviewEnabled('move-content', canUseDeferredSelectionPreview(editableLayer), transformAngle, transformShear), copy: session.pendingPaste.copy, floatingPaste: true, previewSelection: selectionStart, appliedSelection: selectionStart, selectionPivotStart: customSelectionPivot, previewPivot: customSelectionPivot, transformStartTarget: { ...transformTarget }, startAngle: transformAngle, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } event.currentTarget.style.cursor = canvasCursors.selectionMove return } const selectionStart = cloneSelection(session.selection) - inputRef.current.drag = { kind: 'move-selection', start: point, last: point, selectionStart, previewSelection: selectionStart } + inputRef.current.drag = { kind: 'move-selection', start: point, last: point, selectionStart, previewSelection: selectionStart, selectionPivotStart: customSelectionPivot, previewPivot: customSelectionPivot } event.currentTarget.style.cursor = canvasCursors.selectionMove return } if (hit in shearCursors && session.selection) { - beginSelectionAdjustmentEdit() const floating = session.pendingPaste - const source = floating?.source ?? captureSelectionTransform(session.document, session.selection, editableLayer) - if (source) { - const selectionStart = cloneSelection(session.selection) - const transformTarget = floating?.transformTarget ?? { x: selectionStart!.x, y: selectionStart!.y, width: selectionStart!.width, height: selectionStart!.height } - const transformAngle = floating?.transformAngle ?? 0 - const transformShear = floating?.transformShear - inputRef.current.drag = { kind: 'shear-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, translationPreview: floating?.translationPreview, shearHandle: hit as SelectionShearHandle, copy: floatingSelectionCopyMode(floating?.copy ?? null, modifierActive(event.nativeEvent, 'copySelectionContent')), floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart, transformStartTarget: { ...transformTarget }, startAngle: transformAngle, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } - renderAdjustmentPreviewEdit(session.document.id, selectionStart) - } - if (!inputRef.current.drag) endSelectionAdjustmentEdit() - event.currentTarget.style.cursor = canvasCursors.move + const selectionStart = cloneSelection(session.selection) + const transformTarget = floating?.transformTarget ?? { x: selectionStart!.x, y: selectionStart!.y, width: selectionStart!.width, height: selectionStart!.height } + const transformAngle = floating?.transformAngle ?? 0 + const transformShear = floating?.transformShear + inputRef.current.drag = { kind: 'shear-content', start: point, last: point, selectionStart, selectionSource: floating?.source, selectionSourceCacheKey: session.selection, selectionPreparationPending: true, previewEdit: floating?.previewEdit, translationPreview: floating?.translationPreview, deferredSelectionPreview: selectionTransformDeferredPreviewEnabled('shear-content', canUseDeferredSelectionPreview(editableLayer), transformAngle, transformShear), shearHandle: hit as SelectionShearHandle, copy: floatingSelectionCopyMode(floating?.copy ?? null, modifierActive(event.nativeEvent, 'copySelectionContent')), floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart, selectionPivotStart: selectionPivotForSession(session) ?? undefined, transformStartTarget: { ...transformTarget }, startAngle: transformAngle, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } + event.currentTarget.style.cursor = shearCursorForTransform(hit as SelectionShearHandle, transformTarget, transformAngle, transformShear) return } if (hit in rotationCursors && session.selection) { - beginSelectionAdjustmentEdit() const floating = session.pendingPaste - const source = floating?.source ?? captureSelectionTransform(session.document, session.selection, editableLayer) - if (source) { - const selectionStart = cloneSelection(session.selection)! - const transformTarget = floating?.transformTarget ?? { x: selectionStart.x, y: selectionStart.y, width: selectionStart.width, height: selectionStart.height } - const transformAngle = floating?.transformAngle ?? 0 - const transformShear = floating?.transformShear - inputRef.current.drag = { kind: 'rotate-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, translationPreview: floating?.translationPreview, angle: transformAngle, startAngle: transformAngle, copy: floatingSelectionCopyMode(floating?.copy ?? null, modifierActive(event.nativeEvent, 'copySelectionContent')), floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart, transformStartTarget: { ...transformTarget }, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } - renderAdjustmentPreviewEdit(session.document.id, selectionStart) - } - if (!inputRef.current.drag) endSelectionAdjustmentEdit() - event.currentTarget.style.cursor = canvasCursors.move + const selectionStart = cloneSelection(session.selection)! + const transformTarget = floating?.transformTarget ?? { x: selectionStart.x, y: selectionStart.y, width: selectionStart.width, height: selectionStart.height } + const transformAngle = floating?.transformAngle ?? 0 + const transformShear = floating?.transformShear + inputRef.current.drag = { kind: 'rotate-content', start: point, last: point, selectionStart, selectionSource: floating?.source, selectionSourceCacheKey: session.selection, selectionPreparationPending: true, previewEdit: floating?.previewEdit, translationPreview: floating?.translationPreview, deferredSelectionPreview: selectionTransformDeferredPreviewEnabled('rotate-content', canUseDeferredSelectionPreview(editableLayer), transformAngle, transformShear), angle: transformAngle, startAngle: transformAngle, copy: floatingSelectionCopyMode(floating?.copy ?? null, modifierActive(event.nativeEvent, 'copySelectionContent')), floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart, selectionPivotStart: selectionPivotForSession(session) ?? undefined, transformStartTarget: { ...transformTarget }, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } + event.currentTarget.style.cursor = rotationCursorForHit(hit as SelectionRotationHandle) return } if (hit in resizeCursors && session.selection) { - beginSelectionAdjustmentEdit() const floating = session.pendingPaste - const source = floating?.source ?? captureSelectionTransform(session.document, session.selection, editableLayer) - if (source) { - const selectionStart = cloneSelection(session.selection) - const modifiers = selectionTransformModifierState(event.nativeEvent) - const transformTarget = floating?.transformTarget ?? { x: selectionStart!.x, y: selectionStart!.y, width: selectionStart!.width, height: selectionStart!.height } - const transformAngle = floating?.transformAngle ?? 0 - const transformShear = floating?.transformShear - inputRef.current.drag = { kind: 'transform-content', start: point, last: point, selectionStart, selectionSource: source, previewEdit: floating?.previewEdit, translationPreview: floating?.translationPreview, handle: hit as SelectionHandle, copy: floatingSelectionCopyMode(floating?.copy ?? null, modifiers.copy), floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart, transformStartTarget: { ...transformTarget }, startAngle: transformAngle, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } - renderAdjustmentPreviewEdit(session.document.id, selectionStart) - event.currentTarget.style.cursor = canvasCursors.move - } - if (!inputRef.current.drag) endSelectionAdjustmentEdit() + const selectionStart = cloneSelection(session.selection) + const modifiers = selectionTransformModifierState(event.nativeEvent) + const selectionPivotStart = selectionPivotForSession(currentSelectionSession) ?? undefined + const transformTarget = floating?.transformTarget ?? { x: selectionStart!.x, y: selectionStart!.y, width: selectionStart!.width, height: selectionStart!.height } + const transformAngle = floating?.transformAngle ?? 0 + const transformShear = floating?.transformShear + inputRef.current.drag = { kind: 'transform-content', start: point, last: point, selectionStart, selectionSource: floating?.source, selectionSourceCacheKey: session.selection, selectionPreparationPending: true, previewEdit: floating?.previewEdit, translationPreview: floating?.translationPreview, deferredSelectionPreview: selectionTransformDeferredPreviewEnabled('transform-content', canUseDeferredSelectionPreview(editableLayer), transformAngle, transformShear), handle: hit as SelectionHandle, copy: floatingSelectionCopyMode(floating?.copy ?? null, modifiers.copy), floatingPaste: Boolean(floating), previewSelection: selectionStart, appliedSelection: selectionStart, selectionPivotStart, previewPivot: selectionPivotStart ? { ...selectionPivotStart } : undefined, selectionPivotCustom: Boolean(currentSelectionSession.selectionPivot), transformStartTarget: { ...transformTarget }, startAngle: transformAngle, transformStartShear: transformShear ? { ...transformShear } : undefined, previewTarget: { ...transformTarget }, previewAngle: transformAngle, previewShear: transformShear ? { ...transformShear } : undefined } + event.currentTarget.style.cursor = resizeCursorForHit(hit as SelectionHandle) return } if (session.pendingPaste) state.commitFloatingPaste() @@ -2807,9 +3848,37 @@ export function CanvasStage({ session }: { session: DocumentSession }) { 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, startClient: { x: event.clientX, y: event.clientY }, selectionStart: cloneSelection(session.selection), selectionMode: mode, constrain: false } + event.currentTarget.style.cursor = selectionCreationCursor(selectionCrosshair, activeLayerEditable) + return + } + if (session.tool === 'shape') { + if (!canEditLayer || (event.button !== 0 && event.button !== 2)) return + const color = activeColor(event.button) + if (session.shapeKind === 'freeform') inputRef.current.drag = { kind: 'freeform-shape', start: point, last: point, color, path: [point] } + else if (session.shapeKind === 'polygon') inputRef.current.drag = { kind: 'polygon-shape', start: point, last: point, color, path: [point] } + else inputRef.current.drag = { kind: 'shape', start: point, last: point, startClient: { x: event.clientX, y: event.clientY }, constrain: inputRef.current.shiftHeld } + draw() + return + } + if (session.tool === 'line' && (event.button === 0 || event.button === 2)) { + if (!canEditLayer) return + inputRef.current.drag = session.lineKind === 'curve' + ? { kind: 'curve-shape', start: point, last: point, color: activeColor(event.button), curvePhase: 'endpoint', curveAnchorCount: session.curveAnchorCount } + : { kind: 'line-shape', start: point, last: point, color: activeColor(event.button) } + draw() + return + } + if (session.tool === 'airbrush') { + if (!hasRasterFocus || !canEditLayer || (event.button !== 0 && event.button !== 2)) return + const drag: DragState = { + kind: 'airbrush', start: point, last: point, edit: beginPixelEdit(editableLayer.id), + color: activeColor(event.button), startedAt: Date.now(), nextAirbrushAt: performance.now() + session.airbrushIntervalMs + } + inputRef.current.drag = drag + sprayAirbrushRef.current(drag) + scheduleAirbrushTimer() return } - if (session.tool === 'shape') { if (!canEditLayer) return; inputRef.current.drag = { kind: 'shape', start: point, last: point, startClient: { x: event.clientX, y: event.clientY }, constrain: inputRef.current.shiftHeld }; draw(); return } if (session.tool !== 'pencil' && session.tool !== 'eraser') return if (!hasRasterFocus) return if (!canEditLayer) return @@ -2839,6 +3908,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { brushSpeed: beginBrushSpeedTracking({ clientX: event.clientX, clientY: event.clientY, timeStamp: event.timeStamp }), startedAt: Date.now() } + scheduleDraw() } const handlePointerMove = (event: React.PointerEvent): void => { @@ -2872,7 +3942,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { return } const autoPanDrag = inputRef.current.drag - if (autoPanDrag && ['marquee', 'lasso', 'polygon-lasso', 'move-selection', 'move-content', 'transform-content', 'rotate-content', 'shear-content'].includes(autoPanDrag.kind)) { + if (autoPanDrag && ['marquee', 'lasso', 'polygon-lasso', 'freeform-shape', 'polygon-shape', 'line-shape', 'curve-shape', 'create-text-box', 'move-selection', 'move-selection-pivot', 'move-content', 'transform-content', 'rotate-content', 'shear-content'].includes(autoPanDrag.kind)) { const bounds = stageBounds() const edge = 28 const edgeSpeed = (position: number, start: number, end: number): number => position < start + edge @@ -2889,17 +3959,19 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } } updateCursor(event) - inputRef.current.shiftLinePreview = Boolean(lineConnectionActive(event.nativeEvent) && !canvasResizePreviewRef.current && !inputRef.current.spaceHeld && (session.tool === 'pencil' || session.tool === 'eraser') && lineAnchor) + inputRef.current.shiftLinePreview = lineConnectionPreviewActive(event.nativeEvent) const point = localPoint(event) if (point) inputRef.current.updatePointer({ point, clientX: event.clientX, clientY: event.clientY, ctrlKey: event.ctrlKey, altKey: event.altKey }) - const modifierSizing = modifierActive(event.nativeEvent, 'brushSizeAdjust') && (session.tool === 'pencil' || session.tool === 'eraser') + const modifierSizing = modifierActive(event.nativeEvent, 'brushSizeAdjust') && (session.tool === 'pencil' || session.tool === 'airbrush' || session.tool === 'eraser') if (modifierSizing && !inputRef.current.drag) { - if (!inputRef.current.modifierBrushSize) inputRef.current.modifierBrushSize = { x: event.clientX, y: event.clientY, size: session.brushSize } + if (!inputRef.current.modifierBrushSize) inputRef.current.modifierBrushSize = { x: event.clientX, y: event.clientY, size: session.tool === 'airbrush' ? session.airbrushScatterRadius : session.brushSize } else { const delta = event.clientX - inputRef.current.modifierBrushSize.x - useWorkspace.getState().setBrushSize(inputRef.current.modifierBrushSize.size + Math.round(delta / 4)) + const nextSize = inputRef.current.modifierBrushSize.size + Math.round(delta / 4) + if (session.tool === 'airbrush') useWorkspace.getState().setAirbrushScatterRadius(nextSize) + else useWorkspace.getState().setBrushSize(nextSize) } - event.currentTarget.style.cursor = canvasCursors.ewResize + scheduleDraw() return } if (!modifierSizing) inputRef.current.modifierBrushSize = null @@ -2911,10 +3983,21 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (drag.kind === 'brush-size' && drag.startClient) { drag.last = point const delta = event.clientX - drag.startClient.x - state.setBrushSize((drag.startBrushSize ?? session.brushSize) + Math.round(delta / 4)) + const nextSize = (drag.startBrushSize ?? (session.tool === 'airbrush' ? session.airbrushScatterRadius : session.brushSize)) + Math.round(delta / 4) + if (session.tool === 'airbrush') state.setAirbrushScatterRadius(nextSize) + else state.setBrushSize(nextSize) event.currentTarget.style.cursor = canvasCursors.ewResize return } + if (drag.kind === 'move-selection-pivot' && drag.selectionPivotStart) { + const continuousPoint = localContinuousPointAt(event.clientX, event.clientY) + if (!continuousPoint) return + drag.last = continuousPoint + drag.previewPivot = selectionPivotAtDragPoint(drag.selectionPivotStart, drag.start, continuousPoint) + event.currentTarget.style.cursor = canvasCursors.move + scheduleDraw() + return + } if (drag.kind === 'gradient') { drag.rawLast = point drag.constrain = event.shiftKey @@ -2926,9 +4009,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (drag.kind === 'sample-color') { if (point.x >= 0 && point.y >= 0 && point.x < session.document.width && point.y < session.document.height) { const mask = activeLayerMask(session) - const sampled = mask ? readLayerMaskDisplayColorAt(mask, point.x, point.y) : sampleCompositeColor(session.document, point.x, point.y) + const sampled = mask ? readLayerMaskDisplayColorAt(mask, point.x, point.y) : sampleCompositeColor(session.document, point.x, point.y, session.document.activeLayerId) if (drag.sampleSecondary) state.setSecondaryColor(sampled) else state.setPrimaryColor(sampled) + drag.sampledColor = { ...sampled } publishCanvasColorSample(sampled, Boolean(drag.sampleSecondary)) inputRef.current.sampling = true updateEyedropperMagnifier(event.clientX, event.clientY, sampled) @@ -3004,13 +4088,17 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const distanceX = distance.x const distanceY = distance.y drag.moved = drag.moved || distanceX !== 0 || distanceY !== 0 + const previousDistance = drag.layerPreviewOffset ?? { x: 0, y: 0 } + if (previousDistance.x === distanceX && previousDistance.y === distanceY) return + if (drag.selectionPivotStart) drag.previewPivot = { x: drag.selectionPivotStart.x + distanceX, y: drag.selectionPivotStart.y + distanceY } if (drag.duplicateOnDrag && !drag.duplicatedLayerId && (distanceX !== 0 || distanceY !== 0)) { state.mutateActive((active) => { const source = active.document.layers.find((candidate) => candidate.id === drag.layerId) if (!source) return + const layerStyles = cloneLayerStyles(source.layerStyles) const copy = source.format === 'rgba' - ? { ...source, id: `${source.id}-copy-${Date.now()}`, name: `${source.name} ${t('canvas.history.copySuffix')}`, pixels: new Uint8ClampedArray(source.pixels) } as RasterLayer - : { ...source, id: `${source.id}-copy-${Date.now()}`, name: `${source.name} ${t('canvas.history.copySuffix')}`, pixels: new Uint32Array(source.pixels) } as RasterLayer + ? { ...source, id: `${source.id}-copy-${Date.now()}`, name: `${source.name} ${t('canvas.history.copySuffix')}`, ...(layerStyles ? { layerStyles } : {}), pixels: new Uint8ClampedArray(source.pixels) } as RasterLayer + : { ...source, id: `${source.id}-copy-${Date.now()}`, name: `${source.name} ${t('canvas.history.copySuffix')}`, ...(layerStyles ? { layerStyles } : {}), pixels: new Uint32Array(source.pixels) } as RasterLayer const insertionIndex = active.document.layers.indexOf(source) + 1 active.document.layers.splice(insertionIndex, 0, copy) active.document.activeLayerId = copy.id @@ -3023,7 +4111,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { }, false) } const activeIds = drag.duplicatedLayerId ? [drag.duplicatedLayerId] : drag.layerIds ?? [drag.layerId] - state.mutateActive((active) => { + const active = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) + if (!active) return + { if (!drag.duplicatedLayerId && drag.animationCellOffsets && drag.animationCellKeys?.length) { const nextOffsets = Object.fromEntries(drag.animationCellKeys.map((key) => { const offset = drag.animationCellOffsets![key] @@ -3040,8 +4130,17 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } } if (drag.selectionStart) active.selection = shiftSelection(drag.selectionStart, distanceX, distanceY, active.document.width, active.document.height) - }, false) - compositeCacheRef.current.invalidateAll() + } + if (drag.layerContentBounds) { + for (const bounds of Object.values(drag.layerContentBounds)) { + if (!bounds) continue + invalidateCompositeRect(translatedSelectionRect(bounds, previousDistance)) + invalidateCompositeRect(translatedSelectionRect(bounds, distance)) + } + } else { + compositeCacheRef.current.invalidateAll() + } + drag.layerPreviewOffset = { x: distanceX, y: distanceY } scheduleDraw() return } @@ -3128,12 +4227,42 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } scheduleDraw(); return } + if (drag.kind === 'airbrush') { + scheduleDraw() + return + } if (drag.kind === 'shape') { const modifiers = currentSelectionMarqueeModifierState() drag.constrain = modifiers.proportional updateShapePreview(drag, point, modifiers) return } + if (drag.kind === 'freeform-shape') { + const path = drag.path ?? [] + const last = path.at(-1) + if (!last || last.x !== point.x || last.y !== point.y) drag.path = [...path, point] + drag.last = point + scheduleDraw() + return + } + if (drag.kind === 'polygon-shape') { drag.last = point; scheduleDraw(); return } + if (drag.kind === 'line-shape') { + drag.last = event.shiftKey ? constrainLineEndpoint(drag.start, point, lineDirectionStep) : point + scheduleDraw() + return + } + if (drag.kind === 'curve-shape') { + if (drag.curvePhase === 'endpoint') drag.last = point + else { + const anchorIndex = drag.curveAnchorIndex ?? 0 + const controls = drag.curveControls ?? [] + for (let index = anchorIndex; index < controls.length; index += 1) controls[index] = point + drag.curveControls = controls + } + drag.last = point + scheduleDraw() + return + } if (drag.kind === 'marquee') { drag.moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) if (!drag.moved) { scheduleDraw(); return } @@ -3142,6 +4271,38 @@ export function CanvasStage({ session }: { session: DocumentSession }) { updateMarqueePreview(drag, point, modifiers) return } + if (drag.kind === 'create-text-box') { + drag.moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) + drag.last = point + drag.previewTarget = clampSliceRect(shapeBounds(drag.start, point), session.document.width, session.document.height) + scheduleDraw() + return + } + if (drag.kind === 'create-slice') { + drag.moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) + drag.last = point + drag.previewTarget = clampSliceRect(shapeBounds(drag.start, point), session.document.width, session.document.height) + scheduleDraw() + return + } + if (drag.kind === 'move-slice' && drag.sliceStart) { + drag.moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) + drag.last = point + if (drag.sliceIds?.length && drag.sliceStarts) { + const starts = drag.sliceIds.flatMap((id) => drag.sliceStarts?.[id] ? [drag.sliceStarts[id]] : []) + const targets = moveSliceRects(starts, point.x - drag.start.x, point.y - drag.start.y, session.document.width, session.document.height) + drag.slicePreviewTargets = Object.fromEntries(drag.sliceIds.map((id, index) => [id, targets[index]]).filter((entry): entry is [string, SelectionRect] => Boolean(entry[1]))) + drag.previewTarget = drag.slicePreviewTargets[drag.sliceId ?? ''] ?? targets[0] + } else drag.previewTarget = moveSliceRect(drag.sliceStart, point.x - drag.start.x, point.y - drag.start.y, session.document.width, session.document.height) + scheduleDraw() + return + } + if (drag.kind === 'resize-slice' && drag.sliceStart && drag.handle) { + drag.last = point + drag.previewTarget = clampSliceRect(resizeSelectionBounds(drag.sliceStart, point, drag.handle, session.document), session.document.width, session.document.height) + scheduleDraw() + return + } if (drag.kind === 'magic-preview') { const mode = drag.selectionMode ?? session.selectionMode const incoming = symmetrySelection(magicWandSelection(session.document, activePaintLayer(session), point.x, point.y, session.wandTolerance, session.wandContiguous), session.document.width, session.document.height, session.symmetryAxes, symmetryCenter) @@ -3159,66 +4320,98 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (drag.kind === 'polygon-lasso') { scheduleDraw(); return } if (drag.kind === 'move-selection' && drag.selectionStart) { const start = drag.selectionStart - const distance = constrainedTranslation(drag, point.x - drag.start.x, point.y - drag.start.y, modifierActive(event.nativeEvent, 'constrainAxis')) + const dragDelta = symmetrySelectionDragDelta(start, drag.start, { x: point.x - drag.start.x, y: point.y - drag.start.y }, session.document.width, session.document.height, session.symmetryAxes, symmetryCenter) + const distance = constrainedTranslation(drag, dragDelta.x, dragDelta.y, modifierActive(event.nativeEvent, 'constrainAxis')) const target = { ...start, x: start.x + distance.x, y: start.y + distance.y } if (drag.previewTarget?.x === target.x && drag.previewTarget.y === target.y) return drag.previewTarget = target drag.previewAngle = 0 + if (drag.selectionPivotStart) drag.previewPivot = { x: drag.selectionPivotStart.x + distance.x, y: drag.selectionPivotStart.y + distance.y } scheduleSelectionPreview(drag) return } if (drag.kind === 'move-content' && drag.selectionStart) { const start = drag.transformStartTarget ?? drag.selectionStart - const distance = constrainedTranslation(drag, point.x - drag.start.x, point.y - drag.start.y, modifierActive(event.nativeEvent, 'constrainAxis')) + const dragDelta = symmetrySelectionDragDelta(drag.selectionStart, drag.start, { x: point.x - drag.start.x, y: point.y - drag.start.y }, session.document.width, session.document.height, session.symmetryAxes, symmetryCenter) + const distance = constrainedTranslation(drag, dragDelta.x, dragDelta.y, modifierActive(event.nativeEvent, 'constrainAxis')) const target = { ...start, x: start.x + distance.x, y: start.y + distance.y } if (drag.previewTarget?.x === target.x && drag.previewTarget.y === target.y) return + if (!prepareSelectionTransformDrag(drag)) return drag.previewTarget = target drag.previewAngle = drag.startAngle ?? 0 drag.previewShear = drag.transformStartShear ? { ...drag.transformStartShear } : undefined + if (drag.selectionPivotStart) drag.previewPivot = { x: drag.selectionPivotStart.x + distance.x, y: drag.selectionPivotStart.y + distance.y } scheduleSelectionPreview(drag) return } - if (drag.kind === 'transform-content' && drag.selectionStart && drag.selectionSource && drag.handle) { + if (drag.kind === 'transform-content' && drag.selectionStart && drag.handle) { updateSelectionTransformPreview(drag, point, selectionTransformModifierState(event.nativeEvent)) return } - if (drag.kind === 'shear-content' && drag.selectionStart && drag.selectionSource && drag.shearHandle) { + if (drag.kind === 'transform-text-box' && drag.transformStartTarget) { + const modifiers = selectionTransformModifierState(event.nativeEvent) + const target = drag.handle + ? resizeSelectionBounds(drag.transformStartTarget, point, drag.handle, session.document, modifiers.proportional, false, modifiers.fromCenter) + : moveSliceRect(drag.transformStartTarget, point.x - drag.start.x, point.y - drag.start.y, session.document.width, session.document.height) + drag.last = point + drag.moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) + if (!drag.handle && !drag.moved) return + drag.previewTarget = clampSliceRect(target, session.document.width, session.document.height) + state.previewTextBoxTransform(drag.previewTarget) + compositeCacheRef.current.invalidateAll() + scheduleDraw() + return + } + if (drag.kind === 'shear-content' && drag.selectionStart && drag.shearHandle) { const edge = drag.shearHandle.slice(-1) as 'n' | 'e' | 's' | 'w' - const axis = edge === 'n' || edge === 's' ? 'x' : 'y' const angle = drag.startAngle ?? 0 - const radians = angle * Math.PI / 180 + const direction = transformedSelectionShearDirection( + drag.transformStartTarget ?? drag.selectionStart, + angle, + drag.transformStartShear, + edge + ) + if (!direction) return const deltaX = point.x - drag.start.x const deltaY = point.y - drag.start.y - const localDelta = axis === 'x' - ? deltaX * Math.cos(radians) + deltaY * Math.sin(radians) - : -deltaX * Math.sin(radians) + deltaY * Math.cos(radians) - const startShear = drag.transformStartShear - const baseAmount = startShear?.axis === axis && startShear.edge === edge ? startShear.amount : 0 - const amount = baseAmount + Math.round(localDelta) - if (drag.previewShear?.axis === axis && drag.previewShear.edge === edge && drag.previewShear.amount === amount) return - drag.previewTarget = { ...(drag.transformStartTarget ?? drag.selectionStart) } - drag.previewAngle = angle - drag.previewShear = { axis, edge, amount } + const localDelta = deltaX * direction.x + deltaY * direction.y + const amount = Math.round(localDelta) + if (drag.shearAmount === amount) return + if (!prepareSelectionTransformDrag(drag)) return + const transformed = shearTransformedSelection( + drag.transformStartTarget ?? drag.selectionStart, + angle, + drag.transformStartShear, + edge, + amount, + drag.selectionPivotStart + ) + drag.shearAmount = amount + drag.previewTarget = transformed.target + drag.previewAngle = transformed.angle + drag.previewShear = transformed.shear scheduleSelectionPreview(drag) return } if (drag.kind === 'rotate-content' && drag.selectionStart) { const transformTarget = drag.transformStartTarget ?? drag.selectionStart - const centerX = transformTarget.x + transformTarget.width / 2 - const centerY = transformTarget.y + transformTarget.height / 2 - const startAngle = Math.atan2(drag.start.y - centerY, drag.start.x - centerX) - const rawAngle = (Math.atan2(point.y - centerY, point.x - centerX) - startAngle) * 180 / Math.PI - const angle = snapSelectionRotation((drag.startAngle ?? 0) + rawAngle, modifierActive(event.nativeEvent, 'snapSelectionRotation')) - if (drag.previewAngle === angle) return + const startAngle = drag.startAngle ?? 0 + const pivot = drag.selectionPivotStart ?? transformedSelectionPivotPreset(transformTarget, 'center', startAngle, drag.transformStartShear) + const rawAngle = selectionRotationAngle(transformTarget, drag.start, point, false, pivot) + const angle = snapSelectionRotation(startAngle + rawAngle, modifierActive(event.nativeEvent, 'snapSelectionRotation')) + const target = rotateSelectionTargetAroundPivot(transformTarget, pivot, angle - startAngle) + if (drag.previewAngle === angle && drag.previewTarget?.x === target.x && drag.previewTarget.y === target.y) return + if (!prepareSelectionTransformDrag(drag)) return drag.angle = angle drag.previewAngle = angle - drag.previewTarget ??= { ...transformTarget } + drag.previewTarget = target drag.previewShear = drag.transformStartShear ? { ...drag.transformStartShear } : undefined scheduleSelectionPreview(drag) } } const handlePointerUp = (event: React.PointerEvent): void => { + stopAirbrushTimer() if (symmetryDragRef.current) { symmetryDragRef.current = null if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) @@ -3226,7 +4419,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { draw() return } - if (inputRef.current.drag?.kind === 'polygon-lasso') { + if (inputRef.current.drag?.kind === 'polygon-lasso' || inputRef.current.drag?.kind === 'polygon-shape') { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) endSelectionAdjustmentEdit() scheduleDraw() @@ -3248,7 +4441,6 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const selectionPreviewWasPending = Boolean(drag.previewPending) flushSelectionPreview(drag) if (adjustmentPreviewEditRef.current && !selectionPreviewWasPending) prepareAdjustmentPreviewEdit(session.document.id) - if (drag.kind === 'move-layer') compositeCacheRef.current.invalidateSurface() if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) const state = useWorkspace.getState() if (drag.kind === 'pan') { @@ -3277,10 +4469,19 @@ export function CanvasStage({ session }: { session: DocumentSession }) { updateRotationIndicator(liveViewRef.current.rotation, false) return } + if (drag.kind === 'move-selection-pivot') { + if (drag.previewPivot) state.setSelectionPivot(drag.previewPivot) + updateCursor(event) + draw() + return + } if (drag.kind === 'sample-color') { inputRef.current.sampling = false hideEyedropperMagnifier() eyedropperOriginalColorRef.current = null + const sampledColorToAdd = sampledForegroundColorToAdd(drag, paletteSamplingShortcutActive()) + if (sampledColorToAdd) state.addPaletteColor(sampledColorToAdd) + publishCanvasColorSamplingCompleted() if (!drag.temporarySampling && eyedropperSwitchToPencil) { state.setTool('pencil') event.currentTarget.style.cursor = canvasToolCursor('pencil', session.primaryColor) @@ -3302,22 +4503,39 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } updateCursor(event) if (drag.kind === 'draw' && drag.edit) { - state.commitPixelEdit(drag.edit, session.tool === 'eraser' ? t('canvas.history.eraser') : t('canvas.history.draw'), { stroke: true, durationMs: Math.max(1, Date.now() - (drag.startedAt ?? Date.now())) }) + const entry = state.commitPixelEdit(drag.edit, session.tool === 'eraser' ? t('canvas.history.eraser') : t('canvas.history.draw'), { stroke: true, durationMs: Math.max(1, Date.now() - (drag.startedAt ?? Date.now())) }) + const singlePoint = drag.path?.every((point) => point.x === drag.start.x && point.y === drag.start.y) ?? false + lineAnchorHistoryRef.current = entry && singlePoint ? { + documentId: session.document.id, + layerId: drag.edit.layerId, + tool: session.tool === 'eraser' ? 'eraser' : 'pencil', + point: { ...drag.last }, + entry, + baseline: new Map(drag.edit.before), + mergeWithNext: true + } : null if (session.tool === 'eraser') state.setLastEraserPoint(drag.last) else state.setLastPencilPoint(drag.last) } + if (drag.kind === 'airbrush' && drag.edit) { + state.commitPixelEdit(drag.edit, t('canvas.history.airbrush'), { stroke: true, durationMs: Math.max(1, Date.now() - (drag.startedAt ?? Date.now())) }) + } if (drag.kind === 'move-layer' && !drag.duplicatedLayer && drag.animationCellOffsets && drag.animationCellKeys?.length) { const before = drag.animationCellOffsets const after = animationCelOffsetsForKeys(session.document, drag.animationCellKeys) const beforeSelection = cloneSelection(drag.selectionStart ?? null) const afterSelection = cloneSelection(session.selection) if (drag.animationCellKeys.some((key) => after[key] && (after[key].x !== before[key].x || after[key].y !== before[key].y))) { + const affectedLayerIds = [...new Set(drag.animationCellKeys.map((key) => parseAnimationCelKey(key)?.layerId).filter((id): id is string => Boolean(id)))] + const activeFrameOnly = Boolean(drag.layerFrameId) && drag.animationCellKeys.every((key) => parseAnimationCelKey(key)?.frameId === drag.layerFrameId) state.pushHistory({ label: t(drag.animationCellKeys.length > 1 ? 'canvas.history.moveSelectedLayers' : 'canvas.history.moveLayer'), bytes: drag.animationCellKeys.length * 32, undo: () => { setAnimationCelOffsetsForKeys(session.document, before); session.selection = cloneSelection(beforeSelection) }, - redo: () => { setAnimationCelOffsetsForKeys(session.document, after); session.selection = cloneSelection(afterSelection) } + redo: () => { setAnimationCelOffsetsForKeys(session.document, after); session.selection = cloneSelection(afterSelection) }, + invalidation: activeFrameOnly ? moveLayerInvalidation(drag, drag.layerFrameId) : undefined, + affectedLayerIds, + requiresAnimationSync: false }) - state.mutateActive(() => {}, true) } } else if (drag.kind === 'move-layer' && !drag.duplicatedLayer && drag.layerIds && drag.layerIds.length > 1 && drag.layerOffsets) { const before = drag.layerOffsets @@ -3332,9 +4550,11 @@ export function CanvasStage({ session }: { session: DocumentSession }) { state.pushHistory({ label: t('canvas.history.moveSelectedLayers'), bytes: drag.layerIds.length * 32, undo: () => { if (frameId) setAnimationCelOffsets(session.document, frameId, before); session.selection = cloneSelection(beforeSelection) }, - redo: () => { if (frameId) setAnimationCelOffsets(session.document, frameId, after); session.selection = cloneSelection(afterSelection) } + redo: () => { if (frameId) setAnimationCelOffsets(session.document, frameId, after); session.selection = cloneSelection(afterSelection) }, + invalidation: moveLayerInvalidation(drag, frameId), + affectedLayerIds: [...drag.layerIds], + requiresAnimationSync: false }) - state.mutateActive(() => {}, true) } } else if (drag.kind === 'move-layer' && drag.layerId && drag.layerOffset) { const layerId = drag.duplicatedLayerId ?? drag.layerId @@ -3345,7 +4565,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const beforeSelection = cloneSelection(drag.selectionStart ?? null) const afterSelection = cloneSelection(session.selection) state.pushHistory({ - label: t('canvas.history.moveLayer'), bytes: 32, + label: t('canvas.history.moveLayer'), bytes: drag.duplicatedLayer ? drag.duplicatedLayer.pixels.byteLength + layerStylesHistoryBytes(drag.duplicatedLayer.layerStyles) + 32 : 32, undo: () => { if (drag.duplicatedLayer) { session.document.layers = session.document.layers.filter((candidate) => candidate.id !== layerId) @@ -3367,9 +4587,11 @@ export function CanvasStage({ session }: { session: DocumentSession }) { session.selectedGroupIds = [] } else if (drag.layerFrameId) setAnimationCelOffsets(session.document, drag.layerFrameId, { [layerId]: after }) session.selection = cloneSelection(afterSelection) - } + }, + invalidation: drag.duplicatedLayer ? undefined : moveLayerInvalidation(drag, drag.layerFrameId), + affectedLayerIds: drag.duplicatedLayer ? undefined : [layerId], + requiresAnimationSync: drag.duplicatedLayer ? undefined : false }) - state.mutateActive(() => {}, true) } } if (drag.kind === 'move-layer' && drag.collapseLayerSelectionOnClick && !drag.moved && drag.clickLayerId) { @@ -3379,6 +4601,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { if (drag.kind === 'move-layer' && drag.moved && drag.animationCellKeys?.length) { finishAnimationCellOperation(session.document.id) } + if (drag.kind === 'move-layer' && drag.previewPivot) state.setSelectionPivot(drag.previewPivot) if (drag.kind === 'shape') { const layer = activePaintLayer(session) if (!isLayerEffectivelyLocked(session.document, layer)) { @@ -3389,13 +4612,84 @@ export function CanvasStage({ session }: { session: DocumentSession }) { state.commitPixelEdit(edit, ellipse ? t('canvas.history.drawEllipse') : t('canvas.history.drawRectangle')) } } + if (drag.kind === 'freeform-shape') commitShapePoints(drag, filledShapePathPixelPoints(session.document, drag.path ?? []), t('canvas.history.drawFreeform')) + if (drag.kind === 'line-shape') commitBrushPath(drag, lineShapeBrushPoints(drag), t('canvas.history.drawLine')) + if (drag.kind === 'curve-shape') { + if (drag.curvePhase === 'endpoint') { + drag.curveEnd = drag.last + drag.curveControls = Array.from({ length: drag.curveAnchorCount ?? session.curveAnchorCount }, () => ({ ...drag.curveEnd! })) + drag.curveAnchorIndex = 0 + drag.curvePhase = 'anchors' + inputRef.current.drag = drag + scheduleDraw() + return + } + const nextAnchorIndex = (drag.curveAnchorIndex ?? 0) + 1 + if (nextAnchorIndex < (drag.curveControls?.length ?? 0)) { + drag.curveAnchorIndex = nextAnchorIndex + inputRef.current.drag = drag + scheduleDraw() + return + } + commitBrushPath(drag, curveShapePixelPoints(drag), t('canvas.history.drawCurve')) + } 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, t('canvas.history.createSelection')) + const change = marqueeSelectionCommit(drag, session.selection, moved, session.selectionMode) + state.commitSelectionChange(change.before, change.after, t('canvas.history.createSelection')) updateCursor(event) scheduleDraw() } + if (drag.kind === 'create-text-box') { + const moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) + const target = drag.previewTarget ?? clampSliceRect(shapeBounds(drag.start, drag.last), session.document.width, session.document.height) + textToolBoxRef.current = moved ? { ...target } : null + openTextToolDialog({ + documentId: session.document.id, + x: moved ? target.x : drag.start.x, + y: moved ? target.y : drag.start.y, + ...(moved ? { width: target.width, height: target.height } : {}) + }) + scheduleDraw() + } + if (drag.kind === 'transform-text-box' && drag.previewTarget) { + if (!drag.handle && !drag.moved) { + state.cancelTextBoxTransform() + const currentSession = useWorkspace.getState().sessions.find((item) => item.document.id === session.document.id) ?? session + const layer = currentSession.selectedLayerIds.length === 1 + ? currentSession.document.layers.find((candidate) => candidate.id === currentSession.selectedLayerIds[0] && candidate.kind === 'text') + : null + const timeline = ensureAnimationDocument(currentSession.document) + const cel = layer ? timeline.cels.find((candidate) => candidate.layerId === layer.id && candidate.frameId === timeline.activeFrameId) : null + const source = resolveAnimationCel(timeline, cel ?? null) ?? cel + if (layer && cel) openTextToolDialog({ + documentId: currentSession.document.id, + layerId: layer.id, + frameId: timeline.activeFrameId, + x: source?.text?.originX ?? source?.surface?.offsetX ?? layer.offsetX, + y: source?.text?.originY ?? source?.surface?.offsetY ?? layer.offsetY + }) + } else state.commitTextBoxTransform(drag.previewTarget) + compositeCacheRef.current.invalidateAll() + scheduleDraw() + } + if (drag.kind === 'create-slice' && drag.previewTarget && (drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }))) { + state.createSlice(drag.previewTarget) + scheduleDraw() + } + if (drag.kind === 'move-slice' && drag.sliceId) { + const moved = drag.moved || selectionGestureMoved(drag.startClient, { x: event.clientX, y: event.clientY }) + if (!moved && drag.collapseSliceSelectionOnClick) state.selectSlice(drag.sliceId) + else if (moved && drag.sliceIds?.length && drag.slicePreviewTargets) { + if (drag.copy) state.duplicateSlices(drag.sliceIds, drag.slicePreviewTargets) + else state.updateSlices(drag.slicePreviewTargets) + } else if (moved && drag.previewTarget) state.updateSlice(drag.sliceId, drag.previewTarget) + scheduleDraw() + } + if (drag.kind === 'resize-slice' && drag.sliceId && drag.previewTarget) { + state.updateSlice(drag.sliceId, drag.previewTarget) + scheduleDraw() + } if (drag.kind === 'lasso') { const mode = drag.selectionMode ?? session.selectionMode const before = drag.selectionStart ?? null @@ -3404,9 +4698,37 @@ export function CanvasStage({ session }: { session: DocumentSession }) { state.commitSelectionChange(before, after, t('canvas.history.lassoSelection')) } if (drag.kind === 'magic-preview' && drag.selectionStart !== undefined) state.commitSelectionChange(drag.selectionStart ?? null, drag.previewSelection ?? null, t('canvas.history.magicSelection')) - if (drag.kind === 'move-selection' && drag.selectionStart && drag.previewSelection) state.commitSelectionChange(drag.selectionStart, drag.previewSelection, t('canvas.history.moveSelectionBox')) + if (drag.kind === 'move-selection' && drag.selectionStart && drag.previewSelection) { + state.commitSelectionChange(drag.selectionStart, drag.previewSelection, t('canvas.history.moveSelectionBox')) + if (drag.previewPivot) state.setSelectionPivot(drag.previewPivot) + } if ((drag.kind === 'move-content' || drag.kind === 'transform-content' || drag.kind === 'rotate-content' || drag.kind === 'shear-content') && drag.selectionStart && drag.previewSelection) { - const hasPixelPreview = Boolean(drag.previewEdit || drag.translationPreview?.count) + if (drag.deferredSelectionPreview && drag.selectionSource && selectionTransformPreviewChanged(drag)) { + const target = drag.previewTarget ?? drag.previewSelection + const simpleTranslation = drag.kind === 'move-content' + && (drag.previewAngle ?? 0) % 360 === 0 + && !drag.previewShear + && target.width === drag.selectionSource.selection.width + && target.height === drag.selectionSource.selection.height + && !target.flipHorizontal + && !target.flipVertical + if (!simpleTranslation) { + drag.previewEdit = applySelectionTransform( + session.document, + drag.selectionSource, + target, + drag.previewAngle ?? 0, + Boolean(drag.copy), + drag.previewShear, + session.symmetryAxes, + symmetryCenter, + activePaintLayer(session) + ) + drag.deferredSelectionPreview = false + for (const rect of deferredSelectionCommitInvalidationRects(drag)) invalidateCompositeRect(rect) + } + } + const hasPixelPreview = Boolean(drag.previewEdit || drag.translationPreview?.count || (drag.deferredSelectionPreview && drag.selectionSource && selectionTransformPreviewChanged(drag))) const label = !hasPixelPreview ? t('workspace.history.transformSelection') : drag.copy @@ -3417,8 +4739,9 @@ export function CanvasStage({ session }: { session: DocumentSession }) { ? t('workspace.history.moveSelectionContent') : t('workspace.history.transformSelectionContent') if (selectionTransformPreviewChanged(drag)) { - if (drag.floatingPaste) state.updateFloatingPastePreview(drag.previewEdit ?? null, drag.previewSelection, drag.translationPreview, drag.previewTarget, drag.previewAngle, drag.previewShear) - else if (drag.selectionSource) state.beginFloatingSelectionTransform(drag.selectionSource, drag.previewEdit ?? null, drag.selectionStart, drag.previewSelection, Boolean(drag.copy), label, drag.translationPreview, drag.previewTarget, drag.previewAngle, drag.previewShear) + if (drag.floatingPaste) state.updateFloatingPastePreview(drag.previewEdit ?? null, drag.previewSelection, drag.translationPreview, drag.previewTarget, drag.previewAngle, drag.previewShear, Boolean(drag.deferredSelectionPreview)) + else if (drag.selectionSource) state.beginFloatingSelectionTransform(drag.selectionSource, drag.previewEdit ?? null, drag.selectionStart, drag.previewSelection, Boolean(drag.copy), label, drag.translationPreview, drag.previewTarget, drag.previewAngle, drag.previewShear, Boolean(drag.deferredSelectionPreview)) + if (drag.previewPivot) state.setSelectionPivot(drag.selectionPivotCustom === false ? null : drag.previewPivot) } } endSelectionAdjustmentEdit() @@ -3427,8 +4750,13 @@ export function CanvasStage({ session }: { session: DocumentSession }) { useEffect(() => { const keyDown = (event: KeyboardEvent): void => { - if (inputRef.current.drag?.kind !== 'polygon-lasso') return - if (event.key === 'Enter') commitPolygonLasso() + const drag = inputRef.current.drag + if (drag?.kind !== 'polygon-lasso' && drag?.kind !== 'polygon-shape' && drag?.kind !== 'curve-shape') return + if (event.key === 'Enter') { + if (drag.kind === 'polygon-shape') commitPolygonShape() + else if (drag.kind === 'curve-shape') commitCurveShape() + else commitPolygonLasso() + } else if (event.key === 'Escape') { inputRef.current.finish(); scheduleDraw() } else return event.preventDefault() @@ -3439,24 +4767,64 @@ export function CanvasStage({ session }: { session: DocumentSession }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.document.id, session.selectionMode]) - const onWheel = (event: React.WheelEvent): void => { + const onWheel = (event: WheelEvent): void => { const canvas = canvasRef.current if (!canvas) return - if (!canvasResizePreviewRef.current && modifierActive(event.nativeEvent, 'brushSizeWheelAdjust') && (session.tool === 'pencil' || session.tool === 'eraser') && !activeBrushImage?.intrinsicSize) { + const path = event.composedPath() + const targetsCanvas = path.includes(canvas) + const pointer = inputRef.current.pointer + if (!targetsCanvas) { + if (useWorkspace.getState().activeId !== session.document.id || !pointer.visible) return + const target = event.target instanceof Element ? event.target : null + if (target?.closest('input, textarea, select, [contenteditable="true"], .stage-canvas, .modal-backdrop, .context-menu, .panel, .workspace-panel-popup-layer')) return + } + const delta = normalizeCanvasWheelDelta(event as WheelEvent & { wheelDelta?: number }) + if (delta === 0) return + const now = performance.now() + const previous = lastNativeWheelRef.current + if (previous && previous.type !== event.type && now - previous.at < 12 && Math.sign(previous.delta) === Math.sign(delta)) return + lastNativeWheelRef.current = { at: now, delta, type: event.type } + const rect = stageBounds() + const eventPointInside = event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom + const clientX = targetsCanvas || eventPointInside ? event.clientX : pointer.clientX + const clientY = targetsCanvas || eventPointInside ? event.clientY : pointer.clientY + const wheelModifiers = { + ctrlKey: event.ctrlKey || inputRef.current.ctrlHeld, + metaKey: event.metaKey, + altKey: event.altKey || inputRef.current.altHeld, + shiftKey: event.shiftKey || inputRef.current.shiftHeld + } + if (!canvasResizePreviewRef.current && modifierActive(wheelModifiers, 'brushSizeWheelAdjust') && (session.tool === 'pencil' || session.tool === 'airbrush' || session.tool === 'eraser') && (session.tool === 'airbrush' || !activeBrushImage?.intrinsicSize)) { event.preventDefault() - event.stopPropagation() - useWorkspace.getState().setBrushSize(session.brushSize + (event.deltaY < 0 ? 1 : -1)) + event.stopImmediatePropagation() + wheelBrushSizePreviewRef.current = true + if (session.tool === 'airbrush') useWorkspace.getState().setAirbrushScatterRadius(session.airbrushScatterRadius + (delta < 0 ? 1 : -1)) + else useWorkspace.getState().setBrushSize(session.brushSize + (delta < 0 ? 1 : -1)) + updateCursorAt(clientX, clientY, wheelModifiers.ctrlKey, wheelModifiers.altKey, wheelModifiers.shiftKey) + scheduleDraw() return } if (inputRef.current.drag?.kind === 'pan' || !wheelZoomEnabled) return - const rect = stageBounds() event.preventDefault() + event.stopImmediatePropagation() const liveView = liveViewRef.current const oldZoom = liveView.zoom - const newZoom = wheelCanvasZoom(oldZoom, event.deltaY, wheelZoomMode) + const newZoom = wheelCanvasZoom(oldZoom, delta, wheelZoomMode) if (newZoom === oldZoom) return - scheduleZoomPreview(constrainCanvasView({ ...liveView, ...zoomViewAroundViewportPoint(liveView, newZoom, { x: event.clientX - rect.left, y: event.clientY - rect.top }, rect.width, rect.height, session.document.width, session.document.height, rotationIndicatorPosition) })) + scheduleZoomPreview(constrainCanvasView({ ...liveView, ...zoomViewAroundViewportPoint(liveView, newZoom, { x: clientX - rect.left, y: clientY - rect.top }, rect.width, rect.height, session.document.width, session.document.height, rotationIndicatorPosition) })) } + nativeWheelHandlerRef.current = onWheel + + useEffect(() => { + const listener = (event: Event): void => nativeWheelHandlerRef.current(event as WheelEvent) + const options = { capture: true, passive: false } as AddEventListenerOptions + window.addEventListener('wheel', listener, options) + window.addEventListener('mousewheel', listener, options) + return () => { + window.removeEventListener('wheel', listener, options) + window.removeEventListener('mousewheel', listener, options) + } + }, [session.document.id]) const measurePointerInput = (kind: 'pointer-down' | 'pointer-move' | 'pointer-up', action: () => void): void => { const performanceProbe = window.__moonSpriteCanvasProbe @@ -3476,6 +4844,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { const pointerLeave = (event: React.PointerEvent): void => { inputRef.current.pointer.visible = false inputRef.current.resetPointerInteraction() + wheelBrushSizePreviewRef.current = false inputRef.current.altHeld = false inputRef.current.ctrlHeld = false inputRef.current.shiftHeld = false @@ -3485,10 +4854,10 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } const pointerEnter = (event: React.PointerEvent): void => { updateCursor(event) - inputRef.current.shiftLinePreview = lineConnectionActive(event.nativeEvent) && !canvasResizePreviewRef.current && !inputRef.current.sampling && (session.tool === 'pencil' || session.tool === 'eraser') && Boolean(lineAnchor) + inputRef.current.shiftLinePreview = lineConnectionPreviewActive(event.nativeEvent) draw() } const rotationStyle = { transform: 'none', transformOrigin: '50% 50%' } - return
event.preventDefault()} onWheel={onWheel} />
+ return
event.preventDefault()} />
} diff --git a/src/renderer/src/components/CheckboxField.tsx b/src/renderer/src/components/CheckboxField.tsx new file mode 100644 index 0000000..9e1ed77 --- /dev/null +++ b/src/renderer/src/components/CheckboxField.tsx @@ -0,0 +1,20 @@ +import type { InputHTMLAttributes, ReactNode } from 'react' +import { PixelCheckbox } from './PixelCheckbox' +import { Tooltip } from './Tooltip' + +interface CheckboxFieldProps extends Omit, 'type' | 'checked' | 'onChange'> { + checked: boolean + controlPosition?: 'start' | 'end' + label: ReactNode + onChange: (checked: boolean) => void + tooltip?: ReactNode +} + +export function CheckboxField({ checked, className = '', controlPosition = 'start', label, onChange, tooltip, ...inputProps }: CheckboxFieldProps) { + const copy = {label} + const control = onChange(event.target.checked)} /> + const labelCopy = tooltip ? {copy} : copy + return +} diff --git a/src/renderer/src/components/ColorPicker.tsx b/src/renderer/src/components/ColorPicker.tsx index 0f4ea99..c5f2973 100644 --- a/src/renderer/src/components/ColorPicker.tsx +++ b/src/renderer/src/components/ColorPicker.tsx @@ -147,7 +147,7 @@ export const triangleWeightsFromColor = (color: RgbaColor): TriangleWeights => { return { tip: tip / total, white: white / total, black: black / total } } -export function ColorPicker({ color, secondaryColor, onChange, onSecondaryChange, paletteColors, onAddPaletteColor, compact = false, label, config = { scheme: 'sv-square', hueSteps: 0, colorSteps: 0 } }: { color: RgbaColor; secondaryColor?: RgbaColor; onChange: (color: RgbaColor) => void; onSecondaryChange?: (color: RgbaColor) => void; paletteColors?: readonly RgbaColor[]; onAddPaletteColor?: (color: RgbaColor) => void; compact?: boolean; label?: string; config?: ColorPickerConfig }) { +export function ColorPicker({ color, secondaryColor, onChange, onSecondaryChange, paletteColors, onAddPaletteColor, addToPaletteShortcut, compact = false, label, config = { scheme: 'sv-square', hueSteps: 0, colorSteps: 0 } }: { color: RgbaColor; secondaryColor?: RgbaColor; onChange: (color: RgbaColor) => void; onSecondaryChange?: (color: RgbaColor) => void; paletteColors?: readonly RgbaColor[]; onAddPaletteColor?: (color: RgbaColor) => void; addToPaletteShortcut?: string; compact?: boolean; label?: string; config?: ColorPickerConfig }) { const { t } = useI18n() const effectiveLabel = label ?? t('colorPicker.defaultLabel') const [pickerHsv, setPickerHsv] = useState(() => rgbToHsv(color)) @@ -645,8 +645,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) }} />
- entry.r === color.r && entry.g === color.g && entry.b === color.b && entry.a === color.a)} onAddToPalette={onAddPaletteColor ? () => onAddPaletteColor(color) : undefined} /> - {secondaryColor && onSecondaryChange && entry.r === secondaryColor.r && entry.g === secondaryColor.g && entry.b === secondaryColor.b && entry.a === secondaryColor.a)} onAddToPalette={onAddPaletteColor ? () => onAddPaletteColor(secondaryColor) : undefined} />} + entry.r === color.r && entry.g === color.g && entry.b === color.b && entry.a === color.a)} onAddToPalette={onAddPaletteColor ? () => onAddPaletteColor(color) : undefined} addToPaletteShortcut={addToPaletteShortcut} /> + {secondaryColor && onSecondaryChange && entry.r === secondaryColor.r && entry.g === secondaryColor.g && entry.b === secondaryColor.b && entry.a === secondaryColor.a)} onAddToPalette={onAddPaletteColor ? () => onAddPaletteColor(secondaryColor) : undefined} addToPaletteShortcut={addToPaletteShortcut} />} } diff --git a/src/renderer/src/components/ColorReplacementDialog.test.tsx b/src/renderer/src/components/ColorReplacementDialog.test.tsx index 892554f..523b694 100644 --- a/src/renderer/src/components/ColorReplacementDialog.test.tsx +++ b/src/renderer/src/components/ColorReplacementDialog.test.tsx @@ -1,9 +1,10 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createDocument, getActiveLayer, readLayerColor, writeLayerColor } from '@/core/document' +import { ensureAnimationDocument } from '@/core/animation' +import { createDocument, createLayer, getActiveLayer, readLayerColor, writeLayerColor } from '@/core/document' import { useWorkspace } from '@/store/workspace' import { ColorReplacementDialog } from './ColorReplacementDialog' -import { publishCanvasColorSample } from './color-sampling-events' +import { publishCanvasColorSample, publishCanvasColorSamplingCompleted } from './color-sampling-events' beforeEach(() => { localStorage.clear() @@ -16,6 +17,13 @@ afterEach(() => { }) describe('ColorReplacementDialog', () => { + it('starts both colors at white', () => { + useWorkspace.getState().addSession(createDocument('replace color defaults', 2, 2, 'rgba')) + render() + + expect(screen.getAllByText('#FFFFFF')).toHaveLength(2) + }) + it('samples each color field without overwriting the other field', () => { const document = createDocument('replace color sampling', 2, 2, 'rgba') useWorkspace.getState().addSession(document) @@ -29,6 +37,9 @@ describe('ColorReplacementDialog', () => { act(() => publishCanvasColorSample({ r: 18, g: 171, b: 52, a: 255 }, false)) expect(replacement).toHaveTextContent('#12AB34') expect(source.textContent).toBe(originalSource) + act(() => publishCanvasColorSamplingCompleted()) + expect(screen.getAllByRole('button').filter((button) => button.getAttribute('aria-pressed') === 'true')).toHaveLength(0) + expect(useWorkspace.getState().sessions[0].tool).toBe('pencil') fireEvent.click(screen.getByRole('button', { name: '从画布吸取初始颜色' })) act(() => publishCanvasColorSample({ r: 145, g: 21, b: 34, a: 255 }, false)) @@ -43,19 +54,56 @@ describe('ColorReplacementDialog', () => { const document = createDocument('stable replacement preview', 1, 1, 'rgba') writeLayerColor(document, getActiveLayer(document), 0, sourceColor) useWorkspace.getState().addSession(document) - useWorkspace.getState().setPrimaryColor(sourceColor) - useWorkspace.getState().setSecondaryColor(firstReplacement) const restore = vi.spyOn(useWorkspace.getState(), 'restoreColorReplacementPreview') render() + const eyedroppers = screen.getAllByRole('button').filter((button) => button.hasAttribute('aria-pressed')) + fireEvent.click(eyedroppers[0]) + act(() => publishCanvasColorSample(sourceColor, false)) + act(() => publishCanvasColorSamplingCompleted()) + fireEvent.click(eyedroppers[1]) + act(() => publishCanvasColorSample(firstReplacement, false)) + act(() => publishCanvasColorSamplingCompleted()) await waitFor(() => expect(readLayerColor(document, getActiveLayer(document), 0)).toEqual(firstReplacement)) - fireEvent.click(screen.getByRole('button', { name: '从画布吸取替换颜色' })) + fireEvent.click(eyedroppers[1]) act(() => publishCanvasColorSample(nextReplacement, false)) expect(restore).not.toHaveBeenCalled() await waitFor(() => expect(readLayerColor(document, getActiveLayer(document), 0)).toEqual(nextReplacement)) }) + it('moves the live preview to the newly selected layer', async () => { + const sourceColor = { r: 145, g: 21, b: 34, a: 255 } + const replacementColor = { r: 41, g: 121, b: 255, a: 255 } + const document = createDocument('replacement preview layer selection', 1, 1, 'rgba') + const firstLayer = getActiveLayer(document) + const secondLayer = createLayer('Second', 1, 1, 'rgba') + document.layers.push(secondLayer) + ensureAnimationDocument(document) + writeLayerColor(document, firstLayer, 0, sourceColor) + writeLayerColor(document, secondLayer, 0, sourceColor) + useWorkspace.getState().addSession(document) + render() + + const eyedroppers = screen.getAllByRole('button').filter((button) => button.hasAttribute('aria-pressed')) + fireEvent.click(eyedroppers[0]) + act(() => publishCanvasColorSample(sourceColor, false)) + act(() => publishCanvasColorSamplingCompleted()) + fireEvent.click(eyedroppers[1]) + act(() => publishCanvasColorSample(replacementColor, false)) + act(() => publishCanvasColorSamplingCompleted()) + + await waitFor(() => expect(readLayerColor(document, firstLayer, 0)).toEqual(replacementColor)) + expect(readLayerColor(document, secondLayer, 0)).toEqual(sourceColor) + + act(() => useWorkspace.getState().selectLayer(secondLayer.id)) + + await waitFor(() => { + expect(readLayerColor(document, firstLayer, 0)).toEqual(sourceColor) + expect(readLayerColor(document, secondLayer, 0)).toEqual(replacementColor) + }) + }) + it('shows selection as a target without the preview description text', () => { const document = createDocument('selection target', 2, 2, 'rgba') useWorkspace.getState().addSession(document) diff --git a/src/renderer/src/components/ColorReplacementDialog.tsx b/src/renderer/src/components/ColorReplacementDialog.tsx index 621e3b1..1f75f6b 100644 --- a/src/renderer/src/components/ColorReplacementDialog.tsx +++ b/src/renderer/src/components/ColorReplacementDialog.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react' -import type { RgbaColor } from '@shared/types' +import type { RgbaColor, ToolId } from '@shared/types' import { ColorValueControl } from './ColorValueControl' -import { CANVAS_COLOR_SAMPLED_EVENT, type CanvasColorSampledDetail } from './color-sampling-events' +import { DialogHeader } from './DialogHeader' +import { FormField } from './FormField' +import { CANVAS_COLOR_SAMPLED_EVENT, CANVAS_COLOR_SAMPLING_COMPLETED_EVENT, type CanvasColorSampledDetail } from './color-sampling-events' import { LivePreviewToggle } from './LivePreviewToggle' import { ModalShell } from './ModalShell' import { PixelUtilityIcon } from './PixelUtilityIcon' @@ -15,6 +17,7 @@ type DialogTarget = Exclude type SamplingTarget = 'source' | 'replacement' const copyColor = (color: RgbaColor): RgbaColor => ({ ...color }) +const WHITE: RgbaColor = { r: 255, g: 255, b: 255, a: 255 } const eyedropperLargeIcon = TOOL_DEFINITIONS.find((tool) => tool.id === 'eyedropper')?.icon ?? '' const eyedropperIcon = normalEditorToolIconFor(eyedropperLargeIcon) ?? eyedropperLargeIcon @@ -24,12 +27,13 @@ export function ColorReplacementDialog({ onClose }: { onClose: () => void }) { const activeId = useWorkspace((state) => state.activeId) const session = sessions.find((item) => item.document.id === activeId) ?? null const documentId = useRef(session?.document.id ?? null) - const [sourceColor, setSourceColor] = useState(() => copyColor(session?.primaryColor ?? { r: 0, g: 0, b: 0, a: 255 })) - const [replacementColor, setReplacementColor] = useState(() => copyColor(session?.secondaryColor ?? { r: 255, g: 255, b: 255, a: 255 })) + const [sourceColor, setSourceColor] = useState(() => copyColor(WHITE)) + const [replacementColor, setReplacementColor] = useState(() => copyColor(WHITE)) const [target, setTarget] = useState('layers') const [previewEnabled, setPreviewEnabled] = useState(true) const [samplingTarget, setSamplingTarget] = useState(null) const samplingTargetRef = useRef(null) + const samplingReturnToolRef = useRef(null) const previewRef = useRef(null) const previewFrameRef = useRef(null) const closedRef = useRef(false) @@ -55,6 +59,19 @@ export function ColorReplacementDialog({ onClose }: { onClose: () => void }) { : session?.document.layers.length ?? 0 const targetAvailable = targetAvailability[target] const replacementDisabled = !session || !targetAvailable || colorEquals(sourceColor, replacementColor) + const targetSelectionKey = target === 'layers' + ? session?.selectedLayerIds.join('\u0000') ?? '' + : target === 'frames' + ? session?.selectedAnimationFrameIds.join('\u0000') ?? '' + : target === 'cells' + ? session?.selectedAnimationCellKeys.join('\u0000') ?? '' + : target === 'selection' + ? session?.selection + ? `${session.selection.x}:${session.selection.y}:${session.selection.width}:${session.selection.height}` + : '' + : target === 'palette' + ? session?.document.paletteOrder.join('\u0000') ?? '' + : session?.document.id ?? '' const targetGroups = useMemo(() => [{ label: t('colorReplacement.target'), @@ -88,13 +105,26 @@ export function ColorReplacementDialog({ onClose }: { onClose: () => void }) { setReplacementColor(copyColor(color)) } const beginSampling = (nextTarget: SamplingTarget): void => { + if (!samplingTargetRef.current) { + const workspace = useWorkspace.getState() + samplingReturnToolRef.current = workspace.sessions.find((item) => item.document.id === workspace.activeId)?.tool ?? null + } samplingTargetRef.current = nextTarget setSamplingTarget(nextTarget) useWorkspace.getState().setTool('eyedropper') } + const finishSampling = (): void => { + if (!samplingTargetRef.current) return + samplingTargetRef.current = null + setSamplingTarget(null) + const returnTool = samplingReturnToolRef.current + samplingReturnToolRef.current = null + if (returnTool && returnTool !== 'eyedropper') useWorkspace.getState().setTool(returnTool) + } const cancel = (): void => { if (closedRef.current) return closedRef.current = true + finishSampling() cancelScheduledPreview() restorePreview() onClose() @@ -103,6 +133,7 @@ export function ColorReplacementDialog({ onClose }: { onClose: () => void }) { event.preventDefault() if (closedRef.current || replacementDisabled) return closedRef.current = true + finishSampling() cancelScheduledPreview() restorePreview() useWorkspace.getState().replaceColor(target, sourceColor, replacementColor) @@ -120,8 +151,13 @@ export function ColorReplacementDialog({ onClose }: { onClose: () => void }) { if (samplingTargetRef.current === 'source') changeSourceColor(sample.color) else changeReplacementColor(sample.color) } + const completed = (): void => finishSampling() window.addEventListener(CANVAS_COLOR_SAMPLED_EVENT, sampled) - return () => window.removeEventListener(CANVAS_COLOR_SAMPLED_EVENT, sampled) + window.addEventListener(CANVAS_COLOR_SAMPLING_COMPLETED_EVENT, completed) + return () => { + window.removeEventListener(CANVAS_COLOR_SAMPLED_EVENT, sampled) + window.removeEventListener(CANVAS_COLOR_SAMPLING_COMPLETED_EVENT, completed) + } }, []) useEffect(() => { @@ -145,7 +181,7 @@ export function ColorReplacementDialog({ onClose }: { onClose: () => void }) { if (target !== 'palette') workspace.setPrimaryColor(sourceColor) }) return cancelScheduledPreview - }, [sourceColor, replacementColor, target, previewEnabled, replacementDisabled]) + }, [sourceColor, replacementColor, target, targetSelectionKey, previewEnabled, replacementDisabled]) useEffect(() => () => { cancelScheduledPreview() @@ -156,19 +192,19 @@ export function ColorReplacementDialog({ onClose }: { onClose: () => void }) { return
-
COLOR

{t('colorReplacement.title')}

+

{t('colorReplacement.colors')}

- + - +

{t('colorReplacement.range')}

- + {targetAvailable ? t('colorReplacement.targetCount', { count: targetCount }) : t('colorReplacement.targetUnavailable')}
diff --git a/src/renderer/src/components/ColorValueControl.test.tsx b/src/renderer/src/components/ColorValueControl.test.tsx index d0b4e57..70d5d9f 100644 --- a/src/renderer/src/components/ColorValueControl.test.tsx +++ b/src/renderer/src/components/ColorValueControl.test.tsx @@ -1,14 +1,35 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MoonSpriteApi } from '@shared/types' import { ColorValueControl } from './ColorValueControl' import { COLOR_EDITOR_MODES_PREFERENCE_KEY } from '@/core/file-preferences' +import { createDocument } from '@/core/document' +import { useWorkspace } from '@/store/workspace' +import { publishCanvasColorSample, publishCanvasColorSamplingCompleted } from './color-sampling-events' -beforeEach(() => localStorage.clear()) +beforeEach(() => { + localStorage.clear() + useWorkspace.setState({ sessions: [], activeId: null, message: null, dialog: null }) +}) afterEach(() => cleanup()) describe('ColorValueControl', () => { + it('keeps a disabled color trigger closed', () => { + render() + const trigger = screen.getByRole('button', { name: 'Color' }) + + expect(trigger).toBeDisabled() + fireEvent.click(trigger) + expect(document.querySelector('.color-editor-popover')).not.toBeInTheDocument() + }) + + it('renders a mixed selection without showing a misleading hex value', () => { + render() + const trigger = screen.getByRole('button', { name: 'Color' }) + expect(trigger).toHaveClass('mixed-color-trigger') + expect(trigger).not.toHaveTextContent('#2979FF') + }) it('uses transparent color values and readable text over the filled control checkerboard', () => { const { rerender } = render() const trigger = screen.getByRole('button', { name: 'Foreground' }) @@ -21,16 +42,17 @@ describe('ColorValueControl', () => { expect(trigger.style.getPropertyValue('--color-value-contrast')).toBe('#fff') }) - it('opens the shared editor with RGB as the default mode', () => { + it('opens the shared editor with the five default modes led by HSV', () => { render() fireEvent.click(screen.getByRole('button', { name: '背景颜色 浅色' })) expect(screen.getByRole('dialog', { name: '颜色编辑 浅色' })).toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'RGB', selected: true })).toBeInTheDocument() + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['HSV', 'RGB', 'LAB', 'GRAY', 'PLT']) + expect(screen.getByRole('tab', { name: 'HSV', selected: true })).toBeInTheDocument() expect(screen.getByLabelText('当前颜色')).toBeInTheDocument() expect(screen.getByRole('textbox', { name: '背景颜色 HEX' })).toHaveValue('2979FF') - expect(screen.getByRole('spinbutton', { name: '背景颜色 R' })).toHaveValue('41') + expect(screen.getByRole('spinbutton', { name: '背景颜色 H' })).toBeInTheDocument() expect(screen.getByRole('slider', { name: '背景颜色 A滑块' })).toHaveValue('255') }) @@ -68,6 +90,7 @@ describe('ColorValueControl', () => { it('records the new color preview after a slider edit is confirmed', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Color' })) + fireEvent.click(screen.getByRole('tab', { name: 'RGB' })) const slider = screen.getByRole('slider', { name: /Color R/ }) const currentSwatch = document.querySelector('.color-editor-current-swatch i') as HTMLElement const previousStyle = currentSwatch.style.background @@ -148,6 +171,7 @@ describe('ColorValueControl', () => { }) it('expands a compact editor when switching to CMYK', async () => { + localStorage.setItem(COLOR_EDITOR_MODES_PREFERENCE_KEY, JSON.stringify([{ mode: 'hsv', enabled: true }, { mode: 'cmyk', enabled: true }, { mode: 'rgb', enabled: false }, { mode: 'lab', enabled: false }, { mode: 'gray', enabled: false }, { mode: 'palette', enabled: false }, { mode: 'hsl', enabled: false }])) localStorage.setItem('moonsprite.color-editor-size.cmyk-fit', JSON.stringify({ width: 560, height: 240 })) render() fireEvent.click(screen.getByRole('button', { name: 'Color' })) @@ -226,17 +250,61 @@ describe('ColorValueControl', () => { render() fireEvent.click(screen.getByRole('button', { name: '颜色' })) - expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['LAB', 'RGB', 'HSL', 'Gray', 'CMYK']) + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['LAB', 'RGB', 'GRAY', 'PLT']) expect(screen.getByRole('tab', { name: 'LAB', selected: true })).toBeInTheDocument() }) + it('shows the active project palette in PLT order and applies a selected color', () => { + const document = createDocument('palette editor', 2, 2, 'rgba') + document.palette = [ + { id: 1, name: 'Red', color: { r: 255, g: 0, b: 0, a: 255 } }, + { id: 2, name: 'Transparent blue', color: { r: 0, g: 0, b: 255, a: 0 } } + ] + document.paletteOrder = [2, 1] + useWorkspace.getState().addSession(document) + const onChange = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Color' })) + fireEvent.click(screen.getByRole('tab', { name: 'PLT' })) + + const options = screen.getAllByRole('option') + expect(options.map((option) => option.getAttribute('aria-label'))).toEqual(['Transparent blue #0000FF00', 'Red #FF0000']) + fireEvent.click(screen.getByRole('option', { name: 'Red #FF0000' })) + + expect(onChange).toHaveBeenLastCalledWith({ r: 255, g: 0, b: 0, a: 255 }) + expect(screen.getByRole('option', { name: 'Red #FF0000' })).toHaveAttribute('aria-selected', 'true') + }) + + it('samples a canvas color from the editor and restores the previous tool', () => { + useWorkspace.getState().addSession(createDocument('color editor sampling', 2, 2, 'rgba')) + const onChange = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Color' })) + const eyedropper = screen.getByRole('button', { name: '从画布吸色' }) + + fireEvent.click(eyedropper) + expect(eyedropper).toHaveAttribute('aria-pressed', 'true') + expect(useWorkspace.getState().sessions[0].tool).toBe('eyedropper') + fireEvent.pointerDown(document.body) + expect(screen.getByRole('dialog')).toBeInTheDocument() + + act(() => publishCanvasColorSample({ r: 18, g: 171, b: 52, a: 128 }, false)) + expect(onChange).toHaveBeenLastCalledWith({ r: 18, g: 171, b: 52, a: 128 }) + expect(screen.getByRole('textbox', { name: 'Color HEX' })).toHaveValue('12AB3480') + + act(() => publishCanvasColorSamplingCompleted()) + expect(eyedropper).toHaveAttribute('aria-pressed', 'false') + expect(useWorkspace.getState().sessions[0].tool).toBe('pencil') + }) + it('only shows the add-to-palette action for a missing color', () => { const add = vi.fn() - const { container, rerender } = render() + const { container, rerender } = render() const button = container.querySelector('.color-value-add-button')! const actionRow = container.querySelector('.color-value-action-row') expect(actionRow).toHaveClass('supports-palette-action', 'has-add-action') expect(button).not.toBeNull() + expect(button).toHaveAttribute('title', '手动加入当前颜色;快捷操作:按住 Alt+S 后点击颜色') fireEvent.click(button) expect(add).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/ColorValueControl.tsx b/src/renderer/src/components/ColorValueControl.tsx index 35d4f90..c20abc4 100644 --- a/src/renderer/src/components/ColorValueControl.tsx +++ b/src/renderer/src/components/ColorValueControl.tsx @@ -1,17 +1,21 @@ import { createPortal } from 'react-dom' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { RgbaColor } from '@shared/types' +import type { PaletteEntry, RgbaColor, ToolId } from '@shared/types' import { clampByte } from '@/core/raster' -import { colorFromValues, colorToValues, colorValueFields, displayRgbaHex, parseRgbaHex, type ColorValueMode } from '@/core/color-values' +import { colorFromValues, colorToValues, colorValueFields, colorValueModeLabel, displayRgbaHex, parseRgbaHex, type ColorValueMode } from '@/core/color-values' import { NumberInput } from './NumberInput' import { PanelResizeHandles, type ResizeDirection } from './floating-panel' import { useI18n } from '@/components/I18nProvider' import { loadEditorPreferences } from '@/core/file-preferences' import { paletteMarkerColor } from '@/core/palette-layout' import { PixelUtilityIcon } from './PixelUtilityIcon' +import { CANVAS_COLOR_SAMPLED_EVENT, CANVAS_COLOR_SAMPLING_COMPLETED_EVENT, type CanvasColorSampledDetail } from './color-sampling-events' +import { normalEditorToolIconFor, PixelAssetIcon, TOOL_DEFINITIONS } from './app/editor-tools' +import { useWorkspace } from '@/store/workspace' interface ColorValueControlProps { color: RgbaColor + density?: 'compact' | 'regular' | 'emphasized' onChange: (color: RgbaColor) => void label: string roleLabel?: string @@ -19,19 +23,36 @@ interface ColorValueControlProps { storageKey?: string inPalette?: boolean onAddToPalette?: () => void + addToPaletteShortcut?: string fillWithColor?: boolean dismissOnFocusLoss?: boolean preserveAnimationSelection?: boolean + mixed?: boolean + disabled?: boolean } const modeLabels: Array<{ value: ColorValueMode; label: string }> = [ - { value: 'rgb', label: 'RGB' }, - { value: 'hsv', label: 'HSV' }, - { value: 'hsl', label: 'HSL' }, - { value: 'gray', label: 'Gray' }, - { value: 'lab', label: 'LAB' }, - { value: 'cmyk', label: 'CMYK' } -] + 'hsv', 'rgb', 'lab', 'gray', 'palette', 'hsl', 'cmyk' +].map((value) => ({ value: value as ColorValueMode, label: colorValueModeLabel(value as ColorValueMode) })) + +const eyedropperLargeIcon = TOOL_DEFINITIONS.find((tool) => tool.id === 'eyedropper')?.icon ?? '' +const eyedropperIcon = normalEditorToolIconFor(eyedropperLargeIcon) ?? eyedropperLargeIcon + +const activePaletteEntries = (): PaletteEntry[] => { + const workspace = useWorkspace.getState() + const session = workspace.sessions.find((candidate) => candidate.document.id === workspace.activeId) + if (!session) return [] + const entries = new Map(session.document.palette.map((entry) => [entry.id, entry])) + return session.document.paletteOrder.flatMap((id) => { + const entry = entries.get(id) + return entry ? [{ ...entry, color: { ...entry.color } }] : [] + }) +} + +const samePaletteEntries = (left: readonly PaletteEntry[], right: readonly PaletteEntry[]): boolean => left.length === right.length && left.every((entry, index) => { + const candidate = right[index] + return candidate?.id === entry.id && candidate.name === entry.name && sameColor(entry.color, candidate.color) +}) const COLOR_EDITOR_MIN_WIDTH = 520 const COLOR_EDITOR_MIN_HEIGHT = 240 @@ -69,11 +90,11 @@ const colorGradient = (mode: ColorValueMode, values: Record, fal return `linear-gradient(90deg, ${stops.join(', ')})` } -export function ColorValueControl({ color, onChange, label, roleLabel, className = '', storageKey, inPalette = true, onAddToPalette, fillWithColor = false, dismissOnFocusLoss = false, preserveAnimationSelection = false }: ColorValueControlProps) { +export function ColorValueControl({ color, density = 'regular', onChange, label, roleLabel, className = '', storageKey, inPalette = true, onAddToPalette, addToPaletteShortcut, fillWithColor = false, dismissOnFocusLoss = false, preserveAnimationSelection = false, mixed = false, disabled = false }: ColorValueControlProps) { const { locale, t } = useI18n() const [open, setOpen] = useState(false) const [availableModes, setAvailableModes] = useState(() => loadEditorPreferences().colorEditorModes.filter((item) => item.enabled).map((item) => item.mode)) - const [mode, setMode] = useState(() => loadEditorPreferences().colorEditorModes.find((item) => item.enabled)?.mode ?? 'rgb') + const [mode, setMode] = useState(() => loadEditorPreferences().colorEditorModes.find((item) => item.enabled)?.mode ?? 'hsv') const modeMinHeight = mode === 'cmyk' ? COLOR_EDITOR_CMYK_MIN_HEIGHT : COLOR_EDITOR_MIN_HEIGHT const [hexText, setHexText] = useState(() => displayRgbaHex(color)) const [previousColor, setPreviousColor] = useState(() => copyColor(color)) @@ -85,6 +106,8 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className const [size, setSize] = useState({ width: 560, height: 264 }) const [resident, setResident] = useState(false) const [copiedSwatch, setCopiedSwatch] = useState<'previous' | 'current' | null>(null) + const [sampling, setSampling] = useState(false) + const [paletteEntries, setPaletteEntries] = useState(activePaletteEntries) const triggerRef = useRef(null) const popoverRef = useRef(null) const dragRef = useRef<{ pointerX: number; pointerY: number; left: number; top: number } | null>(null) @@ -97,6 +120,10 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className const sizeKey = storageKey ? `moonsprite.color-editor-size.${storageKey}` : null const positionedRef = useRef(false) const copyFeedbackTimeoutRef = useRef(null) + const samplingRef = useRef(false) + const samplingReturnToolRef = useRef<{ documentId: string; tool: ToolId } | null>(null) + const sampledColorHandlerRef = useRef<(color: RgbaColor) => void>(() => undefined) + const finishSamplingRef = useRef<(updateState?: boolean) => void>(() => undefined) useEffect(() => () => { if (copyFeedbackTimeoutRef.current !== null) window.clearTimeout(copyFeedbackTimeoutRef.current) @@ -120,12 +147,22 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className const syncModes = (): void => { const next = loadEditorPreferences().colorEditorModes.filter((item) => item.enabled).map((item) => item.mode) setAvailableModes(next) - setMode((current) => next.includes(current) ? current : next[0] ?? 'rgb') + setMode((current) => next.includes(current) ? current : next[0] ?? 'hsv') } window.addEventListener('moonsprite:preferences-changed', syncModes) return () => window.removeEventListener('moonsprite:preferences-changed', syncModes) }, []) + useEffect(() => { + if (!open) return + const syncPalette = (): void => { + const next = activePaletteEntries() + setPaletteEntries((current) => samePaletteEntries(current, next) ? current : next) + } + syncPalette() + return useWorkspace.subscribe(syncPalette) + }, [open]) + useLayoutEffect(() => { if (!open) return const place = (): void => { @@ -228,7 +265,7 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className }, [resident]) useEffect(() => { - if (!open || (resident && !dismissOnFocusLoss)) return + if (!open || sampling || (resident && !dismissOnFocusLoss)) return const closeTransient = (event: PointerEvent): void => { const target = event.target as Node if (!popoverRef.current?.contains(target) && !triggerRef.current?.contains(target)) { @@ -253,18 +290,89 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className window.removeEventListener('pointerdown', closeTransient, true) window.removeEventListener('blur', closeOnWindowBlur) } - }, [dismissOnFocusLoss, open, resident]) + }, [dismissOnFocusLoss, open, resident, sampling]) - const applyHex = (value: string): boolean => { - const next = parseRgbaHex(value, workingColor.a) - if (!next) return false - setWorkingColor(copyColor(next)) + const applyEditorColor = (nextColor: RgbaColor): void => { + const next = copyColor(nextColor) + setWorkingColor(next) setConfirmedColor(copyColor(next)) setDraftMode(mode) setDraftValues(colorToValues(next, mode)) emittedColorRef.current = copyColor(next) onChange(next) setHexText(displayRgbaHex(next)) + } + + const finishSampling = (updateState = true): void => { + if (!samplingRef.current) return + samplingRef.current = false + if (updateState) setSampling(false) + const returnTarget = samplingReturnToolRef.current + samplingReturnToolRef.current = null + if (!returnTarget || returnTarget.tool === 'eyedropper') return + const workspace = useWorkspace.getState() + if (workspace.activeId === returnTarget.documentId) workspace.setTool(returnTarget.tool) + } + finishSamplingRef.current = finishSampling + + useEffect(() => { + if (!disabled) return + finishSamplingRef.current() + positionedRef.current = false + setOpen(false) + }, [disabled]) + sampledColorHandlerRef.current = applyEditorColor + + const beginSampling = (): void => { + if (samplingRef.current) { + finishSampling() + return + } + const workspace = useWorkspace.getState() + const session = workspace.sessions.find((candidate) => candidate.document.id === workspace.activeId) + if (!session) return + samplingReturnToolRef.current = { documentId: session.document.id, tool: session.tool } + samplingRef.current = true + residentRef.current = true + setResident(true) + setSampling(true) + workspace.setTool('eyedropper') + const active = useWorkspace.getState().sessions.find((candidate) => candidate.document.id === session.document.id) + if (active?.tool !== 'eyedropper') finishSampling() + } + + useEffect(() => { + const sampled = (event: Event): void => { + const sample = (event as CustomEvent).detail + if (samplingRef.current && sample?.color) sampledColorHandlerRef.current(sample.color) + } + const completed = (): void => finishSamplingRef.current() + window.addEventListener(CANVAS_COLOR_SAMPLED_EVENT, sampled) + window.addEventListener(CANVAS_COLOR_SAMPLING_COMPLETED_EVENT, completed) + return () => { + window.removeEventListener(CANVAS_COLOR_SAMPLED_EVENT, sampled) + window.removeEventListener(CANVAS_COLOR_SAMPLING_COMPLETED_EVENT, completed) + } + }, []) + + useEffect(() => { + if (!sampling) return + return useWorkspace.subscribe((workspace) => { + const returnTarget = samplingReturnToolRef.current + const session = returnTarget ? workspace.sessions.find((candidate) => candidate.document.id === returnTarget.documentId) : null + if (returnTarget && workspace.activeId === returnTarget.documentId && session?.tool === 'eyedropper') return + samplingRef.current = false + samplingReturnToolRef.current = null + setSampling(false) + }) + }, [sampling]) + + useEffect(() => () => finishSamplingRef.current(false), []) + + const applyHex = (value: string): boolean => { + const next = parseRgbaHex(value, workingColor.a) + if (!next) return false + applyEditorColor(next) return true } const commitHex = (): void => { @@ -311,16 +419,19 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className 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() - }}>{t('colorEditor.title')}{roleTitle ? ` ${roleTitle}` : ''} + }}>{t('colorEditor.title')}{roleTitle ? ` ${roleTitle}` : ''}
{availableModes.map((value) => modeLabels.find((option) => option.value === value)).filter((option): option is typeof modeLabels[number] => Boolean(option)).map((option) => )}
{copiedSwatch && {t('colorEditor.copied', { hex: displayRgbaHex(copiedSwatch === 'previous' ? previousColor : confirmedColor) })}}
+
-
- {fields.map((field) => { +
+ {mode === 'palette' ?
+ {paletteEntries.length > 0 ? paletteEntries.map((entry) => ) :

{t('colorEditor.paletteEmpty')}

} +
: fields.map((field) => { const gradient = colorGradient(mode, values, color, field) const background = field.key === 'a' ? `${gradient}, repeating-conic-gradient(var(--theme-checker-dark) 0 25%, var(--theme-checker-light) 0 50%) 50% / 12px 12px` : gradient return @@ -336,11 +447,11 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className
: null return <> - {onAddToPalette && !inPalette && } + {onAddToPalette && !inPalette && } {editor && createPortal(editor, document.body)} } diff --git a/src/renderer/src/components/ComponentLibrary.tsx b/src/renderer/src/components/ComponentLibrary.tsx index 0140d07..4af4349 100644 --- a/src/renderer/src/components/ComponentLibrary.tsx +++ b/src/renderer/src/components/ComponentLibrary.tsx @@ -1,22 +1,33 @@ import { useMemo, useState, type ReactElement } from 'react' import { CheckCircle2, FileText, Layers2, Palette, Search } from 'lucide-react' -import type { RgbaColor } from '@shared/types' +import type { GradientDither, OutlineDirections, OutlineKernel, OutlinePosition, RgbaColor } from '@shared/types' import { ColorPicker, type ColorPickerConfig } from './ColorPicker' import { ColorValueControl } from './ColorValueControl' import { DeleteIconButton } from './DeleteIconButton' +import { DialogHeader } from './DialogHeader' +import { FormField } from './FormField' +import { GradientDitherSelect } from './GradientDitherSelect' import { ModalShell } from './ModalShell' import { NumberInput } from './NumberInput' +import { TextInput } from './TextInput' import { TextAreaInput } from './TextAreaInput' import { ThemedSelect, type ThemedSelectGroup } from './ThemedSelect' import { Tooltip } from './Tooltip' import { useI18n } from './I18nProvider' import { PixelCloseIcon as X, PixelDownIcon as ChevronDown, PixelRightIcon as ChevronRight, PixelUtilityIcon, type PixelUtilityIconKind } from './PixelUtilityIcon' -import { PixelCheckbox } from './PixelCheckbox' +import { CheckboxField } from './CheckboxField' +import { PreferenceToggle } from './PreferenceToggle' +import { RangeField } from './RangeField' +import { SegmentedControl } from './SegmentedControl' +import { SettingsSectionHeader } from './SettingsSectionHeader' +import { SettingsNavigation } from './SettingsNavigation' import { LivePreviewToggle } from './LivePreviewToggle' +import { OutlineStrokeControls } from './OutlineStrokeControls' import { BrushDynamicsSettingsPanel } from './app/EditorToolOptions' -import { FILL_KIND_ICONS, SELECTION_KIND_ICONS, fillKindDefinitions, normalEditorToolIconFor, selectionKindDefinitions, shapeKindDefinitions, toolDefinitions } from './app/editor-tools' +import { FILL_KIND_ICONS, SELECTION_KIND_ICONS, fillKindDefinitions, lineKindDefinitions, normalEditorToolIconFor, selectionKindDefinitions, shapeKindDefinitions, toolDefinitions } from './app/editor-tools' import { CURSOR_ICON_LIBRARY } from '@/platform/cursor-theme' import { translate, type AppLocale, type TranslationKey, type TranslationParams } from '@/core/localization' +import { outlineDirectionsForKernel } from '@/core/outline-settings' import type { BrushDynamicsEffect, BrushDynamicsMapping, BrushDynamicsSettings } from '@/core/pressure' type ComponentCategory = 'all' | 'controls' | 'forms' | 'panels' | 'dialogs' | 'editor' @@ -50,12 +61,17 @@ const pixelIconNames: Partial> = { newFolder: '新建文件夹', ungroupFolder: '解组文件夹', plus: '加', minus: '减', close: '叉', up: '上', down: '下', left: '左', right: '右', onion: '洋葱皮', more: '更多', moreLines: '更多(横线)', paletteLocal: '选择本地色板', paletteCenter: '居中', restore: '恢复', undo: '撤销', redo: '重做', workspace: '工作区', copy: '复制', - mergeDown: '向下合并', mergeVisible: '合并可见图层', clippingMask: '剪贴蒙版', layerMask: '图层蒙版', folder: '文件夹', folderOpen: '展开文件夹', move: '移动', + mergeDown: '向下合并', mergeVisible: '合并可见图层', clippingMask: '剪贴蒙版', layerMask: '图层蒙版', layerStyle: '图层样式', folder: '文件夹', folderOpen: '展开文件夹', move: '移动', save: '保存', export: '导出', image: '图像', roadmapPlanned: '未完成', roadmapCompleted: '完成', info: '信息', canvasCenter: '居中', canvasTop: '上', canvasBottom: '下', canvasLeft: '左', canvasRight: '右', canvasTopLeft: '左上', canvasTopRight: '右上', canvasBottomLeft: '左下', canvasBottomRight: '右下', checkboxUnchecked: '复选框未选中', checkboxChecked: '复选框选中', pin: '置顶', clearRecords: '清除记录', - refresh: '刷新', extractColors: '提取颜色', follow: '跟随', check: '选择' + refresh: '刷新', extractColors: '提取颜色', follow: '跟随', check: '选择', + selectionFlipHorizontal: '水平镜像选中', selectionFlipVertical: '垂直镜像选中', + canvasMirrorHorizontal: '水平镜像画布', canvasMirrorVertical: '垂直镜像画布', + invertSelection: '反选', selectAll: '全选', deselect: '取消选择', selectionOutline: '选区边框', + resetView: '重置视图', deleteSelection: '删除选区内容', rotateClockwise90: '顺时针旋转 90°', + rotateCounterClockwise90: '逆时针旋转 90°', grid: '网格' } const pixelIconNameOverrides: Partial> = { link: '连接', paste: '粘贴' } @@ -78,11 +94,12 @@ const cursorLibraryItems = (locale: AppLocale) => CURSOR_ICON_LIBRARY.flatMap((i }) const toolLibraryItems = (locale: AppLocale) => { - const primaryTools = toolDefinitions(locale).filter((item) => item.id !== 'selection' && item.id !== 'shape' && item.id !== 'fill') + const primaryTools = toolDefinitions(locale).filter((item) => item.id !== 'selection' && item.id !== 'shape' && item.id !== 'line' && item.id !== 'fill') const items = [ ...primaryTools.map((item) => ({ id: `tool.${item.id}`, name: item.label, largeSource: item.icon })), ...selectionKindDefinitions(locale).map((item) => ({ id: `tool.selection.${item.id}`, name: item.label, largeSource: SELECTION_KIND_ICONS[item.id] })), ...shapeKindDefinitions(locale).map((item) => ({ id: `tool.shape.${item.id}`, name: item.label, largeSource: item.icon })), + ...lineKindDefinitions(locale).map((item) => ({ id: `tool.line.${item.id}`, name: item.label, largeSource: item.icon })), ...fillKindDefinitions(locale).map((item) => ({ id: `tool.fill.${item.id}`, name: item.label, largeSource: FILL_KIND_ICONS[item.id] })) ] return items.flatMap((item) => { @@ -106,24 +123,31 @@ export const COMPONENT_LIBRARY_ENTRIES: ComponentLibraryEntry[] = [ { id: 'pointer-icons', name: '指针图标', category: 'editor', description: '画布、选区、缩放、旋转和调整操作使用的像素指针。', source: 'platform/cursor-theme.ts', tags: ['图标', '工具'] }, { id: 'delete-icon-button', name: '删除图标按钮', category: 'controls', description: '用于删除预设或列表项目的统一危险图标按钮,提供紧凑、常规和禁用状态。', source: 'DeleteIconButton', tags: ['删除', '危险', '图标'] }, { id: 'context-menu', name: '上下文菜单', category: 'controls', description: '通过右键或更多操作打开的紧凑菜单,支持图标、禁用和危险操作状态。', source: '.context-menu / .context-menu-item', tags: ['右键', '菜单', '操作'] }, - { id: 'segmented', name: '分段选择', category: 'controls', description: '用于工具模式、视图模式和互斥选项。', source: '.segmented-control', tags: ['模式', '选中'] }, + { id: 'segmented', name: '分段选择', category: 'controls', description: '用于工具模式、视图模式和互斥选项。', source: 'SegmentedControl', tags: ['模式', '选中'] }, { id: 'number-input', name: '数值输入', category: 'forms', description: '统一的数字输入、步进按钮和边界限制。', source: 'NumberInput', tags: ['数字', '步进'] }, + { id: 'text-input', name: '文本输入', category: 'forms', description: '用于名称、搜索和短文本内容的统一单行输入。', source: 'TextInput', tags: ['文本', '输入'] }, { id: 'text-area-input', name: '多行文本输入', category: 'forms', description: '用于描述和备注的固定尺寸多行输入,不允许用户拖动改变大小。', source: 'TextAreaInput', tags: ['文本', '描述', '输入'] }, { id: 'themed-select', name: '主题下拉', category: 'forms', description: '带分组、选中标记和键盘导航的下拉菜单。', source: 'ThemedSelect', tags: ['下拉', '分组'] }, - { id: 'range', name: '滑块', category: 'forms', description: '适用于尺寸、不透明度、强度等连续数值。', source: '.brush-size-popover input[type=range]', tags: ['数值', '实时'] }, - { id: 'checkbox', name: '复选框', category: 'forms', description: '用于可以同时启用的独立选项。', source: 'PixelCheckbox', tags: ['设置', '复选'] }, - { id: 'switch', name: '开关', category: 'forms', description: '用于明确的开启与关闭状态。', source: '.preference-toggle / .outline-preview-toggle', tags: ['设置', '开关'] }, + { id: 'gradient-dither-select', name: '渐变抖动选择', category: 'forms', description: '渐变工具和图层样式共用的平滑、Bayer 与方向抖动选择器。', source: 'GradientDitherSelect', tags: ['下拉', '模式', '颜色'] }, + { id: 'form-field', name: '表单字段', category: 'forms', description: '统一标签、控件、说明和悬浮描述的字段布局。', source: 'FormField', tags: ['设置', '输入', '描述'] }, + { id: 'settings-navigation', name: '设置导航', category: 'forms', description: '首选项、快捷键等设置窗口共用的分区导航。', source: 'SettingsNavigation', tags: ['设置', '布局', '选中'] }, + { id: 'settings-section-header', name: '设置分组标题', category: 'forms', description: '统一设置分组标题和右侧操作按钮的对齐与间距。', source: 'SettingsSectionHeader', tags: ['设置', '操作', '布局'] }, + { id: 'range', name: '滑块', category: 'forms', description: '适用于尺寸、不透明度、强度等连续数值。', source: 'RangeField', tags: ['数值', '实时'] }, + { id: 'outline-stroke-controls', name: '描边设置', category: 'forms', description: '描边宽度、位置、快捷形状与八方向像素开关的统一设置组件。', source: 'OutlineStrokeControls', tags: ['设置', '数值', '模式'] }, + { id: 'checkbox', name: '复选框', category: 'forms', description: '用于可以同时启用的独立选项。', source: 'CheckboxField / PixelCheckbox', tags: ['设置', '复选'] }, + { id: 'switch', name: '开关', category: 'forms', description: '用于明确的开启与关闭状态。', source: 'PreferenceToggle', tags: ['设置', '开关'] }, { id: 'live-preview-toggle', name: '实时预览开关', category: 'forms', description: '调整类弹窗用于开启或关闭实时预览的统一开关。', source: 'LivePreviewToggle', tags: ['实时', '开关', '弹窗'] }, { id: 'scrollbar', name: '滚动区域', category: 'forms', description: '下拉菜单和长列表使用的统一滚动条。', source: '.component-scrollbar', tags: ['滚动', '列表'] }, - { id: 'panel-header', name: '栏目标题', category: 'panels', description: '停靠栏目标题、拖动入口和右侧操作。', source: '.panel-header', tags: ['栏目', '停靠'] }, + { id: 'panel-header', name: '栏目标题', category: 'panels', description: '停靠栏目标题、拖动入口和右侧操作。', source: '.panel > header', tags: ['栏目', '停靠'] }, { id: 'layer-row', name: '图层行', category: 'panels', description: '可见性、锁定、组图标、名称、混合模式和拖动状态。', source: 'LayersPanel', tags: ['图层', '拖动'] }, { id: 'swatches', name: '颜色格', category: 'panels', description: '调色板中的居中描边、选中外框和多选状态。', source: '.swatch-grid / .swatch', tags: ['颜色', '多选'] }, { id: 'modal-shell', name: '弹窗框架', category: 'dialogs', description: '统一标题、内容、底部操作、拖动、八向缩放和尺寸位置记忆。', source: 'ModalShell / .modal-backdrop', tags: ['弹窗', '布局', '缩放'] }, + { id: 'dialog-header', name: '弹窗标题栏', category: 'dialogs', description: '统一弹窗眉题、标题、关闭操作和可选标题栏动作。', source: 'DialogHeader', tags: ['弹窗', '布局', '操作'] }, { id: 'save-progress', name: '文件操作进度', category: 'dialogs', description: '用于导出和保存过程的进度、完成确认与顶层反馈。', source: '.save-progress-modal', tags: ['弹窗', '状态', '反馈'] }, { id: 'status', name: '状态提示', category: 'dialogs', description: '用于操作反馈、模式提示和不可用状态。', source: '.statusbar / .advanced-mode-notice', tags: ['提示', '反馈'] }, { id: 'tooltip', name: '悬浮提示', category: 'dialogs', description: '用于描述等较长内容的自定义悬浮提示,自动避开视口边缘。', source: 'Tooltip', tags: ['提示', '悬浮', '描述'] }, { id: 'tool-options', name: '工具属性栏', category: 'editor', description: '工具名称、参数、模式和撤销重做操作。', source: '.tool-options', tags: ['工具', '属性'] }, - { id: 'pressure-options', name: '笔刷动态工具属性', category: 'editor', description: '铅笔与橡皮的压力/速度映射、渐变效果、实时传感器条和禁用状态。', source: 'BrushDynamicsSettingsPanel / .pressure-popover', tags: ['工具', '设置', '传感器'] }, + { id: 'pressure-options', name: '压感工具属性', category: 'editor', description: '铅笔与橡皮的压力/速度映射、渐变效果、实时传感器条和禁用状态。', source: 'BrushDynamicsSettingsPanel / .pressure-popover', tags: ['工具', '设置', '传感器'] }, { id: 'color-picker', name: '颜色选择器', category: 'editor', description: '色盘、色相、透明度、前景色和背景色。', source: 'ColorPicker', tags: ['颜色', '实时'] }, { id: 'color-value', name: '颜色值按钮', category: 'editor', description: '颜色预览、HEX 文本和 RGB/HSV/HSL/Gray 多模式编辑。', source: 'ColorValueControl', tags: ['颜色', 'HEX', '通道'] }, { id: 'panel-dock', name: '停靠栏目', category: 'editor', description: '右侧、左侧和底部停靠区的容器行为。', source: 'InspectorPanels', tags: ['布局', '停靠'] } @@ -151,7 +175,7 @@ function ButtonsPreview({ locale }: { locale: AppLocale }) { } function IconButtonPreview({ locale }: { locale: AppLocale }) { - return
+ return
} function DeleteIconButtonPreview({ locale }: { locale: AppLocale }) { @@ -173,7 +197,7 @@ function ContextMenuPreview({ locale }: { locale: AppLocale }) { function SegmentedPreview({ locale }: { locale: AppLocale }) { const items = [componentText(locale, 'componentLibrary.preview.brush'), componentText(locale, 'componentLibrary.preview.eraser'), componentText(locale, 'componentLibrary.preview.move')] const [selected, setSelected] = useState(items[0]) - return
{items.map((item) => )}
+ return ({ label: item, value: item, disabled: index === 2 }))} value={selected} onChange={setSelected} /> } function NumberInputPreview({ locale }: { locale: AppLocale }) { @@ -182,37 +206,66 @@ function NumberInputPreview({ locale }: { locale: AppLocale }) { const [sliderValue, setSliderValue] = useState(24) const [sliderOpen, setSliderOpen] = useState(false) return
- + +
- - + undefined} /> +
setSliderOpen(true)}> setSliderOpen(true)} />{sliderOpen &&
setSliderValue(Number(event.target.value))} />{sliderValue}px
}
+ undefined} />
} function TextAreaInputPreview({ locale }: { locale: AppLocale }) { const [value, setValue] = useState(() => componentText(locale, 'componentLibrary.preview.descriptionValue')) - return
setValue(event.target.value)} />
+ return
setValue(event.target.value)} />
+} + +function TextInputPreview({ locale }: { locale: AppLocale }) { + const [value, setValue] = useState('MoonSprite') + return
setValue(event.target.value)} /> setValue(event.target.value)} />
} function SelectPreview({ locale }: { locale: AppLocale }) { const [value, setValue] = useState('normal') const groups: Array> = selectGroups.map((group) => ({ label: componentText(locale, group.labelKey), options: group.options.map((option) => ({ value: option.value, label: componentText(locale, option.labelKey) })) })) - return
+ return
+} + +function FormFieldPreview({ locale }: { locale: AppLocale }) { + const [value, setValue] = useState(16) + return
+} + +function SettingsSectionHeaderPreview({ locale }: { locale: AppLocale }) { + return } /> } function RangePreview({ locale }: { locale: AppLocale }) { const [value, setValue] = useState(64) - return
setValue(Number(event.target.value))} />
+ return
+} + +function OutlineStrokeControlsPreview() { + const [thickness, setThickness] = useState(3) + const [position, setPosition] = useState('outside') + const [kernel, setKernel] = useState('round') + const [directions, setDirections] = useState(() => outlineDirectionsForKernel('round')) + return
{ setKernel(nextKernel); setDirections(nextDirections) }} />
+} + +function GradientDitherSelectPreview() { + const [value, setValue] = useState('bayer-4') + return
} function CheckboxPreview({ locale }: { locale: AppLocale }) { const [checked, setChecked] = useState(true) - return + return
undefined} />
} function SwitchPreview({ locale }: { locale: AppLocale }) { const [checked, setChecked] = useState(true) - return + return
undefined} />
} function LivePreviewTogglePreview() { @@ -225,13 +278,13 @@ function ScrollbarPreview({ locale }: { locale: AppLocale }) { } function PanelHeaderPreview({ locale }: { locale: AppLocale }) { - return
PANEL{componentText(locale, 'componentLibrary.preview.panel')}
{componentText(locale, 'componentLibrary.preview.draggablePanel')}
+ return
{componentText(locale, 'componentLibrary.preview.panel')}
{componentText(locale, 'componentLibrary.preview.draggablePanel')}
} function PixelUtilityIconPreview() { const [locked, setLocked] = useState(true) const [visible, setVisible] = useState(true) - const kinds = ['properties', 'delete', 'newFolder', 'ungroupFolder', 'plus', 'minus', 'close', 'up', 'down', 'left', 'right', 'onion', 'more', 'moreLines', 'paletteLocal', 'paletteCenter', 'restore', 'undo', 'redo', 'workspace', 'copy', 'link', 'paste', 'mergeDown', 'mergeVisible', 'clippingMask', 'layerMask', 'folder', 'folderOpen', 'move', 'save', 'export', 'image', 'roadmapPlanned', 'roadmapCompleted', 'info', 'canvasCenter', 'canvasTop', 'canvasBottom', 'canvasLeft', 'canvasRight', 'canvasTopLeft', 'canvasTopRight', 'canvasBottomLeft', 'canvasBottomRight', 'checkboxUnchecked', 'checkboxChecked', 'pin', 'clearRecords', 'refresh', 'extractColors', 'follow', 'check'] as const + const kinds = ['properties', 'delete', 'newFolder', 'ungroupFolder', 'plus', 'minus', 'close', 'up', 'down', 'left', 'right', 'onion', 'more', 'moreLines', 'paletteLocal', 'paletteCenter', 'restore', 'undo', 'redo', 'workspace', 'copy', 'link', 'paste', 'mergeDown', 'mergeVisible', 'clippingMask', 'layerMask', 'layerStyle', 'folder', 'folderOpen', 'move', 'save', 'export', 'image', 'roadmapPlanned', 'roadmapCompleted', 'info', 'canvasCenter', 'canvasTop', 'canvasBottom', 'canvasLeft', 'canvasRight', 'canvasTopLeft', 'canvasTopRight', 'canvasBottomLeft', 'canvasBottomRight', 'checkboxUnchecked', 'checkboxChecked', 'pin', 'clearRecords', 'refresh', 'extractColors', 'follow', 'check', 'selectionFlipHorizontal', 'selectionFlipVertical', 'canvasMirrorHorizontal', 'canvasMirrorVertical', 'invertSelection', 'selectAll', 'deselect', 'selectionOutline', 'resetView', 'deleteSelection', 'rotateClockwise90', 'rotateCounterClockwise90', 'grid'] as const return
{kinds.map((kind) => )}
} @@ -271,7 +324,12 @@ function SwatchesPreview({ locale }: { locale: AppLocale }) { } function ModalShellPreview({ locale }: { locale: AppLocale }) { - return
PREVIEW{componentText(locale, 'componentLibrary.preview.title')}
{componentText(locale, 'componentLibrary.preview.modalContent')}
+ return
undefined} />
{componentText(locale, 'componentLibrary.preview.modalContent')}
+} + +function SettingsNavigationPreview({ locale }: { locale: AppLocale }) { + const [value, setValue] = useState<'general' | 'theme' | 'files'>('general') + return
} function SaveProgressPreview({ locale }: { locale: AppLocale }) { @@ -293,7 +351,7 @@ function TooltipPreview({ locale }: { locale: AppLocale }) { } function ToolOptionsPreview({ locale }: { locale: AppLocale }) { - return
{componentText(locale, 'componentLibrary.preview.brush')}
+ return
{componentText(locale, 'componentLibrary.preview.brush')} undefined} />
} function PressureOptionsPreview({ locale }: { locale: AppLocale }) { @@ -328,19 +386,12 @@ function PressureOptionsPreview({ locale }: { locale: AppLocale }) { } return
-
- - - -
-
- - -
+ +
{componentText(locale, tool === 'pencil' ? 'componentLibrary.preview.brush' : 'componentLibrary.preview.eraser')} - +
{panelOpen &&
setSettings((current) => ({ ...current, gradientDither }))} />
} @@ -356,7 +407,7 @@ function ColorPickerPreview({ locale }: { locale: AppLocale }) { function ColorValuePreview({ locale }: { locale: AppLocale }) { const [color, setColor] = useState(initialColor) const [transparentColor, setTransparentColor] = useState({ r: 155, g: 155, b: 159, a: 0 }) - return
+ return
} function DockPreview({ locale }: { locale: AppLocale }) { @@ -373,9 +424,15 @@ const previewRenderers: Record ReactEl 'context-menu': ContextMenuPreview, segmented: SegmentedPreview, 'number-input': NumberInputPreview, + 'text-input': TextInputPreview, 'text-area-input': TextAreaInputPreview, 'themed-select': SelectPreview, + 'gradient-dither-select': GradientDitherSelectPreview, + 'form-field': FormFieldPreview, + 'settings-navigation': SettingsNavigationPreview, + 'settings-section-header': SettingsSectionHeaderPreview, range: RangePreview, + 'outline-stroke-controls': OutlineStrokeControlsPreview, checkbox: CheckboxPreview, switch: SwitchPreview, 'live-preview-toggle': LivePreviewTogglePreview, @@ -384,6 +441,7 @@ const previewRenderers: Record ReactEl 'layer-row': LayerRowPreview, swatches: SwatchesPreview, 'modal-shell': ModalShellPreview, + 'dialog-header': ModalShellPreview, 'save-progress': SaveProgressPreview, status: StatusPreview, tooltip: TooltipPreview, @@ -411,12 +469,12 @@ export function ComponentLibrary({ onClose }: { onClose: () => void }) { return
{ if (event.target === event.currentTarget) onClose() }}> -
MOONSPRITE UI

{componentText(locale, 'componentLibrary.title')}

{componentText(locale, 'componentLibrary.subtitle')}

+
{componentText(locale, 'componentLibrary.eyebrow')}

{selectedEntry.name}

{selectedEntry.id}
diff --git a/src/renderer/src/components/DialogHeader.tsx b/src/renderer/src/components/DialogHeader.tsx new file mode 100644 index 0000000..1eb1d2b --- /dev/null +++ b/src/renderer/src/components/DialogHeader.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' +import { PixelUtilityIcon } from './PixelUtilityIcon' + +interface DialogHeaderProps { + actions?: ReactNode + className?: string + closeDisabled?: boolean + closeLabel?: string + description?: ReactNode + eyebrow?: ReactNode + onClose?: () => void + title: ReactNode + titleId?: string +} + +export function DialogHeader({ actions, className = '', closeDisabled = false, closeLabel, description, eyebrow, onClose, title, titleId }: DialogHeaderProps) { + return
+
{eyebrow && {eyebrow}}

{title}

{description &&

{description}

}
+ {actions ?? (onClose && )} +
+} diff --git a/src/renderer/src/components/FormField.tsx b/src/renderer/src/components/FormField.tsx new file mode 100644 index 0000000..f46ad05 --- /dev/null +++ b/src/renderer/src/components/FormField.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' +import { Tooltip } from './Tooltip' + +interface FormFieldProps { + children: ReactNode + className?: string + hint?: ReactNode + label: ReactNode + layout?: 'stacked' | 'inline' + tooltip?: ReactNode +} + +export function FormField({ children, className = '', hint, label, layout = 'stacked', tooltip }: FormFieldProps) { + const copy = {label} + return
+ {tooltip ? {copy} : copy} +
{children}
+ {hint && {hint}} +
+} diff --git a/src/renderer/src/components/FutureRoadmapDialog.test.tsx b/src/renderer/src/components/FutureRoadmapDialog.test.tsx index 23dedc7..cb745de 100644 --- a/src/renderer/src/components/FutureRoadmapDialog.test.tsx +++ b/src/renderer/src/components/FutureRoadmapDialog.test.tsx @@ -9,8 +9,8 @@ describe('FutureRoadmapDialog', () => { render() const items = screen.getAllByRole('listitem') expect(items).toHaveLength(21) - expect(items.slice(0, 11).every((item) => item.classList.contains('completed'))).toBe(true) - expect(items.slice(11).every((item) => !item.classList.contains('completed'))).toBe(true) + expect(items.slice(0, 16).every((item) => item.classList.contains('completed'))).toBe(true) + expect(items.slice(16).every((item) => !item.classList.contains('completed'))).toBe(true) }) it('closes from the unified icon button', () => { diff --git a/src/renderer/src/components/FutureRoadmapDialog.tsx b/src/renderer/src/components/FutureRoadmapDialog.tsx index 2053c2c..153f965 100644 --- a/src/renderer/src/components/FutureRoadmapDialog.tsx +++ b/src/renderer/src/components/FutureRoadmapDialog.tsx @@ -1,4 +1,5 @@ import { useI18n } from '@/components/I18nProvider' +import { DialogHeader } from '@/components/DialogHeader' import { ModalShell } from '@/components/ModalShell' import { PixelUtilityIcon } from '@/components/PixelUtilityIcon' import type { TranslationKey } from '@/core/localization' @@ -10,15 +11,15 @@ const roadmapItems: Array<{ id: string; label: TranslationKey; category: Transla { id: 'grid', label: 'roadmap.item.grid', category: 'roadmap.category.canvas', completed: true }, { id: 'clipping-mask', label: 'roadmap.item.clippingMask', category: 'roadmap.category.masksLayers', completed: true }, { id: 'mask', label: 'roadmap.item.mask', category: 'roadmap.category.masksLayers', completed: true }, - { id: 'layer-style', label: 'roadmap.item.layerStyle', category: 'roadmap.category.masksLayers', completed: false }, - { id: 'dither-brush', label: 'roadmap.item.ditherBrush', category: 'roadmap.category.tools', completed: false }, - { id: 'text', label: 'roadmap.item.text', category: 'roadmap.category.tools', completed: false }, - { id: 'pencil-smoothing', label: 'roadmap.item.pencilSmoothing', category: 'roadmap.category.tools', completed: false }, + { id: 'layer-style', label: 'roadmap.item.layerStyle', category: 'roadmap.category.masksLayers', completed: true }, + { id: 'linked-layers', label: 'roadmap.item.linkedLayers', category: 'roadmap.category.masksLayers', completed: false }, + { id: 'dither-brush', label: 'roadmap.item.ditherBrush', category: 'roadmap.category.tools', completed: true }, + { id: 'text', label: 'roadmap.item.text', category: 'roadmap.category.tools', completed: true }, { id: 'pressure', label: 'roadmap.item.pressure', category: 'roadmap.category.input', completed: true }, - { id: 'slice', label: 'roadmap.item.slice', category: 'roadmap.category.tools', completed: false }, + { id: 'slice', label: 'roadmap.item.slice', category: 'roadmap.category.tools', completed: true }, { id: 'tile', label: 'roadmap.item.tile', category: 'roadmap.category.canvas', completed: false }, { id: 'gif-bead-export', label: 'roadmap.item.beadExport', category: 'roadmap.category.export', completed: false }, - { id: 'theme', label: 'roadmap.item.theme', category: 'roadmap.category.interface', completed: false }, + { id: 'theme', label: 'roadmap.item.theme', category: 'roadmap.category.interface', completed: true }, { id: 'extension-support', label: 'roadmap.item.extensionSupport', category: 'roadmap.category.ecosystem', completed: false }, { id: 'indexed-mode', label: 'roadmap.item.indexedMode', category: 'roadmap.category.export', completed: true }, { id: 'timelapse-animation', label: 'roadmap.item.timelapseAnimation', category: 'roadmap.category.animation', completed: true }, @@ -31,7 +32,7 @@ export function FutureRoadmapDialog({ onClose }: { onClose: () => void }) { const { t } = useI18n() return
{ if (event.target === event.currentTarget) onClose() }}> -
MOONSPRITE ROADMAP

{t('roadmap.title')}

+

{t('roadmap.description')}

    {[...roadmapItems].sort((a, b) => Number(b.completed) - Number(a.completed)).map((item) =>
  • {t(item.category)}{t(item.label)}{t(item.completed ? 'roadmap.completed' : 'roadmap.planned')}
  • )}
diff --git a/src/renderer/src/components/GradientDitherSelect.tsx b/src/renderer/src/components/GradientDitherSelect.tsx new file mode 100644 index 0000000..774102e --- /dev/null +++ b/src/renderer/src/components/GradientDitherSelect.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from 'react' +import type { GradientDither, RgbaColor } from '@shared/types' +import { loadEditorPreferences, type CheckerboardPreferences } from '@/core/file-preferences' +import { gradientColorAt } from '@/core/gradient-color' +import type { TranslationKey } from '@/core/localization' +import { ThemedSelect } from './ThemedSelect' +import { useI18n } from './I18nProvider' + +function GradientPresetPreview({ preset }: { preset: GradientDither }) { + const canvasRef = useRef(null) + const [checkerboard, setCheckerboard] = useState(() => loadEditorPreferences().checkerboard) + useEffect(() => { + const syncPreferences = (): void => setCheckerboard(loadEditorPreferences().checkerboard) + window.addEventListener('moonsprite:preferences-changed', syncPreferences) + return () => window.removeEventListener('moonsprite:preferences-changed', syncPreferences) + }, []) + useEffect(() => { + const canvas = canvasRef.current + const context = canvas?.getContext('2d') + if (!canvas || !context) return + const width = canvas.width + const height = canvas.height + const startColor: RgbaColor = { r: 0, g: 0, b: 0, a: 255 } + const endColor: RgbaColor = { r: 255, g: 255, b: 255, a: 255 } + context.clearRect(0, 0, width, height) + context.fillStyle = `rgb(${checkerboard.darkColor.r} ${checkerboard.darkColor.g} ${checkerboard.darkColor.b})` + context.fillRect(0, 0, width, height) + context.fillStyle = `rgb(${checkerboard.lightColor.r} ${checkerboard.lightColor.g} ${checkerboard.lightColor.b})` + for (let y = 0; y < height; y += 2) for (let x = 0; x < width; x += 2) if (((x / 2) + (y / 2)) % 2 === 1) context.fillRect(x, y, 2, 2) + const image = context.createImageData(width, height) + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { + const color = gradientColorAt(startColor, endColor, x, y, { x: 0, y: 0 }, { x: width - 1, y: 0 }, preset) + const offset = (y * width + x) * 4 + image.data[offset] = color.r + image.data[offset + 1] = color.g + image.data[offset + 2] = color.b + image.data[offset + 3] = color.a + } + context.putImageData(image, 0, 0) + }, [checkerboard, preset]) + return