From 516a2dc50f3aeaae2a7a811fac58750250965f75 Mon Sep 17 00:00:00 2001 From: YueHaxgu <167526721+YueHaxgu@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:12:55 +0800 Subject: [PATCH 1/7] feat: improve layer timeline and mask interactions --- docs/product/behavior.md | 1 + src/renderer/src/App.tsx | 10 ++-- src/renderer/src/components/CanvasStage.tsx | 6 +- .../src/components/WorkspacePanels.tsx | 12 ++-- .../components/panels/LayersPanel.test.tsx | 52 ++++++++++++++++- .../src/components/panels/LayersPanel.tsx | 57 +++++++++++++------ .../src/components/panels/PreviewPanel.tsx | 13 ++++- .../src/core/file-preferences.test.ts | 6 +- src/renderer/src/core/file-preferences.ts | 5 ++ src/renderer/src/locales/en-US.ts | 4 +- src/renderer/src/locales/zh-CN.ts | 4 +- src/renderer/src/styles.css | 18 +++++- 12 files changed, 149 insertions(+), 39 deletions(-) diff --git a/docs/product/behavior.md b/docs/product/behavior.md index b2b5a82..61f6fe8 100644 --- a/docs/product/behavior.md +++ b/docs/product/behavior.md @@ -28,6 +28,7 @@ - 动画采用 Aseprite 式图层/cel 模型:图层栏中的每个普通图层是一条跨帧轨道,图层右侧的每个帧格是该图层在对应帧的 cel;图层名称、可见性、锁定、透明度、混合模式、分组和顺序跨帧共享,像素、位图尺寸与偏移属于 cel。 - 播放控制、帧增删复制、帧时长、循环设置和帧列全部集成在图层栏目内,不提供重复的独立时间轴栏目。第一帧紧邻图层名称区域左侧,名称区与帧列之间的分隔线允许拖动并持久化宽度;帧从左到右排列并与图层行严格对齐,选中帧以覆盖帧头和全部 cel 行的整列蓝色外描边反馈,边框四边必须连续。当前图层与当前帧交叉格只保留深色活动背景,不显示额外内部蓝框;帧数超出栏目宽度时在同一区域横向滚动。 +- 图层设置提供“隐藏时间轴”。开启后保留工程中的全部帧与 cel 数据,但图层栏目只显示普通图层列表,时间轴网格和图层栏动画按钮不再显示;帧切换、播放、洋葱皮、帧增删及对应快捷键和预览播放同时停用并显示禁用状态。关闭后恢复原时间轴及其数据。 - 图层栏目中的“新增帧”默认复制当前帧并创建互不影响的独立 cel;右键帧头可打开帧属性、复制当前帧、新建默认 `100 ms` 的空白帧或删除帧。每一帧独立保存毫秒时长并直接显示在帧头,不在栏目工具栏提供全局时长输入。动画至少保留一帧。 - 帧头和 cel 使用一致的外描边多选反馈,并均使用与图层相同的指针拖拽语义完成排序或跨位置移动:移动超过阈值后全部源对象半透明,帧显示插入位置,cel 显示目标格,松手后只产生一次撤销。双击帧头打开该帧时长属性,双击有内容 cel 打开单元格不透明度属性;无内容 cel 的属性、复制和删除命令禁用,但已有 cel 剪贴板时仍可粘贴。单元格不透明度复用图层属性的滑块、数值输入和百分比布局,属性弹窗使用 `Enter` 提交并关闭、`Esc` 放弃并关闭。播放期间隐藏帧与 cel 的选择描边,但保留选择状态。 - 复制帧时复制所选帧的时长及全部图层 cel,粘贴到最末选中帧之后;副本像素必须独立。复制 cel 只处理所选单元格。帧与 cel 的右键菜单必须在复制、粘贴和删除命令中明确写出操作对象。 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 07add02..3dde5d6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -997,7 +997,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')) { @@ -1103,9 +1103,9 @@ export default function App() { if (runCommand('createLayerGroup', () => workspace.createLayerGroup())) return if (runCommand('toggleClippingMask', () => workspace.toggleActiveClippingMask())) 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('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 @@ -1176,7 +1176,7 @@ export default function App() { 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]) + }, [adjustmentOpen, advancedMode, aboutOpen, blockedShortcuts, canvasResizeOpen, colorReplacementOpen, componentLibraryOpen, cycleAdvancedMode, exportOpen, gridSettingsOpen, homeOpen, imageResizeOpen, latestReleaseOpen, newOpen, openMenu, openSaveAs, outlineOpen, preferencesOpen, projectInfoOpen, roadmapOpen, runtimePreferences.timelineHidden, saveAsOpen, shortcutOpen, timelapseOpen, toggleMirrorView, updatePanelVisibility, updateToolRailSide, workspace, workspaceManagerOpen, workspaceSaveOpen, session?.brushSize, session?.document.id, session?.selection, shortcuts]) useEffect(() => { void window.moonSprite.getResourceInfo().then((info) => setResourceLabel(t('app.resource.freeMemory', { value: formatBytes(info.freeBytes) }))) }, [t]) useEffect(() => { diff --git a/src/renderer/src/components/CanvasStage.tsx b/src/renderer/src/components/CanvasStage.tsx index 99d0c7e..4ad6d34 100644 --- a/src/renderer/src/components/CanvasStage.tsx +++ b/src/renderer/src/components/CanvasStage.tsx @@ -120,6 +120,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { 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) @@ -262,6 +263,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { 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 @@ -801,7 +803,7 @@ 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) @@ -1881,7 +1883,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { 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, 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.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, timelineHidden, symmetryAxisPreferences]) const unrotatedStagePoint = (clientX: number, clientY: number): Point => { const bounds = stageBounds() diff --git a/src/renderer/src/components/WorkspacePanels.tsx b/src/renderer/src/components/WorkspacePanels.tsx index 712a208..5e67348 100644 --- a/src/renderer/src/components/WorkspacePanels.tsx +++ b/src/renderer/src/components/WorkspacePanels.tsx @@ -30,8 +30,8 @@ interface InspectorDockHit { } type SquareAnchor = 'start' | 'end' type PanelRenderProps = T & { renderKey: string } -const samePanelRender = (previous: T, next: T): boolean => - previous.renderKey === next.renderKey && previous.docked === next.docked +const samePanelRender = (previous: T, next: T): boolean => + previous.renderKey === next.renderKey && previous.docked === next.docked && previous.sideDocked === next.sideDocked const MemoColorPanel = memo(function MemoColorPanel({ renderKey: _renderKey, ...props }: PanelRenderProps>) { return @@ -367,7 +367,7 @@ export function InspectorPanels({ session, panelVisibility, onClosePreview, pane })) } - const panelFor = (id: WorkspacePanelId, docked: boolean) => { + const panelFor = (id: WorkspacePanelId, docked: boolean, dock?: PanelDock) => { const dockProps: DockDragProps = { docked, onFloatingDock: (dock) => onPanelDockChange(id, dock), onPanelContextMenu: (event) => openPanelContextMenu(id, event), onDockDragStart: (event, detach) => { if (event.button !== 0 || (event.target as HTMLElement).closest('button, input, select')) return dockDragRef.current = { id, startX: event.clientX, startY: event.clientY, detach, moved: false } @@ -378,7 +378,7 @@ export function InspectorPanels({ session, panelVisibility, onClosePreview, pane : id === 'palette' ? : id === 'layers' - ? + ? : return {panel} } @@ -397,7 +397,7 @@ export function InspectorPanels({ session, panelVisibility, onClosePreview, pane const squareLocked = id === 'color' && colorSquareDock === dock const fillsSpaceBeforeSquare = colorSquareDock === dock && ((squareAtEnd && index === squareIndex - 1) || (squareAtStart && index === squareIndex + 1)) return
1) ? 'square-locked' : ''}`} data-inspector-panel-id={id} style={horizontal ? { flex: squareLocked ? `0 0 ${bottomWidths[id]}px` : fillsSpaceBeforeSquare ? `1 1 ${bottomWidths[id]}px` : index === dockOrder.length - 1 ? `1 1 ${bottomWidths[id]}px` : `0 1 ${bottomWidths[id]}px`, minWidth: MINIMUM_BOTTOM_WIDTHS[id], '--locked-size': `${bottomWidths[id]}px` } as React.CSSProperties : { flex: dockOrder.length === 1 ? `1 1 ${sizes[id]}px` : squareLocked ? `0 0 ${sizes[id]}px` : verticalInspectorPanelFlex(sizes[id], index < dockOrder.length - 1, fillsSpaceBeforeSquare), minHeight: MINIMUM_INSPECTOR_SIZES[id] + (index < dockOrder.length - 1 ? 7 : 0), '--locked-size': `${sizes[id]}px` } as React.CSSProperties}> -
{panelFor(id, true)}
+
{panelFor(id, true, dock)}
{!horizontal && index < dockOrder.length - 1 &&
{ const measured = { ...sizesRef.current } for (const panelSlot of document.querySelectorAll(`[data-panel-dock-content="${dock}"] [data-inspector-panel-id]`)) { @@ -427,7 +427,7 @@ export function InspectorPanels({ session, panelVisibility, onClosePreview, pane {renderDock('right')} {leftDockHost && createPortal(renderDock('left'), leftDockHost)} {bottomDockHost && createPortal(renderDock('bottom'), bottomDockHost)} - {createPortal(<>{activeOrder.filter((id) => dockFor(id) === 'floating').map((id) => {panelFor(id, false)})}, document.body)} + {createPortal(<>{activeOrder.filter((id) => dockFor(id) === 'floating').map((id) => {panelFor(id, false, 'floating')})}, document.body)} {panelContextMenu && createPortal(
event.preventDefault()}> diff --git a/src/renderer/src/components/panels/LayersPanel.test.tsx b/src/renderer/src/components/panels/LayersPanel.test.tsx index 424727d..8b91b6f 100644 --- a/src/renderer/src/components/panels/LayersPanel.test.tsx +++ b/src/renderer/src/components/panels/LayersPanel.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createDocument, createLayer, ensureLayerCoversCanvas, getActiveLayer } from '@/core/document' import { animationCelAt, animationCelKey, connectAnimationCels, ensureAnimationDocument } from '@/core/animation' import { buildLayerPanelTree } from '@/core/layer-panel-layout' -import { ONION_SKIN_PREFERENCE_KEY } from '@/core/file-preferences' +import { ONION_SKIN_PREFERENCE_KEY, TIMELINE_HIDDEN_PREFERENCE_KEY } from '@/core/file-preferences' import { useWorkspace } from '@/store/workspace' import { finishAnimationCellOperation, revealLayerInPanel } from '@/components/layer-panel-reveal' import { LayersPanel } from './LayersPanel' @@ -19,6 +19,22 @@ afterEach(() => { }) describe('LayersPanel animation', () => { + it('removes frame and layer edit buttons while docked on either side', () => { + const document = createDocument('side dock actions', 2, 2, 'rgba') + useWorkspace.getState().addSession(document) + const session = useWorkspace.getState().sessions[0] + const { container, rerender } = render() + + expect(container.querySelectorAll('.timeline-frame-edit-button')).toHaveLength(0) + expect(container.querySelectorAll('.layer-structure-edit-button')).toHaveLength(0) + expect(container.querySelectorAll('.layer-animation-edit button')).toHaveLength(1) + expect(container.querySelectorAll('.panel-actions button')).toHaveLength(1) + + rerender() + expect(container.querySelectorAll('.timeline-frame-edit-button')).toHaveLength(2) + expect(container.querySelectorAll('.layer-structure-edit-button')).toHaveLength(3) + }) + it('keeps timeline selections while interacting with a marked floating dialog', () => { const document = createDocument('preserved timeline selection', 2, 2, 'rgba') useWorkspace.getState().addSession(document) @@ -840,11 +856,31 @@ describe('LayersPanel animation', () => { fireEvent.click(container.querySelector('.panel-actions button:last-child')!) const modal = document.querySelector('.layer-settings-modal') expect(modal).not.toBeNull() - fireEvent.click(modal!.querySelector('input[type="checkbox"]')!) + fireEvent.click(screen.getByRole('checkbox', { name: '启用洋葱皮' })) fireEvent.submit(modal!) expect(JSON.parse(localStorage.getItem(ONION_SKIN_PREFERENCE_KEY) ?? '{}')).toMatchObject({ enabled: true, previousFrames: 1, nextFrames: 1 }) }) + + it('hides timeline editing and clears active animation interaction from layer settings', () => { + const spriteDocument = createDocument('hidden timeline', 1, 1, 'rgba') + useWorkspace.getState().addSession(spriteDocument) + useWorkspace.getState().duplicateAnimationFrame() + useWorkspace.getState().setAnimationPlaying(true) + const session = useWorkspace.getState().sessions[0] + const { container } = render() + + fireEvent.click(container.querySelector('.panel-actions button:last-child')!) + fireEvent.click(screen.getByRole('checkbox', { name: '隐藏时间轴' })) + + expect(container.querySelector('.layers-panel')).toHaveClass('timeline-hidden') + expect(container.querySelector('.layer-panel-title')).toHaveTextContent('图层') + expect(document.querySelector('.layer-settings-modal')).toHaveClass('timeline-disabled') + expect(document.querySelector('.layer-settings-onion')).toBeDisabled() + expect(localStorage.getItem(TIMELINE_HIDDEN_PREFERENCE_KEY)).toBe('true') + expect(useWorkspace.getState().sessions[0].animationPlaying).toBe(false) + expect(useWorkspace.getState().sessions[0].selectedAnimationFrameIds).toEqual([]) + }) }) describe('LayersPanel properties', () => { @@ -881,8 +917,17 @@ describe('LayersPanel properties', () => { fireEvent.click(marker!) expect(useWorkspace.getState().sessions[0].activeLayerMaskId).toBe(cel.mask?.id) expect(useWorkspace.getState().sessions[0].layerMaskIsolatedView).toBe(false) - fireEvent.pointerDown(marker!, { button: 0, altKey: true }) + fireEvent.click(maskRow) + expect(container.querySelector('[data-animation-cel-selection]')).not.toBeInTheDocument() + fireEvent.pointerDown(maskRow, { button: 0, altKey: true }) + fireEvent.click(maskRow, { altKey: true }) + expect(useWorkspace.getState().sessions[0].activeLayerMaskId).toBe(cel.mask?.id) + expect(useWorkspace.getState().sessions[0].layerMaskIsolatedView).toBe(true) + fireEvent.pointerDown(maskRow, { button: 0, altKey: true }) + fireEvent.click(maskRow, { altKey: true }) expect(useWorkspace.getState().sessions[0].activeLayerMaskId).toBe(cel.mask?.id) + expect(useWorkspace.getState().sessions[0].layerMaskIsolatedView).toBe(false) + fireEvent.pointerDown(marker!, { button: 0, altKey: true }) expect(useWorkspace.getState().sessions[0].layerMaskIsolatedView).toBe(true) expect(layerRow).not.toHaveClass('selected') expect(container.querySelector('[data-animation-cel-selection]')).toHaveStyle('--animation-row-index: 0') @@ -897,6 +942,7 @@ describe('LayersPanel properties', () => { fireEvent.keyDown(window, { key: 'Alt' }) expect(activeMaskCell).toHaveClass('mask-edit-ready') + expect(maskRow).toHaveClass('mask-edit-ready') expect(container.querySelector('.layers-panel')).not.toHaveClass('layer-alt-copy-ready') fireEvent.keyUp(window, { key: 'Alt' }) diff --git a/src/renderer/src/components/panels/LayersPanel.tsx b/src/renderer/src/components/panels/LayersPanel.tsx index e407476..479e7e0 100644 --- a/src/renderer/src/components/panels/LayersPanel.tsx +++ b/src/renderer/src/components/panels/LayersPanel.tsx @@ -226,7 +226,7 @@ const selectedRowsForProperties = (session: DocumentSession): LayerFormTarget[] ...layerIds.map((id) => ({ id, kind: 'layer' as const })) ] } -export function LayersPanel({ session, docked = false, onDockDragStart, onPanelContextMenu, onFloatingDock }: { session: DocumentSession } & DockDragProps) { +export function LayersPanel({ session, docked = false, sideDocked = false, onDockDragStart, onPanelContextMenu, onFloatingDock }: { session: DocumentSession; sideDocked?: boolean } & DockDragProps) { const { t } = useI18n() const blendOptions: Array<{ value: BlendMode; label: string }> = [ { value: 'normal', label: t('blend.normal') }, { value: 'darken', label: t('blend.darken') }, { value: 'multiply', label: t('blend.multiply') }, @@ -289,6 +289,7 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC const [animationCellSelectionOutlineVisible, setAnimationCellSelectionOutlineVisible] = useState(() => session.selectedAnimationCellKeys.length > 0 || session.selectedAnimationMaskCellKeys.length > 0) const hiddenAnimationCellSelectionSignatureRef = useRef(null) const suppressAnimationClickRef = useRef(false) + const suppressMaskRowClickRef = useRef(false) const [draggingAnimationFrameIds, setDraggingAnimationFrameIds] = useState([]) const [draggingAnimationCellKeys, setDraggingAnimationCellKeys] = useState([]) const [animationCelDropTargetKey, setAnimationCelDropTargetKey] = useState(null) @@ -302,7 +303,10 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC const [frameProperties, setFrameProperties] = useState<{ frameId: string; duration: number } | null>(null) const [celProperties, setCelProperties] = useState<{ layerId: string; frameId: string; opacity: number } | null>(null) const [layerSettingsOpen, setLayerSettingsOpen] = useState(false) - const [layerSettings, setLayerSettings] = useState<{ density: LayerDisplayDensity; onionSkin: OnionSkinPreferences }>({ density: loadLayerDensity(), onionSkin: loadEditorPreferences().onionSkin }) + const [layerSettings, setLayerSettings] = useState<{ density: LayerDisplayDensity; onionSkin: OnionSkinPreferences; timelineHidden: boolean }>(() => { + const preferences = loadEditorPreferences() + return { density: loadLayerDensity(), onionSkin: preferences.onionSkin, timelineHidden: preferences.timelineHidden } + }) const [layerSettingsSlider, setLayerSettingsSlider] = useState<'previousOpacity' | 'nextOpacity' | null>(null) const [layerLabelWidth, setLayerLabelWidth] = useState(loadLayerLabelWidth) const [layerDensity, setLayerDensity] = useState(loadLayerDensity) @@ -441,15 +445,23 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC return keys } const openLayerSettings = (): void => { - setLayerSettings({ density: layerDensity, onionSkin: loadEditorPreferences().onionSkin }) + const preferences = loadEditorPreferences() + setLayerSettings({ density: layerDensity, onionSkin: preferences.onionSkin, timelineHidden: preferences.timelineHidden }) setLayerSettingsSlider(null) setLayerSettingsOpen(true) } - const applyLayerSettings = (next: { density: LayerDisplayDensity; onionSkin: OnionSkinPreferences }): void => { + const applyLayerSettings = (next: { density: LayerDisplayDensity; onionSkin: OnionSkinPreferences; timelineHidden: boolean }): void => { + if (layerSettings.timelineHidden && next.timelineHidden && next.onionSkin !== layerSettings.onionSkin) return setLayerSettings(next) setLayerDensity(next.density) localStorage.setItem(layerDensityKey, next.density) - saveEditorPreferences({ ...loadEditorPreferences(), onionSkin: next.onionSkin }) + saveEditorPreferences({ ...loadEditorPreferences(), onionSkin: next.onionSkin, timelineHidden: next.timelineHidden }) + if (next.timelineHidden) { + setLayerSettingsSlider(null) + store.setAnimationPlaying(false) + store.clearAnimationSelection() + setAnimationMenu(null) + } window.dispatchEvent(new Event('moonsprite:preferences-changed')) } const saveLayerSettings = (): void => { @@ -458,6 +470,7 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC } const resetLayerSettings = (): void => applyLayerSettings({ density: 'normal', + timelineHidden: false, onionSkin: { ...DEFAULT_ONION_SKIN_PREFERENCES, previousColor: { ...DEFAULT_ONION_SKIN_PREFERENCES.previousColor }, @@ -466,7 +479,7 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC }) const toggleOnionSkin = (): void => { const current = loadEditorPreferences().onionSkin - applyLayerSettings({ density: layerDensity, onionSkin: { ...current, enabled: !current.enabled } }) + applyLayerSettings({ density: layerDensity, onionSkin: { ...current, enabled: !current.enabled }, timelineHidden: layerSettings.timelineHidden }) } const selectAnimationFrame = (frameId: string, mode: 'replace' | 'toggle' | 'range' = 'replace'): void => { store.setAnimationPlaying(false) @@ -683,10 +696,19 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC animationPointerDragRef.current = drag event.preventDefault() } + const toggleAnimationMaskIsolatedView = (layerId: string, frameId: string, additive = false): boolean => { + const key = animationCelKey(layerId, frameId) + const cel = celLookup.at(layerId, frameId) + const mask = animationMaskAt(timeline, layerId, frameId) + if (!mask) return false + if (!additive && session.layerMaskIsolatedView && session.activeLayerMaskId === mask.id) store.selectAnimationMaskCell(key) + else if (cel) store.selectLayerMask(cel.id, additive) + else store.selectGroupMask(layerId, frameId, additive) + return true + } const beginAnimationMaskDrag = (event: React.PointerEvent, layerId: string, frameId: string): void => { if (event.button !== 0) return const key = animationCelKey(layerId, frameId) - const cel = celLookup.at(layerId, frameId) const mask = animationMaskAt(timeline, layerId, frameId) if (!mask) { if (event.shiftKey || event.ctrlKey) event.preventDefault() @@ -694,8 +716,7 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC } if (event.altKey) { cancelAnimationPointerDrag() - if (cel) store.selectLayerMask(cel.id, event.shiftKey) - else store.selectGroupMask(layerId, frameId, event.shiftKey) + toggleAnimationMaskIsolatedView(layerId, frameId, event.shiftKey) suppressAnimationClickRef.current = true window.setTimeout(() => { suppressAnimationClickRef.current = false }, 0) event.preventDefault() @@ -1770,8 +1791,8 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC const selectedRoots = new Set(masks.flatMap((item) => selected.has(item.key) ? [resolveAnimationMask(timeline, item.mask)?.id ?? item.mask.id] : [])) return masks.some((item) => Boolean(item.mask.linkedMaskId && (selected.has(item.key) || selectedRoots.has(resolveAnimationMask(timeline, item.mask)?.id ?? '')))) })() - return <>
-
floating.style ? floating.startDrag(event) : onDockDragStart?.(event, floating.startDetachedDrag)}>
event.stopPropagation()}> + return <>
+
floating.style ? floating.startDrag(event) : onDockDragStart?.(event, floating.startDetachedDrag)}>{layerSettings.timelineHidden && {t('panel.layers')}}
event.stopPropagation()}> @@ -1779,19 +1800,20 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC - - -
event.stopPropagation()}>
+ {!sideDocked && } + {!sideDocked && } +
event.stopPropagation()}>{!sideDocked && }{!sideDocked && }{!sideDocked && }
{ if (event.target === event.currentTarget) { store.clearLayerSelection(); store.clearAnimationSelection() } }} onContextMenu={(event) => { const target = (event.target as HTMLElement).closest('[data-layer-id], [data-group-id]'); if (target?.dataset.layerId) openLayerContextMenu(event, 'layer', target.dataset.layerId); else if (target?.dataset.groupId) openLayerContextMenu(event, 'group', target.dataset.groupId) }}>
frame.id)} containerRef={layerListRef} suppressActiveGuide={suppressCellSelectionGuides} />
{ if (event.key === 'ArrowLeft') { event.preventDefault(); setStoredLayerLabelWidth(layerLabelWidth - 12) } else if (event.key === 'ArrowRight') { event.preventDefault(); setStoredLayerLabelWidth(layerLabelWidth + 12) } }} />{displayRows.map((displayRow) => { if (displayRow.kind === 'mask') { const activeCel = displayRow.ownerKind === 'layer' ? celLookup.at(displayRow.owner.id, timeline.activeFrameId) : null const activeMask = animationMaskAt(timeline, displayRow.owner.id, timeline.activeFrameId) const rowSelected = !suppressCellSelectionGuides && (session.selectedAnimationMaskCellKeys.some((key) => parseAnimationCelKey(key)?.layerId === displayRow.owner.id) || Boolean(activeMask && session.activeLayerMaskId === activeMask.id)) const maskNameKey = displayRow.ownerKind === 'group' ? 'core.document.layerGroupMask' : 'core.document.layerMask' + const maskRowTooltip = <>{t(maskNameKey)}{t('layers.layerMaskDescription')}{t('layers.layerMaskUsage')} const maskVisibilityTarget: LayerPanelToggleTarget | null = displayRow.ownerKind === 'layer' ? activeCel ? { control: 'visibility', ownerKind: 'layer-mask', id: activeCel.id } : null : { control: 'visibility', ownerKind: 'group-mask', id: displayRow.owner.id, frameId: timeline.activeFrameId } - return + return } const node = displayRow.node if (node.kind === 'group') { @@ -1931,11 +1953,12 @@ export function LayersPanel({ session, docked = false, onDockDragStart, onPanelC
} {layerSettingsOpen &&
{ if (event.target === event.currentTarget) setLayerSettingsOpen(false) }}> - { event.preventDefault(); saveLayerSettings() }}> + { event.preventDefault(); saveLayerSettings() }}>

{t('layers.settings')}

{ if (!(event.target as Element).closest('.layer-setting-percent')) setLayerSettingsSlider(null) }}>
{t('layers.thumbnailSize')}
applyLayerSettings({ ...layerSettings, density: layerDensityOrder[Number(event.target.value)] })} />{t(layerDensityLabelKeys[layerSettings.density])}
-
{t('layers.onionSkin')}
+ +
{t('layers.onionSkin')}
diff --git a/src/renderer/src/components/panels/PreviewPanel.tsx b/src/renderer/src/components/panels/PreviewPanel.tsx index 96590e6..9ea717a 100644 --- a/src/renderer/src/components/panels/PreviewPanel.tsx +++ b/src/renderer/src/components/panels/PreviewPanel.tsx @@ -54,6 +54,7 @@ export function PreviewPanel({ session, onClose, docked = false, onDockDragStart const [checkerboard, setCheckerboard] = useState(() => loadEditorPreferences().checkerboard) const [canvasSurround, setCanvasSurround] = useState(() => resolveTheme(loadEditorPreferences().theme).definition.seeds.canvasSurround) const [rotationIndicatorPosition, setRotationIndicatorPosition] = useState(() => loadEditorPreferences().rotationIndicatorPosition) + const [timelineHidden, setTimelineHidden] = useState(() => loadEditorPreferences().timelineHidden) const [playbackMenu, setPlaybackMenu] = useState<{ x: number; y: number } | null>(null) const timeline = ensureAnimationDocument(session.document) const initialFrameId = timeline.activeFrameId @@ -161,11 +162,21 @@ export function PreviewPanel({ session, onClose, docked = false, onDockDragStart setCheckerboard(preferences.checkerboard) setCanvasSurround(resolveTheme(preferences.theme).definition.seeds.canvasSurround) setRotationIndicatorPosition(preferences.rotationIndicatorPosition) + setTimelineHidden(preferences.timelineHidden) } window.addEventListener('moonsprite:preferences-changed', syncPreferences) return () => window.removeEventListener('moonsprite:preferences-changed', syncPreferences) }, []) + useEffect(() => { + if (timelineHidden) { + setPreviewPlayingState(false) + setPlaybackMenu(null) + } + // setPreviewPlayingState deliberately follows the current playback state. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timelineHidden]) + useEffect(() => { const blur = (): void => { panDrag.current = null @@ -375,7 +386,7 @@ export function PreviewPanel({ session, onClose, docked = false, onDockDragStart if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) } return
-
floating.style ? floating.startDrag(event) : onDockDragStart?.(event, floating.startDetachedDrag)}>{t('panel.preview')}
+
floating.style ? floating.startDrag(event) : onDockDragStart?.(event, floating.startDetachedDrag)}>{t('panel.preview')}
{ const bounds = canvasRef.current?.getBoundingClientRect(); if (!bounds) return; event.preventDefault(); adjustZoom(event.deltaY < 0, { x: event.clientX - bounds.left, y: event.clientY - bounds.top }) }} onPointerDown={startPan} onPointerMove={(event) => { const drag = panDrag.current; if (!drag) return; schedulePan({ x: drag.panX + event.clientX - drag.x, y: drag.panY + event.clientY - drag.y }) }} onPointerUp={finishPan} onPointerCancel={finishPan}>
{floating.style && } diff --git a/src/renderer/src/core/file-preferences.test.ts b/src/renderer/src/core/file-preferences.test.ts index 1f3cca8..3503b2f 100644 --- a/src/renderer/src/core/file-preferences.test.ts +++ b/src/renderer/src/core/file-preferences.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { BRUSH_SHIFT_LINE_ENABLED_KEY, DEFAULT_GRID_COLOR, DEFAULT_PIXEL_GRID_COLOR, EXPORT_DIRECTORY_PREFERENCE_KEY, EXPORT_FORMAT_PREFERENCE_KEY, GRID_COLOR_PREFERENCE_KEY, LANGUAGE_PREFERENCE_KEY, LAYER_DISPLAY_COLOR_PRESETS_KEY, MOVE_LAYER_CONTENT_PREVIEW_ENABLED_PREFERENCE_KEY, NEW_DOCUMENT_SIZE_PRESETS_KEY, PIXEL_GRID_COLOR_PREFERENCE_KEY, RECOVERY_MINUTES_PREFERENCE_KEY, SAVE_DIRECTORY_PREFERENCE_KEY, SAVE_FORMAT_PREFERENCE_KEY, SYMMETRY_AXIS_PREFERENCE_KEY, THEME_PREFERENCE_KEY, TIMELAPSE_RECORDING_ENABLED_PREFERENCE_KEY, TOOL_ICON_SCALE_PREFERENCE_KEY, UI_SCALE_PREFERENCE_KEY, WHEEL_ZOOM_MODE_PREFERENCE_KEY, ZOOM_TOOL_DRAG_MODE_PREFERENCE_KEY, imageExportKindForPreference, loadEditorPreferences, parseBrushPreviewMode, parseBrushShiftLineEnabled, parseCheckerSize, parseCursorScale, parseDocumentSizePresets, parseDrawingBrushPreviewEnabled, parseExportScalePresets, parseEyedropperMagnifierStyle, parseLayerDisplayColorPresets, parseLineDirectionStep, parseRelativeLuminanceScope, parseSymmetryAxisPreferences, parseToolIconScale, parseUiScale, parseWheelZoomMode, parseZoomToolDragMode, saveEditorPreferences, saveImageKindForPreference } from './file-preferences' +import { BRUSH_SHIFT_LINE_ENABLED_KEY, DEFAULT_GRID_COLOR, DEFAULT_PIXEL_GRID_COLOR, EXPORT_DIRECTORY_PREFERENCE_KEY, EXPORT_FORMAT_PREFERENCE_KEY, GRID_COLOR_PREFERENCE_KEY, LANGUAGE_PREFERENCE_KEY, LAYER_DISPLAY_COLOR_PRESETS_KEY, MOVE_LAYER_CONTENT_PREVIEW_ENABLED_PREFERENCE_KEY, NEW_DOCUMENT_SIZE_PRESETS_KEY, PIXEL_GRID_COLOR_PREFERENCE_KEY, RECOVERY_MINUTES_PREFERENCE_KEY, SAVE_DIRECTORY_PREFERENCE_KEY, SAVE_FORMAT_PREFERENCE_KEY, SYMMETRY_AXIS_PREFERENCE_KEY, THEME_PREFERENCE_KEY, TIMELAPSE_RECORDING_ENABLED_PREFERENCE_KEY, TIMELINE_HIDDEN_PREFERENCE_KEY, TOOL_ICON_SCALE_PREFERENCE_KEY, UI_SCALE_PREFERENCE_KEY, WHEEL_ZOOM_MODE_PREFERENCE_KEY, ZOOM_TOOL_DRAG_MODE_PREFERENCE_KEY, imageExportKindForPreference, loadEditorPreferences, parseBrushPreviewMode, parseBrushShiftLineEnabled, parseCheckerSize, parseCursorScale, parseDocumentSizePresets, parseDrawingBrushPreviewEnabled, parseExportScalePresets, parseEyedropperMagnifierStyle, parseLayerDisplayColorPresets, parseLineDirectionStep, parseRelativeLuminanceScope, parseSymmetryAxisPreferences, parseToolIconScale, parseUiScale, parseWheelZoomMode, parseZoomToolDragMode, saveEditorPreferences, saveImageKindForPreference } from './file-preferences' import { resolveTheme } from './theme' describe('file format preferences', () => { @@ -178,9 +178,11 @@ describe('editor preferences persistence boundary', () => { expect(loadEditorPreferences(adapter).wheelZoomMode).toBe('smooth') saveEditorPreferences({ ...loadEditorPreferences(adapter), brushShiftLineEnabled: false }, adapter) expect(storage.get(BRUSH_SHIFT_LINE_ENABLED_KEY)).toBe('false') - saveEditorPreferences({ ...loadEditorPreferences(adapter), timelapseRecordingEnabled: false, eyedropperMagnifierEnabled: false, eyedropperMagnifierDistortionEnabled: false, moveLayerContentPreviewEnabled: false }, adapter) + saveEditorPreferences({ ...loadEditorPreferences(adapter), timelapseRecordingEnabled: false, timelineHidden: true, eyedropperMagnifierEnabled: false, eyedropperMagnifierDistortionEnabled: false, moveLayerContentPreviewEnabled: false }, adapter) expect(storage.get(TIMELAPSE_RECORDING_ENABLED_PREFERENCE_KEY)).toBe('false') expect(loadEditorPreferences(adapter).timelapseRecordingEnabled).toBe(false) + expect(storage.get(TIMELINE_HIDDEN_PREFERENCE_KEY)).toBe('true') + expect(loadEditorPreferences(adapter).timelineHidden).toBe(true) expect(loadEditorPreferences(adapter).eyedropperMagnifierEnabled).toBe(false) expect(loadEditorPreferences(adapter).eyedropperMagnifierDistortionEnabled).toBe(false) expect(storage.get(MOVE_LAYER_CONTENT_PREVIEW_ENABLED_PREFERENCE_KEY)).toBe('false') diff --git a/src/renderer/src/core/file-preferences.ts b/src/renderer/src/core/file-preferences.ts index 86ea071..cbfa75c 100644 --- a/src/renderer/src/core/file-preferences.ts +++ b/src/renderer/src/core/file-preferences.ts @@ -42,6 +42,7 @@ export const LINE_DIRECTION_STEP_PREFERENCE_KEY = 'moonsprite.preference.line-di export const LAYER_DISPLAY_COLOR_PRESETS_KEY = 'moonsprite.preference.layer-display-color-presets' export const COLOR_EDITOR_MODES_PREFERENCE_KEY = 'moonsprite.preference.color-editor-modes' export const ONION_SKIN_PREFERENCE_KEY = 'moonsprite.preference.onion-skin' +export const TIMELINE_HIDDEN_PREFERENCE_KEY = 'moonsprite.preference.timeline-hidden' export const SYMMETRY_AXIS_PREFERENCE_KEY = 'moonsprite.preference.symmetry-axis' export const TIMELAPSE_RECORDING_ENABLED_PREFERENCE_KEY = 'moonsprite.preference.timelapse-recording-enabled' export const UI_SCALE_PREFERENCE_KEY = 'moonsprite.preference.ui-scale' @@ -256,6 +257,7 @@ export interface EditorPreferences { layerDisplayColorPresets: RgbaColor[] colorEditorModes: ColorEditorModePreference[] onionSkin: OnionSkinPreferences + timelineHidden: boolean symmetryAxis: SymmetryAxisPreferences timelapseRecordingEnabled: boolean theme: ThemePreferences @@ -299,6 +301,7 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { layerDisplayColorPresets: DEFAULT_LAYER_DISPLAY_COLOR_PRESETS, colorEditorModes: DEFAULT_COLOR_EDITOR_MODES, onionSkin: DEFAULT_ONION_SKIN_PREFERENCES, + timelineHidden: false, symmetryAxis: DEFAULT_SYMMETRY_AXIS_PREFERENCES, timelapseRecordingEnabled: true, theme: DEFAULT_THEME_PREFERENCES @@ -574,6 +577,7 @@ export function loadEditorPreferences(storage?: Storage): EditorPreferences { layerDisplayColorPresets: parseLayerDisplayColorPresets(get(LAYER_DISPLAY_COLOR_PRESETS_KEY)), colorEditorModes: parseColorEditorModes(get(COLOR_EDITOR_MODES_PREFERENCE_KEY)), onionSkin: theme.onionSkin, + timelineHidden: get(TIMELINE_HIDDEN_PREFERENCE_KEY) === 'true', symmetryAxis: theme.symmetryAxis, timelapseRecordingEnabled: get(TIMELAPSE_RECORDING_ENABLED_PREFERENCE_KEY) !== 'false', theme: theme.theme @@ -622,6 +626,7 @@ export function saveEditorPreferences(preferences: EditorPreferences, storage?: [LAYER_DISPLAY_COLOR_PRESETS_KEY]: JSON.stringify(parseLayerDisplayColorPresets(JSON.stringify(preferences.layerDisplayColorPresets))), [COLOR_EDITOR_MODES_PREFERENCE_KEY]: JSON.stringify(parseColorEditorModes(JSON.stringify(preferences.colorEditorModes))), [ONION_SKIN_PREFERENCE_KEY]: JSON.stringify(parseOnionSkinPreferences(JSON.stringify(preferences.onionSkin))), + [TIMELINE_HIDDEN_PREFERENCE_KEY]: String(preferences.timelineHidden), [SYMMETRY_AXIS_PREFERENCE_KEY]: JSON.stringify(parseSymmetryAxisPreferences(JSON.stringify(preferences.symmetryAxis))), [TIMELAPSE_RECORDING_ENABLED_PREFERENCE_KEY]: String(preferences.timelapseRecordingEnabled) } diff --git a/src/renderer/src/locales/en-US.ts b/src/renderer/src/locales/en-US.ts index d2b09aa..caf4906 100644 --- a/src/renderer/src/locales/en-US.ts +++ b/src/renderer/src/locales/en-US.ts @@ -1017,7 +1017,7 @@ export const enUSMessages = { 'layers.createLayerMask': 'Create Layer Mask', 'layers.createLayerGroupMask': 'Create Layer Group Mask', 'layers.layerMaskDescription': 'Uses grayscale to control layer opacity: black hides it completely, white shows it completely, and gray makes it partially transparent.', - 'layers.layerMaskUsage': 'Right-click a layer to create masks for every frame with content, or right-click a cel for only that frame; click to paint, or Alt-click to view the mask by itself.', + 'layers.layerMaskUsage': 'Right-click a layer to create masks for every frame with content, or right-click a cel for only that frame; click to paint, Alt-click to enter the mask, and Alt-click again to exit.', 'layers.layerMaskEmptyCel': 'This layer cel has no visible content, so a layer mask cannot be created or pasted here. Add content to the cel first.', 'layers.deleteLayerMask': 'Delete Layer Mask', 'layers.deleteLayerGroupMask': 'Delete Layer Group Mask', @@ -1025,6 +1025,8 @@ export const enUSMessages = { 'layers.properties': 'Properties', 'layers.settings': 'Layer Settings', 'layers.thumbnailSize': 'Thumbnail and Frame Detail', + 'layers.hideTimeline': 'Hide Timeline', + 'layers.hideTimelineDescription': 'Hide the frame grid and layer animation controls, and disable playback, onion skinning, and frame editing.', 'layers.density.compact': 'Compact', 'layers.density.normal': 'Normal', 'layers.density.detailed': 'Detailed', diff --git a/src/renderer/src/locales/zh-CN.ts b/src/renderer/src/locales/zh-CN.ts index d7d0001..16f6c39 100644 --- a/src/renderer/src/locales/zh-CN.ts +++ b/src/renderer/src/locales/zh-CN.ts @@ -1019,7 +1019,7 @@ export const zhCNMessages = { 'layers.createLayerMask': '新建图层蒙版', 'layers.createLayerGroupMask': '新建图层组蒙版', 'layers.layerMaskDescription': '用黑白灰来控制图层的“透明度”,黑色代表完全隐藏,白色代表完全显示,灰色代表半透明。', - 'layers.layerMaskUsage': '右键图层可为所有有内容的帧创建,右键单元格只创建当前帧;单击即可绘制,Alt+左键可单独查看蒙版。', + 'layers.layerMaskUsage': '右键图层可为所有有内容的帧创建,右键单元格只创建当前帧;单击即可绘制,Alt+左键进入图层蒙版,再次操作可退出。', 'layers.layerMaskEmptyCel': '当前图层单元格没有可见内容,无法创建或粘贴图层蒙版。请先在该单元格中绘制内容。', 'layers.deleteLayerMask': '删除图层蒙版', 'layers.deleteLayerGroupMask': '删除图层组蒙版', @@ -1027,6 +1027,8 @@ export const zhCNMessages = { 'layers.properties': '属性', 'layers.settings': '图层设置', 'layers.thumbnailSize': '缩略图与帧信息大小', + 'layers.hideTimeline': '隐藏时间轴', + 'layers.hideTimelineDescription': '隐藏帧网格和图层栏动画按钮,并停用播放、洋葱皮与帧编辑。', 'layers.density.compact': '紧凑', 'layers.density.normal': '标准', 'layers.density.detailed': '详细', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 43d2cbb..d605f94 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -883,9 +883,10 @@ body :is(.palette-panel, .color-panel) > header .panel-actions button { .live-preview-copy { display: grid; gap: 2px; min-width: 0; } .live-preview-label { display: inline-flex; align-items: center; gap: 7px; color: var(--theme-text-primary); font-size: 11px; font-weight: 600; } .live-preview-copy > small { color: var(--theme-text-muted); font-size: 10px; font-weight: 400; } -.live-preview-toggle input { position: absolute; width: 1px !important; height: 1px !important; opacity: 0; pointer-events: none; } +.live-preview-toggle > input, .outline-preview-toggle > input { position: absolute; width: 1px !important; height: 1px !important; opacity: 0; pointer-events: none; } .toggle-track { position: relative; display: block; flex: 0 0 32px; width: 32px; height: 16px; margin-left: auto; background: var(--theme-border); border: 1px solid var(--theme-border-strong); } .toggle-track i { position: absolute; top: 50%; left: 2px; width: 10px; height: 10px; background: var(--theme-text-muted); transform: translateY(-50%); transition: transform 90ms linear; } +.live-preview-toggle > input:focus-visible + .toggle-track, .outline-preview-toggle > input:focus-visible + .toggle-track { outline: 1px solid var(--theme-accent); outline-offset: 2px; } .live-preview-toggle input:checked + .toggle-track, .outline-preview-toggle input:checked + .toggle-track { background: var(--theme-accent); border-color: var(--theme-accent); } .live-preview-toggle input:checked + .toggle-track i, .outline-preview-toggle input:checked + .toggle-track i { background: var(--theme-on-accent); transform: translate(16px, -50%); } @media (max-width: 620px) { .outline-modal-body { grid-template-columns: 1fr; grid-template-areas: 'color' 'width' 'settings' 'preview'; } .outline-color-section { grid-template-rows: auto 250px; } } @@ -980,6 +981,9 @@ body :is(.palette-panel, .color-panel) > header .panel-actions button { .color-value-action-row.has-add-action { grid-template-columns: minmax(0, 1fr) 38px; } .color-value-action-row.supports-palette-action .color-value-trigger { height: 38px; } .color-value-add-button { display: grid; width: 38px; height: 38px; padding: 0; place-items: center; color: var(--theme-text-secondary); background: var(--theme-raised-surface); border: 1px solid var(--theme-border-muted); } +.color-panel .color-value-action-row.has-add-action { grid-template-columns: minmax(0, 1fr) 30px; } +.color-panel .color-value-action-row.supports-palette-action .color-value-trigger { height: 30px; } +.color-panel .color-value-add-button { width: 30px; height: 30px; } .color-value-add-button:hover { color: var(--theme-text-primary); background: var(--theme-surface-hover); border-color: var(--theme-accent); } .color-value-trigger:hover { background: var(--theme-surface-hover); border-color: var(--theme-border-strong); } .color-value-trigger[aria-expanded="true"] { background: var(--theme-surface-active); border-color: var(--theme-border-strong); } @@ -1054,6 +1058,10 @@ body :is(.palette-panel, .color-panel) > header .panel-actions button { .layer-settings-density > div { display: grid; grid-template-columns: minmax(0, 1fr) 68px; align-items: center; gap: 10px; } .layer-settings-density input[type='range'] { width: 100%; margin: 0; accent-color: var(--theme-accent); } .layer-settings-density output { min-width: 0; padding: 8px 6px; overflow: hidden; color: var(--theme-text-soft); background: var(--theme-surface); border: 1px solid var(--theme-border); font: 10px/1 ui-monospace, Consolas, monospace; text-align: center; text-overflow: ellipsis; white-space: nowrap; } +.layer-settings-modal .modal-body > label.layer-timeline-toggle { box-sizing: border-box; display: flex; min-width: 0; height: 35px; min-height: 35px; align-items: center; justify-content: space-between; gap: 12px; padding: 0 9px; color: var(--theme-text-primary); background: var(--theme-deep-surface); border: 1px solid var(--theme-border); font-size: 11px; font-weight: 600; cursor: var(--cursor-pointer); } +.layer-settings-modal .layer-timeline-toggle > .moon-tooltip-anchor { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.layer-settings-modal.timeline-disabled .layer-settings-onion { opacity: .42; } +.layer-settings-modal.timeline-disabled .layer-settings-onion { pointer-events: none; } .layer-settings-onion { display: grid; min-width: 0; gap: 12px; margin: 0; padding: 12px 10px 10px; border: 1px solid var(--theme-border); } .layer-settings-onion > legend { padding: 0 6px; } .layer-settings-onion label { display: grid; min-width: 0; gap: 5px; color: var(--theme-text-muted); font-size: 10px; } @@ -1201,6 +1209,7 @@ body :is(.palette-panel, .color-panel) > header .panel-actions button { padding-bottom: 10px; } .layers-panel > header > .layer-animation-toolbar { flex: 0 1 auto; } +.layer-panel-title { min-width: 0; overflow: hidden; color: var(--theme-text-primary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .layer-animation-toolbar { display: flex; min-width: 0; align-items: center; gap: 3px; padding: 0; overflow: hidden; background: transparent; border: 0; } .layer-animation-playback, .layer-animation-edit { display: inline-flex; flex: 0 0 auto; gap: 3px; } .layer-animation-toolbar button { display: grid; flex: 0 0 auto; place-items: center; width: 26px; height: 26px; padding: 0; color: var(--theme-text-muted); background: var(--theme-raised-surface); border: 1px solid var(--theme-border); } @@ -1209,6 +1218,12 @@ body :is(.palette-panel, .color-panel) > header .panel-actions button { .layer-animation-toolbar button.active { color: var(--theme-text-primary); background: var(--theme-surface-active); border-color: var(--theme-accent); } .layer-animation-toolbar button:disabled { opacity: .35; } .layer-animation-toolbar .pixel-playback-icon { display: block; align-self: center; justify-self: start; width: 22px; height: 22px; margin: 0 0 0 10px; overflow: visible; shape-rendering: crispEdges; pointer-events: none; } +.layers-panel.timeline-hidden .layer-animation-toolbar, +.layers-panel.timeline-hidden .layer-animation-corner, +.layers-panel.timeline-hidden .layer-animation-column-resizer, +.layers-panel.timeline-hidden .layer-animation-grid { display: none; } +.layers-panel.timeline-hidden .layer-animation-list { --animation-header-height: 0px; --layer-effective-label-width: 100%; grid-template-columns: minmax(0, 1fr); overflow-x: hidden; } +.layers-panel.timeline-hidden .layer-animation-tree { position: relative; grid-column: 1; width: 100%; } .layer-animation-list { --animation-grid-line: var(--theme-border); --animation-header-height: 34px; --layer-row-height: 42px; --layer-frame-width: 34px; --layer-mask-marker-height: 11px; --layer-effective-label-width: var(--layer-label-width, 190px); position: relative; display: grid; min-width: 0; min-height: 0; grid-template-columns: var(--layer-effective-label-width) max-content; align-content: start; overflow: auto; background: var(--theme-surface); scrollbar-color: var(--theme-scrollbar-thumb) var(--theme-scrollbar-track); scrollbar-width: thin; } .layer-animation-tree { position: sticky; z-index: 20; grid-column: 1; grid-row: 1; left: 0; width: 100%; min-width: 0; box-sizing: border-box; background: var(--theme-surface); } .left-panel-dock .layer-animation-list, .inspector .layer-animation-list { --layer-effective-label-width: min(var(--layer-label-width, 190px), max(80px, calc(100% - 68px))); } @@ -1260,6 +1275,7 @@ body :is(.palette-panel, .color-panel) > header .panel-actions button { .layers-panel:is(.layer-density-compact, .layer-density-normal) .layer-animation-cel.layer-mask-cel.linked-cel > .cel-mask-marker > .layer-mask-thumbnail, .layers-panel:is(.layer-density-compact, .layer-density-normal) .layer-animation-cel.layer-mask-cel.linked-cel-member > .cel-mask-marker > .layer-mask-thumbnail { display: none; } .layer-animation-cel.layer-mask-cel.mask-edit-ready { cursor: var(--cursor-project) !important; } +.layer-mask-row.mask-edit-ready { cursor: var(--cursor-project) !important; } .layer-mask-thumbnail { display: block; width: 100%; height: 100%; image-rendering: pixelated; } .layer-animation-cel.linked-cel { z-index: 5; border-color: transparent; border-bottom-color: var(--animation-grid-line); } .layer-animation-cel.linked-cel-member { z-index: 5; background: transparent; } From e0d37d3fd4351686c742dd243117ee006f3b14f2 Mon Sep 17 00:00:00 2001 From: YueHaxgu <167526721+YueHaxgu@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:49:48 +0800 Subject: [PATCH 2/7] feat: refine editor UI and palette workflows --- AGENTS.md | 3 +- docs/README.md | 1 + docs/architecture/state-history.md | 3 +- docs/file-format.md | 6 +- docs/product/behavior.md | 13 +- docs/ui-design-system.md | 141 ++ package.json | 2 +- palettes/deep-spectrum-48.palette.json | 8 + palettes/forest-dusk-12.palette.json | 6 - palettes/mono-10.palette.json | 6 - palettes/moonlight-12.palette.json | 6 - palettes/soft-spectrum-48.palette.json | 8 + palettes/sunset-12.palette.json | 6 - palettes/tiny-console-16.palette.json | 6 - palettes/universal-spectrum-48.palette.json | 8 + palettes/vivid-spectrum-48.palette.json | 8 + scripts/canvas-performance-options.mjs | 2 +- scripts/canvas-performance.mjs | 15 +- scripts/performance-build-contract.test.mjs | 5 +- scripts/performance-scope-rules.mjs | 13 +- scripts/performance-scope-rules.test.mjs | 10 +- src-tauri/Cargo.lock | 56 +- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 3 + src-tauri/src/platform_files.rs | 317 ++- src-tauri/src/platform_palette.rs | 31 +- src-tauri/src/platform_storage.rs | 56 +- src/renderer/src/App.tsx | 98 +- .../src/components/CanvasResizeDialog.tsx | 12 +- src/renderer/src/components/CanvasStage.tsx | 84 +- src/renderer/src/components/CheckboxField.tsx | 20 + src/renderer/src/components/ColorPicker.tsx | 4 +- .../ColorReplacementDialog.test.tsx | 58 +- .../src/components/ColorReplacementDialog.tsx | 56 +- .../src/components/ColorValueControl.tsx | 5 +- .../src/components/ComponentLibrary.tsx | 98 +- src/renderer/src/components/DialogHeader.tsx | 21 + src/renderer/src/components/FormField.tsx | 20 + .../src/components/FutureRoadmapDialog.tsx | 3 +- .../src/components/GridSettingsDialog.tsx | 13 +- .../src/components/HomeWorkspace.test.tsx | 66 +- src/renderer/src/components/HomeWorkspace.tsx | 57 +- .../src/components/ImageResizeDialog.tsx | 18 +- .../src/components/LatestReleaseDialog.tsx | 4 +- .../src/components/LivePreviewToggle.tsx | 9 +- .../src/components/NewDocumentDialog.test.tsx | 14 +- .../src/components/NewDocumentDialog.tsx | 33 +- src/renderer/src/components/NumberInput.tsx | 5 +- .../src/components/OpenProgressOverlay.tsx | 32 + src/renderer/src/components/OutlineDialog.tsx | 11 +- .../src/components/PreferenceToggle.tsx | 19 + .../src/components/ProjectInfoDialog.tsx | 4 +- src/renderer/src/components/RangeField.tsx | 24 + .../src/components/SaveProgressOverlay.tsx | 34 + .../src/components/SegmentedControl.tsx | 26 + .../src/components/SettingsNavigation.tsx | 18 + .../src/components/SettingsSectionHeader.tsx | 14 + src/renderer/src/components/TextInput.tsx | 9 + src/renderer/src/components/ThemedSelect.tsx | 5 +- .../src/components/TimelapseDialog.tsx | 16 +- .../src/components/app/AppMenuBar.tsx | 12 +- .../src/components/app/EditorToolOptions.tsx | 86 +- .../src/components/app/SymmetryControls.tsx | 6 +- .../components/canvas-composite-cache.test.ts | 215 ++- .../src/components/canvas-composite-cache.ts | 125 +- .../src/components/color-sampling-events.ts | 5 + .../components/dialogs/AdjustmentDialog.tsx | 18 +- .../dialogs/PreferencesDialog.test.tsx | 35 +- .../components/dialogs/PreferencesDialog.tsx | 218 ++- .../src/components/dialogs/SaveAsDialog.tsx | 5 +- .../src/components/dialogs/ShortcutDialog.tsx | 22 +- .../dialogs/ThemePreferencesSection.tsx | 3 +- .../onion-skin-composite-cache.test.ts | 104 + .../components/onion-skin-composite-cache.ts | 203 ++ .../components/panels/LayersPanel.test.tsx | 47 +- .../src/components/panels/LayersPanel.tsx | 104 +- .../components/panels/PalettePanel.test.tsx | 70 +- .../src/components/panels/PalettePanel.tsx | 110 +- .../components/panels/PreviewPanel.test.tsx | 32 +- .../src/components/panels/PreviewPanel.tsx | 81 +- src/renderer/src/core/animation.test.ts | 43 +- src/renderer/src/core/animation.ts | 56 + src/renderer/src/core/aseprite.test.ts | 70 +- src/renderer/src/core/aseprite.ts | 78 +- src/renderer/src/core/built-in-palettes.ts | 112 +- src/renderer/src/core/document-files.test.ts | 51 +- src/renderer/src/core/document-files.ts | 175 +- .../src/core/document-performance.bench.ts | 11 +- src/renderer/src/core/document.test.ts | 57 +- src/renderer/src/core/document.ts | 180 +- .../src/core/file-preferences.test.ts | 2 +- src/renderer/src/core/file-preferences.ts | 4 +- src/renderer/src/core/gradient.ts | 5 +- src/renderer/src/core/history.test.ts | 33 +- src/renderer/src/core/history.ts | 32 +- .../src/core/imported-palette.test.ts | 93 + src/renderer/src/core/imported-palette.ts | 96 + .../src/core/initial-document-composite.ts | 79 + src/renderer/src/core/layer-panel-state.ts | 126 ++ src/renderer/src/core/localization.ts | 9 +- src/renderer/src/core/open-progress.test.ts | 84 + src/renderer/src/core/open-progress.ts | 72 + src/renderer/src/core/palette.test.ts | 30 +- src/renderer/src/core/palette.ts | 135 +- src/renderer/src/core/png-encode.ts | 93 + src/renderer/src/core/png.test.ts | 4 + src/renderer/src/core/png.ts | 104 +- .../core/project-format-performance.bench.ts | 54 + src/renderer/src/core/project-format.test.ts | 249 ++- src/renderer/src/core/project-format.ts | 534 +++++- src/renderer/src/core/project-gallery.test.ts | 37 + src/renderer/src/core/project-gallery.ts | 31 + src/renderer/src/core/project-metadata.ts | 2 +- src/renderer/src/core/raster-image.test.ts | 4 + src/renderer/src/core/raster-image.ts | 2 + src/renderer/src/core/save-progress.test.ts | 67 + src/renderer/src/core/save-progress.ts | 79 + src/renderer/src/core/shortcuts.test.ts | 30 +- src/renderer/src/core/shortcuts.ts | 56 +- src/renderer/src/core/timelapse.test.ts | 73 +- src/renderer/src/core/timelapse.ts | 302 ++- src/renderer/src/core/tools.test.ts | 83 +- src/renderer/src/core/tools.ts | 237 ++- src/renderer/src/core/view-geometry.test.ts | 9 +- src/renderer/src/core/view-geometry.ts | 8 +- src/renderer/src/env.d.ts | 2 + src/renderer/src/locales/en-US.ts | 86 +- src/renderer/src/locales/shortcuts.ts | 20 +- src/renderer/src/locales/zh-CN.ts | 91 +- src/renderer/src/main.tsx | 23 +- .../src/performance/benchmark-harness.ts | 8 + src/renderer/src/platform/tauri-api.ts | 45 +- .../src/store/document-file-service.ts | 37 +- src/renderer/src/store/recovery-service.ts | 4 +- .../src/store/workspace-history-sync.test.ts | 50 + src/renderer/src/store/workspace-palette.ts | 118 ++ src/renderer/src/store/workspace-session.ts | 14 + src/renderer/src/store/workspace.test.ts | 434 ++++- src/renderer/src/store/workspace.ts | 380 ++-- src/renderer/src/styles.css | 1693 +++++++++-------- .../src/workers/document-decode.worker.ts | 84 + .../src/workers/project-encode.worker.ts | 28 + .../src/workers/project-gallery.worker.ts | 32 + .../src/workers/timelapse-encode.worker.ts | 29 + src/shared/types.ts | 28 +- 145 files changed, 7912 insertions(+), 2136 deletions(-) create mode 100644 docs/ui-design-system.md create mode 100644 palettes/deep-spectrum-48.palette.json delete mode 100644 palettes/forest-dusk-12.palette.json delete mode 100644 palettes/mono-10.palette.json delete mode 100644 palettes/moonlight-12.palette.json create mode 100644 palettes/soft-spectrum-48.palette.json delete mode 100644 palettes/sunset-12.palette.json delete mode 100644 palettes/tiny-console-16.palette.json create mode 100644 palettes/universal-spectrum-48.palette.json create mode 100644 palettes/vivid-spectrum-48.palette.json create mode 100644 src/renderer/src/components/CheckboxField.tsx create mode 100644 src/renderer/src/components/DialogHeader.tsx create mode 100644 src/renderer/src/components/FormField.tsx create mode 100644 src/renderer/src/components/OpenProgressOverlay.tsx create mode 100644 src/renderer/src/components/PreferenceToggle.tsx create mode 100644 src/renderer/src/components/RangeField.tsx create mode 100644 src/renderer/src/components/SaveProgressOverlay.tsx create mode 100644 src/renderer/src/components/SegmentedControl.tsx create mode 100644 src/renderer/src/components/SettingsNavigation.tsx create mode 100644 src/renderer/src/components/SettingsSectionHeader.tsx create mode 100644 src/renderer/src/components/TextInput.tsx create mode 100644 src/renderer/src/components/onion-skin-composite-cache.test.ts create mode 100644 src/renderer/src/components/onion-skin-composite-cache.ts create mode 100644 src/renderer/src/core/imported-palette.test.ts create mode 100644 src/renderer/src/core/imported-palette.ts create mode 100644 src/renderer/src/core/initial-document-composite.ts create mode 100644 src/renderer/src/core/layer-panel-state.ts create mode 100644 src/renderer/src/core/open-progress.test.ts create mode 100644 src/renderer/src/core/open-progress.ts create mode 100644 src/renderer/src/core/png-encode.ts create mode 100644 src/renderer/src/core/project-format-performance.bench.ts create mode 100644 src/renderer/src/core/project-gallery.test.ts create mode 100644 src/renderer/src/core/project-gallery.ts create mode 100644 src/renderer/src/core/save-progress.test.ts create mode 100644 src/renderer/src/core/save-progress.ts create mode 100644 src/renderer/src/store/workspace-history-sync.test.ts create mode 100644 src/renderer/src/workers/document-decode.worker.ts create mode 100644 src/renderer/src/workers/project-encode.worker.ts create mode 100644 src/renderer/src/workers/project-gallery.worker.ts create mode 100644 src/renderer/src/workers/timelapse-encode.worker.ts 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/docs/README.md b/docs/README.md index b8c08e8..43cbd6f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ ## 产品与架构 - [产品行为契约](product/behavior.md):用户可见能力和稳定规则。 +- [UI 设计规范](ui-design-system.md):颜色、字号、间距、控件密度、像素图标和组件复用规则。 - [架构概览](architecture/overview.md):模块职责和依赖方向。 - [状态与历史](architecture/state-history.md):会话、dirty、撤销和视图状态。 - [坐标与渲染](architecture/coordinates-rendering.md):屏幕、视图、画布和图层坐标。 diff --git a/docs/architecture/state-history.md b/docs/architecture/state-history.md index d6d5054..252a345 100644 --- a/docs/architecture/state-history.md +++ b/docs/architecture/state-history.md @@ -13,10 +13,11 @@ MoonSprite 状态分为四类: | 应用状态 | 当前工具、弹窗、栏目布局、首页栏目 | 本地设置或工作区 | 否 | 动画帧、cel、每帧时长和循环方式属于文档内容;当前帧、播放/暂停、播放倍率、开始播放时的帧和停止返回开关属于会话状态。切帧与播放状态变化不得改变 dirty 或写入撤销历史,只有帧增删复制、帧时长和循环方式变化进入文档历史。 +图层可见性与锁定属于文档内容;活动图层、图层栏多选、选择锚点和组展开状态属于不进入撤销的界面上下文。界面上下文按工程路径即时写入本地存储,并在正常保存工程时作为可选元数据写入文件;本地记录优先恢复最近一次会话,失效图层或组 ID 必须过滤,且恢复过程不得改变 dirty。 图层蒙版是附着在单个动画 cel 上的独立灰度文档表面,图层组蒙版则以 `groupId + frameId` 附着在图层组当前帧的合成结果上;透明像素表示未绘制且不改变所有者的显示。活动蒙版 ID 只属于会话选择状态。蒙版像素编辑进入文档历史并绑定所属 `frameId`,撤销、重做和渲染失效只针对该帧。新建和删除蒙版分别形成一个结构历史条目;切换帧或普通选择图层、图层组、cel 时退出蒙版编辑。 -项目显示元数据和缩时录制设置会随工程保存并改变 dirty,但不进入撤销历史。编辑统计和缩时快照只在已提交文档操作后更新;缩时导出、清空前的预览、视图导航和栏目操作不得增加笔画数或生成缩时帧。 +项目显示元数据和缩时录制设置会随工程保存并改变 dirty,但不进入撤销历史。编辑统计和缩时快照只在已提交文档操作后更新;开启录制时,每个已提交的内容操作对应一个等权帧,画面必须在操作提交时冻结,PNG 编码可以排队完成,但不得按空闲延迟合并操作或按现实时间加权。缩时导出、清空前的预览、视图导航和栏目操作不得增加笔画数或生成缩时帧。 ## 历史规则 diff --git a/docs/file-format.md b/docs/file-format.md index 0506aef..314141c 100644 --- a/docs/file-format.md +++ b/docs/file-format.md @@ -9,13 +9,13 @@ - `cels/.rgba`:RGBA cel 的独立像素字节。 - `cels/.idx32`:索引色 cel 的独立稳定调色板 ID。 - `timelapse/.png`:开启缩时录制后,在已提交编辑边界生成的压缩合成快照。 -- `preview.png`:可见图层合成后的预览图。 +- `preview.png`:可见图层合成后的预览图;恢复快照等低延迟写入可以省略,首页会在后台生成有界缩略图并单独缓存,不改写原工程。 v4 动画元数据至少包含一帧、当前帧、帧持续时间、循环状态、cel 与图层/帧的稳定关联,以及可选的逐帧图层组蒙版。`layers/` 保留活动帧兼容位图和图层属性,动画 cel 像素独立写入 `cels/`;解码后活动帧图层表面引用对应 cel,画布工具不直接解析时间轴。早期 v2 文件没有 `cels/` 时,使用 `layers/` 位图补成活动帧 cel。 图层和图层组元数据可选保存 `displayColor`、`description` 与 `clippingMask: true`。`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`、尺寸非法、图层数据缺失或字节长度异常时同样拒绝。未知未来版本不得静默降级读取。 @@ -28,3 +28,5 @@ v1 工程打开时先补成单帧时间轴再迁移到 v4;v2 工程保留原 用户保存到软件的 `*.palette.json` 使用 `schemaVersion: 2`。`colors` 保存独立 RGBA 颜色,`columns` 保存二维列数,`slots` 按行保存颜色在 `colors` 中的索引或空槽 `null`;每个颜色索引必须且只能出现一次。读取时拒绝未知版本、非法列数、越界或重复索引。旧 `schemaVersion: 1` 色板仍可读取,其仅包含紧凑颜色列表,应用后按当前默认列数顺序排列。 修改格式时必须新增迁移策略、兼容测试和 ADR,不得直接改变已有版本的读取语义。 + +普通保存可以只重新编码发生变化的 cel、图层或蒙版数据,并从当前磁盘工程直接复制未修改 ZIP 条目的既有压缩块。复用前必须核对条目路径与 CRC,合并失败时回退为完整编码;增量合并使用的内部保存计划不得写入最终工程,也不改变 v4 的读取语义。 diff --git a/docs/product/behavior.md b/docs/product/behavior.md index 61f6fe8..497cfa5 100644 --- a/docs/product/behavior.md +++ b/docs/product/behavior.md @@ -14,11 +14,11 @@ - PNG、JPEG、WebP、BMP 与 GIF 可通过文件选择或拖放作为单层像素文档打开;GIF 当前读取第一帧。图片导入后保留来源路径,但不会覆盖原图片,首次保存仍按用户选择的工程或图片格式执行。 - 从文件关联启动时应直接进入目标工程,不应只停留在首页。 - 打开或保存的 `.moonsprite`、`.ase`、`.aseprite`、PNG、JPEG、WebP、BMP 与 GIF 进入最近列表;首页和“文件 > 最近打开文件”始终显示真实文件名及后缀。最近记录的原路径不存在时提示用户并自动移除该记录;路径存在但无法解析的文件继续保留原文件和最近记录,并显示“从最近移除”供用户明确处理。画廊中的“删除工程”是独立危险操作,必须二次确认后才删除磁盘文件。 -- 首页先显示完整文件列表,再以最多 3 个任务并发渐进读取缩略图;单项读取失败不得阻塞其他项目。已成功解码的缩略图在当前软件会话内按文件路径和记录时间缓存,栏目刷新时复用未变化结果。 +- 首页先显示完整文件列表,再以最多 3 个任务并发渐进读取缩略图;单项读取失败不得阻塞其他项目。工程缺少内嵌预览图时,必须在后台串行生成最大边长 512 像素的缩略图并写入磁盘缓存,不得在界面主线程合成大画布;已成功解码的缩略图在当前软件会话内按文件路径和记录时间缓存,栏目刷新时复用未变化结果。 - 打开新建工程弹窗时,如果系统剪贴板包含图片,应立即读取图片头部宽高并填入尺寸;此步骤不得解码或跨进程传输整张像素数据。 - 异常关闭工程进入恢复栏目;用户明确放弃后不得反复提示或自动恢复。 - 像素网格和自定义网格的开关以及自定义网格参数属于当前项目显示设置;保存并重新打开项目后恢复原状态,不复用其他项目的网格状态。缩放、平移、旋转和镜像仍只属于当前会话。 -- “文件 > 缩时视频”按项目控制自动录制、画质、导出帧率和缩时倍速;开启后在每次已提交编辑结束时记录合成快照,不记录缩放、平移和栏目操作。记录随 `.moonsprite` 保存,可清空并导出 WebM。 +- 新建画布弹窗提供“记录绘画过程”开关,其默认状态由首选项控制且初始默认关闭。“文件 > 缩时视频”按项目控制录制、画质、导出帧率和播放倍率;开启后每个已提交的绘画或内容编辑操作记录为一个等权视频帧,不按现实时间计时,也不记录缩放、平移和栏目操作。记录随 `.moonsprite` 保存,可清空并导出 MP4 或 WebM。 - “文件 > 项目信息”显示画布、颜色模式、图层/组/帧数量、笔画数、编辑操作数、有效绘画时长、创建与修改时间以及缩时记录帧数。 - “文件 > 导出”提供“导出为”和“导出精灵表”两个选项。“导出为”打开现有导出设置;“导出精灵表”将时间轴中的每一帧完整合成,按时间轴顺序从左到右排列,并打开一个未保存的 RGBA 单图层文档供继续编辑或常规导出,不直接写入磁盘。 @@ -43,18 +43,19 @@ - 画布平移、缩放、旋转视图、面板布局和工具切换不是文档编辑,不得改变 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` 保存。 - 图层选择使用 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` 保存;保存到软件时始终可以“保存为新色板”,当前选择为非内置用户色板时还可明确“保存到当前色板”,两种操作都保留列数、二维位置和空槽,旧版紧凑色板载入时按顺序排列。颜色格同时反映前景色和背景色:前景色保留右上角角标,背景色保留左下角角标,颜色角色本身不随选择变化。 - 前景色和背景色由整个应用共享,而不是分别存入项目标签;切换、新建或打开工程后继续使用最近选择的两种颜色。 @@ -113,6 +114,8 @@ 普通保存、首次保存、另存为和导出都必须在实际编码或写入开始后显示紧凑进度反馈,并在成功后自动关闭。需要 Windows 文件选择器的操作必须先等待选择器返回,用户取消时不显示进度。 +已打开 `.moonsprite` 工程再次保存时,只重新编码发生变化的 cel、图层、蒙版和必须刷新的工程元数据,未修改的像素块与缩时快照直接复用原文件中的压缩数据。增量保存必须继续通过同目录临时文件原子替换;原文件被外部改变、复用校验失败或合并失败时自动完整保存,不得留下不完整工程。 + “另存为”默认选择“首选项 > 文件 > 默认保存格式”。选择 PNG、JPEG 或 WebP 时必须明确提示该格式不会保留图层和工程结构;用户确认的扩展名、Windows 保存类型和实际编码格式必须一致。 “导出设置”的文件名始终显示当前格式后缀,切换格式时保留名称主体并同步后缀。每次导出可单独选择目录、切换到本地展馆目录,或从最近成功导出的完整文件路径中复用其所在目录;弹窗选择的目录优先于首选项默认导出位置。导出预设必须持久化完整格式、缩放、GIF 范围与方向、文件名和目录设置,读取旧预设时不得丢弃已经支持的导出格式。 diff --git a/docs/ui-design-system.md b/docs/ui-design-system.md new file mode 100644 index 0000000..9110494 --- /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`,统一眉题、标题、关闭按钮和标题栏操作;不得在业务弹窗重复拼装标题结构。 +- 标题栏默认 50px;内容默认间距 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..74f5e26 100644 --- a/package.json +++ b/package.json @@ -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", 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/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..b7eb250 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]] @@ -2127,6 +2148,7 @@ dependencies = [ "tauri-build", "tauri-plugin-single-instance", "windows-sys 0.59.0", + "zip", ] [[package]] @@ -5289,12 +5311,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..d9c729b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sysinfo = "0.35" arboard = "3.4" +zip = { version = "4", default-features = false, features = ["deflate"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_System_DataExchange", "Win32_System_Memory"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a3e0bb4..192eedc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -128,7 +128,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, diff --git a/src-tauri/src/platform_files.rs b/src-tauri/src/platform_files.rs index 0f12be6..a0cc0c9 100644 --- a/src-tauri/src/platform_files.rs +++ b/src-tauri/src/platform_files.rs @@ -1,5 +1,160 @@ -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 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") { + 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 +162,161 @@ 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 write_binary_atomic(file_path: String, data: Vec) -> Result<(), String> { - atomic_write(Path::new(&file_path), &data) +pub fn read_project_preview(app: AppHandle, file_path: String) -> Result { + let path = Path::new(&file_path); + if !path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("moonsprite")) + { + return Err("仅 MoonSprite 工程包含内嵌缩略图。".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 }); + } + } + } + } + } + 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")).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 !Path::new(&file_path) + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("moonsprite")) + { + return Err("仅 MoonSprite 工程支持缩略图缓存。".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_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, SAVE_PLAN_ENTRY}; + use std::io::{Cursor, Read, Write}; + 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 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_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_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/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3dde5d6..90dbdea 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -23,6 +23,8 @@ 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 { SaveAsDialog } from '@/components/dialogs/SaveAsDialog' @@ -32,18 +34,22 @@ import { LatestReleaseDialog } from '@/components/LatestReleaseDialog' import { GridSettingsDialog } from '@/components/GridSettingsDialog' import { ProjectInfoDialog } from '@/components/ProjectInfoDialog' import { TimelapseDialog } from '@/components/TimelapseDialog' +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 { 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 { 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 { 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 { readStoredString, writeStoredString } from '@/core/storage' @@ -247,6 +253,12 @@ 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]) useEffect(() => { let disposed = false void window.moonSprite.getDefaultFileDirectories().then((directories) => { @@ -613,7 +625,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,9 +651,9 @@ export default function App() { }) } - const createDocumentAndShow = async (name: string, width: number, height: number, mode: ColorMode): Promise => { + const createDocumentAndShow = async (name: string, width: number, height: number, mode: ColorMode, recordDrawing: boolean): Promise => { const beforeCount = useWorkspace.getState().sessions.length - await useWorkspace.getState().newDocument(name, width, height, mode) + await useWorkspace.getState().newDocument(name, width, height, mode, recordDrawing) if (useWorkspace.getState().sessions.length > beforeCount) setHomeOpen(false) } @@ -921,6 +933,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' } })) @@ -1044,12 +1057,16 @@ export default function App() { if (session?.selection) workspace.commitSelectionChange({ ...session.selection }, null, t('app.selection.cancelHistory')) })) 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('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 @@ -1102,7 +1119,32 @@ export default function App() { if (runCommand('convertColorMode', () => { if (session) void workspace.convertColorMode(session.document.colorMode === 'rgba' ? 'indexed' : 'rgba') })) 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('toggleAnimationPlayback', () => { if (session && !runtimePreferences.timelineHidden) workspace.setAnimationPlaying(!session.animationPlaying) })) 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 @@ -1117,6 +1159,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 +1168,14 @@ 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('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 +1184,10 @@ 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 (event.key === 'Enter' && session?.selection && shouldHandleGlobalSelectionEnter(outlineOpen, true)) { event.preventDefault() if (session.pendingPaste) workspace.commitFloatingPaste() @@ -1176,7 +1228,7 @@ export default function App() { 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, runtimePreferences.timelineHidden, saveAsOpen, shortcutOpen, timelapseOpen, toggleMirrorView, updatePanelVisibility, updateToolRailSide, workspace, workspaceManagerOpen, workspaceSaveOpen, session?.brushSize, session?.document.id, session?.selection, shortcuts]) + }, [adjustmentOpen, advancedMode, aboutOpen, blockedShortcuts, canvasResizeOpen, colorReplacementOpen, componentLibraryOpen, cycleAdvancedMode, exportOpen, gridSettingsOpen, homeOpen, imageResizeOpen, latestReleaseOpen, loadSavedWorkspaces, newOpen, openMenu, openSaveAs, outlineOpen, preferencesOpen, projectInfoOpen, roadmapOpen, runtimePreferences.timelineHidden, saveAsOpen, shortcutOpen, timelapseOpen, toggleMirrorView, toggleTimelineVisibility, updatePanelVisibility, updateToolRailSide, workspace, workspaceManagerOpen, workspaceSaveOpen, session?.brushSize, session?.document.id, session?.selection, shortcuts]) useEffect(() => { void window.moonSprite.getResourceInfo().then((info) => setResourceLabel(t('app.resource.freeMemory', { value: formatBytes(info.freeBytes) }))) }, [t]) useEffect(() => { @@ -1293,6 +1345,7 @@ export default function App() { shortcutFor={shortcutFor} homeOpen={homeOpen} panelVisibility={panelVisibility} + timelineHidden={runtimePreferences.timelineHidden} toolRailSide={toolRailSide} advancedModeActive={advancedMode !== null} recentFiles={recentFiles} @@ -1315,6 +1368,7 @@ export default function App() { onOpenGridSettings={() => setGridSettingsOpen(true)} onToggleMirror={toggleMirrorView} onTogglePanel={(id) => updatePanelVisibility(id, !panelVisibility[id])} + onToggleTimeline={toggleTimelineVisibility} onToolRailSideChange={updateToolRailSide} onCycleAdvancedMode={cycleAdvancedMode} onOpenComponentLibrary={() => setComponentLibraryOpen(true)} @@ -1325,7 +1379,7 @@ export default function App() {
- {openMenu === 'workspace' && createPortal(
{savedWorkspaces.map((saved) => )}
, document.body)}
+ {openMenu === 'workspace' && createPortal(
{savedWorkspaces.map((saved) => )}
, document.body)}
{session && !homeOpen ? : } + + {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) => )}
} + {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')}

+ setExportOpen(false)} />
-
{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 })}
- + {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, scalePercent: format === 'svg' ? 100 : exportForm.scalePercent })} /> {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')}
MIT License
github.com/MoonPixelTeam/moonsprite

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

} {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)} /> + {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 &&
{ if (event.target === event.currentTarget) setWorkspaceManagerOpen(false) }}> setWorkspaceManagerOpen(false)} />
{savedWorkspaces.map((saved) =>
{!saved.builtIn && }
)}
} + 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 && setOutlineOpen(false)} />} 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 4ad6d34..34d3fdc 100644 --- a/src/renderer/src/components/CanvasStage.tsx +++ b/src/renderer/src/components/CanvasStage.tsx @@ -1,6 +1,6 @@ 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 { compositePixelWithLayerColor, compositeRegion, createCompositePointReplacementSampler, createCompositePointSampler, createNormalCompositePointReplacementSampler, createNormalCompositePointSampler, 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 { applyGradient, constrainGradientEndpoint, createGradientColorSampler, gradientRegionSelection } from '@/core/gradient' @@ -20,16 +20,17 @@ import { canvasCursors, canvasStatusTextColor, canvasToolCursor, colorLuminance, import { defaultSymmetryCenter, hasSymmetry, moveSymmetryCenter, symmetryAxisSegment, symmetryPoints, symmetrySelection, transformSymmetrySelection, type SymmetryAxis } from '@/core/symmetry' import { beginAdjustmentPreviewEdit, endAdjustmentPreviewEdit, 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 { 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 { 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' @@ -47,7 +48,6 @@ import eyedropperMagnifierSampledMask from '@/assets/eyedropper-magnifier-sample import eyedropperMagnifierPreviousMask from '@/assets/eyedropper-magnifier-previous-mask.svg?raw' 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 @@ -136,7 +136,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { 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) @@ -267,7 +267,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { 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) { @@ -807,52 +807,26 @@ export function CanvasStage({ session }: { session: DocumentSession }) { 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, @@ -870,7 +844,6 @@ export function CanvasStage({ session }: { session: DocumentSession }) { contentInvalidation: currentSession.contentInvalidation, frameId: document.animation?.activeFrameId, isolatedLayerMask: isolatedLayerMask ?? undefined, - activeDrag, imageSmoothingEnabled: smoothPixelSampling }) if (view.showPixelGrid && shouldRenderPixelGrid(view.zoom)) drawGrid(0, 0, 1, 1, gridColors.pixelGridColor) @@ -880,7 +853,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 @@ -1653,7 +1626,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 @@ -2505,10 +2478,13 @@ export function CanvasStage({ session }: { session: DocumentSession }) { } return } + const eyedropperHeld = modifierActive(event.nativeEvent, 'temporaryEyedropper') const focusesRasterLayer = event.button === 0 && session.tool !== 'hand' && session.tool !== 'zoom' && session.tool !== 'move' + && session.tool !== 'eyedropper' + && !eyedropperHeld && !session.activeLayerMaskId && (session.selectedGroupIds.length > 0 || session.selectedLayerIds.length > 1) if (focusesRasterLayer) state.selectLayer(session.document.activeLayerId) @@ -2544,7 +2520,6 @@ 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) { @@ -3283,6 +3258,7 @@ export function CanvasStage({ session }: { session: DocumentSession }) { inputRef.current.sampling = false hideEyedropperMagnifier() eyedropperOriginalColorRef.current = null + publishCanvasColorSamplingCompleted() if (!drag.temporarySampling && eyedropperSwitchToPencil) { state.setTool('pencil') event.currentTarget.style.cursor = canvasToolCursor('pencil', session.primaryColor) 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..019aaa1 100644 --- a/src/renderer/src/components/ColorPicker.tsx +++ b/src/renderer/src/components/ColorPicker.tsx @@ -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} /> + {secondaryColor && onSecondaryChange && entry.r === secondaryColor.r && entry.g === secondaryColor.g && entry.b === secondaryColor.b && entry.a === secondaryColor.a)} onAddToPalette={onAddPaletteColor ? () => onAddPaletteColor(secondaryColor) : undefined} />}
} 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.tsx b/src/renderer/src/components/ColorValueControl.tsx index 35d4f90..3c7104b 100644 --- a/src/renderer/src/components/ColorValueControl.tsx +++ b/src/renderer/src/components/ColorValueControl.tsx @@ -12,6 +12,7 @@ import { PixelUtilityIcon } from './PixelUtilityIcon' interface ColorValueControlProps { color: RgbaColor + density?: 'compact' | 'regular' | 'emphasized' onChange: (color: RgbaColor) => void label: string roleLabel?: string @@ -69,7 +70,7 @@ 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, fillWithColor = false, dismissOnFocusLoss = false, preserveAnimationSelection = 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)) @@ -336,7 +337,7 @@ export function ColorValueControl({ color, onChange, label, roleLabel, className
: null return <> -
+ return
} function DeleteIconButtonPreview({ locale }: { locale: AppLocale }) { @@ -173,7 +186,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 +195,53 @@ 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 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,7 +254,7 @@ 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() { @@ -271,7 +300,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 +327,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 +362,12 @@ function PressureOptionsPreview({ locale }: { locale: AppLocale }) { } return
-
- - - -
-
- - -
+ +
{componentText(locale, tool === 'pencil' ? 'componentLibrary.preview.brush' : 'componentLibrary.preview.eraser')} - +
{panelOpen &&
setSettings((current) => ({ ...current, gradientDither }))} />
} @@ -356,7 +383,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,8 +400,12 @@ const previewRenderers: Record ReactEl 'context-menu': ContextMenuPreview, segmented: SegmentedPreview, 'number-input': NumberInputPreview, + 'text-input': TextInputPreview, 'text-area-input': TextAreaInputPreview, 'themed-select': SelectPreview, + 'form-field': FormFieldPreview, + 'settings-navigation': SettingsNavigationPreview, + 'settings-section-header': SettingsSectionHeaderPreview, range: RangePreview, checkbox: CheckboxPreview, switch: SwitchPreview, @@ -384,6 +415,7 @@ const previewRenderers: Record ReactEl 'layer-row': LayerRowPreview, swatches: SwatchesPreview, 'modal-shell': ModalShellPreview, + 'dialog-header': ModalShellPreview, 'save-progress': SaveProgressPreview, status: StatusPreview, tooltip: TooltipPreview, @@ -411,10 +443,10 @@ export function ComponentLibrary({ onClose }: { onClose: () => void }) { return
{ if (event.target === event.currentTarget) onClose() }}> -
MOONSPRITE UI

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

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

+
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.tsx b/src/renderer/src/components/FutureRoadmapDialog.tsx index 2053c2c..2687146 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' @@ -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/GridSettingsDialog.tsx b/src/renderer/src/components/GridSettingsDialog.tsx index 3f59cbc..feec2b9 100644 --- a/src/renderer/src/components/GridSettingsDialog.tsx +++ b/src/renderer/src/components/GridSettingsDialog.tsx @@ -2,9 +2,10 @@ import { useState } from 'react' import type { GridSettings } from '@shared/types' import { normalizeGridSettings } from '@/core/grid' import { useI18n } from '@/components/I18nProvider' +import { DialogHeader } from '@/components/DialogHeader' import { ModalShell } from '@/components/ModalShell' +import { FormField } from '@/components/FormField' import { NumberInput } from '@/components/NumberInput' -import { PixelUtilityIcon } from '@/components/PixelUtilityIcon' interface GridSettingsDialogProps { value?: GridSettings @@ -21,12 +22,12 @@ export function GridSettingsDialog({ value, onApply, onClose }: GridSettingsDial return
{ if (event.target === event.currentTarget) onClose() }}> { event.preventDefault(); onApply(normalizeGridSettings(draft)); onClose() }} aria-labelledby="grid-settings-title"> -

{t('gridSettings.title')}

+
- - - - + event.currentTarget.select()} onValueChange={(next) => update('x', next)} /> + update('y', next)} /> + update('width', next)} /> + update('height', next)} />
diff --git a/src/renderer/src/components/HomeWorkspace.test.tsx b/src/renderer/src/components/HomeWorkspace.test.tsx index 222626a..cc06ca6 100644 --- a/src/renderer/src/components/HomeWorkspace.test.tsx +++ b/src/renderer/src/components/HomeWorkspace.test.tsx @@ -1,10 +1,9 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { MoonSpriteApi } from '@shared/types' +import type { MoonSpriteApi, ProjectPreview } from '@shared/types' import { createDocument } from '@/core/document' import { getRecentProjects, recordRecentProject } from '@/core/home-history' import { encodeProject } from '@/core/project-format' -import { useWorkspace } from '@/store/workspace' import { HomeWorkspace } from './HomeWorkspace' const galleryProject = { @@ -23,6 +22,8 @@ function installApi(overrides: Partial): void { window.moonSprite = { ensureBuiltinExample: vi.fn(async () => null), fileExists: vi.fn(async () => true), + readProjectPreview: vi.fn(async () => { throw new Error('preview unavailable') }), + cacheProjectPreview: vi.fn(async () => undefined), listGalleryProjects: vi.fn(async () => ({ directoryPath: 'C:\\gallery', projects: [galleryProject] })), deleteGalleryProject: vi.fn(async () => undefined), ...overrides @@ -41,7 +42,7 @@ describe('HomeWorkspace', () => { it('shows project rows before their previews finish decoding', async () => { localStorage.setItem('moonsprite.home-section.v1', 'gallery') - installApi({ readBinary: vi.fn(() => new Promise(() => {})) }) + installApi({ readProjectPreview: vi.fn(() => new Promise(() => {})) }) render( true)} onRestoreRecovery={vi.fn(async () => true)} />) @@ -52,23 +53,23 @@ describe('HomeWorkspace', () => { it('limits progressive preview loading to three concurrent files', async () => { localStorage.setItem('moonsprite.home-section.v1', 'gallery') const galleryProjects = Array.from({ length: 5 }, (_, index) => ({ filePath: `C:\\gallery\\large-${index}.moonsprite`, fileName: `large-${index}.moonsprite`, modifiedAt: index + 10 })) - const readBinary = vi.fn(() => new Promise(() => {})) + const readProjectPreview = vi.fn(() => new Promise(() => {})) installApi({ listGalleryProjects: vi.fn(async () => ({ directoryPath: 'C:\\gallery', projects: galleryProjects })), - readBinary + readProjectPreview }) render( true)} onRestoreRecovery={vi.fn(async () => true)} />) expect(await screen.findByText('large-4.moonsprite')).toBeInTheDocument() - await waitFor(() => expect(readBinary).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(readProjectPreview).toHaveBeenCalledTimes(3)) }) it('keeps an unreadable gallery file when opening fails', async () => { localStorage.setItem('moonsprite.home-section.v1', 'gallery') const deleteGalleryProject = vi.fn(async () => undefined) const onOpenProject = vi.fn(async () => false) - installApi({ readBinary: vi.fn(async () => { throw new Error('broken preview') }), deleteGalleryProject }) + installApi({ readProjectPreview: vi.fn(async () => { throw new Error('broken preview') }), deleteGalleryProject }) render( true)} />) const openButton = await screen.findByTitle(/点击重新尝试打开/) @@ -85,48 +86,77 @@ describe('HomeWorkspace', () => { installApi({ readBinary: vi.fn(async () => { throw new Error('missing') }), deleteGalleryProject }) render( false)} onRestoreRecovery={vi.fn(async () => true)} />) + fireEvent.click((await screen.findByText('missing-home-test.moonsprite')).closest('.recent-file-open')!) fireEvent.click(await screen.findByRole('button', { name: '从最近移除 missing-home-test.moonsprite' })) expect(getRecentProjects()).toEqual([]) expect(deleteGalleryProject).not.toHaveBeenCalled() }) - it('automatically removes a recent record when its file no longer exists', async () => { + it('loads only the embedded preview for recent MoonSprite projects', async () => { const filePath = 'C:\\art\\renamed-home-test.moonsprite' recordRecentProject(filePath) const readBinary = vi.fn(async () => { throw new Error('should not read a missing file') }) const onOpenProject = vi.fn(async () => false) - installApi({ fileExists: vi.fn(async () => false), readBinary }) + const fileExists = vi.fn(async () => false) + const readProjectPreview = vi.fn(async () => ({ preview: new Uint8Array([1, 2, 3]), width: 4000, height: 2000, colorMode: 'rgba' as const })) + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:recent-preview') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + installApi({ fileExists, readBinary, readProjectPreview }) render( true)} />) - await waitFor(() => expect(getRecentProjects()).toEqual([])) - expect(screen.queryByText('renamed-home-test.moonsprite')).not.toBeInTheDocument() - expect(useWorkspace.getState().message).toBe('renamed-home-test.moonsprite:文件不存在,已从最近记录移除。') + expect(await screen.findByText('renamed-home-test.moonsprite')).toBeInTheDocument() + expect(getRecentProjects()).toHaveLength(1) + expect(fileExists).not.toHaveBeenCalled() + await waitFor(() => expect(readProjectPreview).toHaveBeenCalledWith(filePath)) expect(readBinary).not.toHaveBeenCalled() expect(onOpenProject).not.toHaveBeenCalled() }) - it('reuses a decoded preview while its path and timestamp stay unchanged', async () => { - const cachedProject = { ...galleryProject, filePath: 'C:\\gallery\\cached-home-test.moonsprite', fileName: 'cached-home-test.moonsprite', modifiedAt: 73 } - const bytes = encodeProject(createDocument('cached preview', 3, 2, 'rgba')) + it('generates and caches a thumbnail when a MoonSprite project has no embedded preview', async () => { + const filePath = 'C:\\art\\large-without-preview.moonsprite' + recordRecentProject(filePath) + const document = createDocument('large fallback', 1024, 512, 'rgba') + document.layers[0].pixels.set([255, 0, 0, 255]) + const bytes = encodeProject(document, { includePreview: false, compressionLevel: 1 }) const readBinary = vi.fn(async () => bytes) + const cacheProjectPreview = vi.fn(async (_filePath: string, _preview: ProjectPreview) => undefined) + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:fallback-preview') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + installApi({ + readProjectPreview: vi.fn(async () => { throw new Error('missing embedded preview') }), + readBinary, + cacheProjectPreview + }) + + render( true)} onRestoreRecovery={vi.fn(async () => true)} />) + + await waitFor(() => expect(screen.getByText('1024 x 512 · RGBA')).toBeInTheDocument()) + expect(readBinary).toHaveBeenCalledWith(filePath) + expect(cacheProjectPreview).toHaveBeenCalledWith(filePath, expect.objectContaining({ width: 1024, height: 512, colorMode: 'rgba' })) + expect(cacheProjectPreview.mock.calls[0]?.[1]?.preview.slice(0, 8)).toEqual(new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])) + }) + + it('reuses an embedded preview while its path and timestamp stay unchanged', async () => { + const cachedProject = { ...galleryProject, filePath: 'C:\\gallery\\cached-home-test.moonsprite', fileName: 'cached-home-test.moonsprite', modifiedAt: 73 } + const readProjectPreview = vi.fn(async () => ({ preview: new Uint8Array([1, 2, 3]), width: 3, height: 2, colorMode: 'rgba' as const })) Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => `blob:preview-${Math.random()}`) }) Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) localStorage.setItem('moonsprite.home-section.v1', 'gallery') installApi({ listGalleryProjects: vi.fn(async () => ({ directoryPath: 'C:\\gallery', projects: [cachedProject] })), - readBinary + readProjectPreview }) render( true)} onRestoreRecovery={vi.fn(async () => true)} />) const refresh = await screen.findByRole('button', { name: '刷新当前栏目' }) - await waitFor(() => expect(readBinary).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(readProjectPreview).toHaveBeenCalledTimes(1)) await waitFor(() => expect(refresh).not.toBeDisabled()) fireEvent.click(refresh) await waitFor(() => expect(screen.getByText('3 x 2 · RGBA')).toBeInTheDocument()) - expect(readBinary).toHaveBeenCalledTimes(1) + expect(readProjectPreview).toHaveBeenCalledTimes(1) }) it('continues reordering when the pointer leaves the move button area', async () => { diff --git a/src/renderer/src/components/HomeWorkspace.tsx b/src/renderer/src/components/HomeWorkspace.tsx index e96e930..cce958f 100644 --- a/src/renderer/src/components/HomeWorkspace.tsx +++ b/src/renderer/src/components/HomeWorkspace.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' import { Plus, TriangleAlert } from 'lucide-react' -import type { RecoveryRecord } from '@shared/types' +import type { ProjectPreview, RecoveryRecord } from '@shared/types' import { APP_CHANNEL_LABEL } from '@/core/app-meta' -import { readProjectGalleryMetadata } from '@/core/project-format' import { decodeDocumentFileAsync } from '@/core/document-files' +import { readProjectGalleryMetadataAsync } from '@/core/project-gallery' import { exportDocumentImage } from '@/core/png' import { clearRecentProjects, getGalleryPins, getRecentProjects, recordRecentProject, removeGalleryPin, removeRecentProject, reorderRecentProjects, toggleGalleryPin, toggleRecentProjectPinned, type RecentProject } from '@/core/home-history' import { useWorkspace } from '@/store/workspace' @@ -17,6 +17,7 @@ interface ProjectCard extends RecentProject { width?: number height?: number colorMode?: 'rgba' | 'indexed' + previewLoading?: boolean error?: string } @@ -37,6 +38,7 @@ interface CachedProjectPreview { } const projectPreviewCache = new Map() const maxCachedProjectPreviews = 48 +let projectPreviewFallbackQueue = Promise.resolve() const previewCacheKey = (record: RecentProject): string => `${record.filePath}\u0000${record.lastOpened}` const cacheProjectPreview = (key: string, preview: CachedProjectPreview): void => { projectPreviewCache.delete(key) @@ -47,6 +49,11 @@ const createPreviewUrl = (bytes: Uint8Array): string => { const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer return URL.createObjectURL(new Blob([buffer], { type: 'image/png' })) } +const runProjectPreviewFallback = (task: () => Promise): Promise => { + const result = projectPreviewFallbackQueue.then(task, task) + projectPreviewFallbackQueue = result.then(() => undefined, () => undefined) + return result +} const loadHomeSection = (): HomeSection => { const stored = localStorage.getItem(homeSectionStorageKey) return stored === 'gallery' || stored === 'recovery' ? stored : 'recent' @@ -57,6 +64,12 @@ const formatTime = (value: number, locale: AppLocale): string => { return new Intl.DateTimeFormat(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(value)) } +const formatProjectType = (filePath: string): string => { + const extension = filePath.match(/\.([^./\\]+)$/)?.[1] + if (!extension) return 'FILE' + return extension.toLowerCase() === 'moonsprite' ? 'MoonSprite' : extension.toUpperCase() +} + function parseRecoveryTimestamp(value: string): number { const numeric = Number(value) if (Number.isFinite(numeric)) { @@ -89,7 +102,7 @@ function ProjectFileRow({ project, reorderable, dragging, removePending, onOpen, return
@@ -145,14 +158,26 @@ export function HomeWorkspace({ onNew, onOpen, onOpenProject, onRestoreRecovery projectPreviewCache.set(cacheKey, cached) return { ...record, name: record.fileName, previewUrl: createPreviewUrl(cached.bytes), width: cached.width, height: cached.height, colorMode: cached.colorMode } } - const bytes = await window.moonSprite.readBinary(record.filePath) if (/\.moonsprite$/i.test(record.filePath)) { - const metadata = readProjectGalleryMetadata(bytes) + let metadata: ProjectPreview + try { + metadata = await window.moonSprite.readProjectPreview(record.filePath) + } catch { + metadata = await runProjectPreviewFallback(async () => { + const bytes = await window.moonSprite.readBinary(record.filePath) + const generated = await readProjectGalleryMetadataAsync(bytes) + await window.moonSprite.cacheProjectPreview(record.filePath, generated).catch(() => { + // A cache write failure must not hide an otherwise valid thumbnail. + }) + return generated + }) + } const previewBytes = metadata.preview.slice() cacheProjectPreview(cacheKey, { bytes: previewBytes, width: metadata.width, height: metadata.height, colorMode: metadata.colorMode }) const previewUrl = createPreviewUrl(previewBytes) return { ...record, name: record.fileName, previewUrl, width: metadata.width, height: metadata.height, colorMode: metadata.colorMode } } + const bytes = await window.moonSprite.readBinary(record.filePath) const document = await decodeDocumentFileAsync(bytes, record.filePath) const preview = await exportDocumentImage(document, 100, 'png-auto') const previewBytes = preview.bytes.slice() @@ -181,35 +206,17 @@ export function HomeWorkspace({ onNew, onOpen, onOpenProject, onRestoreRecovery .sort((left, right) => Number(right.pinned) - Number(left.pinned) || right.lastOpened - left.lastOpened) } else records = getRecentProjects() if (generation !== loadGeneration.current) return - const initialCards = records.map((record): ProjectCard => ({ ...record, name: record.fileName })) + const initialCards = records.map((record): ProjectCard => ({ ...record, name: record.fileName, previewLoading: target === 'gallery' || /\.moonsprite$/i.test(record.filePath) })) projectsRef.current = initialCards setProjects(initialCards) setLoading(false) + if (target === 'recent') records = records.filter((record) => /\.moonsprite$/i.test(record.filePath)) let nextIndex = 0 const loadNext = async (): Promise => { while (nextIndex < records.length) { const index = nextIndex nextIndex += 1 const record = records[index] - if (target === 'recent') { - let exists = true - try { - exists = await window.moonSprite.fileExists(record.filePath) - } catch { - // Retain the record when existence cannot be determined. - } - if (generation !== loadGeneration.current) return - if (!exists) { - removeRecentProject(record.filePath) - setProjects((items) => { - const next = items.filter((item) => item.filePath !== record.filePath) - projectsRef.current = next - return next - }) - setMessage(t('home.missingRecentRemovedMessage', { name: record.fileName })) - continue - } - } const card = await readCard(record) if (generation !== loadGeneration.current) { if (card.previewUrl) URL.revokeObjectURL(card.previewUrl) diff --git a/src/renderer/src/components/ImageResizeDialog.tsx b/src/renderer/src/components/ImageResizeDialog.tsx index 479c645..d8db8b4 100644 --- a/src/renderer/src/components/ImageResizeDialog.tsx +++ b/src/renderer/src/components/ImageResizeDialog.tsx @@ -2,7 +2,10 @@ import { useEffect, useState, type FormEvent } from 'react' import type { ImageResizeInterpolation } from '@shared/types' import { ModalShell } from './ModalShell' import { NumberInput } from './NumberInput' +import { FormField } from './FormField' +import { ThemedSelect } from './ThemedSelect' import { useI18n } from './I18nProvider' +import { DialogHeader } from './DialogHeader' import { PixelUtilityIcon } from './PixelUtilityIcon' export function ImageResizeDialog({ open, currentWidth, currentHeight, onClose, onResize }: { @@ -59,28 +62,25 @@ export function ImageResizeDialog({ open, currentWidth, currentHeight, onClose, return
-
-
{t('imageResize.eyebrow')}

{t('imageResize.title')}

- -
+
{t('imageResize.current')}{currentWidth} x {currentHeight} px

{t('imageResize.pixelSize')}

{t(locked ? 'imageResize.locked' : 'imageResize.free')}
- + event.currentTarget.select()} aria-label={t('newDocument.widthAria')} min={1} max={16384} suffix="px" value={width} onValueChange={updateWidth} /> - +

{t('imageResize.scale')}

{t('imageResize.relative')}
- - + +
- +
diff --git a/src/renderer/src/components/LatestReleaseDialog.tsx b/src/renderer/src/components/LatestReleaseDialog.tsx index 040a8f6..f69f9d3 100644 --- a/src/renderer/src/components/LatestReleaseDialog.tsx +++ b/src/renderer/src/components/LatestReleaseDialog.tsx @@ -1,6 +1,6 @@ import { useI18n } from '@/components/I18nProvider' +import { DialogHeader } from '@/components/DialogHeader' import { ModalShell } from '@/components/ModalShell' -import { PixelUtilityIcon } from '@/components/PixelUtilityIcon' import { LATEST_PACKAGED_RELEASE_LABEL } from '@/core/app-meta' import type { TranslationKey } from '@/core/localization' @@ -50,7 +50,7 @@ export function LatestReleaseDialog({ onClose }: { onClose: () => void }) { const { t } = useI18n() return
{ if (event.target === event.currentTarget) onClose() }}> -
MOONSPRITE {latestRelease.version}

{t('latestRelease.title')}

+

{t('latestRelease.version', { version: latestRelease.version })}

{latestRelease.sections.map((section) =>

{t(section.title)}

    {section.items.map((item) =>
  • {t(item)}
  • )}
)}
diff --git a/src/renderer/src/components/LivePreviewToggle.tsx b/src/renderer/src/components/LivePreviewToggle.tsx index 59ec1f7..9e4707f 100644 --- a/src/renderer/src/components/LivePreviewToggle.tsx +++ b/src/renderer/src/components/LivePreviewToggle.tsx @@ -1,5 +1,6 @@ import { PixelUtilityIcon } from './PixelUtilityIcon' import { useI18n } from './I18nProvider' +import { PreferenceToggle } from './PreferenceToggle' interface LivePreviewToggleProps { checked: boolean @@ -11,12 +12,8 @@ interface LivePreviewToggleProps { export function LivePreviewToggle({ checked, onChange, className = '', description, label }: LivePreviewToggleProps) { const { t } = useI18n() - return + } /> } diff --git a/src/renderer/src/components/NewDocumentDialog.test.tsx b/src/renderer/src/components/NewDocumentDialog.test.tsx index 3bf09fd..5451bf8 100644 --- a/src/renderer/src/components/NewDocumentDialog.test.tsx +++ b/src/renderer/src/components/NewDocumentDialog.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { MoonSpriteApi } from '@shared/types' import { getWindowsFileNameError, NewDocumentDialog } from './NewDocumentDialog' @@ -41,4 +41,16 @@ describe('getWindowsFileNameError', () => { expect(readClipboardImageSize).toHaveBeenCalledOnce() expect(readClipboardImage).not.toHaveBeenCalled() }) + + it('keeps drawing recording off by default and submits the selected state', () => { + const onCreate = vi.fn() + render() + + const recording = screen.getByRole('checkbox') + expect(recording).not.toBeChecked() + fireEvent.click(recording) + fireEvent.submit(screen.getByRole('form')) + + expect(onCreate).toHaveBeenCalledWith(expect.any(String), 64, 64, 'rgba', true) + }) }) diff --git a/src/renderer/src/components/NewDocumentDialog.tsx b/src/renderer/src/components/NewDocumentDialog.tsx index f83e923..a544522 100644 --- a/src/renderer/src/components/NewDocumentDialog.tsx +++ b/src/renderer/src/components/NewDocumentDialog.tsx @@ -1,11 +1,15 @@ import { useEffect, useState } from 'react' import type { ColorMode } from '@shared/types' -import { DEFAULT_DOCUMENT_SIZE_PRESETS, type DocumentSizePreset } from '@/core/file-preferences' +import { DEFAULT_DOCUMENT_SIZE_PRESETS, loadEditorPreferences, type DocumentSizePreset } from '@/core/file-preferences' import { AVAILABLE_APP_LOCALES, DEFAULT_APP_LOCALE, translate, type AppLocale } from '@/core/localization' import { useI18n } from './I18nProvider' +import { DialogHeader } from './DialogHeader' import { ModalShell } from './ModalShell' +import { FormField } from './FormField' import { NumberInput } from './NumberInput' -import { PixelUtilityIcon } from './PixelUtilityIcon' +import { SegmentedControl } from './SegmentedControl' +import { TextInput } from './TextInput' +import { PreferenceToggle } from './PreferenceToggle' export function getWindowsFileNameError(value: string, locale: AppLocale = DEFAULT_APP_LOCALE): string | null { const name = value @@ -18,14 +22,19 @@ export function getWindowsFileNameError(value: string, locale: AppLocale = DEFAU return null } -export function NewDocumentDialog({ open, presets = DEFAULT_DOCUMENT_SIZE_PRESETS, onClose, onCreate }: { open: boolean; presets?: readonly DocumentSizePreset[]; onClose: () => void; onCreate: (name: string, width: number, height: number, mode: ColorMode) => void }) { +export function NewDocumentDialog({ open, presets = DEFAULT_DOCUMENT_SIZE_PRESETS, onClose, onCreate }: { open: boolean; presets?: readonly DocumentSizePreset[]; onClose: () => void; onCreate: (name: string, width: number, height: number, mode: ColorMode, recordDrawing: boolean) => void }) { const { locale, t } = useI18n() const [name, setName] = useState(() => t('newDocument.untitled')) const [width, setWidth] = useState(64) const [height, setHeight] = useState(64) const [mode, setMode] = useState('rgba') + const [recordDrawing, setRecordDrawing] = useState(() => loadEditorPreferences().timelapseRecordingEnabled) const [nameError, setNameError] = useState(null) + useEffect(() => { + if (open) setRecordDrawing(loadEditorPreferences().timelapseRecordingEnabled) + }, [open]) + useEffect(() => { setName((current) => AVAILABLE_APP_LOCALES.some((candidate) => current === translate(candidate, 'newDocument.untitled')) ? t('newDocument.untitled') : current) }, [locale, t]) @@ -52,22 +61,20 @@ export function NewDocumentDialog({ open, presets = DEFAULT_DOCUMENT_SIZE_PRESET setNameError(error) return } - onCreate(nextName, width, height, mode) + onCreate(nextName, width, height, mode, recordDrawing) onClose() } return
{ if (event.target === event.currentTarget) onClose() }}> - -
{t('newDocument.eyebrow')}

{t('newDocument.title')}

-
{nameError &&

{nameError}

} + + +
{ setName(event.target.value); setNameError(getWindowsFileNameError(event.target.value, locale)) }} />{nameError &&

{nameError}

}
- - + +
{presets.map((preset) => )}
-
{t('newDocument.colorMode')} - - -
+ +

{t('newDocument.note')}

diff --git a/src/renderer/src/components/NumberInput.tsx b/src/renderer/src/components/NumberInput.tsx index 1daa09c..6176010 100644 --- a/src/renderer/src/components/NumberInput.tsx +++ b/src/renderer/src/components/NumberInput.tsx @@ -9,6 +9,7 @@ interface NumberInputProps extends Omit, ' live?: boolean min?: number max?: number + density?: 'compact' | 'regular' step?: number suffix?: string } @@ -27,7 +28,7 @@ const filterNumericExpression = (source: string): string => { return filtered } -export function NumberInput({ value, onValueChange, live = false, min, max, step = 1, suffix, className = '', onFocus, onBlur, onKeyDown, ...inputProps }: NumberInputProps) { +export function NumberInput({ value, onValueChange, live = false, min, max, density = 'regular', step = 1, suffix, className = '', onFocus, onBlur, onKeyDown, ...inputProps }: NumberInputProps) { const { t } = useI18n() const [draft, setDraft] = useState(String(value)) useLayoutEffect(() => setDraft(String(value)), [value]) @@ -54,7 +55,7 @@ export function NumberInput({ value, onValueChange, live = false, min, max, step if (next !== value) onValueChange(next) } - const control = + const control = updateDraft(event.target.value)} onBlur={(event) => { commit(); onBlur?.(event) }} onKeyDown={(event) => { onKeyDown?.(event); if (event.defaultPrevented || event.key !== 'Enter') return; event.preventDefault(); const form = event.currentTarget.form; commit(); if (form) window.queueMicrotask(() => form.requestSubmit()); else event.currentTarget.blur() }} /> {suffix && } diff --git a/src/renderer/src/components/OpenProgressOverlay.tsx b/src/renderer/src/components/OpenProgressOverlay.tsx new file mode 100644 index 0000000..3b89270 --- /dev/null +++ b/src/renderer/src/components/OpenProgressOverlay.tsx @@ -0,0 +1,32 @@ +import { useSyncExternalStore, type ReactElement } from 'react' +import { createPortal } from 'react-dom' +import { openProgress } from '@/core/open-progress' +import { useI18n } from './I18nProvider' +import { PixelUtilityIcon } from './PixelUtilityIcon' + +export function OpenProgressOverlay(): ReactElement | null { + const { t } = useI18n() + const snapshot = useSyncExternalStore(openProgress.subscribe, openProgress.getSnapshot, openProgress.getSnapshot) + if (snapshot.phase === 'hidden') return null + const complete = snapshot.phase === 'complete' + + return createPortal( +
+
+
+
+
+ {!complete && } +
+
+ {t(complete ? 'workspace.open.progressDone' : 'workspace.open.decoding')} +
+
{t(complete ? 'app.progress.complete' : 'app.progress.processing')}
+
+
+
, + document.body + ) +} diff --git a/src/renderer/src/components/OutlineDialog.tsx b/src/renderer/src/components/OutlineDialog.tsx index fdd8dd4..5d7f116 100644 --- a/src/renderer/src/components/OutlineDialog.tsx +++ b/src/renderer/src/components/OutlineDialog.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react' import type { OutlineDirection, OutlineDirections, OutlineKernel, OutlinePosition, RgbaColor } from '@shared/types' import { ColorPicker } from '@/components/ColorPicker' +import { DialogHeader } from '@/components/DialogHeader' import { useI18n } from '@/components/I18nProvider' import { ModalShell } from '@/components/ModalShell' -import { NumberInput } from '@/components/NumberInput' -import { PixelUtilityIcon } from '@/components/PixelUtilityIcon' +import { RangeField } from '@/components/RangeField' +import { SegmentedControl } from '@/components/SegmentedControl' import { LivePreviewToggle } from '@/components/LivePreviewToggle' import { defaultOutlineSettings, normalizeOutlineSettings } from '@/core/outline-settings' import { useWorkspace, type DocumentSession } from '@/store/workspace' @@ -110,12 +111,12 @@ export function OutlineDialog({ open, session, onClose }: { open: boolean; sessi event.preventDefault() submit() }}> -
OUTLINE

{t('outline.title')}

+
{t('outline.color')}
-
+
{t('outline.settings')} -
{t('outline.position')}
+
{t('outline.position')}
{t('outline.quickShapes')}
{quickShapes.map((shape) => )}
{t('outline.pixelDirections')}
{directionGrid.map((direction) => { diff --git a/src/renderer/src/components/PreferenceToggle.tsx b/src/renderer/src/components/PreferenceToggle.tsx new file mode 100644 index 0000000..b1675ca --- /dev/null +++ b/src/renderer/src/components/PreferenceToggle.tsx @@ -0,0 +1,19 @@ +import type { InputHTMLAttributes, ReactNode } from 'react' +import { Tooltip } from './Tooltip' + +interface PreferenceToggleProps extends Omit, 'type' | 'checked' | 'onChange'> { + checked: boolean + copyClassName?: string + label: ReactNode + onChange: (checked: boolean) => void + tooltip?: ReactNode +} + +export function PreferenceToggle({ checked, className = '', copyClassName = '', label, onChange, tooltip, ...inputProps }: PreferenceToggleProps) { + const copy = {label} + return +} diff --git a/src/renderer/src/components/ProjectInfoDialog.tsx b/src/renderer/src/components/ProjectInfoDialog.tsx index cde1acf..ee664eb 100644 --- a/src/renderer/src/components/ProjectInfoDialog.tsx +++ b/src/renderer/src/components/ProjectInfoDialog.tsx @@ -1,8 +1,8 @@ import { Brush, Film, Layers3, Maximize2 } from 'lucide-react' import type { SpriteDocument } from '@shared/types' +import { DialogHeader } from './DialogHeader' import { ModalShell } from './ModalShell' import { useI18n } from './I18nProvider' -import { PixelUtilityIcon } from './PixelUtilityIcon' interface ProjectInfoDialogProps { document: SpriteDocument @@ -60,7 +60,7 @@ export function ProjectInfoDialog({ document, onClose }: ProjectInfoDialogProps) return
{ if (event.target === event.currentTarget) onClose() }}> -

{t('projectInfo.title')}

+
{highlights.map(({ label, value, suffix, icon: Icon }) =>
)} diff --git a/src/renderer/src/components/RangeField.tsx b/src/renderer/src/components/RangeField.tsx new file mode 100644 index 0000000..c4e5784 --- /dev/null +++ b/src/renderer/src/components/RangeField.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' +import { NumberInput } from './NumberInput' + +interface RangeFieldProps { + autoFocus?: boolean + className?: string + density?: 'compact' | 'regular' + disabled?: boolean + label: ReactNode + max: number + min: number + onChange: (value: number) => void + step?: number + suffix?: string + value: number +} + +export function RangeField({ autoFocus = false, className = '', density = 'regular', disabled = false, label, max, min, onChange, step = 1, suffix, value }: RangeFieldProps) { + return +} diff --git a/src/renderer/src/components/SaveProgressOverlay.tsx b/src/renderer/src/components/SaveProgressOverlay.tsx new file mode 100644 index 0000000..6bea7e9 --- /dev/null +++ b/src/renderer/src/components/SaveProgressOverlay.tsx @@ -0,0 +1,34 @@ +import { useSyncExternalStore, type ReactElement } from 'react' +import { createPortal } from 'react-dom' +import { saveProgress } from '@/core/save-progress' +import { useI18n } from './I18nProvider' +import { PixelUtilityIcon } from './PixelUtilityIcon' + +export function SaveProgressOverlay(): ReactElement | null { + const { t } = useI18n() + const snapshot = useSyncExternalStore(saveProgress.subscribe, saveProgress.getSnapshot, saveProgress.getSnapshot) + if (snapshot.phase === 'hidden') return null + const complete = snapshot.phase === 'complete' + const saveAs = snapshot.kind === 'saveAs' + const title = t(saveAs ? 'workspace.saveAs.progressTitle' : 'workspace.save.progressTitle') + + return createPortal( +
+
+
+
+
+ {!complete && } +
+
+ {t(complete ? (saveAs ? 'workspace.saveAs.progressDone' : 'workspace.save.progressDone') : 'workspace.save.encodingProject')} +
+
{t(complete ? 'app.progress.complete' : 'app.progress.processing')}
+
+
+
, + document.body + ) +} diff --git a/src/renderer/src/components/SegmentedControl.tsx b/src/renderer/src/components/SegmentedControl.tsx new file mode 100644 index 0000000..06b778c --- /dev/null +++ b/src/renderer/src/components/SegmentedControl.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' +import { Tooltip } from './Tooltip' + +export interface SegmentedControlOption { + description?: ReactNode + disabled?: boolean + label: ReactNode + value: T +} + +interface SegmentedControlProps { + className?: string + label: string + onChange: (value: T) => void + options: Array> + value: T +} + +export function SegmentedControl({ className = '', label, onChange, options, value }: SegmentedControlProps) { + return
+ {options.map((option) => { + const button = + return option.description ? {button} : button + })} +
+} diff --git a/src/renderer/src/components/SettingsNavigation.tsx b/src/renderer/src/components/SettingsNavigation.tsx new file mode 100644 index 0000000..0c497b9 --- /dev/null +++ b/src/renderer/src/components/SettingsNavigation.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react' + +export interface SettingsNavigationItem { + label: ReactNode + value: T +} + +interface SettingsNavigationProps { + className?: string + label: string + items: Array> + onChange: (value: T) => void + value: T +} + +export function SettingsNavigation({ className = '', items, label, onChange, value }: SettingsNavigationProps) { + return +} diff --git a/src/renderer/src/components/SettingsSectionHeader.tsx b/src/renderer/src/components/SettingsSectionHeader.tsx new file mode 100644 index 0000000..929138a --- /dev/null +++ b/src/renderer/src/components/SettingsSectionHeader.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +interface SettingsSectionHeaderProps { + actions?: ReactNode + className?: string + title: ReactNode +} + +export function SettingsSectionHeader({ actions, className = '', title }: SettingsSectionHeaderProps) { + return
+ {title} + {actions &&
{actions}
} +
+} diff --git a/src/renderer/src/components/TextInput.tsx b/src/renderer/src/components/TextInput.tsx new file mode 100644 index 0000000..ba0ed04 --- /dev/null +++ b/src/renderer/src/components/TextInput.tsx @@ -0,0 +1,9 @@ +import type { InputHTMLAttributes } from 'react' + +interface TextInputProps extends InputHTMLAttributes { + density?: 'compact' | 'regular' +} + +export function TextInput({ className = '', density = 'regular', type = 'text', ...props }: TextInputProps) { + return +} diff --git a/src/renderer/src/components/ThemedSelect.tsx b/src/renderer/src/components/ThemedSelect.tsx index cc8273d..0b7a5a9 100644 --- a/src/renderer/src/components/ThemedSelect.tsx +++ b/src/renderer/src/components/ThemedSelect.tsx @@ -14,12 +14,13 @@ export interface ThemedSelectOption { description?: string } -export function ThemedSelect({ value, groups, label, onChange, disabled = false, renderSelected, renderOption, showCheck = true, showOptionTooltips = true, popoverClassName = '', popoverWidth, preserveAnimationSelection = false }: { +export function ThemedSelect({ value, groups, label, onChange, disabled = false, density = 'regular', renderSelected, renderOption, showCheck = true, showOptionTooltips = true, popoverClassName = '', popoverWidth, preserveAnimationSelection = false }: { value: T groups: Array> label: string onChange: (value: T) => void disabled?: boolean + density?: 'compact' | 'regular' renderSelected?: (option: ThemedSelectOption) => ReactNode renderOption?: (option: ThemedSelectOption) => ReactNode showCheck?: boolean @@ -74,7 +75,7 @@ export function ThemedSelect({ value, groups, label, onChange, select(options[(current + direction + options.length) % options.length].value) } - return + return +
@@ -123,14 +125,14 @@ export function TimelapseDialog({ settings, onChange, onClear, onExport, onClose {settings.snapshots.length === 0 ? '0 / 0' : `${previewFrame + 1} / ${settings.snapshots.length}`}
- + onChange({ enabled })} />
- - + value={settings.quality} groups={[{ label: t('timelapse.quality'), options: qualityOptions }]} label={t('timelapse.quality')} onChange={(quality) => onChange({ quality })} /> + value={format} groups={[{ label: t('timelapse.videoFormat'), options: [{ value: 'mp4', label: 'MP4' }, { value: 'webm', label: 'WebM' }] }]} label={t('timelapse.videoFormat')} onChange={setFormat} />
- - {exportMode === 'duration' ? : } + value={exportMode} groups={[{ label: t('timelapse.exportMode'), options: [{ value: 'duration', label: t('timelapse.modeDuration'), description: t('timelapse.modeDurationHint') }, { value: 'speed', label: t('timelapse.modeSpeed'), description: t('timelapse.modeSpeedHint') }] }]} label={t('timelapse.exportMode')} onChange={setExportMode} /> + {exportMode === 'duration' ? : onChange({ speed })} />}
{t('timelapse.outputSize')}{outputWidth > 0 ? `${outputWidth} x ${outputHeight}` : '-'}
diff --git a/src/renderer/src/components/app/AppMenuBar.tsx b/src/renderer/src/components/app/AppMenuBar.tsx index 08bbb74..0cfc18c 100644 --- a/src/renderer/src/components/app/AppMenuBar.tsx +++ b/src/renderer/src/components/app/AppMenuBar.tsx @@ -27,6 +27,7 @@ interface AppMenuBarProps { shortcutFor: (id: string) => string homeOpen: boolean panelVisibility: Record + timelineHidden: boolean toolRailSide: ToolRailSide advancedModeActive: boolean recentFiles: RecentProject[] @@ -49,6 +50,7 @@ interface AppMenuBarProps { onOpenGridSettings: () => void onToggleMirror: (axis: 'horizontal' | 'vertical') => void onTogglePanel: (id: WorkspacePanelId) => void + onToggleTimeline: () => void onToolRailSideChange: (side: ToolRailSide) => void onCycleAdvancedMode: () => void onOpenComponentLibrary: () => void @@ -63,6 +65,7 @@ export function AppMenuBar({ shortcutFor, homeOpen, panelVisibility, + timelineHidden, toolRailSide, advancedModeActive, recentFiles, @@ -85,6 +88,7 @@ export function AppMenuBar({ onOpenGridSettings, onToggleMirror, onTogglePanel, + onToggleTimeline, onToolRailSideChange, onCycleAdvancedMode, onOpenComponentLibrary, @@ -123,25 +127,25 @@ export function AppMenuBar({ return
diff --git a/src/renderer/src/components/app/EditorToolOptions.tsx b/src/renderer/src/components/app/EditorToolOptions.tsx index 2e6de39..361011e 100644 --- a/src/renderer/src/components/app/EditorToolOptions.tsx +++ b/src/renderer/src/components/app/EditorToolOptions.tsx @@ -4,8 +4,13 @@ import { ArrowLeftRight } from 'lucide-react' import type { BrushPaintMode, GradientDither, ImageBrush, ImageBrushSettings, ProceduralBrushId, ProceduralBrushSettings, RgbaColor, SelectionMode } from '@shared/types' import { NumberInput } from '@/components/NumberInput' import { ColorValueControl } from '@/components/ColorValueControl' +import { CheckboxField } from '@/components/CheckboxField' +import { FormField } from '@/components/FormField' import { PerformanceProfiler } from '@/components/PerformanceProfiler' +import { RangeField } from '@/components/RangeField' +import { SegmentedControl } from '@/components/SegmentedControl' import { ThemedSelect } from '@/components/ThemedSelect' +import { TextInput } from '@/components/TextInput' import { Tooltip } from '@/components/Tooltip' import { useI18n } from '@/components/I18nProvider' import { toolOptionsRenderKey } from '@/core/app-render-keys' @@ -19,8 +24,7 @@ import { getBrushDynamicsTelemetry, subscribeBrushDynamicsTelemetry, type BrushD import { useWorkspace } from '@/store/workspace' import { useBrushLibrary } from './useBrushLibrary' import { PixelDownIcon as ChevronDown, PixelUtilityIcon } from '@/components/PixelUtilityIcon' -import { PixelCheckbox } from '@/components/PixelCheckbox' -import { PixelAssetIcon, PixelShapeIcon, activeToolPresentation, selectionModes, temporarySelectionModeForModifiers } from './editor-tools' +import { PixelAssetIcon, PixelShapeIcon, selectionModes, temporarySelectionModeForModifiers } from './editor-tools' import { SymmetryControls } from './SymmetryControls' function GrayscaleBrushThumbnail({ brush }: { brush: ImageBrush }) { @@ -201,8 +205,8 @@ function ProceduralBrushControls({ brushId, settings, onChange }: { return <>
{proceduralPresets[brushId].map((preset) => )}
- {proceduralControls[brushId].map((control) => )} - + {proceduralControls[brushId].map((control) => onChange({ [control.key]: value })} />)} +
onChange({ seed })} />
} @@ -211,11 +215,11 @@ function BrushOutputControls({ settings, onChange }: { settings: ImageBrushSetti const { t } = useI18n() return <>
-
+ onChange({ mode })} />
- - - {settings.mode === 'threshold' && } + onChange({ blackPoint })} /> + onChange({ whitePoint })} /> + {settings.mode === 'threshold' && onChange({ threshold })} />}
} @@ -230,7 +234,7 @@ function ToleranceControl({ value, open, label, inputLabel, sliderLabel, onOpen, onChange: (value: number) => void }) { return
- + {open &&
onChange(Number(event.target.value))} /> {value} @@ -245,22 +249,6 @@ const pressureSensorBounds: Record void -}) { - return -} - type BrushDynamicsRangeEndpoint = 'min' | 'max' export const nearestBrushDynamicsRangeEndpoint = ( @@ -460,34 +448,35 @@ export function BrushDynamicsSettingsPanel({ settings, tool, intrinsicSize, brus
- + />
}
{t('toolOptions.pressureOutputRange')} - onChange(activeEffect!, { outputMin })} /> - onChange(activeEffect!, { outputMax })} /> + onChange(activeEffect!, { outputMin })} /> + onChange(activeEffect!, { outputMax })} />
{t('toolOptions.pressureSensorRange')}{t('toolOptions.pressureSensorLive')}: {liveSensorText}
onChange(activeEffect!, endpoint === 'min' ? { inputMin: value } : { inputMax: value })} />
- - + onChange(activeEffect!, { inputMin })} /> + onChange(activeEffect!, { inputMax })} />
-
{t('toolOptions.pressureCurve')}
{curveOptions.map((option) => )}
-
{t('toolOptions.pressureDirection')}
{directionOptions.map((option) => )}
+
{t('toolOptions.pressureCurve')} onChange(activeEffect!, { curve })} />
+
{t('toolOptions.pressureDirection')} onChange(activeEffect!, { direction })} />
:

{t('toolOptions.pressureSelectMapping')}

} @@ -657,7 +646,6 @@ export const EditorToolOptions = memo(function EditorToolOptions({ onOpenColorRe const gradientDither = session.gradientDither ?? 'none' const isBrushTool = session.tool === 'pencil' || session.tool === 'eraser' || (session.tool === 'fill' && fillKind === 'bucket') const supportsSymmetry = session.tool === 'pencil' || session.tool === 'eraser' || session.tool === 'selection' || session.tool === 'shape' || (session.tool === 'fill' && fillKind === 'bucket') - const presentation = activeToolPresentation(session.tool, session.selectionKind, session.shapeKind, locale, fillKind) const selectionModeItems = selectionModes(locale) const brushPaintModeGroups = [{ label: t('toolOptions.brushMode'), @@ -675,11 +663,10 @@ export const EditorToolOptions = memo(function EditorToolOptions({ onOpenColorRe window.dispatchEvent(new Event('moonsprite:preferences-changed')) } return
- {presentation.label} {session.tool === 'eyedropper' && <>
- - + +
@@ -728,18 +715,18 @@ export const EditorToolOptions = memo(function EditorToolOptions({ onOpenColorRe
- {brushOutputOpen &&
{session.proceduralAntialias && }
} + {brushOutputOpen &&
{session.proceduralAntialias && }
}
: } } - {session.brushImageTemporary &&
setBrushSaveName(event.target.value)} />
} + {session.brushImageTemporary &&
setBrushSaveName(event.target.value)} />
} : } }
- {!session.brushImage?.intrinsicSize &&
setBrushSizeFlyoutOpen(true)}> setBrushSizeFlyoutOpen(true)} />{brushSizeFlyoutOpen &&
workspace.setBrushSize(Number(event.target.value))} />{session.brushSize}px
}
} - {session.brushImage?.intrinsicSize && value={session.brushPaintMode} groups={brushPaintModeGroups} label={t('toolOptions.brushMode')} onChange={workspace.setBrushPaintMode} />} - {session.tool === 'pencil' && } - {(session.tool === 'pencil' || session.tool === 'eraser') && } + {!session.brushImage?.intrinsicSize &&
setBrushSizeFlyoutOpen(true)}> setBrushSizeFlyoutOpen(true)} />{brushSizeFlyoutOpen &&
workspace.setBrushSize(Number(event.target.value))} />{session.brushSize}px
}
} + {session.brushImage?.intrinsicSize && density="compact" value={session.brushPaintMode} groups={brushPaintModeGroups} label={t('toolOptions.brushMode')} onChange={workspace.setBrushPaintMode} />} + {session.tool === 'pencil' && } + {(session.tool === 'pencil' || session.tool === 'eraser') && } {(session.tool === 'pencil' || session.tool === 'eraser') &&
{pressureFlyoutOpen && pressurePopoverPosition && createPortal(
, document.body)} @@ -747,17 +734,18 @@ export const EditorToolOptions = memo(function EditorToolOptions({ onOpenColorRe } {session.tool === 'selection' && <>
{selectionModeItems.map((mode) => )}
- {session.selectionKind === 'magic' && <> setToleranceFlyoutOpen('wand')} onChange={workspace.setWandTolerance} />} + {session.selectionKind === 'magic' && <> setToleranceFlyoutOpen('wand')} onChange={workspace.setWandTolerance} />} } - {session.tool === 'shape' &&
{session.shapeRatio !== null &&
workspace.setShapeRatio({ ...session.shapeRatio!, width })} />: workspace.setShapeRatio({ ...session.shapeRatio!, height })} />
}
} - {session.tool === 'fill' && fillKind === 'bucket' && <> setToleranceFlyoutOpen('fill')} onChange={workspace.setFillTolerance} />} + {session.tool === 'shape' &&
workspace.setShapeRatio(checked ? { width: 1, height: 1 } : null)} />{session.shapeRatio !== null &&
workspace.setShapeRatio({ ...session.shapeRatio!, width })} />: workspace.setShapeRatio({ ...session.shapeRatio!, height })} />
}
} + {session.tool === 'fill' && fillKind === 'bucket' && <> setToleranceFlyoutOpen('fill')} onChange={workspace.setFillTolerance} /> workspace.setFillMode(checked ? 'contiguous' : 'global')} />} {session.tool === 'fill' && fillKind === 'gradient' && <> setToleranceFlyoutOpen('gradient')} onChange={workspace.setGradientTolerance} /> - + value={gradientDither} groups={gradientDitherGroups} label={t('toolOptions.gradientDither')} + density="compact" onChange={workspace.setGradientDither} showCheck={false} showOptionTooltips={false} @@ -767,8 +755,8 @@ export const EditorToolOptions = memo(function EditorToolOptions({ onOpenColorRe /> } {supportsSymmetry && } - {session.tool === 'move' && } - {session.tool === 'rotate' &&
} + {session.tool === 'move' && } + {session.tool === 'rotate' &&
workspace.setView({ rotation: ((rotation % 360) + 360) % 360 })} />
}
diff --git a/src/renderer/src/components/app/SymmetryControls.tsx b/src/renderer/src/components/app/SymmetryControls.tsx index bf3c722..62b3659 100644 --- a/src/renderer/src/components/app/SymmetryControls.tsx +++ b/src/renderer/src/components/app/SymmetryControls.tsx @@ -2,6 +2,7 @@ import { createPortal } from 'react-dom' import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react' import type { RgbaColor } from '@shared/types' import { ColorValueControl } from '@/components/ColorValueControl' +import { FormField } from '@/components/FormField' import { NumberInput } from '@/components/NumberInput' import { MAX_SYMMETRY_AXIS_THICKNESS, MIN_SYMMETRY_AXIS_THICKNESS, loadEditorPreferences, saveEditorPreferences, type SymmetryAxisPreferences } from '@/core/file-preferences' import type { SymmetryAxes, SymmetryAxis } from '@/core/symmetry' @@ -143,15 +144,14 @@ export function SymmetryControls({ axes, onAxisToggle, onResetCenter }: Symmetry {t('toolOptions.adjustSymmetryAxis')} {settingsOpen &&
- updatePreferences({ color })} label={t('toolOptions.symmetryAxisColor')} roleLabel={t('toolOptions.symmetryAxisColor')} /> - + updatePreferences({ color })} label={t('toolOptions.symmetryAxisColor')} roleLabel={t('toolOptions.symmetryAxisColor')} /> +
setThicknessSliderOpen(true)}> updatePreferences({ thickness })} onFocus={() => setThicknessSliderOpen(true)} />{thicknessSliderOpen &&
updatePreferences({ thickness: Number(event.target.value) })} />{preferences.thickness}px
}
}
, document.body ) : null return
- {t('toolOptions.symmetry')}
{SYMMETRY_AXES.map((item) => { const selected = axes[item.axis] diff --git a/src/renderer/src/components/canvas-composite-cache.test.ts b/src/renderer/src/components/canvas-composite-cache.test.ts index 0779784..9eb4f60 100644 --- a/src/renderer/src/components/canvas-composite-cache.test.ts +++ b/src/renderer/src/components/canvas-composite-cache.test.ts @@ -2,15 +2,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createDocument, createLayerMask, writeLayerColor } from '@/core/document' import { ensureAnimationDocument } from '@/core/animation' import { CanvasCompositeCache, shouldCacheFullCompositeSurface } from './canvas-composite-cache' +import { canPrepareInitialDocumentComposite, registerInitialDocumentComposite, registerPendingInitialDocumentComposite } from '@/core/initial-document-composite' class MockOffscreenCanvas { + static instances: MockOffscreenCanvas[] = [] readonly context = { putImageData: vi.fn(), drawImage: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + globalAlpha: 1, + globalCompositeOperation: 'source-over', imageSmoothingEnabled: false, imageSmoothingQuality: 'low' } - constructor(public width: number, public height: number) {} + constructor(public width: number, public height: number) { MockOffscreenCanvas.instances.push(this) } getContext() { return this.context } } @@ -20,6 +26,7 @@ class MockImageData { describe('CanvasCompositeCache', () => { beforeEach(() => { + MockOffscreenCanvas.instances = [] vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas) vi.stubGlobal('ImageData', MockImageData) }) @@ -28,12 +35,184 @@ describe('CanvasCompositeCache', () => { vi.unstubAllGlobals() }) + it('uses the exact composite prepared during document decode for the first surface', () => { + const document = createDocument('initial composite', 2, 1, 'rgba') + const pixels = new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 255, 255]) + registerInitialDocumentComposite(document, pixels) + const context = { + save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), + translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: true + } + + new CanvasCompositeCache().draw({ + context: context as never, + document, + view: { zoom: 1, panX: 0, panY: 0, rotation: 0, mirrored: false, mirroredVertical: false, showGrid: false, relativeLuminance: false }, + originX: 0, + originY: 0, + canvasWidth: 2, + canvasHeight: 1, + fromX: 0, + fromY: 0, + toX: 2, + toY: 1, + revision: 0, + contentRevision: 0 + }) + + expect(MockOffscreenCanvas.instances).toHaveLength(1) + expect(MockOffscreenCanvas.instances[0].context.putImageData).toHaveBeenCalledWith(expect.objectContaining({ data: pixels }), 0, 0) + expect(context.drawImage).toHaveBeenCalled() + + const secondContext = { ...context, drawImage: vi.fn() } + new CanvasCompositeCache().draw({ + context: secondContext as never, + document, + view: { zoom: 1, panX: 0, panY: 0, rotation: 0, mirrored: false, mirroredVertical: false, showGrid: false, relativeLuminance: false }, + originX: 0, originY: 0, canvasWidth: 2, canvasHeight: 1, + fromX: 0, fromY: 0, toX: 2, toY: 1, revision: 0, contentRevision: 0 + }) + expect(MockOffscreenCanvas.instances).toHaveLength(1) + expect(secondContext.drawImage.mock.calls[0][0]).toBe(MockOffscreenCanvas.instances[0]) + }) + + it('prepares initial composites only within the exact surface budget', () => { + expect(canPrepareInitialDocumentComposite(4596, 1767)).toBe(true) + expect(canPrepareInitialDocumentComposite(9000, 1767)).toBe(false) + expect(canPrepareInitialDocumentComposite(8192, 8192)).toBe(false) + }) + it('keeps a 4200 by 1800 document in the reusable full-surface cache', () => { expect(shouldCacheFullCompositeSurface(4200, 1800)).toBe(true) expect(shouldCacheFullCompositeSurface(9000, 1800)).toBe(false) expect(shouldCacheFullCompositeSurface(4200, 1800, 16 * 1024 * 1024)).toBe(false) }) + it('draws only the exact visible region while a large initial surface is still pending', () => { + const context = { + save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), + translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: true + } + const document = createDocument('pending initial composite', 1536, 512, 'rgba') + registerPendingInitialDocumentComposite(document, new Promise(() => undefined)) + + new CanvasCompositeCache(16 * 1024 * 1024).draw({ + context: context as never, + document, + view: { zoom: 1, panX: 0, panY: 0, rotation: 0, mirrored: false, mirroredVertical: false, showGrid: false, relativeLuminance: false }, + originX: 0, + originY: 0, + canvasWidth: 1536, + canvasHeight: 512, + fromX: 512, + fromY: 0, + toX: 768, + toY: 512, + revision: 0, + contentRevision: 0 + }) + + expect(MockOffscreenCanvas.instances.at(-1)).toMatchObject({ width: 256, height: 512 }) + }) + + it('reuses one exact full-resolution surface while a large-canvas viewport pans', () => { + const context = { + save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), + translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: true + } + const document = createDocument('tiled viewport', 1536, 512, 'rgba') + const cache = new CanvasCompositeCache(16 * 1024 * 1024) + const draw = (fromX: number, toX: number) => cache.draw({ + context: context as never, + document, + view: { zoom: 1, panX: 0, panY: 0, rotation: 0, mirrored: false, mirroredVertical: false, showGrid: false, relativeLuminance: false }, + originX: -fromX, + originY: 0, + canvasWidth: 512, + canvasHeight: 512, + fromX, + fromY: 0, + toX, + toY: 512, + revision: 1, + contentRevision: 1 + }) + + draw(0, 512) + const surface = context.drawImage.mock.calls.at(-1)?.[0] + draw(256, 768) + expect(context.drawImage.mock.calls.at(-1)?.[0]).toBe(surface) + }) + + it('patches only the dirty region of a large exact surface', () => { + const context = { + save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), + translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: true + } + const document = createDocument('tiled dirty region', 1536, 512, 'rgba') + const cache = new CanvasCompositeCache(16 * 1024 * 1024) + const draw = (contentRevision: number, contentInvalidation: Parameters[0]['contentInvalidation']) => cache.draw({ + context: context as never, + document, + view: { zoom: 1, panX: 0, panY: 0, rotation: 0, mirrored: false, mirroredVertical: false, showGrid: false, relativeLuminance: false }, + originX: 0, + originY: 0, + canvasWidth: 1024, + canvasHeight: 512, + fromX: 0, + fromY: 0, + toX: 1024, + toY: 512, + revision: contentRevision, + contentRevision, + contentInvalidation + }) + + draw(1, null) + const surface = context.drawImage.mock.calls.at(-1)?.[0] as MockOffscreenCanvas + draw(2, { kind: 'region', fromRevision: 1, revision: 2, rect: { x: 700, y: 20, width: 4, height: 4 } }) + expect(context.drawImage.mock.calls.at(-1)?.[0]).toBe(surface) + expect(surface.context.putImageData).toHaveBeenLastCalledWith(expect.objectContaining({ width: 4, height: 4 }), 700, 20) + }) + + it('repairs only visible pixels after a full invalidation and repairs newly revealed pixels before drawing', () => { + const context = { + save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), + translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: true + } + const document = createDocument('deferred full invalidation', 1536, 512, 'rgba') + const cache = new CanvasCompositeCache(16 * 1024 * 1024) + const draw = (fromX: number, toX: number, contentRevision: number, contentInvalidation: Parameters[0]['contentInvalidation']) => cache.draw({ + context: context as never, + document, + view: { zoom: 128, panX: 0, panY: 0, rotation: 0, mirrored: false, mirroredVertical: false, showGrid: false, relativeLuminance: false }, + originX: -fromX * 128, + originY: 0, + canvasWidth: document.width * 128, + canvasHeight: document.height * 128, + fromX, + fromY: 20, + toX, + toY: 24, + revision: contentRevision, + contentRevision, + contentInvalidation + }) + + draw(700, 704, 1, null) + const surface = context.drawImage.mock.calls.at(-1)?.[0] as MockOffscreenCanvas + const initialSurfaceCount = MockOffscreenCanvas.instances.length + draw(700, 704, 2, { kind: 'full', fromRevision: 1, revision: 2 }) + + expect(context.drawImage.mock.calls.at(-1)?.[0]).toBe(surface) + expect(MockOffscreenCanvas.instances).toHaveLength(initialSurfaceCount) + expect(surface.context.putImageData).toHaveBeenLastCalledWith(expect.objectContaining({ width: 4, height: 4 }), 700, 20) + + draw(100, 104, 2, null) + expect(context.drawImage.mock.calls.at(-1)?.[0]).toBe(surface) + expect(surface.context.putImageData).toHaveBeenLastCalledWith(expect.objectContaining({ width: 4, height: 4 }), 100, 20) + }) + it('draws an active layer mask as an isolated white-backed grayscale surface', () => { const context = { save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), @@ -128,8 +307,7 @@ describe('CanvasCompositeCache', () => { fromY: 0, toX: 256, toY: 256, - revision: 1, - activeDrag: 'draw' + revision: 1 }) expect(context.drawImage).toHaveBeenCalledTimes(1) expect(context.drawImage).toHaveBeenCalledWith(expect.any(MockOffscreenCanvas), 0, 0, 256, 256, 10, 20, 384, 384) @@ -158,8 +336,7 @@ describe('CanvasCompositeCache', () => { fromY: 0, toX: 64, toY: 64, - revision: 1, - activeDrag + revision: 1 }) return context } @@ -173,7 +350,7 @@ describe('CanvasCompositeCache', () => { expect(idle.drawImage).toHaveBeenCalledWith(expect.any(MockOffscreenCanvas), 0, 0, 64, 64, 12.25, 8.75, 200.32, 200.32) }) - it('reuses the downscaled surface while panning below 100 percent zoom', () => { + it('draws below 100 percent zoom directly from the exact source surface', () => { const context = { save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: true, @@ -199,19 +376,17 @@ describe('CanvasCompositeCache', () => { }) draw(10) - const scaledSurface = context.drawImage.mock.calls.at(-1)?.[0] as MockOffscreenCanvas - expect(scaledSurface.width).toBe(500) - expect(scaledSurface.height).toBe(500) - expect(scaledSurface.context.drawImage).toHaveBeenCalledTimes(1) - expect(context.drawImage).toHaveBeenLastCalledWith(scaledSurface, 10, 0, 500, 500) + const exactSurface = context.drawImage.mock.calls.at(-1)?.[0] as MockOffscreenCanvas + expect(exactSurface.width).toBe(4000) + expect(exactSurface.height).toBe(4000) + expect(context.drawImage).toHaveBeenLastCalledWith(exactSurface, 0, 0, 4000, 4000, 10, 0, 500, 500) draw(24) - expect(context.drawImage.mock.calls.at(-1)?.[0]).toBe(scaledSurface) - expect(scaledSurface.context.drawImage).toHaveBeenCalledTimes(1) - expect(context.drawImage).toHaveBeenLastCalledWith(scaledSurface, 24, 0, 500, 500) + expect(context.drawImage.mock.calls.at(-1)?.[0]).toBe(exactSurface) + expect(context.drawImage).toHaveBeenLastCalledWith(exactSurface, 0, 0, 4000, 4000, 24, 0, 500, 500) }) - it('rebuilds the downscaled surface after a dirty-region update', () => { + it('keeps the exact source surface after a dirty-region update below 100 percent zoom', () => { const context = { save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: true, @@ -237,10 +412,11 @@ describe('CanvasCompositeCache', () => { } cache.draw(options) - const firstScaledSurface = context.drawImage.mock.calls.at(-1)?.[0] + const exactSurface = context.drawImage.mock.calls.at(-1)?.[0] cache.invalidateRect({ x: 2, y: 3, width: 1, height: 1 }, 64, 64) cache.draw(options) - expect(context.drawImage.mock.calls.at(-1)?.[0]).not.toBe(firstScaledSurface) + expect(context.drawImage.mock.calls.at(-1)?.[0]).toBe(exactSurface) + expect((exactSurface as MockOffscreenCanvas).context.putImageData).toHaveBeenLastCalledWith(expect.objectContaining({ width: 1, height: 1 }), 2, 3) }) it('patches a dirty rectangle without splitting the final display surface', () => { @@ -340,7 +516,7 @@ describe('CanvasCompositeCache', () => { expect(context.drawImage.mock.calls.at(-1)?.[0]).not.toBe(firstSurface) }) - it('patches a pixel-edit region across content revisions and rebuilds for full changes', () => { + it('patches a pixel-edit region across content revisions and reuses the exact surface for full changes', () => { const context = { save: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), rect: vi.fn(), clip: vi.fn(), translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: true @@ -374,6 +550,7 @@ describe('CanvasCompositeCache', () => { expect(firstSurface.context.putImageData).toHaveBeenLastCalledWith(expect.objectContaining({ width: 3, height: 2 }), 8, 9) draw(3, { kind: 'full', fromRevision: 2, revision: 3 }) - expect(context.drawImage.mock.calls.at(-1)?.[0]).not.toBe(firstSurface) + expect(context.drawImage.mock.calls.at(-1)?.[0]).toBe(firstSurface) + expect(firstSurface.context.putImageData).toHaveBeenLastCalledWith(expect.objectContaining({ width: 64, height: 64 }), 0, 0) }) }) diff --git a/src/renderer/src/components/canvas-composite-cache.ts b/src/renderer/src/components/canvas-composite-cache.ts index df58884..3fd2478 100644 --- a/src/renderer/src/components/canvas-composite-cache.ts +++ b/src/renderer/src/components/canvas-composite-cache.ts @@ -1,19 +1,13 @@ import type { LayerMask, SelectionRect, SpriteDocument, ViewState } from '@shared/types' import { compositeRegion, DocumentCompositeCache, renderLayerMaskRegion } from '@/core/document' import { applyRelativeLuminance } from '@/core/raster' -import type { CanvasDragState } from '@/core/canvas-input' +import { initialDocumentCompositePending, initialDocumentCompositeSurface } from '@/core/initial-document-composite' import type { RasterContext2D } from './canvas-selection-renderer' interface CompositeSurface { canvas: OffscreenCanvas revision: number - scaled?: ScaledCompositeSurface -} - -interface ScaledCompositeSurface { - canvas: OffscreenCanvas - zoom: number - imageSmoothingEnabled: boolean + pendingDirtyRects?: SelectionRect[] } interface CompositeRegionSurface extends CompositeSurface { @@ -46,17 +40,47 @@ interface DrawCompositeOptions { } | null frameId?: string isolatedLayerMask?: LayerMask - activeDrag?: CanvasDragState['kind'] imageSmoothingEnabled?: boolean } const MAX_SURFACE_DIMENSION = 8192 const MAX_CACHED_FRAMES = 32 const DEFAULT_MAX_CACHE_BYTES = 128 * 1024 * 1024 -const CACHE_VERSION = 3 +const CACHE_VERSION = 9 const imageData = (pixels: Uint8ClampedArray, width: number, height: number): ImageData => new ImageData(pixels as Uint8ClampedArray, width, height) +const intersectRect = (left: SelectionRect, right: SelectionRect): SelectionRect | null => { + const x = Math.max(left.x, right.x) + const y = Math.max(left.y, right.y) + const toX = Math.min(left.x + left.width, right.x + right.width) + const toY = Math.min(left.y + left.height, right.y + right.height) + return toX > x && toY > y ? { x, y, width: toX - x, height: toY - y } : null +} + +const subtractRect = (source: SelectionRect, removed: SelectionRect): SelectionRect[] => { + const overlap = intersectRect(source, removed) + if (!overlap) return [source] + const result: SelectionRect[] = [] + const sourceRight = source.x + source.width + const sourceBottom = source.y + source.height + const overlapRight = overlap.x + overlap.width + const overlapBottom = overlap.y + overlap.height + if (overlap.y > source.y) result.push({ x: source.x, y: source.y, width: source.width, height: overlap.y - source.y }) + if (overlapBottom < sourceBottom) result.push({ x: source.x, y: overlapBottom, width: source.width, height: sourceBottom - overlapBottom }) + if (overlap.x > source.x) result.push({ x: source.x, y: overlap.y, width: overlap.x - source.x, height: overlap.height }) + if (overlapRight < sourceRight) result.push({ x: overlapRight, y: overlap.y, width: sourceRight - overlapRight, height: overlap.height }) + return result +} + +const visibleDocumentRect = (document: SpriteDocument, fromX: number, fromY: number, toX: number, toY: number): SelectionRect | null => { + const x = Math.max(0, Math.floor(fromX)) + const y = Math.max(0, Math.floor(fromY)) + const right = Math.min(document.width, Math.ceil(toX)) + const bottom = Math.min(document.height, Math.ceil(toY)) + return right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : null +} + export const shouldCacheFullCompositeSurface = (width: number, height: number, maxCacheBytes = DEFAULT_MAX_CACHE_BYTES): boolean => width > 0 && height > 0 && width <= MAX_SURFACE_DIMENSION && height <= MAX_SURFACE_DIMENSION && width * height * 4 <= maxCacheBytes @@ -93,7 +117,7 @@ export class CanvasCompositeCache { } draw({ context, document, view, originX, originY, canvasWidth, canvasHeight, fromX, fromY, toX, toY, revision, contentRevision = revision, contentInvalidation = null, frameId = 'static', isolatedLayerMask, imageSmoothingEnabled = false }: DrawCompositeOptions): void { - const namespace = `${CACHE_VERSION}:${document.id}:${isolatedLayerMask ? `mask:${isolatedLayerMask.id}` : view.relativeLuminance ? 'luminance' : 'color'}` + const namespace = this.surfaceNamespace(document, view, isolatedLayerMask) if (this.namespace !== namespace) { this.namespace = namespace this.invalidateAll() @@ -106,76 +130,77 @@ export class CanvasCompositeCache { context.clip() context.imageSmoothingEnabled = imageSmoothingEnabled if (imageSmoothingEnabled) context.imageSmoothingQuality = 'high' - if (shouldCacheFullCompositeSurface(document.width, document.height, this.maxCacheBytes)) this.drawSurface(context, document, view, originX, originY, canvasWidth, canvasHeight, fromX, fromY, toX, toY, frameKey, frameId, contentRevision, contentInvalidation, imageSmoothingEnabled, isolatedLayerMask) + const initialCompositeIsPending = contentRevision === 0 && !isolatedLayerMask && !view.relativeLuminance && initialDocumentCompositePending(document) + if (isolatedLayerMask || (shouldCacheFullCompositeSurface(document.width, document.height, this.maxCacheBytes) && !initialCompositeIsPending)) this.drawSurface(context, document, view, originX, originY, canvasWidth, canvasHeight, fromX, fromY, toX, toY, frameKey, frameId, contentRevision, contentInvalidation, imageSmoothingEnabled, isolatedLayerMask) else this.drawRegion(context, document, view, originX, originY, fromX, fromY, toX, toY, frameKey, contentRevision, isolatedLayerMask) context.restore() } + private surfaceNamespace(document: SpriteDocument, view: Pick, isolatedLayerMask?: LayerMask): string { + return `${CACHE_VERSION}:${document.id}:${isolatedLayerMask ? `mask:${isolatedLayerMask.id}` : view.relativeLuminance ? 'luminance' : 'color'}` + } + private drawSurface(context: RasterContext2D, document: SpriteDocument, view: ViewState, originX: number, originY: number, canvasWidth: number, canvasHeight: number, fromX: number, fromY: number, toX: number, toY: number, key: string, frameId: string, contentRevision: number, invalidation: DrawCompositeOptions['contentInvalidation'], imageSmoothingEnabled: boolean, isolatedLayerMask?: LayerMask): void { let surface = this.surfaces.get(key) const canApplyInvalidation = surface && surface.revision !== contentRevision && invalidation?.revision === contentRevision && invalidation.fromRevision === surface.revision - if (surface && surface.revision !== contentRevision && (!canApplyInvalidation || invalidation?.kind === 'full')) surface = undefined - if (surface && canApplyInvalidation) { - if (invalidation?.kind === 'region' && (isolatedLayerMask || (invalidation.frameId ?? 'static') === frameId) && invalidation.rect) { - this.invalidateRect(invalidation.rect, document.width, document.height, frameId) + if (surface && surface.revision !== contentRevision) { + if (canApplyInvalidation && invalidation?.kind === 'region') { + if ((isolatedLayerMask || (invalidation.frameId ?? 'static') === frameId) && invalidation.rect) { + this.invalidateRect(invalidation.rect, document.width, document.height, frameId) + } + } else { + surface.pendingDirtyRects = [{ x: 0, y: 0, width: document.width, height: document.height }] + this.dirtyRects.delete(frameId) } surface.revision = contentRevision } if (!surface || surface.canvas.width !== document.width || surface.canvas.height !== document.height) { - const pixels = isolatedLayerMask - ? renderLayerMaskRegion(isolatedLayerMask, 0, 0, document.width, document.height) - : compositeRegion(document, 0, 0, document.width, document.height, this.compositeCache, contentRevision) - if (!isolatedLayerMask && view.relativeLuminance) applyRelativeLuminance(pixels) - const canvas = new OffscreenCanvas(document.width, document.height) - canvas.getContext('2d')?.putImageData(imageData(pixels, document.width, document.height), 0, 0) + const initialSurface = !isolatedLayerMask && !view.relativeLuminance && contentRevision === 0 + ? initialDocumentCompositeSurface(document) + : null + const canvas = initialSurface ?? new OffscreenCanvas(document.width, document.height) + if (!initialSurface) { + const pixels = isolatedLayerMask + ? renderLayerMaskRegion(isolatedLayerMask, 0, 0, document.width, document.height) + : compositeRegion(document, 0, 0, document.width, document.height, this.compositeCache, contentRevision) + if (!isolatedLayerMask && view.relativeLuminance) applyRelativeLuminance(pixels) + canvas.getContext('2d')?.putImageData(imageData(pixels, document.width, document.height), 0, 0) + } surface = { canvas, revision: contentRevision } this.remember(this.surfaces, key, surface) this.dirtyRects.delete(frameId) } else { const dirtyRects = this.dirtyRects.get(frameId) ?? [] + const visibleRect = visibleDocumentRect(document, fromX, fromY, toX, toY) + if (visibleRect && surface.pendingDirtyRects) { + const pendingDirtyRects: SelectionRect[] = [] + for (const rect of surface.pendingDirtyRects) { + const visibleDirtyRect = intersectRect(rect, visibleRect) + if (!visibleDirtyRect) { + pendingDirtyRects.push(rect) + continue + } + dirtyRects.push(visibleDirtyRect) + pendingDirtyRects.push(...subtractRect(rect, visibleDirtyRect)) + } + surface.pendingDirtyRects = pendingDirtyRects.length > 0 ? pendingDirtyRects : undefined + } const surfaceContext = surface.canvas.getContext('2d') if (surfaceContext) for (const rect of dirtyRects) { const pixels = isolatedLayerMask ? renderLayerMaskRegion(isolatedLayerMask, rect.x, rect.y, rect.width, rect.height) - : compositeRegion(document, rect.x, rect.y, rect.width, rect.height) + : compositeRegion(document, rect.x, rect.y, rect.width, rect.height, this.compositeCache, contentRevision) if (!isolatedLayerMask && view.relativeLuminance) applyRelativeLuminance(pixels) surfaceContext.putImageData(imageData(pixels, rect.width, rect.height), rect.x, rect.y) } - if (dirtyRects.length > 0) surface.scaled = undefined this.dirtyRects.delete(frameId) } const visibleWidth = Math.max(0, toX - fromX) const visibleHeight = Math.max(0, toY - fromY) if (visibleWidth > 0 && visibleHeight > 0) { - if (view.zoom < 1) { - const scaledWidth = Math.max(1, Math.round(canvasWidth)) - const scaledHeight = Math.max(1, Math.round(canvasHeight)) - let scaled = surface.scaled - if (!scaled || scaled.zoom !== view.zoom || scaled.imageSmoothingEnabled !== imageSmoothingEnabled || scaled.canvas.width !== scaledWidth || scaled.canvas.height !== scaledHeight) { - const canvas = new OffscreenCanvas(scaledWidth, scaledHeight) - const scaledContext = canvas.getContext('2d') - if (scaledContext) { - scaledContext.imageSmoothingEnabled = imageSmoothingEnabled - if (imageSmoothingEnabled) scaledContext.imageSmoothingQuality = 'high' - scaledContext.drawImage(surface.canvas, 0, 0, surface.canvas.width, surface.canvas.height, 0, 0, scaledWidth, scaledHeight) - } - scaled = { canvas, zoom: view.zoom, imageSmoothingEnabled } - surface.scaled = scaled - } - const scaledFromX = fromX / document.width * scaledWidth - const scaledFromY = fromY / document.height * scaledHeight - const scaledVisibleWidth = visibleWidth / document.width * scaledWidth - const scaledVisibleHeight = visibleHeight / document.height * scaledHeight - if (fromX === 0 && fromY === 0 && toX === document.width && toY === document.height) { - context.drawImage(scaled.canvas, originX, originY, canvasWidth, canvasHeight) - return - } - context.drawImage(scaled.canvas, scaledFromX, scaledFromY, scaledVisibleWidth, scaledVisibleHeight, originX + fromX * view.zoom, originY + fromY * view.zoom, visibleWidth * view.zoom, visibleHeight * view.zoom) - return - } context.drawImage( surface.canvas, fromX, diff --git a/src/renderer/src/components/color-sampling-events.ts b/src/renderer/src/components/color-sampling-events.ts index 71dcadd..e0f5fc1 100644 --- a/src/renderer/src/components/color-sampling-events.ts +++ b/src/renderer/src/components/color-sampling-events.ts @@ -1,6 +1,7 @@ import type { RgbaColor } from '@shared/types' export const CANVAS_COLOR_SAMPLED_EVENT = 'moonsprite:canvas-color-sampled' +export const CANVAS_COLOR_SAMPLING_COMPLETED_EVENT = 'moonsprite:canvas-color-sampling-completed' export interface CanvasColorSampledDetail { color: RgbaColor @@ -12,3 +13,7 @@ export function publishCanvasColorSample(color: RgbaColor, secondary: boolean): detail: { color: { ...color }, secondary } })) } + +export function publishCanvasColorSamplingCompleted(): void { + window.dispatchEvent(new Event(CANVAS_COLOR_SAMPLING_COMPLETED_EVENT)) +} diff --git a/src/renderer/src/components/dialogs/AdjustmentDialog.tsx b/src/renderer/src/components/dialogs/AdjustmentDialog.tsx index fc0e49b..f92faa1 100644 --- a/src/renderer/src/components/dialogs/AdjustmentDialog.tsx +++ b/src/renderer/src/components/dialogs/AdjustmentDialog.tsx @@ -8,11 +8,13 @@ import { type CurveChannel, type CurvePoint } from '@/core/adjustments' -import { NumberInput } from '@/components/NumberInput' +import { RangeField } from '@/components/RangeField' +import { DialogHeader } from '@/components/DialogHeader' import { PixelUtilityIcon } from '@/components/PixelUtilityIcon' import { LivePreviewToggle } from '@/components/LivePreviewToggle' -import { PixelCheckbox } from '@/components/PixelCheckbox' +import { CheckboxField } from '@/components/CheckboxField' import { ModalShell } from '@/components/ModalShell' +import { SegmentedControl } from '@/components/SegmentedControl' import { useI18n } from '@/components/I18nProvider' import { useWorkspace } from '@/store/workspace' import { beginAdjustmentPreviewEdit, endAdjustmentPreviewEdit, registerAdjustmentPreviewController } from '@/core/adjustment-preview-lifecycle' @@ -26,10 +28,6 @@ const adjustmentTargetState = (documentId: string | null) => { } } -function AdjustmentSlider({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) { - return -} - function CurveEditor({ points, channel = 'rgb', histogram, onChange }: { points: CurvePoint[]; channel?: CurveChannel; histogram?: Uint32Array; onChange: (points: CurvePoint[]) => void }) { const { t } = useI18n() const activePointRef = useRef(null) @@ -248,11 +246,11 @@ export function AdjustmentDialog({ kind, onClose }: { kind: AdjustmentKind; onCl const updateBalance = (channel: 'CyanRed' | 'MagentaGreen' | 'YellowBlue', value: number): void => setBalance((current) => ({ ...current, [`${tonePrefix}${channel}`]: value })) const balanceValue = (channel: 'CyanRed' | 'MagentaGreen' | 'YellowBlue'): number => balance[`${tonePrefix}${channel}` as keyof typeof balance] - return
ADJUST

{title}

- {kind === 'brightness-contrast' &&
} - {kind === 'hue-saturation' &&
} + return
+ {kind === 'brightness-contrast' &&
} + {kind === 'hue-saturation' &&
} {kind === 'curves' &&
{(['rgb', 'red', 'green', 'blue'] as CurveChannel[]).map((channel) =>