Compare commits
8 Commits
7188569672
...
6e5d58a638
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5d58a638 | ||
|
|
1a43d1f0e5 | ||
|
|
88208ea1ad | ||
|
|
1acfcbe4a5 | ||
|
|
a030f2065a | ||
|
|
ef0ce15bb5 | ||
|
|
dfa2bffeda | ||
|
|
d9e5e05183 |
@@ -4,8 +4,14 @@ export type CommandContext = {
|
|||||||
state: AppState;
|
state: AppState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CommandHistoryPolicy =
|
||||||
|
| { mode: "auto" }
|
||||||
|
| { mode: "ignore" }
|
||||||
|
| { mode: "deferred"; phase: "begin" | "update" | "commit" };
|
||||||
|
|
||||||
export type Command<TPayload = void> = {
|
export type Command<TPayload = void> = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
history?: CommandHistoryPolicy;
|
||||||
execute(context: CommandContext, payload: TPayload): AppState;
|
execute(context: CommandContext, payload: TPayload): AppState;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { Command } from "./command";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
import { createAppStore } from "@editor/store";
|
import { createAppStore } from "@editor/store";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
import { createCommandRegistry } from "./registry";
|
import { createCommandRegistry } from "./registry";
|
||||||
import { viewportPanCommand } from "./viewport";
|
import { viewportPanCommand } from "./viewport";
|
||||||
|
|
||||||
|
const noOpCommand: Command<unknown> = {
|
||||||
|
id: "test.noop",
|
||||||
|
name: "No-op",
|
||||||
|
execute: ({ state }) => state,
|
||||||
|
};
|
||||||
|
|
||||||
describe("command dispatcher", () => {
|
describe("command dispatcher", () => {
|
||||||
test("throws for unknown commands", () => {
|
test("throws for unknown commands", () => {
|
||||||
const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([]));
|
const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([]));
|
||||||
@@ -16,4 +23,23 @@ describe("command dispatcher", () => {
|
|||||||
store.dispatch(commandIds.viewportPan, { delta: { x: 3, y: 7 } });
|
store.dispatch(commandIds.viewportPan, { delta: { x: 3, y: 7 } });
|
||||||
expect(store.getState().editor.viewport.center).toEqual({ x: 3, y: 7 });
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { AppState, HistorySnapshot } from "@editor/state";
|
import type { AppState, HistorySnapshot } from "@editor/state";
|
||||||
import type { CommandContext } from "./command";
|
import type { CommandContext, CommandHistoryPolicy } from "./command";
|
||||||
import { commandIds } from "./ids";
|
|
||||||
import type { CommandId, CommandPayloads } from "./payloads";
|
import type { CommandId, CommandPayloads } from "./payloads";
|
||||||
import type { CommandRegistry } from "./registry";
|
import type { CommandRegistry } from "./registry";
|
||||||
|
|
||||||
@@ -15,6 +14,8 @@ export function createCommandDispatcher(options: {
|
|||||||
getState: () => AppState;
|
getState: () => AppState;
|
||||||
setState: (state: AppState) => void;
|
setState: (state: AppState) => void;
|
||||||
}): CommandDispatcher {
|
}): CommandDispatcher {
|
||||||
|
let deferredHistory: { snapshot: HistorySnapshot; changed: boolean } | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dispatch(commandId, payload) {
|
dispatch(commandId, payload) {
|
||||||
const command = options.registry.get(commandId);
|
const command = options.registry.get(commandId);
|
||||||
@@ -23,25 +24,91 @@ export function createCommandDispatcher(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentState = options.getState();
|
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 context: CommandContext = { state: currentState };
|
||||||
const executedState = command.execute(context, payload);
|
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);
|
options.setState(nextState);
|
||||||
return nextState;
|
return nextState;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRecordHistory(commandId: CommandId, currentState: AppState, nextState: AppState) {
|
const defaultHistoryPolicy: CommandHistoryPolicy = { mode: "auto" };
|
||||||
if (commandId === commandIds.historyUndo || commandId === commandIds.historyRedo) return false;
|
|
||||||
|
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;
|
return currentState.document !== nextState.document;
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordHistory(currentState: AppState, nextState: AppState): AppState {
|
function recordHistory(historySnapshot: HistorySnapshot, nextState: AppState): AppState {
|
||||||
return {
|
return {
|
||||||
...nextState,
|
...nextState,
|
||||||
history: {
|
history: {
|
||||||
past: [...currentState.history.past, snapshot(currentState)].slice(-100),
|
past: [...nextState.history.past, historySnapshot].slice(-100),
|
||||||
future: [],
|
future: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { documentAddArtboardCommand } from "./document";
|
|||||||
import { historyCommands } from "./history";
|
import { historyCommands } from "./history";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
import { createCommandRegistry } from "./registry";
|
import { createCommandRegistry } from "./registry";
|
||||||
|
import { transformCommands } from "./transform";
|
||||||
|
|
||||||
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands]);
|
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands, ...transformCommands]);
|
||||||
|
|
||||||
describe("history commands", () => {
|
describe("history commands", () => {
|
||||||
test("records document changes and undoes/redoes them", () => {
|
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"]);
|
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 } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { commandIds } from "./ids";
|
|||||||
export const historyUndoCommand: Command = {
|
export const historyUndoCommand: Command = {
|
||||||
id: commandIds.historyUndo,
|
id: commandIds.historyUndo,
|
||||||
name: "Undo",
|
name: "Undo",
|
||||||
|
history: { mode: "ignore" },
|
||||||
execute({ state }) {
|
execute({ state }) {
|
||||||
const previous = state.history.past.at(-1);
|
const previous = state.history.past.at(-1);
|
||||||
if (!previous) return state;
|
if (!previous) return state;
|
||||||
@@ -23,6 +24,7 @@ export const historyUndoCommand: Command = {
|
|||||||
export const historyRedoCommand: Command = {
|
export const historyRedoCommand: Command = {
|
||||||
id: commandIds.historyRedo,
|
id: commandIds.historyRedo,
|
||||||
name: "Redo",
|
name: "Redo",
|
||||||
|
history: { mode: "ignore" },
|
||||||
execute({ state }) {
|
execute({ state }) {
|
||||||
const next = state.history.future[0];
|
const next = state.history.future[0];
|
||||||
if (!next) return state;
|
if (!next) return state;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type TransformSetBoundsPayload = {
|
|||||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||||
id: commandIds.transformBegin,
|
id: commandIds.transformBegin,
|
||||||
name: "Begin transform",
|
name: "Begin transform",
|
||||||
|
history: { mode: "deferred", phase: "begin" },
|
||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -43,6 +44,7 @@ export const transformBeginCommand: Command<TransformBeginPayload> = {
|
|||||||
export const transformUpdateCommand: Command<TransformUpdatePayload> = {
|
export const transformUpdateCommand: Command<TransformUpdatePayload> = {
|
||||||
id: commandIds.transformUpdate,
|
id: commandIds.transformUpdate,
|
||||||
name: "Update transform",
|
name: "Update transform",
|
||||||
|
history: { mode: "deferred", phase: "update" },
|
||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
const session = state.editor.transformSession;
|
const session = state.editor.transformSession;
|
||||||
if (!session) return state;
|
if (!session) return state;
|
||||||
@@ -78,6 +80,7 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
|
|||||||
export const transformEndCommand: Command = {
|
export const transformEndCommand: Command = {
|
||||||
id: commandIds.transformEnd,
|
id: commandIds.transformEnd,
|
||||||
name: "End transform",
|
name: "End transform",
|
||||||
|
history: { mode: "deferred", phase: "commit" },
|
||||||
execute({ state }) {
|
execute({ state }) {
|
||||||
if (!state.editor.transformSession) return state;
|
if (!state.editor.transformSession) return state;
|
||||||
|
|
||||||
|
|||||||
105
editor/document-indexes.test.ts
Normal file
105
editor/document-indexes.test.ts
Normal 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
164
editor/document-indexes.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -4,3 +4,5 @@ export { initialToolState } from "./tools";
|
|||||||
export { createInitialAppState, initialEditorState } from "./initial-state";
|
export { createInitialAppState, initialEditorState } from "./initial-state";
|
||||||
export type { AppStore, StateListener } from "./store";
|
export type { AppStore, StateListener } from "./store";
|
||||||
export { createAppStore } from "./store";
|
export { createAppStore } from "./store";
|
||||||
|
export type { DocumentReadIndex, IndexedLayerInfo } from "./document-indexes";
|
||||||
|
export { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Artboard } from "@core/artboard";
|
import type { Artboard } from "@core/artboard";
|
||||||
import type { ViewportState } from "@editor/state";
|
import type { ViewportState } from "@editor/state";
|
||||||
import { renderCheckerboard } from "./checkerboard";
|
import type { CheckerboardRenderer } from "./checkerboard";
|
||||||
import { documentRectToScreenRect } from "./screen-rect";
|
import { documentRectToScreenRect } from "./screen-rect";
|
||||||
import type { WebGlRendererContext } from "./types";
|
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);
|
const rect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport);
|
||||||
|
|
||||||
if (artboard.backgroundColor === "transparent") {
|
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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
renderer/checkerboard.test.ts
Normal file
134
renderer/checkerboard.test.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,26 +1,148 @@
|
|||||||
import { clearScreenRect } from "./clear-rect";
|
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||||
import type { ScreenRect, WebGlRendererContext } from "./types";
|
|
||||||
|
|
||||||
const darkChecker = [0.82, 0.82, 0.86, 1] as const;
|
const darkChecker: RgbaColor = [0.82, 0.82, 0.86, 1];
|
||||||
const lightChecker = [0.94, 0.94, 0.97, 1] as const;
|
const lightChecker: RgbaColor = [0.94, 0.94, 0.97, 1];
|
||||||
|
|
||||||
export function renderCheckerboard(context: WebGlRendererContext, rect: ScreenRect, squareSize: number) {
|
export type CheckerboardRenderer = {
|
||||||
const clampedSquareSize = Math.max(1, squareSize);
|
render(rect: ScreenRect, squareSize: number): void;
|
||||||
const canvasWidth = context.canvas.width;
|
dispose(): void;
|
||||||
const canvasHeight = context.canvas.height;
|
};
|
||||||
|
|
||||||
for (let py = rect.y; py < rect.y + rect.h; py += clampedSquareSize) {
|
export function createCheckerboardRenderer(context: WebGlRendererContext): CheckerboardRenderer {
|
||||||
for (let px = rect.x; px < rect.x + rect.w; px += clampedSquareSize) {
|
const { gl } = context;
|
||||||
const x = Math.max(0, px);
|
const program = createProgram(gl);
|
||||||
const y = Math.max(0, py);
|
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||||
const w = Math.min(px + clampedSquareSize, rect.x + rect.w, canvasWidth) - x;
|
const canvasSizeLocation = gl.getUniformLocation(program, "u_canvasSize");
|
||||||
const h = Math.min(py + clampedSquareSize, rect.y + rect.h, canvasHeight) - y;
|
const originLocation = gl.getUniformLocation(program, "u_origin");
|
||||||
if (w <= 0 || h <= 0) continue;
|
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 =
|
if (
|
||||||
(Math.floor((px - rect.x) / clampedSquareSize) + Math.floor((py - rect.y) / clampedSquareSize)) % 2 === 0;
|
positionLocation < 0 ||
|
||||||
|
!canvasSizeLocation ||
|
||||||
clearScreenRect(context, { x, y, w, h }, checker ? darkChecker : lightChecker);
|
!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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
|
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes";
|
||||||
import type { EditorState, MaskViewMode, ViewportState } from "@editor/state";
|
import type { EditorState, MaskViewMode, ViewportState } from "@editor/state";
|
||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
|
||||||
import { clearScreenRect } from "./clear-rect";
|
import { clearScreenRect } from "./clear-rect";
|
||||||
import type { ImageTextureRenderer } from "./image-textures";
|
import type { ImageTextureRenderer } from "./image-textures";
|
||||||
import { documentRectToScreenRect } from "./screen-rect";
|
import { documentRectToScreenRect } from "./screen-rect";
|
||||||
@@ -13,17 +13,19 @@ const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
|
|||||||
const maskRevealPreviewOpacity = 0.28;
|
const maskRevealPreviewOpacity = 0.28;
|
||||||
|
|
||||||
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
|
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
|
||||||
|
const documentIndex = createDocumentReadIndex(document);
|
||||||
|
|
||||||
for (const artboard of document.artboards) {
|
for (const artboard of document.artboards) {
|
||||||
if (!artboard.visible) continue;
|
if (!artboard.visible) continue;
|
||||||
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
||||||
const maskLayerIds = collectMaskLayerIds(artboard.layers);
|
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
|
||||||
for (const layer of renderStack(artboard.layers)) renderLayer(context, document, editor, layer, imageTextureRenderer, clipRect, maskLayerIds);
|
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLayer(
|
function renderLayer(
|
||||||
context: WebGlRendererContext,
|
context: WebGlRendererContext,
|
||||||
document: ImageDocument,
|
documentIndex: DocumentReadIndex,
|
||||||
editor: EditorState,
|
editor: EditorState,
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
imageTextureRenderer: ImageTextureRenderer,
|
imageTextureRenderer: ImageTextureRenderer,
|
||||||
@@ -35,24 +37,24 @@ function renderLayer(
|
|||||||
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
|
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
|
||||||
if (!layer.visible || maskLayerIds.has(layer.id)) return;
|
if (!layer.visible || maskLayerIds.has(layer.id)) return;
|
||||||
|
|
||||||
const effectiveClipRect = resolveLayerClipRect(context, document, editor.viewport, layer, clipRect);
|
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect);
|
||||||
if (!effectiveClipRect) return;
|
if (!effectiveClipRect) return;
|
||||||
|
|
||||||
if (layer.type === "group") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) 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;
|
if (!bounds) return;
|
||||||
|
|
||||||
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
||||||
const asset = assetWithBrushStrokePreview(document.assets.find((candidate) => candidate.id === layer.assetId), editor);
|
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
|
||||||
const maskLayer = !editingMaskLayer && layer.clippingMask ? findLayer(document, layer.clippingMask.maskLayerId) : undefined;
|
const maskLayer = !editingMaskLayer && layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
|
||||||
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined, editor);
|
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
|
||||||
const maskBounds = maskLayer ? resolveTransformTargetBounds(document, { type: "layer", id: maskLayer.id }) : undefined;
|
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
|
||||||
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : 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 activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId);
|
||||||
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
|
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
|
||||||
@@ -79,20 +81,18 @@ function renderLayer(
|
|||||||
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
|
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStack(layers: readonly Layer[]) {
|
const emptyLayerIds = new Set<string>();
|
||||||
return [...layers].reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLayerClipRect(
|
function resolveLayerClipRect(
|
||||||
context: WebGlRendererContext,
|
context: WebGlRendererContext,
|
||||||
document: ImageDocument,
|
documentIndex: DocumentReadIndex,
|
||||||
viewport: ViewportState,
|
viewport: ViewportState,
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
clipRect: ScreenRect,
|
clipRect: ScreenRect,
|
||||||
): ScreenRect | undefined {
|
): ScreenRect | undefined {
|
||||||
if (!layer.clippingMask) return clipRect;
|
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;
|
if (!maskBounds) return clipRect;
|
||||||
|
|
||||||
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
|
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;
|
return { ...asset, source: editor.brushStrokePreview.source } as TAsset;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLayer(document: ImageDocument, layerId: string): Layer | undefined {
|
|
||||||
for (const artboard of document.artboards) {
|
|
||||||
const layer = findLayerInTree(artboard.layers, layerId);
|
|
||||||
if (layer) return layer;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLayerInTree(layers: readonly Layer[], layerId: string): Layer | undefined {
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.id === layerId) return layer;
|
|
||||||
if (layer.type === "group") {
|
|
||||||
const child = findLayerInTree(layer.children, layerId);
|
|
||||||
if (child) return child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
|
||||||
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined {
|
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined {
|
||||||
const x1 = Math.max(a.x, b.x);
|
const x1 = Math.max(a.x, b.x);
|
||||||
const y1 = Math.max(a.y, b.y);
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
|
|||||||
import type { EditorState } from "@editor/state";
|
import type { EditorState } from "@editor/state";
|
||||||
import { renderArtboard } from "./artboard";
|
import { renderArtboard } from "./artboard";
|
||||||
import { createBrushPreviewRenderer } from "./brush-preview";
|
import { createBrushPreviewRenderer } from "./brush-preview";
|
||||||
|
import { createCheckerboardRenderer } from "./checkerboard";
|
||||||
import { createImageTextureRenderer } from "./image-textures";
|
import { createImageTextureRenderer } from "./image-textures";
|
||||||
import { renderLayers } from "./layers";
|
import { renderLayers } from "./layers";
|
||||||
import { renderSelectionOverlay } from "./selection";
|
import { renderSelectionOverlay } from "./selection";
|
||||||
@@ -31,6 +32,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rendererContext: WebGlRendererContext = { gl: context, canvas };
|
const rendererContext: WebGlRendererContext = { gl: context, canvas };
|
||||||
|
const checkerboardRenderer = createCheckerboardRenderer(rendererContext);
|
||||||
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
|
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
|
||||||
let lastFrame: RenderFrame | undefined;
|
let lastFrame: RenderFrame | undefined;
|
||||||
let rerenderQueued = false;
|
let rerenderQueued = false;
|
||||||
@@ -58,7 +60,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
|||||||
context.enable(context.SCISSOR_TEST);
|
context.enable(context.SCISSOR_TEST);
|
||||||
|
|
||||||
for (const artboard of frame.document.artboards) {
|
for (const artboard of frame.document.artboards) {
|
||||||
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport);
|
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport, checkerboardRenderer);
|
||||||
}
|
}
|
||||||
imageTextureRenderer.syncAssets(frame.document.assets);
|
imageTextureRenderer.syncAssets(frame.document.assets);
|
||||||
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
|
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
|
||||||
@@ -72,6 +74,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
|||||||
context.disable(context.SCISSOR_TEST);
|
context.disable(context.SCISSOR_TEST);
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
|
checkerboardRenderer.dispose();
|
||||||
imageTextureRenderer.dispose();
|
imageTextureRenderer.dispose();
|
||||||
brushPreviewRenderer?.dispose();
|
brushPreviewRenderer?.dispose();
|
||||||
},
|
},
|
||||||
|
|||||||
76
view/App.tsx
76
view/App.tsx
@@ -10,9 +10,10 @@ import { LayersSheet } from "./LayersSheet";
|
|||||||
import { ShortcutsDisplay } from "./ShortcutsDisplay";
|
import { ShortcutsDisplay } from "./ShortcutsDisplay";
|
||||||
import { ToolOverlay } from "./ToolOverlay";
|
import { ToolOverlay } from "./ToolOverlay";
|
||||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
|
import type { AppState } from "@editor/state";
|
||||||
import type { ToolId } from "@editor/tools";
|
import type { ToolId } from "@editor/tools";
|
||||||
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||||
import { useAppState } from "./useAppState";
|
import { shallowEqual, useAppState } from "./useAppState";
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||||
import { useImageImport } from "./useImageImport";
|
import { useImageImport } from "./useImageImport";
|
||||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||||
@@ -23,14 +24,15 @@ export type AppProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function App({ app }: AppProps) {
|
export function App({ app }: AppProps) {
|
||||||
const state = useAppState(app.store);
|
const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
|
||||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
const { document, selection, viewport, tools, transformSession, maskEdit } = shellState;
|
||||||
|
const viewportActivityIsland = useViewportActivityIsland(viewport);
|
||||||
const imageImport = useImageImport(app.store);
|
const imageImport = useImageImport(app.store);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
const [generateOpen, setGenerateOpen] = useState(false);
|
const [generateOpen, setGenerateOpen] = useState(false);
|
||||||
const previousGenerateTool = useRef<ToolId>("select");
|
const previousGenerateTool = useRef<ToolId>("select");
|
||||||
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
|
||||||
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
|
const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0];
|
||||||
|
|
||||||
const openGenerate = useCallback(() => {
|
const openGenerate = useCallback(() => {
|
||||||
const activeTool = app.store.getState().editor.tools.activeTool;
|
const activeTool = app.store.getState().editor.tools.activeTool;
|
||||||
@@ -108,8 +110,8 @@ export function App({ app }: AppProps) {
|
|||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [app.store, closeGenerate, toggleGenerate]);
|
}, [app.store, closeGenerate, toggleGenerate]);
|
||||||
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
|
const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined;
|
||||||
const brushHint = brushUnavailableHint(state.document, state.editor);
|
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
|
||||||
|
|
||||||
return (
|
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">
|
<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}>
|
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
|
||||||
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
|
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
|
||||||
</button>
|
</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} />
|
<DownloadSimple size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => { closeGenerate(); setLayersOpen((open) => !open); }}>
|
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => { closeGenerate(); setLayersOpen((open) => !open); }}>
|
||||||
@@ -132,41 +134,37 @@ export function App({ app }: AppProps) {
|
|||||||
</header>
|
</header>
|
||||||
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||||
<ToolOverlay
|
<ToolOverlay
|
||||||
activeTool={state.editor.tools.activeTool}
|
activeTool={tools.activeTool}
|
||||||
interactionMode={state.editor.tools.interactionMode}
|
interactionMode={tools.interactionMode}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<GenerateSheet
|
<GenerateSheet
|
||||||
document={state.document}
|
settings={tools.generate}
|
||||||
selection={state.editor.selection}
|
|
||||||
viewport={state.editor.viewport}
|
|
||||||
settings={state.editor.tools.generate}
|
|
||||||
open={generateOpen}
|
open={generateOpen}
|
||||||
onOpenChange={(open) => open ? openGenerate() : closeGenerate()}
|
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
<LayersSheet
|
<LayersSheet
|
||||||
document={state.document}
|
document={document}
|
||||||
selection={state.editor.selection}
|
selection={selection}
|
||||||
maskEdit={state.editor.maskEdit}
|
maskEdit={maskEdit}
|
||||||
open={layersOpen}
|
open={layersOpen}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
||||||
<BottomControlsIsland
|
<BottomControlsIsland
|
||||||
document={state.document}
|
document={document}
|
||||||
selection={state.editor.selection}
|
selection={selection}
|
||||||
viewport={state.editor.viewport}
|
viewport={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}
|
visible={tools.activeTool === "generate" || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "chromaKey" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
activeTool={state.editor.tools.activeTool}
|
activeTool={tools.activeTool}
|
||||||
brushSettings={state.editor.tools.brush}
|
brushSettings={tools.brush}
|
||||||
generateSettings={state.editor.tools.generate}
|
generateSettings={tools.generate}
|
||||||
chromaKeySettings={state.editor.tools.chromaKey}
|
chromaKeySettings={tools.chromaKey}
|
||||||
magicWandSettings={state.editor.tools.magicWand}
|
magicWandSettings={tools.magicWand}
|
||||||
editingMask={Boolean(state.editor.maskEdit)}
|
editingMask={Boolean(maskEdit)}
|
||||||
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
|
maskViewMode={maskEdit?.viewMode ?? "composite"}
|
||||||
brushHint={brushHint}
|
brushHint={brushHint}
|
||||||
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||||
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
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`;
|
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;
|
export default App;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useMemo, useRef } from "react";
|
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 { AppStore } from "@editor/store";
|
||||||
|
import type { InteractionMode } from "@editor/tools";
|
||||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
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 { canvasCursorClass } from "./canvas/cursor";
|
||||||
import { useCanvasInput } from "./canvas/useCanvasInput";
|
import { useCanvasInput } from "./canvas/useCanvasInput";
|
||||||
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
|
||||||
@@ -26,7 +29,7 @@ export function CanvasViewport({
|
|||||||
globalWheelConsumer = ignoreGlobalWheel,
|
globalWheelConsumer = ignoreGlobalWheel,
|
||||||
}: CanvasViewportProps) {
|
}: CanvasViewportProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const state = useAppState(store);
|
const cursorState = useAppState(store, selectCanvasCursorState, canvasCursorStatesEqual);
|
||||||
const inputOptions = useMemo(
|
const inputOptions = useMemo(
|
||||||
() => ({ globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer }),
|
() => ({ globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer }),
|
||||||
[globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer],
|
[globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer],
|
||||||
@@ -35,9 +38,53 @@ export function CanvasViewport({
|
|||||||
useCanvasRenderer(canvasRef, store);
|
useCanvasRenderer(canvasRef, store);
|
||||||
useCanvasResize(canvasRef, store.dispatch);
|
useCanvasResize(canvasRef, store.dispatch);
|
||||||
const input = useCanvasInput(canvasRef, store, inputOptions);
|
const input = useCanvasInput(canvasRef, store, inputOptions);
|
||||||
const brushHint = brushUnavailableHint(state.document, state.editor);
|
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
|
||||||
const hasBrushPreview = Boolean(state.editor.brushPreview && !brushHint && canPreviewBrush(state.document, state.editor));
|
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
|
||||||
const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
|
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, input, hasBrushPreview, !brushHint);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { GenerateSettings } from "@editor/tools";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
import { GenerateControls } from "./bottom-controls/GenerateControls";
|
||||||
|
|
||||||
export type GenerateSheetProps = {
|
export type GenerateSheetProps = {
|
||||||
document: ImageDocument;
|
|
||||||
selection: SelectionState;
|
|
||||||
viewport: ViewportState;
|
|
||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GenerateSheet({ document, selection, viewport, settings, open, onOpenChange, dispatch }: GenerateSheetProps) {
|
export function GenerateSheet({ settings, open, dispatch }: GenerateSheetProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-hidden={!open}
|
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"
|
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{open ? (
|
||||||
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
|
||||||
<GenerateControls document={document} selection={selection} viewport={viewport} settings={settings} dispatch={dispatch} />
|
<GenerateControls settings={settings} dispatch={dispatch} />
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { ArtboardId } from "@core/id";
|
import type { ArtboardId } from "@core/id";
|
||||||
|
import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
|
||||||
import type { MaskEditState, SelectionState } from "@editor/state";
|
import type { MaskEditState, SelectionState } from "@editor/state";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
import { resolveLayerDrop } from "@input/index";
|
||||||
import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index";
|
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||||
|
|
||||||
export type LayersSheetProps = {
|
export type LayersSheetProps = {
|
||||||
@@ -19,12 +19,7 @@ export type LayersSheetProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
|
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
|
||||||
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
|
const draggedLayerId = useRef<string | undefined>(undefined);
|
||||||
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
|
|
||||||
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
|
||||||
const canUngroup = selectedLayer?.layer.type === "group";
|
|
||||||
const maskLayerIds = collectDocumentMaskLayerIds(document);
|
|
||||||
const draggedLayerId = useRef<string>();
|
|
||||||
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
||||||
|
|
||||||
return (
|
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 ? "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">
|
<header className="flex h-20 items-center">
|
||||||
<div className="flex w-full items-center gap-2">
|
<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)}>
|
<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 })}>
|
<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" />
|
<Stack size={24} weight="fill" />
|
||||||
</button>
|
</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} />
|
<ArrowUp size={24} />
|
||||||
</button>
|
</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} />
|
<ArrowDown size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={toolbarButtonClass()} aria-label="Delete selection" title="Delete selection" disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-auto pb-2">
|
<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">
|
<section key={artboard.id} className="mb-5 last:mb-0">
|
||||||
<div
|
<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"}`}
|
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" />
|
<DownloadSimple size={24} weight="regular" />
|
||||||
</button>
|
</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>
|
||||||
<div className="mt-2 space-y-2 pl-5">
|
<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>
|
<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) => (
|
artboard.layers.map((layer) => (
|
||||||
<LayerRow
|
<LayerRow
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
document={document}
|
document={document}
|
||||||
|
documentIndex={documentIndex}
|
||||||
artboardId={artboard.id}
|
artboardId={artboard.id}
|
||||||
layer={layer}
|
layer={layer}
|
||||||
depth={0}
|
depth={0}
|
||||||
@@ -143,14 +180,16 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch }: L
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayerRow({
|
function LayerRow({
|
||||||
document,
|
document,
|
||||||
|
documentIndex,
|
||||||
artboardId,
|
artboardId,
|
||||||
layer,
|
layer,
|
||||||
depth,
|
depth,
|
||||||
@@ -163,6 +202,7 @@ function LayerRow({
|
|||||||
dispatch,
|
dispatch,
|
||||||
}: {
|
}: {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
|
documentIndex: DocumentReadIndex;
|
||||||
artboardId: ArtboardId;
|
artboardId: ArtboardId;
|
||||||
layer: Layer;
|
layer: Layer;
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -177,8 +217,8 @@ function LayerRow({
|
|||||||
if (maskLayerIds.has(layer.id)) return null;
|
if (maskLayerIds.has(layer.id)) return null;
|
||||||
|
|
||||||
const selected = selectedLayerIds.includes(layer.id);
|
const selected = selectedLayerIds.includes(layer.id);
|
||||||
const layerInfo = findLayerInfoInDocument(document, layer.id);
|
const layerInfo = documentIndex.layerInfoById.get(layer.id);
|
||||||
const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
|
const maskLayer = layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
|
||||||
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask);
|
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 editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId);
|
||||||
const rowPadding = 12 + depth * 16;
|
const rowPadding = 12 + depth * 16;
|
||||||
@@ -238,7 +278,7 @@ function LayerRow({
|
|||||||
<button
|
<button
|
||||||
type="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"}
|
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
|
Add mask
|
||||||
</button>
|
</button>
|
||||||
@@ -278,6 +318,7 @@ function LayerRow({
|
|||||||
<LayerRow
|
<LayerRow
|
||||||
key={child.id}
|
key={child.id}
|
||||||
document={document}
|
document={document}
|
||||||
|
documentIndex={documentIndex}
|
||||||
artboardId={artboardId}
|
artboardId={artboardId}
|
||||||
layer={child}
|
layer={child}
|
||||||
depth={depth + 1}
|
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;
|
const layer = layerInfo.layer;
|
||||||
if (layer.type === "group") return;
|
if (layer.type === "group") return;
|
||||||
|
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
const asset = documentIndex.assetById.get(layer.assetId);
|
||||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
||||||
if (!asset || !bounds) return;
|
if (!asset || !bounds) return;
|
||||||
|
|
||||||
const assetId = crypto.randomUUID();
|
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(
|
function dropLayer(
|
||||||
document: ImageDocument,
|
document: ImageDocument,
|
||||||
sourceLayerId: string,
|
sourceLayerId: string,
|
||||||
@@ -400,7 +417,7 @@ function dropLayer(
|
|||||||
if (command) dispatch(commandIds.documentMoveLayer, command);
|
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) {
|
if (selectedLayer) {
|
||||||
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
|
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
|
||||||
return;
|
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);
|
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
|
||||||
if (!artboard) return;
|
if (!artboard) return;
|
||||||
|
|
||||||
@@ -461,11 +478,9 @@ function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: Ap
|
|||||||
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
||||||
const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers;
|
const siblings = info.siblings;
|
||||||
if (!siblings) return;
|
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
|
||||||
|
|
||||||
const maskLayerIds = collectMaskLayerIds(siblings, new Set<string>());
|
|
||||||
const blocks = siblings.flatMap((layer, index) => {
|
const blocks = siblings.flatMap((layer, index) => {
|
||||||
if (maskLayerIds.has(layer.id)) return [];
|
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 {
|
return {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
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 { AppStore } from "@editor/store";
|
||||||
import type { SelectionState, ViewportState } from "@editor/state";
|
|
||||||
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
import type { GenerateMode, GenerateModel, GenerateSettings } from "@editor/tools";
|
||||||
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
||||||
import { BottomControlSlider } from "./Slider";
|
import { BottomControlSlider } from "./Slider";
|
||||||
@@ -25,9 +22,6 @@ const sizePresets = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type GenerateControlsProps = {
|
export type GenerateControlsProps = {
|
||||||
document: ImageDocument;
|
|
||||||
selection: SelectionState;
|
|
||||||
viewport: ViewportState;
|
|
||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
@@ -226,109 +220,3 @@ function panelLabelClass() {
|
|||||||
function panelTextAreaClass(extra = "") {
|
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`;
|
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,31 +3,69 @@ import type { ImageDocument } from "@core/document";
|
|||||||
import type { Vec2D } from "@core/geometry";
|
import type { Vec2D } from "@core/geometry";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { RasterLayer } from "@core/raster-layer";
|
import type { RasterLayer } from "@core/raster-layer";
|
||||||
import type { EditorState } from "@editor/state";
|
import type { MaskEditState, SelectionState } from "@editor/state";
|
||||||
import { isPanInteractionMode } from "@editor/tools";
|
import { isPanInteractionMode, type ToolState } from "@editor/tools";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
|
||||||
export type BrushSession = {
|
export type BrushSession = {
|
||||||
layerId: string;
|
layerId: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
context: CanvasRenderingContext2D;
|
||||||
|
ready: Promise<boolean>;
|
||||||
previousPoint: Vec2D;
|
previousPoint: Vec2D;
|
||||||
mode: "brush" | "eraser";
|
mode: "brush" | "eraser";
|
||||||
source?: string;
|
changed?: boolean;
|
||||||
pending?: Promise<void>;
|
pending?: Promise<void>;
|
||||||
cancelled?: boolean;
|
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);
|
const layer = resolveBrushTargetLayer(document, editor);
|
||||||
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
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));
|
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 (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||||
if (resolveBrushTargetLayer(document, editor)) 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.";
|
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;
|
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
||||||
const editingMask = Boolean(editor.maskEdit);
|
const editingMask = Boolean(editor.maskEdit);
|
||||||
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||||
@@ -66,10 +104,7 @@ export function updateBrushSession(options: {
|
|||||||
}): BrushSession {
|
}): BrushSession {
|
||||||
const state = options.store.getState();
|
const state = options.store.getState();
|
||||||
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
||||||
if (!layer) return options.session;
|
if (!layer || layer.assetId !== options.session.assetId) return options.session;
|
||||||
|
|
||||||
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
|
|
||||||
if (!asset) return options.session;
|
|
||||||
|
|
||||||
const from = options.session.previousPoint;
|
const from = options.session.previousPoint;
|
||||||
const to = options.point;
|
const to = options.point;
|
||||||
@@ -77,13 +112,12 @@ export function updateBrushSession(options: {
|
|||||||
options.session.pending = (options.session.pending ?? Promise.resolve())
|
options.session.pending = (options.session.pending ?? Promise.resolve())
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
if (options.session.cancelled) return;
|
if (options.session.cancelled) return;
|
||||||
|
if (!(await options.session.ready) || options.session.cancelled) return;
|
||||||
|
|
||||||
const source = await drawStroke({
|
drawStrokeSegment({
|
||||||
source: options.session.source ?? asset.source,
|
context: options.session.context,
|
||||||
width: asset.intrinsicSize.w,
|
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
|
||||||
height: asset.intrinsicSize.h,
|
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
|
||||||
from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
|
||||||
to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
|
||||||
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
||||||
size: options.size,
|
size: options.size,
|
||||||
hardness: options.hardness,
|
hardness: options.hardness,
|
||||||
@@ -91,8 +125,8 @@ export function updateBrushSession(options: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (options.session.cancelled) return;
|
if (options.session.cancelled) return;
|
||||||
options.session.source = source;
|
options.session.changed = true;
|
||||||
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
|
requestBrushStrokePreview({ store: options.store, session: options.session });
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
@@ -103,13 +137,16 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
|
|||||||
await options.session.pending;
|
await options.session.pending;
|
||||||
if (options.session.cancelled) return;
|
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);
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
|
closeBrushStrokePreview(options.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
|
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
|
||||||
options.session.cancelled = true;
|
options.session.cancelled = true;
|
||||||
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
|
closeBrushStrokePreview(options.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
|
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: {
|
async function initializeBrushSession(session: BrushSession, source: string) {
|
||||||
source: string;
|
const image = await loadImage(source);
|
||||||
width: number;
|
if (session.cancelled) return false;
|
||||||
height: number;
|
|
||||||
|
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;
|
from: Vec2D;
|
||||||
to: Vec2D;
|
to: Vec2D;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -130,15 +174,9 @@ async function drawStroke(options: {
|
|||||||
hardness: number;
|
hardness: number;
|
||||||
mode: "brush" | "eraser";
|
mode: "brush" | "eraser";
|
||||||
}) {
|
}) {
|
||||||
const canvas = document.createElement("canvas");
|
const { context } = options;
|
||||||
canvas.width = Math.max(1, Math.round(options.width));
|
|
||||||
canvas.height = Math.max(1, Math.round(options.height));
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) return options.source;
|
|
||||||
|
|
||||||
const image = await loadImage(options.source);
|
|
||||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
||||||
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
|
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
|
||||||
|
context.save();
|
||||||
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
|
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
|
||||||
context.strokeStyle = options.color;
|
context.strokeStyle = options.color;
|
||||||
context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : 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.moveTo(options.from.x, options.from.y);
|
||||||
context.lineTo(options.to.x, options.to.y);
|
context.lineTo(options.to.x, options.to.y);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
context.globalCompositeOperation = "source-over";
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
return canvas.toDataURL("image/png");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImage(source: string) {
|
function loadImage(source: string) {
|
||||||
|
|||||||
178
view/canvas/renderFrame.test.ts
Normal file
178
view/canvas/renderFrame.test.ts
Normal 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);
|
||||||
|
}
|
||||||
87
view/canvas/renderFrame.ts
Normal file
87
view/canvas/renderFrame.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
pointerInputEventFromPointerEvent,
|
pointerInputEventFromPointerEvent,
|
||||||
wheelInputEventFromWheelEvent,
|
wheelInputEventFromWheelEvent,
|
||||||
} from "@input/index";
|
} 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";
|
import { applyMagicWandAt } from "./magic-wand";
|
||||||
|
|
||||||
export type CanvasInputOptions = {
|
export type CanvasInputOptions = {
|
||||||
@@ -31,12 +31,13 @@ export function useCanvasInput(
|
|||||||
options: CanvasInputOptions,
|
options: CanvasInputOptions,
|
||||||
): CanvasInputState {
|
): CanvasInputState {
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const brushSession = useRef<BrushSession>();
|
const brushSession = useRef<BrushSession | undefined>(undefined);
|
||||||
const brushSessionId = useRef(0);
|
const brushSessionId = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
const transformHandler = createTransformControlsInputController({
|
const transformHandler = createTransformControlsInputController({
|
||||||
getDocument: () => store.getState().document,
|
getDocument: () => store.getState().document,
|
||||||
@@ -71,6 +72,18 @@ export function useCanvasInput(
|
|||||||
store.dispatch(commandIds.toolSetBrushPreview, { position: viewportPointToDocumentPoint(position, state.editor.viewport) });
|
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) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (isEditableKeyboardTarget(event.target)) return;
|
if (isEditableKeyboardTarget(event.target)) return;
|
||||||
|
|
||||||
@@ -142,10 +155,7 @@ export function useCanvasInput(
|
|||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
if (brushSession.current) {
|
if (brushSession.current) {
|
||||||
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
|
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
|
||||||
const session = brushSession.current;
|
commitActiveBrushSession(inputEvent.position);
|
||||||
brushSessionId.current += 1;
|
|
||||||
brushSession.current = undefined;
|
|
||||||
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,10 +188,7 @@ export function useCanvasInput(
|
|||||||
const handlePointerUp = (event: PointerEvent) => {
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
if (brushSession.current) {
|
if (brushSession.current) {
|
||||||
const session = brushSession.current;
|
commitActiveBrushSession(inputEvent.position);
|
||||||
brushSessionId.current += 1;
|
|
||||||
brushSession.current = undefined;
|
|
||||||
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -226,6 +233,12 @@ export function useCanvasInput(
|
|||||||
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
brushSessionId.current += 1;
|
||||||
|
if (brushSession.current) {
|
||||||
|
cancelBrushSession({ store, session: brushSession.current });
|
||||||
|
brushSession.current = undefined;
|
||||||
|
}
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
canvas.removeEventListener("pointerdown", handlePointerDown);
|
canvas.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, type RefObject } from "react";
|
import { useEffect, type RefObject } from "react";
|
||||||
import type { AppStore } from "@editor/store";
|
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) {
|
export function useCanvasRenderer(canvasRef: RefObject<HTMLCanvasElement | null>, store: AppStore) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -8,14 +9,38 @@ export function useCanvasRenderer(canvasRef: RefObject<HTMLCanvasElement | null>
|
|||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const renderer = createRenderer(canvas);
|
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) => {
|
const unsubscribe = store.subscribe((state) => {
|
||||||
renderer.render(state);
|
const nextFrame = selectCanvasRenderFrame(state);
|
||||||
|
const currentFrame = pendingFrame ?? renderedFrame;
|
||||||
|
if (canvasRenderFramesEqual(currentFrame, nextFrame)) return;
|
||||||
|
scheduleRender(nextFrame);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
};
|
};
|
||||||
}, [canvasRef, store]);
|
}, [canvasRef, store]);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function runGenerate(options: {
|
|||||||
settings: GenerateSettings;
|
settings: GenerateSettings;
|
||||||
dispatch: AppStore["dispatch"];
|
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];
|
const artboard = selection.artboardId ? document.artboards.find((candidate) => candidate.id === selection.artboardId) : document.artboards[0];
|
||||||
if (!artboard) return;
|
if (!artboard) return;
|
||||||
|
|
||||||
|
|||||||
68
view/useAppState.test.ts
Normal file
68
view/useAppState.test.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,102 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useCallback, useRef, useSyncExternalStore } from "react";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { AppState } from "@editor/state";
|
import type { AppState } from "@editor/state";
|
||||||
|
|
||||||
export function useAppState(store: AppStore): AppState {
|
export type AppStateSelector<TSelection> = (state: AppState) => TSelection;
|
||||||
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
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]));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user