Compare commits

...

19 Commits

Author SHA1 Message Date
syntaxbullet
bc0fbea029 feat(renderer): render image textures 2026-07-03 16:23:38 +02:00
syntaxbullet
71e97388da feat(input): select image layers on canvas 2026-07-03 16:20:15 +02:00
syntaxbullet
ed4d419c4d feat(view): import images from file or clipboard 2026-07-03 16:19:44 +02:00
syntaxbullet
44295b1b2d feat(renderer): render image layer placeholders 2026-07-03 16:19:05 +02:00
syntaxbullet
75466d2aa6 feat(commands): add image asset layer commands 2026-07-03 16:18:40 +02:00
syntaxbullet
97c3d3b7ae feat(core): add intrinsic asset dimensions 2026-07-03 16:14:00 +02:00
syntaxbullet
a3b4a4a1b0 refactor(transform): resolve target bounds 2026-07-03 16:12:26 +02:00
syntaxbullet
ff5bb43382 feat(view): show transform measurements 2026-07-03 16:08:45 +02:00
syntaxbullet
166310ace0 feat(input): drag artboard transform controls 2026-07-03 16:08:08 +02:00
syntaxbullet
bf5d9f5371 feat(transform): add transform session commands 2026-07-03 16:06:58 +02:00
syntaxbullet
03d4d4a3b9 feat(renderer): draw transform handles 2026-07-03 16:05:42 +02:00
syntaxbullet
e90b829e62 feat(commands): add artboard bounds command 2026-07-03 16:05:15 +02:00
syntaxbullet
4d77288450 feat(renderer): draw selection overlay 2026-07-03 16:04:43 +02:00
syntaxbullet
28fcbdceda feat(input): support artboard selection 2026-07-03 16:04:43 +02:00
syntaxbullet
46aa1b8595 feat(view): add contextual layer controls 2026-07-03 16:04:43 +02:00
syntaxbullet
b0732c8af1 feat(view): add viewport activity island 2026-07-03 11:43:00 +02:00
syntaxbullet
8965532c3a feat(viewport): add fit artboard command 2026-07-03 11:31:55 +02:00
syntaxbullet
84a610f019 refactor(commands): centralize command ids 2026-07-03 11:31:02 +02:00
syntaxbullet
8320203f11 Add pan tool toolbar 2026-07-03 11:27:48 +02:00
56 changed files with 1996 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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),
};
}

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export const initialEditorState: EditorState = {
layerIds: [],
},
tools: initialToolState,
transformSession: undefined,
};
export function createInitialAppState(name = "Untitled"): AppState {

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 },
}));
}

View File

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

View 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
View 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
View 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"}`;
}

View File

@@ -0,0 +1,5 @@
import { bottomControlDividerClass } from "./styles";
export function BottomControlDivider() {
return <div className={bottomControlDividerClass()} />;
}

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

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

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

View 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>
</>
);
}

View 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`;
}

View File

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

View File

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

View File

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

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