diff --git a/commands/dispatcher.ts b/commands/dispatcher.ts index 8c29475..d6b05a3 100644 --- a/commands/dispatcher.ts +++ b/commands/dispatcher.ts @@ -48,5 +48,12 @@ function recordHistory(currentState: AppState, nextState: AppState): AppState { } function snapshot(state: AppState): HistorySnapshot { - return { document: state.document, editor: state.editor }; + return { + document: state.document, + editor: { + ...state.editor, + brushPreview: undefined, + brushStrokePreview: undefined, + }, + }; } diff --git a/commands/document.test.ts b/commands/document.test.ts index 1dc7c11..ff43e27 100644 --- a/commands/document.test.ts +++ b/commands/document.test.ts @@ -1,15 +1,18 @@ import { describe, expect, test } from "bun:test"; +import type { Layer } from "@core/layer"; import { createInitialAppState } from "@editor/initial-state"; import { documentAddArtboardCommand, documentAddAssetCommand, documentAddGroupLayerCommand, documentAddImageLayerCommand, + documentAddLayerMaskCommand, documentAddRasterLayerCommand, documentGroupLayersCommand, documentMoveLayerCommand, documentRemoveArtboardCommand, documentRemoveLayerCommand, + documentRemoveLayerMaskCommand, documentRenameArtboardCommand, documentRenameLayerCommand, documentSetArtboardBoundsCommand, @@ -201,6 +204,53 @@ describe("document commands", () => { expect(masked.document.artboards[0]?.layers[2]?.clippingMask).toEqual({ maskLayerId: "mask" }); }); + test("adds editable raster masks directly before the target layer", () => { + const state = documentWithLayers([raster("target", "Target")]); + + const masked = documentAddLayerMaskCommand.execute( + { state }, + { + layerId: "target", + asset: maskAsset(), + maskLayer: raster("mask", "Target Mask", "mask-asset"), + }, + ); + + expect(masked.document.assets).toContainEqual(maskAsset()); + expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]); + expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" }); + expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" }); + expect(masked.editor.tools.activeTool).toBe("brush"); + }); + + test("removes attached layer masks and exits mask editing", () => { + const state = documentAddLayerMaskCommand.execute( + { state: documentWithLayers([raster("target", "Target")]) }, + { layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") }, + ); + + const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" }); + + expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]); + expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined(); + expect(unmasked.editor.maskEdit).toBeUndefined(); + }); + + test("cleans mask references when deleting targets or mask layers", () => { + const state = documentAddLayerMaskCommand.execute( + { state: documentWithLayers([raster("target", "Target")]) }, + { layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") }, + ); + + const removedTarget = documentRemoveLayerCommand.execute({ state }, { layerId: "target" }); + const removedMask = documentRemoveLayerCommand.execute({ state }, { layerId: "mask" }); + + expect(removedTarget.document.artboards[0]?.layers).toEqual([]); + expect(removedMask.document.artboards[0]?.layers[0]?.id).toBe("target"); + expect(removedMask.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined(); + expect(removedMask.editor.maskEdit).toBeUndefined(); + }); + test("sets artboard visibility and lock state", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, @@ -236,7 +286,7 @@ describe("document commands", () => { }); }); -function documentWithLayers(layers: ReturnType[]) { +function documentWithLayers(layers: Layer[]) { return { ...createInitialAppState("Test"), document: { @@ -262,3 +312,20 @@ function group(id: string, name: string) { children: [], }; } + +function raster(id: string, name: string, assetId = `${id}-asset`) { + return { + id, + type: "raster" as const, + name, + visible: true, + locked: false, + opacity: 1, + assetId, + transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, + }; +} + +function maskAsset() { + return { id: "mask-asset", name: "Target Mask", mimeType: "image/svg+xml", source: "mask", intrinsicSize: { w: 100, h: 100 } }; +} diff --git a/commands/document.ts b/commands/document.ts index 3e181a9..b6fef46 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -107,6 +107,16 @@ export type DocumentSetLayerClippingMaskPayload = { maskLayerId?: LayerId; }; +export type DocumentAddLayerMaskPayload = { + layerId: LayerId; + asset: Asset; + maskLayer: RasterLayer; +}; + +export type DocumentRemoveLayerMaskPayload = { + layerId: LayerId; +}; + export const documentAddArtboardCommand: Command = { id: commandIds.documentAddArtboard, name: "Add artboard", @@ -153,13 +163,19 @@ export const documentRemoveArtboardCommand: Command artboard.id !== payload.id), + }; + return { ...state, - document: { - ...state.document, - artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id), + document, + editor: { + ...state.editor, + selection: removedSelectedArtboard ? { layerIds: [] } : state.editor.selection, + maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined, }, - editor: removedSelectedArtboard ? { ...state.editor, selection: { layerIds: [] } } : state.editor, }; }, }; @@ -280,11 +296,19 @@ export const documentMoveLayerCommand: Command = { const removed = removeLayerFromDocument(state.document, payload.layerId); if (!removed.layer) return state; - if (payload.toParentGroupId && !findGroup(removed.document, payload.toParentGroupId)) return state; + + const maskLayerId = removed.layer.clippingMask?.maskLayerId; + const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined; + const documentAfterRemoval = removedMask?.document ?? removed.document; + if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state; + + const documentWithMask = removedMask?.layer + ? insertLayer(documentAfterRemoval, payload.toArtboardId, payload.toParentGroupId, removedMask.layer, payload.toIndex) + : documentAfterRemoval; return { ...state, - document: insertLayer(removed.document, payload.toArtboardId, payload.toParentGroupId, removed.layer, payload.toIndex), + document: insertLayer(documentWithMask, payload.toArtboardId, payload.toParentGroupId, removed.layer, payload.toIndex + (removedMask?.layer ? 1 : 0)), }; }, }; @@ -293,12 +317,14 @@ export const documentGroupLayersCommand: Command = { id: commandIds.documentGroupLayers, name: "Group layers", execute({ state }, payload) { - const uniqueIds = [...new Set(payload.layerIds)]; - if (uniqueIds.length === 0) return state; + const requestedIds = [...new Set(payload.layerIds)]; + if (requestedIds.length === 0) return state; const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId); if (!artboard) return state; + const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])]; + const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id)); if (selected.length === 0) return state; @@ -369,12 +395,14 @@ export const documentSetLayerClippingMaskCommand: Command { - const { clippingMask: _clippingMask, ...rest } = layer; - return rest; - }), + document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)), + editor: { + ...state.editor, + maskEdit: previousMaskId && isMaskEditFor(state.editor.maskEdit, payload.layerId, previousMaskId) ? undefined : state.editor.maskEdit, + }, }; } @@ -402,6 +430,91 @@ export const documentSetLayerClippingMaskCommand: Command = { + id: commandIds.documentAddLayerMask, + name: "Add layer mask", + execute({ state }, payload) { + const targetLocation = findLayerLocation(state.document, payload.layerId); + if (!targetLocation || targetLocation.layer.type === "group") return state; + + const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId; + if (existingMaskId) { + const existingMaskLocation = findLayerLocation(state.document, existingMaskId); + if (existingMaskLocation?.layer.type === "group") return state; + if (existingMaskLocation) { + return { + ...state, + editor: { + ...state.editor, + selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] }, + maskEdit: { targetLayerId: payload.layerId, maskLayerId: existingMaskId }, + tools: { + ...state.editor.tools, + activeTool: "brush", + interactionMode: { type: "tool", tool: "brush" }, + }, + }, + }; + } + } + + if (payload.maskLayer.id === payload.layerId) return state; + if (state.document.assets.some((asset) => asset.id === payload.asset.id)) return state; + if (findLayerLocation(state.document, payload.maskLayer.id)) return state; + + const maskLayer: RasterLayer = { + ...payload.maskLayer, + visible: true, + locked: false, + opacity: 1, + clippingMask: undefined, + }; + const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] }; + const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index); + const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } })); + + return { + ...state, + document, + editor: { + ...state.editor, + selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] }, + maskEdit: { targetLayerId: payload.layerId, maskLayerId: maskLayer.id }, + tools: { + ...state.editor.tools, + activeTool: "brush", + interactionMode: { type: "tool", tool: "brush" }, + }, + }, + }; + }, +}; + +export const documentRemoveLayerMaskCommand: Command = { + id: commandIds.documentRemoveLayerMask, + name: "Remove layer mask", + execute({ state }, payload) { + const targetLocation = findLayerLocation(state.document, payload.layerId); + const maskLayerId = targetLocation?.layer.clippingMask?.maskLayerId; + if (!targetLocation || !maskLayerId) { + return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state; + } + + const unmasked = mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)); + const document = removeUnreferencedMaskLayer(unmasked, maskLayerId); + + return { + ...state, + document, + editor: { + ...state.editor, + selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] }, + maskEdit: isMaskEditFor(state.editor.maskEdit, payload.layerId, maskLayerId) ? undefined : state.editor.maskEdit, + }, + }; + }, +}; + export const documentRemoveLayerCommand: Command = { id: commandIds.documentRemoveLayer, name: "Remove layer", @@ -409,10 +522,23 @@ export const documentRemoveLayerCommand: Command = { const removed = removeLayerFromDocument(state.document, payload.layerId); if (!removed.layer) return state; + const removedLayerIds = collectLayerIds(removed.layer); + const removedMaskLayerIds = collectClippingMaskIds([removed.layer]); + const cleanedReferences = removeMissingMaskReferences(removed.document); + const document = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences); + const selection = { + ...state.editor.selection, + layerIds: state.editor.selection.layerIds.filter((id) => !removedLayerIds.has(id)), + }; + return { ...state, - document: removed.document, - editor: { ...state.editor, selection: { ...state.editor.selection, layerIds: state.editor.selection.layerIds.filter((id) => id !== payload.layerId) } }, + document, + editor: { + ...state.editor, + selection, + maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined, + }, }; }, }; @@ -437,6 +563,8 @@ export const documentCommands = [ documentSetLayerLockedCommand, documentRenameLayerCommand, documentSetLayerClippingMaskCommand, + documentAddLayerMaskCommand, + documentRemoveLayerMaskCommand, ] satisfies Command[]; type LayerLocation = { @@ -590,3 +718,81 @@ function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefi } return undefined; } + +function removeLayerMaskReference(layer: Layer): Layer { + const next = { ...layer }; + delete next.clippingMask; + return next; +} + +function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument { + if (isMaskLayerReferenced(document, maskLayerId)) return document; + return removeLayerFromDocument(document, maskLayerId).document; +} + +function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): boolean { + return collectClippingMaskIds(document.artboards.flatMap((artboard) => artboard.layers)).has(maskLayerId); +} + +function removeMissingMaskReferences(document: ImageDocument): ImageDocument { + const existingLayerIds = collectDocumentLayerIds(document); + return mapAllLayersInDocument(document, (layer) => { + if (!layer.clippingMask || existingLayerIds.has(layer.clippingMask.maskLayerId)) return layer; + return removeLayerMaskReference(layer); + }); +} + +function isMaskEditFor(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, targetLayerId: LayerId, maskLayerId: LayerId) { + return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId; +} + +function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, document: ImageDocument) { + if (!maskEdit) return false; + const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer; + const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer; + return Boolean(target?.clippingMask?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group"); +} + +function collectDocumentLayerIds(document: ImageDocument): Set { + const ids = new Set(); + for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids); + return ids; +} + +function collectLayerIds(layer: Layer, ids = new Set()): Set { + ids.add(layer.id); + if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids); + return ids; +} + +function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set()): Set { + for (const layer of layers) collectLayerIds(layer, ids); + return ids; +} + +function collectClippingMaskIds(layers: readonly Layer[], ids = new Set()): Set { + for (const layer of layers) { + if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); + if (layer.type === "group") collectClippingMaskIds(layer.children, ids); + } + return ids; +} + +function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] { + const layerIdSet = new Set(layerIds); + return layers.flatMap((layer) => (layerIdSet.has(layer.id) && layer.clippingMask ? [layer.clippingMask.maskLayerId] : [])); +} + +function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument { + return { + ...document, + artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapAllLayersInTree(artboard.layers, mapLayer) })), + }; +} + +function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) => Layer): Layer[] { + return layers.map((layer) => { + const mapped = layer.type === "group" ? { ...layer, children: mapAllLayersInTree(layer.children, mapLayer) } : layer; + return mapLayer(mapped); + }); +} diff --git a/commands/ids.ts b/commands/ids.ts index 2fa95b0..bee3070 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -18,12 +18,18 @@ export const commandIds = { documentSetLayerLocked: "document.setLayerLocked", documentRenameLayer: "document.renameLayer", documentSetLayerClippingMask: "document.setLayerClippingMask", + documentAddLayerMask: "document.addLayerMask", + documentRemoveLayerMask: "document.removeLayerMask", selectionSet: "selection.set", selectionClear: "selection.clear", selectionAddLayer: "selection.addLayer", toolSetActive: "tool.setActive", toolSetBrushSettings: "tool.setBrushSettings", - toolSetMaskEditLayer: "tool.setMaskEditLayer", + toolSetBrushPreview: "tool.setBrushPreview", + toolSetBrushStrokePreview: "tool.setBrushStrokePreview", + toolSetMaskViewMode: "tool.setMaskViewMode", + toolEnterMaskEdit: "tool.enterMaskEdit", + toolExitMaskEdit: "tool.exitMaskEdit", toolEnterTemporaryPan: "tool.enterTemporaryPan", toolExitTemporaryPan: "tool.exitTemporaryPan", transformBegin: "transform.begin", diff --git a/commands/index.ts b/commands/index.ts index 163d278..5755fea 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -4,12 +4,14 @@ export { documentAddAssetCommand, documentAddGroupLayerCommand, documentAddImageLayerCommand, + documentAddLayerMaskCommand, documentAddRasterLayerCommand, documentCommands, documentGroupLayersCommand, documentMoveLayerCommand, documentRemoveArtboardCommand, documentRemoveLayerCommand, + documentRemoveLayerMaskCommand, documentRenameArtboardCommand, documentRenameLayerCommand, documentSetArtboardBoundsCommand, @@ -26,11 +28,13 @@ export type { DocumentAddAssetPayload, DocumentAddGroupLayerPayload, DocumentAddImageLayerPayload, + DocumentAddLayerMaskPayload, DocumentAddRasterLayerPayload, DocumentGroupLayersPayload, DocumentMoveLayerPayload, DocumentRemoveArtboardPayload, DocumentRemoveLayerPayload, + DocumentRemoveLayerMaskPayload, DocumentRenameArtboardPayload, DocumentRenameLayerPayload, DocumentSetArtboardBoundsPayload, @@ -50,10 +54,10 @@ export type { CommandRegistry } from "./registry"; export { createCommandRegistry } from "./registry"; export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection"; export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; -export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand } from "./tool"; +export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool"; export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform"; export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; -export type { ToolSetActivePayload, ToolSetBrushSettingsPayload, ToolSetMaskEditLayerPayload } from "./tool"; +export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool"; export { viewportCommands, viewportPanCommand, diff --git a/commands/payloads.ts b/commands/payloads.ts index 86fca41..3de400a 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -1,14 +1,16 @@ -import { commandIds } from "./ids"; +import type { commandIds } from "./ids"; import type { DocumentAddArtboardPayload, DocumentAddAssetPayload, DocumentAddGroupLayerPayload, DocumentAddImageLayerPayload, + DocumentAddLayerMaskPayload, DocumentAddRasterLayerPayload, DocumentGroupLayersPayload, DocumentMoveLayerPayload, DocumentRemoveArtboardPayload, DocumentRemoveLayerPayload, + DocumentRemoveLayerMaskPayload, DocumentRenameArtboardPayload, DocumentRenameLayerPayload, DocumentSetArtboardBoundsPayload, @@ -21,7 +23,7 @@ import type { DocumentUngroupLayerPayload, } from "./document"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; -import type { ToolSetActivePayload, ToolSetBrushSettingsPayload, ToolSetMaskEditLayerPayload } from "./tool"; +import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool"; import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; import type { ViewportFitArtboardPayload, @@ -43,6 +45,8 @@ export type CommandPayloads = { [commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload; [commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload; [commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload; + [commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload; + [commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload; [commandIds.documentMoveLayer]: DocumentMoveLayerPayload; [commandIds.documentGroupLayers]: DocumentGroupLayersPayload; [commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload; @@ -56,7 +60,11 @@ export type CommandPayloads = { [commandIds.selectionAddLayer]: SelectionAddLayerPayload; [commandIds.toolSetActive]: ToolSetActivePayload; [commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload; - [commandIds.toolSetMaskEditLayer]: ToolSetMaskEditLayerPayload; + [commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload; + [commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload; + [commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload; + [commandIds.toolEnterMaskEdit]: ToolEnterMaskEditPayload; + [commandIds.toolExitMaskEdit]: void; [commandIds.toolEnterTemporaryPan]: void; [commandIds.toolExitTemporaryPan]: void; [commandIds.transformBegin]: TransformBeginPayload; diff --git a/commands/selection.ts b/commands/selection.ts index cb8f69f..fc0f220 100644 --- a/commands/selection.ts +++ b/commands/selection.ts @@ -15,14 +15,19 @@ export const selectionSetCommand: Command = { id: commandIds.selectionSet, name: "Set selection", execute({ state }, payload) { + const selection = { + artboardId: payload.artboardId, + layerIds: [...payload.layerIds], + }; + return { ...state, editor: { ...state.editor, - selection: { - artboardId: payload.artboardId, - layerIds: [...payload.layerIds], - }, + selection, + maskEdit: selection.layerIds.length === 1 && selection.layerIds[0] === state.editor.maskEdit?.targetLayerId ? state.editor.maskEdit : undefined, + brushPreview: undefined, + brushStrokePreview: undefined, }, }; }, @@ -37,6 +42,9 @@ export const selectionClearCommand: Command = { editor: { ...state.editor, selection: { layerIds: [] }, + maskEdit: undefined, + brushPreview: undefined, + brushStrokePreview: undefined, }, }; }, @@ -56,6 +64,9 @@ export const selectionAddLayerCommand: Command = { ...state.editor.selection, layerIds: [...state.editor.selection.layerIds, payload.layerId], }, + maskEdit: undefined, + brushPreview: undefined, + brushStrokePreview: undefined, }, }; }, diff --git a/commands/tool.test.ts b/commands/tool.test.ts index 6e67729..208229e 100644 --- a/commands/tool.test.ts +++ b/commands/tool.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test"; import { createInitialAppState } from "@editor/initial-state"; -import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand } from "./tool"; +import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool"; + +const defaultBrush = { color: "#111827", size: 8, hardness: 100 }; describe("tool commands", () => { test("sets active tool", () => { const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "crop" }); - expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" }, brush: { color: "#111827", size: 8, hardness: 100 } }); + expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" }, brush: defaultBrush }); }); test("sets brush settings", () => { @@ -14,10 +16,39 @@ describe("tool commands", () => { expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 }); }); - test("sets mask edit layer", () => { - const next = toolSetMaskEditLayerCommand.execute({ state: createInitialAppState("Test") }, { layerId: "mask-1" }); + test("sets and clears brush preview", () => { + const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } }); + const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined); - expect(next.editor.maskEditLayerId).toBe("mask-1"); + expect(showing.editor.brushPreview).toEqual({ position: { x: 10, y: 20 } }); + expect(cleared.editor.brushPreview).toBeUndefined(); + }); + + test("sets and clears brush stroke preview", () => { + const showing = toolSetBrushStrokePreviewCommand.execute({ state: createInitialAppState("Test") }, { layerId: "layer", assetId: "asset", source: "preview" }); + const cleared = toolSetBrushStrokePreviewCommand.execute({ state: showing }, undefined); + + expect(showing.editor.brushStrokePreview).toEqual({ layerId: "layer", assetId: "asset", source: "preview" }); + expect(cleared.editor.brushStrokePreview).toBeUndefined(); + }); + + test("enters and exits mask edit", () => { + const editing = toolEnterMaskEditCommand.execute({ state: stateWithMask() }, { targetLayerId: "target", maskLayerId: "mask" }); + const exited = toolExitMaskEditCommand.execute({ state: editing }, undefined); + + expect(editing.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" }); + expect(editing.editor.selection).toEqual({ artboardId: "a1", layerIds: ["target"] }); + expect(editing.editor.tools.activeTool).toBe("brush"); + expect(exited.editor.maskEdit).toBeUndefined(); + }); + + test("sets mask view mode while editing a mask", () => { + const editing = toolEnterMaskEditCommand.execute({ state: stateWithMask() }, { targetLayerId: "target", maskLayerId: "mask" }); + const alpha = toolSetMaskViewModeCommand.execute({ state: editing }, { mode: "alpha" }); + const ignored = toolSetMaskViewModeCommand.execute({ state: createInitialAppState("Test") }, { mode: "blackWhite" }); + + expect(alpha.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask", viewMode: "alpha" }); + expect(ignored.editor.maskEdit).toBeUndefined(); }); test("enters and exits temporary pan", () => { @@ -25,7 +56,43 @@ describe("tool commands", () => { const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined); const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined); - expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: { color: "#111827", size: 8, hardness: 100 } }); + expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush }); expect(restored.editor.tools).toEqual(initial.editor.tools); }); }); + +function stateWithMask() { + return { + ...createInitialAppState("Test"), + document: { + ...createInitialAppState("Test").document, + artboards: [ + { + id: "a1", + name: "Artboard 1", + bounds: { x: 0, y: 0, w: 320, h: 240 }, + backgroundColor: "transparent" as const, + visible: true, + locked: false, + layers: [ + raster("mask", "Mask"), + { ...raster("target", "Target"), clippingMask: { maskLayerId: "mask" } }, + ], + }, + ], + }, + }; +} + +function raster(id: string, name: string) { + return { + id, + type: "raster" as const, + name, + visible: true, + locked: false, + opacity: 1, + assetId: `${id}-asset`, + transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, + }; +} diff --git a/commands/tool.ts b/commands/tool.ts index 371d609..ad252ac 100644 --- a/commands/tool.ts +++ b/commands/tool.ts @@ -1,4 +1,8 @@ -import type { LayerId } from "@core/id"; +import type { ImageDocument } from "@core/document"; +import type { Vec2D } from "@core/geometry"; +import type { LayerId, ArtboardId, AssetId } from "@core/id"; +import type { Layer } from "@core/layer"; +import type { MaskViewMode } from "@editor/state"; import type { BrushSettings, ToolId } from "@editor/tools"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -9,8 +13,23 @@ export type ToolSetActivePayload = { export type ToolSetBrushSettingsPayload = Partial; -export type ToolSetMaskEditLayerPayload = { - layerId?: LayerId; +export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined; + +export type ToolSetBrushStrokePreviewPayload = + | { + layerId: LayerId; + assetId: AssetId; + source: string; + } + | undefined; + +export type ToolSetMaskViewModePayload = { + mode: MaskViewMode; +}; + +export type ToolEnterMaskEditPayload = { + targetLayerId: LayerId; + maskLayerId: LayerId; }; export const toolSetActiveCommand: Command = { @@ -26,6 +45,8 @@ export const toolSetActiveCommand: Command = { activeTool: payload.tool, interactionMode: { type: "tool", tool: payload.tool }, }, + brushPreview: undefined, + brushStrokePreview: undefined, }, }; }, @@ -52,15 +73,107 @@ export const toolSetBrushSettingsCommand: Command = }, }; -export const toolSetMaskEditLayerCommand: Command = { - id: commandIds.toolSetMaskEditLayer, - name: "Set mask edit layer", +export const toolSetBrushPreviewCommand: Command = { + id: commandIds.toolSetBrushPreview, + name: "Set brush preview", execute({ state }, payload) { + if (!payload) { + if (!state.editor.brushPreview) return state; + return { ...state, editor: { ...state.editor, brushPreview: undefined } }; + } + + const currentPosition = state.editor.brushPreview?.position; + if (currentPosition && currentPosition.x === payload.position.x && currentPosition.y === payload.position.y) return state; + return { ...state, editor: { ...state.editor, - maskEditLayerId: payload.layerId, + brushPreview: { position: { ...payload.position } }, + }, + }; + }, +}; + +export const toolSetBrushStrokePreviewCommand: Command = { + id: commandIds.toolSetBrushStrokePreview, + name: "Set brush stroke preview", + execute({ state }, payload) { + if (!payload) { + if (!state.editor.brushStrokePreview) return state; + return { ...state, editor: { ...state.editor, brushStrokePreview: undefined } }; + } + + const currentPreview = state.editor.brushStrokePreview; + if (currentPreview?.layerId === payload.layerId && currentPreview.assetId === payload.assetId && currentPreview.source === payload.source) return state; + + return { + ...state, + editor: { + ...state.editor, + brushStrokePreview: { layerId: payload.layerId, assetId: payload.assetId, source: payload.source }, + }, + }; + }, +}; + +export const toolSetMaskViewModeCommand: Command = { + id: commandIds.toolSetMaskViewMode, + name: "Set mask view mode", + execute({ state }, payload) { + if (!state.editor.maskEdit || state.editor.maskEdit.viewMode === payload.mode) return state; + + return { + ...state, + editor: { + ...state.editor, + maskEdit: { ...state.editor.maskEdit, viewMode: payload.mode }, + }, + }; + }, +}; + +export const toolEnterMaskEditCommand: Command = { + id: commandIds.toolEnterMaskEdit, + name: "Enter mask edit", + execute({ state }, payload) { + const targetLocation = findLayerLocation(state.document, payload.targetLayerId); + const maskLocation = findLayerLocation(state.document, payload.maskLayerId); + if (!targetLocation || !maskLocation) return state; + if (targetLocation.layer.clippingMask?.maskLayerId !== payload.maskLayerId) return state; + if (maskLocation.layer.type === "group") return state; + + return { + ...state, + editor: { + ...state.editor, + selection: { artboardId: targetLocation.artboardId, layerIds: [payload.targetLayerId] }, + maskEdit: { targetLayerId: payload.targetLayerId, maskLayerId: payload.maskLayerId }, + brushPreview: undefined, + brushStrokePreview: undefined, + tools: { + ...state.editor.tools, + activeTool: "brush", + interactionMode: { type: "tool", tool: "brush" }, + }, + }, + }; + }, +}; + +export const toolExitMaskEditCommand: Command = { + id: commandIds.toolExitMaskEdit, + name: "Exit mask edit", + execute({ state }) { + if (!state.editor.maskEdit) return state; + + return { + ...state, + editor: { + ...state.editor, + maskEdit: undefined, + brushPreview: undefined, + brushStrokePreview: undefined, }, }; }, @@ -106,9 +219,43 @@ export const toolExitTemporaryPanCommand: Command = { }, }; -export const toolCommands = [toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command[]; +export const toolCommands = [ + toolSetActiveCommand, + toolSetBrushSettingsCommand, + toolSetBrushPreviewCommand, + toolSetBrushStrokePreviewCommand, + toolSetMaskViewModeCommand, + toolEnterMaskEditCommand, + toolExitMaskEditCommand, + toolEnterTemporaryPanCommand, + toolExitTemporaryPanCommand, +] satisfies Command[]; function clampNumber(value: number, min: number, max: number) { if (!Number.isFinite(value)) return min; return Math.max(min, Math.min(max, value)); } + +type LayerLocation = { + artboardId: ArtboardId; + layer: Layer; +}; + +function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined { + for (const artboard of document.artboards) { + const layer = findLayerInTree(artboard.layers, layerId); + if (layer) return { artboardId: artboard.id, layer }; + } + return undefined; +} + +function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | undefined { + for (const layer of layers) { + if (layer.id === layerId) return layer; + if (layer.type === "group") { + const child = findLayerInTree(layer.children, layerId); + if (child) return child; + } + } + return undefined; +} diff --git a/editor/initial-state.ts b/editor/initial-state.ts index fffd74f..1bc76ee 100644 --- a/editor/initial-state.ts +++ b/editor/initial-state.ts @@ -13,7 +13,9 @@ export const initialEditorState: EditorState = { }, tools: initialToolState, transformSession: undefined, - maskEditLayerId: undefined, + maskEdit: undefined, + brushPreview: undefined, + brushStrokePreview: undefined, }; export function createInitialAppState(name = "Untitled"): AppState { diff --git a/editor/state.ts b/editor/state.ts index b5e3f35..aee6c05 100644 --- a/editor/state.ts +++ b/editor/state.ts @@ -1,6 +1,6 @@ import type { ImageDocument } from "@core/document"; import type { Angle, Size, Vec2D } from "@core/geometry"; -import type { ArtboardId, LayerId } from "@core/id"; +import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ToolState } from "./tools"; import type { TransformSession } from "./transform"; @@ -16,12 +16,32 @@ export type SelectionState = { layerIds: LayerId[]; }; +export type MaskViewMode = "composite" | "blackWhite" | "alpha" | "overlay"; + +export type MaskEditState = { + targetLayerId: LayerId; + maskLayerId: LayerId; + viewMode?: MaskViewMode; +}; + +export type BrushPreviewState = { + position: Vec2D; +}; + +export type BrushStrokePreviewState = { + layerId: LayerId; + assetId: AssetId; + source: string; +}; + export type EditorState = { viewport: ViewportState; selection: SelectionState; tools: ToolState; transformSession?: TransformSession; - maskEditLayerId?: LayerId; + maskEdit?: MaskEditState; + brushPreview?: BrushPreviewState; + brushStrokePreview?: BrushStrokePreviewState; }; export type HistorySnapshot = { diff --git a/editor/transform-targets.test.ts b/editor/transform-targets.test.ts index cc96dff..c090604 100644 --- a/editor/transform-targets.test.ts +++ b/editor/transform-targets.test.ts @@ -51,6 +51,36 @@ describe("transform targets", () => { expect(layer?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 }); }); + test("keeps attached mask bounds in sync with transformed layers", () => { + const maskedDocument: ImageDocument = { + ...document, + assets: [...document.assets, { id: "mask-asset", name: "Mask", mimeType: "image/png", source: "asset://mask", intrinsicSize: { w: 200, h: 100 } }], + artboards: [ + { + ...document.artboards[0]!, + layers: [ + { + id: "mask", + type: "raster", + name: "Mask", + visible: true, + locked: false, + opacity: 1, + assetId: "mask-asset", + transform: { position: { x: 10, y: 20 }, scale: { x: 0.5, y: 2 }, rotation: 0 }, + }, + { ...document.artboards[0]!.layers[0]!, clippingMask: { maskLayerId: "mask" } }, + ], + }, + ], + }; + + const next = applyTransformTargetBounds(maskedDocument, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 }); + + expect(next.artboards[0]?.layers[0]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 }); + expect(next.artboards[0]?.layers[1]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 }); + }); + test("applies artboard bounds", () => { const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 }); expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 }); diff --git a/editor/transform-targets.ts b/editor/transform-targets.ts index ad7462a..bee6be1 100644 --- a/editor/transform-targets.ts +++ b/editor/transform-targets.ts @@ -34,13 +34,19 @@ export function selectedTransformTarget(document: ImageDocument, selection: { ar } function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument { - return { - ...document, - artboards: document.artboards.map((artboard) => ({ - ...artboard, - layers: applyLayerBoundsInTree(document, artboard.layers, layerId, bounds), - })), - }; + const layer = findLayer(document, layerId); + const targetLayerIds = layer?.clippingMask ? [layerId, layer.clippingMask.maskLayerId] : [layerId]; + + return targetLayerIds.reduce( + (nextDocument, targetLayerId) => ({ + ...nextDocument, + artboards: nextDocument.artboards.map((artboard) => ({ + ...artboard, + layers: applyLayerBoundsInTree(nextDocument, artboard.layers, targetLayerId, bounds), + })), + }), + document, + ); } function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] { diff --git a/input/document-geometry.ts b/input/document-geometry.ts new file mode 100644 index 0000000..6a77436 --- /dev/null +++ b/input/document-geometry.ts @@ -0,0 +1,107 @@ +import type { ImageDocument } from "@core/document"; +import type { Rect, Size, Vec2D } from "@core/geometry"; +import type { ArtboardId, LayerId } from "@core/id"; +import type { Layer } from "@core/layer"; + +export type InputViewportState = { + center: Vec2D; + zoom: number; + rotation?: number; + size: Size; +}; + +export type InputSelectionState = { + artboardId?: ArtboardId; + layerIds: LayerId[]; +}; + +export type InputTransformTarget = + | { type: "artboard"; id: ArtboardId } + | { type: "layer"; id: LayerId }; + +export function selectedTransformTarget(_document: ImageDocument, selection: InputSelectionState): InputTransformTarget | undefined { + if (selection.layerIds.length === 1 && selection.layerIds[0]) return { type: "layer", id: selection.layerIds[0] }; + if (selection.artboardId) return { type: "artboard", id: selection.artboardId }; + return undefined; +} + +export function resolveTransformTargetBounds(document: ImageDocument, target: InputTransformTarget): Rect | undefined { + switch (target.type) { + case "artboard": + return document.artboards.find((artboard) => artboard.id === target.id)?.bounds; + case "layer": { + const layer = findLayer(document, target.id); + return layer ? resolveLayerBounds(document, layer) : undefined; + } + } +} + +export function viewportPointToDocumentPoint(point: Vec2D, viewport: InputViewportState): Vec2D { + return { + x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom, + y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom, + }; +} + +export function documentRectToViewportRect(rect: Rect, viewport: InputViewportState): Rect { + return { + x: viewport.size.w / 2 + (rect.x - viewport.center.x) * viewport.zoom, + y: viewport.size.h / 2 + (rect.y - viewport.center.y) * viewport.zoom, + w: rect.w * viewport.zoom, + h: rect.h * viewport.zoom, + }; +} + +function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined { + for (const artboard of document.artboards) { + const layer = findLayerInTree(artboard.layers, layerId); + if (layer) return layer; + } + + return undefined; +} + +function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | undefined { + for (const layer of layers) { + if (layer.id === layerId) return layer; + if (layer.type === "group") { + const child = findLayerInTree(layer.children, layerId); + if (child) return child; + } + } + + return undefined; +} + +function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undefined { + switch (layer.type) { + case "group": + return unionRects(layer.children.flatMap((child) => { + const bounds = resolveLayerBounds(document, child); + return bounds ? [bounds] : []; + })); + case "image": + case "raster": { + const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + if (!asset) return undefined; + + return { + x: layer.transform.position.x, + y: layer.transform.position.y, + w: asset.intrinsicSize.w * layer.transform.scale.x, + h: asset.intrinsicSize.h * layer.transform.scale.y, + }; + } + } +} + +function unionRects(rects: Rect[]): Rect | undefined { + if (rects.length === 0) return undefined; + + const minX = Math.min(...rects.map((rect) => rect.x)); + const minY = Math.min(...rects.map((rect) => rect.y)); + const maxX = Math.max(...rects.map((rect) => rect.x + rect.w)); + const maxY = Math.max(...rects.map((rect) => rect.y + rect.h)); + + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} diff --git a/input/layers-panel.ts b/input/layers-panel.ts index c0172a5..b443e71 100644 --- a/input/layers-panel.ts +++ b/input/layers-panel.ts @@ -3,7 +3,6 @@ import type { Dispatch } from "@commands/dispatcher"; import type { ImageDocument } from "@core/document"; import type { ArtboardId, LayerId } from "@core/id"; import type { Layer } from "@core/layer"; -import type { SelectionState } from "@editor/state"; import type { KeybindEvent } from "./keyboard"; export type LayerInfo = { @@ -17,7 +16,12 @@ export type LayerDropTarget = { layer: Layer; }; -export function handleDeleteSelectionKey(options: { event: KeybindEvent; selection: SelectionState; dispatch: Dispatch }): boolean { +export type DeleteSelectionState = { + artboardId?: ArtboardId; + layerIds: LayerId[]; +}; + +export function handleDeleteSelectionKey(options: { event: KeybindEvent; selection: DeleteSelectionState; dispatch: Dispatch }): boolean { if (options.event.altKey || options.event.ctrlKey || options.event.metaKey) return false; if (options.event.key !== "Backspace" && options.event.key !== "Delete") return false; diff --git a/input/selection.test.ts b/input/selection.test.ts index a909480..9093c23 100644 --- a/input/selection.test.ts +++ b/input/selection.test.ts @@ -1,30 +1,23 @@ import { describe, expect, test } from "bun:test"; import { commandIds } from "@commands/ids"; -import { createInitialAppState } from "@editor/initial-state"; +import type { ImageDocument } from "@core/document"; import { handleArtboardSelection } from "./selection"; import type { PointerInputEvent } from "./pointer"; const ignoredState = undefined as never; +const viewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }; describe("selection input", () => { test("selects artboard hit by left click", () => { - const state = { - ...createInitialAppState("Test"), - document: { - ...createInitialAppState("Test").document, - artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], - }, - editor: { - ...createInitialAppState("Test").editor, - viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, - }, - }; + const document = createDocument({ + artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], + }); const dispatched: unknown[] = []; const consumed = handleArtboardSelection({ event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }), - document: state.document, - viewport: state.editor.viewport, + document, + viewport, dispatch: (commandId, payload) => { dispatched.push({ commandId, payload }); return ignoredState; @@ -36,45 +29,37 @@ describe("selection input", () => { }); test("selects topmost image layer before artboard", () => { - const state = { - ...createInitialAppState("Test"), - document: { - ...createInitialAppState("Test").document, - assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }], - artboards: [ - { - id: "a1", - name: "Artboard", - bounds: { x: -100, y: -100, w: 200, h: 200 }, - backgroundColor: "transparent", - visible: true, - locked: false, - layers: [ - { - id: "l1", - type: "image", - name: "Image", - visible: true, - locked: false, - opacity: 1, - assetId: "asset-1", - transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 }, - }, - ], - }, - ], - }, - editor: { - ...createInitialAppState("Test").editor, - viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, - }, - }; + const document = createDocument({ + assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }], + artboards: [ + { + id: "a1", + name: "Artboard", + bounds: { x: -100, y: -100, w: 200, h: 200 }, + backgroundColor: "transparent", + visible: true, + locked: false, + layers: [ + { + id: "l1", + type: "image", + name: "Image", + visible: true, + locked: false, + opacity: 1, + assetId: "asset-1", + transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 }, + }, + ], + }, + ], + }); const dispatched: unknown[] = []; const consumed = handleArtboardSelection({ event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }), - document: state.document, - viewport: state.editor.viewport, + document, + viewport, dispatch: (commandId, payload) => { dispatched.push({ commandId, payload }); return ignoredState; @@ -85,14 +70,70 @@ describe("selection input", () => { expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["l1"] } }]); }); - test("clears selection when clicking outside artboards", () => { - const state = createInitialAppState("Test"); + test("ignores mask layers during canvas hit testing", () => { + const document = createDocument({ + assets: [ + { id: "target-asset", name: "Target", mimeType: "image/png", source: "target", intrinsicSize: { w: 50, h: 50 } }, + { id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 50, h: 50 } }, + ], + artboards: [ + { + id: "a1", + name: "Artboard", + bounds: { x: -100, y: -100, w: 200, h: 200 }, + backgroundColor: "transparent", + visible: true, + locked: false, + layers: [ + { + id: "target", + type: "image", + name: "Target", + visible: true, + locked: false, + opacity: 1, + assetId: "target-asset", + transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 }, + clippingMask: { maskLayerId: "mask" }, + }, + { + id: "mask", + type: "raster", + name: "Mask", + visible: true, + locked: false, + opacity: 1, + assetId: "mask-asset", + transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 }, + }, + ], + }, + ], + }); const dispatched: unknown[] = []; const consumed = handleArtboardSelection({ event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }), - document: state.document, - viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, + document, + viewport, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return ignoredState; + }, + }); + + expect(consumed).toBe(true); + expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["target"] } }]); + }); + + test("clears selection when clicking outside artboards", () => { + const document = createDocument(); + const dispatched: unknown[] = []; + + const consumed = handleArtboardSelection({ + event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }), + document, + viewport, dispatch: (commandId, payload) => { dispatched.push({ commandId, payload }); return ignoredState; @@ -104,6 +145,17 @@ describe("selection input", () => { }); }); +function createDocument(overrides: Partial = {}): ImageDocument { + return { + id: "d1", + name: "Test", + version: 1, + assets: [], + artboards: [], + ...overrides, + }; +} + function pointerEvent(overrides: Partial): PointerInputEvent { return { pointerId: 1, diff --git a/input/selection.ts b/input/selection.ts index bd6a97c..875ca4e 100644 --- a/input/selection.ts +++ b/input/selection.ts @@ -2,14 +2,13 @@ import { commandIds } from "@commands/ids"; import type { Dispatch } from "@commands/dispatcher"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; -import { resolveTransformTargetBounds } from "@editor/transform-targets"; -import type { ViewportState } from "@editor/state"; +import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry"; import type { PointerInputEvent } from "./pointer"; export function handleArtboardSelection(options: { event: PointerInputEvent; document: ImageDocument; - viewport: ViewportState; + viewport: InputViewportState; dispatch: Dispatch; }): boolean { if (options.event.pointerType !== "mouse" || (options.event.buttons & 1) !== 1) return false; @@ -37,20 +36,21 @@ export function handleArtboardSelection(options: { } function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) { + const maskLayerIds = collectMaskLayerIds(document.artboards.flatMap((artboard) => artboard.layers)); for (const artboard of [...document.artboards].reverse()) { if (!artboard.visible || artboard.locked) continue; - const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point); + const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point, maskLayerIds); if (layerId) return { artboardId: artboard.id, layerId }; } return undefined; } -function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }): string | undefined { +function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }, maskLayerIds: ReadonlySet): string | undefined { for (const layer of layers) { - if (!layer.visible || layer.locked) continue; + if (!layer.visible || layer.locked || maskLayerIds.has(layer.id)) continue; if (layer.type === "group") { - const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point); + const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point, maskLayerIds); if (childId) return childId; } @@ -63,9 +63,11 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], return undefined; } -function viewportPointToDocumentPoint(point: PointerInputEvent["position"], viewport: ViewportState) { - return { - x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom, - y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom, - }; +function collectMaskLayerIds(layers: readonly Layer[], ids = new Set()): Set { + for (const layer of layers) { + if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); + if (layer.type === "group") collectMaskLayerIds(layer.children, ids); + } + return ids; } + diff --git a/input/transform-controls.test.ts b/input/transform-controls.test.ts index 4803070..fa2c521 100644 --- a/input/transform-controls.test.ts +++ b/input/transform-controls.test.ts @@ -1,35 +1,30 @@ import { describe, expect, test } from "bun:test"; import { commandIds } from "@commands/ids"; -import { createInitialAppState } from "@editor/initial-state"; +import type { ImageDocument } from "@core/document"; import type { PointerInputEvent } from "./pointer"; -import { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls"; +import { createTransformControlsInputController, hitTestArtboardTransformHandle, type TransformControlsEditorState } from "./transform-controls"; const ignoredState = undefined as never; +const defaultViewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }; +const defaultTools = { + activeTool: "select" as const, + interactionMode: { type: "tool" as const, tool: "select" as const }, +}; describe("transform controls input", () => { test("hit tests artboard handles and body", () => { - const viewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }; const bounds = { x: -50, y: -50, w: 100, h: 100 }; - expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, viewport)).toBe("nw"); - expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, viewport)).toBe("body"); - expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, viewport)).toBeUndefined(); + expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, defaultViewport)).toBe("nw"); + expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, defaultViewport)).toBe("body"); + expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, defaultViewport)).toBeUndefined(); }); test("does not transform while temporary pan is active", () => { - const state = { - ...createInitialAppState("Test"), - document: { - ...createInitialAppState("Test").document, - artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], - }, - editor: { - ...createInitialAppState("Test").editor, - viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, - selection: { artboardId: "a1", layerIds: [] }, - tools: { activeTool: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const }, brush: { color: "#111827", size: 8, hardness: 100 } }, - }, - }; + const state = createState({ + selection: { artboardId: "a1", layerIds: [] }, + tools: { activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" } }, + }); const dispatched: unknown[] = []; const controller = createTransformControlsInputController({ getDocument: () => state.document, @@ -45,19 +40,10 @@ describe("transform controls input", () => { }); test("crop tool resizes from handles but does not move body", () => { - const state = { - ...createInitialAppState("Test"), - document: { - ...createInitialAppState("Test").document, - artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], - }, - editor: { - ...createInitialAppState("Test").editor, - viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, - selection: { artboardId: "a1", layerIds: [] }, - tools: { activeTool: "crop" as const, interactionMode: { type: "tool" as const, tool: "crop" as const }, brush: { color: "#111827", size: 8, hardness: 100 } }, - }, - }; + const state = createState({ + selection: { artboardId: "a1", layerIds: [] }, + tools: { activeTool: "crop", interactionMode: { type: "tool", tool: "crop" } }, + }); const dispatched: unknown[] = []; const controller = createTransformControlsInputController({ getDocument: () => state.document, @@ -73,25 +59,14 @@ describe("transform controls input", () => { }); test("dispatches transform lifecycle for selected artboard", () => { - let state = { - ...createInitialAppState("Test"), - document: { - ...createInitialAppState("Test").document, - artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], - }, - editor: { - ...createInitialAppState("Test").editor, - viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, - selection: { artboardId: "a1", layerIds: [] }, - }, - }; + let state = createState({ selection: { artboardId: "a1", layerIds: [] } }); const dispatched: unknown[] = []; const controller = createTransformControlsInputController({ getDocument: () => state.document, getEditor: () => state.editor, dispatch: (commandId, payload) => { dispatched.push({ commandId, payload }); - if (commandId === commandIds.transformBegin) state = { ...state, editor: { ...state.editor, transformSession: payload as never } }; + if (commandId === commandIds.transformBegin) state = { ...state, editor: { ...state.editor, transformSession: payload } }; return ignoredState; }, }); @@ -108,6 +83,24 @@ describe("transform controls input", () => { }); }); +function createState(editorOverrides: Partial = {}): { document: ImageDocument; editor: TransformControlsEditorState } { + return { + document: { + id: "d1", + name: "Test", + version: 1, + assets: [], + artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], + }, + editor: { + viewport: defaultViewport, + selection: { layerIds: [] }, + tools: defaultTools, + ...editorOverrides, + }, + }; +} + function pointerEvent(overrides: Partial): PointerInputEvent { return { pointerId: 1, diff --git a/input/transform-controls.ts b/input/transform-controls.ts index 3449df0..b7cedd4 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -2,13 +2,36 @@ import { commandIds } from "@commands/ids"; import type { Dispatch } from "@commands/dispatcher"; import type { ImageDocument } from "@core/document"; import type { Rect, Vec2D } from "@core/geometry"; -import type { EditorState } from "@editor/state"; -import { isPanInteractionMode } from "@editor/tools"; -import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; -import type { TransformHandle } from "@editor/transform"; +import { + documentRectToViewportRect, + resolveTransformTargetBounds, + selectedTransformTarget, + viewportPointToDocumentPoint, + type InputSelectionState, + type InputTransformTarget, + type InputViewportState, +} from "./document-geometry"; import { findLayerInfoInDocument } from "./layers-panel"; import type { PointerInputEvent } from "./pointer"; +type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; + +type InputToolId = "select" | "crop" | "brush" | "eraser" | "pan"; + +type InputInteractionMode = + | { type: "tool"; tool: InputToolId } + | { type: "temporary-pan"; previousTool: InputToolId }; + +export type TransformControlsEditorState = { + viewport: InputViewportState; + selection: InputSelectionState; + tools: { + activeTool: InputToolId; + interactionMode: InputInteractionMode; + }; + transformSession?: unknown; +}; + export type TransformControlsInputController = { pointerDown(event: PointerInputEvent): boolean; pointerMove(event: PointerInputEvent): boolean; @@ -17,7 +40,7 @@ export type TransformControlsInputController = { export function createTransformControlsInputController(options: { getDocument: () => ImageDocument; - getEditor: () => EditorState; + getEditor: () => TransformControlsEditorState; dispatch: Dispatch; }): TransformControlsInputController { return { @@ -62,7 +85,7 @@ export function createTransformControlsInputController(options: { }; } -export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, viewport: EditorState["viewport"]): TransformHandle | undefined { +export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, viewport: InputViewportState): TransformHandle | undefined { const rect = documentRectToViewportRect(bounds, viewport); const handles = transformHandleRects(rect); const handle = handles.find((candidate) => pointInRect(position, candidate.rect)); @@ -71,7 +94,7 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi return undefined; } -function isTransformTargetLocked(document: ImageDocument, target: { type: "artboard" | "layer"; id: string }) { +function isTransformTargetLocked(document: ImageDocument, target: InputTransformTarget) { if (target.type === "artboard") { const artboard = document.artboards.find((candidate) => candidate.id === target.id); return !artboard || !artboard.visible || artboard.locked; @@ -97,20 +120,8 @@ function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect return points.map(({ handle, point }) => ({ handle, rect: { x: point.x - half, y: point.y - half, w: size, h: size } })); } -function viewportPointToDocumentPoint(point: Vec2D, viewport: EditorState["viewport"]): Vec2D { - return { - x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom, - y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom, - }; -} - -function documentRectToViewportRect(rect: Rect, viewport: EditorState["viewport"]): Rect { - return { - x: viewport.size.w / 2 + (rect.x - viewport.center.x) * viewport.zoom, - y: viewport.size.h / 2 + (rect.y - viewport.center.y) * viewport.zoom, - w: rect.w * viewport.zoom, - h: rect.h * viewport.zoom, - }; +function isPanInteractionMode(interactionMode: InputInteractionMode): boolean { + return interactionMode.type === "temporary-pan" || (interactionMode.type === "tool" && interactionMode.tool === "pan"); } function pointInRect(point: Vec2D, rect: Rect) { diff --git a/input/viewport-pan-store.test.ts b/input/viewport-pan-store.test.ts index a58dcc7..77c211b 100644 --- a/input/viewport-pan-store.test.ts +++ b/input/viewport-pan-store.test.ts @@ -1,61 +1,101 @@ import { describe, expect, test } from "bun:test"; +import type { Dispatch } from "@commands/dispatcher"; import { commandIds } from "@commands/ids"; -import { toolCommands } from "@commands/tool"; -import { viewportCommands } from "@commands/viewport"; -import { createCommandRegistry } from "@commands/registry"; -import { createInitialAppState } from "@editor/initial-state"; -import { createAppStore } from "@editor/store"; import type { PointerInputEvent } from "./pointer"; import { createViewportPanInputController } from "./viewport-pan"; -const registry = createCommandRegistry([...toolCommands, ...viewportCommands]); +const ignoredState = undefined as never; describe("viewport pan store integration", () => { - test("space key input updates actual store tool mode", () => { - const store = createAppStore(createInitialAppState("Test"), registry); + test("space key input updates actual tool mode", () => { + const store = createInputStore(); const controller = createController(store); expect(controller.keyDown(keyEvent("Space"))).toBe(true); - expect(store.getState().editor.tools.interactionMode).toEqual({ type: "temporary-pan", previousTool: "select" }); + expect(store.state.tools.interactionMode).toEqual({ type: "temporary-pan", previousTool: "select" }); expect(controller.keyUp(keyEvent("Space"))).toBe(true); - expect(store.getState().editor.tools.interactionMode).toEqual({ type: "tool", tool: "select" }); + expect(store.state.tools.interactionMode).toEqual({ type: "tool", tool: "select" }); }); test("left-drag only pans while temporary pan is active", () => { - const store = createAppStore(createInitialAppState("Test"), registry); + const store = createInputStore(); const controller = createController(store); expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(false); expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 10, y: 0 } }))).toBe(false); - expect(store.getState().editor.viewport.center).toEqual({ x: 0, y: 0 }); + expect(store.state.viewport.center).toEqual({ x: 0, y: 0 }); store.dispatch(commandIds.toolEnterTemporaryPan, undefined); expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true); expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 10, y: 0 } }))).toBe(true); - expect(store.getState().editor.viewport.center).toEqual({ x: -10, y: 0 }); + expect(store.state.viewport.center).toEqual({ x: -10, y: 0 }); }); test("left-drag pans while pan tool is active", () => { - const store = createAppStore(createInitialAppState("Test"), registry); + const store = createInputStore(); const controller = createController(store); store.dispatch(commandIds.toolSetActive, { tool: "pan" }); expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true); expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 4, y: -2 } }))).toBe(true); - expect(store.getState().editor.viewport.center).toEqual({ x: -4, y: 2 }); + expect(store.state.viewport.center).toEqual({ x: -4, y: 2 }); }); }); -function createController(store: ReturnType) { +type InputToolId = "select" | "crop" | "brush" | "eraser" | "pan"; + +type InputStore = ReturnType; + +function createInputStore() { + const store = { + state: { + viewport: { center: { x: 0, y: 0 }, zoom: 1 }, + tools: { + activeTool: "select" as InputToolId, + interactionMode: { type: "tool" as const, tool: "select" as InputToolId }, + }, + }, + dispatch: ((commandId, payload) => { + switch (commandId) { + case commandIds.toolSetActive: + store.state.tools.activeTool = payload.tool; + store.state.tools.interactionMode = { type: "tool", tool: payload.tool }; + break; + case commandIds.toolEnterTemporaryPan: + store.state.tools.interactionMode = { type: "temporary-pan", previousTool: store.state.tools.activeTool }; + break; + case commandIds.toolExitTemporaryPan: { + const mode = store.state.tools.interactionMode; + if (mode.type === "temporary-pan") { + store.state.tools.activeTool = mode.previousTool; + store.state.tools.interactionMode = { type: "tool", tool: mode.previousTool }; + } + break; + } + case commandIds.viewportPan: + store.state.viewport.center = { + x: store.state.viewport.center.x + payload.delta.x, + y: store.state.viewport.center.y + payload.delta.y, + }; + break; + } + return ignoredState; + }) as Dispatch, + }; + + return store; +} + +function createController(store: InputStore) { return createViewportPanInputController({ globalKeyConsumer: () => false, globalPointerConsumer: () => false, - getCurrentZoom: () => store.getState().editor.viewport.zoom, + getCurrentZoom: () => store.state.viewport.zoom, isPanMode: () => { - const mode = store.getState().editor.tools.interactionMode; + const mode = store.state.tools.interactionMode; return mode.type === "temporary-pan" || (mode.type === "tool" && mode.tool === "pan"); }, dispatch: store.dispatch, diff --git a/renderer/brush-preview.ts b/renderer/brush-preview.ts new file mode 100644 index 0000000..6906c91 --- /dev/null +++ b/renderer/brush-preview.ts @@ -0,0 +1,216 @@ +import type { ImageDocument } from "@core/document"; +import type { Vec2D } from "@core/geometry"; +import type { Layer } from "@core/layer"; +import type { RasterLayer } from "@core/raster-layer"; +import type { EditorState } from "@editor/state"; +import type { RgbaColor, WebGlRendererContext } from "./types"; + +export type BrushPreviewRenderer = { + render(document: ImageDocument, editor: EditorState): void; + dispose(): void; +}; + +const previewHaloColor: RgbaColor = [0, 0, 0, 0.55]; +const previewColor: RgbaColor = [1, 1, 1, 0.92]; +const previewFillOpacity = 0.08; + +export function createBrushPreviewRenderer(context: WebGlRendererContext): BrushPreviewRenderer { + const { gl } = context; + const program = createProgram(gl); + const positionLocation = gl.getAttribLocation(program, "a_position"); + const canvasSizeLocation = gl.getUniformLocation(program, "u_canvasSize"); + const centerLocation = gl.getUniformLocation(program, "u_center"); + const radiusLocation = gl.getUniformLocation(program, "u_radius"); + const hardnessLocation = gl.getUniformLocation(program, "u_hardness"); + const colorLocation = gl.getUniformLocation(program, "u_color"); + const ringWidthLocation = gl.getUniformLocation(program, "u_ringWidth"); + const fillOpacityLocation = gl.getUniformLocation(program, "u_fillOpacity"); + const positionBuffer = gl.createBuffer(); + + if (!canvasSizeLocation || !centerLocation || !radiusLocation || !hardnessLocation || !colorLocation || !ringWidthLocation || !fillOpacityLocation || !positionBuffer) { + throw new Error("Failed to create brush preview renderer"); + } + + return { + render(document, editor) { + const preview = resolveBrushPreview(document, editor, context.canvas); + if (!preview) return; + + gl.disable(gl.SCISSOR_TEST); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.useProgram(program); + + gl.uniform2f(canvasSizeLocation, context.canvas.width, context.canvas.height); + gl.uniform2f(centerLocation, preview.center.x, preview.center.y); + gl.uniform2f(radiusLocation, preview.radius.x, preview.radius.y); + gl.uniform1f(hardnessLocation, preview.hardness); + + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, previewVertices(preview.center, preview.radius), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(positionLocation); + gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); + + drawPreviewPass(gl, colorLocation, ringWidthLocation, fillOpacityLocation, previewHaloColor, 3, 0); + drawPreviewPass(gl, colorLocation, ringWidthLocation, fillOpacityLocation, previewColor, 1.35, previewFillOpacity); + + gl.disable(gl.BLEND); + }, + dispose() { + gl.deleteBuffer(positionBuffer); + gl.deleteProgram(program); + }, + }; +} + +function drawPreviewPass( + gl: WebGL2RenderingContext, + colorLocation: WebGLUniformLocation, + ringWidthLocation: WebGLUniformLocation, + fillOpacityLocation: WebGLUniformLocation, + color: RgbaColor, + ringWidth: number, + fillOpacity: number, +) { + gl.uniform4fv(colorLocation, color); + gl.uniform1f(ringWidthLocation, ringWidth); + gl.uniform1f(fillOpacityLocation, fillOpacity); + gl.drawArrays(gl.TRIANGLES, 0, 6); +} + +function resolveBrushPreview(document: ImageDocument, editor: EditorState, canvas: HTMLCanvasElement) { + if (!editor.brushPreview || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined; + if (editor.tools.interactionMode.type === "temporary-pan" || (editor.tools.interactionMode.type === "tool" && editor.tools.interactionMode.tool === "pan")) return undefined; + + const layer = resolveBrushTargetLayer(document, editor); + if (!layer) return undefined; + + const size = Math.max(1, editor.tools.brush.size); + const zoom = editor.viewport.zoom; + return { + center: documentPointToScreenPoint(canvas, editor.brushPreview.position, editor), + radius: { + x: Math.max(1, Math.abs(layer.transform.scale.x) * size * zoom * 0.5), + y: Math.max(1, Math.abs(layer.transform.scale.y) * size * zoom * 0.5), + }, + hardness: Math.max(0, Math.min(1, editor.tools.brush.hardness / 100)), + }; +} + +function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined { + const editingMask = Boolean(editor.maskEdit); + const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0]; + if (!layerId) return undefined; + + const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); + if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined; + return layer; +} + +function documentPointToScreenPoint(canvas: HTMLCanvasElement, point: Vec2D, editor: EditorState): Vec2D { + return { + x: canvas.width / 2 + (point.x - editor.viewport.center.x) * editor.viewport.zoom, + y: canvas.height / 2 + (point.y - editor.viewport.center.y) * editor.viewport.zoom, + }; +} + +function previewVertices(center: Vec2D, radius: Vec2D) { + const padding = 5; + const x1 = center.x - radius.x - padding; + const x2 = center.x + radius.x + padding; + const y1 = center.y - radius.y - padding; + const y2 = center.y + radius.y + padding; + + return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]); +} + +function findRasterLayer(layers: readonly Layer[], layerId: string): RasterLayer | undefined { + for (const layer of layers) { + if (layer.id === layerId && layer.type === "raster") return layer; + if (layer.type === "group") { + const child = findRasterLayer(layer.children, layerId); + if (child) return child; + } + } + return undefined; +} + +function createProgram(gl: WebGL2RenderingContext) { + const vertexShader = compileShader( + gl, + gl.VERTEX_SHADER, + `#version 300 es + in vec2 a_position; + uniform vec2 u_canvasSize; + out vec2 v_position; + void main() { + vec2 clip = vec2((a_position.x / u_canvasSize.x) * 2.0 - 1.0, 1.0 - (a_position.y / u_canvasSize.y) * 2.0); + gl_Position = vec4(clip, 0.0, 1.0); + v_position = a_position; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform vec2 u_center; + uniform vec2 u_radius; + uniform float u_hardness; + uniform vec4 u_color; + uniform float u_ringWidth; + uniform float u_fillOpacity; + in vec2 v_position; + out vec4 outColor; + void main() { + vec2 radius = max(u_radius, vec2(1.0)); + float minimumRadius = max(1.0, min(radius.x, radius.y)); + float normalizedDistance = length((v_position - u_center) / radius); + float outerDistance = abs(normalizedDistance - 1.0) * minimumRadius; + float outerRing = 1.0 - smoothstep(max(0.0, u_ringWidth - 1.0), u_ringWidth + 1.0, outerDistance); + + float innerRing = 0.0; + if (u_hardness > 0.05 && u_hardness < 0.98) { + float innerDistance = abs(normalizedDistance - u_hardness) * minimumRadius; + innerRing = 0.45 * (1.0 - smoothstep(max(0.0, u_ringWidth - 1.0), u_ringWidth + 1.0, innerDistance)); + } + + float fillStart = min(u_hardness, 0.98); + float fill = normalizedDistance <= 1.0 ? 1.0 - smoothstep(fillStart, 1.0, normalizedDistance) : 0.0; + float alpha = max(max(outerRing, innerRing) * u_color.a, fill * u_fillOpacity); + if (alpha <= 0.001) discard; + outColor = vec4(u_color.rgb * alpha, alpha); + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create brush preview shader program"); + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) ?? "Unknown brush preview program link error"; + gl.deleteProgram(program); + throw new Error(message); + } + + return program; +} + +function compileShader(gl: WebGL2RenderingContext, type: number, source: string) { + const shader = gl.createShader(type); + if (!shader) throw new Error("Failed to create shader"); + + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const message = gl.getShaderInfoLog(shader) ?? "Unknown shader compile error"; + gl.deleteShader(shader); + throw new Error(message); + } + + return shader; +} diff --git a/renderer/image-textures.ts b/renderer/image-textures.ts index e2a198b..bcc4773 100644 --- a/renderer/image-textures.ts +++ b/renderer/image-textures.ts @@ -1,10 +1,14 @@ import type { Asset } from "@core/asset"; import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; +export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay"; + export type ImageTextureRenderer = { syncAssets(assets: readonly Asset[]): void; render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean; renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect): boolean; + renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect): boolean; + renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean; renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect): boolean; dispose(): void; }; @@ -20,6 +24,15 @@ type TextureEntry = { previousTexture?: WebGLTexture; }; +type MaskVisualizationResources = { + program: WebGLProgram; + positionLocation: number; + texCoordLocation: number; + samplerLocation: WebGLUniformLocation; + modeLocation: WebGLUniformLocation; + colorLocation: WebGLUniformLocation; +}; + export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer { const { gl } = context; const program = createProgram(gl); @@ -32,6 +45,13 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord"); const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image"); const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask"); + const maskRevealPreviewProgram = createMaskRevealPreviewProgram(gl); + const maskRevealPreviewPositionLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_position"); + const maskRevealPreviewTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_texCoord"); + const maskRevealPreviewMaskTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_maskTexCoord"); + const maskRevealPreviewSamplerLocation = gl.getUniformLocation(maskRevealPreviewProgram, "u_image"); + const maskRevealPreviewMaskSamplerLocation = gl.getUniformLocation(maskRevealPreviewProgram, "u_mask"); + const maskRevealPreviewOpacityLocation = gl.getUniformLocation(maskRevealPreviewProgram, "u_opacity"); const tintedProgram = createTintedProgram(gl); const tintedPositionLocation = gl.getAttribLocation(tintedProgram, "a_position"); const tintedTexCoordLocation = gl.getAttribLocation(tintedProgram, "a_texCoord"); @@ -41,9 +61,22 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali const texCoordBuffer = gl.createBuffer(); const maskTexCoordBuffer = gl.createBuffer(); const textures = new Map(); + let maskVisualizationResources: MaskVisualizationResources | "failed" | undefined; let disposed = false; - if (!positionBuffer || !texCoordBuffer || !maskTexCoordBuffer || !samplerLocation || !maskedSamplerLocation || !maskedMaskSamplerLocation || !tintedSamplerLocation || !tintedColorLocation) throw new Error("Failed to create image texture renderer"); + if ( + !positionBuffer || + !texCoordBuffer || + !maskTexCoordBuffer || + !samplerLocation || + !maskedSamplerLocation || + !maskedMaskSamplerLocation || + !maskRevealPreviewSamplerLocation || + !maskRevealPreviewMaskSamplerLocation || + !maskRevealPreviewOpacityLocation || + !tintedSamplerLocation || + !tintedColorLocation + ) throw new Error("Failed to create image texture renderer"); gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW); @@ -68,8 +101,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.enable(gl.SCISSOR_TEST); gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h); - gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + enablePremultipliedAlphaBlending(gl); gl.useProgram(program); gl.activeTexture(gl.TEXTURE0); @@ -103,8 +135,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.enable(gl.SCISSOR_TEST); gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h); - gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + enablePremultipliedAlphaBlending(gl); gl.useProgram(maskedProgram); gl.activeTexture(gl.TEXTURE0); @@ -133,6 +164,90 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.disable(gl.BLEND); return true; }, + renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect) { + const clampedOpacity = Math.max(0, Math.min(1, opacity)); + if (clampedOpacity <= 0) return true; + + const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; + const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined; + if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; + + const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed); + const maskEntry = getTextureEntry(context, textures, maskAsset, invalidate, () => disposed); + const texture = renderableTexture(entry); + const maskTexture = renderableTexture(maskEntry); + if (!texture || !maskTexture) return false; + + gl.enable(gl.SCISSOR_TEST); + gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h); + enablePremultipliedAlphaBlending(gl); + gl.useProgram(maskRevealPreviewProgram); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(maskRevealPreviewSamplerLocation, 0); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, maskTexture); + gl.uniform1i(maskRevealPreviewMaskSamplerLocation, 1); + gl.uniform1f(maskRevealPreviewOpacityLocation, clampedOpacity); + + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(maskRevealPreviewPositionLocation); + gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); + gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation); + gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer); + gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation); + gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.disable(gl.BLEND); + return true; + }, + renderMaskVisualization(maskAsset, maskRect, mode, color = [1, 1, 1, 1], clipRect) { + const drawRect = clipRect ? intersectScreenRects(maskRect, clipRect) : maskRect; + if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; + + const resources = getMaskVisualizationResources(gl, () => maskVisualizationResources, (nextResources) => { + maskVisualizationResources = nextResources; + }); + if (!resources) return false; + + const maskEntry = getTextureEntry(context, textures, maskAsset, invalidate, () => disposed); + const maskTexture = renderableTexture(maskEntry); + if (!maskTexture) return false; + + gl.enable(gl.SCISSOR_TEST); + gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h); + enablePremultipliedAlphaBlending(gl); + gl.useProgram(resources.program); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, maskTexture); + gl.uniform1i(resources.samplerLocation, 0); + gl.uniform1i(resources.modeLocation, maskVisualizationModeValue(mode)); + gl.uniform4fv(resources.colorLocation, color); + + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(resources.positionLocation); + gl.vertexAttribPointer(resources.positionLocation, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); + gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(resources.texCoordLocation); + gl.vertexAttribPointer(resources.texCoordLocation, 2, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.disable(gl.BLEND); + return true; + }, renderTinted(asset, rect, color, clipRect) { const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; @@ -143,8 +258,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.enable(gl.SCISSOR_TEST); gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h); - gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + enablePremultipliedAlphaBlending(gl); gl.useProgram(tintedProgram); gl.activeTexture(gl.TEXTURE0); @@ -174,6 +288,8 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.deleteBuffer(maskTexCoordBuffer); gl.deleteProgram(program); gl.deleteProgram(maskedProgram); + gl.deleteProgram(maskRevealPreviewProgram); + if (maskVisualizationResources && maskVisualizationResources !== "failed") gl.deleteProgram(maskVisualizationResources.program); gl.deleteProgram(tintedProgram); }, }; @@ -274,11 +390,17 @@ function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); return texture; } +function enablePremultipliedAlphaBlending(gl: WebGL2RenderingContext) { + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); +} + function fullTexCoords() { return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]); } @@ -344,6 +466,108 @@ function createProgram(gl: WebGL2RenderingContext) { return program; } +function getMaskVisualizationResources( + gl: WebGL2RenderingContext, + getResources: () => MaskVisualizationResources | "failed" | undefined, + setResources: (resources: MaskVisualizationResources | "failed") => void, +): MaskVisualizationResources | undefined { + const currentResources = getResources(); + if (currentResources === "failed") return undefined; + if (currentResources) return currentResources; + + try { + const resources = createMaskVisualizationProgram(gl); + setResources(resources); + return resources; + } catch { + setResources("failed"); + return undefined; + } +} + +function maskVisualizationModeValue(mode: MaskVisualizationMode) { + switch (mode) { + case "blackWhite": + return 0; + case "alpha": + return 1; + case "hiddenOverlay": + return 2; + } +} + +function createMaskVisualizationProgram(gl: WebGL2RenderingContext): MaskVisualizationResources { + const vertexShader = compileShader( + gl, + gl.VERTEX_SHADER, + `#version 300 es + in vec2 a_position; + in vec2 a_texCoord; + out vec2 v_texCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform sampler2D u_mask; + uniform int u_mode; + uniform vec4 u_color; + in vec2 v_texCoord; + out vec4 outColor; + void main() { + float maskAlpha = texture(u_mask, v_texCoord).a; + if (u_mode == 0) { + float value = step(0.5, maskAlpha); + outColor = vec4(value, value, value, 1.0); + return; + } + if (u_mode == 1) { + outColor = vec4(maskAlpha, maskAlpha, maskAlpha, 1.0); + return; + } + + float alpha = (1.0 - maskAlpha) * u_color.a; + outColor = vec4(u_color.rgb * alpha, alpha); + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create mask visualization shader program"); + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) ?? "Unknown mask visualization program link error"; + gl.deleteProgram(program); + throw new Error(message); + } + + const samplerLocation = gl.getUniformLocation(program, "u_mask"); + const modeLocation = gl.getUniformLocation(program, "u_mode"); + const colorLocation = gl.getUniformLocation(program, "u_color"); + if (!samplerLocation || !modeLocation || !colorLocation) { + gl.deleteProgram(program); + throw new Error("Failed to resolve mask visualization shader uniforms"); + } + + return { + program, + positionLocation: gl.getAttribLocation(program, "a_position"), + texCoordLocation: gl.getAttribLocation(program, "a_texCoord"), + samplerLocation, + modeLocation, + colorLocation, + }; +} + function createTintedProgram(gl: WebGL2RenderingContext) { const vertexShader = compileShader( gl, @@ -367,8 +591,8 @@ function createTintedProgram(gl: WebGL2RenderingContext) { in vec2 v_texCoord; out vec4 outColor; void main() { - float maskAlpha = texture(u_image, v_texCoord).a; - outColor = vec4(u_color.rgb, u_color.a * maskAlpha); + float alpha = u_color.a * texture(u_image, v_texCoord).a; + outColor = vec4(u_color.rgb * alpha, alpha); }`, ); const program = gl.createProgram(); @@ -389,6 +613,58 @@ function createTintedProgram(gl: WebGL2RenderingContext) { return program; } +function createMaskRevealPreviewProgram(gl: WebGL2RenderingContext) { + const vertexShader = compileShader( + gl, + gl.VERTEX_SHADER, + `#version 300 es + in vec2 a_position; + in vec2 a_texCoord; + in vec2 a_maskTexCoord; + out vec2 v_texCoord; + out vec2 v_maskTexCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + v_maskTexCoord = a_maskTexCoord; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform sampler2D u_image; + uniform sampler2D u_mask; + uniform float u_opacity; + in vec2 v_texCoord; + in vec2 v_maskTexCoord; + out vec4 outColor; + void main() { + vec4 color = texture(u_image, v_texCoord); + float hiddenMaskAlpha = 1.0 - texture(u_mask, v_maskTexCoord).a; + float previewAlpha = clamp(hiddenMaskAlpha * u_opacity, 0.0, 1.0); + outColor = vec4(color.rgb * previewAlpha, color.a * previewAlpha); + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create mask reveal preview shader program"); + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) ?? "Unknown mask reveal preview program link error"; + gl.deleteProgram(program); + throw new Error(message); + } + + return program; +} + function createMaskedProgram(gl: WebGL2RenderingContext) { const vertexShader = compileShader( gl, @@ -418,7 +694,8 @@ function createMaskedProgram(gl: WebGL2RenderingContext) { void main() { vec4 color = texture(u_image, v_texCoord); float maskAlpha = texture(u_mask, v_maskTexCoord).a; - outColor = vec4(color.rgb, color.a * maskAlpha); + float alpha = color.a * maskAlpha; + outColor = vec4(color.rgb * maskAlpha, alpha); }`, ); const program = gl.createProgram(); diff --git a/renderer/layers.ts b/renderer/layers.ts index e15fa20..54904fb 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -1,6 +1,6 @@ import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; -import type { EditorState, ViewportState } from "@editor/state"; +import type { EditorState, MaskViewMode, ViewportState } from "@editor/state"; import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { clearScreenRect } from "./clear-rect"; import type { ImageTextureRenderer } from "./image-textures"; @@ -9,6 +9,8 @@ import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; +const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45]; +const maskRevealPreviewOpacity = 0.28; export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) { for (const artboard of document.artboards) { @@ -28,7 +30,9 @@ function renderLayer( clipRect: ScreenRect, maskLayerIds: ReadonlySet, ) { - const editingMaskLayer = editor.maskEditLayerId === layer.id; + const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id; + const maskViewMode = editor.maskEdit?.viewMode ?? "composite"; + const isolatedMaskView = isIsolatedMaskView(maskViewMode); if (!layer.visible || maskLayerIds.has(layer.id)) return; const effectiveClipRect = resolveLayerClipRect(context, document, editor.viewport, layer, clipRect); @@ -39,16 +43,33 @@ function renderLayer( return; } + if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) return; + const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); if (!bounds) return; const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); - const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + const asset = assetWithBrushStrokePreview(document.assets.find((candidate) => candidate.id === layer.assetId), editor); const maskLayer = !editingMaskLayer && layer.clippingMask ? findLayer(document, layer.clippingMask.maskLayerId) : undefined; - const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; + const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined, editor); const maskBounds = maskLayer ? resolveTransformTargetBounds(document, { type: "layer", id: maskLayer.id }) : undefined; const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined; - if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect)) return; + const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId); + const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite"; + + if (asset && maskAsset && maskRect && activeMaskTarget) { + if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return; + if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return; + if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect)) { + imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect); + return; + } + } + + if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect)) { + if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect); + return; + } if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return; const fallbackRect = intersectScreenRects(rect, effectiveClipRect); @@ -68,11 +89,20 @@ function resolveLayerClipRect( if (!layer.clippingMask) return clipRect; const maskBounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.clippingMask.maskLayerId }); - if (!maskBounds) return undefined; + if (!maskBounds) return clipRect; return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport)); } +function isIsolatedMaskView(mode: MaskViewMode) { + return mode === "blackWhite" || mode === "alpha" || mode === "overlay"; +} + +function assetWithBrushStrokePreview(asset: TAsset, editor: EditorState): TAsset { + if (!asset || editor.brushStrokePreview?.assetId !== asset.id) return asset; + return { ...asset, source: editor.brushStrokePreview.source } as TAsset; +} + function findLayer(document: ImageDocument, layerId: string): Layer | undefined { for (const artboard of document.artboards) { const layer = findLayerInTree(artboard.layers, layerId); diff --git a/renderer/mask-edit-overlay.ts b/renderer/mask-edit-overlay.ts deleted file mode 100644 index 92dce56..0000000 --- a/renderer/mask-edit-overlay.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ImageDocument } from "@core/document"; -import type { Layer } from "@core/layer"; -import type { EditorState } from "@editor/state"; -import { resolveTransformTargetBounds } from "@editor/transform-targets"; -import type { ImageTextureRenderer } from "./image-textures"; -import { documentRectToScreenRect } from "./screen-rect"; -import type { WebGlRendererContext } from "./types"; - -const maskOverlayColor = [0.25, 0.65, 1, 0.35] as const; - -export function renderMaskEditOverlay( - context: WebGlRendererContext, - document: ImageDocument, - editor: EditorState, - imageTextureRenderer: ImageTextureRenderer, -) { - const layerId = editor.maskEditLayerId; - if (!layerId) return; - - const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); - if (!layer || layer.type === "group") return; - - const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); - const asset = document.assets.find((candidate) => candidate.id === layer.assetId); - if (!bounds || !asset) return; - - const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); - imageTextureRenderer.renderTinted(asset, rect, maskOverlayColor); -} - -function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined { - for (const layer of layers) { - if (layer.id === layerId) return layer; - if (layer.type === "group") { - const child = findLayer(layer.children, layerId); - if (child) return child; - } - } - return undefined; -} diff --git a/renderer/renderer.ts b/renderer/renderer.ts index c0b5322..0b1134d 100644 --- a/renderer/renderer.ts +++ b/renderer/renderer.ts @@ -1,9 +1,9 @@ import type { ImageDocument } from "@core/document"; import type { EditorState } from "@editor/state"; import { renderArtboard } from "./artboard"; +import { createBrushPreviewRenderer } from "./brush-preview"; import { createImageTextureRenderer } from "./image-textures"; import { renderLayers } from "./layers"; -import { renderMaskEditOverlay } from "./mask-edit-overlay"; import { renderSelectionOverlay } from "./selection"; import { renderTransformControls } from "./transform-controls"; import type { WebGlRendererContext } from "./types"; @@ -31,6 +31,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke } const rendererContext: WebGlRendererContext = { gl: context, canvas }; + const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext); let lastFrame: RenderFrame | undefined; let rerenderQueued = false; const imageTextureRenderer = createImageTextureRenderer(rendererContext, () => { @@ -61,17 +62,28 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke } imageTextureRenderer.syncAssets(frame.document.assets); renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer); - renderMaskEditOverlay(rendererContext, frame.document, frame.editor, imageTextureRenderer); - renderSelectionOverlay(rendererContext, frame.document, frame.editor); - renderTransformControls(rendererContext, frame.document, frame.editor); + if (!frame.editor.maskEdit) { + renderSelectionOverlay(rendererContext, frame.document, frame.editor); + renderTransformControls(rendererContext, frame.document, frame.editor); + } + brushPreviewRenderer?.render(frame.document, frame.editor); context.disable(context.SCISSOR_TEST); }, dispose() { imageTextureRenderer.dispose(); + brushPreviewRenderer?.dispose(); }, }; return renderer; } + +function createOptionalBrushPreviewRenderer(context: WebGlRendererContext) { + try { + return createBrushPreviewRenderer(context); + } catch { + return undefined; + } +} diff --git a/view/App.tsx b/view/App.tsx index 63e3c5d..654678a 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; +import { DownloadSimple, FolderOpen, ImageSquare, Stack } from "@phosphor-icons/react"; import type { ImageStudioApp } from "@app/app"; +import { commandIds } from "@commands/ids"; import { BottomControlsIsland } from "./BottomControlsIsland"; import { CanvasViewport } from "./CanvasViewport"; import { LayersSheet } from "./LayersSheet"; @@ -8,6 +10,7 @@ import { labelForTool } from "./toolLabels"; import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; import { handleDeleteSelectionKey, handleHistoryKey, keybindEventFromKeyboardEvent } from "@input/index"; import { useAppState } from "./useAppState"; +import { downloadArtboardPng } from "./exportArtboardPng"; import { useImageImport } from "./useImageImport"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; import "./index.css"; @@ -23,6 +26,8 @@ export function App({ app }: AppProps) { const imageImport = useImageImport(app.store); const [layersOpen, setLayersOpen] = useState(false); const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection); + const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0]; + const activeToolLabel = state.editor.maskEdit ? "Mask edit" : labelForTool(state.editor.tools.activeTool); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -41,6 +46,12 @@ export function App({ app }: AppProps) { if (event.altKey || event.ctrlKey || event.metaKey) return; + if (event.key === "Escape" && app.store.getState().editor.maskEdit) { + app.store.dispatch(commandIds.toolExitMaskEdit, undefined); + event.preventDefault(); + return; + } + if (event.key.toLowerCase() === "l") { setLayersOpen(true); event.preventDefault(); @@ -63,11 +74,39 @@ export function App({ app }: AppProps) { return (
{imageImport.input} -
-

Image Studio

-
- {state.document.name} · {labelForTool(state.editor.tools.activeTool)} · {zoomPercent}% · {state.editor.viewport.size.w}× - {state.editor.viewport.size.h} +
+
+
+ +
+
+

Image Studio

+
{state.document.name}
+
+
+ +
+ {activeToolLabel} + {zoomPercent}% + + {state.editor.viewport.size.w}×{state.editor.viewport.size.h} + + {activeArtboard ? {activeArtboard.name} : null} +
+ +
+ + +
@@ -80,7 +119,7 @@ export function App({ app }: AppProps) { setLayersOpen(false)} @@ -92,6 +131,8 @@ export function App({ app }: AppProps) { action={viewportActivityIsland.action} activeTool={state.editor.tools.activeTool} brushSettings={state.editor.tools.brush} + editingMask={Boolean(state.editor.maskEdit)} + maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"} transformBounds={viewportActivityIsland.visible ? undefined : transformBounds} transformTarget={viewportActivityIsland.visible ? undefined : transformTarget} dispatch={app.store.dispatch} @@ -102,4 +143,13 @@ export function App({ app }: AppProps) { ); } +function topBarChipClass() { + return "rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs font-medium text-white/70"; +} + +function topBarButtonClass(active = false) { + const base = "inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"; + return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`; +} + export default App; diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index c533bde..fec4e4b 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -1,5 +1,5 @@ import type { AppStore } from "@editor/store"; -import type { ViewportState } from "@editor/state"; +import type { MaskViewMode, ViewportState } from "@editor/state"; import type { BrushSettings, ToolId } from "@editor/tools"; import { BrushControls } from "./bottom-controls/BrushControls"; import { PanControls } from "./bottom-controls/PanControls"; @@ -15,12 +15,14 @@ export type BottomControlsIslandProps = { action: BottomControlsAction; activeTool: ToolId; brushSettings: BrushSettings; + editingMask?: boolean; + maskViewMode?: MaskViewMode; transformBounds?: Rect; transformTarget?: TransformTarget; dispatch: AppStore["dispatch"]; }; -export function BottomControlsIsland({ viewport, visible, action, activeTool, brushSettings, transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) { +export function BottomControlsIsland({ viewport, visible, action, activeTool, brushSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) { const zoomPercent = Math.round(viewport.zoom * 100); const x = Math.round(viewport.center.x); const y = Math.round(viewport.center.y); @@ -33,7 +35,7 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br }`} > {activeTool === "brush" || activeTool === "eraser" ? ( - + ) : transformBounds && transformTarget ? ( ) : action === "pan" ? ( diff --git a/view/CanvasViewport.tsx b/view/CanvasViewport.tsx index 2bcff44..aa5eb08 100644 --- a/view/CanvasViewport.tsx +++ b/view/CanvasViewport.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef } from "react"; import type { AppStore } from "@editor/store"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; +import { canPreviewBrush } from "./canvas/brush"; import { canvasCursorClass } from "./canvas/cursor"; import { useCanvasInput } from "./canvas/useCanvasInput"; import { useCanvasRenderer } from "./canvas/useCanvasRenderer"; @@ -34,7 +35,8 @@ export function CanvasViewport({ useCanvasRenderer(canvasRef, store); useCanvasResize(canvasRef, store.dispatch); const input = useCanvasInput(canvasRef, store, inputOptions); - const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input); + const hasBrushPreview = Boolean(state.editor.brushPreview && canPreviewBrush(state.document, state.editor)); + const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input, hasBrushPreview); return ; } diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index 0349013..9fc5250 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -1,35 +1,37 @@ import { useRef, useState, type DragEvent, type MutableRefObject } from "react"; -import { DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash, X } from "@phosphor-icons/react"; +import { DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; import type { ArtboardId } from "@core/id"; -import type { SelectionState } from "@editor/state"; +import type { MaskEditState, SelectionState } from "@editor/state"; import type { AppStore } from "@editor/store"; +import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index"; import { downloadArtboardPng } from "./exportArtboardPng"; export type LayersSheetProps = { document: ImageDocument; selection: SelectionState; - maskEditLayerId?: string; + maskEdit?: MaskEditState; open: boolean; dispatch: AppStore["dispatch"]; onClose: () => void; }; -export function LayersSheet({ document, selection, maskEditLayerId, open, dispatch, onClose }: LayersSheetProps) { +export function LayersSheet({ document, selection, maskEdit, open, dispatch, onClose }: LayersSheetProps) { const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id; const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]); const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0); const canUngroup = selectedLayer?.layer.type === "group"; + const maskLayerIds = collectDocumentMaskLayerIds(document); const draggedLayerId = useRef(); const [editingTitle, setEditingTitle] = useState(); return (
- +
- {artboard.layers.length} + {countDisplayLayers(artboard.layers, maskLayerIds)}
- {artboard.layers.length === 0 ? ( + {countDisplayLayers(artboard.layers, maskLayerIds) === 0 ? (
No layers yet
) : ( artboard.layers.map((layer) => ( @@ -137,8 +137,8 @@ export function LayersSheet({ document, selection, maskEditLayerId, open, dispat draggedLayerId={draggedLayerId} editingTitle={editingTitle} setEditingTitle={setEditingTitle} - selectedMaskLayer={selectedLayer} - maskEditLayerId={maskEditLayerId} + maskLayerIds={maskLayerIds} + maskEdit={maskEdit} dispatch={dispatch} /> )) @@ -160,8 +160,8 @@ function LayerRow({ draggedLayerId, editingTitle, setEditingTitle, - selectedMaskLayer, - maskEditLayerId, + maskLayerIds, + maskEdit, dispatch, }: { document: ImageDocument; @@ -172,23 +172,25 @@ function LayerRow({ draggedLayerId: MutableRefObject; editingTitle: EditingTitle | undefined; setEditingTitle: (editingTitle: EditingTitle | undefined) => void; - selectedMaskLayer?: LayerInfo; - maskEditLayerId?: string; + maskLayerIds: ReadonlySet; + maskEdit?: MaskEditState; dispatch: AppStore["dispatch"]; }) { + if (maskLayerIds.has(layer.id)) return null; + const selected = selectedLayerIds.includes(layer.id); const layerInfo = findLayerInfoInDocument(document, layer.id); const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined; - const maskIndent = maskLayer ? 24 : 0; - const canSetMask = - Boolean(selectedMaskLayer && layerInfo && selectedMaskLayer.layer.id !== layer.id && selectedMaskLayer.artboardId === layerInfo.artboardId && selectedMaskLayer.parentGroupId === layerInfo.parentGroupId); - const editingMask = maskEditLayerId === layer.clippingMask?.maskLayerId; + const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask); + const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId); + const rowPadding = 12 + depth * 16; + return (
{ event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", layer.id); @@ -207,8 +209,7 @@ function LayerRow({ draggedLayerId.current = undefined; }} > - {maskLayer ? : null} - {editingTitle?.type === "layer" && editingTitle.id === layer.id ? ( @@ -231,30 +232,46 @@ function LayerRow({ {layer.name} )} - - + ) : null} +
- {maskLayer ? ( -
- - masked by {maskLayer.name} + {layer.clippingMask ? ( +
+ + {maskLayer ? `Layer mask · ${maskLayer.name}` : "Layer mask missing"} + {maskLayer ? ( + + ) : null}
) : null} @@ -270,8 +287,8 @@ function LayerRow({ draggedLayerId={draggedLayerId} editingTitle={editingTitle} setEditingTitle={setEditingTitle} - selectedMaskLayer={selectedMaskLayer} - maskEditLayerId={maskEditLayerId} + maskLayerIds={maskLayerIds} + maskEdit={maskEdit} dispatch={dispatch} /> )) @@ -304,6 +321,69 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o ); } +function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: AppStore["dispatch"]) { + const layer = layerInfo.layer; + if (layer.type === "group") return; + + const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); + if (!asset || !bounds) return; + + const assetId = crypto.randomUUID(); + const maskLayerId = crypto.randomUUID(); + const width = Math.max(1, Math.round(asset.intrinsicSize.w)); + const height = Math.max(1, Math.round(asset.intrinsicSize.h)); + const source = `data:image/svg+xml,${encodeURIComponent(``)}`; + + dispatch(commandIds.documentAddLayerMask, { + layerId: layer.id, + asset: { + id: assetId, + name: `${layer.name} Mask`, + mimeType: "image/svg+xml", + source, + intrinsicSize: { w: width, h: height }, + }, + maskLayer: { + id: maskLayerId, + type: "raster", + name: `${layer.name} Mask`, + visible: true, + locked: false, + opacity: 1, + assetId, + transform: { + position: { x: bounds.x, y: bounds.y }, + scale: { x: bounds.w / width, y: bounds.h / height }, + rotation: layer.transform.rotation, + }, + }, + }); +} + +function collectDocumentMaskLayerIds(document: ImageDocument): Set { + const ids = new Set(); + for (const artboard of document.artboards) collectMaskLayerIds(artboard.layers, ids); + return ids; +} + +function collectMaskLayerIds(layers: readonly Layer[], ids: Set) { + for (const layer of layers) { + if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); + if (layer.type === "group") collectMaskLayerIds(layer.children, ids); + } +} + +function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet): number { + let count = 0; + for (const layer of layers) { + if (maskLayerIds.has(layer.id)) continue; + count += 1; + if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds); + } + return count; +} + function dropLayer( document: ImageDocument, sourceLayerId: string, diff --git a/view/bottom-controls/BrushControls.tsx b/view/bottom-controls/BrushControls.tsx index f981a46..6bd8dab 100644 --- a/view/bottom-controls/BrushControls.tsx +++ b/view/bottom-controls/BrushControls.tsx @@ -1,5 +1,6 @@ import { commandIds } from "@commands/ids"; import type { AppStore } from "@editor/store"; +import type { MaskViewMode } from "@editor/state"; import type { BrushSettings, ToolId } from "@editor/tools"; import { BottomControlDivider } from "./Divider"; import { bottomControlLabelClass } from "./styles"; @@ -7,15 +8,17 @@ import { bottomControlLabelClass } from "./styles"; export type BrushControlsProps = { tool: Extract; settings: BrushSettings; + editingMask?: boolean; + maskViewMode?: MaskViewMode; dispatch: AppStore["dispatch"]; }; -export function BrushControls({ tool, settings, dispatch }: BrushControlsProps) { +export function BrushControls({ tool, settings, editingMask = false, maskViewMode = "composite", dispatch }: BrushControlsProps) { return (
- {tool === "eraser" ? "Eraser" : "Brush"} + {editingMask ? `Mask · ${tool === "eraser" ? "Hide" : "Reveal"}` : tool === "eraser" ? "Eraser" : "Brush"} - {tool === "brush" ? ( + {tool === "brush" && !editingMask ? ( + {editingMask ? ( + <> + + + + + + ) : null}
); } diff --git a/view/canvas/brush.ts b/view/canvas/brush.ts index feda50e..fdca2d8 100644 --- a/view/canvas/brush.ts +++ b/view/canvas/brush.ts @@ -9,48 +9,88 @@ import type { AppStore } from "@editor/store"; export type BrushSession = { layerId: string; + assetId: string; previousPoint: Vec2D; mode: "brush" | "eraser"; + source?: string; + pending?: Promise; + cancelled?: boolean; }; export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined { - if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined; - const layerId = editor.maskEditLayerId ?? editor.selection.layerIds[0]; - if (!layerId) return undefined; - const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); - if (!layer || layer.locked || !layer.visible) return undefined; - return { layerId, previousPoint: point, mode: editor.tools.activeTool }; + const layer = resolveBrushTargetLayer(document, editor); + if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined; + return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool }; } -export async function updateBrushSession(options: { +export function canPreviewBrush(document: ImageDocument, editor: EditorState): boolean { + return Boolean(resolveBrushTargetLayer(document, editor)); +} + +function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined { + if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined; + const editingMask = Boolean(editor.maskEdit); + const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0]; + if (!layerId) return undefined; + const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); + if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined; + return layer; +} + +export function updateBrushSession(options: { store: AppStore; session: BrushSession; point: Vec2D; color: string; size: number; hardness: number; -}): Promise { +}): BrushSession { const state = options.store.getState(); const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId); - if (!layer) return { ...options.session, previousPoint: options.point }; + if (!layer) return options.session; const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId); - if (!asset) return { ...options.session, previousPoint: options.point }; + if (!asset) return options.session; - const source = await drawStroke({ - source: asset.source, - width: asset.intrinsicSize.w, - height: asset.intrinsicSize.h, - from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h), - to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h), - color: state.editor.maskEditLayerId ? "#ffffff" : options.color, - size: options.size, - hardness: options.hardness, - mode: options.session.mode, - }); + const from = options.session.previousPoint; + const to = options.point; + options.session.previousPoint = to; + options.session.pending = (options.session.pending ?? Promise.resolve()) + .then(async () => { + if (options.session.cancelled) return; - options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: asset.id, source }); - return { ...options.session, previousPoint: options.point }; + const source = await drawStroke({ + source: options.session.source ?? asset.source, + width: asset.intrinsicSize.w, + height: asset.intrinsicSize.h, + from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h), + to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h), + color: state.editor.maskEdit ? "#ffffff" : options.color, + size: options.size, + hardness: options.hardness, + mode: options.session.mode, + }); + + if (options.session.cancelled) return; + options.session.source = source; + options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source }); + }) + .catch(() => undefined); + + return options.session; +} + +export async function commitBrushSession(options: { store: AppStore; session: BrushSession }) { + await options.session.pending; + if (options.session.cancelled) return; + + if (options.session.source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source: options.session.source }); + options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined); +} + +export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) { + options.session.cancelled = true; + options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined); } function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D { diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index 611f902..9c10447 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -2,9 +2,10 @@ import type { InteractionMode } from "@editor/tools"; import { isPanInteractionMode } from "@editor/tools"; import type { CanvasInputState } from "./useCanvasInput"; -export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) { +export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState, hasBrushPreview = false) { if (input.isPanning) return "cursor-grabbing"; if (isPanInteractionMode(interactionMode)) return "cursor-grab"; - if (interactionMode.type === "tool" && (interactionMode.tool === "crop" || interactionMode.tool === "brush" || interactionMode.tool === "eraser")) return "cursor-crosshair"; + if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) return hasBrushPreview ? "cursor-none" : "cursor-crosshair"; + if (interactionMode.type === "tool" && interactionMode.tool === "crop") return "cursor-crosshair"; return "cursor-default"; } diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index c11d866..bbb34f9 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type RefObject } from "react"; +import { commandIds } from "@commands/ids"; import type { AppStore } from "@editor/store"; import { isPanInteractionMode } from "@editor/tools"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; @@ -11,7 +12,7 @@ import { pointerInputEventFromPointerEvent, wheelInputEventFromWheelEvent, } from "@input/index"; -import { beginBrushSession, updateBrushSession, type BrushSession } from "./brush"; +import { beginBrushSession, canPreviewBrush, commitBrushSession, updateBrushSession, type BrushSession } from "./brush"; export type CanvasInputOptions = { globalKeybindConsumer: GlobalKeybindConsumer; @@ -50,9 +51,31 @@ export function useCanvasInput( isPanMode: () => isPanInteractionMode(store.getState().editor.tools.interactionMode), }); + const clearBrushPreview = () => { + if (store.getState().editor.brushPreview) store.dispatch(commandIds.toolSetBrushPreview, undefined); + }; + + const updateBrushPreview = (position: { x: number; y: number }) => { + if (!pointInsideCanvas(position, canvas)) { + clearBrushPreview(); + return; + } + + const state = store.getState(); + if (!canPreviewBrush(state.document, state.editor)) { + clearBrushPreview(); + return; + } + + store.dispatch(commandIds.toolSetBrushPreview, { position: viewportPointToDocumentPoint(position, state.editor.viewport) }); + }; + const handleKeyDown = (event: KeyboardEvent) => { const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event)); - if (consumed) event.preventDefault(); + if (consumed) { + clearBrushPreview(); + event.preventDefault(); + } }; const handleKeyUp = (event: KeyboardEvent) => { @@ -64,6 +87,7 @@ export function useCanvasInput( const inputEvent = pointerInputEventFromPointerEvent(event); const transformed = transformHandler.pointerDown(inputEvent); if (transformed) { + clearBrushPreview(); canvas.setPointerCapture(event.pointerId); event.preventDefault(); return; @@ -71,6 +95,7 @@ export function useCanvasInput( const consumed = panHandler.pointerDown(inputEvent); if (consumed) { + clearBrushPreview(); canvas.setPointerCapture(event.pointerId); setIsPanning(true); event.preventDefault(); @@ -78,10 +103,12 @@ export function useCanvasInput( } const state = store.getState(); + const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport); const brush = (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode) - ? beginBrushSession(state.document, state.editor, viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport)) + ? beginBrushSession(state.document, state.editor, documentPoint) : undefined; if (brush) { + store.dispatch(commandIds.toolSetBrushPreview, { position: documentPoint }); brushSessionId.current += 1; brushSession.current = brush; canvas.setPointerCapture(event.pointerId); @@ -104,43 +131,53 @@ export function useCanvasInput( const inputEvent = pointerInputEventFromPointerEvent(event); if (brushSession.current) { if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) { + const session = brushSession.current; brushSessionId.current += 1; brushSession.current = undefined; + void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position)); event.preventDefault(); return; } - const activeSessionId = brushSessionId.current; const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport); + store.dispatch(commandIds.toolSetBrushPreview, { position: point }); const settings = store.getState().editor.tools.brush; - void updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness }).then((nextSession) => { - if (brushSessionId.current === activeSessionId) brushSession.current = nextSession; - }); + brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness }); event.preventDefault(); return; } const transformed = transformHandler.pointerMove(inputEvent); if (transformed) { + clearBrushPreview(); event.preventDefault(); return; } const consumed = panHandler.pointerMove(inputEvent); - if (consumed) event.preventDefault(); + if (consumed) { + clearBrushPreview(); + event.preventDefault(); + return; + } + + updateBrushPreview(inputEvent.position); }; const handlePointerUp = (event: PointerEvent) => { const inputEvent = pointerInputEventFromPointerEvent(event); if (brushSession.current) { + const session = brushSession.current; brushSessionId.current += 1; brushSession.current = undefined; + void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position)); event.preventDefault(); return; } const transformed = transformHandler.pointerUp(inputEvent); if (transformed) { + clearBrushPreview(); event.preventDefault(); return; } @@ -149,9 +186,14 @@ export function useCanvasInput( if (!consumed) return; setIsPanning(false); + clearBrushPreview(); event.preventDefault(); }; + const handlePointerLeave = () => { + if (!brushSession.current) clearBrushPreview(); + }; + const handleWheel = (event: WheelEvent) => { const consumed = handleViewportWheel({ event: wheelInputEventFromWheelEvent(event), @@ -169,6 +211,7 @@ export function useCanvasInput( canvas.addEventListener("pointermove", handlePointerMove); canvas.addEventListener("pointerup", handlePointerUp); canvas.addEventListener("pointercancel", handlePointerUp); + canvas.addEventListener("pointerleave", handlePointerLeave); canvas.addEventListener("wheel", handleWheel, { passive: false }); return () => { @@ -178,6 +221,7 @@ export function useCanvasInput( canvas.removeEventListener("pointermove", handlePointerMove); canvas.removeEventListener("pointerup", handlePointerUp); canvas.removeEventListener("pointercancel", handlePointerUp); + canvas.removeEventListener("pointerleave", handlePointerLeave); canvas.removeEventListener("wheel", handleWheel); }; }, [canvasRef, options, store]); @@ -185,6 +229,10 @@ export function useCanvasInput( return { isPanning }; } +function pointInsideCanvas(point: { x: number; y: number }, canvas: HTMLCanvasElement) { + return point.x >= 0 && point.y >= 0 && point.x <= canvas.width && point.y <= canvas.height; +} + function viewportPointToDocumentPoint(point: { x: number; y: number }, viewport: { center: { x: number; y: number }; size: { w: number; h: number }; zoom: number }) { return { x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,