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:
@@ -48,5 +48,12 @@ function recordHistory(currentState: AppState, nextState: AppState): AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshot(state: AppState): HistorySnapshot {
|
function snapshot(state: AppState): HistorySnapshot {
|
||||||
return { document: state.document, editor: state.editor };
|
return {
|
||||||
|
document: state.document,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
brushPreview: undefined,
|
||||||
|
brushStrokePreview: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
import {
|
import {
|
||||||
documentAddArtboardCommand,
|
documentAddArtboardCommand,
|
||||||
documentAddAssetCommand,
|
documentAddAssetCommand,
|
||||||
documentAddGroupLayerCommand,
|
documentAddGroupLayerCommand,
|
||||||
documentAddImageLayerCommand,
|
documentAddImageLayerCommand,
|
||||||
|
documentAddLayerMaskCommand,
|
||||||
documentAddRasterLayerCommand,
|
documentAddRasterLayerCommand,
|
||||||
documentGroupLayersCommand,
|
documentGroupLayersCommand,
|
||||||
documentMoveLayerCommand,
|
documentMoveLayerCommand,
|
||||||
documentRemoveArtboardCommand,
|
documentRemoveArtboardCommand,
|
||||||
documentRemoveLayerCommand,
|
documentRemoveLayerCommand,
|
||||||
|
documentRemoveLayerMaskCommand,
|
||||||
documentRenameArtboardCommand,
|
documentRenameArtboardCommand,
|
||||||
documentRenameLayerCommand,
|
documentRenameLayerCommand,
|
||||||
documentSetArtboardBoundsCommand,
|
documentSetArtboardBoundsCommand,
|
||||||
@@ -201,6 +204,53 @@ describe("document commands", () => {
|
|||||||
expect(masked.document.artboards[0]?.layers[2]?.clippingMask).toEqual({ maskLayerId: "mask" });
|
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", () => {
|
test("sets artboard visibility and lock state", () => {
|
||||||
const state = documentAddArtboardCommand.execute(
|
const state = documentAddArtboardCommand.execute(
|
||||||
{ state: createInitialAppState("Test") },
|
{ state: createInitialAppState("Test") },
|
||||||
@@ -236,7 +286,7 @@ describe("document commands", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function documentWithLayers(layers: ReturnType<typeof group>[]) {
|
function documentWithLayers(layers: Layer[]) {
|
||||||
return {
|
return {
|
||||||
...createInitialAppState("Test"),
|
...createInitialAppState("Test"),
|
||||||
document: {
|
document: {
|
||||||
@@ -262,3 +312,20 @@ function group(id: string, name: string) {
|
|||||||
children: [],
|
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 } };
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,6 +107,16 @@ export type DocumentSetLayerClippingMaskPayload = {
|
|||||||
maskLayerId?: LayerId;
|
maskLayerId?: LayerId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentAddLayerMaskPayload = {
|
||||||
|
layerId: LayerId;
|
||||||
|
asset: Asset;
|
||||||
|
maskLayer: RasterLayer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentRemoveLayerMaskPayload = {
|
||||||
|
layerId: LayerId;
|
||||||
|
};
|
||||||
|
|
||||||
export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
||||||
id: commandIds.documentAddArtboard,
|
id: commandIds.documentAddArtboard,
|
||||||
name: "Add artboard",
|
name: "Add artboard",
|
||||||
@@ -153,13 +163,19 @@ export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayloa
|
|||||||
name: "Remove artboard",
|
name: "Remove artboard",
|
||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
|
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
|
||||||
|
const document = {
|
||||||
|
...state.document,
|
||||||
|
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
document: {
|
document,
|
||||||
...state.document,
|
editor: {
|
||||||
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
|
...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);
|
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||||
if (!removed.layer) return state;
|
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 {
|
return {
|
||||||
...state,
|
...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,
|
id: commandIds.documentGroupLayers,
|
||||||
name: "Group layers",
|
name: "Group layers",
|
||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
const uniqueIds = [...new Set(payload.layerIds)];
|
const requestedIds = [...new Set(payload.layerIds)];
|
||||||
if (uniqueIds.length === 0) return state;
|
if (requestedIds.length === 0) return state;
|
||||||
|
|
||||||
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
|
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
|
||||||
if (!artboard) return state;
|
if (!artboard) return state;
|
||||||
|
|
||||||
|
const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])];
|
||||||
|
|
||||||
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
|
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
|
||||||
if (selected.length === 0) return state;
|
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 === payload.layerId) return state;
|
||||||
|
|
||||||
if (!payload.maskLayerId) {
|
if (!payload.maskLayerId) {
|
||||||
|
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
|
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
|
||||||
const { clippingMask: _clippingMask, ...rest } = layer;
|
editor: {
|
||||||
return rest;
|
...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> = {
|
export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
|
||||||
id: commandIds.documentRemoveLayer,
|
id: commandIds.documentRemoveLayer,
|
||||||
name: "Remove layer",
|
name: "Remove layer",
|
||||||
@@ -409,10 +522,23 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
|
|||||||
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||||
if (!removed.layer) return state;
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
document: removed.document,
|
document,
|
||||||
editor: { ...state.editor, selection: { ...state.editor.selection, layerIds: state.editor.selection.layerIds.filter((id) => id !== payload.layerId) } },
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
selection,
|
||||||
|
maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -437,6 +563,8 @@ export const documentCommands = [
|
|||||||
documentSetLayerLockedCommand,
|
documentSetLayerLockedCommand,
|
||||||
documentRenameLayerCommand,
|
documentRenameLayerCommand,
|
||||||
documentSetLayerClippingMaskCommand,
|
documentSetLayerClippingMaskCommand,
|
||||||
|
documentAddLayerMaskCommand,
|
||||||
|
documentRemoveLayerMaskCommand,
|
||||||
] satisfies Command<unknown>[];
|
] satisfies Command<unknown>[];
|
||||||
|
|
||||||
type LayerLocation = {
|
type LayerLocation = {
|
||||||
@@ -590,3 +718,81 @@ function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefi
|
|||||||
}
|
}
|
||||||
return undefined;
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,18 @@ export const commandIds = {
|
|||||||
documentSetLayerLocked: "document.setLayerLocked",
|
documentSetLayerLocked: "document.setLayerLocked",
|
||||||
documentRenameLayer: "document.renameLayer",
|
documentRenameLayer: "document.renameLayer",
|
||||||
documentSetLayerClippingMask: "document.setLayerClippingMask",
|
documentSetLayerClippingMask: "document.setLayerClippingMask",
|
||||||
|
documentAddLayerMask: "document.addLayerMask",
|
||||||
|
documentRemoveLayerMask: "document.removeLayerMask",
|
||||||
selectionSet: "selection.set",
|
selectionSet: "selection.set",
|
||||||
selectionClear: "selection.clear",
|
selectionClear: "selection.clear",
|
||||||
selectionAddLayer: "selection.addLayer",
|
selectionAddLayer: "selection.addLayer",
|
||||||
toolSetActive: "tool.setActive",
|
toolSetActive: "tool.setActive",
|
||||||
toolSetBrushSettings: "tool.setBrushSettings",
|
toolSetBrushSettings: "tool.setBrushSettings",
|
||||||
toolSetMaskEditLayer: "tool.setMaskEditLayer",
|
toolSetBrushPreview: "tool.setBrushPreview",
|
||||||
|
toolSetBrushStrokePreview: "tool.setBrushStrokePreview",
|
||||||
|
toolSetMaskViewMode: "tool.setMaskViewMode",
|
||||||
|
toolEnterMaskEdit: "tool.enterMaskEdit",
|
||||||
|
toolExitMaskEdit: "tool.exitMaskEdit",
|
||||||
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
||||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||||
transformBegin: "transform.begin",
|
transformBegin: "transform.begin",
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ export {
|
|||||||
documentAddAssetCommand,
|
documentAddAssetCommand,
|
||||||
documentAddGroupLayerCommand,
|
documentAddGroupLayerCommand,
|
||||||
documentAddImageLayerCommand,
|
documentAddImageLayerCommand,
|
||||||
|
documentAddLayerMaskCommand,
|
||||||
documentAddRasterLayerCommand,
|
documentAddRasterLayerCommand,
|
||||||
documentCommands,
|
documentCommands,
|
||||||
documentGroupLayersCommand,
|
documentGroupLayersCommand,
|
||||||
documentMoveLayerCommand,
|
documentMoveLayerCommand,
|
||||||
documentRemoveArtboardCommand,
|
documentRemoveArtboardCommand,
|
||||||
documentRemoveLayerCommand,
|
documentRemoveLayerCommand,
|
||||||
|
documentRemoveLayerMaskCommand,
|
||||||
documentRenameArtboardCommand,
|
documentRenameArtboardCommand,
|
||||||
documentRenameLayerCommand,
|
documentRenameLayerCommand,
|
||||||
documentSetArtboardBoundsCommand,
|
documentSetArtboardBoundsCommand,
|
||||||
@@ -26,11 +28,13 @@ export type {
|
|||||||
DocumentAddAssetPayload,
|
DocumentAddAssetPayload,
|
||||||
DocumentAddGroupLayerPayload,
|
DocumentAddGroupLayerPayload,
|
||||||
DocumentAddImageLayerPayload,
|
DocumentAddImageLayerPayload,
|
||||||
|
DocumentAddLayerMaskPayload,
|
||||||
DocumentAddRasterLayerPayload,
|
DocumentAddRasterLayerPayload,
|
||||||
DocumentGroupLayersPayload,
|
DocumentGroupLayersPayload,
|
||||||
DocumentMoveLayerPayload,
|
DocumentMoveLayerPayload,
|
||||||
DocumentRemoveArtboardPayload,
|
DocumentRemoveArtboardPayload,
|
||||||
DocumentRemoveLayerPayload,
|
DocumentRemoveLayerPayload,
|
||||||
|
DocumentRemoveLayerMaskPayload,
|
||||||
DocumentRenameArtboardPayload,
|
DocumentRenameArtboardPayload,
|
||||||
DocumentRenameLayerPayload,
|
DocumentRenameLayerPayload,
|
||||||
DocumentSetArtboardBoundsPayload,
|
DocumentSetArtboardBoundsPayload,
|
||||||
@@ -50,10 +54,10 @@ export type { CommandRegistry } from "./registry";
|
|||||||
export { createCommandRegistry } from "./registry";
|
export { createCommandRegistry } from "./registry";
|
||||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||||
export type { SelectionAddLayerPayload, SelectionSetPayload } 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 { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } 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 {
|
export {
|
||||||
viewportCommands,
|
viewportCommands,
|
||||||
viewportPanCommand,
|
viewportPanCommand,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { commandIds } from "./ids";
|
import type { commandIds } from "./ids";
|
||||||
import type {
|
import type {
|
||||||
DocumentAddArtboardPayload,
|
DocumentAddArtboardPayload,
|
||||||
DocumentAddAssetPayload,
|
DocumentAddAssetPayload,
|
||||||
DocumentAddGroupLayerPayload,
|
DocumentAddGroupLayerPayload,
|
||||||
DocumentAddImageLayerPayload,
|
DocumentAddImageLayerPayload,
|
||||||
|
DocumentAddLayerMaskPayload,
|
||||||
DocumentAddRasterLayerPayload,
|
DocumentAddRasterLayerPayload,
|
||||||
DocumentGroupLayersPayload,
|
DocumentGroupLayersPayload,
|
||||||
DocumentMoveLayerPayload,
|
DocumentMoveLayerPayload,
|
||||||
DocumentRemoveArtboardPayload,
|
DocumentRemoveArtboardPayload,
|
||||||
DocumentRemoveLayerPayload,
|
DocumentRemoveLayerPayload,
|
||||||
|
DocumentRemoveLayerMaskPayload,
|
||||||
DocumentRenameArtboardPayload,
|
DocumentRenameArtboardPayload,
|
||||||
DocumentRenameLayerPayload,
|
DocumentRenameLayerPayload,
|
||||||
DocumentSetArtboardBoundsPayload,
|
DocumentSetArtboardBoundsPayload,
|
||||||
@@ -21,7 +23,7 @@ import type {
|
|||||||
DocumentUngroupLayerPayload,
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
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 { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
import type {
|
import type {
|
||||||
ViewportFitArtboardPayload,
|
ViewportFitArtboardPayload,
|
||||||
@@ -43,6 +45,8 @@ export type CommandPayloads = {
|
|||||||
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
||||||
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
|
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
|
||||||
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
||||||
|
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
|
||||||
|
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
|
||||||
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
|
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
|
||||||
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
|
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
|
||||||
[commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload;
|
[commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload;
|
||||||
@@ -56,7 +60,11 @@ export type CommandPayloads = {
|
|||||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||||
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
[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.toolEnterTemporaryPan]: void;
|
||||||
[commandIds.toolExitTemporaryPan]: void;
|
[commandIds.toolExitTemporaryPan]: void;
|
||||||
[commandIds.transformBegin]: TransformBeginPayload;
|
[commandIds.transformBegin]: TransformBeginPayload;
|
||||||
|
|||||||
@@ -15,14 +15,19 @@ export const selectionSetCommand: Command<SelectionSetPayload> = {
|
|||||||
id: commandIds.selectionSet,
|
id: commandIds.selectionSet,
|
||||||
name: "Set selection",
|
name: "Set selection",
|
||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
|
const selection = {
|
||||||
|
artboardId: payload.artboardId,
|
||||||
|
layerIds: [...payload.layerIds],
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
selection: {
|
selection,
|
||||||
artboardId: payload.artboardId,
|
maskEdit: selection.layerIds.length === 1 && selection.layerIds[0] === state.editor.maskEdit?.targetLayerId ? state.editor.maskEdit : undefined,
|
||||||
layerIds: [...payload.layerIds],
|
brushPreview: undefined,
|
||||||
},
|
brushStrokePreview: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -37,6 +42,9 @@ export const selectionClearCommand: Command = {
|
|||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
selection: { layerIds: [] },
|
selection: { layerIds: [] },
|
||||||
|
maskEdit: undefined,
|
||||||
|
brushPreview: undefined,
|
||||||
|
brushStrokePreview: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -56,6 +64,9 @@ export const selectionAddLayerCommand: Command<SelectionAddLayerPayload> = {
|
|||||||
...state.editor.selection,
|
...state.editor.selection,
|
||||||
layerIds: [...state.editor.selection.layerIds, payload.layerId],
|
layerIds: [...state.editor.selection.layerIds, payload.layerId],
|
||||||
},
|
},
|
||||||
|
maskEdit: undefined,
|
||||||
|
brushPreview: undefined,
|
||||||
|
brushStrokePreview: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
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", () => {
|
describe("tool commands", () => {
|
||||||
test("sets active tool", () => {
|
test("sets active tool", () => {
|
||||||
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "crop" });
|
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", () => {
|
test("sets brush settings", () => {
|
||||||
@@ -14,10 +16,39 @@ describe("tool commands", () => {
|
|||||||
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
|
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sets mask edit layer", () => {
|
test("sets and clears brush preview", () => {
|
||||||
const next = toolSetMaskEditLayerCommand.execute({ state: createInitialAppState("Test") }, { layerId: "mask-1" });
|
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", () => {
|
test("enters and exits temporary pan", () => {
|
||||||
@@ -25,7 +56,43 @@ describe("tool commands", () => {
|
|||||||
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
||||||
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, 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);
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
163
commands/tool.ts
163
commands/tool.ts
@@ -1,4 +1,8 @@
|
|||||||
import type { LayerId } from "@core/id";
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { Vec2D } from "@core/geometry";
|
||||||
|
import type { LayerId, ArtboardId, AssetId } from "@core/id";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { MaskViewMode } from "@editor/state";
|
||||||
import type { BrushSettings, ToolId } from "@editor/tools";
|
import type { BrushSettings, ToolId } from "@editor/tools";
|
||||||
import type { Command } from "./command";
|
import type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
@@ -9,8 +13,23 @@ export type ToolSetActivePayload = {
|
|||||||
|
|
||||||
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||||
|
|
||||||
export type ToolSetMaskEditLayerPayload = {
|
export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined;
|
||||||
layerId?: LayerId;
|
|
||||||
|
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> = {
|
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
||||||
@@ -26,6 +45,8 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
|||||||
activeTool: payload.tool,
|
activeTool: payload.tool,
|
||||||
interactionMode: { type: "tool", tool: 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> = {
|
export const toolSetBrushPreviewCommand: Command<ToolSetBrushPreviewPayload> = {
|
||||||
id: commandIds.toolSetMaskEditLayer,
|
id: commandIds.toolSetBrushPreview,
|
||||||
name: "Set mask edit layer",
|
name: "Set brush preview",
|
||||||
execute({ state }, payload) {
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
editor: {
|
editor: {
|
||||||
...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) {
|
function clampNumber(value: number, min: number, max: number) {
|
||||||
if (!Number.isFinite(value)) return min;
|
if (!Number.isFinite(value)) return min;
|
||||||
return Math.max(min, Math.min(max, value));
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export const initialEditorState: EditorState = {
|
|||||||
},
|
},
|
||||||
tools: initialToolState,
|
tools: initialToolState,
|
||||||
transformSession: undefined,
|
transformSession: undefined,
|
||||||
maskEditLayerId: undefined,
|
maskEdit: undefined,
|
||||||
|
brushPreview: undefined,
|
||||||
|
brushStrokePreview: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createInitialAppState(name = "Untitled"): AppState {
|
export function createInitialAppState(name = "Untitled"): AppState {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Angle, Size, Vec2D } from "@core/geometry";
|
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 { ToolState } from "./tools";
|
||||||
import type { TransformSession } from "./transform";
|
import type { TransformSession } from "./transform";
|
||||||
|
|
||||||
@@ -16,12 +16,32 @@ export type SelectionState = {
|
|||||||
layerIds: LayerId[];
|
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 = {
|
export type EditorState = {
|
||||||
viewport: ViewportState;
|
viewport: ViewportState;
|
||||||
selection: SelectionState;
|
selection: SelectionState;
|
||||||
tools: ToolState;
|
tools: ToolState;
|
||||||
transformSession?: TransformSession;
|
transformSession?: TransformSession;
|
||||||
maskEditLayerId?: LayerId;
|
maskEdit?: MaskEditState;
|
||||||
|
brushPreview?: BrushPreviewState;
|
||||||
|
brushStrokePreview?: BrushStrokePreviewState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HistorySnapshot = {
|
export type HistorySnapshot = {
|
||||||
|
|||||||
@@ -51,6 +51,36 @@ describe("transform targets", () => {
|
|||||||
expect(layer?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
|
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", () => {
|
test("applies artboard bounds", () => {
|
||||||
const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 });
|
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 });
|
expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 });
|
||||||
|
|||||||
@@ -34,13 +34,19 @@ export function selectedTransformTarget(document: ImageDocument, selection: { ar
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
|
function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
|
||||||
return {
|
const layer = findLayer(document, layerId);
|
||||||
...document,
|
const targetLayerIds = layer?.clippingMask ? [layerId, layer.clippingMask.maskLayerId] : [layerId];
|
||||||
artboards: document.artboards.map((artboard) => ({
|
|
||||||
...artboard,
|
return targetLayerIds.reduce(
|
||||||
layers: applyLayerBoundsInTree(document, artboard.layers, layerId, bounds),
|
(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[] {
|
function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
|
||||||
|
|||||||
107
input/document-geometry.ts
Normal file
107
input/document-geometry.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import type { Dispatch } from "@commands/dispatcher";
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { ArtboardId, LayerId } from "@core/id";
|
import type { ArtboardId, LayerId } from "@core/id";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { SelectionState } from "@editor/state";
|
|
||||||
import type { KeybindEvent } from "./keyboard";
|
import type { KeybindEvent } from "./keyboard";
|
||||||
|
|
||||||
export type LayerInfo = {
|
export type LayerInfo = {
|
||||||
@@ -17,7 +16,12 @@ export type LayerDropTarget = {
|
|||||||
layer: Layer;
|
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.altKey || options.event.ctrlKey || options.event.metaKey) return false;
|
||||||
if (options.event.key !== "Backspace" && options.event.key !== "Delete") return false;
|
if (options.event.key !== "Backspace" && options.event.key !== "Delete") return false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import type { ImageDocument } from "@core/document";
|
||||||
import { handleArtboardSelection } from "./selection";
|
import { handleArtboardSelection } from "./selection";
|
||||||
import type { PointerInputEvent } from "./pointer";
|
import type { PointerInputEvent } from "./pointer";
|
||||||
|
|
||||||
const ignoredState = undefined as never;
|
const ignoredState = undefined as never;
|
||||||
|
const viewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } };
|
||||||
|
|
||||||
describe("selection input", () => {
|
describe("selection input", () => {
|
||||||
test("selects artboard hit by left click", () => {
|
test("selects artboard hit by left click", () => {
|
||||||
const state = {
|
const document = createDocument({
|
||||||
...createInitialAppState("Test"),
|
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||||
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 dispatched: unknown[] = [];
|
const dispatched: unknown[] = [];
|
||||||
|
|
||||||
const consumed = handleArtboardSelection({
|
const consumed = handleArtboardSelection({
|
||||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||||
document: state.document,
|
document,
|
||||||
viewport: state.editor.viewport,
|
viewport,
|
||||||
dispatch: (commandId, payload) => {
|
dispatch: (commandId, payload) => {
|
||||||
dispatched.push({ commandId, payload });
|
dispatched.push({ commandId, payload });
|
||||||
return ignoredState;
|
return ignoredState;
|
||||||
@@ -36,45 +29,37 @@ describe("selection input", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("selects topmost image layer before artboard", () => {
|
test("selects topmost image layer before artboard", () => {
|
||||||
const state = {
|
const document = createDocument({
|
||||||
...createInitialAppState("Test"),
|
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
|
||||||
document: {
|
artboards: [
|
||||||
...createInitialAppState("Test").document,
|
{
|
||||||
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
|
id: "a1",
|
||||||
artboards: [
|
name: "Artboard",
|
||||||
{
|
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
||||||
id: "a1",
|
backgroundColor: "transparent",
|
||||||
name: "Artboard",
|
visible: true,
|
||||||
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
locked: false,
|
||||||
backgroundColor: "transparent",
|
layers: [
|
||||||
visible: true,
|
{
|
||||||
locked: false,
|
id: "l1",
|
||||||
layers: [
|
type: "image",
|
||||||
{
|
name: "Image",
|
||||||
id: "l1",
|
visible: true,
|
||||||
type: "image",
|
locked: false,
|
||||||
name: "Image",
|
opacity: 1,
|
||||||
visible: true,
|
assetId: "asset-1",
|
||||||
locked: false,
|
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
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 dispatched: unknown[] = [];
|
const dispatched: unknown[] = [];
|
||||||
|
|
||||||
const consumed = handleArtboardSelection({
|
const consumed = handleArtboardSelection({
|
||||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||||
document: state.document,
|
document,
|
||||||
viewport: state.editor.viewport,
|
viewport,
|
||||||
dispatch: (commandId, payload) => {
|
dispatch: (commandId, payload) => {
|
||||||
dispatched.push({ commandId, payload });
|
dispatched.push({ commandId, payload });
|
||||||
return ignoredState;
|
return ignoredState;
|
||||||
@@ -85,14 +70,70 @@ describe("selection input", () => {
|
|||||||
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["l1"] } }]);
|
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["l1"] } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears selection when clicking outside artboards", () => {
|
test("ignores mask layers during canvas hit testing", () => {
|
||||||
const state = createInitialAppState("Test");
|
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 dispatched: unknown[] = [];
|
||||||
|
|
||||||
const consumed = handleArtboardSelection({
|
const consumed = handleArtboardSelection({
|
||||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||||
document: state.document,
|
document,
|
||||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
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) => {
|
dispatch: (commandId, payload) => {
|
||||||
dispatched.push({ commandId, payload });
|
dispatched.push({ commandId, payload });
|
||||||
return ignoredState;
|
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 {
|
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
|
||||||
return {
|
return {
|
||||||
pointerId: 1,
|
pointerId: 1,
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { commandIds } from "@commands/ids";
|
|||||||
import type { Dispatch } from "@commands/dispatcher";
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
|
||||||
import type { ViewportState } from "@editor/state";
|
|
||||||
import type { PointerInputEvent } from "./pointer";
|
import type { PointerInputEvent } from "./pointer";
|
||||||
|
|
||||||
export function handleArtboardSelection(options: {
|
export function handleArtboardSelection(options: {
|
||||||
event: PointerInputEvent;
|
event: PointerInputEvent;
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
viewport: ViewportState;
|
viewport: InputViewportState;
|
||||||
dispatch: Dispatch;
|
dispatch: Dispatch;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (options.event.pointerType !== "mouse" || (options.event.buttons & 1) !== 1) return false;
|
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 }) {
|
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()) {
|
for (const artboard of [...document.artboards].reverse()) {
|
||||||
if (!artboard.visible || artboard.locked) continue;
|
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 };
|
if (layerId) return { artboardId: artboard.id, layerId };
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
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) {
|
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") {
|
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;
|
if (childId) return childId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +63,11 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[],
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewportPointToDocumentPoint(point: PointerInputEvent["position"], viewport: ViewportState) {
|
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> {
|
||||||
return {
|
for (const layer of layers) {
|
||||||
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
|
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||||
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
|
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
||||||
};
|
}
|
||||||
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,30 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { PointerInputEvent } from "./pointer";
|
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 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", () => {
|
describe("transform controls input", () => {
|
||||||
test("hit tests artboard handles and body", () => {
|
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 };
|
const bounds = { x: -50, y: -50, w: 100, h: 100 };
|
||||||
|
|
||||||
expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, viewport)).toBe("nw");
|
expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, defaultViewport)).toBe("nw");
|
||||||
expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, viewport)).toBe("body");
|
expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, defaultViewport)).toBe("body");
|
||||||
expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, viewport)).toBeUndefined();
|
expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, defaultViewport)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not transform while temporary pan is active", () => {
|
test("does not transform while temporary pan is active", () => {
|
||||||
const state = {
|
const state = createState({
|
||||||
...createInitialAppState("Test"),
|
selection: { artboardId: "a1", layerIds: [] },
|
||||||
document: {
|
tools: { activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" } },
|
||||||
...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 dispatched: unknown[] = [];
|
const dispatched: unknown[] = [];
|
||||||
const controller = createTransformControlsInputController({
|
const controller = createTransformControlsInputController({
|
||||||
getDocument: () => state.document,
|
getDocument: () => state.document,
|
||||||
@@ -45,19 +40,10 @@ describe("transform controls input", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("crop tool resizes from handles but does not move body", () => {
|
test("crop tool resizes from handles but does not move body", () => {
|
||||||
const state = {
|
const state = createState({
|
||||||
...createInitialAppState("Test"),
|
selection: { artboardId: "a1", layerIds: [] },
|
||||||
document: {
|
tools: { activeTool: "crop", interactionMode: { type: "tool", tool: "crop" } },
|
||||||
...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 dispatched: unknown[] = [];
|
const dispatched: unknown[] = [];
|
||||||
const controller = createTransformControlsInputController({
|
const controller = createTransformControlsInputController({
|
||||||
getDocument: () => state.document,
|
getDocument: () => state.document,
|
||||||
@@ -73,25 +59,14 @@ describe("transform controls input", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("dispatches transform lifecycle for selected artboard", () => {
|
test("dispatches transform lifecycle for selected artboard", () => {
|
||||||
let state = {
|
let state = createState({ selection: { artboardId: "a1", layerIds: [] } });
|
||||||
...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: [] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const dispatched: unknown[] = [];
|
const dispatched: unknown[] = [];
|
||||||
const controller = createTransformControlsInputController({
|
const controller = createTransformControlsInputController({
|
||||||
getDocument: () => state.document,
|
getDocument: () => state.document,
|
||||||
getEditor: () => state.editor,
|
getEditor: () => state.editor,
|
||||||
dispatch: (commandId, payload) => {
|
dispatch: (commandId, payload) => {
|
||||||
dispatched.push({ 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;
|
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 {
|
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
|
||||||
return {
|
return {
|
||||||
pointerId: 1,
|
pointerId: 1,
|
||||||
|
|||||||
@@ -2,13 +2,36 @@ import { commandIds } from "@commands/ids";
|
|||||||
import type { Dispatch } from "@commands/dispatcher";
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Rect, Vec2D } from "@core/geometry";
|
import type { Rect, Vec2D } from "@core/geometry";
|
||||||
import type { EditorState } from "@editor/state";
|
import {
|
||||||
import { isPanInteractionMode } from "@editor/tools";
|
documentRectToViewportRect,
|
||||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
resolveTransformTargetBounds,
|
||||||
import type { TransformHandle } from "@editor/transform";
|
selectedTransformTarget,
|
||||||
|
viewportPointToDocumentPoint,
|
||||||
|
type InputSelectionState,
|
||||||
|
type InputTransformTarget,
|
||||||
|
type InputViewportState,
|
||||||
|
} from "./document-geometry";
|
||||||
import { findLayerInfoInDocument } from "./layers-panel";
|
import { findLayerInfoInDocument } from "./layers-panel";
|
||||||
import type { PointerInputEvent } from "./pointer";
|
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 = {
|
export type TransformControlsInputController = {
|
||||||
pointerDown(event: PointerInputEvent): boolean;
|
pointerDown(event: PointerInputEvent): boolean;
|
||||||
pointerMove(event: PointerInputEvent): boolean;
|
pointerMove(event: PointerInputEvent): boolean;
|
||||||
@@ -17,7 +40,7 @@ export type TransformControlsInputController = {
|
|||||||
|
|
||||||
export function createTransformControlsInputController(options: {
|
export function createTransformControlsInputController(options: {
|
||||||
getDocument: () => ImageDocument;
|
getDocument: () => ImageDocument;
|
||||||
getEditor: () => EditorState;
|
getEditor: () => TransformControlsEditorState;
|
||||||
dispatch: Dispatch;
|
dispatch: Dispatch;
|
||||||
}): TransformControlsInputController {
|
}): TransformControlsInputController {
|
||||||
return {
|
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 rect = documentRectToViewportRect(bounds, viewport);
|
||||||
const handles = transformHandleRects(rect);
|
const handles = transformHandleRects(rect);
|
||||||
const handle = handles.find((candidate) => pointInRect(position, candidate.rect));
|
const handle = handles.find((candidate) => pointInRect(position, candidate.rect));
|
||||||
@@ -71,7 +94,7 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransformTargetLocked(document: ImageDocument, target: { type: "artboard" | "layer"; id: string }) {
|
function isTransformTargetLocked(document: ImageDocument, target: InputTransformTarget) {
|
||||||
if (target.type === "artboard") {
|
if (target.type === "artboard") {
|
||||||
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
|
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
|
||||||
return !artboard || !artboard.visible || artboard.locked;
|
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 } }));
|
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 {
|
function isPanInteractionMode(interactionMode: InputInteractionMode): boolean {
|
||||||
return {
|
return interactionMode.type === "temporary-pan" || (interactionMode.type === "tool" && interactionMode.tool === "pan");
|
||||||
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 pointInRect(point: Vec2D, rect: Rect) {
|
function pointInRect(point: Vec2D, rect: Rect) {
|
||||||
|
|||||||
@@ -1,61 +1,101 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
import { commandIds } from "@commands/ids";
|
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 type { PointerInputEvent } from "./pointer";
|
||||||
import { createViewportPanInputController } from "./viewport-pan";
|
import { createViewportPanInputController } from "./viewport-pan";
|
||||||
|
|
||||||
const registry = createCommandRegistry([...toolCommands, ...viewportCommands]);
|
const ignoredState = undefined as never;
|
||||||
|
|
||||||
describe("viewport pan store integration", () => {
|
describe("viewport pan store integration", () => {
|
||||||
test("space key input updates actual store tool mode", () => {
|
test("space key input updates actual tool mode", () => {
|
||||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
const store = createInputStore();
|
||||||
const controller = createController(store);
|
const controller = createController(store);
|
||||||
|
|
||||||
expect(controller.keyDown(keyEvent("Space"))).toBe(true);
|
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(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", () => {
|
test("left-drag only pans while temporary pan is active", () => {
|
||||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
const store = createInputStore();
|
||||||
const controller = createController(store);
|
const controller = createController(store);
|
||||||
|
|
||||||
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(false);
|
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(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);
|
store.dispatch(commandIds.toolEnterTemporaryPan, undefined);
|
||||||
|
|
||||||
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true);
|
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(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", () => {
|
test("left-drag pans while pan tool is active", () => {
|
||||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
const store = createInputStore();
|
||||||
const controller = createController(store);
|
const controller = createController(store);
|
||||||
|
|
||||||
store.dispatch(commandIds.toolSetActive, { tool: "pan" });
|
store.dispatch(commandIds.toolSetActive, { tool: "pan" });
|
||||||
|
|
||||||
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true);
|
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(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({
|
return createViewportPanInputController({
|
||||||
globalKeyConsumer: () => false,
|
globalKeyConsumer: () => false,
|
||||||
globalPointerConsumer: () => false,
|
globalPointerConsumer: () => false,
|
||||||
getCurrentZoom: () => store.getState().editor.viewport.zoom,
|
getCurrentZoom: () => store.state.viewport.zoom,
|
||||||
isPanMode: () => {
|
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");
|
return mode.type === "temporary-pan" || (mode.type === "tool" && mode.tool === "pan");
|
||||||
},
|
},
|
||||||
dispatch: store.dispatch,
|
dispatch: store.dispatch,
|
||||||
|
|||||||
216
renderer/brush-preview.ts
Normal file
216
renderer/brush-preview.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import type { Asset } from "@core/asset";
|
import type { Asset } from "@core/asset";
|
||||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||||
|
|
||||||
|
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
|
||||||
|
|
||||||
export type ImageTextureRenderer = {
|
export type ImageTextureRenderer = {
|
||||||
syncAssets(assets: readonly Asset[]): void;
|
syncAssets(assets: readonly Asset[]): void;
|
||||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean;
|
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean;
|
||||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: 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;
|
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect): boolean;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
};
|
};
|
||||||
@@ -20,6 +24,15 @@ type TextureEntry = {
|
|||||||
previousTexture?: WebGLTexture;
|
previousTexture?: WebGLTexture;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MaskVisualizationResources = {
|
||||||
|
program: WebGLProgram;
|
||||||
|
positionLocation: number;
|
||||||
|
texCoordLocation: number;
|
||||||
|
samplerLocation: WebGLUniformLocation;
|
||||||
|
modeLocation: WebGLUniformLocation;
|
||||||
|
colorLocation: WebGLUniformLocation;
|
||||||
|
};
|
||||||
|
|
||||||
export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer {
|
export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer {
|
||||||
const { gl } = context;
|
const { gl } = context;
|
||||||
const program = createProgram(gl);
|
const program = createProgram(gl);
|
||||||
@@ -32,6 +45,13 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord");
|
const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord");
|
||||||
const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image");
|
const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image");
|
||||||
const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask");
|
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 tintedProgram = createTintedProgram(gl);
|
||||||
const tintedPositionLocation = gl.getAttribLocation(tintedProgram, "a_position");
|
const tintedPositionLocation = gl.getAttribLocation(tintedProgram, "a_position");
|
||||||
const tintedTexCoordLocation = gl.getAttribLocation(tintedProgram, "a_texCoord");
|
const tintedTexCoordLocation = gl.getAttribLocation(tintedProgram, "a_texCoord");
|
||||||
@@ -41,9 +61,22 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
const texCoordBuffer = gl.createBuffer();
|
const texCoordBuffer = gl.createBuffer();
|
||||||
const maskTexCoordBuffer = gl.createBuffer();
|
const maskTexCoordBuffer = gl.createBuffer();
|
||||||
const textures = new Map<string, TextureEntry>();
|
const textures = new Map<string, TextureEntry>();
|
||||||
|
let maskVisualizationResources: MaskVisualizationResources | "failed" | undefined;
|
||||||
let disposed = false;
|
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.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);
|
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.enable(gl.SCISSOR_TEST);
|
||||||
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
||||||
gl.enable(gl.BLEND);
|
enablePremultipliedAlphaBlending(gl);
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
||||||
gl.useProgram(program);
|
gl.useProgram(program);
|
||||||
|
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
@@ -103,8 +135,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
|
|
||||||
gl.enable(gl.SCISSOR_TEST);
|
gl.enable(gl.SCISSOR_TEST);
|
||||||
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
||||||
gl.enable(gl.BLEND);
|
enablePremultipliedAlphaBlending(gl);
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
||||||
gl.useProgram(maskedProgram);
|
gl.useProgram(maskedProgram);
|
||||||
|
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
@@ -133,6 +164,90 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.disable(gl.BLEND);
|
gl.disable(gl.BLEND);
|
||||||
return true;
|
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) {
|
renderTinted(asset, rect, color, clipRect) {
|
||||||
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
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.enable(gl.SCISSOR_TEST);
|
||||||
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
||||||
gl.enable(gl.BLEND);
|
enablePremultipliedAlphaBlending(gl);
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
||||||
gl.useProgram(tintedProgram);
|
gl.useProgram(tintedProgram);
|
||||||
|
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
@@ -174,6 +288,8 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.deleteBuffer(maskTexCoordBuffer);
|
gl.deleteBuffer(maskTexCoordBuffer);
|
||||||
gl.deleteProgram(program);
|
gl.deleteProgram(program);
|
||||||
gl.deleteProgram(maskedProgram);
|
gl.deleteProgram(maskedProgram);
|
||||||
|
gl.deleteProgram(maskRevealPreviewProgram);
|
||||||
|
if (maskVisualizationResources && maskVisualizationResources !== "failed") gl.deleteProgram(maskVisualizationResources.program);
|
||||||
gl.deleteProgram(tintedProgram);
|
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_MIN_FILTER, gl.LINEAR);
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_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_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);
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||||
|
|
||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enablePremultipliedAlphaBlending(gl: WebGL2RenderingContext) {
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
function fullTexCoords() {
|
function fullTexCoords() {
|
||||||
return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]);
|
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;
|
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) {
|
function createTintedProgram(gl: WebGL2RenderingContext) {
|
||||||
const vertexShader = compileShader(
|
const vertexShader = compileShader(
|
||||||
gl,
|
gl,
|
||||||
@@ -367,8 +591,8 @@ function createTintedProgram(gl: WebGL2RenderingContext) {
|
|||||||
in vec2 v_texCoord;
|
in vec2 v_texCoord;
|
||||||
out vec4 outColor;
|
out vec4 outColor;
|
||||||
void main() {
|
void main() {
|
||||||
float maskAlpha = texture(u_image, v_texCoord).a;
|
float alpha = u_color.a * texture(u_image, v_texCoord).a;
|
||||||
outColor = vec4(u_color.rgb, u_color.a * maskAlpha);
|
outColor = vec4(u_color.rgb * alpha, alpha);
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
const program = gl.createProgram();
|
const program = gl.createProgram();
|
||||||
@@ -389,6 +613,58 @@ function createTintedProgram(gl: WebGL2RenderingContext) {
|
|||||||
return program;
|
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) {
|
function createMaskedProgram(gl: WebGL2RenderingContext) {
|
||||||
const vertexShader = compileShader(
|
const vertexShader = compileShader(
|
||||||
gl,
|
gl,
|
||||||
@@ -418,7 +694,8 @@ function createMaskedProgram(gl: WebGL2RenderingContext) {
|
|||||||
void main() {
|
void main() {
|
||||||
vec4 color = texture(u_image, v_texCoord);
|
vec4 color = texture(u_image, v_texCoord);
|
||||||
float maskAlpha = texture(u_mask, v_maskTexCoord).a;
|
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();
|
const program = gl.createProgram();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
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 { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||||
import { clearScreenRect } from "./clear-rect";
|
import { clearScreenRect } from "./clear-rect";
|
||||||
import type { ImageTextureRenderer } from "./image-textures";
|
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 imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
|
||||||
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 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) {
|
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
|
||||||
for (const artboard of document.artboards) {
|
for (const artboard of document.artboards) {
|
||||||
@@ -28,7 +30,9 @@ function renderLayer(
|
|||||||
clipRect: ScreenRect,
|
clipRect: ScreenRect,
|
||||||
maskLayerIds: ReadonlySet<string>,
|
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;
|
if (!layer.visible || maskLayerIds.has(layer.id)) return;
|
||||||
|
|
||||||
const effectiveClipRect = resolveLayerClipRect(context, document, editor.viewport, layer, clipRect);
|
const effectiveClipRect = resolveLayerClipRect(context, document, editor.viewport, layer, clipRect);
|
||||||
@@ -39,16 +43,33 @@ function renderLayer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) return;
|
||||||
|
|
||||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||||
if (!bounds) return;
|
if (!bounds) return;
|
||||||
|
|
||||||
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
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 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 maskBounds = maskLayer ? resolveTransformTargetBounds(document, { type: "layer", id: maskLayer.id }) : undefined;
|
||||||
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : 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;
|
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return;
|
||||||
|
|
||||||
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
||||||
@@ -68,11 +89,20 @@ function resolveLayerClipRect(
|
|||||||
if (!layer.clippingMask) return clipRect;
|
if (!layer.clippingMask) return clipRect;
|
||||||
|
|
||||||
const maskBounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.clippingMask.maskLayerId });
|
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));
|
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 {
|
function findLayer(document: ImageDocument, layerId: string): Layer | undefined {
|
||||||
for (const artboard of document.artboards) {
|
for (const artboard of document.artboards) {
|
||||||
const layer = findLayerInTree(artboard.layers, layerId);
|
const layer = findLayerInTree(artboard.layers, layerId);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { EditorState } from "@editor/state";
|
import type { EditorState } from "@editor/state";
|
||||||
import { renderArtboard } from "./artboard";
|
import { renderArtboard } from "./artboard";
|
||||||
|
import { createBrushPreviewRenderer } from "./brush-preview";
|
||||||
import { createImageTextureRenderer } from "./image-textures";
|
import { createImageTextureRenderer } from "./image-textures";
|
||||||
import { renderLayers } from "./layers";
|
import { renderLayers } from "./layers";
|
||||||
import { renderMaskEditOverlay } from "./mask-edit-overlay";
|
|
||||||
import { renderSelectionOverlay } from "./selection";
|
import { renderSelectionOverlay } from "./selection";
|
||||||
import { renderTransformControls } from "./transform-controls";
|
import { renderTransformControls } from "./transform-controls";
|
||||||
import type { WebGlRendererContext } from "./types";
|
import type { WebGlRendererContext } from "./types";
|
||||||
@@ -31,6 +31,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rendererContext: WebGlRendererContext = { gl: context, canvas };
|
const rendererContext: WebGlRendererContext = { gl: context, canvas };
|
||||||
|
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
|
||||||
let lastFrame: RenderFrame | undefined;
|
let lastFrame: RenderFrame | undefined;
|
||||||
let rerenderQueued = false;
|
let rerenderQueued = false;
|
||||||
const imageTextureRenderer = createImageTextureRenderer(rendererContext, () => {
|
const imageTextureRenderer = createImageTextureRenderer(rendererContext, () => {
|
||||||
@@ -61,17 +62,28 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
|||||||
}
|
}
|
||||||
imageTextureRenderer.syncAssets(frame.document.assets);
|
imageTextureRenderer.syncAssets(frame.document.assets);
|
||||||
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
|
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
|
||||||
renderMaskEditOverlay(rendererContext, frame.document, frame.editor, imageTextureRenderer);
|
|
||||||
|
|
||||||
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
|
if (!frame.editor.maskEdit) {
|
||||||
renderTransformControls(rendererContext, frame.document, frame.editor);
|
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
|
||||||
|
renderTransformControls(rendererContext, frame.document, frame.editor);
|
||||||
|
}
|
||||||
|
brushPreviewRenderer?.render(frame.document, frame.editor);
|
||||||
|
|
||||||
context.disable(context.SCISSOR_TEST);
|
context.disable(context.SCISSOR_TEST);
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
imageTextureRenderer.dispose();
|
imageTextureRenderer.dispose();
|
||||||
|
brushPreviewRenderer?.dispose();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return renderer;
|
return renderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createOptionalBrushPreviewRenderer(context: WebGlRendererContext) {
|
||||||
|
try {
|
||||||
|
return createBrushPreviewRenderer(context);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
62
view/App.tsx
62
view/App.tsx
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { DownloadSimple, FolderOpen, ImageSquare, Stack } from "@phosphor-icons/react";
|
||||||
import type { ImageStudioApp } from "@app/app";
|
import type { ImageStudioApp } from "@app/app";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
import { BottomControlsIsland } from "./BottomControlsIsland";
|
import { BottomControlsIsland } from "./BottomControlsIsland";
|
||||||
import { CanvasViewport } from "./CanvasViewport";
|
import { CanvasViewport } from "./CanvasViewport";
|
||||||
import { LayersSheet } from "./LayersSheet";
|
import { LayersSheet } from "./LayersSheet";
|
||||||
@@ -8,6 +10,7 @@ import { labelForTool } from "./toolLabels";
|
|||||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
import { handleDeleteSelectionKey, handleHistoryKey, keybindEventFromKeyboardEvent } from "@input/index";
|
import { handleDeleteSelectionKey, handleHistoryKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||||
import { useAppState } from "./useAppState";
|
import { useAppState } from "./useAppState";
|
||||||
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||||
import { useImageImport } from "./useImageImport";
|
import { useImageImport } from "./useImageImport";
|
||||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
@@ -23,6 +26,8 @@ export function App({ app }: AppProps) {
|
|||||||
const imageImport = useImageImport(app.store);
|
const imageImport = useImageImport(app.store);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -41,6 +46,12 @@ export function App({ app }: AppProps) {
|
|||||||
|
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
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") {
|
if (event.key.toLowerCase() === "l") {
|
||||||
setLayersOpen(true);
|
setLayersOpen(true);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -63,11 +74,39 @@ export function App({ app }: AppProps) {
|
|||||||
return (
|
return (
|
||||||
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
||||||
{imageImport.input}
|
{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">
|
<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">
|
||||||
<h1 className="text-sm font-medium">Image Studio</h1>
|
<div className="flex min-w-0 items-center gap-3 pl-1.5">
|
||||||
<div className="text-xs">
|
<div className="grid size-7 place-items-center rounded-lg bg-white text-black shadow-sm">
|
||||||
{state.document.name} · {labelForTool(state.editor.tools.activeTool)} · {zoomPercent}% · {state.editor.viewport.size.w}×
|
<ImageSquare size={18} weight="fill" />
|
||||||
{state.editor.viewport.size.h}
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||||
@@ -80,7 +119,7 @@ export function App({ app }: AppProps) {
|
|||||||
<LayersSheet
|
<LayersSheet
|
||||||
document={state.document}
|
document={state.document}
|
||||||
selection={state.editor.selection}
|
selection={state.editor.selection}
|
||||||
maskEditLayerId={state.editor.maskEditLayerId}
|
maskEdit={state.editor.maskEdit}
|
||||||
open={layersOpen}
|
open={layersOpen}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
onClose={() => setLayersOpen(false)}
|
onClose={() => setLayersOpen(false)}
|
||||||
@@ -92,6 +131,8 @@ export function App({ app }: AppProps) {
|
|||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
activeTool={state.editor.tools.activeTool}
|
activeTool={state.editor.tools.activeTool}
|
||||||
brushSettings={state.editor.tools.brush}
|
brushSettings={state.editor.tools.brush}
|
||||||
|
editingMask={Boolean(state.editor.maskEdit)}
|
||||||
|
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
|
||||||
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||||
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
||||||
dispatch={app.store.dispatch}
|
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;
|
export default App;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AppStore } from "@editor/store";
|
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 type { BrushSettings, ToolId } from "@editor/tools";
|
||||||
import { BrushControls } from "./bottom-controls/BrushControls";
|
import { BrushControls } from "./bottom-controls/BrushControls";
|
||||||
import { PanControls } from "./bottom-controls/PanControls";
|
import { PanControls } from "./bottom-controls/PanControls";
|
||||||
@@ -15,12 +15,14 @@ export type BottomControlsIslandProps = {
|
|||||||
action: BottomControlsAction;
|
action: BottomControlsAction;
|
||||||
activeTool: ToolId;
|
activeTool: ToolId;
|
||||||
brushSettings: BrushSettings;
|
brushSettings: BrushSettings;
|
||||||
|
editingMask?: boolean;
|
||||||
|
maskViewMode?: MaskViewMode;
|
||||||
transformBounds?: Rect;
|
transformBounds?: Rect;
|
||||||
transformTarget?: TransformTarget;
|
transformTarget?: TransformTarget;
|
||||||
dispatch: AppStore["dispatch"];
|
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 zoomPercent = Math.round(viewport.zoom * 100);
|
||||||
const x = Math.round(viewport.center.x);
|
const x = Math.round(viewport.center.x);
|
||||||
const y = Math.round(viewport.center.y);
|
const y = Math.round(viewport.center.y);
|
||||||
@@ -33,7 +35,7 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{activeTool === "brush" || activeTool === "eraser" ? (
|
{activeTool === "brush" || activeTool === "eraser" ? (
|
||||||
<BrushControls tool={activeTool} settings={brushSettings} dispatch={dispatch} />
|
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||||
) : transformBounds && transformTarget ? (
|
) : transformBounds && transformTarget ? (
|
||||||
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||||
) : action === "pan" ? (
|
) : action === "pan" ? (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
||||||
|
import { canPreviewBrush } from "./canvas/brush";
|
||||||
import { canvasCursorClass } from "./canvas/cursor";
|
import { canvasCursorClass } from "./canvas/cursor";
|
||||||
import { useCanvasInput } from "./canvas/useCanvasInput";
|
import { useCanvasInput } from "./canvas/useCanvasInput";
|
||||||
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
||||||
@@ -34,7 +35,8 @@ export function CanvasViewport({
|
|||||||
useCanvasRenderer(canvasRef, store);
|
useCanvasRenderer(canvasRef, store);
|
||||||
useCanvasResize(canvasRef, store.dispatch);
|
useCanvasResize(canvasRef, store.dispatch);
|
||||||
const input = useCanvasInput(canvasRef, store, inputOptions);
|
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}`} />;
|
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
import { useRef, useState, type DragEvent, type MutableRefObject } from "react";
|
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 { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { ArtboardId } from "@core/id";
|
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 type { AppStore } from "@editor/store";
|
||||||
|
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||||
import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index";
|
import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index";
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||||
|
|
||||||
export type LayersSheetProps = {
|
export type LayersSheetProps = {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
selection: SelectionState;
|
selection: SelectionState;
|
||||||
maskEditLayerId?: string;
|
maskEdit?: MaskEditState;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
onClose: () => void;
|
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 selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
|
||||||
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
|
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
|
||||||
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
||||||
const canUngroup = selectedLayer?.layer.type === "group";
|
const canUngroup = selectedLayer?.layer.type === "group";
|
||||||
|
const maskLayerIds = collectDocumentMaskLayerIds(document);
|
||||||
const draggedLayerId = useRef<string>();
|
const draggedLayerId = useRef<string>();
|
||||||
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-hidden={!open}
|
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"
|
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="font-medium tracking-wide">Layers</div>
|
||||||
<div className="text-xs text-white/40">Press L to open</div>
|
<div className="text-xs text-white/40">Press L to open</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
|
|
||||||
<X size={18} weight="regular" />
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-wrap gap-2 border-b border-white/10 bg-black/20 p-3">
|
<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)}>
|
<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" />
|
<DownloadSimple size={17} weight="regular" />
|
||||||
</button>
|
</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>
|
||||||
<div className="mt-2 space-y-1.5 pl-3">
|
<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>
|
<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) => (
|
artboard.layers.map((layer) => (
|
||||||
@@ -137,8 +137,8 @@ export function LayersSheet({ document, selection, maskEditLayerId, open, dispat
|
|||||||
draggedLayerId={draggedLayerId}
|
draggedLayerId={draggedLayerId}
|
||||||
editingTitle={editingTitle}
|
editingTitle={editingTitle}
|
||||||
setEditingTitle={setEditingTitle}
|
setEditingTitle={setEditingTitle}
|
||||||
selectedMaskLayer={selectedLayer}
|
maskLayerIds={maskLayerIds}
|
||||||
maskEditLayerId={maskEditLayerId}
|
maskEdit={maskEdit}
|
||||||
dispatch={dispatch}
|
dispatch={dispatch}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -160,8 +160,8 @@ function LayerRow({
|
|||||||
draggedLayerId,
|
draggedLayerId,
|
||||||
editingTitle,
|
editingTitle,
|
||||||
setEditingTitle,
|
setEditingTitle,
|
||||||
selectedMaskLayer,
|
maskLayerIds,
|
||||||
maskEditLayerId,
|
maskEdit,
|
||||||
dispatch,
|
dispatch,
|
||||||
}: {
|
}: {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
@@ -172,23 +172,25 @@ function LayerRow({
|
|||||||
draggedLayerId: MutableRefObject<string | undefined>;
|
draggedLayerId: MutableRefObject<string | undefined>;
|
||||||
editingTitle: EditingTitle | undefined;
|
editingTitle: EditingTitle | undefined;
|
||||||
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
||||||
selectedMaskLayer?: LayerInfo;
|
maskLayerIds: ReadonlySet<string>;
|
||||||
maskEditLayerId?: string;
|
maskEdit?: MaskEditState;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
}) {
|
}) {
|
||||||
|
if (maskLayerIds.has(layer.id)) return null;
|
||||||
|
|
||||||
const selected = selectedLayerIds.includes(layer.id);
|
const selected = selectedLayerIds.includes(layer.id);
|
||||||
const layerInfo = findLayerInfoInDocument(document, layer.id);
|
const layerInfo = findLayerInfoInDocument(document, layer.id);
|
||||||
const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
|
const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
|
||||||
const maskIndent = maskLayer ? 24 : 0;
|
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask);
|
||||||
const canSetMask =
|
const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId);
|
||||||
Boolean(selectedMaskLayer && layerInfo && selectedMaskLayer.layer.id !== layer.id && selectedMaskLayer.artboardId === layerInfo.artboardId && selectedMaskLayer.parentGroupId === layerInfo.parentGroupId);
|
const rowPadding = 12 + depth * 16;
|
||||||
const editingMask = maskEditLayerId === layer.clippingMask?.maskLayerId;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
draggable
|
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"}`}
|
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: 12 + depth * 16 + maskIndent }}
|
style={{ paddingLeft: rowPadding }}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
event.dataTransfer.setData("text/plain", layer.id);
|
event.dataTransfer.setData("text/plain", layer.id);
|
||||||
@@ -207,8 +209,7 @@ function LayerRow({
|
|||||||
draggedLayerId.current = undefined;
|
draggedLayerId.current = undefined;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{maskLayer ? <span className={selected ? "text-black/40" : "text-sky-200/55"}>↳</span> : null}
|
<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 })}>
|
||||||
<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 })}>
|
|
||||||
{layer.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
|
{layer.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
|
||||||
</button>
|
</button>
|
||||||
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
|
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
|
||||||
@@ -231,30 +232,46 @@ function LayerRow({
|
|||||||
{layer.name}
|
{layer.name}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{layer.clippingMask ? (
|
||||||
type="button"
|
<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"}`}>
|
||||||
className={selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"}
|
<Stack size={13} weight="fill" /> Mask
|
||||||
aria-label={layer.clippingMask ? "Clear layer mask" : "Use selected layer as mask"}
|
</span>
|
||||||
title={layer.clippingMask ? "Clear mask" : "Use selected layer as mask"}
|
) : canAddMask && layerInfo ? (
|
||||||
disabled={!layer.clippingMask && !canSetMask}
|
<button
|
||||||
onClick={() => dispatch(commandIds.documentSetLayerClippingMask, { layerId: layer.id, maskLayerId: layer.clippingMask ? undefined : selectedMaskLayer?.layer.id })}
|
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"}
|
||||||
<Stack size={17} weight={layer.clippingMask ? "fill" : "regular"} />
|
onClick={() => addLayerMask(document, layerInfo, dispatch)}
|
||||||
</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 })}>
|
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" />}
|
{layer.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{maskLayer ? (
|
{layer.clippingMask ? (
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-sky-100/60" style={{ paddingLeft: 52 + depth * 16 + maskIndent }}>
|
<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 }}>
|
||||||
<span className="h-px w-5 bg-sky-200/25" />
|
<Stack size={14} weight="fill" />
|
||||||
<span>masked by {maskLayer.name}</span>
|
<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
|
<button
|
||||||
type="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"}`}
|
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.toolSetMaskEditLayer, { layerId: editingMask ? undefined : layer.clippingMask?.maskLayerId })}
|
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
|
||||||
>
|
>
|
||||||
{editingMask ? "Editing mask" : "Edit mask"}
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -270,8 +287,8 @@ function LayerRow({
|
|||||||
draggedLayerId={draggedLayerId}
|
draggedLayerId={draggedLayerId}
|
||||||
editingTitle={editingTitle}
|
editingTitle={editingTitle}
|
||||||
setEditingTitle={setEditingTitle}
|
setEditingTitle={setEditingTitle}
|
||||||
selectedMaskLayer={selectedMaskLayer}
|
maskLayerIds={maskLayerIds}
|
||||||
maskEditLayerId={maskEditLayerId}
|
maskEdit={maskEdit}
|
||||||
dispatch={dispatch}
|
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(
|
function dropLayer(
|
||||||
document: ImageDocument,
|
document: ImageDocument,
|
||||||
sourceLayerId: string,
|
sourceLayerId: string,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { MaskViewMode } from "@editor/state";
|
||||||
import type { BrushSettings, ToolId } from "@editor/tools";
|
import type { BrushSettings, ToolId } from "@editor/tools";
|
||||||
import { BottomControlDivider } from "./Divider";
|
import { BottomControlDivider } from "./Divider";
|
||||||
import { bottomControlLabelClass } from "./styles";
|
import { bottomControlLabelClass } from "./styles";
|
||||||
@@ -7,15 +8,17 @@ import { bottomControlLabelClass } from "./styles";
|
|||||||
export type BrushControlsProps = {
|
export type BrushControlsProps = {
|
||||||
tool: Extract<ToolId, "brush" | "eraser">;
|
tool: Extract<ToolId, "brush" | "eraser">;
|
||||||
settings: BrushSettings;
|
settings: BrushSettings;
|
||||||
|
editingMask?: boolean;
|
||||||
|
maskViewMode?: MaskViewMode;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BrushControls({ tool, settings, dispatch }: BrushControlsProps) {
|
export function BrushControls({ tool, settings, editingMask = false, maskViewMode = "composite", dispatch }: BrushControlsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
|
<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 />
|
<BottomControlDivider />
|
||||||
{tool === "brush" ? (
|
{tool === "brush" && !editingMask ? (
|
||||||
<label className="flex items-center gap-1.5">
|
<label className="flex items-center gap-1.5">
|
||||||
<span className={bottomControlLabelClass()}>Color</span>
|
<span className={bottomControlLabelClass()}>Color</span>
|
||||||
<input
|
<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>
|
<span className="w-8 text-right text-white">{Math.round(settings.hardness)}</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,48 +9,88 @@ import type { AppStore } from "@editor/store";
|
|||||||
|
|
||||||
export type BrushSession = {
|
export type BrushSession = {
|
||||||
layerId: string;
|
layerId: string;
|
||||||
|
assetId: string;
|
||||||
previousPoint: Vec2D;
|
previousPoint: Vec2D;
|
||||||
mode: "brush" | "eraser";
|
mode: "brush" | "eraser";
|
||||||
|
source?: string;
|
||||||
|
pending?: Promise<void>;
|
||||||
|
cancelled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
|
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 layer = resolveBrushTargetLayer(document, editor);
|
||||||
const layerId = editor.maskEditLayerId ?? editor.selection.layerIds[0];
|
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||||
if (!layerId) return undefined;
|
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
store: AppStore;
|
||||||
session: BrushSession;
|
session: BrushSession;
|
||||||
point: Vec2D;
|
point: Vec2D;
|
||||||
color: string;
|
color: string;
|
||||||
size: number;
|
size: number;
|
||||||
hardness: number;
|
hardness: number;
|
||||||
}): Promise<BrushSession> {
|
}): BrushSession {
|
||||||
const state = options.store.getState();
|
const state = options.store.getState();
|
||||||
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
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);
|
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({
|
const from = options.session.previousPoint;
|
||||||
source: asset.source,
|
const to = options.point;
|
||||||
width: asset.intrinsicSize.w,
|
options.session.previousPoint = to;
|
||||||
height: asset.intrinsicSize.h,
|
options.session.pending = (options.session.pending ?? Promise.resolve())
|
||||||
from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
.then(async () => {
|
||||||
to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
if (options.session.cancelled) return;
|
||||||
color: state.editor.maskEditLayerId ? "#ffffff" : options.color,
|
|
||||||
size: options.size,
|
|
||||||
hardness: options.hardness,
|
|
||||||
mode: options.session.mode,
|
|
||||||
});
|
|
||||||
|
|
||||||
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: asset.id, source });
|
const source = await drawStroke({
|
||||||
return { ...options.session, previousPoint: options.point };
|
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 {
|
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { InteractionMode } from "@editor/tools";
|
|||||||
import { isPanInteractionMode } from "@editor/tools";
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
import type { CanvasInputState } from "./useCanvasInput";
|
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 (input.isPanning) return "cursor-grabbing";
|
||||||
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
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";
|
return "cursor-default";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { isPanInteractionMode } from "@editor/tools";
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
pointerInputEventFromPointerEvent,
|
pointerInputEventFromPointerEvent,
|
||||||
wheelInputEventFromWheelEvent,
|
wheelInputEventFromWheelEvent,
|
||||||
} from "@input/index";
|
} from "@input/index";
|
||||||
import { beginBrushSession, updateBrushSession, type BrushSession } from "./brush";
|
import { beginBrushSession, canPreviewBrush, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
|
||||||
|
|
||||||
export type CanvasInputOptions = {
|
export type CanvasInputOptions = {
|
||||||
globalKeybindConsumer: GlobalKeybindConsumer;
|
globalKeybindConsumer: GlobalKeybindConsumer;
|
||||||
@@ -50,9 +51,31 @@ export function useCanvasInput(
|
|||||||
isPanMode: () => isPanInteractionMode(store.getState().editor.tools.interactionMode),
|
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 handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event));
|
const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event));
|
||||||
if (consumed) event.preventDefault();
|
if (consumed) {
|
||||||
|
clearBrushPreview();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (event: KeyboardEvent) => {
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
@@ -64,6 +87,7 @@ export function useCanvasInput(
|
|||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
const transformed = transformHandler.pointerDown(inputEvent);
|
const transformed = transformHandler.pointerDown(inputEvent);
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
|
clearBrushPreview();
|
||||||
canvas.setPointerCapture(event.pointerId);
|
canvas.setPointerCapture(event.pointerId);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -71,6 +95,7 @@ export function useCanvasInput(
|
|||||||
|
|
||||||
const consumed = panHandler.pointerDown(inputEvent);
|
const consumed = panHandler.pointerDown(inputEvent);
|
||||||
if (consumed) {
|
if (consumed) {
|
||||||
|
clearBrushPreview();
|
||||||
canvas.setPointerCapture(event.pointerId);
|
canvas.setPointerCapture(event.pointerId);
|
||||||
setIsPanning(true);
|
setIsPanning(true);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -78,10 +103,12 @@ export function useCanvasInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
|
const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport);
|
||||||
const brush = (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
|
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;
|
: undefined;
|
||||||
if (brush) {
|
if (brush) {
|
||||||
|
store.dispatch(commandIds.toolSetBrushPreview, { position: documentPoint });
|
||||||
brushSessionId.current += 1;
|
brushSessionId.current += 1;
|
||||||
brushSession.current = brush;
|
brushSession.current = brush;
|
||||||
canvas.setPointerCapture(event.pointerId);
|
canvas.setPointerCapture(event.pointerId);
|
||||||
@@ -104,43 +131,53 @@ export function useCanvasInput(
|
|||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
if (brushSession.current) {
|
if (brushSession.current) {
|
||||||
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
|
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
|
||||||
|
const session = brushSession.current;
|
||||||
brushSessionId.current += 1;
|
brushSessionId.current += 1;
|
||||||
brushSession.current = undefined;
|
brushSession.current = undefined;
|
||||||
|
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSessionId = brushSessionId.current;
|
|
||||||
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||||
|
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
|
||||||
const settings = store.getState().editor.tools.brush;
|
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) => {
|
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness });
|
||||||
if (brushSessionId.current === activeSessionId) brushSession.current = nextSession;
|
|
||||||
});
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformed = transformHandler.pointerMove(inputEvent);
|
const transformed = transformHandler.pointerMove(inputEvent);
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
|
clearBrushPreview();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumed = panHandler.pointerMove(inputEvent);
|
const consumed = panHandler.pointerMove(inputEvent);
|
||||||
if (consumed) event.preventDefault();
|
if (consumed) {
|
||||||
|
clearBrushPreview();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBrushPreview(inputEvent.position);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (event: PointerEvent) => {
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
if (brushSession.current) {
|
if (brushSession.current) {
|
||||||
|
const session = brushSession.current;
|
||||||
brushSessionId.current += 1;
|
brushSessionId.current += 1;
|
||||||
brushSession.current = undefined;
|
brushSession.current = undefined;
|
||||||
|
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformed = transformHandler.pointerUp(inputEvent);
|
const transformed = transformHandler.pointerUp(inputEvent);
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
|
clearBrushPreview();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,9 +186,14 @@ export function useCanvasInput(
|
|||||||
if (!consumed) return;
|
if (!consumed) return;
|
||||||
|
|
||||||
setIsPanning(false);
|
setIsPanning(false);
|
||||||
|
clearBrushPreview();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePointerLeave = () => {
|
||||||
|
if (!brushSession.current) clearBrushPreview();
|
||||||
|
};
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
const consumed = handleViewportWheel({
|
const consumed = handleViewportWheel({
|
||||||
event: wheelInputEventFromWheelEvent(event),
|
event: wheelInputEventFromWheelEvent(event),
|
||||||
@@ -169,6 +211,7 @@ export function useCanvasInput(
|
|||||||
canvas.addEventListener("pointermove", handlePointerMove);
|
canvas.addEventListener("pointermove", handlePointerMove);
|
||||||
canvas.addEventListener("pointerup", handlePointerUp);
|
canvas.addEventListener("pointerup", handlePointerUp);
|
||||||
canvas.addEventListener("pointercancel", handlePointerUp);
|
canvas.addEventListener("pointercancel", handlePointerUp);
|
||||||
|
canvas.addEventListener("pointerleave", handlePointerLeave);
|
||||||
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -178,6 +221,7 @@ export function useCanvasInput(
|
|||||||
canvas.removeEventListener("pointermove", handlePointerMove);
|
canvas.removeEventListener("pointermove", handlePointerMove);
|
||||||
canvas.removeEventListener("pointerup", handlePointerUp);
|
canvas.removeEventListener("pointerup", handlePointerUp);
|
||||||
canvas.removeEventListener("pointercancel", handlePointerUp);
|
canvas.removeEventListener("pointercancel", handlePointerUp);
|
||||||
|
canvas.removeEventListener("pointerleave", handlePointerLeave);
|
||||||
canvas.removeEventListener("wheel", handleWheel);
|
canvas.removeEventListener("wheel", handleWheel);
|
||||||
};
|
};
|
||||||
}, [canvasRef, options, store]);
|
}, [canvasRef, options, store]);
|
||||||
@@ -185,6 +229,10 @@ export function useCanvasInput(
|
|||||||
return { isPanning };
|
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 }) {
|
function viewportPointToDocumentPoint(point: { x: number; y: number }, viewport: { center: { x: number; y: number }; size: { w: number; h: number }; zoom: number }) {
|
||||||
return {
|
return {
|
||||||
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
|
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
|
||||||
|
|||||||
Reference in New Issue
Block a user