feat: enhance layer masking functionality and brush controls

- Refactor LayersSheet component to support mask editing state and improve layer visibility handling.
- Introduce functions to collect mask layer IDs and count display layers excluding masks.
- Update BrushControls to include mask view mode options and a Done button for exiting mask editing.
- Modify brush session handling to support brush previews when editing masks.
- Implement a new BrushPreviewRenderer for rendering brush strokes with visual feedback.
- Add document geometry utilities for transforming points and resolving layer bounds.
- Ensure proper cleanup of brush preview on pointer leave and other interactions.
This commit is contained in:
syntaxbullet
2026-07-03 20:49:29 +02:00
parent daaa2a6667
commit 4ad0bb8b2c
33 changed files with 1889 additions and 351 deletions

View File

@@ -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,
},
};
}

View File

@@ -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<typeof group>[]) {
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 } };
}

View File

@@ -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<DocumentAddArtboardPayload> = {
id: commandIds.documentAddArtboard,
name: "Add artboard",
@@ -153,13 +163,19 @@ export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayloa
name: "Remove artboard",
execute({ state }, payload) {
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
const document = {
...state.document,
artboards: state.document.artboards.filter((artboard) => 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<DocumentMoveLayerPayload> = {
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<DocumentGroupLayersPayload> = {
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<DocumentSetLayerClippi
if (payload.maskLayerId === payload.layerId) return state;
if (!payload.maskLayerId) {
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId;
return {
...state,
document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
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<DocumentSetLayerClippi
},
};
export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> = {
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<DocumentRemoveLayerMaskPayload> = {
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<DocumentRemoveLayerPayload> = {
id: commandIds.documentRemoveLayer,
name: "Remove layer",
@@ -409,10 +522,23 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
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<unknown>[];
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<LayerId> {
const ids = new Set<LayerId>();
for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids);
return ids;
}
function collectLayerIds(layer: Layer, ids = new Set<LayerId>()): Set<LayerId> {
ids.add(layer.id);
if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids);
return ids;
}
function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) collectLayerIds(layer, ids);
return ids;
}
function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
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);
});
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

View File

@@ -15,14 +15,19 @@ export const selectionSetCommand: Command<SelectionSetPayload> = {
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<SelectionAddLayerPayload> = {
...state.editor.selection,
layerIds: [...state.editor.selection.layerIds, payload.layerId],
},
maskEdit: undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},

View File

@@ -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 },
};
}

View File

@@ -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<BrushSettings>;
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<ToolSetActivePayload> = {
@@ -26,6 +45,8 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
activeTool: payload.tool,
interactionMode: { type: "tool", tool: payload.tool },
},
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},
@@ -52,15 +73,107 @@ export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> =
},
};
export const toolSetMaskEditLayerCommand: Command<ToolSetMaskEditLayerPayload> = {
id: commandIds.toolSetMaskEditLayer,
name: "Set mask edit layer",
export const toolSetBrushPreviewCommand: Command<ToolSetBrushPreviewPayload> = {
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<ToolSetBrushStrokePreviewPayload> = {
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<ToolSetMaskViewModePayload> = {
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<ToolEnterMaskEditPayload> = {
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<unknown>[];
export const toolCommands = [
toolSetActiveCommand,
toolSetBrushSettingsCommand,
toolSetBrushPreviewCommand,
toolSetBrushStrokePreviewCommand,
toolSetMaskViewModeCommand,
toolEnterMaskEditCommand,
toolExitMaskEditCommand,
toolEnterTemporaryPanCommand,
toolExitTemporaryPanCommand,
] satisfies Command<unknown>[];
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;
}

View File

@@ -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 {

View File

@@ -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 = {

View File

@@ -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 });

View File

@@ -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[] {

107
input/document-geometry.ts Normal file
View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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> = {}): ImageDocument {
return {
id: "d1",
name: "Test",
version: 1,
assets: [],
artboards: [],
...overrides,
};
}
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
return {
pointerId: 1,

View File

@@ -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>): 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<string>()): Set<string> {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
return ids;
}

View File

@@ -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<TransformControlsEditorState> = {}): { 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>): PointerInputEvent {
return {
pointerId: 1,

View File

@@ -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) {

View File

@@ -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<typeof createAppStore>) {
type InputToolId = "select" | "crop" | "brush" | "eraser" | "pan";
type InputStore = ReturnType<typeof createInputStore>;
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,

216
renderer/brush-preview.ts Normal file
View File

@@ -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;
}

View File

@@ -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<string, TextureEntry>();
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();

View File

@@ -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<string>,
) {
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<TAsset extends ImageDocument["assets"][number] | undefined>(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);

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 (
<main className="relative h-full overflow-hidden bg-background text-foreground">
{imageImport.input}
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
<h1 className="text-sm font-medium">Image Studio</h1>
<div className="text-xs">
{state.document.name} · {labelForTool(state.editor.tools.activeTool)} · {zoomPercent}% · {state.editor.viewport.size.w}×
{state.editor.viewport.size.h}
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-12 items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/85 px-2.5 text-white shadow-2xl shadow-black/35 backdrop-blur-xl">
<div className="flex min-w-0 items-center gap-3 pl-1.5">
<div className="grid size-7 place-items-center rounded-lg bg-white text-black shadow-sm">
<ImageSquare size={18} weight="fill" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-4 tracking-wide">Image Studio</h1>
<div className="truncate text-[11px] leading-4 text-white/45">{state.document.name}</div>
</div>
</div>
<div className="hidden min-w-0 flex-1 items-center justify-center gap-1.5 md:flex">
<span className={topBarChipClass()}>{activeToolLabel}</span>
<span className={topBarChipClass()}>{zoomPercent}%</span>
<span className={topBarChipClass()}>
{state.editor.viewport.size.w}×{state.editor.viewport.size.h}
</span>
{activeArtboard ? <span className="truncate rounded-full bg-sky-400/15 px-3 py-1 text-xs font-medium text-sky-100 ring-1 ring-sky-300/20">{activeArtboard.name}</span> : null}
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
<FolderOpen size={16} />
<span className="hidden sm:inline">Import</span>
</button>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, state.document.assets)}>
<DownloadSimple size={16} />
<span className="hidden sm:inline">Export</span>
</button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => setLayersOpen((open) => !open)}>
<Stack size={16} weight={layersOpen ? "fill" : "regular"} />
<span className="hidden sm:inline">Layers</span>
</button>
</div>
</header>
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
@@ -80,7 +119,7 @@ export function App({ app }: AppProps) {
<LayersSheet
document={state.document}
selection={state.editor.selection}
maskEditLayerId={state.editor.maskEditLayerId}
maskEdit={state.editor.maskEdit}
open={layersOpen}
dispatch={app.store.dispatch}
onClose={() => 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;

View File

@@ -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" ? (
<BrushControls tool={activeTool} settings={brushSettings} dispatch={dispatch} />
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
) : transformBounds && transformTarget ? (
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
) : action === "pan" ? (

View File

@@ -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 <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
}

View File

@@ -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<string>();
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
return (
<aside
aria-hidden={!open}
className={`pointer-events-auto absolute right-4 top-12 z-20 w-96 overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/85 text-sm text-white shadow-2xl shadow-black/40 backdrop-blur-xl transition-all duration-200 ${
className={`pointer-events-auto absolute right-4 top-16 z-20 w-96 overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/85 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-4 opacity-0"
}`}
>
@@ -38,9 +40,7 @@ export function LayersSheet({ document, selection, maskEditLayerId, open, dispat
<div className="font-medium tracking-wide">Layers</div>
<div className="text-xs text-white/40">Press L to open</div>
</div>
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
<X size={18} weight="regular" />
</button>
</header>
<div className="flex flex-wrap gap-2 border-b border-white/10 bg-black/20 p-3">
<button type="button" className={toolbarButtonClass()} onClick={() => addArtboard(document, dispatch)}>
@@ -120,10 +120,10 @@ export function LayersSheet({ document, selection, maskEditLayerId, open, dispat
>
<DownloadSimple size={17} weight="regular" />
</button>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/45">{artboard.layers.length}</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/45">{countDisplayLayers(artboard.layers, maskLayerIds)}</span>
</div>
<div className="mt-2 space-y-1.5 pl-3">
{artboard.layers.length === 0 ? (
{countDisplayLayers(artboard.layers, maskLayerIds) === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-white/35">No layers yet</div>
) : (
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<string | undefined>;
editingTitle: EditingTitle | undefined;
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
selectedMaskLayer?: LayerInfo;
maskEditLayerId?: string;
maskLayerIds: ReadonlySet<string>;
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 (
<div>
<div
draggable
className={`group flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition ${selected ? "bg-white text-black shadow-sm" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
style={{ paddingLeft: 12 + depth * 16 + maskIndent }}
className={`group flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition ${editingMask ? "bg-sky-300 text-black shadow-sm" : selected ? "bg-white text-black shadow-sm" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
style={{ paddingLeft: rowPadding }}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", layer.id);
@@ -207,8 +209,7 @@ function LayerRow({
draggedLayerId.current = undefined;
}}
>
{maskLayer ? <span className={selected ? "text-black/40" : "text-sky-200/55"}></span> : null}
<button type="button" className={selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
<button type="button" className={editingMask || selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
{layer.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
</button>
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
@@ -231,30 +232,46 @@ function LayerRow({
{layer.name}
</button>
)}
<button
type="button"
className={selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"}
aria-label={layer.clippingMask ? "Clear layer mask" : "Use selected layer as mask"}
title={layer.clippingMask ? "Clear mask" : "Use selected layer as mask"}
disabled={!layer.clippingMask && !canSetMask}
onClick={() => dispatch(commandIds.documentSetLayerClippingMask, { layerId: layer.id, maskLayerId: layer.clippingMask ? undefined : selectedMaskLayer?.layer.id })}
>
<Stack size={17} weight={layer.clippingMask ? "fill" : "regular"} />
</button>
<button type="button" className={selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"} aria-label={layer.locked ? "Unlock layer" : "Lock layer"} onClick={() => dispatch(commandIds.documentSetLayerLocked, { layerId: layer.id, locked: !layer.locked })}>
{layer.clippingMask ? (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<Stack size={13} weight="fill" /> Mask
</span>
) : canAddMask && layerInfo ? (
<button
type="button"
className={editingMask || selected ? "rounded-full bg-black/10 px-2 py-0.5 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-2 py-0.5 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
onClick={() => addLayerMask(document, layerInfo, dispatch)}
>
Add mask
</button>
) : null}
<button type="button" className={editingMask || selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"} aria-label={layer.locked ? "Unlock layer" : "Lock layer"} onClick={() => dispatch(commandIds.documentSetLayerLocked, { layerId: layer.id, locked: !layer.locked })}>
{layer.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
</button>
</div>
{maskLayer ? (
<div className="mt-1 flex items-center gap-2 text-xs text-sky-100/60" style={{ paddingLeft: 52 + depth * 16 + maskIndent }}>
<span className="h-px w-5 bg-sky-200/25" />
<span>masked by {maskLayer.name}</span>
{layer.clippingMask ? (
<div className="mt-1 flex items-center gap-2 rounded-xl border border-sky-300/10 bg-sky-400/[0.04] px-3 py-1.5 text-xs text-sky-100/70" style={{ marginLeft: rowPadding + 24 }}>
<Stack size={14} weight="fill" />
<span className="min-w-0 flex-1 truncate">{maskLayer ? `Layer mask · ${maskLayer.name}` : "Layer mask missing"}</span>
{maskLayer ? (
<button
type="button"
className={`rounded-full px-2 py-0.5 transition ${editingMask ? "bg-sky-300 text-black" : "bg-sky-400/10 text-sky-100/75 hover:bg-sky-400/20 hover:text-sky-50"}`}
onClick={() =>
editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined)
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId })
}
>
{editingMask ? "Done" : "Edit"}
</button>
) : null}
<button
type="button"
className={`rounded-full px-2 py-0.5 transition ${editingMask ? "bg-sky-300 text-black" : "bg-sky-400/10 text-sky-100/75 hover:bg-sky-400/20 hover:text-sky-50"}`}
onClick={() => dispatch(commandIds.toolSetMaskEditLayer, { layerId: editingMask ? undefined : layer.clippingMask?.maskLayerId })}
className="rounded-full px-2 py-0.5 text-sky-100/55 transition hover:bg-red-400/15 hover:text-red-100"
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
>
{editingMask ? "Editing mask" : "Edit mask"}
Remove
</button>
</div>
) : 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(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
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<string> {
const ids = new Set<string>();
for (const artboard of document.artboards) collectMaskLayerIds(artboard.layers, ids);
return ids;
}
function collectMaskLayerIds(layers: readonly Layer[], ids: Set<string>) {
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<string>): 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,

View File

@@ -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<ToolId, "brush" | "eraser">;
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 (
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
<span className="px-2 font-medium text-white/85">{tool === "eraser" ? "Eraser" : "Brush"}</span>
<span className="px-2 font-medium text-white/85">{editingMask ? `Mask · ${tool === "eraser" ? "Hide" : "Reveal"}` : tool === "eraser" ? "Eraser" : "Brush"}</span>
<BottomControlDivider />
{tool === "brush" ? (
{tool === "brush" && !editingMask ? (
<label className="flex items-center gap-1.5">
<span className={bottomControlLabelClass()}>Color</span>
<input
@@ -54,6 +57,33 @@ export function BrushControls({ tool, settings, dispatch }: BrushControlsProps)
/>
<span className="w-8 text-right text-white">{Math.round(settings.hardness)}</span>
</label>
{editingMask ? (
<>
<BottomControlDivider />
<label className="flex items-center gap-1.5">
<span className={bottomControlLabelClass()}>View</span>
<select
className="h-7 rounded-full border border-white/10 bg-black/70 px-2 text-white outline-none"
value={maskViewMode}
aria-label="Mask view mode"
onChange={(event) => dispatch(commandIds.toolSetMaskViewMode, { mode: event.target.value as MaskViewMode })}
>
<option value="composite">Image</option>
<option value="blackWhite">B/W</option>
<option value="alpha">Alpha</option>
<option value="overlay">Overlay</option>
</select>
</label>
<BottomControlDivider />
<button
type="button"
className="rounded-full bg-white px-3 py-1 font-medium text-black transition hover:bg-white/90"
onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}
>
Done
</button>
</>
) : null}
</div>
);
}

View File

@@ -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<void>;
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> {
}): 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 {

View File

@@ -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";
}

View File

@@ -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,