Compare commits
14 Commits
ba3f253ee5
...
4ad0bb8b2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad0bb8b2c | ||
|
|
daaa2a6667 | ||
|
|
15f3a934e0 | ||
|
|
4a21b28ed0 | ||
|
|
29c0d1eafd | ||
|
|
64aebbe23a | ||
|
|
e75f8bc0a7 | ||
|
|
c13a6f5917 | ||
|
|
9fc0c18114 | ||
|
|
665f4a5340 | ||
|
|
c946fb4c93 | ||
|
|
5e4cde4df9 | ||
|
|
0ffbc4f68b | ||
|
|
fedeaffa57 |
@@ -1,4 +1,5 @@
|
|||||||
import { documentCommands } from "@commands/document";
|
import { documentCommands } from "@commands/document";
|
||||||
|
import { historyCommands } from "@commands/history";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import { createCommandRegistry } from "@commands/registry";
|
import { createCommandRegistry } from "@commands/registry";
|
||||||
import { selectionCommands } from "@commands/selection";
|
import { selectionCommands } from "@commands/selection";
|
||||||
@@ -11,7 +12,7 @@ import { createAppStore } from "@editor/store";
|
|||||||
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
||||||
|
|
||||||
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
|
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
|
||||||
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...transformCommands]);
|
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...transformCommands, ...historyCommands]);
|
||||||
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
||||||
|
|
||||||
if (options?.createDefaultArtboard !== false) {
|
if (options?.createDefaultArtboard !== false) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AppState } from "@editor/state";
|
import type { AppState, HistorySnapshot } from "@editor/state";
|
||||||
import type { CommandContext } from "./command";
|
import type { CommandContext } from "./command";
|
||||||
|
import { commandIds } from "./ids";
|
||||||
import type { CommandId, CommandPayloads } from "./payloads";
|
import type { CommandId, CommandPayloads } from "./payloads";
|
||||||
import type { CommandRegistry } from "./registry";
|
import type { CommandRegistry } from "./registry";
|
||||||
|
|
||||||
@@ -21,10 +22,38 @@ export function createCommandDispatcher(options: {
|
|||||||
throw new Error(`Unknown command: ${commandId}`);
|
throw new Error(`Unknown command: ${commandId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const context: CommandContext = { state: options.getState() };
|
const currentState = options.getState();
|
||||||
const nextState = command.execute(context, payload);
|
const context: CommandContext = { state: currentState };
|
||||||
|
const executedState = command.execute(context, payload);
|
||||||
|
const nextState = shouldRecordHistory(commandId, currentState, executedState) ? recordHistory(currentState, executedState) : executedState;
|
||||||
options.setState(nextState);
|
options.setState(nextState);
|
||||||
return nextState;
|
return nextState;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRecordHistory(commandId: CommandId, currentState: AppState, nextState: AppState) {
|
||||||
|
if (commandId === commandIds.historyUndo || commandId === commandIds.historyRedo) return false;
|
||||||
|
return currentState.document !== nextState.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordHistory(currentState: AppState, nextState: AppState): AppState {
|
||||||
|
return {
|
||||||
|
...nextState,
|
||||||
|
history: {
|
||||||
|
past: [...currentState.history.past, snapshot(currentState)].slice(-100),
|
||||||
|
future: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(state: AppState): HistorySnapshot {
|
||||||
|
return {
|
||||||
|
document: state.document,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
brushPreview: undefined,
|
||||||
|
brushStrokePreview: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
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 { documentAddArtboardCommand, documentAddAssetCommand, documentAddImageLayerCommand, documentSetArtboardBoundsCommand } from "./document";
|
import {
|
||||||
|
documentAddArtboardCommand,
|
||||||
|
documentAddAssetCommand,
|
||||||
|
documentAddGroupLayerCommand,
|
||||||
|
documentAddImageLayerCommand,
|
||||||
|
documentAddLayerMaskCommand,
|
||||||
|
documentAddRasterLayerCommand,
|
||||||
|
documentGroupLayersCommand,
|
||||||
|
documentMoveLayerCommand,
|
||||||
|
documentRemoveArtboardCommand,
|
||||||
|
documentRemoveLayerCommand,
|
||||||
|
documentRemoveLayerMaskCommand,
|
||||||
|
documentRenameArtboardCommand,
|
||||||
|
documentRenameLayerCommand,
|
||||||
|
documentSetArtboardBoundsCommand,
|
||||||
|
documentSetArtboardLockedCommand,
|
||||||
|
documentSetArtboardVisibleCommand,
|
||||||
|
documentSetLayerClippingMaskCommand,
|
||||||
|
documentSetLayerLockedCommand,
|
||||||
|
documentSetLayerVisibleCommand,
|
||||||
|
documentUpdateAssetSourceCommand,
|
||||||
|
documentUngroupLayerCommand,
|
||||||
|
} from "./document";
|
||||||
|
|
||||||
describe("document commands", () => {
|
describe("document commands", () => {
|
||||||
test("adds transparent artboard", () => {
|
test("adds transparent artboard", () => {
|
||||||
@@ -15,6 +38,8 @@ describe("document commands", () => {
|
|||||||
name: "Artboard 1",
|
name: "Artboard 1",
|
||||||
bounds: { x: 0, y: 0, w: 320, h: 240 },
|
bounds: { x: 0, y: 0, w: 320, h: 240 },
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
layers: [],
|
layers: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -31,6 +56,17 @@ describe("document commands", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("updates asset sources", () => {
|
||||||
|
const state = documentAddAssetCommand.execute(
|
||||||
|
{ state: createInitialAppState("Test") },
|
||||||
|
{ asset: { id: "asset-1", name: "Image", mimeType: "image/png", source: "old", intrinsicSize: { w: 100, h: 50 } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = documentUpdateAssetSourceCommand.execute({ state }, { assetId: "asset-1", source: "new" });
|
||||||
|
|
||||||
|
expect(next.document.assets[0]?.source).toBe("new");
|
||||||
|
});
|
||||||
|
|
||||||
test("adds image layers to artboards", () => {
|
test("adds image layers to artboards", () => {
|
||||||
const state = documentAddArtboardCommand.execute(
|
const state = documentAddArtboardCommand.execute(
|
||||||
{ state: createInitialAppState("Test") },
|
{ state: createInitialAppState("Test") },
|
||||||
@@ -57,6 +93,187 @@ describe("document commands", () => {
|
|||||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["l1"]);
|
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["l1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("adds raster layers to artboards", () => {
|
||||||
|
const state = documentAddArtboardCommand.execute(
|
||||||
|
{ state: createInitialAppState("Test") },
|
||||||
|
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = documentAddRasterLayerCommand.execute(
|
||||||
|
{ state },
|
||||||
|
{
|
||||||
|
artboardId: "a1",
|
||||||
|
layer: {
|
||||||
|
id: "r1",
|
||||||
|
type: "raster",
|
||||||
|
name: "Raster",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId: "asset-1",
|
||||||
|
transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["r1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds group layers", () => {
|
||||||
|
const state = documentAddArtboardCommand.execute(
|
||||||
|
{ state: createInitialAppState("Test") },
|
||||||
|
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = documentAddGroupLayerCommand.execute({ state }, { artboardId: "a1", group: group("g1", "Group") });
|
||||||
|
|
||||||
|
expect(next.document.artboards[0]?.layers).toEqual([group("g1", "Group")]);
|
||||||
|
expect(next.editor.selection.layerIds).toEqual(["g1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moves layers", () => {
|
||||||
|
const state = documentWithLayers([group("a", "A"), group("b", "B"), group("c", "C")]);
|
||||||
|
|
||||||
|
const next = documentMoveLayerCommand.execute({ state }, { layerId: "a", toArtboardId: "a1", toIndex: 2 });
|
||||||
|
|
||||||
|
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["b", "c", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups and ungroups top-level layers", () => {
|
||||||
|
const state = documentWithLayers([group("a", "A"), group("b", "B"), group("c", "C")]);
|
||||||
|
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
|
||||||
|
|
||||||
|
expect(grouped.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["g", "c"]);
|
||||||
|
expect((grouped.document.artboards[0]?.layers[0] as { children: { id: string }[] }).children.map((layer) => layer.id)).toEqual(["a", "b"]);
|
||||||
|
expect(grouped.editor.selection.layerIds).toEqual(["g"]);
|
||||||
|
|
||||||
|
const ungrouped = documentUngroupLayerCommand.execute({ state: grouped }, { groupId: "g" });
|
||||||
|
|
||||||
|
expect(ungrouped.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes layers", () => {
|
||||||
|
const state = documentWithLayers([group("a", "A"), group("b", "B")]);
|
||||||
|
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } };
|
||||||
|
const next = documentRemoveLayerCommand.execute({ state: selectedState }, { layerId: "a" });
|
||||||
|
|
||||||
|
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["b"]);
|
||||||
|
expect(next.editor.selection.layerIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes artboards and clears artboard selection", () => {
|
||||||
|
const state = documentAddArtboardCommand.execute(
|
||||||
|
{ state: createInitialAppState("Test") },
|
||||||
|
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
|
||||||
|
);
|
||||||
|
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: [] } } };
|
||||||
|
|
||||||
|
const next = documentRemoveArtboardCommand.execute({ state: selectedState }, { id: "a1" });
|
||||||
|
|
||||||
|
expect(next.document.artboards).toEqual([]);
|
||||||
|
expect(next.editor.selection).toEqual({ layerIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renames artboards and layers", () => {
|
||||||
|
const state = documentWithLayers([group("a", "A")]);
|
||||||
|
const renamedArtboard = documentRenameArtboardCommand.execute({ state }, { id: "a1", name: "New Artboard" });
|
||||||
|
const renamedLayer = documentRenameLayerCommand.execute({ state: renamedArtboard }, { layerId: "a", name: "New Layer" });
|
||||||
|
|
||||||
|
expect(renamedLayer.document.artboards[0]?.name).toBe("New Artboard");
|
||||||
|
expect(renamedLayer.document.artboards[0]?.layers[0]?.name).toBe("New Layer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets and clears layer clipping masks", () => {
|
||||||
|
const state = documentWithLayers([group("mask", "Mask"), group("target", "Target")]);
|
||||||
|
const masked = documentSetLayerClippingMaskCommand.execute({ state }, { layerId: "target", maskLayerId: "mask" });
|
||||||
|
|
||||||
|
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
|
||||||
|
|
||||||
|
const cleared = documentSetLayerClippingMaskCommand.execute({ state: masked }, { layerId: "target" });
|
||||||
|
|
||||||
|
expect(cleared.document.artboards[0]?.layers[1]?.clippingMask).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moves masked layers directly after their mask", () => {
|
||||||
|
const state = documentWithLayers([group("target", "Target"), group("other", "Other"), group("mask", "Mask")]);
|
||||||
|
|
||||||
|
const masked = documentSetLayerClippingMaskCommand.execute({ state }, { layerId: "target", maskLayerId: "mask" });
|
||||||
|
|
||||||
|
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["other", "mask", "target"]);
|
||||||
|
expect(masked.document.artboards[0]?.layers[2]?.clippingMask).toEqual({ maskLayerId: "mask" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds editable raster masks directly before the target layer", () => {
|
||||||
|
const state = documentWithLayers([raster("target", "Target")]);
|
||||||
|
|
||||||
|
const masked = documentAddLayerMaskCommand.execute(
|
||||||
|
{ state },
|
||||||
|
{
|
||||||
|
layerId: "target",
|
||||||
|
asset: maskAsset(),
|
||||||
|
maskLayer: raster("mask", "Target Mask", "mask-asset"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(masked.document.assets).toContainEqual(maskAsset());
|
||||||
|
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]);
|
||||||
|
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
|
||||||
|
expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
|
||||||
|
expect(masked.editor.tools.activeTool).toBe("brush");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes attached layer masks and exits mask editing", () => {
|
||||||
|
const state = documentAddLayerMaskCommand.execute(
|
||||||
|
{ state: documentWithLayers([raster("target", "Target")]) },
|
||||||
|
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
|
||||||
|
);
|
||||||
|
|
||||||
|
const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" });
|
||||||
|
|
||||||
|
expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]);
|
||||||
|
expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
|
||||||
|
expect(unmasked.editor.maskEdit).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleans mask references when deleting targets or mask layers", () => {
|
||||||
|
const state = documentAddLayerMaskCommand.execute(
|
||||||
|
{ state: documentWithLayers([raster("target", "Target")]) },
|
||||||
|
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
|
||||||
|
);
|
||||||
|
|
||||||
|
const removedTarget = documentRemoveLayerCommand.execute({ state }, { layerId: "target" });
|
||||||
|
const removedMask = documentRemoveLayerCommand.execute({ state }, { layerId: "mask" });
|
||||||
|
|
||||||
|
expect(removedTarget.document.artboards[0]?.layers).toEqual([]);
|
||||||
|
expect(removedMask.document.artboards[0]?.layers[0]?.id).toBe("target");
|
||||||
|
expect(removedMask.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
|
||||||
|
expect(removedMask.editor.maskEdit).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets artboard visibility and lock state", () => {
|
||||||
|
const state = documentAddArtboardCommand.execute(
|
||||||
|
{ state: createInitialAppState("Test") },
|
||||||
|
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hidden = documentSetArtboardVisibleCommand.execute({ state }, { id: "a1", visible: false });
|
||||||
|
const locked = documentSetArtboardLockedCommand.execute({ state: hidden }, { id: "a1", locked: true });
|
||||||
|
|
||||||
|
expect(locked.document.artboards[0]?.visible).toBe(false);
|
||||||
|
expect(locked.document.artboards[0]?.locked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets layer visibility and lock state", () => {
|
||||||
|
const state = documentWithLayers([group("a", "A")]);
|
||||||
|
|
||||||
|
const hidden = documentSetLayerVisibleCommand.execute({ state }, { layerId: "a", visible: false });
|
||||||
|
const locked = documentSetLayerLockedCommand.execute({ state: hidden }, { layerId: "a", locked: true });
|
||||||
|
|
||||||
|
expect(locked.document.artboards[0]?.layers[0]?.visible).toBe(false);
|
||||||
|
expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("sets artboard bounds", () => {
|
test("sets artboard bounds", () => {
|
||||||
const state = documentAddArtboardCommand.execute(
|
const state = documentAddArtboardCommand.execute(
|
||||||
{ state: createInitialAppState("Test") },
|
{ state: createInitialAppState("Test") },
|
||||||
@@ -68,3 +285,47 @@ describe("document commands", () => {
|
|||||||
expect(next.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 640, h: 480 });
|
expect(next.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 640, h: 480 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function documentWithLayers(layers: Layer[]) {
|
||||||
|
return {
|
||||||
|
...createInitialAppState("Test"),
|
||||||
|
document: {
|
||||||
|
...createInitialAppState("Test").document,
|
||||||
|
artboards: [{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 }, backgroundColor: "transparent", visible: true, locked: false, layers }],
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
...createInitialAppState("Test").editor,
|
||||||
|
selection: { artboardId: "a1", layerIds: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(id: string, name: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "group" as const,
|
||||||
|
name,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
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 } };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { Asset } from "@core/asset";
|
import type { Asset } from "@core/asset";
|
||||||
import type { ImageLayer } from "@core/image-layer";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { ArtboardId } from "@core/id";
|
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||||
|
import type { ImageLayer } from "@core/image-layer";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { RasterLayer } from "@core/raster-layer";
|
||||||
|
import type { LayerGroup } from "@core/layer-group";
|
||||||
import type { Command } from "./command";
|
import type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
@@ -16,15 +20,103 @@ export type DocumentSetArtboardBoundsPayload = {
|
|||||||
bounds: Rect;
|
bounds: Rect;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentRemoveArtboardPayload = {
|
||||||
|
id: ArtboardId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentSetArtboardVisiblePayload = {
|
||||||
|
id: ArtboardId;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentSetArtboardLockedPayload = {
|
||||||
|
id: ArtboardId;
|
||||||
|
locked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentRenameArtboardPayload = {
|
||||||
|
id: ArtboardId;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DocumentAddAssetPayload = {
|
export type DocumentAddAssetPayload = {
|
||||||
asset: Asset;
|
asset: Asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentUpdateAssetSourcePayload = {
|
||||||
|
assetId: AssetId;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DocumentAddImageLayerPayload = {
|
export type DocumentAddImageLayerPayload = {
|
||||||
artboardId: ArtboardId;
|
artboardId: ArtboardId;
|
||||||
|
parentGroupId?: LayerId;
|
||||||
layer: ImageLayer;
|
layer: ImageLayer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentAddRasterLayerPayload = {
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
parentGroupId?: LayerId;
|
||||||
|
layer: RasterLayer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentAddGroupLayerPayload = {
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
parentGroupId?: LayerId;
|
||||||
|
group: LayerGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentMoveLayerPayload = {
|
||||||
|
layerId: LayerId;
|
||||||
|
toArtboardId: ArtboardId;
|
||||||
|
toParentGroupId?: LayerId;
|
||||||
|
toIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentGroupLayersPayload = {
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
layerIds: LayerId[];
|
||||||
|
group: LayerGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentUngroupLayerPayload = {
|
||||||
|
groupId: LayerId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentRemoveLayerPayload = {
|
||||||
|
layerId: LayerId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentSetLayerVisiblePayload = {
|
||||||
|
layerId: LayerId;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentSetLayerLockedPayload = {
|
||||||
|
layerId: LayerId;
|
||||||
|
locked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentRenameLayerPayload = {
|
||||||
|
layerId: LayerId;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentSetLayerClippingMaskPayload = {
|
||||||
|
layerId: 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",
|
||||||
@@ -40,6 +132,8 @@ export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
bounds: payload.bounds,
|
bounds: payload.bounds,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
layers: [],
|
layers: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -64,6 +158,72 @@ export const documentSetArtboardBoundsCommand: Command<DocumentSetArtboardBounds
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
|
||||||
|
id: commandIds.documentRemoveArtboard,
|
||||||
|
name: "Remove artboard",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
|
||||||
|
const document = {
|
||||||
|
...state.document,
|
||||||
|
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
selection: removedSelectedArtboard ? { layerIds: [] } : state.editor.selection,
|
||||||
|
maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentSetArtboardVisibleCommand: Command<DocumentSetArtboardVisiblePayload> = {
|
||||||
|
id: commandIds.documentSetArtboardVisible,
|
||||||
|
name: "Set artboard visible",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: {
|
||||||
|
...state.document,
|
||||||
|
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, visible: payload.visible } : artboard)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentSetArtboardLockedCommand: Command<DocumentSetArtboardLockedPayload> = {
|
||||||
|
id: commandIds.documentSetArtboardLocked,
|
||||||
|
name: "Set artboard locked",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: {
|
||||||
|
...state.document,
|
||||||
|
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, locked: payload.locked } : artboard)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentRenameArtboardCommand: Command<DocumentRenameArtboardPayload> = {
|
||||||
|
id: commandIds.documentRenameArtboard,
|
||||||
|
name: "Rename artboard",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const name = payload.name.trim();
|
||||||
|
if (!name) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: {
|
||||||
|
...state.document,
|
||||||
|
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, name } : artboard)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const documentAddAssetCommand: Command<DocumentAddAssetPayload> = {
|
export const documentAddAssetCommand: Command<DocumentAddAssetPayload> = {
|
||||||
id: commandIds.documentAddAsset,
|
id: commandIds.documentAddAsset,
|
||||||
name: "Add asset",
|
name: "Add asset",
|
||||||
@@ -80,17 +240,304 @@ export const documentAddAssetCommand: Command<DocumentAddAssetPayload> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const documentUpdateAssetSourceCommand: Command<DocumentUpdateAssetSourcePayload> = {
|
||||||
|
id: commandIds.documentUpdateAssetSource,
|
||||||
|
name: "Update asset source",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: {
|
||||||
|
...state.document,
|
||||||
|
assets: state.document.assets.map((asset) => (asset.id === payload.assetId ? { ...asset, source: payload.source } : asset)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload> = {
|
export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload> = {
|
||||||
id: commandIds.documentAddImageLayer,
|
id: commandIds.documentAddImageLayer,
|
||||||
name: "Add image layer",
|
name: "Add image layer",
|
||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
document: {
|
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentAddRasterLayerCommand: Command<DocumentAddRasterLayerPayload> = {
|
||||||
|
id: commandIds.documentAddRasterLayer,
|
||||||
|
name: "Add raster layer",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentAddGroupLayerCommand: Command<DocumentAddGroupLayerPayload> = {
|
||||||
|
id: commandIds.documentAddGroupLayer,
|
||||||
|
name: "Add group layer",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.group),
|
||||||
|
editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.group.id] } },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
|
||||||
|
id: commandIds.documentMoveLayer,
|
||||||
|
name: "Move layer",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
if (payload.toParentGroupId && !findGroup(state.document, payload.toParentGroupId)) return state;
|
||||||
|
|
||||||
|
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||||
|
if (!removed.layer) return state;
|
||||||
|
|
||||||
|
const maskLayerId = removed.layer.clippingMask?.maskLayerId;
|
||||||
|
const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined;
|
||||||
|
const documentAfterRemoval = removedMask?.document ?? removed.document;
|
||||||
|
if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state;
|
||||||
|
|
||||||
|
const documentWithMask = removedMask?.layer
|
||||||
|
? insertLayer(documentAfterRemoval, payload.toArtboardId, payload.toParentGroupId, removedMask.layer, payload.toIndex)
|
||||||
|
: documentAfterRemoval;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: insertLayer(documentWithMask, payload.toArtboardId, payload.toParentGroupId, removed.layer, payload.toIndex + (removedMask?.layer ? 1 : 0)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
|
||||||
|
id: commandIds.documentGroupLayers,
|
||||||
|
name: "Group layers",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const requestedIds = [...new Set(payload.layerIds)];
|
||||||
|
if (requestedIds.length === 0) return state;
|
||||||
|
|
||||||
|
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
|
||||||
|
if (!artboard) return state;
|
||||||
|
|
||||||
|
const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])];
|
||||||
|
|
||||||
|
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
|
||||||
|
if (selected.length === 0) return state;
|
||||||
|
|
||||||
|
const firstIndex = artboard.layers.findIndex((layer) => layer.id === selected[0]?.id);
|
||||||
|
const group: LayerGroup = { ...payload.group, children: selected };
|
||||||
|
const document = {
|
||||||
...state.document,
|
...state.document,
|
||||||
artboards: state.document.artboards.map((artboard) =>
|
artboards: state.document.artboards.map((candidate) =>
|
||||||
artboard.id === payload.artboardId ? { ...artboard, layers: [...artboard.layers, payload.layer] } : artboard,
|
candidate.id === payload.artboardId
|
||||||
|
? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] }
|
||||||
|
: candidate,
|
||||||
),
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document,
|
||||||
|
editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [group.id] } },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentUngroupLayerCommand: Command<DocumentUngroupLayerPayload> = {
|
||||||
|
id: commandIds.documentUngroupLayer,
|
||||||
|
name: "Ungroup layer",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const result = ungroupLayerInDocument(state.document, payload.groupId);
|
||||||
|
if (!result.changed) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: result.document,
|
||||||
|
editor: { ...state.editor, selection: { artboardId: result.artboardId, layerIds: result.children.map((layer) => layer.id) } },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentSetLayerVisibleCommand: Command<DocumentSetLayerVisiblePayload> = {
|
||||||
|
id: commandIds.documentSetLayerVisible,
|
||||||
|
name: "Set layer visible",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, visible: payload.visible })) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentSetLayerLockedCommand: Command<DocumentSetLayerLockedPayload> = {
|
||||||
|
id: commandIds.documentSetLayerLocked,
|
||||||
|
name: "Set layer locked",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, locked: payload.locked })) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentRenameLayerCommand: Command<DocumentRenameLayerPayload> = {
|
||||||
|
id: commandIds.documentRenameLayer,
|
||||||
|
name: "Rename layer",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const name = payload.name.trim();
|
||||||
|
if (!name) return state;
|
||||||
|
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, name })) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippingMaskPayload> = {
|
||||||
|
id: commandIds.documentSetLayerClippingMask,
|
||||||
|
name: "Set layer clipping mask",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
if (payload.maskLayerId === payload.layerId) return state;
|
||||||
|
|
||||||
|
if (!payload.maskLayerId) {
|
||||||
|
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
maskEdit: previousMaskId && isMaskEditFor(state.editor.maskEdit, payload.layerId, previousMaskId) ? undefined : state.editor.maskEdit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||||
|
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
||||||
|
if (!targetLocation || !maskLocation) return state;
|
||||||
|
if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state;
|
||||||
|
|
||||||
|
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||||
|
if (!removed.layer) return state;
|
||||||
|
|
||||||
|
const maskLocationAfterRemoval = findLayerLocation(removed.document, payload.maskLayerId);
|
||||||
|
if (!maskLocationAfterRemoval) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: insertLayer(
|
||||||
|
removed.document,
|
||||||
|
maskLocationAfterRemoval.artboardId,
|
||||||
|
maskLocationAfterRemoval.parentGroupId,
|
||||||
|
{ ...removed.layer, clippingMask: { maskLayerId: payload.maskLayerId } },
|
||||||
|
maskLocationAfterRemoval.index + 1,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> = {
|
||||||
|
id: commandIds.documentAddLayerMask,
|
||||||
|
name: "Add layer mask",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||||
|
if (!targetLocation || targetLocation.layer.type === "group") return state;
|
||||||
|
|
||||||
|
const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId;
|
||||||
|
if (existingMaskId) {
|
||||||
|
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
|
||||||
|
if (existingMaskLocation?.layer.type === "group") return state;
|
||||||
|
if (existingMaskLocation) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
|
||||||
|
maskEdit: { targetLayerId: payload.layerId, maskLayerId: existingMaskId },
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
activeTool: "brush",
|
||||||
|
interactionMode: { type: "tool", tool: "brush" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.maskLayer.id === payload.layerId) return state;
|
||||||
|
if (state.document.assets.some((asset) => asset.id === payload.asset.id)) return state;
|
||||||
|
if (findLayerLocation(state.document, payload.maskLayer.id)) return state;
|
||||||
|
|
||||||
|
const maskLayer: RasterLayer = {
|
||||||
|
...payload.maskLayer,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
clippingMask: undefined,
|
||||||
|
};
|
||||||
|
const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] };
|
||||||
|
const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index);
|
||||||
|
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
|
||||||
|
maskEdit: { targetLayerId: payload.layerId, maskLayerId: maskLayer.id },
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
activeTool: "brush",
|
||||||
|
interactionMode: { type: "tool", tool: "brush" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayload> = {
|
||||||
|
id: commandIds.documentRemoveLayerMask,
|
||||||
|
name: "Remove layer mask",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||||
|
const maskLayerId = targetLocation?.layer.clippingMask?.maskLayerId;
|
||||||
|
if (!targetLocation || !maskLayerId) {
|
||||||
|
return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmasked = mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer));
|
||||||
|
const document = removeUnreferencedMaskLayer(unmasked, maskLayerId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
|
||||||
|
maskEdit: isMaskEditFor(state.editor.maskEdit, payload.layerId, maskLayerId) ? undefined : state.editor.maskEdit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
|
||||||
|
id: commandIds.documentRemoveLayer,
|
||||||
|
name: "Remove layer",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||||
|
if (!removed.layer) return state;
|
||||||
|
|
||||||
|
const removedLayerIds = collectLayerIds(removed.layer);
|
||||||
|
const removedMaskLayerIds = collectClippingMaskIds([removed.layer]);
|
||||||
|
const cleanedReferences = removeMissingMaskReferences(removed.document);
|
||||||
|
const document = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
|
||||||
|
const selection = {
|
||||||
|
...state.editor.selection,
|
||||||
|
layerIds: state.editor.selection.layerIds.filter((id) => !removedLayerIds.has(id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
selection,
|
||||||
|
maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -99,6 +546,253 @@ export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload>
|
|||||||
export const documentCommands = [
|
export const documentCommands = [
|
||||||
documentAddArtboardCommand,
|
documentAddArtboardCommand,
|
||||||
documentSetArtboardBoundsCommand,
|
documentSetArtboardBoundsCommand,
|
||||||
|
documentRemoveArtboardCommand,
|
||||||
|
documentSetArtboardVisibleCommand,
|
||||||
|
documentSetArtboardLockedCommand,
|
||||||
|
documentRenameArtboardCommand,
|
||||||
documentAddAssetCommand,
|
documentAddAssetCommand,
|
||||||
|
documentUpdateAssetSourceCommand,
|
||||||
documentAddImageLayerCommand,
|
documentAddImageLayerCommand,
|
||||||
|
documentAddRasterLayerCommand,
|
||||||
|
documentAddGroupLayerCommand,
|
||||||
|
documentMoveLayerCommand,
|
||||||
|
documentGroupLayersCommand,
|
||||||
|
documentUngroupLayerCommand,
|
||||||
|
documentRemoveLayerCommand,
|
||||||
|
documentSetLayerVisibleCommand,
|
||||||
|
documentSetLayerLockedCommand,
|
||||||
|
documentRenameLayerCommand,
|
||||||
|
documentSetLayerClippingMaskCommand,
|
||||||
|
documentAddLayerMaskCommand,
|
||||||
|
documentRemoveLayerMaskCommand,
|
||||||
] satisfies Command<unknown>[];
|
] satisfies Command<unknown>[];
|
||||||
|
|
||||||
|
type LayerLocation = {
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
parentGroupId?: LayerId;
|
||||||
|
index: number;
|
||||||
|
layer: Layer;
|
||||||
|
};
|
||||||
|
|
||||||
|
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
|
||||||
|
for (const artboard of document.artboards) {
|
||||||
|
const location = findLayerLocationInTree(artboard.layers, layerId, artboard.id);
|
||||||
|
if (location) return location;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerLocation | undefined {
|
||||||
|
for (let index = 0; index < layers.length; index++) {
|
||||||
|
const layer = layers[index];
|
||||||
|
if (!layer) continue;
|
||||||
|
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer };
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLayerInDocument(document: ImageDocument, layerId: LayerId, mapLayer: (layer: Layer) => Layer): ImageDocument {
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapLayerInTree(artboard.layers, layerId, mapLayer) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLayerInTree(layers: Layer[], layerId: LayerId, mapLayer: (layer: Layer) => Layer): Layer[] {
|
||||||
|
return layers.map((layer) => {
|
||||||
|
if (layer.id === layerId) return mapLayer(layer);
|
||||||
|
if (layer.type === "group") return { ...layer, children: mapLayerInTree(layer.children, layerId, mapLayer) };
|
||||||
|
return layer;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLayer(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layer: Layer, index?: number): ImageDocument {
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
artboards: document.artboards.map((artboard) => {
|
||||||
|
if (artboard.id !== artboardId) return artboard;
|
||||||
|
if (!parentGroupId) return { ...artboard, layers: insertAt(artboard.layers, layer, index) };
|
||||||
|
return { ...artboard, layers: insertLayerInGroup(artboard.layers, parentGroupId, layer, index) };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, index?: number): Layer[] {
|
||||||
|
return layers.map((candidate) => {
|
||||||
|
if (candidate.type === "group" && candidate.id === groupId) return { ...candidate, children: insertAt(candidate.children, layer, index) };
|
||||||
|
if (candidate.type === "group") return { ...candidate, children: insertLayerInGroup(candidate.children, groupId, layer, index) };
|
||||||
|
return candidate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
|
||||||
|
let removed: Layer | undefined;
|
||||||
|
return {
|
||||||
|
document: {
|
||||||
|
...document,
|
||||||
|
artboards: document.artboards.map((artboard) => {
|
||||||
|
const result = removeLayerFromTree(artboard.layers, layerId);
|
||||||
|
if (result.layer) removed = result.layer;
|
||||||
|
return { ...artboard, layers: result.layers };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
layer: removed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLayerFromTree(layers: Layer[], layerId: LayerId): { layers: Layer[]; layer?: Layer } {
|
||||||
|
let removed: Layer | undefined;
|
||||||
|
const next: Layer[] = [];
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.id === layerId) {
|
||||||
|
removed = layer;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const result = removeLayerFromTree(layer.children, layerId);
|
||||||
|
if (result.layer) removed = result.layer;
|
||||||
|
next.push({ ...layer, children: result.layers });
|
||||||
|
} else {
|
||||||
|
next.push(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { layers: next, layer: removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ungroupLayerInDocument(document: ImageDocument, groupId: LayerId): { document: ImageDocument; changed: boolean; artboardId?: ArtboardId; children: Layer[] } {
|
||||||
|
let changed = false;
|
||||||
|
let artboardId: ArtboardId | undefined;
|
||||||
|
let children: Layer[] = [];
|
||||||
|
const next = {
|
||||||
|
...document,
|
||||||
|
artboards: document.artboards.map((artboard) => {
|
||||||
|
const result = ungroupLayerInTree(artboard.layers, groupId);
|
||||||
|
if (result.changed) {
|
||||||
|
changed = true;
|
||||||
|
artboardId = artboard.id;
|
||||||
|
children = result.children;
|
||||||
|
}
|
||||||
|
return { ...artboard, layers: result.layers };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { document: next, changed, artboardId, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ungroupLayerInTree(layers: Layer[], groupId: LayerId): { layers: Layer[]; changed: boolean; children: Layer[] } {
|
||||||
|
const next: Layer[] = [];
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.type === "group" && layer.id === groupId) return { layers: [...next, ...layer.children, ...layers.slice(next.length + 1)], changed: true, children: layer.children };
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const result = ungroupLayerInTree(layer.children, groupId);
|
||||||
|
if (result.changed) return { layers: [...next, { ...layer, children: result.layers }, ...layers.slice(next.length + 1)], changed: true, children: result.children };
|
||||||
|
}
|
||||||
|
next.push(layer);
|
||||||
|
}
|
||||||
|
return { layers, changed: false, children: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAt(layers: Layer[], layer: Layer, index = layers.length) {
|
||||||
|
const clamped = Math.max(0, Math.min(index, layers.length));
|
||||||
|
return [...layers.slice(0, clamped), layer, ...layers.slice(clamped)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGroup(document: ImageDocument, groupId: LayerId): LayerGroup | undefined {
|
||||||
|
for (const artboard of document.artboards) {
|
||||||
|
const group = findGroupInTree(artboard.layers, groupId);
|
||||||
|
if (group) return group;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.type === "group" && layer.id === groupId) return layer;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const child = findGroupInTree(layer.children, groupId);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
29
commands/history.test.ts
Normal file
29
commands/history.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
|
import { createAppStore } from "@editor/store";
|
||||||
|
import { documentAddArtboardCommand } from "./document";
|
||||||
|
import { historyCommands } from "./history";
|
||||||
|
import { commandIds } from "./ids";
|
||||||
|
import { createCommandRegistry } from "./registry";
|
||||||
|
|
||||||
|
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands]);
|
||||||
|
|
||||||
|
describe("history commands", () => {
|
||||||
|
test("records document changes and undoes/redoes them", () => {
|
||||||
|
const store = createAppStore(createInitialAppState("Test"), registry);
|
||||||
|
|
||||||
|
store.dispatch(commandIds.documentAddArtboard, { id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 } });
|
||||||
|
|
||||||
|
expect(store.getState().document.artboards.map((artboard) => artboard.id)).toEqual(["a1"]);
|
||||||
|
expect(store.getState().history.past).toHaveLength(1);
|
||||||
|
|
||||||
|
store.dispatch(commandIds.historyUndo, undefined);
|
||||||
|
|
||||||
|
expect(store.getState().document.artboards).toEqual([]);
|
||||||
|
expect(store.getState().history.future).toHaveLength(1);
|
||||||
|
|
||||||
|
store.dispatch(commandIds.historyRedo, undefined);
|
||||||
|
|
||||||
|
expect(store.getState().document.artboards.map((artboard) => artboard.id)).toEqual(["a1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
commands/history.ts
Normal file
42
commands/history.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Command } from "./command";
|
||||||
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
|
export const historyUndoCommand: Command = {
|
||||||
|
id: commandIds.historyUndo,
|
||||||
|
name: "Undo",
|
||||||
|
execute({ state }) {
|
||||||
|
const previous = state.history.past.at(-1);
|
||||||
|
if (!previous) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: previous.document,
|
||||||
|
editor: previous.editor,
|
||||||
|
history: {
|
||||||
|
past: state.history.past.slice(0, -1),
|
||||||
|
future: [{ document: state.document, editor: state.editor }, ...state.history.future],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const historyRedoCommand: Command = {
|
||||||
|
id: commandIds.historyRedo,
|
||||||
|
name: "Redo",
|
||||||
|
execute({ state }) {
|
||||||
|
const next = state.history.future[0];
|
||||||
|
if (!next) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: next.document,
|
||||||
|
editor: next.editor,
|
||||||
|
history: {
|
||||||
|
past: [...state.history.past, { document: state.document, editor: state.editor }],
|
||||||
|
future: state.history.future.slice(1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const historyCommands = [historyUndoCommand, historyRedoCommand] satisfies Command<unknown>[];
|
||||||
@@ -1,16 +1,40 @@
|
|||||||
export const commandIds = {
|
export const commandIds = {
|
||||||
documentAddArtboard: "document.addArtboard",
|
documentAddArtboard: "document.addArtboard",
|
||||||
documentSetArtboardBounds: "document.setArtboardBounds",
|
documentSetArtboardBounds: "document.setArtboardBounds",
|
||||||
|
documentRemoveArtboard: "document.removeArtboard",
|
||||||
|
documentSetArtboardVisible: "document.setArtboardVisible",
|
||||||
|
documentSetArtboardLocked: "document.setArtboardLocked",
|
||||||
|
documentRenameArtboard: "document.renameArtboard",
|
||||||
documentAddAsset: "document.addAsset",
|
documentAddAsset: "document.addAsset",
|
||||||
|
documentUpdateAssetSource: "document.updateAssetSource",
|
||||||
documentAddImageLayer: "document.addImageLayer",
|
documentAddImageLayer: "document.addImageLayer",
|
||||||
|
documentAddRasterLayer: "document.addRasterLayer",
|
||||||
|
documentAddGroupLayer: "document.addGroupLayer",
|
||||||
|
documentMoveLayer: "document.moveLayer",
|
||||||
|
documentGroupLayers: "document.groupLayers",
|
||||||
|
documentUngroupLayer: "document.ungroupLayer",
|
||||||
|
documentRemoveLayer: "document.removeLayer",
|
||||||
|
documentSetLayerVisible: "document.setLayerVisible",
|
||||||
|
documentSetLayerLocked: "document.setLayerLocked",
|
||||||
|
documentRenameLayer: "document.renameLayer",
|
||||||
|
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",
|
||||||
|
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",
|
||||||
transformUpdate: "transform.update",
|
transformUpdate: "transform.update",
|
||||||
|
transformSetBounds: "transform.setBounds",
|
||||||
transformEnd: "transform.end",
|
transformEnd: "transform.end",
|
||||||
viewportPan: "viewport.pan",
|
viewportPan: "viewport.pan",
|
||||||
viewportSetZoom: "viewport.setZoom",
|
viewportSetZoom: "viewport.setZoom",
|
||||||
@@ -18,4 +42,6 @@ export const commandIds = {
|
|||||||
viewportSetSize: "viewport.setSize",
|
viewportSetSize: "viewport.setSize",
|
||||||
viewportReset: "viewport.reset",
|
viewportReset: "viewport.reset",
|
||||||
viewportFitArtboard: "viewport.fitArtboard",
|
viewportFitArtboard: "viewport.fitArtboard",
|
||||||
|
historyUndo: "history.undo",
|
||||||
|
historyRedo: "history.redo",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -2,16 +2,51 @@ export type { Command, CommandContext } from "./command";
|
|||||||
export {
|
export {
|
||||||
documentAddArtboardCommand,
|
documentAddArtboardCommand,
|
||||||
documentAddAssetCommand,
|
documentAddAssetCommand,
|
||||||
|
documentAddGroupLayerCommand,
|
||||||
documentAddImageLayerCommand,
|
documentAddImageLayerCommand,
|
||||||
|
documentAddLayerMaskCommand,
|
||||||
|
documentAddRasterLayerCommand,
|
||||||
documentCommands,
|
documentCommands,
|
||||||
|
documentGroupLayersCommand,
|
||||||
|
documentMoveLayerCommand,
|
||||||
|
documentRemoveArtboardCommand,
|
||||||
|
documentRemoveLayerCommand,
|
||||||
|
documentRemoveLayerMaskCommand,
|
||||||
|
documentRenameArtboardCommand,
|
||||||
|
documentRenameLayerCommand,
|
||||||
documentSetArtboardBoundsCommand,
|
documentSetArtboardBoundsCommand,
|
||||||
|
documentSetArtboardLockedCommand,
|
||||||
|
documentSetArtboardVisibleCommand,
|
||||||
|
documentSetLayerClippingMaskCommand,
|
||||||
|
documentSetLayerLockedCommand,
|
||||||
|
documentSetLayerVisibleCommand,
|
||||||
|
documentUpdateAssetSourceCommand,
|
||||||
|
documentUngroupLayerCommand,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
export type {
|
export type {
|
||||||
DocumentAddArtboardPayload,
|
DocumentAddArtboardPayload,
|
||||||
DocumentAddAssetPayload,
|
DocumentAddAssetPayload,
|
||||||
|
DocumentAddGroupLayerPayload,
|
||||||
DocumentAddImageLayerPayload,
|
DocumentAddImageLayerPayload,
|
||||||
|
DocumentAddLayerMaskPayload,
|
||||||
|
DocumentAddRasterLayerPayload,
|
||||||
|
DocumentGroupLayersPayload,
|
||||||
|
DocumentMoveLayerPayload,
|
||||||
|
DocumentRemoveArtboardPayload,
|
||||||
|
DocumentRemoveLayerPayload,
|
||||||
|
DocumentRemoveLayerMaskPayload,
|
||||||
|
DocumentRenameArtboardPayload,
|
||||||
|
DocumentRenameLayerPayload,
|
||||||
DocumentSetArtboardBoundsPayload,
|
DocumentSetArtboardBoundsPayload,
|
||||||
|
DocumentSetArtboardLockedPayload,
|
||||||
|
DocumentSetArtboardVisiblePayload,
|
||||||
|
DocumentSetLayerClippingMaskPayload,
|
||||||
|
DocumentSetLayerLockedPayload,
|
||||||
|
DocumentSetLayerVisiblePayload,
|
||||||
|
DocumentUpdateAssetSourcePayload,
|
||||||
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
|
export { historyCommands, historyRedoCommand, historyUndoCommand } from "./history";
|
||||||
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
||||||
export type { CommandId, CommandPayloads } from "./payloads";
|
export type { CommandId, CommandPayloads } from "./payloads";
|
||||||
export { createCommandDispatcher } from "./dispatcher";
|
export { createCommandDispatcher } from "./dispatcher";
|
||||||
@@ -19,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 } from "./tool";
|
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformUpdateCommand } from "./transform";
|
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||||
export type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
export type { ToolSetActivePayload } from "./tool";
|
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||||
export {
|
export {
|
||||||
viewportCommands,
|
viewportCommands,
|
||||||
viewportPanCommand,
|
viewportPanCommand,
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import { commandIds } from "./ids";
|
import type { commandIds } from "./ids";
|
||||||
import type {
|
import type {
|
||||||
DocumentAddArtboardPayload,
|
DocumentAddArtboardPayload,
|
||||||
DocumentAddAssetPayload,
|
DocumentAddAssetPayload,
|
||||||
|
DocumentAddGroupLayerPayload,
|
||||||
DocumentAddImageLayerPayload,
|
DocumentAddImageLayerPayload,
|
||||||
|
DocumentAddLayerMaskPayload,
|
||||||
|
DocumentAddRasterLayerPayload,
|
||||||
|
DocumentGroupLayersPayload,
|
||||||
|
DocumentMoveLayerPayload,
|
||||||
|
DocumentRemoveArtboardPayload,
|
||||||
|
DocumentRemoveLayerPayload,
|
||||||
|
DocumentRemoveLayerMaskPayload,
|
||||||
|
DocumentRenameArtboardPayload,
|
||||||
|
DocumentRenameLayerPayload,
|
||||||
DocumentSetArtboardBoundsPayload,
|
DocumentSetArtboardBoundsPayload,
|
||||||
|
DocumentSetArtboardLockedPayload,
|
||||||
|
DocumentSetArtboardVisiblePayload,
|
||||||
|
DocumentSetLayerClippingMaskPayload,
|
||||||
|
DocumentSetLayerLockedPayload,
|
||||||
|
DocumentSetLayerVisiblePayload,
|
||||||
|
DocumentUpdateAssetSourcePayload,
|
||||||
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
import type { ToolSetActivePayload } from "./tool";
|
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||||
import type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
import type {
|
import type {
|
||||||
ViewportFitArtboardPayload,
|
ViewportFitArtboardPayload,
|
||||||
ViewportPanPayload,
|
ViewportPanPayload,
|
||||||
@@ -19,16 +36,40 @@ import type {
|
|||||||
export type CommandPayloads = {
|
export type CommandPayloads = {
|
||||||
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
||||||
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
||||||
|
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
|
||||||
|
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
|
||||||
|
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
|
||||||
|
[commandIds.documentRenameArtboard]: DocumentRenameArtboardPayload;
|
||||||
[commandIds.documentAddAsset]: DocumentAddAssetPayload;
|
[commandIds.documentAddAsset]: DocumentAddAssetPayload;
|
||||||
|
[commandIds.documentUpdateAssetSource]: DocumentUpdateAssetSourcePayload;
|
||||||
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
||||||
|
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
|
||||||
|
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
||||||
|
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
|
||||||
|
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
|
||||||
|
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
|
||||||
|
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
|
||||||
|
[commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload;
|
||||||
|
[commandIds.documentRemoveLayer]: DocumentRemoveLayerPayload;
|
||||||
|
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
|
||||||
|
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
|
||||||
|
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
|
||||||
|
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;
|
||||||
[commandIds.selectionSet]: SelectionSetPayload;
|
[commandIds.selectionSet]: SelectionSetPayload;
|
||||||
[commandIds.selectionClear]: void;
|
[commandIds.selectionClear]: void;
|
||||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||||
|
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
||||||
|
[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;
|
||||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||||
|
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
||||||
[commandIds.transformEnd]: void;
|
[commandIds.transformEnd]: void;
|
||||||
[commandIds.viewportPan]: ViewportPanPayload;
|
[commandIds.viewportPan]: ViewportPanPayload;
|
||||||
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
||||||
@@ -36,6 +77,8 @@ export type CommandPayloads = {
|
|||||||
[commandIds.viewportSetSize]: ViewportSetSizePayload;
|
[commandIds.viewportSetSize]: ViewportSetSizePayload;
|
||||||
[commandIds.viewportReset]: void;
|
[commandIds.viewportReset]: void;
|
||||||
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
|
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
|
||||||
|
[commandIds.historyUndo]: void;
|
||||||
|
[commandIds.historyRedo]: void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandId = keyof CommandPayloads;
|
export type CommandId = keyof CommandPayloads;
|
||||||
|
|||||||
@@ -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,54 @@
|
|||||||
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 } 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: "pan" });
|
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "crop" });
|
||||||
expect(next.editor.tools).toEqual({ activeTool: "pan", interactionMode: { type: "tool", tool: "pan" } });
|
expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" }, brush: defaultBrush });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets brush settings", () => {
|
||||||
|
const next = toolSetBrushSettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#ff0000", size: 24, hardness: 50 });
|
||||||
|
|
||||||
|
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets and clears brush preview", () => {
|
||||||
|
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
|
||||||
|
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);
|
||||||
|
|
||||||
|
expect(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", () => {
|
||||||
@@ -13,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" } });
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
200
commands/tool.ts
200
commands/tool.ts
@@ -1,4 +1,9 @@
|
|||||||
import type { ToolId } from "@editor/tools";
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { Vec2D } from "@core/geometry";
|
||||||
|
import type { LayerId, ArtboardId, AssetId } from "@core/id";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { MaskViewMode } from "@editor/state";
|
||||||
|
import type { BrushSettings, ToolId } from "@editor/tools";
|
||||||
import type { Command } from "./command";
|
import type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
@@ -6,6 +11,27 @@ export type ToolSetActivePayload = {
|
|||||||
tool: ToolId;
|
tool: ToolId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||||
|
|
||||||
|
export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined;
|
||||||
|
|
||||||
|
export type ToolSetBrushStrokePreviewPayload =
|
||||||
|
| {
|
||||||
|
layerId: LayerId;
|
||||||
|
assetId: AssetId;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export type ToolSetMaskViewModePayload = {
|
||||||
|
mode: MaskViewMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolEnterMaskEditPayload = {
|
||||||
|
targetLayerId: LayerId;
|
||||||
|
maskLayerId: LayerId;
|
||||||
|
};
|
||||||
|
|
||||||
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
||||||
id: commandIds.toolSetActive,
|
id: commandIds.toolSetActive,
|
||||||
name: "Set active tool",
|
name: "Set active tool",
|
||||||
@@ -15,9 +41,139 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
|||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
tools: {
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
activeTool: payload.tool,
|
activeTool: payload.tool,
|
||||||
interactionMode: { type: "tool", tool: payload.tool },
|
interactionMode: { type: "tool", tool: payload.tool },
|
||||||
},
|
},
|
||||||
|
brushPreview: undefined,
|
||||||
|
brushStrokePreview: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> = {
|
||||||
|
id: commandIds.toolSetBrushSettings,
|
||||||
|
name: "Set brush settings",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
brush: {
|
||||||
|
color: payload.color ?? state.editor.tools.brush.color,
|
||||||
|
size: clampNumber(payload.size ?? state.editor.tools.brush.size, 1, 200),
|
||||||
|
hardness: clampNumber(payload.hardness ?? state.editor.tools.brush.hardness, 0, 100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toolSetBrushPreviewCommand: Command<ToolSetBrushPreviewPayload> = {
|
||||||
|
id: commandIds.toolSetBrushPreview,
|
||||||
|
name: "Set brush preview",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
if (!payload) {
|
||||||
|
if (!state.editor.brushPreview) return state;
|
||||||
|
return { ...state, editor: { ...state.editor, brushPreview: undefined } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPosition = state.editor.brushPreview?.position;
|
||||||
|
if (currentPosition && currentPosition.x === payload.position.x && currentPosition.y === payload.position.y) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -54,6 +210,7 @@ export const toolExitTemporaryPanCommand: Command = {
|
|||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
tools: {
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
activeTool: mode.previousTool,
|
activeTool: mode.previousTool,
|
||||||
interactionMode: { type: "tool", tool: mode.previousTool },
|
interactionMode: { type: "tool", tool: mode.previousTool },
|
||||||
},
|
},
|
||||||
@@ -62,4 +219,43 @@ export const toolExitTemporaryPanCommand: Command = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toolCommands = [toolSetActiveCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command<unknown>[];
|
export const toolCommands = [
|
||||||
|
toolSetActiveCommand,
|
||||||
|
toolSetBrushSettingsCommand,
|
||||||
|
toolSetBrushPreviewCommand,
|
||||||
|
toolSetBrushStrokePreviewCommand,
|
||||||
|
toolSetMaskViewModeCommand,
|
||||||
|
toolEnterMaskEditCommand,
|
||||||
|
toolExitMaskEditCommand,
|
||||||
|
toolEnterTemporaryPanCommand,
|
||||||
|
toolExitTemporaryPanCommand,
|
||||||
|
] satisfies Command<unknown>[];
|
||||||
|
|
||||||
|
function clampNumber(value: number, min: number, max: number) {
|
||||||
|
if (!Number.isFinite(value)) return min;
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
type LayerLocation = {
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
layer: Layer;
|
||||||
|
};
|
||||||
|
|
||||||
|
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
|
||||||
|
for (const artboard of document.artboards) {
|
||||||
|
const layer = findLayerInTree(artboard.layers, layerId);
|
||||||
|
if (layer) return { artboardId: artboard.id, layer };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.id === layerId) return layer;
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const child = findLayerInTree(layer.children, layerId);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { documentAddArtboardCommand } from "./document";
|
import { documentAddArtboardCommand } from "./document";
|
||||||
import { transformBeginCommand, transformEndCommand, transformUpdateCommand } from "./transform";
|
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||||
|
|
||||||
function artboardState() {
|
function artboardState() {
|
||||||
return documentAddArtboardCommand.execute(
|
return documentAddArtboardCommand.execute(
|
||||||
@@ -61,6 +61,24 @@ describe("transform commands", () => {
|
|||||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: -50, y: -40, w: 150, h: 120 });
|
expect(updated.document.artboards[0]?.bounds).toEqual({ x: -50, y: -40, w: 150, h: 120 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sets transform target bounds directly", () => {
|
||||||
|
const updated = transformSetBoundsCommand.execute(
|
||||||
|
{ state: artboardState() },
|
||||||
|
{ target: { type: "artboard", id: "a1" }, bounds: { x: 10, y: 20, w: 300, h: 200 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 300, h: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct bounds edits enforce minimum size", () => {
|
||||||
|
const updated = transformSetBoundsCommand.execute(
|
||||||
|
{ state: artboardState() },
|
||||||
|
{ target: { type: "artboard", id: "a1" }, bounds: { x: 10, y: 20, w: -5, h: 0 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 4, y: 19, w: 1, h: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
test("ends transform session", () => {
|
test("ends transform session", () => {
|
||||||
const started = transformBeginCommand.execute(
|
const started = transformBeginCommand.execute(
|
||||||
{ state: artboardState() },
|
{ state: artboardState() },
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export type TransformUpdatePayload = {
|
|||||||
shiftKey?: boolean;
|
shiftKey?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TransformSetBoundsPayload = {
|
||||||
|
target: TransformTarget;
|
||||||
|
bounds: Rect;
|
||||||
|
};
|
||||||
|
|
||||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||||
id: commandIds.transformBegin,
|
id: commandIds.transformBegin,
|
||||||
name: "Begin transform",
|
name: "Begin transform",
|
||||||
@@ -59,6 +64,17 @@ export const transformUpdateCommand: Command<TransformUpdatePayload> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
|
||||||
|
id: commandIds.transformSetBounds,
|
||||||
|
name: "Set transform bounds",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
document: applyTransformTargetBounds(state.document, payload.target, normalizeRect(payload.bounds)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const transformEndCommand: Command = {
|
export const transformEndCommand: Command = {
|
||||||
id: commandIds.transformEnd,
|
id: commandIds.transformEnd,
|
||||||
name: "End transform",
|
name: "End transform",
|
||||||
@@ -75,7 +91,7 @@ export const transformEndCommand: Command = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformEndCommand] satisfies Command<unknown>[];
|
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||||
|
|
||||||
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
|
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
|
||||||
if (handle === "body") {
|
if (handle === "body") {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe("viewport commands", () => {
|
|||||||
...viewportSetSizeCommand.execute(context(), { w: 1000, h: 800 }),
|
...viewportSetSizeCommand.execute(context(), { w: 1000, h: 800 }),
|
||||||
document: {
|
document: {
|
||||||
...context().state.document,
|
...context().state.document,
|
||||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: -400, y: -300, w: 800, h: 600 }, backgroundColor: "transparent", layers: [] }],
|
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: -400, y: -300, w: 800, h: 600 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ describe("viewport commands", () => {
|
|||||||
...context().state,
|
...context().state,
|
||||||
document: {
|
document: {
|
||||||
...context().state.document,
|
...context().state.document,
|
||||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: 10, y: 20, w: 100, h: 200 }, backgroundColor: "transparent", layers: [] }],
|
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: 10, y: 20, w: 100, h: 200 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ export type Artboard = {
|
|||||||
name: string;
|
name: string;
|
||||||
bounds: Rect;
|
bounds: Rect;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
layers: Layer[];
|
layers: Layer[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id";
|
|||||||
export type { ImageLayer } from "./image-layer";
|
export type { ImageLayer } from "./image-layer";
|
||||||
export type { Layer } from "./layer";
|
export type { Layer } from "./layer";
|
||||||
export type { LayerGroup } from "./layer-group";
|
export type { LayerGroup } from "./layer-group";
|
||||||
|
export type { RasterLayer } from "./raster-layer";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ImageLayer } from "./image-layer";
|
import type { ImageLayer } from "./image-layer";
|
||||||
import type { LayerGroup } from "./layer-group";
|
import type { LayerGroup } from "./layer-group";
|
||||||
|
import type { RasterLayer } from "./raster-layer";
|
||||||
|
|
||||||
export type Layer = ImageLayer | LayerGroup;
|
export type Layer = ImageLayer | RasterLayer | LayerGroup;
|
||||||
|
|||||||
7
core/raster-layer.ts
Normal file
7
core/raster-layer.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { BaseLayer } from "./base-layer";
|
||||||
|
import type { AssetId } from "./id";
|
||||||
|
|
||||||
|
export type RasterLayer = BaseLayer & {
|
||||||
|
type: "raster";
|
||||||
|
assetId: AssetId;
|
||||||
|
};
|
||||||
@@ -13,6 +13,9 @@ export const initialEditorState: EditorState = {
|
|||||||
},
|
},
|
||||||
tools: initialToolState,
|
tools: initialToolState,
|
||||||
transformSession: undefined,
|
transformSession: undefined,
|
||||||
|
maskEdit: undefined,
|
||||||
|
brushPreview: undefined,
|
||||||
|
brushStrokePreview: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createInitialAppState(name = "Untitled"): AppState {
|
export function createInitialAppState(name = "Untitled"): AppState {
|
||||||
@@ -25,5 +28,6 @@ export function createInitialAppState(name = "Untitled"): AppState {
|
|||||||
assets: [],
|
assets: [],
|
||||||
},
|
},
|
||||||
editor: initialEditorState,
|
editor: initialEditorState,
|
||||||
|
history: { past: [], future: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +16,46 @@ 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;
|
||||||
|
maskEdit?: MaskEditState;
|
||||||
|
brushPreview?: BrushPreviewState;
|
||||||
|
brushStrokePreview?: BrushStrokePreviewState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistorySnapshot = {
|
||||||
|
document: ImageDocument;
|
||||||
|
editor: EditorState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoryState = {
|
||||||
|
past: HistorySnapshot[];
|
||||||
|
future: HistorySnapshot[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
editor: EditorState;
|
editor: EditorState;
|
||||||
|
history: HistoryState;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const availableToolIds = ["select", "pan"] as const;
|
export const availableToolIds = ["select", "crop", "brush", "eraser", "pan"] as const;
|
||||||
|
|
||||||
export type ToolId = (typeof availableToolIds)[number];
|
export type ToolId = (typeof availableToolIds)[number];
|
||||||
|
|
||||||
@@ -6,14 +6,22 @@ export type InteractionMode =
|
|||||||
| { type: "tool"; tool: ToolId }
|
| { type: "tool"; tool: ToolId }
|
||||||
| { type: "temporary-pan"; previousTool: ToolId };
|
| { type: "temporary-pan"; previousTool: ToolId };
|
||||||
|
|
||||||
|
export type BrushSettings = {
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
hardness: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ToolState = {
|
export type ToolState = {
|
||||||
activeTool: ToolId;
|
activeTool: ToolId;
|
||||||
interactionMode: InteractionMode;
|
interactionMode: InteractionMode;
|
||||||
|
brush: BrushSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialToolState: ToolState = {
|
export const initialToolState: ToolState = {
|
||||||
activeTool: "select",
|
activeTool: "select",
|
||||||
interactionMode: { type: "tool", tool: "select" },
|
interactionMode: { type: "tool", tool: "select" },
|
||||||
|
brush: { color: "#111827", size: 8, hardness: 100 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const document: ImageDocument = {
|
|||||||
name: "Artboard",
|
name: "Artboard",
|
||||||
bounds: { x: 0, y: 0, w: 100, h: 80 },
|
bounds: { x: 0, y: 0, w: 100, h: 80 },
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
layers: [
|
layers: [
|
||||||
{
|
{
|
||||||
id: "l1",
|
id: "l1",
|
||||||
@@ -49,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,18 +34,24 @@ 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) => ({
|
|
||||||
|
return targetLayerIds.reduce(
|
||||||
|
(nextDocument, targetLayerId) => ({
|
||||||
|
...nextDocument,
|
||||||
|
artboards: nextDocument.artboards.map((artboard) => ({
|
||||||
...artboard,
|
...artboard,
|
||||||
layers: applyLayerBoundsInTree(document, artboard.layers, layerId, bounds),
|
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[] {
|
||||||
return layers.map((layer) => {
|
return layers.map((layer) => {
|
||||||
if (layer.id === layerId && layer.type === "image") {
|
if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) {
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
if (!asset) return layer;
|
if (!asset) return layer;
|
||||||
|
|
||||||
@@ -95,7 +101,8 @@ function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undef
|
|||||||
const bounds = resolveLayerBounds(document, child);
|
const bounds = resolveLayerBounds(document, child);
|
||||||
return bounds ? [bounds] : [];
|
return bounds ? [bounds] : [];
|
||||||
}));
|
}));
|
||||||
case "image": {
|
case "image":
|
||||||
|
case "raster": {
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
if (!asset) return undefined;
|
if (!asset) return undefined;
|
||||||
|
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
12
input/history.ts
Normal file
12
input/history.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
|
import type { KeybindEvent } from "./keyboard";
|
||||||
|
|
||||||
|
export function handleHistoryKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean {
|
||||||
|
if (options.event.altKey) return false;
|
||||||
|
const modifier = options.event.metaKey || options.event.ctrlKey;
|
||||||
|
if (!modifier || options.event.key.toLowerCase() !== "z") return false;
|
||||||
|
|
||||||
|
options.dispatch(options.event.shiftKey ? commandIds.historyRedo : commandIds.historyUndo, undefined);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ export {
|
|||||||
} from "./dom";
|
} from "./dom";
|
||||||
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
||||||
export { handleKeybind, keybindFromEvent } from "./keyboard";
|
export { handleKeybind, keybindFromEvent } from "./keyboard";
|
||||||
|
export { handleHistoryKey } from "./history";
|
||||||
|
export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
|
||||||
|
export type { LayerDropTarget, LayerInfo } from "./layers-panel";
|
||||||
export { handleArtboardSelection } from "./selection";
|
export { handleArtboardSelection } from "./selection";
|
||||||
export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
||||||
export type { TransformControlsInputController } from "./transform-controls";
|
export type { TransformControlsInputController } from "./transform-controls";
|
||||||
|
|||||||
89
input/layers-panel.test.ts
Normal file
89
input/layers-panel.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import { handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
|
||||||
|
|
||||||
|
const document: ImageDocument = {
|
||||||
|
id: "doc",
|
||||||
|
name: "Doc",
|
||||||
|
version: 1,
|
||||||
|
assets: [],
|
||||||
|
artboards: [
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
name: "Artboard",
|
||||||
|
bounds: { x: 0, y: 0, w: 100, h: 100 },
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
layers: [group("a"), group("b"), { ...group("g"), children: [group("c")] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("layers panel input", () => {
|
||||||
|
test("resolves dropping a layer before another layer", () => {
|
||||||
|
expect(resolveLayerDrop({ document, sourceLayerId: "b", target: { artboardId: "a1", layer: group("a") }, verticalRatio: 0.1 })).toEqual({
|
||||||
|
layerId: "b",
|
||||||
|
toArtboardId: "a1",
|
||||||
|
toParentGroupId: undefined,
|
||||||
|
toIndex: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves dropping a layer into a group", () => {
|
||||||
|
expect(resolveLayerDrop({ document, sourceLayerId: "a", target: { artboardId: "a1", layer: { ...group("g"), children: [group("c")] } }, verticalRatio: 0.5 })).toEqual({
|
||||||
|
layerId: "a",
|
||||||
|
toArtboardId: "a1",
|
||||||
|
toParentGroupId: "g",
|
||||||
|
toIndex: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatches delete commands for selected layers", () => {
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
const consumed = handleDeleteSelectionKey({
|
||||||
|
event: { key: "Backspace", code: "Backspace", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
|
||||||
|
selection: { artboardId: "a1", layerIds: ["a", "b"] },
|
||||||
|
dispatch: (commandId, payload) => {
|
||||||
|
dispatched.push({ commandId, payload });
|
||||||
|
return undefined as never;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consumed).toBe(true);
|
||||||
|
expect(dispatched).toEqual([
|
||||||
|
{ commandId: commandIds.documentRemoveLayer, payload: { layerId: "a" } },
|
||||||
|
{ commandId: commandIds.documentRemoveLayer, payload: { layerId: "b" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatches delete command for selected artboard when no layers are selected", () => {
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
const consumed = handleDeleteSelectionKey({
|
||||||
|
event: { key: "Delete", code: "Delete", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
|
||||||
|
selection: { artboardId: "a1", layerIds: [] },
|
||||||
|
dispatch: (commandId, payload) => {
|
||||||
|
dispatched.push({ commandId, payload });
|
||||||
|
return undefined as never;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consumed).toBe(true);
|
||||||
|
expect(dispatched).toEqual([{ commandId: commandIds.documentRemoveArtboard, payload: { id: "a1" } }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function group(id: string): Extract<Layer, { type: "group" }> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "group",
|
||||||
|
name: id,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
99
input/layers-panel.ts
Normal file
99
input/layers-panel.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
|
import type { ImageDocument } from "@core/document";
|
||||||
|
import type { ArtboardId, LayerId } from "@core/id";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { KeybindEvent } from "./keyboard";
|
||||||
|
|
||||||
|
export type LayerInfo = {
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
parentGroupId?: LayerId;
|
||||||
|
layer: Layer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayerDropTarget = {
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
layer: Layer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteSelectionState = {
|
||||||
|
artboardId?: ArtboardId;
|
||||||
|
layerIds: LayerId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function handleDeleteSelectionKey(options: { event: KeybindEvent; selection: DeleteSelectionState; dispatch: Dispatch }): boolean {
|
||||||
|
if (options.event.altKey || options.event.ctrlKey || options.event.metaKey) return false;
|
||||||
|
if (options.event.key !== "Backspace" && options.event.key !== "Delete") return false;
|
||||||
|
|
||||||
|
for (const layerId of options.selection.layerIds) options.dispatch(commandIds.documentRemoveLayer, { layerId });
|
||||||
|
if (options.selection.layerIds.length === 0 && options.selection.artboardId) {
|
||||||
|
options.dispatch(commandIds.documentRemoveArtboard, { id: options.selection.artboardId });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLayerDrop(options: {
|
||||||
|
document: ImageDocument;
|
||||||
|
sourceLayerId: LayerId;
|
||||||
|
target: LayerDropTarget;
|
||||||
|
verticalRatio: number;
|
||||||
|
}): { layerId: LayerId; toArtboardId: ArtboardId; toParentGroupId?: LayerId; toIndex: number } | undefined {
|
||||||
|
const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id);
|
||||||
|
const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId);
|
||||||
|
if (!targetInfo || !sourceInfo || options.sourceLayerId === options.target.layer.id) return undefined;
|
||||||
|
|
||||||
|
const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio));
|
||||||
|
const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66;
|
||||||
|
if (dropIntoGroup) {
|
||||||
|
return {
|
||||||
|
layerId: options.sourceLayerId,
|
||||||
|
toArtboardId: options.target.artboardId,
|
||||||
|
toParentGroupId: options.target.layer.id,
|
||||||
|
toIndex: options.target.layer.children.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblings = targetInfo.parentGroupId
|
||||||
|
? findGroup(options.document, targetInfo.parentGroupId)?.children
|
||||||
|
: options.document.artboards.find((artboard) => artboard.id === targetInfo.artboardId)?.layers;
|
||||||
|
if (!siblings) return undefined;
|
||||||
|
|
||||||
|
const targetIndex = siblings.findIndex((layer) => layer.id === options.target.layer.id);
|
||||||
|
const sourceIndex = sourceInfo.parentGroupId === targetInfo.parentGroupId && sourceInfo.artboardId === targetInfo.artboardId
|
||||||
|
? siblings.findIndex((layer) => layer.id === options.sourceLayerId)
|
||||||
|
: -1;
|
||||||
|
const rawIndex = verticalRatio < 0.5 ? targetIndex : targetIndex + 1;
|
||||||
|
const adjustedIndex = sourceIndex >= 0 && sourceIndex < rawIndex ? rawIndex - 1 : rawIndex;
|
||||||
|
|
||||||
|
return {
|
||||||
|
layerId: options.sourceLayerId,
|
||||||
|
toArtboardId: targetInfo.artboardId,
|
||||||
|
toParentGroupId: targetInfo.parentGroupId,
|
||||||
|
toIndex: adjustedIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLayerInfoInDocument(document: ImageDocument, layerId?: LayerId): LayerInfo | undefined {
|
||||||
|
if (!layerId) return undefined;
|
||||||
|
for (const artboard of document.artboards) {
|
||||||
|
const found = findLayerInfo(artboard.layers, layerId, artboard.id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGroup(document: ImageDocument, groupId: LayerId): Extract<Layer, { type: "group" }> | undefined {
|
||||||
|
const info = findLayerInfoInDocument(document, groupId);
|
||||||
|
return info?.layer.type === "group" ? info.layer : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLayerInfo(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerInfo | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.id === layerId) return { artboardId, parentGroupId, layer };
|
||||||
|
if (layer.type === "group") {
|
||||||
|
const found = findLayerInfo(layer.children, layerId, artboardId, layer.id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -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", 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,10 +29,7 @@ describe("selection input", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("selects topmost image layer before artboard", () => {
|
test("selects topmost image layer before artboard", () => {
|
||||||
const state = {
|
const document = createDocument({
|
||||||
...createInitialAppState("Test"),
|
|
||||||
document: {
|
|
||||||
...createInitialAppState("Test").document,
|
|
||||||
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
|
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
|
||||||
artboards: [
|
artboards: [
|
||||||
{
|
{
|
||||||
@@ -47,6 +37,8 @@ describe("selection input", () => {
|
|||||||
name: "Artboard",
|
name: "Artboard",
|
||||||
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
layers: [
|
layers: [
|
||||||
{
|
{
|
||||||
id: "l1",
|
id: "l1",
|
||||||
@@ -61,18 +53,13 @@ describe("selection input", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
});
|
||||||
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;
|
||||||
@@ -83,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;
|
||||||
@@ -102,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;
|
||||||
@@ -22,6 +21,7 @@ export function handleArtboardSelection(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const artboard = [...options.document.artboards].reverse().find((candidate) => {
|
const artboard = [...options.document.artboards].reverse().find((candidate) => {
|
||||||
|
if (!candidate.visible || candidate.locked) return false;
|
||||||
const bounds = candidate.bounds;
|
const bounds = candidate.bounds;
|
||||||
return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
|
return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
|
||||||
});
|
});
|
||||||
@@ -36,18 +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()) {
|
||||||
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point);
|
if (!artboard.visible || artboard.locked) continue;
|
||||||
|
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 || 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,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,41 +1,72 @@
|
|||||||
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("dispatches transform lifecycle for selected artboard", () => {
|
test("does not transform while temporary pan is active", () => {
|
||||||
let state = {
|
const state = createState({
|
||||||
...createInitialAppState("Test"),
|
|
||||||
document: {
|
|
||||||
...createInitialAppState("Test").document,
|
|
||||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }],
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
...createInitialAppState("Test").editor,
|
|
||||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
|
||||||
selection: { artboardId: "a1", layerIds: [] },
|
selection: { artboardId: "a1", layerIds: [] },
|
||||||
},
|
tools: { activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" } },
|
||||||
};
|
});
|
||||||
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 } };
|
return ignoredState;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(false);
|
||||||
|
expect(dispatched).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("crop tool resizes from handles but does not move body", () => {
|
||||||
|
const state = createState({
|
||||||
|
selection: { artboardId: "a1", layerIds: [] },
|
||||||
|
tools: { activeTool: "crop", interactionMode: { type: "tool", tool: "crop" } },
|
||||||
|
});
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
const controller = createTransformControlsInputController({
|
||||||
|
getDocument: () => state.document,
|
||||||
|
getEditor: () => state.editor,
|
||||||
|
dispatch: (commandId, payload) => {
|
||||||
|
dispatched.push({ commandId, payload });
|
||||||
|
return ignoredState;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(false);
|
||||||
|
expect(controller.pointerDown(pointerEvent({ position: { x: 150, y: 150 }, buttons: 1 }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatches transform lifecycle for selected artboard", () => {
|
||||||
|
let state = createState({ selection: { artboardId: "a1", layerIds: [] } });
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
const controller = createTransformControlsInputController({
|
||||||
|
getDocument: () => state.document,
|
||||||
|
getEditor: () => state.editor,
|
||||||
|
dispatch: (commandId, payload) => {
|
||||||
|
dispatched.push({ commandId, payload });
|
||||||
|
if (commandId === commandIds.transformBegin) state = { ...state, editor: { ...state.editor, transformSession: payload } };
|
||||||
return ignoredState;
|
return ignoredState;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -52,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,11 +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 { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
documentRectToViewportRect,
|
||||||
import type { TransformHandle } from "@editor/transform";
|
resolveTransformTargetBounds,
|
||||||
|
selectedTransformTarget,
|
||||||
|
viewportPointToDocumentPoint,
|
||||||
|
type InputSelectionState,
|
||||||
|
type InputTransformTarget,
|
||||||
|
type InputViewportState,
|
||||||
|
} from "./document-geometry";
|
||||||
|
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;
|
||||||
@@ -15,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 {
|
||||||
@@ -23,17 +48,18 @@ export function createTransformControlsInputController(options: {
|
|||||||
if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false;
|
if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false;
|
||||||
|
|
||||||
const editor = options.getEditor();
|
const editor = options.getEditor();
|
||||||
if (editor.tools.activeTool !== "select") return false;
|
const cropToolActive = editor.tools.activeTool === "crop";
|
||||||
|
if ((editor.tools.activeTool !== "select" && !cropToolActive) || isPanInteractionMode(editor.tools.interactionMode)) return false;
|
||||||
|
|
||||||
const document = options.getDocument();
|
const document = options.getDocument();
|
||||||
const target = selectedTransformTarget(document, editor.selection);
|
const target = selectedTransformTarget(document, editor.selection);
|
||||||
if (!target) return false;
|
if (!target || isTransformTargetLocked(document, target)) return false;
|
||||||
|
|
||||||
const bounds = resolveTransformTargetBounds(document, target);
|
const bounds = resolveTransformTargetBounds(document, target);
|
||||||
if (!bounds) return false;
|
if (!bounds) return false;
|
||||||
|
|
||||||
const handle = hitTestArtboardTransformHandle(event.position, bounds, editor.viewport);
|
const handle = hitTestArtboardTransformHandle(event.position, bounds, editor.viewport);
|
||||||
if (!handle) return false;
|
if (!handle || (cropToolActive && handle === "body")) return false;
|
||||||
|
|
||||||
options.dispatch(commandIds.transformBegin, {
|
options.dispatch(commandIds.transformBegin, {
|
||||||
target,
|
target,
|
||||||
@@ -45,7 +71,7 @@ export function createTransformControlsInputController(options: {
|
|||||||
},
|
},
|
||||||
pointerMove(event) {
|
pointerMove(event) {
|
||||||
const editor = options.getEditor();
|
const editor = options.getEditor();
|
||||||
if (!editor.transformSession) return false;
|
if (!editor.transformSession || isPanInteractionMode(editor.tools.interactionMode)) return false;
|
||||||
|
|
||||||
options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport), shiftKey: event.shiftKey });
|
options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport), shiftKey: event.shiftKey });
|
||||||
return true;
|
return true;
|
||||||
@@ -59,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));
|
||||||
@@ -68,6 +94,15 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTransformTargetLocked(document: ImageDocument, target: InputTransformTarget) {
|
||||||
|
if (target.type === "artboard") {
|
||||||
|
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
|
||||||
|
return !artboard || !artboard.visible || artboard.locked;
|
||||||
|
}
|
||||||
|
const layer = findLayerInfoInDocument(document, target.id)?.layer;
|
||||||
|
return !layer || !layer.visible || layer.locked;
|
||||||
|
}
|
||||||
|
|
||||||
function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect }[] {
|
function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect }[] {
|
||||||
const size = 12;
|
const size = 12;
|
||||||
const half = size / 2;
|
const half = size / 2;
|
||||||
@@ -85,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,40 +1,93 @@
|
|||||||
import type { Asset } from "@core/asset";
|
import type { Asset } from "@core/asset";
|
||||||
import type { 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;
|
||||||
|
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect): boolean;
|
||||||
|
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
|
||||||
|
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect): boolean;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TextureEntry =
|
type TextureState =
|
||||||
| { status: "loading"; image: HTMLImageElement }
|
| { status: "loading"; image: HTMLImageElement }
|
||||||
| { status: "ready"; texture: WebGLTexture }
|
| { status: "ready"; texture: WebGLTexture }
|
||||||
| { status: "error" };
|
| { status: "error" };
|
||||||
|
|
||||||
|
type TextureEntry = {
|
||||||
|
source: string;
|
||||||
|
current: TextureState;
|
||||||
|
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);
|
||||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||||
const texCoordLocation = gl.getAttribLocation(program, "a_texCoord");
|
const texCoordLocation = gl.getAttribLocation(program, "a_texCoord");
|
||||||
const samplerLocation = gl.getUniformLocation(program, "u_image");
|
const samplerLocation = gl.getUniformLocation(program, "u_image");
|
||||||
|
const maskedProgram = createMaskedProgram(gl);
|
||||||
|
const maskedPositionLocation = gl.getAttribLocation(maskedProgram, "a_position");
|
||||||
|
const maskedTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_texCoord");
|
||||||
|
const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord");
|
||||||
|
const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image");
|
||||||
|
const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask");
|
||||||
|
const maskRevealPreviewProgram = createMaskRevealPreviewProgram(gl);
|
||||||
|
const maskRevealPreviewPositionLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_position");
|
||||||
|
const maskRevealPreviewTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_texCoord");
|
||||||
|
const maskRevealPreviewMaskTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_maskTexCoord");
|
||||||
|
const maskRevealPreviewSamplerLocation = gl.getUniformLocation(maskRevealPreviewProgram, "u_image");
|
||||||
|
const maskRevealPreviewMaskSamplerLocation = gl.getUniformLocation(maskRevealPreviewProgram, "u_mask");
|
||||||
|
const maskRevealPreviewOpacityLocation = gl.getUniformLocation(maskRevealPreviewProgram, "u_opacity");
|
||||||
|
const tintedProgram = createTintedProgram(gl);
|
||||||
|
const tintedPositionLocation = gl.getAttribLocation(tintedProgram, "a_position");
|
||||||
|
const tintedTexCoordLocation = gl.getAttribLocation(tintedProgram, "a_texCoord");
|
||||||
|
const tintedSamplerLocation = gl.getUniformLocation(tintedProgram, "u_image");
|
||||||
|
const tintedColorLocation = gl.getUniformLocation(tintedProgram, "u_color");
|
||||||
const positionBuffer = gl.createBuffer();
|
const positionBuffer = gl.createBuffer();
|
||||||
const texCoordBuffer = gl.createBuffer();
|
const texCoordBuffer = 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 || !samplerLocation) 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);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
syncAssets(assets) {
|
syncAssets(assets) {
|
||||||
const activeKeys = new Set(assets.map(textureKey));
|
const activeIds = new Set(assets.map((asset) => asset.id));
|
||||||
for (const [key, entry] of textures) {
|
for (const [assetId, entry] of textures) {
|
||||||
if (!activeKeys.has(key)) {
|
if (!activeIds.has(assetId)) {
|
||||||
disposeEntry(gl, entry);
|
disposeEntry(gl, entry);
|
||||||
textures.delete(key);
|
textures.delete(assetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -43,16 +96,16 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||||
|
|
||||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||||
if (entry.status !== "ready") return false;
|
const texture = renderableTexture(entry);
|
||||||
|
if (!texture) return false;
|
||||||
|
|
||||||
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);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
gl.uniform1i(samplerLocation, 0);
|
gl.uniform1i(samplerLocation, 0);
|
||||||
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||||
@@ -61,6 +114,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||||
gl.enableVertexAttribArray(texCoordLocation);
|
gl.enableVertexAttribArray(texCoordLocation);
|
||||||
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
@@ -68,12 +122,175 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.disable(gl.BLEND);
|
gl.disable(gl.BLEND);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
renderMasked(asset, rect, maskAsset, maskRect, clipRect) {
|
||||||
|
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(maskedProgram);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.uniform1i(maskedSamplerLocation, 0);
|
||||||
|
gl.activeTexture(gl.TEXTURE1);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
|
||||||
|
gl.uniform1i(maskedMaskSamplerLocation, 1);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(maskedPositionLocation);
|
||||||
|
gl.vertexAttribPointer(maskedPositionLocation, 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(maskedTexCoordLocation);
|
||||||
|
gl.vertexAttribPointer(maskedTexCoordLocation, 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(maskedMaskTexCoordLocation);
|
||||||
|
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
gl.disable(gl.BLEND);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect) {
|
||||||
|
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
if (clampedOpacity <= 0) return true;
|
||||||
|
|
||||||
|
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||||
|
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
|
||||||
|
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||||
|
|
||||||
|
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||||
|
const maskEntry = getTextureEntry(context, textures, maskAsset, invalidate, () => disposed);
|
||||||
|
const texture = renderableTexture(entry);
|
||||||
|
const maskTexture = renderableTexture(maskEntry);
|
||||||
|
if (!texture || !maskTexture) return false;
|
||||||
|
|
||||||
|
gl.enable(gl.SCISSOR_TEST);
|
||||||
|
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
||||||
|
enablePremultipliedAlphaBlending(gl);
|
||||||
|
gl.useProgram(maskRevealPreviewProgram);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.uniform1i(maskRevealPreviewSamplerLocation, 0);
|
||||||
|
gl.activeTexture(gl.TEXTURE1);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
|
||||||
|
gl.uniform1i(maskRevealPreviewMaskSamplerLocation, 1);
|
||||||
|
gl.uniform1f(maskRevealPreviewOpacityLocation, clampedOpacity);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(maskRevealPreviewPositionLocation);
|
||||||
|
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation);
|
||||||
|
gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
|
||||||
|
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
gl.disable(gl.BLEND);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
renderMaskVisualization(maskAsset, maskRect, mode, color = [1, 1, 1, 1], clipRect) {
|
||||||
|
const drawRect = clipRect ? intersectScreenRects(maskRect, clipRect) : maskRect;
|
||||||
|
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||||
|
|
||||||
|
const resources = getMaskVisualizationResources(gl, () => maskVisualizationResources, (nextResources) => {
|
||||||
|
maskVisualizationResources = nextResources;
|
||||||
|
});
|
||||||
|
if (!resources) return false;
|
||||||
|
|
||||||
|
const maskEntry = getTextureEntry(context, textures, maskAsset, invalidate, () => disposed);
|
||||||
|
const maskTexture = renderableTexture(maskEntry);
|
||||||
|
if (!maskTexture) return false;
|
||||||
|
|
||||||
|
gl.enable(gl.SCISSOR_TEST);
|
||||||
|
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
||||||
|
enablePremultipliedAlphaBlending(gl);
|
||||||
|
gl.useProgram(resources.program);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
|
||||||
|
gl.uniform1i(resources.samplerLocation, 0);
|
||||||
|
gl.uniform1i(resources.modeLocation, maskVisualizationModeValue(mode));
|
||||||
|
gl.uniform4fv(resources.colorLocation, color);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(resources.positionLocation);
|
||||||
|
gl.vertexAttribPointer(resources.positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(resources.texCoordLocation);
|
||||||
|
gl.vertexAttribPointer(resources.texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
gl.disable(gl.BLEND);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
renderTinted(asset, rect, color, clipRect) {
|
||||||
|
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||||
|
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||||
|
|
||||||
|
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||||
|
const texture = renderableTexture(entry);
|
||||||
|
if (!texture) 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(tintedProgram);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.uniform1i(tintedSamplerLocation, 0);
|
||||||
|
gl.uniform4fv(tintedColorLocation, color);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(tintedPositionLocation);
|
||||||
|
gl.vertexAttribPointer(tintedPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(tintedTexCoordLocation);
|
||||||
|
gl.vertexAttribPointer(tintedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
gl.disable(gl.BLEND);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
for (const entry of textures.values()) disposeEntry(gl, entry);
|
for (const entry of textures.values()) disposeEntry(gl, entry);
|
||||||
gl.deleteBuffer(positionBuffer);
|
gl.deleteBuffer(positionBuffer);
|
||||||
gl.deleteBuffer(texCoordBuffer);
|
gl.deleteBuffer(texCoordBuffer);
|
||||||
|
gl.deleteBuffer(maskTexCoordBuffer);
|
||||||
gl.deleteProgram(program);
|
gl.deleteProgram(program);
|
||||||
|
gl.deleteProgram(maskedProgram);
|
||||||
|
gl.deleteProgram(maskRevealPreviewProgram);
|
||||||
|
if (maskVisualizationResources && maskVisualizationResources !== "failed") gl.deleteProgram(maskVisualizationResources.program);
|
||||||
|
gl.deleteProgram(tintedProgram);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -85,42 +302,71 @@ function getTextureEntry(
|
|||||||
invalidate: () => void,
|
invalidate: () => void,
|
||||||
isDisposed: () => boolean,
|
isDisposed: () => boolean,
|
||||||
): TextureEntry {
|
): TextureEntry {
|
||||||
const key = textureKey(asset);
|
const cached = textures.get(asset.id);
|
||||||
const cached = textures.get(key);
|
if (cached && cached.source === asset.source) return cached;
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
if (cached.current.status === "ready") {
|
||||||
|
if (cached.previousTexture) context.gl.deleteTexture(cached.previousTexture);
|
||||||
|
cached.previousTexture = cached.current.texture;
|
||||||
|
} else {
|
||||||
|
disposeState(context.gl, cached.current);
|
||||||
|
}
|
||||||
|
cached.source = asset.source;
|
||||||
|
cached.current = loadTextureState(context, asset, cached, invalidate, isDisposed);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: TextureEntry = { source: asset.source, current: { status: "error" } };
|
||||||
|
entry.current = loadTextureState(context, asset, entry, invalidate, isDisposed);
|
||||||
|
textures.set(asset.id, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTextureState(
|
||||||
|
context: WebGlRendererContext,
|
||||||
|
asset: Asset,
|
||||||
|
entry: TextureEntry,
|
||||||
|
invalidate: () => void,
|
||||||
|
isDisposed: () => boolean,
|
||||||
|
): TextureState {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
textures.set(key, { status: "loading", image });
|
const source = asset.source;
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
if (isDisposed()) return;
|
if (isDisposed() || entry.source !== source) return;
|
||||||
const texture = createTexture(context.gl, image);
|
const texture = createTexture(context.gl, image);
|
||||||
disposeEntry(context.gl, textures.get(key));
|
if (entry.previousTexture) context.gl.deleteTexture(entry.previousTexture);
|
||||||
textures.set(key, { status: "ready", texture });
|
entry.previousTexture = undefined;
|
||||||
|
entry.current = { status: "ready", texture };
|
||||||
invalidate();
|
invalidate();
|
||||||
};
|
};
|
||||||
image.onerror = () => {
|
image.onerror = () => {
|
||||||
if (isDisposed()) return;
|
if (isDisposed() || entry.source !== source) return;
|
||||||
textures.set(key, { status: "error" });
|
entry.current = { status: "error" };
|
||||||
invalidate();
|
invalidate();
|
||||||
};
|
};
|
||||||
image.src = asset.source;
|
image.src = source;
|
||||||
|
|
||||||
return { status: "loading", image };
|
return { status: "loading", image };
|
||||||
}
|
}
|
||||||
|
|
||||||
function textureKey(asset: Asset) {
|
function renderableTexture(entry: TextureEntry): WebGLTexture | undefined {
|
||||||
return `${asset.id}:${asset.source}`;
|
if (entry.current.status === "ready") return entry.current.texture;
|
||||||
|
return entry.previousTexture;
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeEntry(gl: WebGL2RenderingContext, entry: TextureEntry | undefined) {
|
function disposeEntry(gl: WebGL2RenderingContext, entry: TextureEntry) {
|
||||||
if (!entry) return;
|
disposeState(gl, entry.current);
|
||||||
if (entry.status === "ready") {
|
if (entry.previousTexture) gl.deleteTexture(entry.previousTexture);
|
||||||
gl.deleteTexture(entry.texture);
|
}
|
||||||
|
|
||||||
|
function disposeState(gl: WebGL2RenderingContext, state: TextureState) {
|
||||||
|
if (state.status === "ready") {
|
||||||
|
gl.deleteTexture(state.texture);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (entry.status === "loading") {
|
if (state.status === "loading") {
|
||||||
entry.image.onload = null;
|
state.image.onload = null;
|
||||||
entry.image.onerror = null;
|
state.image.onerror = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,11 +390,30 @@ 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() {
|
||||||
|
return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function texCoordsForRect(drawRect: ScreenRect, sourceRect: ScreenRect) {
|
||||||
|
const x1 = (drawRect.x - sourceRect.x) / sourceRect.w;
|
||||||
|
const x2 = (drawRect.x + drawRect.w - sourceRect.x) / sourceRect.w;
|
||||||
|
const y1 = (drawRect.y - sourceRect.y) / sourceRect.h;
|
||||||
|
const y2 = (drawRect.y + drawRect.h - sourceRect.y) / sourceRect.h;
|
||||||
|
|
||||||
|
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||||
|
}
|
||||||
|
|
||||||
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) {
|
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) {
|
||||||
const x1 = (rect.x / canvas.width) * 2 - 1;
|
const x1 = (rect.x / canvas.width) * 2 - 1;
|
||||||
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
|
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
|
||||||
@@ -201,6 +466,256 @@ 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) {
|
||||||
|
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_image;
|
||||||
|
uniform vec4 u_color;
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
out vec4 outColor;
|
||||||
|
void main() {
|
||||||
|
float alpha = u_color.a * texture(u_image, v_texCoord).a;
|
||||||
|
outColor = vec4(u_color.rgb * alpha, alpha);
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
if (!program) throw new Error("Failed to create tinted image 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 tinted program link error";
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMaskRevealPreviewProgram(gl: WebGL2RenderingContext) {
|
||||||
|
const vertexShader = compileShader(
|
||||||
|
gl,
|
||||||
|
gl.VERTEX_SHADER,
|
||||||
|
`#version 300 es
|
||||||
|
in vec2 a_position;
|
||||||
|
in vec2 a_texCoord;
|
||||||
|
in vec2 a_maskTexCoord;
|
||||||
|
out vec2 v_texCoord;
|
||||||
|
out vec2 v_maskTexCoord;
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||||
|
v_texCoord = a_texCoord;
|
||||||
|
v_maskTexCoord = a_maskTexCoord;
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
const fragmentShader = compileShader(
|
||||||
|
gl,
|
||||||
|
gl.FRAGMENT_SHADER,
|
||||||
|
`#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D u_image;
|
||||||
|
uniform sampler2D u_mask;
|
||||||
|
uniform float u_opacity;
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec2 v_maskTexCoord;
|
||||||
|
out vec4 outColor;
|
||||||
|
void main() {
|
||||||
|
vec4 color = texture(u_image, v_texCoord);
|
||||||
|
float hiddenMaskAlpha = 1.0 - texture(u_mask, v_maskTexCoord).a;
|
||||||
|
float previewAlpha = clamp(hiddenMaskAlpha * u_opacity, 0.0, 1.0);
|
||||||
|
outColor = vec4(color.rgb * previewAlpha, color.a * previewAlpha);
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
if (!program) throw new Error("Failed to create mask reveal preview shader program");
|
||||||
|
|
||||||
|
gl.attachShader(program, vertexShader);
|
||||||
|
gl.attachShader(program, fragmentShader);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
gl.deleteShader(vertexShader);
|
||||||
|
gl.deleteShader(fragmentShader);
|
||||||
|
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
const message = gl.getProgramInfoLog(program) ?? "Unknown mask reveal preview program link error";
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMaskedProgram(gl: WebGL2RenderingContext) {
|
||||||
|
const vertexShader = compileShader(
|
||||||
|
gl,
|
||||||
|
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;
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec2 v_maskTexCoord;
|
||||||
|
out vec4 outColor;
|
||||||
|
void main() {
|
||||||
|
vec4 color = texture(u_image, v_texCoord);
|
||||||
|
float maskAlpha = texture(u_mask, v_maskTexCoord).a;
|
||||||
|
float alpha = color.a * maskAlpha;
|
||||||
|
outColor = vec4(color.rgb * maskAlpha, alpha);
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
if (!program) throw new Error("Failed to create masked image 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 masked program link error";
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
function compileShader(gl: WebGL2RenderingContext, type: number, source: string) {
|
function compileShader(gl: WebGL2RenderingContext, type: number, source: string) {
|
||||||
const shader = gl.createShader(type);
|
const shader = gl.createShader(type);
|
||||||
if (!shader) throw new Error("Failed to create shader");
|
if (!shader) throw new Error("Failed to create shader");
|
||||||
|
|||||||
@@ -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 { 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,43 +9,127 @@ 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, viewport: ViewportState, 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) {
|
||||||
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport);
|
if (!artboard.visible) continue;
|
||||||
for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect);
|
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
||||||
|
const maskLayerIds = collectMaskLayerIds(artboard.layers);
|
||||||
|
for (const layer of artboard.layers) renderLayer(context, document, editor, layer, imageTextureRenderer, clipRect, maskLayerIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLayer(
|
function renderLayer(
|
||||||
context: WebGlRendererContext,
|
context: WebGlRendererContext,
|
||||||
document: ImageDocument,
|
document: ImageDocument,
|
||||||
viewport: ViewportState,
|
editor: EditorState,
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
imageTextureRenderer: ImageTextureRenderer,
|
imageTextureRenderer: ImageTextureRenderer,
|
||||||
clipRect: ScreenRect,
|
clipRect: ScreenRect,
|
||||||
|
maskLayerIds: ReadonlySet<string>,
|
||||||
) {
|
) {
|
||||||
if (!layer.visible) return;
|
const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id;
|
||||||
|
const maskViewMode = editor.maskEdit?.viewMode ?? "composite";
|
||||||
|
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
|
||||||
|
if (!layer.visible || maskLayerIds.has(layer.id)) return;
|
||||||
|
|
||||||
|
const effectiveClipRect = resolveLayerClipRect(context, document, editor.viewport, layer, clipRect);
|
||||||
|
if (!effectiveClipRect) return;
|
||||||
|
|
||||||
if (layer.type === "group") {
|
if (layer.type === "group") {
|
||||||
for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer, clipRect);
|
for (const child of layer.children) renderLayer(context, document, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds);
|
||||||
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, 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);
|
||||||
if (asset && imageTextureRenderer.render(asset, rect, clipRect)) return;
|
const maskLayer = !editingMaskLayer && layer.clippingMask ? findLayer(document, layer.clippingMask.maskLayerId) : undefined;
|
||||||
|
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined, editor);
|
||||||
|
const maskBounds = maskLayer ? resolveTransformTargetBounds(document, { type: "layer", id: maskLayer.id }) : undefined;
|
||||||
|
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
|
||||||
|
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId);
|
||||||
|
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
|
||||||
|
|
||||||
const fallbackRect = intersectScreenRects(rect, clipRect);
|
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
||||||
|
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
|
||||||
|
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
|
||||||
|
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect)) {
|
||||||
|
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect)) {
|
||||||
|
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return;
|
||||||
|
|
||||||
|
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
||||||
if (!fallbackRect) return;
|
if (!fallbackRect) return;
|
||||||
clearScreenRect(context, fallbackRect, imageLayerColor);
|
clearScreenRect(context, fallbackRect, imageLayerColor);
|
||||||
const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, clipRect);
|
const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, effectiveClipRect);
|
||||||
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
|
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLayerClipRect(
|
||||||
|
context: WebGlRendererContext,
|
||||||
|
document: ImageDocument,
|
||||||
|
viewport: ViewportState,
|
||||||
|
layer: Layer,
|
||||||
|
clipRect: ScreenRect,
|
||||||
|
): ScreenRect | undefined {
|
||||||
|
if (!layer.clippingMask) return clipRect;
|
||||||
|
|
||||||
|
const maskBounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.clippingMask.maskLayerId });
|
||||||
|
if (!maskBounds) return clipRect;
|
||||||
|
|
||||||
|
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIsolatedMaskView(mode: MaskViewMode) {
|
||||||
|
return mode === "blackWhite" || mode === "alpha" || mode === "overlay";
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetWithBrushStrokePreview<TAsset extends ImageDocument["assets"][number] | undefined>(asset: TAsset, editor: EditorState): TAsset {
|
||||||
|
if (!asset || editor.brushStrokePreview?.assetId !== asset.id) return asset;
|
||||||
|
return { ...asset, source: editor.brushStrokePreview.source } as TAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLayer(document: ImageDocument, layerId: string): Layer | undefined {
|
||||||
|
for (const artboard of document.artboards) {
|
||||||
|
const layer = findLayerInTree(artboard.layers, layerId);
|
||||||
|
if (layer) return layer;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLayerInTree(layers: readonly Layer[], layerId: string): 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 collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||||
|
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined {
|
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined {
|
||||||
const x1 = Math.max(a.x, b.x);
|
const x1 = Math.max(a.x, b.x);
|
||||||
const y1 = Math.max(a.y, b.y);
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { renderSelectionOverlay } from "./selection";
|
import { renderSelectionOverlay } from "./selection";
|
||||||
@@ -30,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, () => {
|
||||||
@@ -56,20 +58,32 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
|||||||
context.enable(context.SCISSOR_TEST);
|
context.enable(context.SCISSOR_TEST);
|
||||||
|
|
||||||
for (const artboard of frame.document.artboards) {
|
for (const artboard of frame.document.artboards) {
|
||||||
renderArtboard(rendererContext, artboard, frame.editor.viewport);
|
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport);
|
||||||
}
|
}
|
||||||
imageTextureRenderer.syncAssets(frame.document.assets);
|
imageTextureRenderer.syncAssets(frame.document.assets);
|
||||||
renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer);
|
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
|
||||||
|
|
||||||
|
if (!frame.editor.maskEdit) {
|
||||||
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
|
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
|
||||||
renderTransformControls(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
125
view/App.tsx
125
view/App.tsx
@@ -1,13 +1,16 @@
|
|||||||
import { 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";
|
||||||
import { ToolOverlay } from "./ToolOverlay";
|
import { ToolOverlay } from "./ToolOverlay";
|
||||||
import { labelForTool } from "./toolLabels";
|
import { labelForTool } from "./toolLabels";
|
||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
import { getSelectionSummary } from "./selectionSummary";
|
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";
|
||||||
@@ -22,19 +25,88 @@ export function App({ app }: AppProps) {
|
|||||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
||||||
const imageImport = useImageImport(app.store);
|
const imageImport = useImageImport(app.store);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
const selectionSummary = getSelectionSummary(state.document, state.editor.selection);
|
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
||||||
const transformBounds = state.editor.transformSession
|
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
|
||||||
? resolveTransformTargetBounds(state.document, state.editor.transformSession.target)
|
const activeToolLabel = state.editor.maskEdit ? "Mask edit" : labelForTool(state.editor.tools.activeTool);
|
||||||
: undefined;
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
const editableTarget =
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement);
|
||||||
|
|
||||||
|
if (editableTarget) return;
|
||||||
|
|
||||||
|
const historyConsumed = handleHistoryKey({ event: keybindEventFromKeyboardEvent(event), dispatch: app.store.dispatch });
|
||||||
|
if (historyConsumed) {
|
||||||
|
event.preventDefault();
|
||||||
|
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") {
|
||||||
|
setLayersOpen(true);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = handleDeleteSelectionKey({
|
||||||
|
event: keybindEventFromKeyboardEvent(event),
|
||||||
|
selection: app.store.getState().editor.selection,
|
||||||
|
dispatch: app.store.dispatch,
|
||||||
|
});
|
||||||
|
if (consumed) event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [app.store]);
|
||||||
|
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
|
||||||
|
|
||||||
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">
|
||||||
@@ -44,16 +116,26 @@ export function App({ app }: AppProps) {
|
|||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LayersSheet document={state.document} selection={state.editor.selection} open={layersOpen} onClose={() => setLayersOpen(false)} />
|
<LayersSheet
|
||||||
|
document={state.document}
|
||||||
|
selection={state.editor.selection}
|
||||||
|
maskEdit={state.editor.maskEdit}
|
||||||
|
open={layersOpen}
|
||||||
|
dispatch={app.store.dispatch}
|
||||||
|
onClose={() => setLayersOpen(false)}
|
||||||
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
||||||
<BottomControlsIsland
|
<BottomControlsIsland
|
||||||
viewport={state.editor.viewport}
|
viewport={state.editor.viewport}
|
||||||
visible={Boolean(transformBounds) || selectionSummary.type !== "none" || viewportActivityIsland.visible}
|
visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
selection={selectionSummary}
|
activeTool={state.editor.tools.activeTool}
|
||||||
transformBounds={transformBounds}
|
brushSettings={state.editor.tools.brush}
|
||||||
|
editingMask={Boolean(state.editor.maskEdit)}
|
||||||
|
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
|
||||||
|
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||||
|
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
onOpenLayers={() => setLayersOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CanvasViewport store={app.store} />
|
<CanvasViewport store={app.store} />
|
||||||
@@ -61,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,25 +1,28 @@
|
|||||||
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 { BrushControls } from "./bottom-controls/BrushControls";
|
||||||
import { PanControls } from "./bottom-controls/PanControls";
|
import { PanControls } from "./bottom-controls/PanControls";
|
||||||
import { SelectionControls } from "./bottom-controls/SelectionControls";
|
|
||||||
import { TransformControls } from "./bottom-controls/TransformControls";
|
import { TransformControls } from "./bottom-controls/TransformControls";
|
||||||
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { SelectionSummary } from "./selectionSummary";
|
import type { TransformTarget } from "@editor/transform";
|
||||||
|
|
||||||
export type BottomControlsAction = "pan" | "zoom";
|
export type BottomControlsAction = "pan" | "zoom";
|
||||||
|
|
||||||
export type BottomControlsIslandProps = {
|
export type BottomControlsIslandProps = {
|
||||||
viewport: ViewportState;
|
viewport: ViewportState;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
action: BottomControlsAction;
|
action: BottomControlsAction;
|
||||||
selection: SelectionSummary;
|
activeTool: ToolId;
|
||||||
|
brushSettings: BrushSettings;
|
||||||
|
editingMask?: boolean;
|
||||||
|
maskViewMode?: MaskViewMode;
|
||||||
transformBounds?: Rect;
|
transformBounds?: Rect;
|
||||||
|
transformTarget?: TransformTarget;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
onOpenLayers: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BottomControlsIsland({ viewport, visible, action, selection, transformBounds, dispatch, onOpenLayers }: 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);
|
||||||
@@ -31,10 +34,10 @@ export function BottomControlsIsland({ viewport, visible, action, selection, tra
|
|||||||
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{transformBounds ? (
|
{activeTool === "brush" || activeTool === "eraser" ? (
|
||||||
<TransformControls bounds={transformBounds} />
|
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||||
) : selection.type !== "none" ? (
|
) : transformBounds && transformTarget ? (
|
||||||
<SelectionControls selection={selection} onOpenLayers={onOpenLayers} />
|
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||||
) : action === "pan" ? (
|
) : action === "pan" ? (
|
||||||
<PanControls x={x} y={y} />
|
<PanControls x={x} y={y} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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,40 +1,147 @@
|
|||||||
import { Eye, EyeSlash, Lock, LockOpen, X } from "@phosphor-icons/react";
|
import { useRef, useState, type DragEvent, type MutableRefObject } from "react";
|
||||||
|
import { DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { SelectionState } from "@editor/state";
|
import type { ArtboardId } from "@core/id";
|
||||||
|
import type { MaskEditState, SelectionState } from "@editor/state";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||||
|
import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index";
|
||||||
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||||
|
|
||||||
export type LayersSheetProps = {
|
export type LayersSheetProps = {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
selection: SelectionState;
|
selection: SelectionState;
|
||||||
|
maskEdit?: MaskEditState;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LayersSheet({ document, selection, open, onClose }: LayersSheetProps) {
|
export function LayersSheet({ document, selection, maskEdit, open, dispatch, onClose }: LayersSheetProps) {
|
||||||
|
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
|
||||||
|
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
|
||||||
|
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
||||||
|
const canUngroup = selectedLayer?.layer.type === "group";
|
||||||
|
const maskLayerIds = collectDocumentMaskLayerIds(document);
|
||||||
|
const draggedLayerId = useRef<string>();
|
||||||
|
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
className={`pointer-events-auto absolute right-3 top-12 z-20 w-72 rounded-xl border border-white/10 bg-black/75 text-xs text-white shadow-xl backdrop-blur 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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<header className="flex h-10 items-center justify-between border-b border-white/10 px-3">
|
<header className="flex h-14 items-center justify-between border-b border-white/10 bg-white/[0.03] px-4">
|
||||||
<span className="font-medium">Layers</span>
|
<div>
|
||||||
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
|
<div className="font-medium tracking-wide">Layers</div>
|
||||||
<X size={16} weight="regular" />
|
<div className="text-xs text-white/40">Press L to open</div>
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div className="max-h-96 overflow-auto p-2">
|
|
||||||
{document.artboards.map((artboard) => (
|
|
||||||
<section key={artboard.id} className="mb-2 last:mb-0">
|
|
||||||
<div className={`rounded-md px-2 py-1 text-white/60 ${selection.artboardId === artboard.id ? "bg-white/10 text-white" : ""}`}>
|
|
||||||
{artboard.name}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 space-y-1 pl-2">
|
|
||||||
{artboard.layers.length === 0 ? (
|
</header>
|
||||||
<div className="px-2 py-1 text-white/40">No layers</div>
|
<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)}>
|
||||||
|
<Plus size={16} /> Artboard
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
|
||||||
|
<Plus size={16} /> Layer
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroup(selectedArtboardId, dispatch)}>
|
||||||
|
<FolderPlus size={16} /> Group
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!canGroup} onClick={() => selection.artboardId && groupSelection(selection.artboardId, selection.layerIds, dispatch)}>
|
||||||
|
<Stack size={16} /> Group selected
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
|
||||||
|
Ungroup
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, -1, dispatch)}>
|
||||||
|
Up
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, 1, dispatch)}>
|
||||||
|
Down
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
|
||||||
|
<Trash size={16} /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[32rem] overflow-auto p-3">
|
||||||
|
{document.artboards.map((artboard) => (
|
||||||
|
<section key={artboard.id} className="mb-4 last:mb-0">
|
||||||
|
<div
|
||||||
|
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-sky-400/15 text-sky-100 ring-1 ring-sky-300/20" : "text-white/65 hover:bg-white/[0.06] hover:text-white"}`}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (draggedLayerId.current) event.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (draggedLayerId.current) dispatch(commandIds.documentMoveLayer, { layerId: draggedLayerId.current, toArtboardId: artboard.id, toIndex: artboard.layers.length });
|
||||||
|
draggedLayerId.current = undefined;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className="text-white/55 transition hover:text-white" aria-label={artboard.visible ? "Hide artboard" : "Show artboard"} onClick={() => dispatch(commandIds.documentSetArtboardVisible, { id: artboard.id, visible: !artboard.visible })}>
|
||||||
|
{artboard.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="text-white/55 transition hover:text-white" aria-label={artboard.locked ? "Unlock artboard" : "Lock artboard"} onClick={() => dispatch(commandIds.documentSetArtboardLocked, { id: artboard.id, locked: !artboard.locked })}>
|
||||||
|
{artboard.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
|
||||||
|
</button>
|
||||||
|
{editingTitle?.type === "artboard" && editingTitle.id === artboard.id ? (
|
||||||
|
<RenameInput
|
||||||
|
value={editingTitle.draft}
|
||||||
|
onChange={(draft) => setEditingTitle({ ...editingTitle, draft })}
|
||||||
|
onCancel={() => setEditingTitle(undefined)}
|
||||||
|
onCommit={() => {
|
||||||
|
dispatch(commandIds.documentRenameArtboard, { id: artboard.id, name: editingTitle.draft });
|
||||||
|
setEditingTitle(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
artboard.layers.map((layer) => <LayerRow key={layer.id} layer={layer} depth={0} selectedLayerIds={selection.layerIds} />)
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 truncate text-left font-medium"
|
||||||
|
onClick={() => dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [] })}
|
||||||
|
onDoubleClick={() => setEditingTitle({ type: "artboard", id: artboard.id, draft: artboard.name })}
|
||||||
|
>
|
||||||
|
{artboard.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-white/55 transition hover:text-white"
|
||||||
|
aria-label={`Export ${artboard.name} as PNG`}
|
||||||
|
title="Export PNG"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void downloadArtboardPng(artboard, document.assets);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DownloadSimple size={17} weight="regular" />
|
||||||
|
</button>
|
||||||
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/45">{countDisplayLayers(artboard.layers, maskLayerIds)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1.5 pl-3">
|
||||||
|
{countDisplayLayers(artboard.layers, maskLayerIds) === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-white/35">No layers yet</div>
|
||||||
|
) : (
|
||||||
|
artboard.layers.map((layer) => (
|
||||||
|
<LayerRow
|
||||||
|
key={layer.id}
|
||||||
|
document={document}
|
||||||
|
artboardId={artboard.id}
|
||||||
|
layer={layer}
|
||||||
|
depth={0}
|
||||||
|
selectedLayerIds={selection.layerIds}
|
||||||
|
draggedLayerId={draggedLayerId}
|
||||||
|
editingTitle={editingTitle}
|
||||||
|
setEditingTitle={setEditingTitle}
|
||||||
|
maskLayerIds={maskLayerIds}
|
||||||
|
maskEdit={maskEdit}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -44,20 +151,347 @@ export function LayersSheet({ document, selection, open, onClose }: LayersSheetP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayerRow({ layer, depth, selectedLayerIds }: { layer: Layer; depth: number; selectedLayerIds: string[] }) {
|
function LayerRow({
|
||||||
|
document,
|
||||||
|
artboardId,
|
||||||
|
layer,
|
||||||
|
depth,
|
||||||
|
selectedLayerIds,
|
||||||
|
draggedLayerId,
|
||||||
|
editingTitle,
|
||||||
|
setEditingTitle,
|
||||||
|
maskLayerIds,
|
||||||
|
maskEdit,
|
||||||
|
dispatch,
|
||||||
|
}: {
|
||||||
|
document: ImageDocument;
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
layer: Layer;
|
||||||
|
depth: number;
|
||||||
|
selectedLayerIds: string[];
|
||||||
|
draggedLayerId: MutableRefObject<string | undefined>;
|
||||||
|
editingTitle: EditingTitle | undefined;
|
||||||
|
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
||||||
|
maskLayerIds: ReadonlySet<string>;
|
||||||
|
maskEdit?: MaskEditState;
|
||||||
|
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 maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
|
||||||
|
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask);
|
||||||
|
const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId);
|
||||||
|
const rowPadding = 12 + depth * 16;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`flex items-center gap-2 rounded-md px-2 py-1 ${selected ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`} style={{ paddingLeft: 8 + depth * 12 }}>
|
<div
|
||||||
{layer.visible ? <Eye size={14} weight="regular" /> : <EyeSlash size={14} weight="regular" />}
|
draggable
|
||||||
{layer.locked ? <Lock size={14} weight="regular" /> : <LockOpen size={14} weight="regular" />}
|
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"}`}
|
||||||
<span className="min-w-0 flex-1 truncate">{layer.name}</span>
|
style={{ paddingLeft: rowPadding }}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", layer.id);
|
||||||
|
draggedLayerId.current = layer.id;
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
draggedLayerId.current = undefined;
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (draggedLayerId.current && draggedLayerId.current !== layer.id) event.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sourceLayerId = draggedLayerId.current ?? event.dataTransfer.getData("text/plain");
|
||||||
|
if (sourceLayerId && sourceLayerId !== layer.id) dropLayer(document, sourceLayerId, { artboardId, layer }, event, dispatch);
|
||||||
|
draggedLayerId.current = undefined;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className={editingMask || selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
|
||||||
|
{layer.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
|
||||||
|
</button>
|
||||||
|
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
|
||||||
|
<RenameInput
|
||||||
|
value={editingTitle.draft}
|
||||||
|
onChange={(draft) => setEditingTitle({ ...editingTitle, draft })}
|
||||||
|
onCancel={() => setEditingTitle(undefined)}
|
||||||
|
onCommit={() => {
|
||||||
|
dispatch(commandIds.documentRenameLayer, { layerId: layer.id, name: editingTitle.draft });
|
||||||
|
setEditingTitle(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 truncate text-left font-medium"
|
||||||
|
onClick={() => dispatch(commandIds.selectionSet, { artboardId, layerIds: [layer.id] })}
|
||||||
|
onDoubleClick={() => setEditingTitle({ type: "layer", id: layer.id, draft: layer.name })}
|
||||||
|
>
|
||||||
|
{layer.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{layer.clippingMask ? (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
|
||||||
|
<Stack size={13} weight="fill" /> Mask
|
||||||
|
</span>
|
||||||
|
) : canAddMask && layerInfo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={editingMask || selected ? "rounded-full bg-black/10 px-2 py-0.5 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-2 py-0.5 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
|
||||||
|
onClick={() => addLayerMask(document, layerInfo, dispatch)}
|
||||||
|
>
|
||||||
|
Add mask
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className={editingMask || selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"} aria-label={layer.locked ? "Unlock layer" : "Lock layer"} onClick={() => dispatch(commandIds.documentSetLayerLocked, { layerId: layer.id, locked: !layer.locked })}>
|
||||||
|
{layer.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{layer.type === "group" ? layer.children.map((child) => <LayerRow key={child.id} layer={child} depth={depth + 1} selectedLayerIds={selectedLayerIds} />) : null}
|
{layer.clippingMask ? (
|
||||||
|
<div className="mt-1 flex items-center gap-2 rounded-xl border border-sky-300/10 bg-sky-400/[0.04] px-3 py-1.5 text-xs text-sky-100/70" style={{ marginLeft: rowPadding + 24 }}>
|
||||||
|
<Stack size={14} weight="fill" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{maskLayer ? `Layer mask · ${maskLayer.name}` : "Layer mask missing"}</span>
|
||||||
|
{maskLayer ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-2 py-0.5 transition ${editingMask ? "bg-sky-300 text-black" : "bg-sky-400/10 text-sky-100/75 hover:bg-sky-400/20 hover:text-sky-50"}`}
|
||||||
|
onClick={() =>
|
||||||
|
editingMask
|
||||||
|
? dispatch(commandIds.toolExitMaskEdit, undefined)
|
||||||
|
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editingMask ? "Done" : "Edit"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full px-2 py-0.5 text-sky-100/55 transition hover:bg-red-400/15 hover:text-red-100"
|
||||||
|
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{layer.type === "group"
|
||||||
|
? layer.children.map((child) => (
|
||||||
|
<LayerRow
|
||||||
|
key={child.id}
|
||||||
|
document={document}
|
||||||
|
artboardId={artboardId}
|
||||||
|
layer={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
selectedLayerIds={selectedLayerIds}
|
||||||
|
draggedLayerId={draggedLayerId}
|
||||||
|
editingTitle={editingTitle}
|
||||||
|
setEditingTitle={setEditingTitle}
|
||||||
|
maskLayerIds={maskLayerIds}
|
||||||
|
maskEdit={maskEdit}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconButtonClass() {
|
type EditingTitle =
|
||||||
return "grid size-7 place-items-center rounded-full text-white/80 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none";
|
| { type: "artboard"; id: ArtboardId; draft: string }
|
||||||
|
| { type: "layer"; id: string; draft: string };
|
||||||
|
|
||||||
|
function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; onChange: (value: string) => void; onCommit: () => void; onCancel: () => void }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="min-w-0 flex-1 rounded-md bg-white px-2 py-1 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === "Enter") event.currentTarget.blur();
|
||||||
|
if (event.key === "Escape") onCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: AppStore["dispatch"]) {
|
||||||
|
const layer = layerInfo.layer;
|
||||||
|
if (layer.type === "group") return;
|
||||||
|
|
||||||
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
|
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||||
|
if (!asset || !bounds) return;
|
||||||
|
|
||||||
|
const assetId = crypto.randomUUID();
|
||||||
|
const maskLayerId = crypto.randomUUID();
|
||||||
|
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
|
||||||
|
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
|
||||||
|
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
|
||||||
|
|
||||||
|
dispatch(commandIds.documentAddLayerMask, {
|
||||||
|
layerId: layer.id,
|
||||||
|
asset: {
|
||||||
|
id: assetId,
|
||||||
|
name: `${layer.name} Mask`,
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
source,
|
||||||
|
intrinsicSize: { w: width, h: height },
|
||||||
|
},
|
||||||
|
maskLayer: {
|
||||||
|
id: maskLayerId,
|
||||||
|
type: "raster",
|
||||||
|
name: `${layer.name} Mask`,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId,
|
||||||
|
transform: {
|
||||||
|
position: { x: bounds.x, y: bounds.y },
|
||||||
|
scale: { x: bounds.w / width, y: bounds.h / height },
|
||||||
|
rotation: layer.transform.rotation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDocumentMaskLayerIds(document: ImageDocument): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const artboard of document.artboards) collectMaskLayerIds(artboard.layers, ids);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMaskLayerIds(layers: readonly Layer[], ids: Set<string>) {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||||
|
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<string>): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (maskLayerIds.has(layer.id)) continue;
|
||||||
|
count += 1;
|
||||||
|
if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropLayer(
|
||||||
|
document: ImageDocument,
|
||||||
|
sourceLayerId: string,
|
||||||
|
target: { artboardId: ArtboardId; layer: Layer },
|
||||||
|
event: DragEvent<HTMLElement>,
|
||||||
|
dispatch: AppStore["dispatch"],
|
||||||
|
) {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const command = resolveLayerDrop({
|
||||||
|
document,
|
||||||
|
sourceLayerId,
|
||||||
|
target,
|
||||||
|
verticalRatio: (event.clientY - rect.top) / Math.max(1, rect.height),
|
||||||
|
});
|
||||||
|
if (command) dispatch(commandIds.documentMoveLayer, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelection(selection: SelectionState, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) {
|
||||||
|
if (selectedLayer) {
|
||||||
|
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
|
||||||
|
const index = document.artboards.length + 1;
|
||||||
|
dispatch(commandIds.documentAddArtboard, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: `Artboard ${index}`,
|
||||||
|
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) {
|
||||||
|
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
|
||||||
|
if (!artboard) return;
|
||||||
|
|
||||||
|
const assetId = crypto.randomUUID();
|
||||||
|
const layerId = crypto.randomUUID();
|
||||||
|
const width = Math.max(1, Math.round(artboard.bounds.w));
|
||||||
|
const height = Math.max(1, Math.round(artboard.bounds.h));
|
||||||
|
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
|
||||||
|
|
||||||
|
dispatch(commandIds.documentAddAsset, {
|
||||||
|
asset: {
|
||||||
|
id: assetId,
|
||||||
|
name: "Empty Layer",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
source,
|
||||||
|
intrinsicSize: { w: width, h: height },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(commandIds.documentAddRasterLayer, {
|
||||||
|
artboardId,
|
||||||
|
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
|
||||||
|
layer: {
|
||||||
|
id: layerId,
|
||||||
|
type: "raster",
|
||||||
|
name: "Layer",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId,
|
||||||
|
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGroup(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
|
||||||
|
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
|
||||||
|
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
||||||
|
const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers;
|
||||||
|
if (!siblings) return;
|
||||||
|
const currentIndex = siblings.findIndex((layer) => layer.id === info.layer.id);
|
||||||
|
if (currentIndex < 0) return;
|
||||||
|
dispatch(commandIds.documentMoveLayer, {
|
||||||
|
layerId: info.layer.id,
|
||||||
|
toArtboardId: info.artboardId,
|
||||||
|
toParentGroupId: info.parentGroupId,
|
||||||
|
toIndex: currentIndex + direction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGroup(name: string): Layer {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "group",
|
||||||
|
name,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarButtonClass() {
|
||||||
|
return "inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 text-white/75 transition hover:border-white/20 hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconButtonClass() {
|
||||||
|
return "grid size-8 place-items-center rounded-full text-white/70 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cursor, Hand } from "@phosphor-icons/react";
|
import { Crop, Cursor, Eraser, Hand, PaintBrush } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { InteractionMode, ToolId } from "@editor/tools";
|
import type { InteractionMode, ToolId } from "@editor/tools";
|
||||||
@@ -41,6 +41,12 @@ export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverl
|
|||||||
|
|
||||||
function iconForTool(tool: ToolId) {
|
function iconForTool(tool: ToolId) {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
|
case "crop":
|
||||||
|
return Crop;
|
||||||
|
case "brush":
|
||||||
|
return PaintBrush;
|
||||||
|
case "eraser":
|
||||||
|
return Eraser;
|
||||||
case "pan":
|
case "pan":
|
||||||
return Hand;
|
return Hand;
|
||||||
case "select":
|
case "select":
|
||||||
|
|||||||
89
view/bottom-controls/BrushControls.tsx
Normal file
89
view/bottom-controls/BrushControls.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { MaskViewMode } from "@editor/state";
|
||||||
|
import type { BrushSettings, ToolId } from "@editor/tools";
|
||||||
|
import { BottomControlDivider } from "./Divider";
|
||||||
|
import { bottomControlLabelClass } from "./styles";
|
||||||
|
|
||||||
|
export type BrushControlsProps = {
|
||||||
|
tool: Extract<ToolId, "brush" | "eraser">;
|
||||||
|
settings: BrushSettings;
|
||||||
|
editingMask?: boolean;
|
||||||
|
maskViewMode?: MaskViewMode;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrushControls({ tool, settings, editingMask = false, maskViewMode = "composite", dispatch }: BrushControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
|
||||||
|
<span className="px-2 font-medium text-white/85">{editingMask ? `Mask · ${tool === "eraser" ? "Hide" : "Reveal"}` : tool === "eraser" ? "Eraser" : "Brush"}</span>
|
||||||
|
<BottomControlDivider />
|
||||||
|
{tool === "brush" && !editingMask ? (
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className={bottomControlLabelClass()}>Color</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="h-7 w-8 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||||
|
value={settings.color}
|
||||||
|
aria-label="Brush color"
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { color: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className={bottomControlLabelClass()}>Size</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={settings.size}
|
||||||
|
className="w-24 accent-white"
|
||||||
|
aria-label={`${tool} size`}
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { size: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right text-white">{Math.round(settings.size)}</span>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className={bottomControlLabelClass()}>Hard</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={settings.hardness}
|
||||||
|
className="w-24 accent-white"
|
||||||
|
aria-label={`${tool} hardness`}
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { hardness: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right text-white">{Math.round(settings.hardness)}</span>
|
||||||
|
</label>
|
||||||
|
{editingMask ? (
|
||||||
|
<>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className={bottomControlLabelClass()}>View</span>
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-full border border-white/10 bg-black/70 px-2 text-white outline-none"
|
||||||
|
value={maskViewMode}
|
||||||
|
aria-label="Mask view mode"
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetMaskViewMode, { mode: event.target.value as MaskViewMode })}
|
||||||
|
>
|
||||||
|
<option value="composite">Image</option>
|
||||||
|
<option value="blackWhite">B/W</option>
|
||||||
|
<option value="alpha">Alpha</option>
|
||||||
|
<option value="overlay">Overlay</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full bg-white px-3 py-1 font-medium text-black transition hover:bg-white/90"
|
||||||
|
onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Stack } from "@phosphor-icons/react";
|
|
||||||
import type { SelectionSummary } from "../selectionSummary";
|
|
||||||
import { BottomControlDivider } from "./Divider";
|
|
||||||
import { bottomControlButtonClass } from "./styles";
|
|
||||||
|
|
||||||
export type SelectionControlsProps = {
|
|
||||||
selection: SelectionSummary;
|
|
||||||
onOpenLayers: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SelectionControls({ selection, onOpenLayers }: SelectionControlsProps) {
|
|
||||||
const label = selectionLabel(selection);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button type="button" className={bottomControlButtonClass()} aria-label="Open layers" title="Open layers" onClick={onOpenLayers}>
|
|
||||||
<Stack size={16} weight="regular" />
|
|
||||||
</button>
|
|
||||||
<BottomControlDivider />
|
|
||||||
<span className="max-w-48 truncate px-1 text-white">{label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectionLabel(selection: SelectionSummary) {
|
|
||||||
switch (selection.type) {
|
|
||||||
case "artboard":
|
|
||||||
return `${selection.name} · ${selection.layerCount} layers`;
|
|
||||||
case "layer":
|
|
||||||
return selection.name;
|
|
||||||
case "multi-layer":
|
|
||||||
return `${selection.count} layers selected`;
|
|
||||||
case "none":
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,100 @@
|
|||||||
|
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { BoundingBox } from "@phosphor-icons/react";
|
import { BoundingBox } from "@phosphor-icons/react";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { TransformTarget } from "@editor/transform";
|
||||||
import { BottomControlDivider } from "./Divider";
|
import { BottomControlDivider } from "./Divider";
|
||||||
import { bottomControlIconSlotClass, bottomControlLabelClass, bottomControlValueClass } from "./styles";
|
import { bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass } from "./styles";
|
||||||
|
|
||||||
export type TransformControlsProps = {
|
export type TransformControlsProps = {
|
||||||
bounds: Rect;
|
bounds: Rect;
|
||||||
|
target: TransformTarget;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TransformControls({ bounds }: TransformControlsProps) {
|
type BoundsField = keyof Rect;
|
||||||
|
|
||||||
|
export function TransformControls({ bounds, target, dispatch }: TransformControlsProps) {
|
||||||
|
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(draftFromBounds(bounds));
|
||||||
|
}, [bounds.x, bounds.y, bounds.w, bounds.h]);
|
||||||
|
|
||||||
|
const commitField = (field: BoundsField) => {
|
||||||
|
const value = Number.parseFloat(draft[field]);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
setDraft(draftFromBounds(bounds));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(commandIds.transformSetBounds, {
|
||||||
|
target,
|
||||||
|
bounds: {
|
||||||
|
...bounds,
|
||||||
|
[field]: field === "w" || field === "h" ? Math.max(1, value) : value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
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={bottomControlIconSlotClass()}>
|
<span className={bottomControlIconSlotClass()}>
|
||||||
<BoundingBox size={16} weight="regular" />
|
<BoundingBox size={16} weight="regular" />
|
||||||
</span>
|
</span>
|
||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
<span className={bottomControlLabelClass()}>X</span>
|
<BoundsInput label="X" field="x" draft={draft.x} setDraft={setDraft} commitField={commitField} />
|
||||||
<span className={bottomControlValueClass()}>{Math.round(bounds.x)}</span>
|
<BoundsInput label="Y" field="y" draft={draft.y} setDraft={setDraft} commitField={commitField} />
|
||||||
<span className={bottomControlLabelClass()}>Y</span>
|
|
||||||
<span className={bottomControlValueClass()}>{Math.round(bounds.y)}</span>
|
|
||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
<span className={bottomControlLabelClass()}>W</span>
|
<BoundsInput label="W" field="w" draft={draft.w} setDraft={setDraft} commitField={commitField} />
|
||||||
<span className={bottomControlValueClass()}>{Math.round(bounds.w)}</span>
|
<BoundsInput label="H" field="h" draft={draft.h} setDraft={setDraft} commitField={commitField} />
|
||||||
<span className={bottomControlLabelClass()}>H</span>
|
|
||||||
<span className={bottomControlValueClass()}>{Math.round(bounds.h)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BoundsInput({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
commitField,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: BoundsField;
|
||||||
|
draft: string;
|
||||||
|
setDraft: Dispatch<SetStateAction<Record<BoundsField, string>>>;
|
||||||
|
commitField: (field: BoundsField) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span className={bottomControlLabelClass()}>{label}</span>
|
||||||
|
<input
|
||||||
|
className={bottomControlInputClass()}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))}
|
||||||
|
onBlur={() => commitField(field)}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
commitField(field);
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") event.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromBounds(bounds: Rect): Record<BoundsField, string> {
|
||||||
|
return {
|
||||||
|
x: String(Math.round(bounds.x)),
|
||||||
|
y: String(Math.round(bounds.y)),
|
||||||
|
w: String(Math.round(bounds.w)),
|
||||||
|
h: String(Math.round(bounds.h)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export function bottomControlValueClass() {
|
|||||||
return "min-w-10 text-center text-white";
|
return "min-w-10 text-center text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bottomControlInputClass() {
|
||||||
|
return "h-7 w-14 px-1 text-center text-white outline-none transition placeholder:text-white/30 focus:text-white";
|
||||||
|
}
|
||||||
|
|
||||||
export function bottomControlIconSlotClass() {
|
export function bottomControlIconSlotClass() {
|
||||||
return "grid size-7 place-items-center rounded-full text-white/80";
|
return "grid size-7 place-items-center rounded-full text-white/80";
|
||||||
}
|
}
|
||||||
|
|||||||
157
view/canvas/brush.ts
Normal file
157
view/canvas/brush.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
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 { isPanInteractionMode } from "@editor/tools";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
|
||||||
|
export type BrushSession = {
|
||||||
|
layerId: string;
|
||||||
|
assetId: string;
|
||||||
|
previousPoint: Vec2D;
|
||||||
|
mode: "brush" | "eraser";
|
||||||
|
source?: string;
|
||||||
|
pending?: Promise<void>;
|
||||||
|
cancelled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
|
||||||
|
const layer = resolveBrushTargetLayer(document, editor);
|
||||||
|
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||||
|
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPreviewBrush(document: ImageDocument, editor: EditorState): boolean {
|
||||||
|
return Boolean(resolveBrushTargetLayer(document, editor));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined {
|
||||||
|
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||||
|
const editingMask = Boolean(editor.maskEdit);
|
||||||
|
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||||
|
if (!layerId) return undefined;
|
||||||
|
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||||
|
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBrushSession(options: {
|
||||||
|
store: AppStore;
|
||||||
|
session: BrushSession;
|
||||||
|
point: Vec2D;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
hardness: number;
|
||||||
|
}): BrushSession {
|
||||||
|
const state = options.store.getState();
|
||||||
|
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
||||||
|
if (!layer) return options.session;
|
||||||
|
|
||||||
|
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
|
if (!asset) return options.session;
|
||||||
|
|
||||||
|
const from = options.session.previousPoint;
|
||||||
|
const to = options.point;
|
||||||
|
options.session.previousPoint = to;
|
||||||
|
options.session.pending = (options.session.pending ?? Promise.resolve())
|
||||||
|
.then(async () => {
|
||||||
|
if (options.session.cancelled) return;
|
||||||
|
|
||||||
|
const source = await drawStroke({
|
||||||
|
source: options.session.source ?? asset.source,
|
||||||
|
width: asset.intrinsicSize.w,
|
||||||
|
height: asset.intrinsicSize.h,
|
||||||
|
from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
||||||
|
to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
||||||
|
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
||||||
|
size: options.size,
|
||||||
|
hardness: options.hardness,
|
||||||
|
mode: options.session.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.session.cancelled) return;
|
||||||
|
options.session.source = source;
|
||||||
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
return options.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitBrushSession(options: { store: AppStore; session: BrushSession }) {
|
||||||
|
await options.session.pending;
|
||||||
|
if (options.session.cancelled) return;
|
||||||
|
|
||||||
|
if (options.session.source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source: options.session.source });
|
||||||
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
|
||||||
|
options.session.cancelled = true;
|
||||||
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
|
||||||
|
return {
|
||||||
|
x: ((point.x - layer.transform.position.x) / Math.max(0.0001, layer.transform.scale.x) / width) * width,
|
||||||
|
y: ((point.y - layer.transform.position.y) / Math.max(0.0001, layer.transform.scale.y) / height) * height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawStroke(options: {
|
||||||
|
source: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
from: Vec2D;
|
||||||
|
to: Vec2D;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
hardness: number;
|
||||||
|
mode: "brush" | "eraser";
|
||||||
|
}) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.round(options.width));
|
||||||
|
canvas.height = Math.max(1, Math.round(options.height));
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return options.source;
|
||||||
|
|
||||||
|
const image = await loadImage(options.source);
|
||||||
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
|
||||||
|
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
|
||||||
|
context.strokeStyle = options.color;
|
||||||
|
context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color;
|
||||||
|
context.shadowBlur = (1 - hardness) * options.size;
|
||||||
|
context.lineWidth = options.size;
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(options.from.x, options.from.y);
|
||||||
|
context.lineTo(options.to.x, options.to.y);
|
||||||
|
context.stroke();
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load raster layer"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRasterLayer(layers: 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;
|
||||||
|
}
|
||||||
@@ -2,8 +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 === "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, 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,6 +12,7 @@ import {
|
|||||||
pointerInputEventFromPointerEvent,
|
pointerInputEventFromPointerEvent,
|
||||||
wheelInputEventFromWheelEvent,
|
wheelInputEventFromWheelEvent,
|
||||||
} from "@input/index";
|
} from "@input/index";
|
||||||
|
import { beginBrushSession, canPreviewBrush, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
|
||||||
|
|
||||||
export type CanvasInputOptions = {
|
export type CanvasInputOptions = {
|
||||||
globalKeybindConsumer: GlobalKeybindConsumer;
|
globalKeybindConsumer: GlobalKeybindConsumer;
|
||||||
@@ -28,6 +30,8 @@ export function useCanvasInput(
|
|||||||
options: CanvasInputOptions,
|
options: CanvasInputOptions,
|
||||||
): CanvasInputState {
|
): CanvasInputState {
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const brushSession = useRef<BrushSession>();
|
||||||
|
const brushSessionId = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -47,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) => {
|
||||||
@@ -61,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;
|
||||||
@@ -68,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();
|
||||||
@@ -75,10 +103,25 @@ export function useCanvasInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
const selected = state.editor.tools.activeTool === "select" && handleArtboardSelection({
|
const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport);
|
||||||
|
const brush = (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
|
||||||
|
? beginBrushSession(state.document, state.editor, documentPoint)
|
||||||
|
: undefined;
|
||||||
|
if (brush) {
|
||||||
|
store.dispatch(commandIds.toolSetBrushPreview, { position: documentPoint });
|
||||||
|
brushSessionId.current += 1;
|
||||||
|
brushSession.current = brush;
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentState = store.getState();
|
||||||
|
const selectionToolActive = currentState.editor.tools.activeTool === "select" || currentState.editor.tools.activeTool === "crop";
|
||||||
|
const selected = selectionToolActive && handleArtboardSelection({
|
||||||
event: inputEvent,
|
event: inputEvent,
|
||||||
document: state.document,
|
document: currentState.document,
|
||||||
viewport: state.editor.viewport,
|
viewport: currentState.editor.viewport,
|
||||||
dispatch: store.dispatch,
|
dispatch: store.dispatch,
|
||||||
});
|
});
|
||||||
if (selected) event.preventDefault();
|
if (selected) event.preventDefault();
|
||||||
@@ -86,20 +129,55 @@ export function useCanvasInput(
|
|||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
|
if (brushSession.current) {
|
||||||
|
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
|
||||||
|
const session = brushSession.current;
|
||||||
|
brushSessionId.current += 1;
|
||||||
|
brushSession.current = undefined;
|
||||||
|
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||||
|
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
|
||||||
|
const settings = store.getState().editor.tools.brush;
|
||||||
|
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness });
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const transformed = transformHandler.pointerMove(inputEvent);
|
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) {
|
||||||
|
const session = brushSession.current;
|
||||||
|
brushSessionId.current += 1;
|
||||||
|
brushSession.current = undefined;
|
||||||
|
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const transformed = transformHandler.pointerUp(inputEvent);
|
const transformed = transformHandler.pointerUp(inputEvent);
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
|
clearBrushPreview();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -108,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),
|
||||||
@@ -128,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 () => {
|
||||||
@@ -137,9 +221,21 @@ 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]);
|
||||||
|
|
||||||
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 }) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
149
view/exportArtboardPng.ts
Normal file
149
view/exportArtboardPng.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { Artboard } from "@core/artboard";
|
||||||
|
import type { Asset } from "@core/asset";
|
||||||
|
import type { Rect } from "@core/geometry";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
|
||||||
|
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
|
||||||
|
const width = Math.max(1, Math.round(artboard.bounds.w));
|
||||||
|
const height = Math.max(1, Math.round(artboard.bounds.h));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Canvas 2D is not available");
|
||||||
|
|
||||||
|
if (artboard.backgroundColor !== "transparent") {
|
||||||
|
context.fillStyle = artboard.backgroundColor;
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskLayerIds = collectMaskLayerIds(artboard.layers);
|
||||||
|
context.save();
|
||||||
|
context.translate(-artboard.bounds.x, -artboard.bounds.y);
|
||||||
|
for (const layer of artboard.layers) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds });
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
const url = canvas.toDataURL("image/png");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${safeFilename(artboard.name)}.png`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawLayer(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
layer: Layer,
|
||||||
|
layerTree: readonly Layer[],
|
||||||
|
assets: readonly Asset[],
|
||||||
|
artboardBounds: Rect,
|
||||||
|
options: { maskLayerIds: ReadonlySet<string>; ignoreOwnMask?: boolean },
|
||||||
|
) {
|
||||||
|
if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return;
|
||||||
|
|
||||||
|
if (!options.ignoreOwnMask && layer.clippingMask) {
|
||||||
|
const maskLayer = findLayer(layerTree, layer.clippingMask.maskLayerId);
|
||||||
|
if (!maskLayer) return;
|
||||||
|
await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.globalAlpha *= layer.opacity;
|
||||||
|
|
||||||
|
if (layer.type === "group") {
|
||||||
|
for (const child of layer.children) await drawLayer(context, child, layerTree, assets, artboardBounds, options);
|
||||||
|
context.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
|
if (!asset) {
|
||||||
|
context.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await loadImage(asset.source);
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
layer.transform.position.x,
|
||||||
|
layer.transform.position.y,
|
||||||
|
asset.intrinsicSize.w * layer.transform.scale.x,
|
||||||
|
asset.intrinsicSize.h * layer.transform.scale.y,
|
||||||
|
);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawMaskedLayer(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
layer: Layer,
|
||||||
|
maskLayer: Layer,
|
||||||
|
layerTree: readonly Layer[],
|
||||||
|
assets: readonly Asset[],
|
||||||
|
artboardBounds: Rect,
|
||||||
|
) {
|
||||||
|
const layerCanvas = createArtboardCanvas(artboardBounds);
|
||||||
|
const layerContext = translatedContext(layerCanvas, artboardBounds);
|
||||||
|
await drawLayer(layerContext, layer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true });
|
||||||
|
layerContext.restore();
|
||||||
|
|
||||||
|
const maskCanvas = createArtboardCanvas(artboardBounds);
|
||||||
|
const maskContext = translatedContext(maskCanvas, artboardBounds);
|
||||||
|
await drawLayer(maskContext, maskLayer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true });
|
||||||
|
maskContext.restore();
|
||||||
|
|
||||||
|
const compositeContext = layerCanvas.getContext("2d");
|
||||||
|
if (!compositeContext) return;
|
||||||
|
compositeContext.globalCompositeOperation = "destination-in";
|
||||||
|
compositeContext.drawImage(maskCanvas, 0, 0);
|
||||||
|
compositeContext.globalCompositeOperation = "source-over";
|
||||||
|
|
||||||
|
context.drawImage(layerCanvas, artboardBounds.x, artboardBounds.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArtboardCanvas(bounds: Rect) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.round(bounds.w));
|
||||||
|
canvas.height = Math.max(1, Math.round(bounds.h));
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Canvas 2D is not available");
|
||||||
|
context.save();
|
||||||
|
context.translate(-bounds.x, -bounds.y);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||||
|
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load image for export"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFilename(name: string) {
|
||||||
|
return name.trim().replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "artboard";
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
import type { SelectionState } from "@editor/state";
|
|
||||||
|
|
||||||
export type SelectionSummary =
|
|
||||||
| { type: "none" }
|
|
||||||
| { type: "artboard"; name: string; layerCount: number }
|
|
||||||
| { type: "layer"; name: string; layer: Layer }
|
|
||||||
| { type: "multi-layer"; count: number; layers: Layer[] };
|
|
||||||
|
|
||||||
export function getSelectionSummary(document: ImageDocument, selection: SelectionState): SelectionSummary {
|
|
||||||
const selectedLayers = selection.layerIds.flatMap((layerId) => {
|
|
||||||
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
|
||||||
return layer ? [layer] : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedLayers.length === 1 && selectedLayers[0]) {
|
|
||||||
return { type: "layer", name: selectedLayers[0].name, layer: selectedLayers[0] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedLayers.length > 1) {
|
|
||||||
return { type: "multi-layer", count: selectedLayers.length, layers: selectedLayers };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selection.artboardId) {
|
|
||||||
const artboard = document.artboards.find((candidate) => candidate.id === selection.artboardId);
|
|
||||||
if (artboard) return { type: "artboard", name: artboard.name, layerCount: artboard.layers.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "none" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLayer(layers: 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,5 +1,16 @@
|
|||||||
import type { ToolId } from "@editor/tools";
|
import type { ToolId } from "@editor/tools";
|
||||||
|
|
||||||
export function labelForTool(tool: ToolId): string {
|
export function labelForTool(tool: ToolId): string {
|
||||||
return tool === "pan" ? "Pan" : "Select";
|
switch (tool) {
|
||||||
|
case "crop":
|
||||||
|
return "Crop";
|
||||||
|
case "brush":
|
||||||
|
return "Brush";
|
||||||
|
case "eraser":
|
||||||
|
return "Eraser";
|
||||||
|
case "pan":
|
||||||
|
return "Pan";
|
||||||
|
case "select":
|
||||||
|
return "Select";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user