Compare commits
19 Commits
b5c7f87a20
...
bc0fbea029
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc0fbea029 | ||
|
|
71e97388da | ||
|
|
ed4d419c4d | ||
|
|
44295b1b2d | ||
|
|
75466d2aa6 | ||
|
|
97c3d3b7ae | ||
|
|
a3b4a4a1b0 | ||
|
|
ff5bb43382 | ||
|
|
166310ace0 | ||
|
|
bf5d9f5371 | ||
|
|
03d4d4a3b9 | ||
|
|
e90b829e62 | ||
|
|
4d77288450 | ||
|
|
28fcbdceda | ||
|
|
46aa1b8595 | ||
|
|
b0732c8af1 | ||
|
|
8965532c3a | ||
|
|
84a610f019 | ||
|
|
8320203f11 |
10
app/app.ts
10
app/app.ts
@@ -1,7 +1,9 @@
|
||||
import { documentCommands } from "@commands/document";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { createCommandRegistry } from "@commands/registry";
|
||||
import { selectionCommands } from "@commands/selection";
|
||||
import { toolCommands } from "@commands/tool";
|
||||
import { transformCommands } from "@commands/transform";
|
||||
import { viewportCommands } from "@commands/viewport";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { createAppStore } from "@editor/store";
|
||||
@@ -9,15 +11,17 @@ import { createAppStore } from "@editor/store";
|
||||
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
|
||||
|
||||
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
|
||||
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands]);
|
||||
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...transformCommands]);
|
||||
const store = createAppStore(createInitialAppState(options?.documentName), registry);
|
||||
|
||||
if (options?.createDefaultArtboard !== false) {
|
||||
store.dispatch("document.addArtboard", {
|
||||
id: crypto.randomUUID(),
|
||||
const artboardId = crypto.randomUUID();
|
||||
store.dispatch(commandIds.documentAddArtboard, {
|
||||
id: artboardId,
|
||||
name: "Artboard 1",
|
||||
bounds: { x: -400, y: -300, w: 800, h: 600 },
|
||||
});
|
||||
store.dispatch(commandIds.viewportFitArtboard, { artboardId });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -84,6 +85,8 @@
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
|
||||
|
||||
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { createAppStore } from "@editor/store";
|
||||
import { commandIds } from "./ids";
|
||||
import { createCommandRegistry } from "./registry";
|
||||
import { viewportPanCommand } from "./viewport";
|
||||
|
||||
@@ -12,7 +13,7 @@ describe("command dispatcher", () => {
|
||||
|
||||
test("dispatch applies command result to store", () => {
|
||||
const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([viewportPanCommand]));
|
||||
store.dispatch("viewport.pan", { delta: { x: 3, y: 7 } });
|
||||
store.dispatch(commandIds.viewportPan, { delta: { x: 3, y: 7 } });
|
||||
expect(store.getState().editor.viewport.center).toEqual({ x: 3, y: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand } from "./document";
|
||||
import { documentAddArtboardCommand, documentAddAssetCommand, documentAddImageLayerCommand, documentSetArtboardBoundsCommand } from "./document";
|
||||
|
||||
describe("document commands", () => {
|
||||
test("adds transparent artboard", () => {
|
||||
@@ -19,4 +19,52 @@ describe("document commands", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds assets", () => {
|
||||
const next = documentAddAssetCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
{ asset: { id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 100, h: 50 } } },
|
||||
);
|
||||
|
||||
expect(next.document.assets).toEqual([
|
||||
{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 100, h: 50 } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds image 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 = documentAddImageLayerCommand.execute(
|
||||
{ state },
|
||||
{
|
||||
artboardId: "a1",
|
||||
layer: {
|
||||
id: "l1",
|
||||
type: "image",
|
||||
name: "Image",
|
||||
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(["l1"]);
|
||||
});
|
||||
|
||||
test("sets artboard bounds", () => {
|
||||
const state = documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
|
||||
);
|
||||
|
||||
const next = documentSetArtboardBoundsCommand.execute({ state }, { id: "a1", bounds: { x: 10, y: 20, w: 640, h: 480 } });
|
||||
|
||||
expect(next.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 640, h: 480 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type DocumentAddArtboardPayload = {
|
||||
id: ArtboardId;
|
||||
@@ -8,8 +11,22 @@ export type DocumentAddArtboardPayload = {
|
||||
bounds: Rect;
|
||||
};
|
||||
|
||||
export type DocumentSetArtboardBoundsPayload = {
|
||||
id: ArtboardId;
|
||||
bounds: Rect;
|
||||
};
|
||||
|
||||
export type DocumentAddAssetPayload = {
|
||||
asset: Asset;
|
||||
};
|
||||
|
||||
export type DocumentAddImageLayerPayload = {
|
||||
artboardId: ArtboardId;
|
||||
layer: ImageLayer;
|
||||
};
|
||||
|
||||
export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
||||
id: "document.addArtboard",
|
||||
id: commandIds.documentAddArtboard,
|
||||
name: "Add artboard",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
@@ -31,4 +48,57 @@ export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const documentCommands = [documentAddArtboardCommand] satisfies Command<unknown>[];
|
||||
export const documentSetArtboardBoundsCommand: Command<DocumentSetArtboardBoundsPayload> = {
|
||||
id: commandIds.documentSetArtboardBounds,
|
||||
name: "Set artboard bounds",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) =>
|
||||
artboard.id === payload.id ? { ...artboard, bounds: { ...payload.bounds } } : artboard,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentAddAssetCommand: Command<DocumentAddAssetPayload> = {
|
||||
id: commandIds.documentAddAsset,
|
||||
name: "Add asset",
|
||||
execute({ state }, payload) {
|
||||
if (state.document.assets.some((asset) => asset.id === payload.asset.id)) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
assets: [...state.document.assets, payload.asset],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload> = {
|
||||
id: commandIds.documentAddImageLayer,
|
||||
name: "Add image layer",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) =>
|
||||
artboard.id === payload.artboardId ? { ...artboard, layers: [...artboard.layers, payload.layer] } : artboard,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentCommands = [
|
||||
documentAddArtboardCommand,
|
||||
documentSetArtboardBoundsCommand,
|
||||
documentAddAssetCommand,
|
||||
documentAddImageLayerCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
21
commands/ids.ts
Normal file
21
commands/ids.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const commandIds = {
|
||||
documentAddArtboard: "document.addArtboard",
|
||||
documentSetArtboardBounds: "document.setArtboardBounds",
|
||||
documentAddAsset: "document.addAsset",
|
||||
documentAddImageLayer: "document.addImageLayer",
|
||||
selectionSet: "selection.set",
|
||||
selectionClear: "selection.clear",
|
||||
selectionAddLayer: "selection.addLayer",
|
||||
toolSetActive: "tool.setActive",
|
||||
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||
transformBegin: "transform.begin",
|
||||
transformUpdate: "transform.update",
|
||||
transformEnd: "transform.end",
|
||||
viewportPan: "viewport.pan",
|
||||
viewportSetZoom: "viewport.setZoom",
|
||||
viewportZoomAroundPoint: "viewport.zoomAroundPoint",
|
||||
viewportSetSize: "viewport.setSize",
|
||||
viewportReset: "viewport.reset",
|
||||
viewportFitArtboard: "viewport.fitArtboard",
|
||||
} as const;
|
||||
@@ -1,6 +1,17 @@
|
||||
export type { Command, CommandContext } from "./command";
|
||||
export { documentAddArtboardCommand, documentCommands } from "./document";
|
||||
export type { DocumentAddArtboardPayload } from "./document";
|
||||
export {
|
||||
documentAddArtboardCommand,
|
||||
documentAddAssetCommand,
|
||||
documentAddImageLayerCommand,
|
||||
documentCommands,
|
||||
documentSetArtboardBoundsCommand,
|
||||
} from "./document";
|
||||
export type {
|
||||
DocumentAddArtboardPayload,
|
||||
DocumentAddAssetPayload,
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentSetArtboardBoundsPayload,
|
||||
} from "./document";
|
||||
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
||||
export type { CommandId, CommandPayloads } from "./payloads";
|
||||
export { createCommandDispatcher } from "./dispatcher";
|
||||
@@ -9,6 +20,8 @@ export { createCommandRegistry } from "./registry";
|
||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
|
||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformUpdateCommand } from "./transform";
|
||||
export type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
||||
export type { ToolSetActivePayload } from "./tool";
|
||||
export {
|
||||
viewportCommands,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { DocumentAddArtboardPayload } from "./document";
|
||||
import { commandIds } from "./ids";
|
||||
import type {
|
||||
DocumentAddArtboardPayload,
|
||||
DocumentAddAssetPayload,
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentSetArtboardBoundsPayload,
|
||||
} from "./document";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolSetActivePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
||||
import type {
|
||||
ViewportFitArtboardPayload,
|
||||
ViewportPanPayload,
|
||||
ViewportSetSizePayload,
|
||||
ViewportSetZoomPayload,
|
||||
@@ -9,18 +17,25 @@ import type {
|
||||
} from "./viewport";
|
||||
|
||||
export type CommandPayloads = {
|
||||
"document.addArtboard": DocumentAddArtboardPayload;
|
||||
"selection.set": SelectionSetPayload;
|
||||
"selection.clear": void;
|
||||
"selection.addLayer": SelectionAddLayerPayload;
|
||||
"tool.setActive": ToolSetActivePayload;
|
||||
"tool.enterTemporaryPan": void;
|
||||
"tool.exitTemporaryPan": void;
|
||||
"viewport.pan": ViewportPanPayload;
|
||||
"viewport.setZoom": ViewportSetZoomPayload;
|
||||
"viewport.zoomAroundPoint": ViewportZoomAroundPointPayload;
|
||||
"viewport.setSize": ViewportSetSizePayload;
|
||||
"viewport.reset": void;
|
||||
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
||||
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
||||
[commandIds.documentAddAsset]: DocumentAddAssetPayload;
|
||||
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
||||
[commandIds.selectionSet]: SelectionSetPayload;
|
||||
[commandIds.selectionClear]: void;
|
||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||
[commandIds.toolEnterTemporaryPan]: void;
|
||||
[commandIds.toolExitTemporaryPan]: void;
|
||||
[commandIds.transformBegin]: TransformBeginPayload;
|
||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||
[commandIds.transformEnd]: void;
|
||||
[commandIds.viewportPan]: ViewportPanPayload;
|
||||
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
||||
[commandIds.viewportZoomAroundPoint]: ViewportZoomAroundPointPayload;
|
||||
[commandIds.viewportSetSize]: ViewportSetSizePayload;
|
||||
[commandIds.viewportReset]: void;
|
||||
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
|
||||
};
|
||||
|
||||
export type CommandId = keyof CommandPayloads;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type SelectionSetPayload = {
|
||||
artboardId?: ArtboardId;
|
||||
@@ -11,7 +12,7 @@ export type SelectionAddLayerPayload = {
|
||||
};
|
||||
|
||||
export const selectionSetCommand: Command<SelectionSetPayload> = {
|
||||
id: "selection.set",
|
||||
id: commandIds.selectionSet,
|
||||
name: "Set selection",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
@@ -28,7 +29,7 @@ export const selectionSetCommand: Command<SelectionSetPayload> = {
|
||||
};
|
||||
|
||||
export const selectionClearCommand: Command = {
|
||||
id: "selection.clear",
|
||||
id: commandIds.selectionClear,
|
||||
name: "Clear selection",
|
||||
execute({ state }) {
|
||||
return {
|
||||
@@ -42,7 +43,7 @@ export const selectionClearCommand: Command = {
|
||||
};
|
||||
|
||||
export const selectionAddLayerCommand: Command<SelectionAddLayerPayload> = {
|
||||
id: "selection.addLayer",
|
||||
id: commandIds.selectionAddLayer,
|
||||
name: "Add layer to selection",
|
||||
execute({ state }, payload) {
|
||||
if (state.editor.selection.layerIds.includes(payload.layerId)) return state;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ToolId } from "@editor/tools";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type ToolSetActivePayload = {
|
||||
tool: ToolId;
|
||||
};
|
||||
|
||||
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
||||
id: "tool.setActive",
|
||||
id: commandIds.toolSetActive,
|
||||
name: "Set active tool",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
@@ -23,7 +24,7 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
||||
};
|
||||
|
||||
export const toolEnterTemporaryPanCommand: Command = {
|
||||
id: "tool.enterTemporaryPan",
|
||||
id: commandIds.toolEnterTemporaryPan,
|
||||
name: "Enter temporary pan",
|
||||
execute({ state }) {
|
||||
if (state.editor.tools.interactionMode.type === "temporary-pan") return state;
|
||||
@@ -42,7 +43,7 @@ export const toolEnterTemporaryPanCommand: Command = {
|
||||
};
|
||||
|
||||
export const toolExitTemporaryPanCommand: Command = {
|
||||
id: "tool.exitTemporaryPan",
|
||||
id: commandIds.toolExitTemporaryPan,
|
||||
name: "Exit temporary pan",
|
||||
execute({ state }) {
|
||||
const mode = state.editor.tools.interactionMode;
|
||||
|
||||
44
commands/transform.test.ts
Normal file
44
commands/transform.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand } from "./document";
|
||||
import { transformBeginCommand, transformEndCommand, transformUpdateCommand } from "./transform";
|
||||
|
||||
function artboardState() {
|
||||
return documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
{ id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||
);
|
||||
}
|
||||
|
||||
describe("transform commands", () => {
|
||||
test("moves artboard transform target", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
{ target: { type: "artboard", id: "a1" }, handle: "body", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||
);
|
||||
const updated = transformUpdateCommand.execute({ state: started }, { point: { x: 10, y: 20 } });
|
||||
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 100, h: 80 });
|
||||
});
|
||||
|
||||
test("resizes artboard from southeast handle", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
{ target: { type: "artboard", id: "a1" }, handle: "se", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||
);
|
||||
const updated = transformUpdateCommand.execute({ state: started }, { point: { x: 10, y: 20 } });
|
||||
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 110, h: 100 });
|
||||
});
|
||||
|
||||
test("ends transform session", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
{ target: { type: "artboard", id: "a1" }, handle: "body", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||
);
|
||||
|
||||
const ended = transformEndCommand.execute({ state: started }, undefined);
|
||||
|
||||
expect(ended.editor.transformSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
104
commands/transform.ts
Normal file
104
commands/transform.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import { applyTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { TransformHandle, TransformTarget } from "@editor/transform";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type TransformBeginPayload = {
|
||||
target: TransformTarget;
|
||||
handle: TransformHandle;
|
||||
point: Vec2D;
|
||||
initialBounds: Rect;
|
||||
};
|
||||
|
||||
export type TransformUpdatePayload = {
|
||||
point: Vec2D;
|
||||
};
|
||||
|
||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||
id: commandIds.transformBegin,
|
||||
name: "Begin transform",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
transformSession: {
|
||||
target: payload.target,
|
||||
handle: payload.handle,
|
||||
startPoint: payload.point,
|
||||
initialBounds: payload.initialBounds,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const transformUpdateCommand: Command<TransformUpdatePayload> = {
|
||||
id: commandIds.transformUpdate,
|
||||
name: "Update transform",
|
||||
execute({ state }, payload) {
|
||||
const session = state.editor.transformSession;
|
||||
if (!session) return state;
|
||||
|
||||
const nextBounds = transformBounds(session.initialBounds, session.handle, {
|
||||
x: payload.point.x - session.startPoint.x,
|
||||
y: payload.point.y - session.startPoint.y,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: applyTransformTargetBounds(state.document, session.target, nextBounds),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const transformEndCommand: Command = {
|
||||
id: commandIds.transformEnd,
|
||||
name: "End transform",
|
||||
execute({ state }) {
|
||||
if (!state.editor.transformSession) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
transformSession: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||
|
||||
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D): Rect {
|
||||
if (handle === "body") return { ...bounds, x: bounds.x + delta.x, y: bounds.y + delta.y };
|
||||
|
||||
let x = bounds.x;
|
||||
let y = bounds.y;
|
||||
let w = bounds.w;
|
||||
let h = bounds.h;
|
||||
|
||||
if (handle.includes("w")) {
|
||||
x = bounds.x + delta.x;
|
||||
w = bounds.w - delta.x;
|
||||
}
|
||||
if (handle.includes("e")) w = bounds.w + delta.x;
|
||||
if (handle.includes("n")) {
|
||||
y = bounds.y + delta.y;
|
||||
h = bounds.h - delta.y;
|
||||
}
|
||||
if (handle.includes("s")) h = bounds.h + delta.y;
|
||||
|
||||
return normalizeRect({ x, y, w, h });
|
||||
}
|
||||
|
||||
function normalizeRect(rect: Rect): Rect {
|
||||
const minSize = 1;
|
||||
return {
|
||||
x: rect.w < minSize ? rect.x + rect.w - minSize : rect.x,
|
||||
y: rect.h < minSize ? rect.y + rect.h - minSize : rect.y,
|
||||
w: Math.max(minSize, rect.w),
|
||||
h: Math.max(minSize, rect.h),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import {
|
||||
viewportFitArtboardCommand,
|
||||
viewportPanCommand,
|
||||
viewportResetCommand,
|
||||
viewportSetSizeCommand,
|
||||
@@ -34,6 +35,36 @@ describe("viewport commands", () => {
|
||||
expect(next.editor.viewport.center).toEqual({ x: 25, y: 0 });
|
||||
});
|
||||
|
||||
test("fits an artboard in the viewport", () => {
|
||||
const state = {
|
||||
...viewportSetSizeCommand.execute(context(), { w: 1000, h: 800 }),
|
||||
document: {
|
||||
...context().state.document,
|
||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: -400, y: -300, w: 800, h: 600 }, backgroundColor: "transparent", layers: [] }],
|
||||
},
|
||||
};
|
||||
|
||||
const next = viewportFitArtboardCommand.execute({ state }, { artboardId: "artboard-1", padding: 100 });
|
||||
|
||||
expect(next.editor.viewport.center).toEqual({ x: 0, y: 0 });
|
||||
expect(next.editor.viewport.zoom).toBe(1);
|
||||
});
|
||||
|
||||
test("fit artboard centers even before viewport size is known", () => {
|
||||
const state = {
|
||||
...context().state,
|
||||
document: {
|
||||
...context().state.document,
|
||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: 10, y: 20, w: 100, h: 200 }, backgroundColor: "transparent", layers: [] }],
|
||||
},
|
||||
};
|
||||
|
||||
const next = viewportFitArtboardCommand.execute({ state }, { artboardId: "artboard-1" });
|
||||
|
||||
expect(next.editor.viewport.center).toEqual({ x: 60, y: 120 });
|
||||
expect(next.editor.viewport.zoom).toBe(1);
|
||||
});
|
||||
|
||||
test("resets viewport but preserves size", () => {
|
||||
const sized = viewportSetSizeCommand.execute(context(), { w: 640, h: 480 });
|
||||
const panned = viewportPanCommand.execute({ state: sized }, { delta: { x: 4, y: 8 } });
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import type { Vec2D } from "@core/geometry";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type ViewportPanPayload = {
|
||||
delta: Vec2D;
|
||||
@@ -19,8 +21,13 @@ export type ViewportSetSizePayload = {
|
||||
h: number;
|
||||
};
|
||||
|
||||
export type ViewportFitArtboardPayload = {
|
||||
artboardId?: ArtboardId;
|
||||
padding?: number;
|
||||
};
|
||||
|
||||
export const viewportPanCommand: Command<ViewportPanPayload> = {
|
||||
id: "viewport.pan",
|
||||
id: commandIds.viewportPan,
|
||||
name: "Pan viewport",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
@@ -40,7 +47,7 @@ export const viewportPanCommand: Command<ViewportPanPayload> = {
|
||||
};
|
||||
|
||||
export const viewportSetZoomCommand: Command<ViewportSetZoomPayload> = {
|
||||
id: "viewport.setZoom",
|
||||
id: commandIds.viewportSetZoom,
|
||||
name: "Set viewport zoom",
|
||||
execute({ state }, payload) {
|
||||
const zoom = Math.max(0.01, payload.zoom);
|
||||
@@ -59,7 +66,7 @@ export const viewportSetZoomCommand: Command<ViewportSetZoomPayload> = {
|
||||
};
|
||||
|
||||
export const viewportZoomAroundPointCommand: Command<ViewportZoomAroundPointPayload> = {
|
||||
id: "viewport.zoomAroundPoint",
|
||||
id: commandIds.viewportZoomAroundPoint,
|
||||
name: "Zoom viewport around point",
|
||||
execute({ state }, payload) {
|
||||
const viewport = state.editor.viewport;
|
||||
@@ -91,7 +98,7 @@ export const viewportZoomAroundPointCommand: Command<ViewportZoomAroundPointPayl
|
||||
};
|
||||
|
||||
export const viewportSetSizeCommand: Command<ViewportSetSizePayload> = {
|
||||
id: "viewport.setSize",
|
||||
id: commandIds.viewportSetSize,
|
||||
name: "Set viewport size",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
@@ -110,8 +117,42 @@ export const viewportSetSizeCommand: Command<ViewportSetSizePayload> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const viewportFitArtboardCommand: Command<ViewportFitArtboardPayload | undefined> = {
|
||||
id: commandIds.viewportFitArtboard,
|
||||
name: "Fit artboard in viewport",
|
||||
execute({ state }, payload) {
|
||||
const artboardId = payload?.artboardId ?? state.editor.selection.artboardId;
|
||||
const artboard = artboardId
|
||||
? state.document.artboards.find((candidate) => candidate.id === artboardId)
|
||||
: state.document.artboards[0];
|
||||
|
||||
if (!artboard) return state;
|
||||
|
||||
const padding = Math.max(0, payload?.padding ?? 48);
|
||||
const availableWidth = Math.max(0, state.editor.viewport.size.w - padding * 2);
|
||||
const availableHeight = Math.max(0, state.editor.viewport.size.h - padding * 2);
|
||||
const canFitZoom = availableWidth > 0 && availableHeight > 0 && artboard.bounds.w > 0 && artboard.bounds.h > 0;
|
||||
const zoom = canFitZoom ? Math.max(0.01, Math.min(availableWidth / artboard.bounds.w, availableHeight / artboard.bounds.h)) : state.editor.viewport.zoom;
|
||||
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
viewport: {
|
||||
...state.editor.viewport,
|
||||
center: {
|
||||
x: artboard.bounds.x + artboard.bounds.w / 2,
|
||||
y: artboard.bounds.y + artboard.bounds.h / 2,
|
||||
},
|
||||
zoom,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const viewportResetCommand: Command = {
|
||||
id: "viewport.reset",
|
||||
id: commandIds.viewportReset,
|
||||
name: "Reset viewport",
|
||||
execute({ state }) {
|
||||
return {
|
||||
@@ -134,5 +175,6 @@ export const viewportCommands = [
|
||||
viewportSetZoomCommand,
|
||||
viewportZoomAroundPointCommand,
|
||||
viewportSetSizeCommand,
|
||||
viewportFitArtboardCommand,
|
||||
viewportResetCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Size } from "./geometry";
|
||||
import type { AssetId } from "./id";
|
||||
|
||||
export type Asset = {
|
||||
@@ -5,4 +6,5 @@ export type Asset = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
source: string;
|
||||
intrinsicSize: Size;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export const initialEditorState: EditorState = {
|
||||
layerIds: [],
|
||||
},
|
||||
tools: initialToolState,
|
||||
transformSession: undefined,
|
||||
};
|
||||
|
||||
export function createInitialAppState(name = "Untitled"): AppState {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
|
||||
import type { Angle, Size, Vec2D } from "@core/geometry";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { ToolState } from "./tools";
|
||||
import type { TransformSession } from "./transform";
|
||||
|
||||
export type ViewportState = {
|
||||
center: Vec2D;
|
||||
@@ -19,6 +20,7 @@ export type EditorState = {
|
||||
viewport: ViewportState;
|
||||
selection: SelectionState;
|
||||
tools: ToolState;
|
||||
transformSession?: TransformSession;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type ToolId = "select" | "pan";
|
||||
export const availableToolIds = ["select", "pan"] as const;
|
||||
|
||||
export type ToolId = (typeof availableToolIds)[number];
|
||||
|
||||
export type InteractionMode =
|
||||
| { type: "tool"; tool: ToolId }
|
||||
@@ -13,3 +15,7 @@ export const initialToolState: ToolState = {
|
||||
activeTool: "select",
|
||||
interactionMode: { type: "tool", tool: "select" },
|
||||
};
|
||||
|
||||
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
||||
return interactionMode.type === "temporary-pan" || (interactionMode.type === "tool" && interactionMode.tool === "pan");
|
||||
}
|
||||
|
||||
56
editor/transform-targets.test.ts
Normal file
56
editor/transform-targets.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import { applyTransformTargetBounds, resolveTransformTargetBounds, selectedTransformTarget } from "./transform-targets";
|
||||
|
||||
const document: ImageDocument = {
|
||||
id: "d1",
|
||||
name: "Test",
|
||||
version: 1,
|
||||
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "asset://image", intrinsicSize: { w: 200, h: 100 } }],
|
||||
artboards: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "Artboard",
|
||||
bounds: { x: 0, y: 0, w: 100, h: 80 },
|
||||
backgroundColor: "transparent",
|
||||
layers: [
|
||||
{
|
||||
id: "l1",
|
||||
type: "image",
|
||||
name: "Image Layer",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: "asset-1",
|
||||
transform: { position: { x: 10, y: 20 }, scale: { x: 0.5, y: 2 }, rotation: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("transform targets", () => {
|
||||
test("selects layer target before artboard target", () => {
|
||||
expect(selectedTransformTarget(document, { artboardId: "a1", layerIds: ["l1"] })).toEqual({ type: "layer", id: "l1" });
|
||||
});
|
||||
|
||||
test("resolves artboard bounds", () => {
|
||||
expect(resolveTransformTargetBounds(document, { type: "artboard", id: "a1" })).toEqual({ x: 0, y: 0, w: 100, h: 80 });
|
||||
});
|
||||
|
||||
test("resolves image layer bounds from intrinsic asset dimensions", () => {
|
||||
expect(resolveTransformTargetBounds(document, { type: "layer", id: "l1" })).toEqual({ x: 10, y: 20, w: 100, h: 200 });
|
||||
});
|
||||
|
||||
test("applies image layer bounds to transform", () => {
|
||||
const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 });
|
||||
const layer = next.artboards[0]?.layers[0];
|
||||
|
||||
expect(layer?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
|
||||
});
|
||||
|
||||
test("applies artboard bounds", () => {
|
||||
const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 });
|
||||
expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 });
|
||||
});
|
||||
});
|
||||
121
editor/transform-targets.ts
Normal file
121
editor/transform-targets.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { TransformTarget } from "./transform";
|
||||
|
||||
export function resolveTransformTargetBounds(document: ImageDocument, target: TransformTarget): 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 applyTransformTargetBounds(document: ImageDocument, target: TransformTarget, bounds: Rect): ImageDocument {
|
||||
switch (target.type) {
|
||||
case "artboard":
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => (artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard)),
|
||||
};
|
||||
case "layer":
|
||||
return applyLayerBounds(document, target.id, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedTransformTarget(document: ImageDocument, selection: { artboardId?: ArtboardId; layerIds: LayerId[] }): TransformTarget | 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;
|
||||
}
|
||||
|
||||
function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => ({
|
||||
...artboard,
|
||||
layers: applyLayerBoundsInTree(document, artboard.layers, layerId, bounds),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.id === layerId && layer.type === "image") {
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return layer;
|
||||
|
||||
return {
|
||||
...layer,
|
||||
transform: {
|
||||
...layer.transform,
|
||||
position: { x: bounds.x, y: bounds.y },
|
||||
scale: {
|
||||
x: bounds.w / asset.intrinsicSize.w,
|
||||
y: bounds.h / asset.intrinsicSize.h,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (layer.type === "group") return { ...layer, children: applyLayerBoundsInTree(document, layer.children, layerId, bounds) };
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
|
||||
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: 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": {
|
||||
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 };
|
||||
}
|
||||
21
editor/transform.ts
Normal file
21
editor/transform.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
|
||||
export type TransformHandle = "body" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||
|
||||
export type TransformTarget =
|
||||
| {
|
||||
type: "artboard";
|
||||
id: ArtboardId;
|
||||
}
|
||||
| {
|
||||
type: "layer";
|
||||
id: LayerId;
|
||||
};
|
||||
|
||||
export type TransformSession = {
|
||||
target: TransformTarget;
|
||||
handle: TransformHandle;
|
||||
startPoint: Vec2D;
|
||||
initialBounds: Rect;
|
||||
};
|
||||
@@ -5,6 +5,9 @@ export {
|
||||
} from "./dom";
|
||||
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
||||
export { handleKeybind, keybindFromEvent } from "./keyboard";
|
||||
export { handleArtboardSelection } from "./selection";
|
||||
export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
||||
export type { TransformControlsInputController } from "./transform-controls";
|
||||
export type { ViewportPanInputController } from "./viewport-pan";
|
||||
export { createViewportPanInputController } from "./viewport-pan";
|
||||
export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer";
|
||||
|
||||
117
input/selection.test.ts
Normal file
117
input/selection.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { handleArtboardSelection } from "./selection";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
|
||||
const ignoredState = undefined as never;
|
||||
|
||||
describe("selection input", () => {
|
||||
test("selects artboard hit by left click", () => {
|
||||
const state = {
|
||||
...createInitialAppState("Test"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||
},
|
||||
};
|
||||
const dispatched: unknown[] = [];
|
||||
|
||||
const consumed = handleArtboardSelection({
|
||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||
document: state.document,
|
||||
viewport: state.editor.viewport,
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
return ignoredState;
|
||||
},
|
||||
});
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: [] } }]);
|
||||
});
|
||||
|
||||
test("selects topmost image layer before artboard", () => {
|
||||
const state = {
|
||||
...createInitialAppState("Test"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
|
||||
artboards: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "Artboard",
|
||||
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
||||
backgroundColor: "transparent",
|
||||
layers: [
|
||||
{
|
||||
id: "l1",
|
||||
type: "image",
|
||||
name: "Image",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: "asset-1",
|
||||
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||
},
|
||||
};
|
||||
const dispatched: unknown[] = [];
|
||||
|
||||
const consumed = handleArtboardSelection({
|
||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||
document: state.document,
|
||||
viewport: state.editor.viewport,
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
return ignoredState;
|
||||
},
|
||||
});
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["l1"] } }]);
|
||||
});
|
||||
|
||||
test("clears selection when clicking outside artboards", () => {
|
||||
const state = createInitialAppState("Test");
|
||||
const dispatched: unknown[] = [];
|
||||
|
||||
const consumed = handleArtboardSelection({
|
||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||
document: state.document,
|
||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
return ignoredState;
|
||||
},
|
||||
});
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(dispatched).toEqual([{ commandId: commandIds.selectionClear, payload: undefined }]);
|
||||
});
|
||||
});
|
||||
|
||||
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
|
||||
return {
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position: { x: 0, y: 0 },
|
||||
buttons: 0,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
68
input/selection.ts
Normal file
68
input/selection.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Dispatch } from "@commands/dispatcher";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { ViewportState } from "@editor/state";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
|
||||
export function handleArtboardSelection(options: {
|
||||
event: PointerInputEvent;
|
||||
document: ImageDocument;
|
||||
viewport: ViewportState;
|
||||
dispatch: Dispatch;
|
||||
}): boolean {
|
||||
if (options.event.pointerType !== "mouse" || (options.event.buttons & 1) !== 1) return false;
|
||||
|
||||
const point = viewportPointToDocumentPoint(options.event.position, options.viewport);
|
||||
const layerHit = findTopmostLayerAtPoint(options.document, point);
|
||||
if (layerHit) {
|
||||
options.dispatch(commandIds.selectionSet, { artboardId: layerHit.artboardId, layerIds: [layerHit.layerId] });
|
||||
return true;
|
||||
}
|
||||
|
||||
const artboard = [...options.document.artboards].reverse().find((candidate) => {
|
||||
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;
|
||||
});
|
||||
|
||||
if (!artboard) {
|
||||
options.dispatch(commandIds.selectionClear, undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
options.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [] });
|
||||
return true;
|
||||
}
|
||||
|
||||
function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) {
|
||||
for (const artboard of [...document.artboards].reverse()) {
|
||||
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point);
|
||||
if (layerId) return { artboardId: artboard.id, layerId };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }): string | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "group") {
|
||||
const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point);
|
||||
if (childId) return childId;
|
||||
}
|
||||
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
if (bounds && point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h) {
|
||||
return layer.id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function viewportPointToDocumentPoint(point: PointerInputEvent["position"], viewport: ViewportState) {
|
||||
return {
|
||||
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
|
||||
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
|
||||
};
|
||||
}
|
||||
66
input/transform-controls.test.ts
Normal file
66
input/transform-controls.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
import { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
||||
|
||||
const ignoredState = undefined as never;
|
||||
|
||||
describe("transform controls input", () => {
|
||||
test("hit tests artboard handles and body", () => {
|
||||
const viewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } };
|
||||
const bounds = { x: -50, y: -50, w: 100, h: 100 };
|
||||
|
||||
expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, viewport)).toBe("nw");
|
||||
expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, viewport)).toBe("body");
|
||||
expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, viewport)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("dispatches transform lifecycle for selected artboard", () => {
|
||||
let state = {
|
||||
...createInitialAppState("Test"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||
selection: { artboardId: "a1", layerIds: [] },
|
||||
},
|
||||
};
|
||||
const dispatched: unknown[] = [];
|
||||
const controller = createTransformControlsInputController({
|
||||
getDocument: () => state.document,
|
||||
getEditor: () => state.editor,
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
if (commandId === commandIds.transformBegin) state = { ...state, editor: { ...state.editor, transformSession: payload as never } };
|
||||
return ignoredState;
|
||||
},
|
||||
});
|
||||
|
||||
expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(true);
|
||||
expect(controller.pointerMove(pointerEvent({ position: { x: 110, y: 120 }, buttons: 1 }))).toBe(true);
|
||||
expect(controller.pointerUp(pointerEvent({ position: { x: 110, y: 120 }, buttons: 0 }))).toBe(true);
|
||||
expect(dispatched.map((event) => (event as { commandId: string }).commandId)).toEqual([
|
||||
commandIds.transformBegin,
|
||||
commandIds.transformUpdate,
|
||||
commandIds.transformEnd,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
|
||||
return {
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position: { x: 0, y: 0 },
|
||||
buttons: 0,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
106
input/transform-controls.ts
Normal file
106
input/transform-controls.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Dispatch } from "@commands/dispatcher";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||
import type { TransformHandle } from "@editor/transform";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
|
||||
export type TransformControlsInputController = {
|
||||
pointerDown(event: PointerInputEvent): boolean;
|
||||
pointerMove(event: PointerInputEvent): boolean;
|
||||
pointerUp(event: PointerInputEvent): boolean;
|
||||
};
|
||||
|
||||
export function createTransformControlsInputController(options: {
|
||||
getDocument: () => ImageDocument;
|
||||
getEditor: () => EditorState;
|
||||
dispatch: Dispatch;
|
||||
}): TransformControlsInputController {
|
||||
return {
|
||||
pointerDown(event) {
|
||||
if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false;
|
||||
|
||||
const editor = options.getEditor();
|
||||
if (editor.tools.activeTool !== "select") return false;
|
||||
|
||||
const document = options.getDocument();
|
||||
const target = selectedTransformTarget(document, editor.selection);
|
||||
if (!target) return false;
|
||||
|
||||
const bounds = resolveTransformTargetBounds(document, target);
|
||||
if (!bounds) return false;
|
||||
|
||||
const handle = hitTestArtboardTransformHandle(event.position, bounds, editor.viewport);
|
||||
if (!handle) return false;
|
||||
|
||||
options.dispatch(commandIds.transformBegin, {
|
||||
target,
|
||||
handle,
|
||||
point: viewportPointToDocumentPoint(event.position, editor.viewport),
|
||||
initialBounds: bounds,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pointerMove(event) {
|
||||
const editor = options.getEditor();
|
||||
if (!editor.transformSession) return false;
|
||||
|
||||
options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport) });
|
||||
return true;
|
||||
},
|
||||
pointerUp() {
|
||||
if (!options.getEditor().transformSession) return false;
|
||||
|
||||
options.dispatch(commandIds.transformEnd, undefined);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, viewport: EditorState["viewport"]): TransformHandle | undefined {
|
||||
const rect = documentRectToViewportRect(bounds, viewport);
|
||||
const handles = transformHandleRects(rect);
|
||||
const handle = handles.find((candidate) => pointInRect(position, candidate.rect));
|
||||
if (handle) return handle.handle;
|
||||
if (pointInRect(position, rect)) return "body";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect }[] {
|
||||
const size = 12;
|
||||
const half = size / 2;
|
||||
const points: { handle: TransformHandle; point: Vec2D }[] = [
|
||||
{ handle: "nw", point: { x: rect.x, y: rect.y } },
|
||||
{ handle: "n", point: { x: rect.x + rect.w / 2, y: rect.y } },
|
||||
{ handle: "ne", point: { x: rect.x + rect.w, y: rect.y } },
|
||||
{ handle: "e", point: { x: rect.x + rect.w, y: rect.y + rect.h / 2 } },
|
||||
{ handle: "se", point: { x: rect.x + rect.w, y: rect.y + rect.h } },
|
||||
{ handle: "s", point: { x: rect.x + rect.w / 2, y: rect.y + rect.h } },
|
||||
{ handle: "sw", point: { x: rect.x, y: rect.y + rect.h } },
|
||||
{ handle: "w", point: { x: rect.x, y: rect.y + rect.h / 2 } },
|
||||
];
|
||||
|
||||
return points.map(({ handle, point }) => ({ handle, rect: { x: point.x - half, y: point.y - half, w: size, h: size } }));
|
||||
}
|
||||
|
||||
function viewportPointToDocumentPoint(point: Vec2D, viewport: EditorState["viewport"]): Vec2D {
|
||||
return {
|
||||
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
|
||||
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
function documentRectToViewportRect(rect: Rect, viewport: EditorState["viewport"]): Rect {
|
||||
return {
|
||||
x: viewport.size.w / 2 + (rect.x - viewport.center.x) * viewport.zoom,
|
||||
y: viewport.size.h / 2 + (rect.y - viewport.center.y) * viewport.zoom,
|
||||
w: rect.w * viewport.zoom,
|
||||
h: rect.h * viewport.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
function pointInRect(point: Vec2D, rect: Rect) {
|
||||
return point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h;
|
||||
}
|
||||
81
input/viewport-pan-store.test.ts
Normal file
81
input/viewport-pan-store.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { toolCommands } from "@commands/tool";
|
||||
import { viewportCommands } from "@commands/viewport";
|
||||
import { createCommandRegistry } from "@commands/registry";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { createAppStore } from "@editor/store";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
import { createViewportPanInputController } from "./viewport-pan";
|
||||
|
||||
const registry = createCommandRegistry([...toolCommands, ...viewportCommands]);
|
||||
|
||||
describe("viewport pan store integration", () => {
|
||||
test("space key input updates actual store tool mode", () => {
|
||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
||||
const controller = createController(store);
|
||||
|
||||
expect(controller.keyDown(keyEvent("Space"))).toBe(true);
|
||||
expect(store.getState().editor.tools.interactionMode).toEqual({ type: "temporary-pan", previousTool: "select" });
|
||||
|
||||
expect(controller.keyUp(keyEvent("Space"))).toBe(true);
|
||||
expect(store.getState().editor.tools.interactionMode).toEqual({ type: "tool", tool: "select" });
|
||||
});
|
||||
|
||||
test("left-drag only pans while temporary pan is active", () => {
|
||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
||||
const controller = createController(store);
|
||||
|
||||
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(false);
|
||||
expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 10, y: 0 } }))).toBe(false);
|
||||
expect(store.getState().editor.viewport.center).toEqual({ x: 0, y: 0 });
|
||||
|
||||
store.dispatch(commandIds.toolEnterTemporaryPan, undefined);
|
||||
|
||||
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true);
|
||||
expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 10, y: 0 } }))).toBe(true);
|
||||
expect(store.getState().editor.viewport.center).toEqual({ x: -10, y: 0 });
|
||||
});
|
||||
|
||||
test("left-drag pans while pan tool is active", () => {
|
||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
||||
const controller = createController(store);
|
||||
|
||||
store.dispatch(commandIds.toolSetActive, { tool: "pan" });
|
||||
|
||||
expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true);
|
||||
expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 4, y: -2 } }))).toBe(true);
|
||||
expect(store.getState().editor.viewport.center).toEqual({ x: -4, y: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
function createController(store: ReturnType<typeof createAppStore>) {
|
||||
return createViewportPanInputController({
|
||||
globalKeyConsumer: () => false,
|
||||
globalPointerConsumer: () => false,
|
||||
getCurrentZoom: () => store.getState().editor.viewport.zoom,
|
||||
isPanMode: () => {
|
||||
const mode = store.getState().editor.tools.interactionMode;
|
||||
return mode.type === "temporary-pan" || (mode.type === "tool" && mode.tool === "pan");
|
||||
},
|
||||
dispatch: store.dispatch,
|
||||
});
|
||||
}
|
||||
|
||||
function keyEvent(code: string) {
|
||||
return { key: code === "Space" ? " " : code, code, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false };
|
||||
}
|
||||
|
||||
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
|
||||
return {
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position: { x: 0, y: 0 },
|
||||
buttons: 0,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { createViewportPanInputController } from "./viewport-pan";
|
||||
|
||||
const ignoredState = undefined as never;
|
||||
@@ -20,8 +21,8 @@ describe("viewport pan input controller", () => {
|
||||
expect(controller.keyDown(keyEvent("Space"))).toBe(true);
|
||||
expect(controller.keyUp(keyEvent("Space"))).toBe(true);
|
||||
expect(dispatched).toEqual([
|
||||
{ commandId: "tool.enterTemporaryPan", payload: undefined },
|
||||
{ commandId: "tool.exitTemporaryPan", payload: undefined },
|
||||
{ commandId: commandIds.toolEnterTemporaryPan, payload: undefined },
|
||||
{ commandId: commandIds.toolExitTemporaryPan, payload: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Dispatch } from "@commands/dispatcher";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { GlobalKeybindConsumer, KeybindEvent } from "./keyboard";
|
||||
import type { GlobalPointerConsumer, PointerInputEvent } from "./pointer";
|
||||
import { createViewportPointerPanHandler } from "./viewport";
|
||||
@@ -31,13 +32,13 @@ export function createViewportPanInputController(options: {
|
||||
if (options.globalKeyConsumer(event)) return true;
|
||||
if (event.code !== "Space") return false;
|
||||
|
||||
options.dispatch("tool.enterTemporaryPan", undefined);
|
||||
options.dispatch(commandIds.toolEnterTemporaryPan, undefined);
|
||||
return true;
|
||||
},
|
||||
keyUp(event) {
|
||||
if (event.code !== "Space") return false;
|
||||
|
||||
options.dispatch("tool.exitTemporaryPan", undefined);
|
||||
options.dispatch(commandIds.toolExitTemporaryPan, undefined);
|
||||
return true;
|
||||
},
|
||||
pointerDown: pointerPan.pointerDown,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { createViewportPointerPanHandler, handleViewportWheel } from "./viewport";
|
||||
|
||||
const ignoredState = undefined as never;
|
||||
@@ -17,7 +18,7 @@ describe("viewport input", () => {
|
||||
});
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(dispatched[0]).toEqual({ commandId: "viewport.zoomAroundPoint", payload: { zoom: Math.exp(0.1), point: { x: 10, y: 20 } } });
|
||||
expect(dispatched[0]).toEqual({ commandId: commandIds.viewportZoomAroundPoint, payload: { zoom: Math.exp(0.1), point: { x: 10, y: 20 } } });
|
||||
});
|
||||
|
||||
test("global wheel consumer prevents command dispatch", () => {
|
||||
@@ -49,7 +50,7 @@ describe("viewport input", () => {
|
||||
|
||||
expect(pan.pointerDown(basePointer({ buttons: 4, position: { x: 10, y: 10 } }))).toBe(true);
|
||||
expect(pan.pointerMove(basePointer({ buttons: 4, position: { x: 14, y: 6 } }))).toBe(true);
|
||||
expect(dispatched[0]).toEqual({ commandId: "viewport.pan", payload: { delta: { x: -2, y: 2 } } });
|
||||
expect(dispatched[0]).toEqual({ commandId: commandIds.viewportPan, payload: { delta: { x: -2, y: 2 } } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Dispatch } from "@commands/dispatcher";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer";
|
||||
|
||||
export type ViewportPointerPanHandler = {
|
||||
@@ -37,7 +38,7 @@ export function createViewportPointerPanHandler(options: {
|
||||
};
|
||||
|
||||
lastPosition = event.position;
|
||||
options.dispatch("viewport.pan", {
|
||||
options.dispatch(commandIds.viewportPan, {
|
||||
delta: {
|
||||
x: -screenDelta.x / zoom,
|
||||
y: -screenDelta.y / zoom,
|
||||
@@ -69,7 +70,7 @@ export function handleViewportWheel(options: {
|
||||
if (options.globalConsumer(options.event)) return true;
|
||||
|
||||
const zoomFactor = Math.exp(-options.event.delta.y * 0.001);
|
||||
options.dispatch("viewport.zoomAroundPoint", {
|
||||
options.dispatch(commandIds.viewportZoomAroundPoint, {
|
||||
zoom: options.currentZoom * zoomFactor,
|
||||
point: options.event.position,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import type { Artboard } from "@core/artboard";
|
||||
import type { ViewportState } from "@editor/state";
|
||||
import { renderCheckerboard } from "./checkerboard";
|
||||
import type { ScreenRect, WebGlRendererContext } from "./types";
|
||||
import { documentRectToScreenRect } from "./screen-rect";
|
||||
import type { WebGlRendererContext } from "./types";
|
||||
|
||||
export function renderArtboard(context: WebGlRendererContext, artboard: Artboard, viewport: ViewportState) {
|
||||
const rect = artboardScreenRect(context.canvas, artboard, viewport);
|
||||
const rect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport);
|
||||
|
||||
if (artboard.backgroundColor === "transparent") {
|
||||
renderCheckerboard(context, rect, Math.max(4, Math.round(12 * viewport.zoom)));
|
||||
}
|
||||
}
|
||||
|
||||
function artboardScreenRect(canvas: HTMLCanvasElement, artboard: Artboard, viewport: ViewportState): ScreenRect {
|
||||
return {
|
||||
x: Math.round(canvas.width / 2 + (artboard.bounds.x - viewport.center.x) * viewport.zoom),
|
||||
y: Math.round(canvas.height / 2 + (artboard.bounds.y - viewport.center.y) * viewport.zoom),
|
||||
w: Math.max(0, Math.round(artboard.bounds.w * viewport.zoom)),
|
||||
h: Math.max(0, Math.round(artboard.bounds.h * viewport.zoom)),
|
||||
};
|
||||
}
|
||||
|
||||
173
renderer/image-textures.ts
Normal file
173
renderer/image-textures.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { ScreenRect, WebGlRendererContext } from "./types";
|
||||
|
||||
export type ImageTextureRenderer = {
|
||||
render(asset: Asset, rect: ScreenRect): boolean;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
type TextureEntry =
|
||||
| { status: "loading"; image: HTMLImageElement }
|
||||
| { status: "ready"; texture: WebGLTexture }
|
||||
| { status: "error" };
|
||||
|
||||
export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer {
|
||||
const { gl } = context;
|
||||
const program = createProgram(gl);
|
||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||
const texCoordLocation = gl.getAttribLocation(program, "a_texCoord");
|
||||
const samplerLocation = gl.getUniformLocation(program, "u_image");
|
||||
const positionBuffer = gl.createBuffer();
|
||||
const texCoordBuffer = gl.createBuffer();
|
||||
const textures = new Map<string, TextureEntry>();
|
||||
|
||||
if (!positionBuffer || !texCoordBuffer || !samplerLocation) throw new Error("Failed to create image texture renderer");
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW);
|
||||
|
||||
return {
|
||||
render(asset, rect) {
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate);
|
||||
if (entry.status !== "ready") return false;
|
||||
|
||||
gl.disable(gl.SCISSOR_TEST);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.useProgram(program);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||
gl.uniform1i(samplerLocation, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.enableVertexAttribArray(texCoordLocation);
|
||||
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.disable(gl.BLEND);
|
||||
gl.enable(gl.SCISSOR_TEST);
|
||||
return true;
|
||||
},
|
||||
dispose() {
|
||||
for (const entry of textures.values()) {
|
||||
if (entry.status === "ready") gl.deleteTexture(entry.texture);
|
||||
}
|
||||
gl.deleteBuffer(positionBuffer);
|
||||
gl.deleteBuffer(texCoordBuffer);
|
||||
gl.deleteProgram(program);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getTextureEntry(
|
||||
context: WebGlRendererContext,
|
||||
textures: Map<string, TextureEntry>,
|
||||
asset: Asset,
|
||||
invalidate: () => void,
|
||||
): TextureEntry {
|
||||
const cached = textures.get(asset.id);
|
||||
if (cached) return cached;
|
||||
|
||||
const image = new Image();
|
||||
textures.set(asset.id, { status: "loading", image });
|
||||
image.onload = () => {
|
||||
const texture = createTexture(context.gl, image);
|
||||
textures.set(asset.id, { status: "ready", texture });
|
||||
invalidate();
|
||||
};
|
||||
image.onerror = () => {
|
||||
textures.set(asset.id, { status: "error" });
|
||||
invalidate();
|
||||
};
|
||||
image.src = asset.source;
|
||||
|
||||
return { status: "loading", image };
|
||||
}
|
||||
|
||||
function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) {
|
||||
const texture = gl.createTexture();
|
||||
if (!texture) throw new Error("Failed to create image texture");
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) {
|
||||
const x1 = (rect.x / canvas.width) * 2 - 1;
|
||||
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
|
||||
const y1 = 1 - (rect.y / canvas.height) * 2;
|
||||
const y2 = 1 - ((rect.y + rect.h) / canvas.height) * 2;
|
||||
|
||||
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||
}
|
||||
|
||||
function createProgram(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;
|
||||
in vec2 v_texCoord;
|
||||
out vec4 outColor;
|
||||
void main() {
|
||||
outColor = texture(u_image, v_texCoord);
|
||||
}`,
|
||||
);
|
||||
const program = gl.createProgram();
|
||||
if (!program) throw new Error("Failed to create 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 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;
|
||||
}
|
||||
36
renderer/layers.ts
Normal file
36
renderer/layers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { ViewportState } from "@editor/state";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import { clearScreenRect } from "./clear-rect";
|
||||
import type { ImageTextureRenderer } from "./image-textures";
|
||||
import { documentRectToScreenRect } from "./screen-rect";
|
||||
import type { RgbaColor, WebGlRendererContext } from "./types";
|
||||
|
||||
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
|
||||
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
|
||||
|
||||
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, imageTextureRenderer: ImageTextureRenderer) {
|
||||
for (const artboard of document.artboards) {
|
||||
for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLayer(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, layer: Layer, imageTextureRenderer: ImageTextureRenderer) {
|
||||
if (!layer.visible) return;
|
||||
|
||||
if (layer.type === "group") {
|
||||
for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
if (!bounds) return;
|
||||
|
||||
const rect = documentRectToScreenRect(context.canvas, bounds, viewport);
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (asset && imageTextureRenderer.render(asset, rect)) return;
|
||||
|
||||
clearScreenRect(context, rect, imageLayerColor);
|
||||
clearScreenRect(context, { x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, imageLayerInsetColor);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { renderArtboard } from "./artboard";
|
||||
import { createImageTextureRenderer } from "./image-textures";
|
||||
import { renderLayers } from "./layers";
|
||||
import { renderSelectionOverlay } from "./selection";
|
||||
import { renderTransformControls } from "./transform-controls";
|
||||
import type { WebGlRendererContext } from "./types";
|
||||
|
||||
export type RenderFrame = {
|
||||
@@ -26,9 +30,20 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
}
|
||||
|
||||
const rendererContext: WebGlRendererContext = { gl: context, canvas };
|
||||
let lastFrame: RenderFrame | undefined;
|
||||
let rerenderQueued = false;
|
||||
const imageTextureRenderer = createImageTextureRenderer(rendererContext, () => {
|
||||
if (!lastFrame || rerenderQueued) return;
|
||||
rerenderQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
rerenderQueued = false;
|
||||
if (lastFrame) renderer.render(lastFrame);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
const renderer: ImageRenderer = {
|
||||
render(frame) {
|
||||
lastFrame = frame;
|
||||
const { w, h } = frame.editor.viewport.size;
|
||||
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
@@ -43,9 +58,17 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
for (const artboard of frame.document.artboards) {
|
||||
renderArtboard(rendererContext, artboard, frame.editor.viewport);
|
||||
}
|
||||
renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer);
|
||||
|
||||
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
|
||||
renderTransformControls(rendererContext, frame.document, frame.editor);
|
||||
|
||||
context.disable(context.SCISSOR_TEST);
|
||||
},
|
||||
dispose() {},
|
||||
dispose() {
|
||||
imageTextureRenderer.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
return renderer;
|
||||
}
|
||||
|
||||
12
renderer/screen-rect.ts
Normal file
12
renderer/screen-rect.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { ViewportState } from "@editor/state";
|
||||
import type { ScreenRect } from "./types";
|
||||
|
||||
export function documentRectToScreenRect(canvas: HTMLCanvasElement, rect: Rect, viewport: ViewportState): ScreenRect {
|
||||
return {
|
||||
x: Math.round(canvas.width / 2 + (rect.x - viewport.center.x) * viewport.zoom),
|
||||
y: Math.round(canvas.height / 2 + (rect.y - viewport.center.y) * viewport.zoom),
|
||||
w: Math.max(0, Math.round(rect.w * viewport.zoom)),
|
||||
h: Math.max(0, Math.round(rect.h * viewport.zoom)),
|
||||
};
|
||||
}
|
||||
24
renderer/selection.ts
Normal file
24
renderer/selection.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||
import { clearScreenRect } from "./clear-rect";
|
||||
import { documentRectToScreenRect } from "./screen-rect";
|
||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||
|
||||
const selectionColor: RgbaColor = [0.1, 0.65, 1, 1];
|
||||
|
||||
export function renderSelectionOverlay(context: WebGlRendererContext, document: ImageDocument, editor: EditorState) {
|
||||
const target = selectedTransformTarget(document, editor.selection);
|
||||
const bounds = target ? resolveTransformTargetBounds(document, target) : undefined;
|
||||
if (!bounds) return;
|
||||
|
||||
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
||||
renderScreenRectStroke(context, rect, 3, selectionColor);
|
||||
}
|
||||
|
||||
function renderScreenRectStroke(context: WebGlRendererContext, rect: ScreenRect, width: number, color: RgbaColor) {
|
||||
clearScreenRect(context, { x: rect.x - width, y: rect.y - width, w: rect.w + width * 2, h: width }, color);
|
||||
clearScreenRect(context, { x: rect.x - width, y: rect.y + rect.h, w: rect.w + width * 2, h: width }, color);
|
||||
clearScreenRect(context, { x: rect.x - width, y: rect.y, w: width, h: rect.h }, color);
|
||||
clearScreenRect(context, { x: rect.x + rect.w, y: rect.y, w: width, h: rect.h }, color);
|
||||
}
|
||||
42
renderer/transform-controls.ts
Normal file
42
renderer/transform-controls.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||
import { clearScreenRect } from "./clear-rect";
|
||||
import { documentRectToScreenRect } from "./screen-rect";
|
||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||
|
||||
const handleColor: RgbaColor = [1, 1, 1, 1];
|
||||
const handleBorderColor: RgbaColor = [0.1, 0.65, 1, 1];
|
||||
|
||||
export function renderTransformControls(context: WebGlRendererContext, document: ImageDocument, editor: EditorState) {
|
||||
const target = selectedTransformTarget(document, editor.selection);
|
||||
const bounds = target ? resolveTransformTargetBounds(document, target) : undefined;
|
||||
if (!bounds) return;
|
||||
|
||||
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
||||
for (const handle of transformHandleRects(rect)) {
|
||||
clearScreenRect(context, handle.border, handleBorderColor);
|
||||
clearScreenRect(context, handle.fill, handleColor);
|
||||
}
|
||||
}
|
||||
|
||||
function transformHandleRects(rect: ScreenRect) {
|
||||
const size = 8;
|
||||
const border = 2;
|
||||
const half = size / 2;
|
||||
const points = [
|
||||
{ x: rect.x, y: rect.y },
|
||||
{ x: rect.x + rect.w / 2, y: rect.y },
|
||||
{ x: rect.x + rect.w, y: rect.y },
|
||||
{ x: rect.x + rect.w, y: rect.y + rect.h / 2 },
|
||||
{ x: rect.x + rect.w, y: rect.y + rect.h },
|
||||
{ x: rect.x + rect.w / 2, y: rect.y + rect.h },
|
||||
{ x: rect.x, y: rect.y + rect.h },
|
||||
{ x: rect.x, y: rect.y + rect.h / 2 },
|
||||
];
|
||||
|
||||
return points.map((point) => ({
|
||||
border: { x: Math.round(point.x - half - border), y: Math.round(point.y - half - border), w: size + border * 2, h: size + border * 2 },
|
||||
fill: { x: Math.round(point.x - half), y: Math.round(point.y - half), w: size, h: size },
|
||||
}));
|
||||
}
|
||||
41
view/App.tsx
41
view/App.tsx
@@ -1,6 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import type { ImageStudioApp } from "@app/app";
|
||||
import { BottomControlsIsland } from "./BottomControlsIsland";
|
||||
import { CanvasViewport } from "./CanvasViewport";
|
||||
import { LayersSheet } from "./LayersSheet";
|
||||
import { ToolOverlay } from "./ToolOverlay";
|
||||
import { labelForTool } from "./toolLabels";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import { getSelectionSummary } from "./selectionSummary";
|
||||
import { useAppState } from "./useAppState";
|
||||
import { useImageImport } from "./useImageImport";
|
||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||
import "./index.css";
|
||||
|
||||
export type AppProps = {
|
||||
@@ -10,15 +19,43 @@ export type AppProps = {
|
||||
export function App({ app }: AppProps) {
|
||||
const state = useAppState(app.store);
|
||||
const zoomPercent = Math.round(state.editor.viewport.zoom * 100);
|
||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
||||
const imageImport = useImageImport(app.store);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const selectionSummary = getSelectionSummary(state.document, state.editor.selection);
|
||||
const transformBounds = state.editor.transformSession
|
||||
? resolveTransformTargetBounds(state.document, state.editor.transformSession.target)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<main className="relative h-full bg-background text-foreground">
|
||||
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
||||
{imageImport.input}
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
|
||||
<h1 className="text-sm font-medium">Image Studio</h1>
|
||||
<div className="text-xs">
|
||||
{state.document.name} · {zoomPercent}% · {state.editor.viewport.size.w}×{state.editor.viewport.size.h}
|
||||
{state.document.name} · {labelForTool(state.editor.tools.activeTool)} · {zoomPercent}% · {state.editor.viewport.size.w}×
|
||||
{state.editor.viewport.size.h}
|
||||
</div>
|
||||
</header>
|
||||
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||
<ToolOverlay
|
||||
activeTool={state.editor.tools.activeTool}
|
||||
interactionMode={state.editor.tools.interactionMode}
|
||||
dispatch={app.store.dispatch}
|
||||
/>
|
||||
</div>
|
||||
<LayersSheet document={state.document} selection={state.editor.selection} open={layersOpen} onClose={() => setLayersOpen(false)} />
|
||||
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
||||
<BottomControlsIsland
|
||||
viewport={state.editor.viewport}
|
||||
visible={Boolean(transformBounds) || selectionSummary.type !== "none" || viewportActivityIsland.visible}
|
||||
action={viewportActivityIsland.action}
|
||||
selection={selectionSummary}
|
||||
transformBounds={transformBounds}
|
||||
dispatch={app.store.dispatch}
|
||||
onOpenLayers={() => setLayersOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<CanvasViewport store={app.store} />
|
||||
</main>
|
||||
);
|
||||
|
||||
45
view/BottomControlsIsland.tsx
Normal file
45
view/BottomControlsIsland.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { ViewportState } from "@editor/state";
|
||||
import { PanControls } from "./bottom-controls/PanControls";
|
||||
import { SelectionControls } from "./bottom-controls/SelectionControls";
|
||||
import { TransformControls } from "./bottom-controls/TransformControls";
|
||||
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { SelectionSummary } from "./selectionSummary";
|
||||
|
||||
export type BottomControlsAction = "pan" | "zoom";
|
||||
|
||||
export type BottomControlsIslandProps = {
|
||||
viewport: ViewportState;
|
||||
visible: boolean;
|
||||
action: BottomControlsAction;
|
||||
selection: SelectionSummary;
|
||||
transformBounds?: Rect;
|
||||
dispatch: AppStore["dispatch"];
|
||||
onOpenLayers: () => void;
|
||||
};
|
||||
|
||||
export function BottomControlsIsland({ viewport, visible, action, selection, transformBounds, dispatch, onOpenLayers }: BottomControlsIslandProps) {
|
||||
const zoomPercent = Math.round(viewport.zoom * 100);
|
||||
const x = Math.round(viewport.center.x);
|
||||
const y = Math.round(viewport.center.y);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!visible}
|
||||
className={`flex h-10 min-w-48 items-center justify-center gap-2 rounded-full border border-white/10 bg-black/70 px-2 py-1 text-xs text-white shadow-xl backdrop-blur transition-all duration-200 ${
|
||||
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{transformBounds ? (
|
||||
<TransformControls bounds={transformBounds} />
|
||||
) : selection.type !== "none" ? (
|
||||
<SelectionControls selection={selection} onOpenLayers={onOpenLayers} />
|
||||
) : action === "pan" ? (
|
||||
<PanControls x={x} y={y} />
|
||||
) : (
|
||||
<ZoomControls zoom={viewport.zoom} zoomPercent={zoomPercent} dispatch={dispatch} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
view/LayersSheet.tsx
Normal file
63
view/LayersSheet.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Eye, EyeSlash, Lock, LockOpen, X } from "@phosphor-icons/react";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
|
||||
export type LayersSheetProps = {
|
||||
document: ImageDocument;
|
||||
selection: SelectionState;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function LayersSheet({ document, selection, open, onClose }: LayersSheetProps) {
|
||||
return (
|
||||
<aside
|
||||
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 ${
|
||||
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">
|
||||
<span className="font-medium">Layers</span>
|
||||
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
|
||||
<X size={16} weight="regular" />
|
||||
</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 className="mt-1 space-y-1 pl-2">
|
||||
{artboard.layers.length === 0 ? (
|
||||
<div className="px-2 py-1 text-white/40">No layers</div>
|
||||
) : (
|
||||
artboard.layers.map((layer) => <LayerRow key={layer.id} layer={layer} depth={0} selectedLayerIds={selection.layerIds} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerRow({ layer, depth, selectedLayerIds }: { layer: Layer; depth: number; selectedLayerIds: string[] }) {
|
||||
const selected = selectedLayerIds.includes(layer.id);
|
||||
return (
|
||||
<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 }}>
|
||||
{layer.visible ? <Eye size={14} weight="regular" /> : <EyeSlash size={14} weight="regular" />}
|
||||
{layer.locked ? <Lock size={14} weight="regular" /> : <LockOpen size={14} weight="regular" />}
|
||||
<span className="min-w-0 flex-1 truncate">{layer.name}</span>
|
||||
</div>
|
||||
{layer.type === "group" ? layer.children.map((child) => <LayerRow key={child.id} layer={child} depth={depth + 1} selectedLayerIds={selectedLayerIds} />) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconButtonClass() {
|
||||
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";
|
||||
}
|
||||
58
view/ToolOverlay.tsx
Normal file
58
view/ToolOverlay.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Cursor, Hand } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { InteractionMode, ToolId } from "@editor/tools";
|
||||
import { availableToolIds } from "@editor/tools";
|
||||
import { labelForTool } from "./toolLabels";
|
||||
|
||||
export type ToolOverlayProps = {
|
||||
activeTool: ToolId;
|
||||
interactionMode: InteractionMode;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
|
||||
export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverlayProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Tools"
|
||||
className="pointer-events-auto flex flex-col gap-1 rounded-lg border border-white/10 bg-black/70 p-1 text-white shadow-xl backdrop-blur"
|
||||
>
|
||||
{availableToolIds.map((tool) => {
|
||||
const active = isToolHighlighted(tool, activeTool, interactionMode);
|
||||
const Icon = iconForTool(tool);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tool}
|
||||
type="button"
|
||||
aria-label={labelForTool(tool)}
|
||||
aria-pressed={active}
|
||||
title={labelForTool(tool)}
|
||||
className={buttonClass(active)}
|
||||
onClick={() => dispatch(commandIds.toolSetActive, { tool })}
|
||||
>
|
||||
<Icon size={22} weight={active ? "fill" : "regular"} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForTool(tool: ToolId) {
|
||||
switch (tool) {
|
||||
case "pan":
|
||||
return Hand;
|
||||
case "select":
|
||||
return Cursor;
|
||||
}
|
||||
}
|
||||
|
||||
function isToolHighlighted(tool: ToolId, activeTool: ToolId, interactionMode: InteractionMode) {
|
||||
if (interactionMode.type === "temporary-pan") return tool === "pan";
|
||||
return activeTool === tool;
|
||||
}
|
||||
|
||||
function buttonClass(active: boolean) {
|
||||
return `grid size-9 place-items-center rounded-md transition focus:outline-none focus-visible:outline-none ${active ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`;
|
||||
}
|
||||
5
view/bottom-controls/Divider.tsx
Normal file
5
view/bottom-controls/Divider.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { bottomControlDividerClass } from "./styles";
|
||||
|
||||
export function BottomControlDivider() {
|
||||
return <div className={bottomControlDividerClass()} />;
|
||||
}
|
||||
24
view/bottom-controls/PanControls.tsx
Normal file
24
view/bottom-controls/PanControls.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Hand } from "@phosphor-icons/react";
|
||||
import { BottomControlDivider } from "./Divider";
|
||||
import { bottomControlIconSlotClass, bottomControlLabelClass, bottomControlValueClass } from "./styles";
|
||||
|
||||
export type PanControlsProps = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export function PanControls({ x, y }: PanControlsProps) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
|
||||
<span className={bottomControlIconSlotClass()}>
|
||||
<Hand size={16} weight="regular" />
|
||||
</span>
|
||||
<BottomControlDivider />
|
||||
<span className={bottomControlLabelClass()}>X</span>
|
||||
<span className={bottomControlValueClass()}>{x}</span>
|
||||
<BottomControlDivider />
|
||||
<span className={bottomControlLabelClass()}>Y</span>
|
||||
<span className={bottomControlValueClass()}>{y}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
view/bottom-controls/SelectionControls.tsx
Normal file
36
view/bottom-controls/SelectionControls.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
28
view/bottom-controls/TransformControls.tsx
Normal file
28
view/bottom-controls/TransformControls.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BoundingBox } from "@phosphor-icons/react";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import { BottomControlDivider } from "./Divider";
|
||||
import { bottomControlIconSlotClass, bottomControlLabelClass, bottomControlValueClass } from "./styles";
|
||||
|
||||
export type TransformControlsProps = {
|
||||
bounds: Rect;
|
||||
};
|
||||
|
||||
export function TransformControls({ bounds }: TransformControlsProps) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
|
||||
<span className={bottomControlIconSlotClass()}>
|
||||
<BoundingBox size={16} weight="regular" />
|
||||
</span>
|
||||
<BottomControlDivider />
|
||||
<span className={bottomControlLabelClass()}>X</span>
|
||||
<span className={bottomControlValueClass()}>{Math.round(bounds.x)}</span>
|
||||
<span className={bottomControlLabelClass()}>Y</span>
|
||||
<span className={bottomControlValueClass()}>{Math.round(bounds.y)}</span>
|
||||
<BottomControlDivider />
|
||||
<span className={bottomControlLabelClass()}>W</span>
|
||||
<span className={bottomControlValueClass()}>{Math.round(bounds.w)}</span>
|
||||
<span className={bottomControlLabelClass()}>H</span>
|
||||
<span className={bottomControlValueClass()}>{Math.round(bounds.h)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
view/bottom-controls/ZoomControls.tsx
Normal file
29
view/bottom-controls/ZoomControls.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Minus, CornersOut, Plus } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { BottomControlDivider } from "./Divider";
|
||||
import { bottomControlButtonClass } from "./styles";
|
||||
|
||||
export type ZoomControlsProps = {
|
||||
zoom: number;
|
||||
zoomPercent: number;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
|
||||
export function ZoomControls({ zoom, zoomPercent, dispatch }: ZoomControlsProps) {
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom out" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom / 1.2 })}>
|
||||
<Minus size={16} weight="regular" />
|
||||
</button>
|
||||
<span className="min-w-14 text-center tabular-nums text-white">{zoomPercent}%</span>
|
||||
<button type="button" className={bottomControlButtonClass()} aria-label="Zoom in" onClick={() => dispatch(commandIds.viewportSetZoom, { zoom: zoom * 1.2 })}>
|
||||
<Plus size={16} weight="regular" />
|
||||
</button>
|
||||
<BottomControlDivider />
|
||||
<button type="button" className={bottomControlButtonClass()} aria-label="Fit artboard" title="Fit artboard" onClick={() => dispatch(commandIds.viewportFitArtboard, undefined)}>
|
||||
<CornersOut size={16} weight="regular" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
view/bottom-controls/styles.ts
Normal file
19
view/bottom-controls/styles.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function bottomControlDividerClass() {
|
||||
return "mx-1 h-4 w-px bg-white/15";
|
||||
}
|
||||
|
||||
export function bottomControlLabelClass() {
|
||||
return "text-white/60";
|
||||
}
|
||||
|
||||
export function bottomControlValueClass() {
|
||||
return "min-w-10 text-center text-white";
|
||||
}
|
||||
|
||||
export function bottomControlIconSlotClass() {
|
||||
return "grid size-7 place-items-center rounded-full text-white/80";
|
||||
}
|
||||
|
||||
export function bottomControlButtonClass() {
|
||||
return `${bottomControlIconSlotClass()} transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none`;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { InteractionMode } from "@editor/tools";
|
||||
import { isPanInteractionMode } from "@editor/tools";
|
||||
import type { CanvasInputState } from "./useCanvasInput";
|
||||
|
||||
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
|
||||
if (input.isPanning) return "cursor-grabbing";
|
||||
if (interactionMode.type === "temporary-pan") return "cursor-grab";
|
||||
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
||||
return "cursor-default";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { isPanInteractionMode } from "@editor/tools";
|
||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
||||
import {
|
||||
createTransformControlsInputController,
|
||||
createViewportPanInputController,
|
||||
handleArtboardSelection,
|
||||
handleViewportWheel,
|
||||
keybindEventFromKeyboardEvent,
|
||||
pointerInputEventFromPointerEvent,
|
||||
@@ -30,12 +33,18 @@ export function useCanvasInput(
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const transformHandler = createTransformControlsInputController({
|
||||
getDocument: () => store.getState().document,
|
||||
getEditor: () => store.getState().editor,
|
||||
dispatch: store.dispatch,
|
||||
});
|
||||
|
||||
const panHandler = createViewportPanInputController({
|
||||
globalKeyConsumer: options.globalKeybindConsumer,
|
||||
globalPointerConsumer: options.globalPointerConsumer,
|
||||
dispatch: store.dispatch,
|
||||
getCurrentZoom: () => store.getState().editor.viewport.zoom,
|
||||
isPanMode: () => store.getState().editor.tools.interactionMode.type === "temporary-pan",
|
||||
isPanMode: () => isPanInteractionMode(store.getState().editor.tools.interactionMode),
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -49,21 +58,53 @@ export function useCanvasInput(
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const consumed = panHandler.pointerDown(pointerInputEventFromPointerEvent(event));
|
||||
if (!consumed) return;
|
||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||
const transformed = transformHandler.pointerDown(inputEvent);
|
||||
if (transformed) {
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
setIsPanning(true);
|
||||
event.preventDefault();
|
||||
const consumed = panHandler.pointerDown(inputEvent);
|
||||
if (consumed) {
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
setIsPanning(true);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const selected = state.editor.tools.activeTool === "select" && handleArtboardSelection({
|
||||
event: inputEvent,
|
||||
document: state.document,
|
||||
viewport: state.editor.viewport,
|
||||
dispatch: store.dispatch,
|
||||
});
|
||||
if (selected) event.preventDefault();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const consumed = panHandler.pointerMove(pointerInputEventFromPointerEvent(event));
|
||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||
const transformed = transformHandler.pointerMove(inputEvent);
|
||||
if (transformed) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const consumed = panHandler.pointerMove(inputEvent);
|
||||
if (consumed) event.preventDefault();
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
const consumed = panHandler.pointerUp(pointerInputEventFromPointerEvent(event));
|
||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||
const transformed = transformHandler.pointerUp(inputEvent);
|
||||
if (transformed) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const consumed = panHandler.pointerUp(inputEvent);
|
||||
if (!consumed) return;
|
||||
|
||||
setIsPanning(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import type { Dispatch } from "@commands/dispatcher";
|
||||
import { commandIds } from "@commands/ids";
|
||||
|
||||
export function useCanvasResize(canvasRef: RefObject<HTMLCanvasElement | null>, dispatch: Dispatch) {
|
||||
useEffect(() => {
|
||||
@@ -11,7 +12,7 @@ export function useCanvasResize(canvasRef: RefObject<HTMLCanvasElement | null>,
|
||||
|
||||
const width = Math.floor(entry.contentRect.width);
|
||||
const height = Math.floor(entry.contentRect.height);
|
||||
dispatch("viewport.setSize", { w: width, h: height });
|
||||
dispatch(commandIds.viewportSetSize, { w: width, h: height });
|
||||
});
|
||||
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
43
view/selectionSummary.ts
Normal file
43
view/selectionSummary.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
}
|
||||
5
view/toolLabels.ts
Normal file
5
view/toolLabels.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ToolId } from "@editor/tools";
|
||||
|
||||
export function labelForTool(tool: ToolId): string {
|
||||
return tool === "pan" ? "Pan" : "Select";
|
||||
}
|
||||
108
view/useImageImport.tsx
Normal file
108
view/useImageImport.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { AppStore } from "@editor/store";
|
||||
|
||||
export function useImageImport(store: AppStore) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const importFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!file.type.startsWith("image/")) return;
|
||||
|
||||
const source = URL.createObjectURL(file);
|
||||
const intrinsicSize = await loadImageSize(source);
|
||||
const state = store.getState();
|
||||
const artboard = state.editor.selection.artboardId
|
||||
? state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId)
|
||||
: state.document.artboards[0];
|
||||
|
||||
if (!artboard) {
|
||||
URL.revokeObjectURL(source);
|
||||
return;
|
||||
}
|
||||
|
||||
const assetId = crypto.randomUUID();
|
||||
const layerId = crypto.randomUUID();
|
||||
const center = state.editor.viewport.center;
|
||||
|
||||
store.dispatch(commandIds.documentAddAsset, {
|
||||
asset: {
|
||||
id: assetId,
|
||||
name: file.name,
|
||||
mimeType: file.type,
|
||||
source,
|
||||
intrinsicSize,
|
||||
},
|
||||
});
|
||||
store.dispatch(commandIds.documentAddImageLayer, {
|
||||
artboardId: artboard.id,
|
||||
layer: {
|
||||
id: layerId,
|
||||
type: "image",
|
||||
name: file.name,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId,
|
||||
transform: {
|
||||
position: { x: center.x - intrinsicSize.w / 2, y: center.y - intrinsicSize.h / 2 },
|
||||
scale: { x: 1, y: 1 },
|
||||
rotation: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
store.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const openFilePicker = useCallback(() => inputRef.current?.click(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.key.toLowerCase() !== "o" || (!event.metaKey && !event.ctrlKey)) return;
|
||||
event.preventDefault();
|
||||
openFilePicker();
|
||||
};
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const file = [...(event.clipboardData?.files ?? [])].find((candidate) => candidate.type.startsWith("image/"));
|
||||
if (!file) return;
|
||||
|
||||
event.preventDefault();
|
||||
void importFile(file);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
}, [importFile, openFilePicker]);
|
||||
|
||||
const input = (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
if (file) void importFile(file);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return { input, openFilePicker, importFile };
|
||||
}
|
||||
|
||||
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve({ w: image.naturalWidth, h: image.naturalHeight });
|
||||
image.onerror = () => reject(new Error("Failed to load image"));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
31
view/useViewportActivityIsland.ts
Normal file
31
view/useViewportActivityIsland.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ViewportState } from "@editor/state";
|
||||
import type { BottomControlsAction } from "./BottomControlsIsland";
|
||||
|
||||
export type ViewportActivityIslandState = {
|
||||
visible: boolean;
|
||||
action: BottomControlsAction;
|
||||
};
|
||||
|
||||
export function useViewportActivityIsland(viewport: ViewportState): ViewportActivityIslandState {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [action, setAction] = useState<BottomControlsAction>("zoom");
|
||||
const previousViewport = useRef({ zoom: viewport.zoom, center: viewport.center });
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousViewport.current;
|
||||
const zoomChanged = previous.zoom !== viewport.zoom;
|
||||
const centerChanged = previous.center.x !== viewport.center.x || previous.center.y !== viewport.center.y;
|
||||
|
||||
if (!zoomChanged && !centerChanged) return;
|
||||
|
||||
previousViewport.current = { zoom: viewport.zoom, center: viewport.center };
|
||||
setAction(zoomChanged ? "zoom" : "pan");
|
||||
setVisible(true);
|
||||
|
||||
const timeout = window.setTimeout(() => setVisible(false), 1200);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [viewport.center, viewport.zoom]);
|
||||
|
||||
return { visible, action };
|
||||
}
|
||||
Reference in New Issue
Block a user