Compare commits

...

8 Commits

Author SHA1 Message Date
syntaxbullet
6e5d58a638 perf(brush): reuse stroke session canvas 2026-07-04 16:09:44 +02:00
syntaxbullet
1a43d1f0e5 fix(commands): narrow deferred history policy 2026-07-04 15:59:30 +02:00
syntaxbullet
88208ea1ad perf(document): index layer read paths 2026-07-04 15:58:38 +02:00
syntaxbullet
1acfcbe4a5 perf(history): batch transform drag undo 2026-07-04 15:49:13 +02:00
syntaxbullet
a030f2065a perf(renderer): draw checkerboard with shader 2026-07-04 15:43:50 +02:00
syntaxbullet
ef0ce15bb5 perf(view): reduce shell rerenders 2026-07-04 15:37:54 +02:00
syntaxbullet
dfa2bffeda perf(canvas): coalesce renderer redraws 2026-07-04 15:27:20 +02:00
syntaxbullet
d9e5e05183 perf(commands): skip no-op dispatch emissions 2026-07-04 15:21:41 +02:00
27 changed files with 1556 additions and 338 deletions

View File

@@ -4,8 +4,14 @@ export type CommandContext = {
state: AppState;
};
export type CommandHistoryPolicy =
| { mode: "auto" }
| { mode: "ignore" }
| { mode: "deferred"; phase: "begin" | "update" | "commit" };
export type Command<TPayload = void> = {
id: string;
name: string;
history?: CommandHistoryPolicy;
execute(context: CommandContext, payload: TPayload): AppState;
};

View File

@@ -1,10 +1,17 @@
import { describe, expect, test } from "bun:test";
import type { Command } from "./command";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import { commandIds } from "./ids";
import { createCommandRegistry } from "./registry";
import { viewportPanCommand } from "./viewport";
const noOpCommand: Command<unknown> = {
id: "test.noop",
name: "No-op",
execute: ({ state }) => state,
};
describe("command dispatcher", () => {
test("throws for unknown commands", () => {
const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([]));
@@ -16,4 +23,23 @@ describe("command dispatcher", () => {
store.dispatch(commandIds.viewportPan, { delta: { x: 3, y: 7 } });
expect(store.getState().editor.viewport.center).toEqual({ x: 3, y: 7 });
});
test("repeated no-op commands do not emit or record history", () => {
const initialState = createInitialAppState("Test");
const store = createAppStore(initialState, createCommandRegistry([noOpCommand]));
let notificationCount = 0;
store.subscribe(() => {
notificationCount += 1;
});
const firstResult = store.dispatch("test.noop" as never, undefined as never);
const secondResult = store.dispatch("test.noop" as never, undefined as never);
expect(firstResult).toBe(initialState);
expect(secondResult).toBe(initialState);
expect(store.getState()).toBe(initialState);
expect(store.getState().history.past).toHaveLength(0);
expect(store.getState().history.future).toHaveLength(0);
expect(notificationCount).toBe(0);
});
});

View File

@@ -1,6 +1,5 @@
import type { AppState, HistorySnapshot } from "@editor/state";
import type { CommandContext } from "./command";
import { commandIds } from "./ids";
import type { CommandContext, CommandHistoryPolicy } from "./command";
import type { CommandId, CommandPayloads } from "./payloads";
import type { CommandRegistry } from "./registry";
@@ -15,6 +14,8 @@ export function createCommandDispatcher(options: {
getState: () => AppState;
setState: (state: AppState) => void;
}): CommandDispatcher {
let deferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined;
return {
dispatch(commandId, payload) {
const command = options.registry.get(commandId);
@@ -23,25 +24,91 @@ export function createCommandDispatcher(options: {
}
const currentState = options.getState();
const historyPolicy = command.history ?? defaultHistoryPolicy;
const deferredSnapshot = historyPolicy.mode === "deferred" && historyPolicy.phase === "begin" ? snapshot(currentState) : undefined;
const context: CommandContext = { state: currentState };
const executedState = command.execute(context, payload);
const nextState = shouldRecordHistory(commandId, currentState, executedState) ? recordHistory(currentState, executedState) : executedState;
if (executedState === currentState) {
if (historyPolicy.mode === "deferred" && historyPolicy.phase === "commit") deferredHistory = undefined;
return currentState;
}
const nextState = applyHistoryPolicy({
currentState,
nextState: executedState,
historyPolicy,
deferredSnapshot,
getDeferredHistory: () => deferredHistory,
setDeferredHistory: (nextDeferredHistory) => {
deferredHistory = nextDeferredHistory;
},
});
options.setState(nextState);
return nextState;
},
};
}
function shouldRecordHistory(commandId: CommandId, currentState: AppState, nextState: AppState) {
if (commandId === commandIds.historyUndo || commandId === commandIds.historyRedo) return false;
const defaultHistoryPolicy: CommandHistoryPolicy = { mode: "auto" };
function applyHistoryPolicy(options: {
currentState: AppState;
nextState: AppState;
historyPolicy: CommandHistoryPolicy;
deferredSnapshot?: HistorySnapshot;
getDeferredHistory: () => { snapshot: HistorySnapshot; changed: boolean } | undefined;
setDeferredHistory: (nextDeferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined) => void;
}): AppState {
const { historyPolicy } = options;
if (historyPolicy.mode === "ignore") {
options.setDeferredHistory(undefined);
return options.nextState;
}
if (historyPolicy.mode === "deferred") {
return applyDeferredHistoryPolicy({ ...options, historyPolicy });
}
return shouldRecordHistory(options.currentState, options.nextState) ? recordHistory(snapshot(options.currentState), options.nextState) : options.nextState;
}
function applyDeferredHistoryPolicy(options: {
currentState: AppState;
nextState: AppState;
historyPolicy: Extract<CommandHistoryPolicy, { mode: "deferred" }>;
deferredSnapshot?: HistorySnapshot;
getDeferredHistory: () => { snapshot: HistorySnapshot; changed: boolean } | undefined;
setDeferredHistory: (nextDeferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined) => void;
}): AppState {
switch (options.historyPolicy.phase) {
case "begin":
options.setDeferredHistory(options.deferredSnapshot ? { snapshot: options.deferredSnapshot, changed: false } : undefined);
return options.nextState;
case "update": {
const deferredHistory = options.getDeferredHistory();
if (deferredHistory && options.currentState.document !== options.nextState.document) {
options.setDeferredHistory({ ...deferredHistory, changed: true });
}
return options.nextState;
}
case "commit": {
const deferredHistory = options.getDeferredHistory();
options.setDeferredHistory(undefined);
return deferredHistory?.changed ? recordHistory(deferredHistory.snapshot, options.nextState) : options.nextState;
}
}
}
function shouldRecordHistory(currentState: AppState, nextState: AppState) {
return currentState.document !== nextState.document;
}
function recordHistory(currentState: AppState, nextState: AppState): AppState {
function recordHistory(historySnapshot: HistorySnapshot, nextState: AppState): AppState {
return {
...nextState,
history: {
past: [...currentState.history.past, snapshot(currentState)].slice(-100),
past: [...nextState.history.past, historySnapshot].slice(-100),
future: [],
},
};

View File

@@ -5,8 +5,9 @@ import { documentAddArtboardCommand } from "./document";
import { historyCommands } from "./history";
import { commandIds } from "./ids";
import { createCommandRegistry } from "./registry";
import { transformCommands } from "./transform";
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands]);
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands, ...transformCommands]);
describe("history commands", () => {
test("records document changes and undoes/redoes them", () => {
@@ -26,4 +27,81 @@ describe("history commands", () => {
expect(store.getState().document.artboards.map((artboard) => artboard.id)).toEqual(["a1"]);
});
test("records one history entry for a transform drag", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformBegin, {
target: { type: "artboard", id: "a1" },
handle: "body",
point: { x: 0, y: 0 },
initialBounds: { x: 0, y: 0, w: 100, h: 80 },
});
store.dispatch(commandIds.transformUpdate, { point: { x: 5, y: 10 } });
store.dispatch(commandIds.transformUpdate, { point: { x: 10, y: 20 } });
store.dispatch(commandIds.transformUpdate, { point: { x: 15, y: 25 } });
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 15, y: 25, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().history.past).toHaveLength(1);
expect(store.getState().history.past[0]?.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past[0]?.editor.transformSession).toBeUndefined();
store.dispatch(commandIds.historyUndo, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().history.future).toHaveLength(1);
});
test("keeps direct transform bounds edits normally undoable", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformSetBounds, { target: { type: "artboard", id: "a1" }, bounds: { x: 12, y: 24, w: 120, h: 90 } });
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 12, y: 24, w: 120, h: 90 });
expect(store.getState().history.past).toHaveLength(1);
store.dispatch(commandIds.historyUndo, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
});
test("does not record history for transform update or end without a session", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformUpdate, { point: { x: 10, y: 20 } });
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
expect(store.getState().history.future).toHaveLength(0);
});
test("does not record history for a transform session with no document update", () => {
const store = createAppStore(artboardState(), registry);
store.dispatch(commandIds.transformBegin, {
target: { type: "artboard", id: "a1" },
handle: "body",
point: { x: 0, y: 0 },
initialBounds: { x: 0, y: 0, w: 100, h: 80 },
});
store.dispatch(commandIds.transformEnd, undefined);
expect(store.getState().editor.transformSession).toBeUndefined();
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
expect(store.getState().history.past).toHaveLength(0);
});
});
function artboardState() {
return documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 80 } },
);
}

View File

@@ -4,6 +4,7 @@ import { commandIds } from "./ids";
export const historyUndoCommand: Command = {
id: commandIds.historyUndo,
name: "Undo",
history: { mode: "ignore" },
execute({ state }) {
const previous = state.history.past.at(-1);
if (!previous) return state;
@@ -23,6 +24,7 @@ export const historyUndoCommand: Command = {
export const historyRedoCommand: Command = {
id: commandIds.historyRedo,
name: "Redo",
history: { mode: "ignore" },
execute({ state }) {
const next = state.history.future[0];
if (!next) return state;

View File

@@ -24,6 +24,7 @@ export type TransformSetBoundsPayload = {
export const transformBeginCommand: Command<TransformBeginPayload> = {
id: commandIds.transformBegin,
name: "Begin transform",
history: { mode: "deferred", phase: "begin" },
execute({ state }, payload) {
return {
...state,
@@ -43,6 +44,7 @@ export const transformBeginCommand: Command<TransformBeginPayload> = {
export const transformUpdateCommand: Command<TransformUpdatePayload> = {
id: commandIds.transformUpdate,
name: "Update transform",
history: { mode: "deferred", phase: "update" },
execute({ state }, payload) {
const session = state.editor.transformSession;
if (!session) return state;
@@ -78,6 +80,7 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
export const transformEndCommand: Command = {
id: commandIds.transformEnd,
name: "End transform",
history: { mode: "deferred", phase: "commit" },
execute({ state }) {
if (!state.editor.transformSession) return state;

View File

@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";
const document: ImageDocument = {
id: "d1",
name: "Indexed Document",
version: 1,
assets: [
{ id: "asset-target", name: "Target", mimeType: "image/png", source: "asset://target", intrinsicSize: { w: 100, h: 50 } },
{ id: "asset-mask", name: "Mask", mimeType: "image/png", source: "asset://mask", intrinsicSize: { w: 100, h: 50 } },
{ id: "asset-nested", name: "Nested", mimeType: "image/png", source: "asset://nested", intrinsicSize: { w: 20, h: 10 } },
],
artboards: [
{
id: "a1",
name: "Artboard",
bounds: { x: 0, y: 0, w: 400, h: 300 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [
raster("mask", "Mask", "asset-mask"),
{ ...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }), clippingMask: { maskLayerId: "mask" } },
group("group", "Group", [
raster("nested-mask", "Nested Mask", "asset-mask"),
{ ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } },
]),
],
},
],
};
describe("document read indexes", () => {
test("indexes assets, layers, layer info, masks, and display counts", () => {
const index = createDocumentReadIndex(document);
expect(index.assetById.get("asset-target")).toBe(document.assets[0]);
expect(index.layerById.get("nested-target")?.name).toBe("Nested Target");
expect(index.layerInfoById.get("target")).toMatchObject({ artboardId: "a1", index: 1 });
expect(index.layerInfoById.get("nested-target")).toMatchObject({ artboardId: "a1", parentGroupId: "group", index: 1 });
expect(index.maskLayerIds).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByArtboardId.get("a1")).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByLayerList.get(document.artboards[0]!.layers)).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByLayerList.get(groupLayer(document, "group").children)).toEqual(new Set(["nested-mask"]));
expect(index.displayLayerCountByArtboardId.get("a1")).toBe(3);
});
test("resolves layer bounds from indexed assets without scanning the document", () => {
const index = createDocumentReadIndex(document);
expect(resolveIndexedLayerBounds(index, "target")).toEqual({ x: 10, y: 20, w: 50, h: 25 });
expect(resolveIndexedLayerBounds(index, "group")).toEqual({ x: 0, y: 0, w: 120, h: 50 });
expect(resolveIndexedLayerBounds(index, raster("missing", "Missing", "missing-asset"))).toBeUndefined();
});
test("visits layers back to front without mutating source order", () => {
const layers = document.artboards[0]!.layers;
const visited: string[] = [];
forEachLayerBackToFront(layers, (layer) => visited.push(layer.id));
expect(visited).toEqual(["group", "target", "mask"]);
expect(layers.map((layer) => layer.id)).toEqual(["mask", "target", "group"]);
});
});
function raster(
id: string,
name: string,
assetId: string,
position = { x: 0, y: 0 },
scale = { x: 1, y: 1 },
): Extract<Layer, { type: "raster" }> {
return {
id,
type: "raster",
name,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position, scale, rotation: 0 },
};
}
function group(id: string, name: string, children: Layer[]): Extract<Layer, { type: "group" }> {
return {
id,
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children,
};
}
function groupLayer(document: ImageDocument, id: string): Extract<Layer, { type: "group" }> {
const layer = document.artboards[0]!.layers.find((candidate) => candidate.id === id);
if (!layer || layer.type !== "group") throw new Error(`Missing group ${id}`);
return layer;
}

164
editor/document-indexes.ts Normal file
View File

@@ -0,0 +1,164 @@
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
export type IndexedLayerInfo = {
artboardId: ArtboardId;
parentGroupId?: LayerId;
layer: Layer;
siblings: readonly Layer[];
index: number;
};
export type DocumentReadIndex = {
assetById: ReadonlyMap<AssetId, Asset>;
layerById: ReadonlyMap<LayerId, Layer>;
layerInfoById: ReadonlyMap<LayerId, IndexedLayerInfo>;
maskLayerIds: ReadonlySet<LayerId>;
maskLayerIdsByArtboardId: ReadonlyMap<ArtboardId, ReadonlySet<LayerId>>;
maskLayerIdsByLayerList: ReadonlyMap<readonly Layer[], ReadonlySet<LayerId>>;
displayLayerCountByArtboardId: ReadonlyMap<ArtboardId, number>;
};
export function createDocumentReadIndex(document: ImageDocument): DocumentReadIndex {
const assetById = new Map<AssetId, Asset>();
const layerById = new Map<LayerId, Layer>();
const layerInfoById = new Map<LayerId, IndexedLayerInfo>();
const maskLayerIds = new Set<LayerId>();
const maskLayerIdsByArtboardId = new Map<ArtboardId, ReadonlySet<LayerId>>();
const maskLayerIdsByLayerList = new Map<readonly Layer[], ReadonlySet<LayerId>>();
const displayLayerCountByArtboardId = new Map<ArtboardId, number>();
for (const asset of document.assets) assetById.set(asset.id, asset);
for (const artboard of document.artboards) {
const artboardMaskLayerIds = indexLayerTree({
layers: artboard.layers,
artboardId: artboard.id,
layerById,
layerInfoById,
documentMaskLayerIds: maskLayerIds,
maskLayerIdsByLayerList,
});
maskLayerIdsByArtboardId.set(artboard.id, artboardMaskLayerIds);
}
for (const artboard of document.artboards) {
displayLayerCountByArtboardId.set(artboard.id, countDisplayLayers(artboard.layers, maskLayerIds));
}
return {
assetById,
layerById,
layerInfoById,
maskLayerIds,
maskLayerIdsByArtboardId,
maskLayerIdsByLayerList,
displayLayerCountByArtboardId,
};
}
export function forEachLayerBackToFront(layers: readonly Layer[], visit: (layer: Layer) => void) {
for (let index = layers.length - 1; index >= 0; index -= 1) {
const layer = layers[index];
if (layer) visit(layer);
}
}
export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: Layer | LayerId): Rect | undefined {
const layer = typeof layerOrId === "string" ? index.layerById.get(layerOrId) : layerOrId;
if (!layer) return undefined;
switch (layer.type) {
case "group":
return unionLayerBounds(index, layer.children);
case "image":
case "raster": {
const asset = index.assetById.get(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 indexLayerTree(options: {
layers: readonly Layer[];
artboardId: ArtboardId;
parentGroupId?: LayerId;
layerById: Map<LayerId, Layer>;
layerInfoById: Map<LayerId, IndexedLayerInfo>;
documentMaskLayerIds: Set<LayerId>;
maskLayerIdsByLayerList: Map<readonly Layer[], ReadonlySet<LayerId>>;
}): Set<LayerId> {
const layerListMaskLayerIds = new Set<LayerId>();
for (let index = 0; index < options.layers.length; index += 1) {
const layer = options.layers[index];
if (!layer) continue;
options.layerById.set(layer.id, layer);
options.layerInfoById.set(layer.id, {
artboardId: options.artboardId,
parentGroupId: options.parentGroupId,
layer,
siblings: options.layers,
index,
});
if (layer.clippingMask) {
options.documentMaskLayerIds.add(layer.clippingMask.maskLayerId);
layerListMaskLayerIds.add(layer.clippingMask.maskLayerId);
}
if (layer.type === "group") {
const childMaskLayerIds = indexLayerTree({
...options,
layers: layer.children,
parentGroupId: layer.id,
});
for (const maskLayerId of childMaskLayerIds) layerListMaskLayerIds.add(maskLayerId);
}
}
options.maskLayerIdsByLayerList.set(options.layers, layerListMaskLayerIds);
return layerListMaskLayerIds;
}
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<LayerId>): number {
let count = 0;
for (const layer of layers) {
if (maskLayerIds.has(layer.id)) continue;
count += 1;
if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds);
}
return count;
}
function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): Rect | undefined {
let bounds: Rect | undefined;
for (const layer of layers) {
const layerBounds = resolveIndexedLayerBounds(index, layer);
if (!layerBounds) continue;
bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds;
}
return bounds;
}
function unionRects(a: Rect, b: Rect): Rect {
const minX = Math.min(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxX = Math.max(a.x + a.w, b.x + b.w);
const maxY = Math.max(a.y + a.h, b.y + b.h);
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}

View File

@@ -4,3 +4,5 @@ export { initialToolState } from "./tools";
export { createInitialAppState, initialEditorState } from "./initial-state";
export type { AppStore, StateListener } from "./store";
export { createAppStore } from "./store";
export type { DocumentReadIndex, IndexedLayerInfo } from "./document-indexes";
export { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";

View File

@@ -1,13 +1,13 @@
import type { Artboard } from "@core/artboard";
import type { ViewportState } from "@editor/state";
import { renderCheckerboard } from "./checkerboard";
import type { CheckerboardRenderer } from "./checkerboard";
import { documentRectToScreenRect } from "./screen-rect";
import type { WebGlRendererContext } from "./types";
export function renderArtboard(context: WebGlRendererContext, artboard: Artboard, viewport: ViewportState) {
export function renderArtboard(context: WebGlRendererContext, artboard: Artboard, viewport: ViewportState, checkerboardRenderer: CheckerboardRenderer) {
const rect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport);
if (artboard.backgroundColor === "transparent") {
renderCheckerboard(context, rect, Math.max(4, Math.round(12 * viewport.zoom)));
checkerboardRenderer.render(rect, Math.max(4, Math.round(12 * viewport.zoom)));
}
}

View File

@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test";
import { createCheckerboardRenderer } from "./checkerboard";
import type { WebGlRendererContext } from "./types";
type FakeGlCall = {
name: string;
args: unknown[];
};
type FakeUniformLocation = {
name: string;
};
describe("checkerboard renderer", () => {
test("renders a clipped checkerboard with one draw call", () => {
const fake = createFakeGl();
const renderer = createCheckerboardRenderer(createFakeContext(fake.gl, 100, 80));
renderer.render({ x: -5, y: 3, w: 30, h: 20 }, 12);
expect(fake.calls.filter((call) => call.name === "drawArrays")).toHaveLength(1);
expect(fake.calls.find((call) => call.name === "scissor")?.args).toEqual([0, 57, 25, 20]);
expect(fake.uniforms.get("u_origin")).toEqual([-5, 3]);
expect(fake.uniforms.get("u_squareSize")).toEqual([12]);
expect(Array.from(fake.bufferData.at(-1) ?? [])).toEqual([0, 3, 25, 3, 0, 23, 0, 23, 25, 3, 25, 23]);
});
test("skips fully clipped checkerboards", () => {
const fake = createFakeGl();
const renderer = createCheckerboardRenderer(createFakeContext(fake.gl, 100, 80));
renderer.render({ x: 120, y: 0, w: 20, h: 20 }, 8);
expect(fake.calls.filter((call) => call.name === "drawArrays")).toHaveLength(0);
});
test("disposes WebGL resources", () => {
const fake = createFakeGl();
const renderer = createCheckerboardRenderer(createFakeContext(fake.gl, 100, 80));
renderer.dispose();
expect(fake.calls.filter((call) => call.name === "deleteBuffer")).toHaveLength(1);
expect(fake.calls.filter((call) => call.name === "deleteProgram")).toHaveLength(1);
});
});
function createFakeContext(gl: WebGL2RenderingContext, width: number, height: number): WebGlRendererContext {
return {
gl,
canvas: { width, height } as HTMLCanvasElement,
};
}
function createFakeGl() {
const calls: FakeGlCall[] = [];
const uniforms = new Map<string, number[]>();
const bufferData: Float32Array[] = [];
const shader = {};
const program = {};
const buffer = {};
const noop = () => {};
const gl = {
ARRAY_BUFFER: 0x8892,
BLEND: 0x0be2,
COMPILE_STATUS: 0x8b81,
DYNAMIC_DRAW: 0x88e8,
FLOAT: 0x1406,
FRAGMENT_SHADER: 0x8b30,
LINK_STATUS: 0x8b82,
SCISSOR_TEST: 0x0c11,
TRIANGLES: 0x0004,
VERTEX_SHADER: 0x8b31,
attachShader: noop,
bindBuffer: noop,
bufferData: (...args: [number, BufferSource, number]) => {
const data = args[1];
if (data instanceof Float32Array) bufferData.push(data);
},
compileShader: noop,
createBuffer: () => buffer,
createProgram: () => program,
createShader: () => shader,
deleteBuffer: () => {
calls.push({ name: "deleteBuffer", args: [] });
},
deleteProgram: () => {
calls.push({ name: "deleteProgram", args: [] });
},
deleteShader: noop,
disable: (capability: number) => {
calls.push({ name: "disable", args: [capability] });
},
disableVertexAttribArray: (index: number) => {
calls.push({ name: "disableVertexAttribArray", args: [index] });
},
drawArrays: (mode: number, first: number, count: number) => {
calls.push({ name: "drawArrays", args: [mode, first, count] });
},
enable: (capability: number) => {
calls.push({ name: "enable", args: [capability] });
},
enableVertexAttribArray: (index: number) => {
calls.push({ name: "enableVertexAttribArray", args: [index] });
},
getAttribLocation: () => 0,
getProgramInfoLog: () => null,
getProgramParameter: () => true,
getShaderInfoLog: () => null,
getShaderParameter: () => true,
getUniformLocation: (...args: [unknown, string]) => ({ name: args[1] }),
linkProgram: noop,
scissor: (x: number, y: number, w: number, h: number) => {
calls.push({ name: "scissor", args: [x, y, w, h] });
},
shaderSource: noop,
uniform1f: (location: FakeUniformLocation, value: number) => {
uniforms.set(location.name, [value]);
},
uniform2f: (location: FakeUniformLocation, x: number, y: number) => {
uniforms.set(location.name, [x, y]);
},
uniform4fv: (location: FakeUniformLocation, value: Iterable<number>) => {
uniforms.set(location.name, Array.from(value));
},
useProgram: noop,
vertexAttribPointer: (index: number, size: number, type: number, normalized: boolean, stride: number, offset: number) => {
calls.push({ name: "vertexAttribPointer", args: [index, size, type, normalized, stride, offset] });
},
} as unknown as WebGL2RenderingContext;
return { gl, calls, uniforms, bufferData };
}

View File

@@ -1,26 +1,148 @@
import { clearScreenRect } from "./clear-rect";
import type { ScreenRect, WebGlRendererContext } from "./types";
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
const darkChecker = [0.82, 0.82, 0.86, 1] as const;
const lightChecker = [0.94, 0.94, 0.97, 1] as const;
const darkChecker: RgbaColor = [0.82, 0.82, 0.86, 1];
const lightChecker: RgbaColor = [0.94, 0.94, 0.97, 1];
export function renderCheckerboard(context: WebGlRendererContext, rect: ScreenRect, squareSize: number) {
const clampedSquareSize = Math.max(1, squareSize);
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
export type CheckerboardRenderer = {
render(rect: ScreenRect, squareSize: number): void;
dispose(): void;
};
for (let py = rect.y; py < rect.y + rect.h; py += clampedSquareSize) {
for (let px = rect.x; px < rect.x + rect.w; px += clampedSquareSize) {
const x = Math.max(0, px);
const y = Math.max(0, py);
const w = Math.min(px + clampedSquareSize, rect.x + rect.w, canvasWidth) - x;
const h = Math.min(py + clampedSquareSize, rect.y + rect.h, canvasHeight) - y;
if (w <= 0 || h <= 0) continue;
export function createCheckerboardRenderer(context: WebGlRendererContext): CheckerboardRenderer {
const { gl } = context;
const program = createProgram(gl);
const positionLocation = gl.getAttribLocation(program, "a_position");
const canvasSizeLocation = gl.getUniformLocation(program, "u_canvasSize");
const originLocation = gl.getUniformLocation(program, "u_origin");
const squareSizeLocation = gl.getUniformLocation(program, "u_squareSize");
const darkColorLocation = gl.getUniformLocation(program, "u_darkColor");
const lightColorLocation = gl.getUniformLocation(program, "u_lightColor");
const positionBuffer = gl.createBuffer();
const checker =
(Math.floor((px - rect.x) / clampedSquareSize) + Math.floor((py - rect.y) / clampedSquareSize)) % 2 === 0;
clearScreenRect(context, { x, y, w, h }, checker ? darkChecker : lightChecker);
}
if (
positionLocation < 0 ||
!canvasSizeLocation ||
!originLocation ||
!squareSizeLocation ||
!darkColorLocation ||
!lightColorLocation ||
!positionBuffer
) {
if (positionBuffer) gl.deleteBuffer(positionBuffer);
gl.deleteProgram(program);
throw new Error("Failed to create checkerboard renderer");
}
return {
render(rect, squareSize) {
const drawRect = intersectScreenRectWithCanvas(rect, context.canvas);
if (!drawRect) return;
gl.enable(gl.SCISSOR_TEST);
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
gl.disable(gl.BLEND);
gl.useProgram(program);
gl.uniform2f(canvasSizeLocation, context.canvas.width, context.canvas.height);
gl.uniform2f(originLocation, rect.x, rect.y);
gl.uniform1f(squareSizeLocation, Math.max(1, squareSize));
gl.uniform4fv(darkColorLocation, darkChecker);
gl.uniform4fv(lightColorLocation, lightChecker);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, screenRectVertices(drawRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.disableVertexAttribArray(positionLocation);
},
dispose() {
gl.deleteBuffer(positionBuffer);
gl.deleteProgram(program);
},
};
}
function intersectScreenRectWithCanvas(rect: ScreenRect, canvas: HTMLCanvasElement): ScreenRect | undefined {
const x1 = Math.max(0, rect.x);
const y1 = Math.max(0, rect.y);
const x2 = Math.min(canvas.width, rect.x + rect.w);
const y2 = Math.min(canvas.height, rect.y + rect.h);
if (x2 <= x1 || y2 <= y1) return undefined;
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
function screenRectVertices(rect: ScreenRect) {
const x1 = rect.x;
const x2 = rect.x + rect.w;
const y1 = rect.y;
const y2 = rect.y + rect.h;
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;
uniform vec2 u_canvasSize;
out vec2 v_screenPosition;
void main() {
vec2 clip = vec2((a_position.x / u_canvasSize.x) * 2.0 - 1.0, 1.0 - (a_position.y / u_canvasSize.y) * 2.0);
gl_Position = vec4(clip, 0.0, 1.0);
v_screenPosition = a_position;
}`,
);
const fragmentShader = compileShader(
gl,
gl.FRAGMENT_SHADER,
`#version 300 es
precision highp float;
uniform vec2 u_origin;
uniform float u_squareSize;
uniform vec4 u_darkColor;
uniform vec4 u_lightColor;
in vec2 v_screenPosition;
out vec4 outColor;
void main() {
vec2 cell = floor((v_screenPosition - u_origin) / max(1.0, u_squareSize));
float parity = mod(cell.x + cell.y, 2.0);
outColor = mix(u_darkColor, u_lightColor, parity);
}`,
);
const program = gl.createProgram();
if (!program) throw new Error("Failed to create checkerboard 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 checkerboard program link error";
gl.deleteProgram(program);
throw new Error(message);
}
return program;
}
function compileShader(gl: WebGL2RenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) throw new Error("Failed to create shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader) ?? "Unknown shader compile error";
gl.deleteShader(shader);
throw new Error(message);
}
return shader;
}

View File

@@ -1,7 +1,7 @@
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes";
import type { EditorState, MaskViewMode, ViewportState } from "@editor/state";
import { resolveTransformTargetBounds } from "@editor/transform-targets";
import { clearScreenRect } from "./clear-rect";
import type { ImageTextureRenderer } from "./image-textures";
import { documentRectToScreenRect } from "./screen-rect";
@@ -13,17 +13,19 @@ const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
const maskRevealPreviewOpacity = 0.28;
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
const documentIndex = createDocumentReadIndex(document);
for (const artboard of document.artboards) {
if (!artboard.visible) continue;
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
const maskLayerIds = collectMaskLayerIds(artboard.layers);
for (const layer of renderStack(artboard.layers)) renderLayer(context, document, editor, layer, imageTextureRenderer, clipRect, maskLayerIds);
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds));
}
}
function renderLayer(
context: WebGlRendererContext,
document: ImageDocument,
documentIndex: DocumentReadIndex,
editor: EditorState,
layer: Layer,
imageTextureRenderer: ImageTextureRenderer,
@@ -35,24 +37,24 @@ function renderLayer(
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
if (!layer.visible || maskLayerIds.has(layer.id)) return;
const effectiveClipRect = resolveLayerClipRect(context, document, editor.viewport, layer, clipRect);
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect);
if (!effectiveClipRect) return;
if (layer.type === "group") {
for (const child of renderStack(layer.children)) renderLayer(context, document, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds);
forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds));
return;
}
if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) return;
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
if (!bounds) return;
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
const asset = assetWithBrushStrokePreview(document.assets.find((candidate) => candidate.id === layer.assetId), editor);
const maskLayer = !editingMaskLayer && layer.clippingMask ? findLayer(document, layer.clippingMask.maskLayerId) : undefined;
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined, editor);
const maskBounds = maskLayer ? resolveTransformTargetBounds(document, { type: "layer", id: maskLayer.id }) : undefined;
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
const maskLayer = !editingMaskLayer && layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId);
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
@@ -79,20 +81,18 @@ function renderLayer(
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
}
function renderStack(layers: readonly Layer[]) {
return [...layers].reverse();
}
const emptyLayerIds = new Set<string>();
function resolveLayerClipRect(
context: WebGlRendererContext,
document: ImageDocument,
documentIndex: DocumentReadIndex,
viewport: ViewportState,
layer: Layer,
clipRect: ScreenRect,
): ScreenRect | undefined {
if (!layer.clippingMask) return clipRect;
const maskBounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.clippingMask.maskLayerId });
const maskBounds = resolveIndexedLayerBounds(documentIndex, layer.clippingMask.maskLayerId);
if (!maskBounds) return clipRect;
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
@@ -107,33 +107,6 @@ function assetWithBrushStrokePreview<TAsset extends ImageDocument["assets"][numb
return { ...asset, source: editor.brushStrokePreview.source } as TAsset;
}
function findLayer(document: ImageDocument, layerId: string): Layer | undefined {
for (const artboard of document.artboards) {
const layer = findLayerInTree(artboard.layers, layerId);
if (layer) return layer;
}
return undefined;
}
function findLayerInTree(layers: readonly Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const child = findLayerInTree(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
return ids;
}
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined {
const x1 = Math.max(a.x, b.x);
const y1 = Math.max(a.y, b.y);

View File

@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
import type { EditorState } from "@editor/state";
import { renderArtboard } from "./artboard";
import { createBrushPreviewRenderer } from "./brush-preview";
import { createCheckerboardRenderer } from "./checkerboard";
import { createImageTextureRenderer } from "./image-textures";
import { renderLayers } from "./layers";
import { renderSelectionOverlay } from "./selection";
@@ -31,6 +32,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
}
const rendererContext: WebGlRendererContext = { gl: context, canvas };
const checkerboardRenderer = createCheckerboardRenderer(rendererContext);
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
let lastFrame: RenderFrame | undefined;
let rerenderQueued = false;
@@ -58,7 +60,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
context.enable(context.SCISSOR_TEST);
for (const artboard of frame.document.artboards) {
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport);
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport, checkerboardRenderer);
}
imageTextureRenderer.syncAssets(frame.document.assets);
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
@@ -72,6 +74,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
context.disable(context.SCISSOR_TEST);
},
dispose() {
checkerboardRenderer.dispose();
imageTextureRenderer.dispose();
brushPreviewRenderer?.dispose();
},

View File

@@ -10,9 +10,10 @@ import { LayersSheet } from "./LayersSheet";
import { ShortcutsDisplay } from "./ShortcutsDisplay";
import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { AppState } from "@editor/state";
import type { ToolId } from "@editor/tools";
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { useAppState } from "./useAppState";
import { shallowEqual, useAppState } from "./useAppState";
import { downloadArtboardPng } from "./exportArtboardPng";
import { useImageImport } from "./useImageImport";
import { useViewportActivityIsland } from "./useViewportActivityIsland";
@@ -23,14 +24,15 @@ export type AppProps = {
};
export function App({ app }: AppProps) {
const state = useAppState(app.store);
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
const { document, selection, viewport, tools, transformSession, maskEdit } = shellState;
const viewportActivityIsland = useViewportActivityIsland(viewport);
const imageImport = useImageImport(app.store);
const [layersOpen, setLayersOpen] = useState(false);
const [generateOpen, setGenerateOpen] = useState(false);
const previousGenerateTool = useRef<ToolId>("select");
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0];
const openGenerate = useCallback(() => {
const activeTool = app.store.getState().editor.tools.activeTool;
@@ -108,8 +110,8 @@ export function App({ app }: AppProps) {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [app.store, closeGenerate, toggleGenerate]);
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
const brushHint = brushUnavailableHint(state.document, state.editor);
const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined;
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
return (
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
@@ -122,7 +124,7 @@ export function App({ app }: AppProps) {
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
</button>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, state.document.assets)}>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, document.assets)}>
<DownloadSimple size={24} />
</button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => { closeGenerate(); setLayersOpen((open) => !open); }}>
@@ -132,41 +134,37 @@ export function App({ app }: AppProps) {
</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}
activeTool={tools.activeTool}
interactionMode={tools.interactionMode}
dispatch={app.store.dispatch}
/>
</div>
<GenerateSheet
document={state.document}
selection={state.editor.selection}
viewport={state.editor.viewport}
settings={state.editor.tools.generate}
settings={tools.generate}
open={generateOpen}
onOpenChange={(open) => open ? openGenerate() : closeGenerate()}
dispatch={app.store.dispatch}
/>
<LayersSheet
document={state.document}
selection={state.editor.selection}
maskEdit={state.editor.maskEdit}
document={document}
selection={selection}
maskEdit={maskEdit}
open={layersOpen}
dispatch={app.store.dispatch}
/>
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland
document={state.document}
selection={state.editor.selection}
viewport={state.editor.viewport}
visible={state.editor.tools.activeTool === "generate" || state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || state.editor.tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
document={document}
selection={selection}
viewport={viewport}
visible={tools.activeTool === "generate" || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "chromaKey" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
activeTool={state.editor.tools.activeTool}
brushSettings={state.editor.tools.brush}
generateSettings={state.editor.tools.generate}
chromaKeySettings={state.editor.tools.chromaKey}
magicWandSettings={state.editor.tools.magicWand}
editingMask={Boolean(state.editor.maskEdit)}
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
activeTool={tools.activeTool}
brushSettings={tools.brush}
generateSettings={tools.generate}
chromaKeySettings={tools.chromaKey}
magicWandSettings={tools.magicWand}
editingMask={Boolean(maskEdit)}
maskViewMode={maskEdit?.viewMode ?? "composite"}
brushHint={brushHint}
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
@@ -186,4 +184,24 @@ function topBarButtonClass(active = false) {
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
}
type AppShellState = {
document: AppState["document"];
selection: AppState["editor"]["selection"];
viewport: AppState["editor"]["viewport"];
tools: AppState["editor"]["tools"];
transformSession: AppState["editor"]["transformSession"];
maskEdit: AppState["editor"]["maskEdit"];
};
function selectAppShellState(state: AppState): AppShellState {
return {
document: state.document,
selection: state.editor.selection,
viewport: state.editor.viewport,
tools: state.editor.tools,
transformSession: state.editor.transformSession,
maskEdit: state.editor.maskEdit,
};
}
export default App;

View File

@@ -1,7 +1,10 @@
import { useMemo, useRef } from "react";
import type { ImageDocument } from "@core/document";
import type { AppState, MaskEditState } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { InteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
import { brushUnavailableHint, canPreviewBrush } from "./canvas/brush";
import { brushUnavailableHint, canPreviewBrush, type BrushTargetEditorState } from "./canvas/brush";
import { canvasCursorClass } from "./canvas/cursor";
import { useCanvasInput } from "./canvas/useCanvasInput";
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
@@ -26,7 +29,7 @@ export function CanvasViewport({
globalWheelConsumer = ignoreGlobalWheel,
}: CanvasViewportProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const state = useAppState(store);
const cursorState = useAppState(store, selectCanvasCursorState, canvasCursorStatesEqual);
const inputOptions = useMemo(
() => ({ globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer }),
[globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer],
@@ -35,9 +38,53 @@ export function CanvasViewport({
useCanvasRenderer(canvasRef, store);
useCanvasResize(canvasRef, store.dispatch);
const input = useCanvasInput(canvasRef, store, inputOptions);
const brushHint = brushUnavailableHint(state.document, state.editor);
const hasBrushPreview = Boolean(state.editor.brushPreview && !brushHint && canPreviewBrush(state.document, state.editor));
const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
}
type CanvasCursorState = {
document: ImageDocument;
editor: BrushTargetEditorState;
hasBrushPreview: boolean;
};
function selectCanvasCursorState(state: AppState): CanvasCursorState {
return {
document: state.document,
editor: {
selection: state.editor.selection,
tools: {
activeTool: state.editor.tools.activeTool,
interactionMode: state.editor.tools.interactionMode,
},
maskEdit: state.editor.maskEdit,
},
hasBrushPreview: Boolean(state.editor.brushPreview),
};
}
function canvasCursorStatesEqual(a: CanvasCursorState, b: CanvasCursorState): boolean {
return (
a.document === b.document &&
a.hasBrushPreview === b.hasBrushPreview &&
a.editor.selection === b.editor.selection &&
a.editor.tools.activeTool === b.editor.tools.activeTool &&
interactionModesEqual(a.editor.tools.interactionMode, b.editor.tools.interactionMode) &&
maskEditStatesEqual(a.editor.maskEdit, b.editor.maskEdit)
);
}
function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean {
if (a.type !== b.type) return false;
if (a.type === "temporary-pan") return b.type === "temporary-pan" && a.previousTool === b.previousTool;
return b.type === "tool" && a.tool === b.tool;
}
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
}

View File

@@ -1,20 +1,14 @@
import type { ImageDocument } from "@core/document";
import type { SelectionState, ViewportState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools";
import type { AppStore } from "@editor/store";
import { GenerateControls } from "./bottom-controls/GenerateControls";
export type GenerateSheetProps = {
document: ImageDocument;
selection: SelectionState;
viewport: ViewportState;
settings: GenerateSettings;
open: boolean;
onOpenChange: (open: boolean) => void;
dispatch: AppStore["dispatch"];
};
export function GenerateSheet({ document, selection, viewport, settings, open, onOpenChange, dispatch }: GenerateSheetProps) {
export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps) {
return (
<aside
aria-hidden={!open}
@@ -23,10 +17,11 @@ export function GenerateSheet({ document, selection, viewport, settings, open, o
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`}
>
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
<GenerateControls document={document} selection={selection} viewport={viewport} settings={settings} dispatch={dispatch} />
</div>
{open ? (
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
<GenerateControls settings={settings} dispatch={dispatch} />
</div>
) : null}
</aside>
);
}

View File

@@ -1,13 +1,13 @@
import { useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { ArtboardId } from "@core/id";
import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveTransformTargetBounds } from "@editor/transform-targets";
import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index";
import { resolveLayerDrop } from "@input/index";
import { downloadArtboardPng } from "./exportArtboardPng";
export type LayersSheetProps = {
@@ -19,12 +19,7 @@ export type LayersSheetProps = {
};
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
const canUngroup = selectedLayer?.layer.type === "group";
const maskLayerIds = collectDocumentMaskLayerIds(document);
const draggedLayerId = useRef<string>();
const draggedLayerId = useRef<string | undefined>(undefined);
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
return (
@@ -34,6 +29,44 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`}
>
{open ? (
<LayersSheetBody
document={document}
selection={selection}
maskEdit={maskEdit}
draggedLayerId={draggedLayerId}
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
dispatch={dispatch}
/>
) : null}
</aside>
);
}
function LayersSheetBody({
document,
selection,
maskEdit,
draggedLayerId,
editingTitle,
setEditingTitle,
dispatch,
}: Omit<LayersSheetProps, "open"> & {
draggedLayerId: MutableRefObject<string | undefined>;
editingTitle: EditingTitle | undefined;
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
}) {
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
const selectedLayerId = selection.layerIds[0];
const selectedLayer = selectedLayerId ? documentIndex.layerInfoById.get(selectedLayerId) : undefined;
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
const canUngroup = selectedLayer?.layer.type === "group";
const maskLayerIds = documentIndex.maskLayerIds;
return (
<>
<header className="flex h-20 items-center">
<div className="flex w-full items-center gap-2">
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add artboard" title="Add artboard" onClick={() => addArtboard(document, dispatch)}>
@@ -56,10 +89,10 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
<button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
<Stack size={24} weight="fill" />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Move layer up" title="Move layer up" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, -1, dispatch)}>
<button type="button" className={toolbarButtonClass()} aria-label="Move layer up" title="Move layer up" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(documentIndex, selectedLayer, -1, dispatch)}>
<ArrowUp size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Move layer down" title="Move layer down" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, 1, dispatch)}>
<button type="button" className={toolbarButtonClass()} aria-label="Move layer down" title="Move layer down" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(documentIndex, selectedLayer, 1, dispatch)}>
<ArrowDown size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Delete selection" title="Delete selection" disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
@@ -67,7 +100,10 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto pb-2">
{document.artboards.map((artboard) => (
{document.artboards.map((artboard) => {
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
return (
<section key={artboard.id} className="mb-5 last:mb-0">
<div
className={`flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-white text-black" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
@@ -118,16 +154,17 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
>
<DownloadSimple size={24} weight="regular" />
</button>
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-8 rounded-full bg-black/10 px-2 py-1 text-center text-xs text-black/45" : "min-w-8 rounded-full bg-white/10 px-2 py-1 text-center text-xs text-white/45"}>{countDisplayLayers(artboard.layers, maskLayerIds)}</span>
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-8 rounded-full bg-black/10 px-2 py-1 text-center text-xs text-black/45" : "min-w-8 rounded-full bg-white/10 px-2 py-1 text-center text-xs text-white/45"}>{displayLayerCount}</span>
</div>
<div className="mt-2 space-y-2 pl-5">
{countDisplayLayers(artboard.layers, maskLayerIds) === 0 ? (
{displayLayerCount === 0 ? (
<div className="rounded-[1.5rem] border border-dashed border-white/10 px-4 py-5 text-center text-white/35">No layers yet</div>
) : (
artboard.layers.map((layer) => (
<LayerRow
key={layer.id}
document={document}
documentIndex={documentIndex}
artboardId={artboard.id}
layer={layer}
depth={0}
@@ -143,14 +180,16 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
)}
</div>
</section>
))}
);
})}
</div>
</aside>
</>
);
}
function LayerRow({
document,
documentIndex,
artboardId,
layer,
depth,
@@ -163,6 +202,7 @@ function LayerRow({
dispatch,
}: {
document: ImageDocument;
documentIndex: DocumentReadIndex;
artboardId: ArtboardId;
layer: Layer;
depth: number;
@@ -177,8 +217,8 @@ function LayerRow({
if (maskLayerIds.has(layer.id)) return null;
const selected = selectedLayerIds.includes(layer.id);
const layerInfo = findLayerInfoInDocument(document, layer.id);
const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
const layerInfo = documentIndex.layerInfoById.get(layer.id);
const maskLayer = layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask);
const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId);
const rowPadding = 12 + depth * 16;
@@ -238,7 +278,7 @@ function LayerRow({
<button
type="button"
className={editingMask || selected ? "rounded-full bg-black/10 px-3 py-1 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-3 py-1 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
onClick={() => addLayerMask(document, layerInfo, dispatch)}
onClick={() => addLayerMask(documentIndex, layerInfo, dispatch)}
>
Add mask
</button>
@@ -278,6 +318,7 @@ function LayerRow({
<LayerRow
key={child.id}
document={document}
documentIndex={documentIndex}
artboardId={artboardId}
layer={child}
depth={depth + 1}
@@ -319,12 +360,12 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
);
}
function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: AppStore["dispatch"]) {
function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
const layer = layerInfo.layer;
if (layer.type === "group") return;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const asset = documentIndex.assetById.get(layer.assetId);
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
if (!asset || !bounds) return;
const assetId = crypto.randomUUID();
@@ -359,30 +400,6 @@ function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: A
});
}
function collectDocumentMaskLayerIds(document: ImageDocument): Set<string> {
const ids = new Set<string>();
for (const artboard of document.artboards) collectMaskLayerIds(artboard.layers, ids);
return ids;
}
function collectMaskLayerIds(layers: readonly Layer[], ids: Set<string>): Set<string> {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
return ids;
}
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<string>): number {
let count = 0;
for (const layer of layers) {
if (maskLayerIds.has(layer.id)) continue;
count += 1;
if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds);
}
return count;
}
function dropLayer(
document: ImageDocument,
sourceLayerId: string,
@@ -400,7 +417,7 @@ function dropLayer(
if (command) dispatch(commandIds.documentMoveLayer, command);
}
function deleteSelection(selection: SelectionState, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) {
function deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
if (selectedLayer) {
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
return;
@@ -417,7 +434,7 @@ function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
});
}
function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) {
function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
if (!artboard) return;
@@ -461,11 +478,9 @@ function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: Ap
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
}
function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers;
if (!siblings) return;
const maskLayerIds = collectMaskLayerIds(siblings, new Set<string>());
function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
const siblings = info.siblings;
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
const blocks = siblings.flatMap((layer, index) => {
if (maskLayerIds.has(layer.id)) return [];
@@ -491,7 +506,9 @@ function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1,
});
}
function createGroup(name: string): Layer {
const emptyLayerIds = new Set<string>();
function createGroup(name: string): Extract<Layer, { type: "group" }> {
return {
id: crypto.randomUUID(),
type: "group",

View File

@@ -1,10 +1,7 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { CaretDown, CaretUp } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { AppStore } from "@editor/store";
import type { SelectionState, ViewportState } from "@editor/state";
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
import { BottomControlSlider } from "./Slider";
@@ -25,9 +22,6 @@ const sizePresets = [
] as const;
export type GenerateControlsProps = {
document: ImageDocument;
selection: SelectionState;
viewport: ViewportState;
settings: GenerateSettings;
dispatch: AppStore["dispatch"];
};
@@ -226,109 +220,3 @@ function panelLabelClass() {
function panelTextAreaClass(extra = "") {
return `${extra} resize-none rounded-[1.25rem] bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:bg-white/[0.07] focus:ring-2 focus:ring-white/30`;
}
async function generateImage(options: GenerateControlsProps & { setBusy: (busy: boolean) => void; setError: (error: string | undefined) => void }) {
const { document, selection, viewport, settings, dispatch, setBusy, setError } = options;
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
if (!artboard) return;
setBusy(true);
setError(undefined);
try {
const target = resolveSelectedImage(document, selection);
const inputImage = target && settings.mode !== "text-to-image" ? await imageSourceToDataUrl(target.asset.source) : undefined;
const maskImage = target?.maskAsset && settings.mode === "inpaint" ? await imageSourceToDataUrl(target.maskAsset.source) : undefined;
const response = await fetch("/api/comfy/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode: settings.mode,
model: settings.model,
prompt: settings.prompt,
negativePrompt: settings.negativePrompt,
strength: settings.strength,
steps: settings.steps,
cfg: settings.cfg,
seed: settings.seed,
sampler: settings.sampler,
scheduler: settings.scheduler,
width: settings.width,
height: settings.height,
outpaint: settings.outpaint,
inputImage,
maskImage,
}),
});
if (!response.ok) throw new Error(await response.text());
const generated = await response.json() as { source: string; mimeType: string };
const intrinsicSize = await loadImageSize(generated.source);
const assetId = crypto.randomUUID();
const layerId = crypto.randomUUID();
dispatch(commandIds.documentAddAsset, { asset: { id: assetId, name: "Generated image", mimeType: generated.mimeType, source: generated.source, intrinsicSize } });
dispatch(commandIds.documentAddImageLayer, {
artboardId: artboard.id,
layer: {
id: layerId,
type: "image",
name: "Generated image",
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: viewport.center.x - intrinsicSize.w / 2, y: viewport.center.y - intrinsicSize.h / 2 }, scale: { x: 1, y: 1 }, rotation: 0 },
},
});
dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] });
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Generation failed");
} finally {
setBusy(false);
}
}
function resolveSelectedImage(document: ImageDocument, selection: SelectionState) {
const layerId = selection.layerIds[0];
if (!layerId) return undefined;
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset ? { layer, asset, maskAsset } : undefined;
}
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const found = findLayer(layer.children, layerId);
if (found) return found;
}
}
return undefined;
}
async function imageSourceToDataUrl(source: string) {
if (source.startsWith("data:")) return source;
const image = await loadImage(source);
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("Unable to read selected image");
context.drawImage(image, 0, 0);
return canvas.toDataURL("image/png");
}
function loadImageSize(source: string): Promise<{ w: number; h: number }> {
return loadImage(source).then((image) => ({ w: image.naturalWidth, h: image.naturalHeight }));
}
function loadImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image"));
image.src = source;
});
}

View File

@@ -3,31 +3,69 @@ import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { RasterLayer } from "@core/raster-layer";
import type { EditorState } from "@editor/state";
import { isPanInteractionMode } from "@editor/tools";
import type { MaskEditState, SelectionState } from "@editor/state";
import { isPanInteractionMode, type ToolState } from "@editor/tools";
import type { AppStore } from "@editor/store";
export type BrushSession = {
layerId: string;
assetId: string;
width: number;
height: number;
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
ready: Promise<boolean>;
previousPoint: Vec2D;
mode: "brush" | "eraser";
source?: string;
changed?: boolean;
pending?: Promise<void>;
cancelled?: boolean;
previewClosed?: boolean;
previewRequested?: boolean;
previewInFlight?: boolean;
previewFrame?: number;
previewSource?: string;
};
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
export type BrushTargetEditorState = {
selection: SelectionState;
tools: Pick<ToolState, "activeTool" | "interactionMode">;
maskEdit?: MaskEditState;
};
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
const layer = resolveBrushTargetLayer(document, editor);
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return undefined;
const canvas = globalThis.document.createElement("canvas");
canvas.width = Math.max(1, Math.round(asset.intrinsicSize.w));
canvas.height = Math.max(1, Math.round(asset.intrinsicSize.h));
const context = canvas.getContext("2d");
if (!context) return undefined;
const session: BrushSession = {
layerId: layer.id,
assetId: layer.assetId,
width: canvas.width,
height: canvas.height,
canvas,
context,
ready: Promise.resolve(false),
previousPoint: point,
mode: editor.tools.activeTool,
};
session.ready = initializeBrushSession(session, asset.source).catch(() => false);
return session;
}
export function canPreviewBrush(document: ImageDocument, editor: EditorState): boolean {
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
return Boolean(resolveBrushTargetLayer(document, editor));
}
export function brushUnavailableHint(document: ImageDocument, editor: EditorState): string | undefined {
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
if (resolveBrushTargetLayer(document, editor)) return undefined;
@@ -46,7 +84,7 @@ export function brushUnavailableHint(document: ImageDocument, editor: EditorStat
return "Select a raster layer or layer mask to paint.";
}
function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined {
function resolveBrushTargetLayer(document: ImageDocument, editor: BrushTargetEditorState): RasterLayer | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const editingMask = Boolean(editor.maskEdit);
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
@@ -66,10 +104,7 @@ export function updateBrushSession(options: {
}): BrushSession {
const state = options.store.getState();
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
if (!layer) return options.session;
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return options.session;
if (!layer || layer.assetId !== options.session.assetId) return options.session;
const from = options.session.previousPoint;
const to = options.point;
@@ -77,13 +112,12 @@ export function updateBrushSession(options: {
options.session.pending = (options.session.pending ?? Promise.resolve())
.then(async () => {
if (options.session.cancelled) return;
if (!(await options.session.ready) || options.session.cancelled) return;
const source = await drawStroke({
source: options.session.source ?? asset.source,
width: asset.intrinsicSize.w,
height: asset.intrinsicSize.h,
from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
drawStrokeSegment({
context: options.session.context,
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
color: state.editor.maskEdit ? "#ffffff" : options.color,
size: options.size,
hardness: options.hardness,
@@ -91,8 +125,8 @@ export function updateBrushSession(options: {
});
if (options.session.cancelled) return;
options.session.source = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
options.session.changed = true;
requestBrushStrokePreview({ store: options.store, session: options.session });
})
.catch(() => undefined);
@@ -103,13 +137,16 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
await options.session.pending;
if (options.session.cancelled) return;
if (options.session.source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source: options.session.source });
const source = options.session.changed ? canvasToDataUrl(options.session.canvas) : undefined;
if (source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
closeBrushStrokePreview(options.session);
}
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
options.session.cancelled = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
closeBrushStrokePreview(options.session);
}
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
@@ -119,10 +156,17 @@ function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: numb
};
}
async function drawStroke(options: {
source: string;
width: number;
height: number;
async function initializeBrushSession(session: BrushSession, source: string) {
const image = await loadImage(source);
if (session.cancelled) return false;
session.context.clearRect(0, 0, session.width, session.height);
session.context.drawImage(image, 0, 0, session.width, session.height);
return true;
}
function drawStrokeSegment(options: {
context: CanvasRenderingContext2D;
from: Vec2D;
to: Vec2D;
color: string;
@@ -130,15 +174,9 @@ async function drawStroke(options: {
hardness: number;
mode: "brush" | "eraser";
}) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(options.width));
canvas.height = Math.max(1, Math.round(options.height));
const context = canvas.getContext("2d");
if (!context) return options.source;
const image = await loadImage(options.source);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const { context } = options;
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
context.save();
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
context.strokeStyle = options.color;
context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color;
@@ -150,9 +188,73 @@ async function drawStroke(options: {
context.moveTo(options.from.x, options.from.y);
context.lineTo(options.to.x, options.to.y);
context.stroke();
context.globalCompositeOperation = "source-over";
context.restore();
}
return canvas.toDataURL("image/png");
function requestBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
if (options.session.cancelled || options.session.previewClosed) return;
options.session.previewRequested = true;
if (options.session.previewFrame !== undefined || options.session.previewInFlight) return;
options.session.previewFrame = requestAnimationFrame(() => {
options.session.previewFrame = undefined;
void publishBrushStrokePreview(options);
});
}
async function publishBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
if (options.session.cancelled || options.session.previewClosed || !options.session.previewRequested) return;
options.session.previewRequested = false;
options.session.previewInFlight = true;
const source = await canvasToObjectUrl(options.session.canvas).catch(() => undefined);
options.session.previewInFlight = false;
if (!source) {
if (options.session.previewRequested) requestBrushStrokePreview(options);
return;
}
if (options.session.cancelled || options.session.previewClosed) {
URL.revokeObjectURL(source);
return;
}
const previousSource = options.session.previewSource;
options.session.previewSource = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
if (previousSource) URL.revokeObjectURL(previousSource);
if (options.session.previewRequested) requestBrushStrokePreview(options);
}
function closeBrushStrokePreview(session: BrushSession) {
session.previewClosed = true;
if (session.previewFrame !== undefined) {
cancelAnimationFrame(session.previewFrame);
session.previewFrame = undefined;
}
if (session.previewSource) {
URL.revokeObjectURL(session.previewSource);
session.previewSource = undefined;
}
}
function canvasToObjectUrl(canvas: HTMLCanvasElement) {
return new Promise<string | undefined>((resolve) => {
canvas.toBlob((blob) => {
resolve(blob ? URL.createObjectURL(blob) : undefined);
}, "image/png");
});
}
function canvasToDataUrl(canvas: HTMLCanvasElement) {
try {
return canvas.toDataURL("image/png");
} catch {
return undefined;
}
}
function loadImage(source: string) {

View File

@@ -0,0 +1,178 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import type { AppState } from "@editor/state";
import { canvasRenderFramesEqual, selectCanvasRenderFrame } from "./renderFrame";
describe("canvas render frame selection", () => {
test("ignores non-visual tool and history state", () => {
const state = createInitialAppState("Test");
const next: AppState = {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
generate: {
...state.editor.tools.generate,
prompt: "new prompt",
steps: state.editor.tools.generate.steps + 1,
},
chromaKey: {
...state.editor.tools.chromaKey,
tolerance: state.editor.tools.chromaKey.tolerance + 1,
},
magicWand: {
...state.editor.tools.magicWand,
contiguous: !state.editor.tools.magicWand.contiguous,
},
},
},
history: { past: [{ document: state.document, editor: state.editor }], future: [] },
};
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(next))).toBe(true);
});
test("detects document changes", () => {
const state = createInitialAppState("Test");
const next = {
...state,
document: {
...state.document,
name: "Changed",
},
};
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(next))).toBe(false);
});
test("compares stable visual editor fields structurally", () => {
const state = createInitialAppState("Test");
const sameSelectionValues = {
...state,
editor: {
...state.editor,
selection: {
artboardId: state.editor.selection.artboardId,
layerIds: [...state.editor.selection.layerIds],
},
},
};
const changedBrush = {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
brush: {
...state.editor.tools.brush,
size: state.editor.tools.brush.size + 1,
},
},
},
};
const changedPreview = {
...state,
editor: {
...state.editor,
brushPreview: { position: { x: 10, y: 20 } },
},
};
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(sameSelectionValues))).toBe(true);
expectFrameChanged(state, changedBrush);
expectFrameChanged(state, changedPreview);
});
for (const [name, change] of visualEditorChanges) {
test(`detects ${name} changes`, () => {
const state = createInitialAppState("Test");
expectFrameChanged(state, change(state));
});
}
});
const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
[
"viewport",
(state) => ({
...state,
editor: {
...state.editor,
viewport: {
...state.editor.viewport,
center: { x: state.editor.viewport.center.x + 1, y: state.editor.viewport.center.y },
},
},
}),
],
[
"selection",
(state) => ({
...state,
editor: {
...state.editor,
selection: { artboardId: "artboard", layerIds: ["layer"] },
},
}),
],
[
"transform session",
(state) => ({
...state,
editor: {
...state.editor,
transformSession: {
target: { type: "artboard", id: "artboard" },
handle: "body",
startPoint: { x: 1, y: 2 },
initialBounds: { x: 0, y: 0, w: 100, h: 80 },
},
},
}),
],
[
"mask edit",
(state) => ({
...state,
editor: {
...state.editor,
maskEdit: { targetLayerId: "target", maskLayerId: "mask", viewMode: "overlay" },
},
}),
],
[
"brush stroke preview",
(state) => ({
...state,
editor: {
...state.editor,
brushStrokePreview: { layerId: "layer", assetId: "asset", source: "preview" },
},
}),
],
[
"active tool",
(state) => ({
...state,
editor: {
...state.editor,
tools: { ...state.editor.tools, activeTool: "brush" },
},
}),
],
[
"interaction mode",
(state) => ({
...state,
editor: {
...state.editor,
tools: { ...state.editor.tools, interactionMode: { type: "temporary-pan", previousTool: "select" } },
},
}),
],
];
function expectFrameChanged(previous: AppState, next: AppState) {
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(previous), selectCanvasRenderFrame(next))).toBe(false);
}

View File

@@ -0,0 +1,87 @@
import type { Rect, Vec2D } from "@core/geometry";
import type { RenderFrame } from "@renderer/index";
import type { AppState, BrushPreviewState, BrushStrokePreviewState, EditorState, MaskEditState, SelectionState, ViewportState } from "@editor/state";
import type { BrushSettings, InteractionMode } from "@editor/tools";
import type { TransformSession, TransformTarget } from "@editor/transform";
export function selectCanvasRenderFrame(state: AppState): RenderFrame {
return {
document: state.document,
editor: state.editor,
};
}
export function canvasRenderFramesEqual(a: RenderFrame, b: RenderFrame): boolean {
return a.document === b.document && visualEditorStatesEqual(a.editor, b.editor);
}
function visualEditorStatesEqual(a: EditorState, b: EditorState): boolean {
return (
viewportStatesEqual(a.viewport, b.viewport) &&
selectionStatesEqual(a.selection, b.selection) &&
transformSessionsEqual(a.transformSession, b.transformSession) &&
maskEditStatesEqual(a.maskEdit, b.maskEdit) &&
brushPreviewStatesEqual(a.brushPreview, b.brushPreview) &&
brushStrokePreviewStatesEqual(a.brushStrokePreview, b.brushStrokePreview) &&
visualToolStatesEqual(a.tools, b.tools)
);
}
function viewportStatesEqual(a: ViewportState, b: ViewportState): boolean {
return a.zoom === b.zoom && a.rotation === b.rotation && vec2Equal(a.center, b.center) && a.size.w === b.size.w && a.size.h === b.size.h;
}
function selectionStatesEqual(a: SelectionState, b: SelectionState): boolean {
if (a.artboardId !== b.artboardId || a.layerIds.length !== b.layerIds.length) return false;
return a.layerIds.every((layerId, index) => layerId === b.layerIds[index]);
}
function transformSessionsEqual(a: TransformSession | undefined, b: TransformSession | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return transformTargetsEqual(a.target, b.target) && a.handle === b.handle && vec2Equal(a.startPoint, b.startPoint) && rectsEqual(a.initialBounds, b.initialBounds);
}
function transformTargetsEqual(a: TransformTarget, b: TransformTarget): boolean {
return a.type === b.type && a.id === b.id;
}
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
}
function brushPreviewStatesEqual(a: BrushPreviewState | undefined, b: BrushPreviewState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return vec2Equal(a.position, b.position);
}
function brushStrokePreviewStatesEqual(a: BrushStrokePreviewState | undefined, b: BrushStrokePreviewState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.layerId === b.layerId && a.assetId === b.assetId && a.source === b.source;
}
function visualToolStatesEqual(a: EditorState["tools"], b: EditorState["tools"]): boolean {
return a.activeTool === b.activeTool && interactionModesEqual(a.interactionMode, b.interactionMode) && brushSettingsEqual(a.brush, b.brush);
}
function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean {
if (a.type !== b.type) return false;
if (a.type === "temporary-pan") return b.type === "temporary-pan" && a.previousTool === b.previousTool;
return b.type === "tool" && a.tool === b.tool;
}
function brushSettingsEqual(a: BrushSettings, b: BrushSettings): boolean {
return a.color === b.color && a.size === b.size && a.hardness === b.hardness;
}
function vec2Equal(a: Vec2D, b: Vec2D): boolean {
return a.x === b.x && a.y === b.y;
}
function rectsEqual(a: Rect, b: Rect): boolean {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}

View File

@@ -12,7 +12,7 @@ import {
pointerInputEventFromPointerEvent,
wheelInputEventFromWheelEvent,
} from "@input/index";
import { beginBrushSession, canPreviewBrush, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
import { applyMagicWandAt } from "./magic-wand";
export type CanvasInputOptions = {
@@ -31,12 +31,13 @@ export function useCanvasInput(
options: CanvasInputOptions,
): CanvasInputState {
const [isPanning, setIsPanning] = useState(false);
const brushSession = useRef<BrushSession>();
const brushSession = useRef<BrushSession | undefined>(undefined);
const brushSessionId = useRef(0);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
const transformHandler = createTransformControlsInputController({
getDocument: () => store.getState().document,
@@ -71,6 +72,18 @@ export function useCanvasInput(
store.dispatch(commandIds.toolSetBrushPreview, { position: viewportPointToDocumentPoint(position, state.editor.viewport) });
};
const commitActiveBrushSession = (position: { x: number; y: number }) => {
const session = brushSession.current;
if (!session) return;
const sessionId = brushSessionId.current + 1;
brushSessionId.current = sessionId;
brushSession.current = undefined;
void commitBrushSession({ store, session }).then(() => {
if (!disposed && brushSessionId.current === sessionId && !brushSession.current) updateBrushPreview(position);
});
};
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableKeyboardTarget(event.target)) return;
@@ -142,10 +155,7 @@ export function useCanvasInput(
const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) {
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
const session = brushSession.current;
brushSessionId.current += 1;
brushSession.current = undefined;
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
commitActiveBrushSession(inputEvent.position);
event.preventDefault();
return;
}
@@ -178,10 +188,7 @@ export function useCanvasInput(
const handlePointerUp = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) {
const session = brushSession.current;
brushSessionId.current += 1;
brushSession.current = undefined;
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
commitActiveBrushSession(inputEvent.position);
event.preventDefault();
return;
}
@@ -226,6 +233,12 @@ export function useCanvasInput(
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => {
disposed = true;
brushSessionId.current += 1;
if (brushSession.current) {
cancelBrushSession({ store, session: brushSession.current });
brushSession.current = undefined;
}
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
canvas.removeEventListener("pointerdown", handlePointerDown);

View File

@@ -1,6 +1,7 @@
import { useEffect, type RefObject } from "react";
import type { AppStore } from "@editor/store";
import { createRenderer } from "@renderer/index";
import { createRenderer, type RenderFrame } from "@renderer/index";
import { canvasRenderFramesEqual, selectCanvasRenderFrame } from "./renderFrame";
export function useCanvasRenderer(canvasRef: RefObject<HTMLCanvasElement | null>, store: AppStore) {
useEffect(() => {
@@ -8,14 +9,38 @@ export function useCanvasRenderer(canvasRef: RefObject<HTMLCanvasElement | null>
if (!canvas) return;
const renderer = createRenderer(canvas);
renderer.render(store.getState());
let renderedFrame = selectCanvasRenderFrame(store.getState());
let pendingFrame: RenderFrame | undefined;
let animationFrame: number | undefined;
const flushRender = () => {
animationFrame = undefined;
const frame = pendingFrame;
if (!frame) return;
pendingFrame = undefined;
renderedFrame = frame;
renderer.render(frame);
};
const scheduleRender = (frame: RenderFrame) => {
pendingFrame = frame;
if (animationFrame !== undefined) return;
animationFrame = requestAnimationFrame(flushRender);
};
scheduleRender(renderedFrame);
const unsubscribe = store.subscribe((state) => {
renderer.render(state);
const nextFrame = selectCanvasRenderFrame(state);
const currentFrame = pendingFrame ?? renderedFrame;
if (canvasRenderFramesEqual(currentFrame, nextFrame)) return;
scheduleRender(nextFrame);
});
return () => {
unsubscribe();
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
renderer.dispose();
};
}, [canvasRef, store]);

View File

@@ -12,7 +12,7 @@ export async function runGenerate(options: {
settings: GenerateSettings;
dispatch: AppStore["dispatch"];
}) {
const { document, selection, viewport, settings, dispatch } = options;
const { document, selection, settings, dispatch } = options;
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
if (!artboard) return;

68
view/useAppState.test.ts Normal file
View File

@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import type { AppState } from "@editor/state";
import { createAppStateSelectorSnapshot, shallowEqual } from "./useAppState";
describe("app state selector snapshot", () => {
test("reuses selected snapshots when equality reports no relevant changes", () => {
const state = createInitialAppState("Test");
const snapshot = createAppStateSelectorSnapshot(selectShellLikeState, shallowEqual);
const selected = snapshot.getSnapshot(state);
const previewOnlyUpdate: AppState = {
...state,
editor: {
...state.editor,
brushStrokePreview: { layerId: "layer", assetId: "asset", source: "preview" },
},
};
expect(snapshot.updateSnapshot(previewOnlyUpdate)).toBe(false);
expect(snapshot.getSnapshot(previewOnlyUpdate)).toBe(selected);
});
test("can collapse brush preview updates to presence instead of position", () => {
const state = createInitialAppState("Test");
const snapshot = createAppStateSelectorSnapshot((next: AppState) => ({ hasBrushPreview: Boolean(next.editor.brushPreview) }), shallowEqual);
const withoutPreview = snapshot.getSnapshot(state);
const withPreview: AppState = {
...state,
editor: {
...state.editor,
brushPreview: { position: { x: 10, y: 20 } },
},
};
expect(snapshot.updateSnapshot(withPreview)).toBe(true);
const previewPresent = snapshot.getSnapshot(withPreview);
expect(previewPresent).not.toBe(withoutPreview);
expect(previewPresent.hasBrushPreview).toBe(true);
const movedPreview: AppState = {
...withPreview,
editor: {
...withPreview.editor,
brushPreview: { position: { x: 30, y: 40 } },
},
};
expect(snapshot.updateSnapshot(movedPreview)).toBe(false);
expect(snapshot.getSnapshot(movedPreview)).toBe(previewPresent);
});
test("shallow equality uses object keys and Object.is value checks", () => {
expect(shallowEqual({ id: "a", value: Number.NaN }, { id: "a", value: Number.NaN })).toBe(true);
expect(shallowEqual({ id: "a", value: 0 }, { id: "a", value: -0 })).toBe(false);
expect(shallowEqual({ id: "a" }, { id: "a", extra: true })).toBe(false);
});
});
function selectShellLikeState(state: AppState) {
return {
document: state.document,
selection: state.editor.selection,
viewport: state.editor.viewport,
tools: state.editor.tools,
transformSession: state.editor.transformSession,
maskEdit: state.editor.maskEdit,
};
}

View File

@@ -1,7 +1,102 @@
import { useSyncExternalStore } from "react";
import { useCallback, useRef, useSyncExternalStore } from "react";
import type { AppStore } from "@editor/store";
import type { AppState } from "@editor/state";
export function useAppState(store: AppStore): AppState {
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
export type AppStateSelector<TSelection> = (state: AppState) => TSelection;
export type AppStateEquality<TSelection> = (a: TSelection, b: TSelection) => boolean;
type AppStateSelectorSnapshot<TSelection> = {
getSnapshot(state: AppState): TSelection;
updateSnapshot(state: AppState): boolean;
};
type AppStateSelectorRef<TSelection> = {
selector: AppStateSelector<TSelection>;
isEqual: AppStateEquality<TSelection>;
snapshot: AppStateSelectorSnapshot<TSelection>;
};
const selectAppState = (state: AppState) => state;
export function useAppState(store: AppStore): AppState;
export function useAppState<TSelection>(
store: AppStore,
selector: AppStateSelector<TSelection>,
isEqual?: AppStateEquality<TSelection>,
): TSelection;
export function useAppState<TSelection>(
store: AppStore,
selector: AppStateSelector<TSelection> = selectAppState as AppStateSelector<TSelection>,
isEqual: AppStateEquality<TSelection> = Object.is,
): TSelection {
const selectorRef = useRef<AppStateSelectorRef<TSelection> | undefined>(undefined);
if (!selectorRef.current || selectorRef.current.selector !== selector || selectorRef.current.isEqual !== isEqual) {
selectorRef.current = {
selector,
isEqual,
snapshot: createAppStateSelectorSnapshot(selector, isEqual),
};
}
const getSnapshot = useCallback(() => selectorRef.current!.snapshot.getSnapshot(store.getState()), [store]);
const subscribe = useCallback(
(onStoreChange: () => void) =>
store.subscribe((state) => {
if (selectorRef.current!.snapshot.updateSnapshot(state)) onStoreChange();
}),
[store],
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function createAppStateSelectorSnapshot<TSelection>(
selector: AppStateSelector<TSelection>,
isEqual: AppStateEquality<TSelection> = Object.is,
): AppStateSelectorSnapshot<TSelection> {
let stateSnapshot: AppState | undefined;
let selectedSnapshot: TSelection | undefined;
let hasSnapshot = false;
const setSnapshot = (state: AppState, selected: TSelection) => {
stateSnapshot = state;
selectedSnapshot = selected;
hasSnapshot = true;
};
return {
getSnapshot(state) {
if (hasSnapshot && stateSnapshot === state) return selectedSnapshot as TSelection;
const selected = selector(state);
if (hasSnapshot && isEqual(selectedSnapshot as TSelection, selected)) {
stateSnapshot = state;
return selectedSnapshot as TSelection;
}
setSnapshot(state, selected);
return selected;
},
updateSnapshot(state) {
const selected = selector(state);
if (hasSnapshot && isEqual(selectedSnapshot as TSelection, selected)) {
stateSnapshot = state;
return false;
}
setSnapshot(state, selected);
return true;
},
};
}
export function shallowEqual<TObject extends object>(a: TObject, b: TObject): boolean {
if (Object.is(a, b)) return true;
const aKeys = Object.keys(a) as Array<keyof TObject>;
const bKeys = Object.keys(b) as Array<keyof TObject>;
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && Object.is(a[key], b[key]));
}