Compare commits
6 Commits
49b029d60a
...
37aa719047
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37aa719047 | ||
|
|
606426c885 | ||
|
|
b928fb599b | ||
|
|
9fb3a19912 | ||
|
|
4d7358af4b | ||
|
|
bef2fb3051 |
39
commands/adjustment.test.ts
Normal file
39
commands/adjustment.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { neutralColorAdjustment } from "@core/adjustment-layer";
|
||||
import { documentAddArtboardCommand, documentAddAdjustmentLayerCommand, documentSetAdjustmentCommand, documentDuplicateLayerCommand, documentMoveLayerCommand } from "./document";
|
||||
|
||||
const transform = { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 };
|
||||
function stateWithAdjustment() {
|
||||
let state = documentAddArtboardCommand.execute({ state: createInitialAppState("test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 10, h: 10 } });
|
||||
state = documentAddAdjustmentLayerCommand.execute({ state }, { artboardId: "board", layer: { id: "adjust", type: "adjustment", name: "Color", visible: true, locked: false, opacity: 1, transform, adjustment: neutralColorAdjustment } });
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("adjustment commands", () => {
|
||||
test("creates and selects a persistent adjustment node", () => {
|
||||
const state = stateWithAdjustment();
|
||||
expect(state.document.artboards[0]?.layers[0]?.type).toBe("adjustment");
|
||||
expect(state.editor.selection.layerIds).toEqual(["adjust"]);
|
||||
});
|
||||
test("validates edits and preserves locked layers", () => {
|
||||
const state = stateWithAdjustment();
|
||||
const changed = documentSetAdjustmentCommand.execute({ state }, { layerId: "adjust", adjustment: { ...neutralColorAdjustment, brightness: .25 } });
|
||||
const invalid = documentSetAdjustmentCommand.execute({ state: changed }, { layerId: "adjust", adjustment: { ...neutralColorAdjustment, contrast: 2 } });
|
||||
expect((changed.document.artboards[0]?.layers[0] as { adjustment: { brightness: number } }).adjustment.brightness).toBe(.25);
|
||||
expect(invalid).toBe(changed);
|
||||
});
|
||||
test("duplicates and moves adjustment nodes like tree siblings", () => {
|
||||
let state = stateWithAdjustment();
|
||||
state = documentDuplicateLayerCommand.execute({ state }, { layerId: "adjust", idByLayerId: { adjust: "copy" } });
|
||||
state = documentMoveLayerCommand.execute({ state }, { layerId: "copy", toArtboardId: "board", toIndex: 0 });
|
||||
expect(state.document.artboards[0]?.layers.map((layer) => [layer.id, layer.type])).toEqual([["copy", "adjustment"], ["adjust", "adjustment"]]);
|
||||
});
|
||||
test("rejects nested creation, movement, and grouping to preserve exact scope", () => {
|
||||
const state = stateWithAdjustment();
|
||||
const nested = documentAddAdjustmentLayerCommand.execute({ state }, { artboardId: "board", parentGroupId: "group", layer: { id: "nested", type: "adjustment", name: "Nested", visible: true, locked: false, opacity: 1, transform, adjustment: neutralColorAdjustment } });
|
||||
const moved = documentMoveLayerCommand.execute({ state }, { layerId: "adjust", toArtboardId: "board", toParentGroupId: "group", toIndex: 0 });
|
||||
expect(nested).toBe(state);
|
||||
expect(moved).toBe(state);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
documentAddRasterLayerCommand,
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentGroupLayersCommand,
|
||||
documentDuplicateLayerCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
documentRemoveLayerCommand,
|
||||
@@ -17,10 +18,13 @@ import {
|
||||
documentRenameArtboardCommand,
|
||||
documentRenameLayerCommand,
|
||||
documentSetArtboardBoundsCommand,
|
||||
documentResizeArtboardCommand,
|
||||
documentSetArtboardLockedCommand,
|
||||
documentSetArtboardVisibleCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentSetLayerOpacityCommand,
|
||||
documentSetLayerSourceRectCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentUpdateAssetSourceCommand,
|
||||
documentUngroupLayerCommand,
|
||||
@@ -57,6 +61,32 @@ describe("document commands", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("crops a raster layer non-destructively and clamps the crop to its asset", () => {
|
||||
const state = documentWithRaster();
|
||||
const next = documentSetLayerSourceRectCommand.execute({ state }, { layerId: "r1", sourceRect: { x: 20, y: 10, w: 200, h: 100 } });
|
||||
expect(next.document.artboards[0]?.layers[0]).toMatchObject({ sourceRect: { x: 20, y: 10, w: 80, h: 40 } });
|
||||
expect(next.document.assets[0]?.intrinsicSize).toEqual({ w: 100, h: 50 });
|
||||
|
||||
const reset = documentSetLayerSourceRectCommand.execute({ state: next }, { layerId: "r1" });
|
||||
expect(reset.document.artboards[0]?.layers[0]).not.toHaveProperty("sourceRect");
|
||||
});
|
||||
|
||||
test("resizes artboard bounds without scaling contents", () => {
|
||||
const state = documentWithRaster();
|
||||
const next = documentResizeArtboardCommand.execute({ state }, { id: "a1", bounds: { x: 5, y: 10, w: 640, h: 480 }, scaleContents: false });
|
||||
expect(next.document.artboards[0]?.bounds).toEqual({ x: 5, y: 10, w: 640, h: 480 });
|
||||
expect(next.document.artboards[0]?.layers[0]?.transform).toEqual(state.document.artboards[0]?.layers[0]?.transform);
|
||||
});
|
||||
|
||||
test("resizes an artboard and scales nested contents including masks", () => {
|
||||
const state = documentWithRaster();
|
||||
const leaf = state.document.artboards[0]!.layers[0]!;
|
||||
state.document.artboards[0]!.layers = [{ ...group("g", "Group"), children: [leaf] }];
|
||||
const next = documentResizeArtboardCommand.execute({ state }, { id: "a1", bounds: { x: 10, y: 20, w: 640, h: 120 }, scaleContents: true });
|
||||
const nested = next.document.artboards[0]?.layers[0];
|
||||
expect(nested?.type === "group" ? nested.children[0]?.transform : undefined).toEqual({ position: { x: 30, y: 30 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
|
||||
});
|
||||
|
||||
test("updates asset sources", () => {
|
||||
const state = documentAddAssetCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
@@ -335,6 +365,46 @@ describe("document commands", () => {
|
||||
expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true);
|
||||
});
|
||||
|
||||
test("sets normalized opacity and refuses locked layer edits", () => {
|
||||
const state = documentWithLayers([raster("a", "A")]);
|
||||
const translucent = documentSetLayerOpacityCommand.execute({ state }, { layerId: "a", opacity: 0.35 });
|
||||
const clamped = documentSetLayerOpacityCommand.execute({ state: translucent }, { layerId: "a", opacity: 2 });
|
||||
const lockedState = documentSetLayerLockedCommand.execute({ state: clamped }, { layerId: "a", locked: true });
|
||||
const ignored = documentSetLayerOpacityCommand.execute({ state: lockedState }, { layerId: "a", opacity: 0 });
|
||||
|
||||
expect(translucent.document.artboards[0]?.layers[0]?.opacity).toBe(0.35);
|
||||
expect(clamped.document.artboards[0]?.layers[0]?.opacity).toBe(1);
|
||||
expect(ignored).toBe(lockedState);
|
||||
});
|
||||
|
||||
test("duplicates a nested group with remapped child mask references", () => {
|
||||
const maskedTarget = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } };
|
||||
const parent = { ...group("parent", "Parent"), children: [raster("mask", "Mask"), maskedTarget] };
|
||||
const state = documentWithLayers([parent]);
|
||||
const next = documentDuplicateLayerCommand.execute(
|
||||
{ state },
|
||||
{ layerId: "parent", idByLayerId: { parent: "parent-copy", mask: "mask-copy", target: "target-copy" } },
|
||||
);
|
||||
|
||||
const duplicate = next.document.artboards[0]?.layers[1];
|
||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["parent", "parent-copy"]);
|
||||
expect(duplicate?.type === "group" ? duplicate.children.map((layer) => layer.id) : []).toEqual(["mask-copy", "target-copy"]);
|
||||
expect(duplicate?.type === "group" ? duplicate.children[1]?.layerMask?.maskLayerId : undefined).toBe("mask-copy");
|
||||
expect(next.editor.selection.layerIds).toEqual(["parent-copy"]);
|
||||
});
|
||||
|
||||
test("duplicates an attached top-level mask beside its target", () => {
|
||||
const target = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } };
|
||||
const state = documentWithLayers([raster("mask", "Mask"), target]);
|
||||
const next = documentDuplicateLayerCommand.execute(
|
||||
{ state },
|
||||
{ layerId: "target", idByLayerId: { mask: "mask-copy", target: "target-copy" } },
|
||||
);
|
||||
|
||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target", "mask-copy", "target-copy"]);
|
||||
expect(next.document.artboards[0]?.layers[3]?.layerMask?.maskLayerId).toBe("mask-copy");
|
||||
});
|
||||
|
||||
test("sets artboard bounds", () => {
|
||||
const state = documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
@@ -361,6 +431,12 @@ function documentWithLayers(layers: Layer[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function documentWithRaster() {
|
||||
const state = documentWithLayers([{ ...raster("r1", "Raster", "asset-1"), transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 } }]);
|
||||
state.document.assets = [{ id: "asset-1", name: "Raster", mimeType: "image/png", source: "asset://raster", intrinsicSize: { w: 100, h: 50 } }];
|
||||
return state;
|
||||
}
|
||||
|
||||
function group(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -6,7 +6,11 @@ import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { RasterLayer } from "@core/raster-layer";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { LayerGroup } from "@core/layer-group";
|
||||
import type { AdjustmentLayer, ColorAdjustment } from "@core/adjustment-layer";
|
||||
import type { TextLayer, TextStyle } from "@core/text-layer";
|
||||
import { isValidTextStyle } from "@core/text-layer";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
@@ -21,6 +25,12 @@ export type DocumentSetArtboardBoundsPayload = {
|
||||
bounds: Rect;
|
||||
};
|
||||
|
||||
export type DocumentResizeArtboardPayload = {
|
||||
id: ArtboardId;
|
||||
bounds: Rect;
|
||||
scaleContents: boolean;
|
||||
};
|
||||
|
||||
export type DocumentRemoveArtboardPayload = {
|
||||
id: ArtboardId;
|
||||
};
|
||||
@@ -66,6 +76,10 @@ export type DocumentAddGroupLayerPayload = {
|
||||
parentGroupId?: LayerId;
|
||||
group: LayerGroup;
|
||||
};
|
||||
export type DocumentAddAdjustmentLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: AdjustmentLayer };
|
||||
export type DocumentSetAdjustmentPayload = { layerId: LayerId; adjustment: ColorAdjustment };
|
||||
export type DocumentAddTextLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: TextLayer };
|
||||
export type DocumentSetTextLayerPayload = { layerId: LayerId; content: string; style: TextStyle };
|
||||
|
||||
export type DocumentMoveLayerPayload = {
|
||||
layerId: LayerId;
|
||||
@@ -98,6 +112,21 @@ export type DocumentSetLayerLockedPayload = {
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
export type DocumentSetLayerOpacityPayload = {
|
||||
layerId: LayerId;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export type DocumentSetLayerSourceRectPayload = {
|
||||
layerId: LayerId;
|
||||
sourceRect?: Rect;
|
||||
};
|
||||
|
||||
export type DocumentDuplicateLayerPayload = {
|
||||
layerId: LayerId;
|
||||
idByLayerId: Record<LayerId, LayerId>;
|
||||
};
|
||||
|
||||
export type DocumentRenameLayerPayload = {
|
||||
layerId: LayerId;
|
||||
name: string;
|
||||
@@ -178,6 +207,29 @@ export const documentSetArtboardBoundsCommand: Command<DocumentSetArtboardBounds
|
||||
},
|
||||
};
|
||||
|
||||
export const documentResizeArtboardCommand: Command<DocumentResizeArtboardPayload> = {
|
||||
id: commandIds.documentResizeArtboard,
|
||||
name: "Resize artboard",
|
||||
execute({ state }, payload) {
|
||||
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.id);
|
||||
const bounds = validRect(payload.bounds);
|
||||
if (!artboard || artboard.locked || !bounds) return state;
|
||||
const scaleX = bounds.w / artboard.bounds.w;
|
||||
const scaleY = bounds.h / artboard.bounds.h;
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((candidate) => candidate.id !== payload.id ? candidate : {
|
||||
...candidate,
|
||||
bounds,
|
||||
layers: payload.scaleContents ? scaleLayerTree(candidate.layers, artboard.bounds, bounds, scaleX, scaleY) : candidate.layers,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
|
||||
id: commandIds.documentRemoveArtboard,
|
||||
name: "Remove artboard",
|
||||
@@ -308,10 +360,51 @@ export const documentAddGroupLayerCommand: Command<DocumentAddGroupLayerPayload>
|
||||
},
|
||||
};
|
||||
|
||||
export const documentAddAdjustmentLayerCommand: Command<DocumentAddAdjustmentLayerPayload> = {
|
||||
id: commandIds.documentAddAdjustmentLayer,
|
||||
name: "Add adjustment layer",
|
||||
execute({ state }, payload) {
|
||||
if (payload.parentGroupId || !validAdjustment(payload.layer.adjustment)) return state;
|
||||
return { ...state, document: insertLayer(state.document, payload.artboardId, undefined, payload.layer, 0), editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentAddTextLayerCommand: Command<DocumentAddTextLayerPayload> = {
|
||||
id: commandIds.documentAddTextLayer,
|
||||
name: "Add text layer",
|
||||
execute({ state }, payload) {
|
||||
if (!payload.layer.content.trim() || !isValidTextStyle(payload.layer.style)) return state;
|
||||
const document = insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer);
|
||||
return { ...state, document, editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetTextLayerCommand: Command<DocumentSetTextLayerPayload> = {
|
||||
id: commandIds.documentSetTextLayer,
|
||||
name: "Edit text layer",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || location.layer.type !== "text" || location.layer.locked || !payload.content.trim() || !isValidTextStyle(payload.style)) return state;
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "text" ? { ...layer, content: payload.content, style: { ...payload.style } } : layer) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetAdjustmentCommand: Command<DocumentSetAdjustmentPayload> = {
|
||||
id: commandIds.documentSetAdjustment,
|
||||
name: "Edit adjustment layer",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || location.layer.type !== "adjustment" || location.layer.locked || !validAdjustment(payload.adjustment)) return state;
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "adjustment" ? { ...layer, adjustment: payload.adjustment } : layer) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
|
||||
id: commandIds.documentMoveLayer,
|
||||
name: "Move layer",
|
||||
execute({ state }, payload) {
|
||||
const source = findLayerLocation(state.document, payload.layerId);
|
||||
if (source?.layer.type === "adjustment" && payload.toParentGroupId) return state;
|
||||
if (payload.toParentGroupId && !findGroup(state.document, payload.toParentGroupId)) return state;
|
||||
|
||||
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||
@@ -345,6 +438,7 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
|
||||
const location = findLayerLocation(state.document, layerId);
|
||||
return location ? [location] : [];
|
||||
});
|
||||
if (requestedLocations.some((location) => location.layer.type === "adjustment")) return state;
|
||||
if (requestedLocations.length !== requestedIds.length) return state;
|
||||
|
||||
const firstLocation = requestedLocations[0];
|
||||
@@ -402,6 +496,77 @@ export const documentSetLayerLockedCommand: Command<DocumentSetLayerLockedPayloa
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetLayerOpacityCommand: Command<DocumentSetLayerOpacityPayload> = {
|
||||
id: commandIds.documentSetLayerOpacity,
|
||||
name: "Set layer opacity",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || location.layer.locked || !Number.isFinite(payload.opacity)) return state;
|
||||
const opacity = Math.min(1, Math.max(0, payload.opacity));
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, opacity })) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetLayerSourceRectCommand: Command<DocumentSetLayerSourceRectPayload> = {
|
||||
id: commandIds.documentSetLayerSourceRect,
|
||||
name: "Crop layer",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || (location.layer.type !== "image" && location.layer.type !== "raster") || location.layer.locked || location.layer.transform.rotation !== 0) return state;
|
||||
const leaf = location.layer;
|
||||
const asset = state.document.assets.find((candidate) => candidate.id === leaf.assetId);
|
||||
if (!asset) return state;
|
||||
const sourceRect = payload.sourceRect ? clampSourceRect(payload.sourceRect, asset.intrinsicSize.w, asset.intrinsicSize.h) : undefined;
|
||||
if (payload.sourceRect && !sourceRect) return state;
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
|
||||
if (layer.type !== "image" && layer.type !== "raster") return layer;
|
||||
if (sourceRect) return { ...layer, sourceRect };
|
||||
const uncropped = { ...layer };
|
||||
delete uncropped.sourceRect;
|
||||
return uncropped;
|
||||
}) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentDuplicateLayerCommand: Command<DocumentDuplicateLayerPayload> = {
|
||||
id: commandIds.documentDuplicateLayer,
|
||||
name: "Duplicate layer",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || location.layer.locked) return state;
|
||||
const maskId = getLayerMask(location.layer)?.maskLayerId;
|
||||
const mask = maskId ? location.siblings.find((layer) => layer.id === maskId) : undefined;
|
||||
const sourceLayers = mask ? [mask, location.layer] : [location.layer];
|
||||
const sourceIds = new Set(sourceLayers.flatMap((layer) => [...collectLayerIds(layer)]));
|
||||
const mappedIds = [...sourceIds].map((id) => payload.idByLayerId[id]);
|
||||
if (mappedIds.some((id) => !id) || new Set(mappedIds).size !== mappedIds.length || mappedIds.some((id) => findLayerLocation(state.document, id!))) return state;
|
||||
|
||||
const duplicates = sourceLayers.map((layer) => duplicateLayerTree(layer, payload.idByLayerId));
|
||||
const insertionIndex = Math.max(...sourceLayers.map((layer) => location.siblings.findIndex((candidate) => candidate.id === layer.id))) + 1;
|
||||
const siblings = [...location.siblings.slice(0, insertionIndex), ...duplicates, ...location.siblings.slice(insertionIndex)];
|
||||
const duplicatedLayerId = payload.idByLayerId[payload.layerId];
|
||||
if (!duplicatedLayerId) return state;
|
||||
return {
|
||||
...state,
|
||||
document: replaceLayerListInDocument(state.document, location.artboardId, location.parentGroupId, siblings),
|
||||
editor: { ...state.editor, selection: { artboardId: location.artboardId, layerIds: [duplicatedLayerId] } },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function duplicateLayerTree(layer: Layer, idByLayerId: Record<LayerId, LayerId>): Layer {
|
||||
const layerMask = getLayerMask(layer);
|
||||
const duplicatedMaskId = layerMask ? idByLayerId[layerMask.maskLayerId] : undefined;
|
||||
const duplicated = {
|
||||
...layer,
|
||||
id: idByLayerId[layer.id]!,
|
||||
name: `${layer.name} copy`,
|
||||
transform: { ...layer.transform, position: { ...layer.transform.position }, scale: { ...layer.transform.scale } },
|
||||
...(layerMask && duplicatedMaskId ? { layerMask: { ...layerMask, maskLayerId: duplicatedMaskId }, clippingMask: layer.clippingMask ? { maskLayerId: duplicatedMaskId } : undefined } : {}),
|
||||
};
|
||||
return layer.type === "group" ? { ...duplicated, type: "group", children: layer.children.map((child) => duplicateLayerTree(child, idByLayerId)) } : duplicated;
|
||||
}
|
||||
|
||||
export const documentRenameLayerCommand: Command<DocumentRenameLayerPayload> = {
|
||||
id: commandIds.documentRenameLayer,
|
||||
name: "Rename layer",
|
||||
@@ -433,6 +598,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
|
||||
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
||||
if (!targetLocation || !maskLocation) return state;
|
||||
if (targetLocation.layer.type === "adjustment" || targetLocation.layer.type === "text") return state;
|
||||
if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state;
|
||||
|
||||
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||
@@ -459,7 +625,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
||||
name: "Add layer mask",
|
||||
execute({ state }, payload) {
|
||||
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||
if (!targetLocation || targetLocation.layer.type === "group") return state;
|
||||
if (!targetLocation || (targetLocation.layer.type !== "image" && targetLocation.layer.type !== "raster")) return state;
|
||||
|
||||
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
|
||||
if (existingMaskId) {
|
||||
@@ -522,7 +688,7 @@ export const documentApplyLayerMaskOperationCommand: Command<DocumentApplyLayerM
|
||||
if (!payload.source.trim()) return state;
|
||||
|
||||
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
||||
if (!maskLocation || maskLocation.layer.type === "group") return state;
|
||||
if (!maskLocation || (maskLocation.layer.type !== "image" && maskLocation.layer.type !== "raster")) return state;
|
||||
if (!isReferencedMaskLayer(state.document, payload.maskLayerId)) return state;
|
||||
const maskAssetId = maskLocation.layer.assetId;
|
||||
|
||||
@@ -604,6 +770,7 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
|
||||
export const documentCommands = [
|
||||
documentAddArtboardCommand,
|
||||
documentSetArtboardBoundsCommand,
|
||||
documentResizeArtboardCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
documentSetArtboardVisibleCommand,
|
||||
documentSetArtboardLockedCommand,
|
||||
@@ -613,15 +780,56 @@ export const documentCommands = [
|
||||
documentAddImageLayerCommand,
|
||||
documentAddRasterLayerCommand,
|
||||
documentAddGroupLayerCommand,
|
||||
documentAddAdjustmentLayerCommand,
|
||||
documentAddTextLayerCommand,
|
||||
documentSetTextLayerCommand,
|
||||
documentSetAdjustmentCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentGroupLayersCommand,
|
||||
documentUngroupLayerCommand,
|
||||
documentRemoveLayerCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentSetLayerOpacityCommand,
|
||||
documentSetLayerSourceRectCommand,
|
||||
documentDuplicateLayerCommand,
|
||||
documentRenameLayerCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentAddLayerMaskCommand,
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentRemoveLayerMaskCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
function validRect(rect: Rect): Rect | undefined {
|
||||
return [rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? { ...rect } : undefined;
|
||||
}
|
||||
|
||||
function validAdjustment(value: ColorAdjustment): boolean {
|
||||
return [value.brightness, value.contrast, value.saturation, value.colorBalance.red, value.colorBalance.green, value.colorBalance.blue].every((number) => Number.isFinite(number) && number >= -1 && number <= 1);
|
||||
}
|
||||
|
||||
function clampSourceRect(rect: Rect, width: number, height: number): Rect | undefined {
|
||||
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite)) return undefined;
|
||||
const x = Math.max(0, Math.min(width - 1, rect.x));
|
||||
const y = Math.max(0, Math.min(height - 1, rect.y));
|
||||
const w = Math.min(width - x, rect.w);
|
||||
const h = Math.min(height - y, rect.h);
|
||||
return w >= 1 && h >= 1 ? { x, y, w, h } : undefined;
|
||||
}
|
||||
|
||||
function scaleLayerTree(layers: Layer[], before: Rect, after: Rect, scaleX: number, scaleY: number): Layer[] {
|
||||
return layers.map((layer) => layer.type === "adjustment" ? layer : layer.type === "group" ? {
|
||||
...layer,
|
||||
children: scaleLayerTree(layer.children, before, after, scaleX, scaleY),
|
||||
} : {
|
||||
...layer,
|
||||
transform: {
|
||||
...layer.transform,
|
||||
position: {
|
||||
x: after.x + (layer.transform.position.x - before.x) * scaleX,
|
||||
y: after.y + (layer.transform.position.y - before.y) * scaleY,
|
||||
},
|
||||
scale: { x: layer.transform.scale.x * scaleX, y: layer.transform.scale.y * scaleY },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export const commandIds = {
|
||||
projectOpen: "project.open",
|
||||
documentAddArtboard: "document.addArtboard",
|
||||
documentSetArtboardBounds: "document.setArtboardBounds",
|
||||
documentResizeArtboard: "document.resizeArtboard",
|
||||
documentRemoveArtboard: "document.removeArtboard",
|
||||
documentSetArtboardVisible: "document.setArtboardVisible",
|
||||
documentSetArtboardLocked: "document.setArtboardLocked",
|
||||
@@ -11,12 +12,19 @@ export const commandIds = {
|
||||
documentAddImageLayer: "document.addImageLayer",
|
||||
documentAddRasterLayer: "document.addRasterLayer",
|
||||
documentAddGroupLayer: "document.addGroupLayer",
|
||||
documentAddAdjustmentLayer: "document.addAdjustmentLayer",
|
||||
documentAddTextLayer: "document.addTextLayer",
|
||||
documentSetTextLayer: "document.setTextLayer",
|
||||
documentSetAdjustment: "document.setAdjustment",
|
||||
documentMoveLayer: "document.moveLayer",
|
||||
documentGroupLayers: "document.groupLayers",
|
||||
documentUngroupLayer: "document.ungroupLayer",
|
||||
documentRemoveLayer: "document.removeLayer",
|
||||
documentSetLayerVisible: "document.setLayerVisible",
|
||||
documentSetLayerLocked: "document.setLayerLocked",
|
||||
documentSetLayerOpacity: "document.setLayerOpacity",
|
||||
documentSetLayerSourceRect: "document.setLayerSourceRect",
|
||||
documentDuplicateLayer: "document.duplicateLayer",
|
||||
documentRenameLayer: "document.renameLayer",
|
||||
documentSetLayerClippingMask: "document.setLayerClippingMask",
|
||||
documentAddLayerMask: "document.addLayerMask",
|
||||
@@ -27,6 +35,7 @@ export const commandIds = {
|
||||
selectionAddLayer: "selection.addLayer",
|
||||
toolSetActive: "tool.setActive",
|
||||
toolSetGenerateSettings: "tool.setGenerateSettings",
|
||||
toolChooseGenerateIntent: "tool.chooseGenerateIntent",
|
||||
toolSetBrushSettings: "tool.setBrushSettings",
|
||||
toolSetChromaKeySettings: "tool.setChromaKeySettings",
|
||||
toolSetMagicWandSettings: "tool.setMagicWandSettings",
|
||||
@@ -55,6 +64,7 @@ export const commandIds = {
|
||||
transformBegin: "transform.begin",
|
||||
transformUpdate: "transform.update",
|
||||
transformSetBounds: "transform.setBounds",
|
||||
transformSetRotation: "transform.setRotation",
|
||||
transformEnd: "transform.end",
|
||||
viewportPan: "viewport.pan",
|
||||
viewportSetZoom: "viewport.setZoom",
|
||||
|
||||
@@ -5,12 +5,17 @@ export {
|
||||
documentAddArtboardCommand,
|
||||
documentAddAssetCommand,
|
||||
documentAddGroupLayerCommand,
|
||||
documentAddAdjustmentLayerCommand,
|
||||
documentSetAdjustmentCommand,
|
||||
documentAddTextLayerCommand,
|
||||
documentSetTextLayerCommand,
|
||||
documentAddImageLayerCommand,
|
||||
documentAddLayerMaskCommand,
|
||||
documentAddRasterLayerCommand,
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentCommands,
|
||||
documentGroupLayersCommand,
|
||||
documentDuplicateLayerCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
documentRemoveLayerCommand,
|
||||
@@ -22,6 +27,7 @@ export {
|
||||
documentSetArtboardVisibleCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentSetLayerOpacityCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentUpdateAssetSourceCommand,
|
||||
documentUngroupLayerCommand,
|
||||
@@ -30,11 +36,16 @@ export type {
|
||||
DocumentAddArtboardPayload,
|
||||
DocumentAddAssetPayload,
|
||||
DocumentAddGroupLayerPayload,
|
||||
DocumentAddAdjustmentLayerPayload,
|
||||
DocumentSetAdjustmentPayload,
|
||||
DocumentAddTextLayerPayload,
|
||||
DocumentSetTextLayerPayload,
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentAddLayerMaskPayload,
|
||||
DocumentAddRasterLayerPayload,
|
||||
DocumentApplyLayerMaskOperationPayload,
|
||||
DocumentGroupLayersPayload,
|
||||
DocumentDuplicateLayerPayload,
|
||||
DocumentMoveLayerPayload,
|
||||
DocumentRemoveArtboardPayload,
|
||||
DocumentRemoveLayerPayload,
|
||||
@@ -46,6 +57,7 @@ export type {
|
||||
DocumentSetArtboardVisiblePayload,
|
||||
DocumentSetLayerClippingMaskPayload,
|
||||
DocumentSetLayerLockedPayload,
|
||||
DocumentSetLayerOpacityPayload,
|
||||
DocumentSetLayerVisiblePayload,
|
||||
DocumentUpdateAssetSourcePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
@@ -89,10 +101,10 @@ export type { CommandRegistry } from "./registry";
|
||||
export { createCommandRegistry } from "./registry";
|
||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
export { toolChooseGenerateIntentCommand, toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
|
||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
|
||||
export type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
export {
|
||||
viewportCommands,
|
||||
viewportPanCommand,
|
||||
|
||||
@@ -3,11 +3,14 @@ import type {
|
||||
DocumentAddArtboardPayload,
|
||||
DocumentAddAssetPayload,
|
||||
DocumentAddGroupLayerPayload,
|
||||
DocumentAddAdjustmentLayerPayload,
|
||||
DocumentSetAdjustmentPayload,
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentAddLayerMaskPayload,
|
||||
DocumentAddRasterLayerPayload,
|
||||
DocumentApplyLayerMaskOperationPayload,
|
||||
DocumentGroupLayersPayload,
|
||||
DocumentDuplicateLayerPayload,
|
||||
DocumentMoveLayerPayload,
|
||||
DocumentRemoveArtboardPayload,
|
||||
DocumentRemoveLayerPayload,
|
||||
@@ -15,13 +18,18 @@ import type {
|
||||
DocumentRenameArtboardPayload,
|
||||
DocumentRenameLayerPayload,
|
||||
DocumentSetArtboardBoundsPayload,
|
||||
DocumentResizeArtboardPayload,
|
||||
DocumentSetArtboardLockedPayload,
|
||||
DocumentSetArtboardVisiblePayload,
|
||||
DocumentSetLayerClippingMaskPayload,
|
||||
DocumentSetLayerLockedPayload,
|
||||
DocumentSetLayerOpacityPayload,
|
||||
DocumentSetLayerSourceRectPayload,
|
||||
DocumentSetLayerVisiblePayload,
|
||||
DocumentUpdateAssetSourcePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
DocumentAddTextLayerPayload,
|
||||
DocumentSetTextLayerPayload,
|
||||
} from "./document";
|
||||
import type {
|
||||
GenerationAddCandidatePayload,
|
||||
@@ -44,8 +52,8 @@ import type {
|
||||
CommandPaletteSetSelectedIndexPayload,
|
||||
} from "./palette";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
import type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
|
||||
import type { WorkspaceSetPanelPayload } from "./workspace";
|
||||
import type { EditorSetPointerSessionPayload } from "./editor";
|
||||
import type { ProjectOpenPayload } from "./project";
|
||||
@@ -61,6 +69,7 @@ export type CommandPayloads = {
|
||||
[commandIds.projectOpen]: ProjectOpenPayload;
|
||||
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
||||
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
||||
[commandIds.documentResizeArtboard]: DocumentResizeArtboardPayload;
|
||||
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
|
||||
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
|
||||
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
|
||||
@@ -70,6 +79,10 @@ export type CommandPayloads = {
|
||||
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
||||
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
|
||||
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
||||
[commandIds.documentAddAdjustmentLayer]: DocumentAddAdjustmentLayerPayload;
|
||||
[commandIds.documentSetAdjustment]: DocumentSetAdjustmentPayload;
|
||||
[commandIds.documentAddTextLayer]: DocumentAddTextLayerPayload;
|
||||
[commandIds.documentSetTextLayer]: DocumentSetTextLayerPayload;
|
||||
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
|
||||
[commandIds.documentApplyLayerMaskOperation]: DocumentApplyLayerMaskOperationPayload;
|
||||
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
|
||||
@@ -79,6 +92,9 @@ export type CommandPayloads = {
|
||||
[commandIds.documentRemoveLayer]: DocumentRemoveLayerPayload;
|
||||
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
|
||||
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
|
||||
[commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload;
|
||||
[commandIds.documentSetLayerSourceRect]: DocumentSetLayerSourceRectPayload;
|
||||
[commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload;
|
||||
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
|
||||
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;
|
||||
[commandIds.selectionSet]: SelectionSetPayload;
|
||||
@@ -86,6 +102,7 @@ export type CommandPayloads = {
|
||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||
[commandIds.toolSetGenerateSettings]: ToolSetGenerateSettingsPayload;
|
||||
[commandIds.toolChooseGenerateIntent]: ToolChooseGenerateIntentPayload;
|
||||
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
||||
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
|
||||
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
|
||||
@@ -114,6 +131,7 @@ export type CommandPayloads = {
|
||||
[commandIds.transformBegin]: TransformBeginPayload;
|
||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
||||
[commandIds.transformSetRotation]: TransformSetRotationPayload;
|
||||
[commandIds.transformEnd]: void;
|
||||
[commandIds.viewportPan]: ViewportPanPayload;
|
||||
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
||||
|
||||
23
commands/text-layer.test.ts
Normal file
23
commands/text-layer.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand, documentAddTextLayerCommand, documentGroupLayersCommand, documentSetTextLayerCommand } from "./document";
|
||||
import type { TextLayer } from "@core/text-layer";
|
||||
|
||||
const text: TextLayer = { id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 5, y: 6 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "Hello", style: { fontFamily: "Arial", fontSize: 24, fontWeight: 400, fontStyle: "normal", color: "#112233", alignment: "left", lineHeight: 1.2 } };
|
||||
|
||||
describe("text layer commands", () => {
|
||||
test("creates, selects and edits authoritative text", () => {
|
||||
const base = documentAddArtboardCommand.execute({ state: createInitialAppState("Test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } });
|
||||
const added = documentAddTextLayerCommand.execute({ state: base }, { artboardId: "board", layer: text });
|
||||
expect(added.editor.selection.layerIds).toEqual(["text"]);
|
||||
const edited = documentSetTextLayerCommand.execute({ state: added }, { layerId: "text", content: "Changed", style: { ...text.style, fontWeight: 700 } });
|
||||
expect(edited.document.artboards[0]?.layers[0]).toMatchObject({ type: "text", content: "Changed", style: { fontWeight: 700 } });
|
||||
});
|
||||
|
||||
test("can be grouped as a real tree node", () => {
|
||||
const base = documentAddArtboardCommand.execute({ state: createInitialAppState("Test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } });
|
||||
const added = documentAddTextLayerCommand.execute({ state: base }, { artboardId: "board", layer: text });
|
||||
const grouped = documentGroupLayersCommand.execute({ state: added }, { artboardId: "board", layerIds: ["text"], group: { id: "group", type: "group", name: "Group", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, children: [] } });
|
||||
expect(grouped.document.artboards[0]?.layers[0]).toMatchObject({ type: "group", children: [{ id: "text", type: "text" }] });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
import { toolChooseGenerateIntentCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
|
||||
const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
|
||||
const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 };
|
||||
@@ -60,6 +60,15 @@ describe("tool commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("maps user intents to supported generation modes and a compatible architecture", () => {
|
||||
const zImage = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { architecture: "z-image" });
|
||||
const replace = toolChooseGenerateIntentCommand.execute({ state: zImage }, { intent: "replace" });
|
||||
const variations = toolChooseGenerateIntentCommand.execute({ state: replace }, { intent: "variations" });
|
||||
|
||||
expect(replace.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "inpaint" });
|
||||
expect(variations.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "image-to-image" });
|
||||
});
|
||||
|
||||
test("sets and clears brush preview", () => {
|
||||
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
|
||||
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { MaskViewMode } from "@editor/state";
|
||||
import { generateArchitectureDefaults } from "@editor/tools";
|
||||
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
||||
import type { BrushSettings, ChromaKeySettings, GenerateIntent, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ToolSetActivePayload = {
|
||||
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||
|
||||
export type ToolSetGenerateSettingsPayload = Partial<GenerateSettings>;
|
||||
export type ToolChooseGenerateIntentPayload = { intent: GenerateIntent };
|
||||
|
||||
export type ToolSetChromaKeySettingsPayload = Partial<ChromaKeySettings>;
|
||||
|
||||
@@ -120,6 +121,25 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
|
||||
},
|
||||
};
|
||||
|
||||
const modeByGenerateIntent: Record<GenerateIntent, GenerateSettings["mode"]> = {
|
||||
create: "text-to-image",
|
||||
replace: "inpaint",
|
||||
extend: "outpaint",
|
||||
variations: "image-to-image",
|
||||
};
|
||||
|
||||
export const toolChooseGenerateIntentCommand: Command<ToolChooseGenerateIntentPayload> = {
|
||||
id: commandIds.toolChooseGenerateIntent,
|
||||
name: "Choose AI edit intent",
|
||||
execute({ state }, payload) {
|
||||
const mode = modeByGenerateIntent[payload.intent];
|
||||
const architecture = generateArchitectureDefaults[state.editor.tools.generate.architecture].supportedModes.includes(mode)
|
||||
? state.editor.tools.generate.architecture
|
||||
: "sdxl";
|
||||
return toolSetGenerateSettingsCommand.execute({ state }, { architecture, mode });
|
||||
},
|
||||
};
|
||||
|
||||
export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> = {
|
||||
id: commandIds.toolSetBrushSettings,
|
||||
name: "Set brush settings",
|
||||
@@ -339,6 +359,7 @@ export const toolExitTemporaryPanCommand: Command = {
|
||||
export const toolCommands = [
|
||||
toolSetActiveCommand,
|
||||
toolSetGenerateSettingsCommand,
|
||||
toolChooseGenerateIntentCommand,
|
||||
toolSetBrushSettingsCommand,
|
||||
toolSetChromaKeySettingsCommand,
|
||||
toolSetMagicWandSettingsCommand,
|
||||
|
||||
@@ -5,12 +5,13 @@ import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { TransformTarget } from "@editor/transform";
|
||||
import { measureTextLayer } from "@core/text-layer";
|
||||
|
||||
export function applyTransformTargetBounds(document: ImageDocument, target: TransformTarget, bounds: Rect): ImageDocument {
|
||||
if (target.type === "artboard") return { ...document, artboards: document.artboards.map((artboard) => artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard) };
|
||||
const layer = findLayer(document, target.id);
|
||||
if (!layer) return document;
|
||||
return layer.type === "group" ? applyGroupBounds(document, layer.id, bounds) : applyLeafBounds(document, layer.id, bounds);
|
||||
return layer.type === "group" ? applyGroupBounds(document, layer.id, bounds) : layer.type === "adjustment" ? document : applyLeafBounds(document, layer.id, bounds);
|
||||
}
|
||||
|
||||
function applyLeafBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
|
||||
@@ -39,15 +40,19 @@ function mapGroupBounds(document: ImageDocument, layers: Layer[], groupId: Layer
|
||||
|
||||
function scaleSubtree(document: ImageDocument, layer: Layer, initial: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
|
||||
if (layer.type === "group") return { ...layer, children: layer.children.map((child) => scaleSubtree(document, child, initial, bounds, scale)) };
|
||||
if (!document.assets.some((asset) => asset.id === layer.assetId)) return layer;
|
||||
if (layer.type === "adjustment") return layer;
|
||||
if (layer.type !== "text" && !document.assets.some((asset) => asset.id === layer.assetId)) return layer;
|
||||
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x + (layer.transform.position.x - initial.x) * scale.x, y: bounds.y + (layer.transform.position.y - initial.y) * scale.y }, scale: { x: layer.transform.scale.x * scale.x, y: layer.transform.scale.y * scale.y } } };
|
||||
}
|
||||
|
||||
function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.id === layerId && layer.type !== "group") {
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
return asset ? { ...layer, transform: { ...layer.transform, position: { x: bounds.x, y: bounds.y }, scale: { x: bounds.w / asset.intrinsicSize.w, y: bounds.h / asset.intrinsicSize.h } } } : layer;
|
||||
if (layer.id === layerId && layer.type !== "group" && layer.type !== "adjustment") {
|
||||
const asset = layer.type === "text" ? undefined : document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (layer.type !== "text" && !asset) return layer;
|
||||
const source = layer.type === "text" ? { x: 0, y: 0, ...measureTextLayer(layer) } : layer.sourceRect ?? { x: 0, y: 0, ...asset!.intrinsicSize };
|
||||
const scale = { x: bounds.w / source.w, y: bounds.h / source.h };
|
||||
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x - source.x * scale.x, y: bounds.y - source.y * scale.y }, scale } };
|
||||
}
|
||||
return layer.type === "group" ? { ...layer, children: mapLeafBounds(document, layer.children, layerId, bounds) } : layer;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand } from "./document";
|
||||
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
|
||||
import { documentAddAssetCommand, documentAddRasterLayerCommand, documentAddLayerMaskCommand, documentSetLayerLockedCommand } from "./document";
|
||||
|
||||
function artboardState() {
|
||||
return documentAddArtboardCommand.execute(
|
||||
@@ -79,6 +80,19 @@ describe("transform commands", () => {
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 4, y: 19, w: 1, h: 1 });
|
||||
});
|
||||
|
||||
test("sets leaf rotation and keeps its attached mask aligned", () => {
|
||||
let state = artboardState();
|
||||
state = documentAddAssetCommand.execute({ state }, { asset: { id: "asset", name: "Asset", mimeType: "image/png", source: "asset", intrinsicSize: { w: 10, h: 10 } } });
|
||||
state = documentAddRasterLayerCommand.execute({ state }, { artboardId: "a1", layer: { id: "layer", type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId: "asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } });
|
||||
state = documentAddLayerMaskCommand.execute({ state }, { layerId: "layer", asset: { id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 10, h: 10 } }, maskLayer: { id: "mask", type: "raster", name: "Mask", visible: true, locked: false, opacity: 1, assetId: "mask-asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } });
|
||||
|
||||
const rotated = transformSetRotationCommand.execute({ state }, { target: { type: "layer", id: "layer" }, rotation: Math.PI / 2 });
|
||||
expect(rotated.document.artboards[0]?.layers.map((layer) => layer.transform.rotation)).toEqual([Math.PI / 2, Math.PI / 2]);
|
||||
|
||||
const locked = documentSetLayerLockedCommand.execute({ state: rotated }, { layerId: "layer", locked: true });
|
||||
expect(transformSetRotationCommand.execute({ state: locked }, { target: { type: "layer", id: "layer" }, rotation: 0 })).toBe(locked);
|
||||
});
|
||||
|
||||
test("ends transform session", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { applyTransformTargetBounds } from "./transform-document";
|
||||
import type { TransformHandle, TransformTarget } from "@editor/transform";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
import type { AppState } from "@editor/state";
|
||||
|
||||
export type TransformBeginPayload = {
|
||||
target: TransformTarget;
|
||||
@@ -21,6 +23,11 @@ export type TransformSetBoundsPayload = {
|
||||
bounds: Rect;
|
||||
};
|
||||
|
||||
export type TransformSetRotationPayload = {
|
||||
target: TransformTarget;
|
||||
rotation: number;
|
||||
};
|
||||
|
||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||
id: commandIds.transformBegin,
|
||||
name: "Begin transform",
|
||||
@@ -70,6 +77,7 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
|
||||
id: commandIds.transformSetBounds,
|
||||
name: "Set transform bounds",
|
||||
execute({ state }, payload) {
|
||||
if (isTargetLocked(state, payload.target)) return state;
|
||||
return {
|
||||
...state,
|
||||
document: applyTransformTargetBounds(state.document, payload.target, normalizeRect(payload.bounds)),
|
||||
@@ -77,6 +85,42 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const transformSetRotationCommand: Command<TransformSetRotationPayload> = {
|
||||
id: commandIds.transformSetRotation,
|
||||
name: "Set transform rotation",
|
||||
execute({ state }, payload) {
|
||||
if (payload.target.type !== "layer" || !Number.isFinite(payload.rotation) || isTargetLocked(state, payload.target)) return state;
|
||||
const location = state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, payload.target.id)).find(Boolean);
|
||||
if (!location || location.type === "group") return state;
|
||||
const maskId = "layerMask" in location ? location.layerMask?.maskLayerId : undefined;
|
||||
const ids = new Set([payload.target.id, ...(maskId ? [maskId] : [])]);
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) => ({ ...artboard, layers: mapRotation(artboard.layers, ids, payload.rotation) })),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function isTargetLocked(state: AppState, target: TransformTarget) {
|
||||
if (target.type === "artboard") return state.document.artboards.find((artboard) => artboard.id === target.id)?.locked !== false;
|
||||
return state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, target.id)).find(Boolean)?.locked !== false;
|
||||
}
|
||||
|
||||
function findLayerInTree(layers: Layer[], id: string): Layer[] {
|
||||
return layers.flatMap((layer) => layer.id === id ? [layer] : layer.type === "group" ? findLayerInTree(layer.children, id) : []);
|
||||
}
|
||||
|
||||
function mapRotation(layers: Layer[], ids: ReadonlySet<string>, rotation: number): Layer[] {
|
||||
return layers.map((layer) => ({
|
||||
...layer,
|
||||
...(ids.has(layer.id) ? { transform: { ...layer.transform, rotation } } : {}),
|
||||
...(layer.type === "group" ? { children: mapRotation(layer.children, ids, rotation) } : {}),
|
||||
})) as Layer[];
|
||||
}
|
||||
|
||||
export const transformEndCommand: Command = {
|
||||
id: commandIds.transformEnd,
|
||||
name: "End transform",
|
||||
@@ -94,7 +138,7 @@ export const transformEndCommand: Command = {
|
||||
},
|
||||
};
|
||||
|
||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformSetRotationCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||
|
||||
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
|
||||
if (handle === "body") {
|
||||
|
||||
26
core/adjustment-layer.ts
Normal file
26
core/adjustment-layer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { BaseLayer } from "./base-layer";
|
||||
|
||||
export type ColorAdjustment = {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
colorBalance: { red: number; green: number; blue: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* A non-destructive adjustment affecting pixels already composited beneath it
|
||||
* on its artboard. Adjustment layers are top-level artboard nodes so live WebGL
|
||||
* and exported PNG compositing have identical scope. Transform and masks are
|
||||
* intentionally unsupported.
|
||||
*/
|
||||
export type AdjustmentLayer = BaseLayer & {
|
||||
type: "adjustment";
|
||||
adjustment: ColorAdjustment;
|
||||
};
|
||||
|
||||
export const neutralColorAdjustment: ColorAdjustment = {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
colorBalance: { red: 0, green: 0, blue: 0 },
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { BaseLayer } from "./base-layer";
|
||||
import type { AssetId } from "./id";
|
||||
import type { Rect } from "./geometry";
|
||||
|
||||
export type ImageLayer = BaseLayer & {
|
||||
type: "image";
|
||||
assetId: AssetId;
|
||||
/** Non-destructive source pixels displayed by this layer. Absence means the full asset. */
|
||||
sourceRect?: Rect;
|
||||
};
|
||||
|
||||
@@ -15,8 +15,12 @@ export type {
|
||||
} from "./geometry";
|
||||
export type { ArtboardId, AssetId, DocumentId, GenerationCandidateId, GenerationJobId, LayerId } from "./id";
|
||||
export type { ImageLayer } from "./image-layer";
|
||||
export type { AdjustmentLayer, ColorAdjustment } from "./adjustment-layer";
|
||||
export { neutralColorAdjustment } from "./adjustment-layer";
|
||||
export type { Layer } from "./layer";
|
||||
export type { LayerMask } from "./layer-mask";
|
||||
export { getLayerMask, hasLayerMask } from "./layer-mask-utils";
|
||||
export type { LayerGroup } from "./layer-group";
|
||||
export type { RasterLayer } from "./raster-layer";
|
||||
export type { TextAlignment, TextFontFamily, TextFontStyle, TextFontWeight, TextLayer, TextStyle } from "./text-layer";
|
||||
export { builtInTextFonts, isValidTextStyle, measureTextLayer } from "./text-layer";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ImageLayer } from "./image-layer";
|
||||
import type { LayerGroup } from "./layer-group";
|
||||
import type { RasterLayer } from "./raster-layer";
|
||||
import type { AdjustmentLayer } from "./adjustment-layer";
|
||||
import type { TextLayer } from "./text-layer";
|
||||
|
||||
export type Layer = ImageLayer | RasterLayer | LayerGroup;
|
||||
export type Layer = ImageLayer | RasterLayer | LayerGroup | AdjustmentLayer | TextLayer;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { BaseLayer } from "./base-layer";
|
||||
import type { AssetId } from "./id";
|
||||
import type { Rect } from "./geometry";
|
||||
|
||||
export type RasterLayer = BaseLayer & {
|
||||
type: "raster";
|
||||
assetId: AssetId;
|
||||
/** Non-destructive source pixels displayed by this layer. Absence means the full asset. */
|
||||
sourceRect?: Rect;
|
||||
};
|
||||
|
||||
13
core/text-layer.test.ts
Normal file
13
core/text-layer.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isValidTextStyle, measureTextLayer, type TextLayer } from "./text-layer";
|
||||
|
||||
const layer: TextLayer = { id: "text-1", type: "text", name: "Title", visible: true, locked: false, opacity: 1, transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "Hello\nWorld", style: { fontFamily: "Arial", fontSize: 20, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "left", lineHeight: 1.25 } };
|
||||
|
||||
describe("text layer metrics", () => {
|
||||
test("uses deterministic multiline bounds", () => expect(measureTextLayer(layer)).toEqual({ w: 54, h: 50 }));
|
||||
test("validates the closed built-in typography policy", () => {
|
||||
expect(isValidTextStyle(layer.style)).toBeTrue();
|
||||
expect(isValidTextStyle({ ...layer.style, fontFamily: "Remote Font" as never })).toBeFalse();
|
||||
expect(isValidTextStyle({ ...layer.style, color: "red" })).toBeFalse();
|
||||
});
|
||||
});
|
||||
44
core/text-layer.ts
Normal file
44
core/text-layer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { BaseLayer } from "./base-layer";
|
||||
import type { Size } from "./geometry";
|
||||
|
||||
export const builtInTextFonts = ["Arial", "Georgia", "Courier New", "Trebuchet MS"] as const;
|
||||
export type TextFontFamily = typeof builtInTextFonts[number];
|
||||
export type TextAlignment = "left" | "center" | "right";
|
||||
export type TextFontStyle = "normal" | "italic";
|
||||
export type TextFontWeight = 400 | 700;
|
||||
|
||||
export type TextStyle = {
|
||||
fontFamily: TextFontFamily;
|
||||
fontSize: number;
|
||||
fontWeight: TextFontWeight;
|
||||
fontStyle: TextFontStyle;
|
||||
color: string;
|
||||
alignment: TextAlignment;
|
||||
lineHeight: number;
|
||||
};
|
||||
|
||||
export type TextLayer = BaseLayer & {
|
||||
type: "text";
|
||||
content: string;
|
||||
style: TextStyle;
|
||||
};
|
||||
|
||||
/** Deterministic layout box shared by selection, renderer and export. */
|
||||
export function measureTextLayer(layer: Pick<TextLayer, "content" | "style">): Size {
|
||||
const lines = layer.content.split("\n");
|
||||
const weightFactor = layer.style.fontWeight === 700 ? 1.04 : 1;
|
||||
const italicFactor = layer.style.fontStyle === "italic" ? 1.03 : 1;
|
||||
const familyFactor = layer.style.fontFamily === "Courier New" ? 0.62 : layer.style.fontFamily === "Georgia" ? 0.56 : 0.54;
|
||||
const longest = Math.max(1, ...lines.map((line) => [...line].length));
|
||||
return {
|
||||
w: Math.max(1, longest * layer.style.fontSize * familyFactor * weightFactor * italicFactor),
|
||||
h: Math.max(1, lines.length * layer.style.fontSize * layer.style.lineHeight),
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidTextStyle(style: TextStyle): boolean {
|
||||
return builtInTextFonts.includes(style.fontFamily) && Number.isFinite(style.fontSize) && style.fontSize >= 1 && style.fontSize <= 1000
|
||||
&& (style.fontWeight === 400 || style.fontWeight === 700) && (style.fontStyle === "normal" || style.fontStyle === "italic")
|
||||
&& /^#[0-9a-f]{6}$/i.test(style.color) && (style.alignment === "left" || style.alignment === "center" || style.alignment === "right")
|
||||
&& Number.isFinite(style.lineHeight) && style.lineHeight >= 0.5 && style.lineHeight <= 5;
|
||||
}
|
||||
@@ -58,6 +58,11 @@ describe("document read indexes", () => {
|
||||
expect(resolveIndexedLayerBounds(index, raster("missing", "Missing", "missing-asset"))).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves a cropped layer from its retained source-pixel position", () => {
|
||||
const cropped = { ...document, artboards: [{ ...document.artboards[0]!, layers: [{ ...raster("cropped", "Cropped", "asset-target", { x: 10, y: 20 }, { x: 2, y: 3 }), sourceRect: { x: 5, y: 4, w: 20, h: 10 } }] }] };
|
||||
expect(resolveIndexedLayerBounds(createDocumentReadIndex(cropped), "cropped")).toEqual({ x: 20, y: 32, w: 40, h: 30 });
|
||||
});
|
||||
|
||||
test("visits layers back to front without mutating source order", () => {
|
||||
const layers = document.artboards[0]!.layers;
|
||||
const visited: string[] = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry";
|
||||
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { measureTextLayer } from "@core/text-layer";
|
||||
|
||||
export type IndexedLayerInfo = {
|
||||
artboardId: ArtboardId;
|
||||
@@ -75,16 +76,23 @@ export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: L
|
||||
switch (layer.type) {
|
||||
case "group":
|
||||
return unionLayerBounds(index, layer.children);
|
||||
case "adjustment":
|
||||
return undefined;
|
||||
case "text": {
|
||||
const size = measureTextLayer(layer);
|
||||
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
|
||||
}
|
||||
case "image":
|
||||
case "raster": {
|
||||
const asset = index.assetById.get(layer.assetId);
|
||||
if (!asset) return undefined;
|
||||
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
|
||||
|
||||
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,
|
||||
x: layer.transform.position.x + source.x * layer.transform.scale.x,
|
||||
y: layer.transform.position.y + source.y * layer.transform.scale.y,
|
||||
w: source.w * layer.transform.scale.x,
|
||||
h: source.h * layer.transform.scale.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -151,6 +159,7 @@ function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): R
|
||||
stack.push(...layer.children);
|
||||
continue;
|
||||
}
|
||||
if (layer.type === "adjustment") continue;
|
||||
const layerBounds = resolveIndexedLayerBounds(index, layer);
|
||||
if (!layerBounds) continue;
|
||||
bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds;
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ChromaKeySettings = {
|
||||
export type MagicWandMode = "replace" | "add" | "subtract";
|
||||
|
||||
export type GenerateMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
||||
export type GenerateIntent = "create" | "replace" | "extend" | "variations";
|
||||
|
||||
export const generateArchitectures = ["sdxl", "z-image", "z-image-turbo", "anima"] as const;
|
||||
export type GenerateArchitecture = (typeof generateArchitectures)[number];
|
||||
|
||||
@@ -56,6 +56,13 @@ describe("transform targets", () => {
|
||||
expect(layer?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
|
||||
});
|
||||
|
||||
test("applies visible bounds to a cropped layer while retaining its source crop", () => {
|
||||
const cropped = { ...document, artboards: [{ ...document.artboards[0]!, layers: [{ ...document.artboards[0]!.layers[0]!, sourceRect: { x: 10, y: 5, w: 40, h: 20 } }] }] };
|
||||
const next = applyTransformTargetBounds(cropped, { type: "layer", id: "l1" }, { x: 100, y: 80, w: 200, h: 60 });
|
||||
expect(next.artboards[0]?.layers[0]).toMatchObject({ sourceRect: { x: 10, y: 5, w: 40, h: 20 }, transform: { position: { x: 50, y: 65 }, scale: { x: 5, y: 3 } } });
|
||||
expect(resolveTransformTargetBounds(next, { type: "layer", id: "l1" })).toEqual({ x: 100, y: 80, w: 200, h: 60 });
|
||||
});
|
||||
|
||||
test("keeps attached mask bounds in sync with transformed layers", () => {
|
||||
const maskedDocument: ImageDocument = {
|
||||
...document,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Rect } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { TransformTarget } from "./transform";
|
||||
import { measureTextLayer } from "@core/text-layer";
|
||||
|
||||
export function resolveTransformTargetBounds(document: ImageDocument, target: TransformTarget): Rect | undefined {
|
||||
switch (target.type) {
|
||||
@@ -53,14 +54,21 @@ function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undef
|
||||
case "raster": {
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return undefined;
|
||||
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
|
||||
|
||||
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,
|
||||
x: layer.transform.position.x + source.x * layer.transform.scale.x,
|
||||
y: layer.transform.position.y + source.y * layer.transform.scale.y,
|
||||
w: source.w * layer.transform.scale.x,
|
||||
h: source.h * layer.transform.scale.y,
|
||||
};
|
||||
}
|
||||
case "text": {
|
||||
const size = measureTextLayer(layer);
|
||||
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
|
||||
}
|
||||
case "adjustment":
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Rect, Size, Vec2D } from "@core/geometry";
|
||||
import type { InputArtboardId, InputDocument, InputLayer, InputLayerId } from "./read-model";
|
||||
import { measureTextLayer } from "@core/text-layer";
|
||||
|
||||
export type InputViewportState = {
|
||||
center: Vec2D;
|
||||
@@ -90,6 +91,12 @@ function resolveLayerBounds(document: InputDocument, layer: InputLayer): Rect |
|
||||
h: asset.intrinsicSize.h * layer.transform.scale.y,
|
||||
};
|
||||
}
|
||||
case "text": {
|
||||
const size = measureTextLayer(layer);
|
||||
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
|
||||
}
|
||||
case "adjustment":
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Rect, Size, Transform } from "@core/geometry";
|
||||
import type { TextStyle } from "@core/text-layer";
|
||||
|
||||
export type InputLayerId = string;
|
||||
export type InputArtboardId = string;
|
||||
@@ -16,6 +17,8 @@ type InputBaseLayer = {
|
||||
|
||||
export type InputLayer =
|
||||
| (InputBaseLayer & { type: "group"; children: InputLayer[] })
|
||||
| (InputBaseLayer & { type: "adjustment" })
|
||||
| (InputBaseLayer & { type: "text"; content: string; style: TextStyle })
|
||||
| (InputBaseLayer & { type: "image" | "raster"; assetId: string });
|
||||
|
||||
export type InputDocument = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-index
|
||||
import { resolveIndexedLayerBounds } from "@editor/document-indexes";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { neutralColorAdjustment } from "@core/adjustment-layer";
|
||||
|
||||
export function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
|
||||
const index = document.artboards.length + 1;
|
||||
@@ -62,6 +63,20 @@ export function addGroupLayer(artboardId: ArtboardId, dispatch: AppStore["dispat
|
||||
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
|
||||
}
|
||||
|
||||
export function addAdjustmentLayer(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
|
||||
dispatch(commandIds.documentAddAdjustmentLayer, { artboardId, layer: { id: crypto.randomUUID(), type: "adjustment", name: "Color adjustment", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { ...neutralColorAdjustment, colorBalance: { ...neutralColorAdjustment.colorBalance } } } });
|
||||
}
|
||||
|
||||
export function addTextLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
|
||||
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
|
||||
if (!artboard) return;
|
||||
dispatch(commandIds.documentAddTextLayer, { artboardId, parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined, layer: {
|
||||
id: crypto.randomUUID(), type: "text", name: "Text", visible: true, locked: false, opacity: 1,
|
||||
transform: { position: { x: artboard.bounds.x + 40, y: artboard.bounds.y + 40 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
content: "Text", style: { fontFamily: "Arial", fontSize: 48, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "left", lineHeight: 1.2 },
|
||||
} });
|
||||
}
|
||||
|
||||
export function groupLayers(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
|
||||
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
||||
}
|
||||
@@ -105,7 +120,7 @@ export function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerIn
|
||||
|
||||
export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
|
||||
const layer = layerInfo.layer;
|
||||
if (layer.type === "group") return;
|
||||
if (layer.type === "group" || layer.type === "adjustment" || layer.type === "text") return;
|
||||
|
||||
const asset = documentIndex.assetById.get(layer.assetId);
|
||||
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
||||
@@ -143,6 +158,25 @@ export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: Indexe
|
||||
});
|
||||
}
|
||||
|
||||
export function duplicateLayer(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
|
||||
const ids = collectDuplicateIds(layerInfo.layer, layerInfo.siblings);
|
||||
dispatch(commandIds.documentDuplicateLayer, {
|
||||
layerId: layerInfo.layer.id,
|
||||
idByLayerId: Object.fromEntries(ids.map((id) => [id, crypto.randomUUID()])),
|
||||
});
|
||||
}
|
||||
|
||||
function collectDuplicateIds(layer: Layer, siblings: readonly Layer[]): string[] {
|
||||
const ids = collectTreeIds(layer);
|
||||
const maskId = getLayerMask(layer)?.maskLayerId;
|
||||
const mask = maskId ? siblings.find((candidate) => candidate.id === maskId) : undefined;
|
||||
return mask ? [...collectTreeIds(mask), ...ids] : ids;
|
||||
}
|
||||
|
||||
function collectTreeIds(layer: Layer): string[] {
|
||||
return [layer.id, ...(layer.type === "group" ? layer.children.flatMap(collectTreeIds) : [])];
|
||||
}
|
||||
|
||||
const emptyLayerIds = new Set<string>();
|
||||
|
||||
function createGroup(name: string): Extract<Layer, { type: "group" }> {
|
||||
|
||||
@@ -134,7 +134,7 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
|
||||
|
||||
const documentIndex = createDocumentReadIndex(document);
|
||||
const layerInfo = documentIndex.layerInfoById.get(selection.layerIds[0]);
|
||||
if (!layerInfo || layerInfo.layer.type === "group") throw new Error("Select one image or raster layer to inpaint.");
|
||||
if (!layerInfo || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) throw new Error("Select one image or raster layer to inpaint. Text must be rasterized first.");
|
||||
|
||||
const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
|
||||
if (!asset) throw new Error("The selected layer is missing its source image.");
|
||||
@@ -142,7 +142,7 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
|
||||
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
|
||||
|
||||
const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId);
|
||||
if (!maskLayer || maskLayer.type === "group") throw new Error("The selected layer mask is missing.");
|
||||
if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) throw new Error("The selected layer mask is missing.");
|
||||
|
||||
const maskAsset = documentIndex.assetById.get(maskLayer.assetId);
|
||||
if (!maskAsset) throw new Error("The selected layer mask is missing its image data.");
|
||||
|
||||
@@ -85,7 +85,7 @@ function resolveTarget(document: ImageDocument, selection: SelectionState, artbo
|
||||
const layerId = selection.layerIds[0];
|
||||
const index = createDocumentReadIndex(document);
|
||||
const layerInfo = layerId ? index.layerInfoById.get(layerId) : undefined;
|
||||
if (!layerInfo || layerInfo.artboardId !== artboardId || layerInfo.layer.type === "group") {
|
||||
if (!layerInfo || layerInfo.artboardId !== artboardId || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) {
|
||||
throw new Error("Select one image or raster layer before placing generated output.");
|
||||
}
|
||||
const asset = index.assetById.get(layerInfo.layer.assetId);
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("generation preconditions", () => {
|
||||
});
|
||||
|
||||
test("requires an enabled mask for inpaint", () => {
|
||||
expect(checkGenerationPreconditions(document(), selected(), settings("inpaint"))).toEqual({ ready: false, message: "Add and enable a layer mask on the selected image before inpainting." });
|
||||
expect(checkGenerationPreconditions(document(), selected(), settings("inpaint"))).toEqual({ ready: false, message: "Paint a mask over the area you want AI to replace.", repair: "add-mask" });
|
||||
});
|
||||
|
||||
test("allows inpaint when the selected source has an aligned enabled mask", () => {
|
||||
@@ -24,7 +24,7 @@ describe("generation preconditions", () => {
|
||||
|
||||
test("explains prompt and outpaint-padding requirements", () => {
|
||||
expect(checkGenerationPreconditions(document(), selected(), { ...settings("image-to-image"), prompt: " " })).toEqual({ ready: false, message: "Enter a prompt to generate an image." });
|
||||
expect(checkGenerationPreconditions(document(), selected(), { ...settings("outpaint"), outpaint: { left: 0, top: 0, right: 0, bottom: 0, feathering: 0 } })).toEqual({ ready: false, message: "Add outpaint padding on at least one side." });
|
||||
expect(checkGenerationPreconditions(document(), selected(), { ...settings("outpaint"), outpaint: { left: 0, top: 0, right: 0, bottom: 0, feathering: 0 } })).toEqual({ ready: false, message: "Add outpaint padding on at least one side.", repair: "set-outpaint-padding" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { GenerateSettings } from "@editor/tools";
|
||||
|
||||
export type GenerationPrecondition =
|
||||
| { ready: true }
|
||||
| { ready: false; message: string };
|
||||
| { ready: false; message: string; repair?: "add-mask" | "set-outpaint-padding" };
|
||||
|
||||
export function checkGenerationPreconditions(
|
||||
document: ImageDocument,
|
||||
@@ -27,7 +27,7 @@ export function checkGenerationPreconditions(
|
||||
|
||||
const index = createDocumentReadIndex(document);
|
||||
const layerInfo = index.layerInfoById.get(selection.layerIds[0]);
|
||||
if (!layerInfo || layerInfo.artboardId !== artboard.id || layerInfo.layer.type === "group") {
|
||||
if (!layerInfo || layerInfo.artboardId !== artboard.id || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) {
|
||||
return missing(modeSelectionMessage(settings.mode));
|
||||
}
|
||||
|
||||
@@ -38,16 +38,16 @@ export function checkGenerationPreconditions(
|
||||
if (settings.mode === "outpaint") {
|
||||
const padding = settings.outpaint;
|
||||
if (padding.left + padding.top + padding.right + padding.bottom <= 0) {
|
||||
return missing("Add outpaint padding on at least one side.");
|
||||
return missing("Add outpaint padding on at least one side.", "set-outpaint-padding");
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.mode !== "inpaint") return { ready: true };
|
||||
|
||||
const layerMask = getLayerMask(layerInfo.layer);
|
||||
if (!layerMask?.enabled) return missing("Add and enable a layer mask on the selected image before inpainting.");
|
||||
if (!layerMask?.enabled) return missing("Paint a mask over the area you want AI to replace.", "add-mask");
|
||||
const maskLayer = index.layerById.get(layerMask.maskLayerId);
|
||||
if (!maskLayer || maskLayer.type === "group") return missing("The selected layer mask is missing.");
|
||||
if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) return missing("The selected layer mask is missing.");
|
||||
const maskAsset = index.assetById.get(maskLayer.assetId);
|
||||
if (!maskAsset) return missing("The selected layer mask is missing its image data.");
|
||||
if (Math.round(asset.intrinsicSize.w) !== Math.round(maskAsset.intrinsicSize.w) || Math.round(asset.intrinsicSize.h) !== Math.round(maskAsset.intrinsicSize.h)) {
|
||||
@@ -69,8 +69,8 @@ function modeSelectionMessage(mode: GenerateSettings["mode"]): string {
|
||||
return "Select exactly one image or raster layer to outpaint.";
|
||||
}
|
||||
|
||||
function missing(message: string): GenerationPrecondition {
|
||||
return { ready: false, message };
|
||||
function missing(message: string, repair?: Extract<GenerationPrecondition, { ready: false }>["repair"]): GenerationPrecondition {
|
||||
return { ready: false, message, ...(repair ? { repair } : {}) };
|
||||
}
|
||||
|
||||
function rectsAligned(a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }): boolean {
|
||||
|
||||
@@ -210,11 +210,11 @@ 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;
|
||||
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset ? { layer, asset, maskAsset } : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,22 @@ describe("generation workflow", () => {
|
||||
expect(app.store.getState().editor.generation.candidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("repairs replace prerequisites by adding an aligned editable mask through commands", async () => {
|
||||
const app = createTestApp();
|
||||
const state = app.store.getState();
|
||||
state.document.assets.push({ id: "source-asset", name: "Source", mimeType: "image/png", source: "source", intrinsicSize: { w: 80, h: 60 } });
|
||||
state.document.artboards[0]!.layers.push({ id: "source", type: "raster", name: "Source", visible: true, locked: false, opacity: 1, assetId: "source-asset", transform: { position: { x: 4, y: 8 }, scale: { x: 2, y: 2 }, rotation: 0 } });
|
||||
state.editor.selection = { artboardId: "artboard", layerIds: ["source"] };
|
||||
const ids = ["mask-asset", "mask-layer"];
|
||||
const workflow = createGenerationWorkflow(app.store, dependencies({ createId: () => ids.shift() ?? "unused" }));
|
||||
|
||||
await workflow.prepareInpaintMask();
|
||||
|
||||
const next = app.store.getState();
|
||||
expect(next.document.artboards[0]?.layers[0]).toMatchObject({ id: "mask-layer", transform: { position: { x: 4, y: 8 }, scale: { x: 2, y: 2 }, rotation: 0 } });
|
||||
expect(next.editor.maskEdit).toEqual({ targetLayerId: "source", maskLayerId: "mask-layer" });
|
||||
});
|
||||
|
||||
test("cancels the active operation and records a cancelled job", async () => {
|
||||
const app = createTestApp();
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { GenerationCandidate, GenerationJobKind } from "@editor/state";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
@@ -50,6 +51,34 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
|
||||
|
||||
loadResources: () => dependencies.loadGenerationResources(store),
|
||||
|
||||
prepareInpaintMask: async () => {
|
||||
const state = store.getState();
|
||||
const layerId = state.editor.selection.layerIds.length === 1 ? state.editor.selection.layerIds[0] : undefined;
|
||||
if (!layerId) return;
|
||||
const artboard = state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId);
|
||||
const layer = artboard ? findLayer(artboard.layers, layerId) : undefined;
|
||||
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return;
|
||||
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return;
|
||||
const source = await dependencies.createRefinementMask(asset.intrinsicSize.w, asset.intrinsicSize.h);
|
||||
const maskAssetId = dependencies.createId();
|
||||
const maskLayerId = dependencies.createId();
|
||||
store.dispatch(commandIds.documentAddLayerMask, {
|
||||
layerId,
|
||||
asset: { id: maskAssetId, name: `${layer.name} AI edit mask`, mimeType: "image/png", source, intrinsicSize: { ...asset.intrinsicSize } },
|
||||
maskLayer: {
|
||||
id: maskLayerId,
|
||||
type: "raster",
|
||||
name: `${layer.name} AI edit mask`,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: maskAssetId,
|
||||
transform: { position: { ...layer.transform.position }, scale: { ...layer.transform.scale }, rotation: layer.transform.rotation },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
generate: () => job("generate", "Generating", async (signal) => {
|
||||
const state = store.getState();
|
||||
await dependencies.runGenerate({
|
||||
@@ -130,6 +159,17 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
|
||||
};
|
||||
}
|
||||
|
||||
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) return layer;
|
||||
if (layer.type === "group") {
|
||||
const child = findLayer(layer.children, layerId);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findCandidate(store: AppStore, candidateId: string): GenerationCandidate {
|
||||
const candidate = store.getState().editor.generation.candidates.find((item) => item.id === candidateId);
|
||||
if (!candidate) throw new Error("This generation candidate is no longer available.");
|
||||
|
||||
@@ -14,19 +14,19 @@ export function resolveChromaKeyTarget(document: ImageDocument, selection: Selec
|
||||
const layerId = selection.layerIds[0];
|
||||
if (selection.layerIds.length !== 1 || !layerId) return undefined;
|
||||
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
|
||||
if (!layer || layer.type === "group") return undefined;
|
||||
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
||||
}
|
||||
|
||||
export async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof resolveChromaKeyTarget>>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) {
|
||||
const source = await createChromaKeyMask(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
|
||||
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
|
||||
if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) {
|
||||
dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } });
|
||||
dispatch(commandIds.workspaceSetPanel, { panel: "none" });
|
||||
return;
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverri
|
||||
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
|
||||
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
|
||||
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
|
||||
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
|
||||
if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) {
|
||||
store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
|
||||
return true;
|
||||
}
|
||||
@@ -39,12 +39,12 @@ function resolveTarget(document: ImageDocument, editor: EditorState) {
|
||||
const layerId = editor.selection.layerIds[0];
|
||||
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
|
||||
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||
if (!layer || layer.type === "group") return undefined;
|
||||
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,37 @@ describe("project format", () => {
|
||||
expect(() => serializeProject(missingAsset)).toThrow("references missing asset");
|
||||
});
|
||||
|
||||
test("rejects invalid persisted layer crops", () => {
|
||||
const document = projectDocument();
|
||||
const layer = document.artboards[0]!.layers[0]!;
|
||||
if (layer.type !== "image" && layer.type !== "raster") throw new Error("Expected image layer");
|
||||
layer.sourceRect = { x: 0, y: 0, w: 0, h: 10 };
|
||||
const source = JSON.stringify({ format: "image-studio-project", version: 1, savedAt: "2026-07-10T12:00:00.000Z", document });
|
||||
expect(() => parseProject(source)).toThrow("invalid source crop");
|
||||
});
|
||||
|
||||
test("round-trips adjustment layers and rejects out-of-range settings", () => {
|
||||
const document = projectDocument();
|
||||
document.artboards[0]!.layers.unshift({ id: "adjust", type: "adjustment", name: "Color", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { brightness: .2, contrast: 0, saturation: 0, colorBalance: { red: 0, green: 0, blue: 0 } } });
|
||||
expect(parseProject(serializeProject(document)).document.artboards[0]?.layers[0]?.type).toBe("adjustment");
|
||||
const invalid = JSON.parse(serializeProject(document)); invalid.document.artboards[0].layers[0].adjustment.brightness = 4;
|
||||
expect(() => parseProject(JSON.stringify(invalid))).toThrow("invalid settings");
|
||||
});
|
||||
|
||||
test("rejects nested adjustment scope", () => {
|
||||
const document = projectDocument();
|
||||
document.artboards[0]!.layers = [{ id: "group", type: "group", name: "Group", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, children: [{ id: "adjust", type: "adjustment", name: "Color", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { brightness: 0, contrast: 0, saturation: 0, colorBalance: { red: 0, green: 0, blue: 0 } } }] }];
|
||||
expect(() => parseProject(serializeProject(document))).toThrow("artboard level");
|
||||
});
|
||||
|
||||
test("round-trips valid text and rejects unsupported typography", () => {
|
||||
const document = projectDocument();
|
||||
document.artboards[0]!.layers.unshift({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: .8, transform: { position: { x: 1, y: 2 }, scale: { x: 1, y: 1 }, rotation: .2 }, content: "Hello", style: { fontFamily: "Arial", fontSize: 20, fontWeight: 700, fontStyle: "italic", color: "#123456", alignment: "right", lineHeight: 1.2 } });
|
||||
expect(parseProject(serializeProject(document)).document.artboards[0]?.layers[0]?.type).toBe("text");
|
||||
const invalid = JSON.parse(serializeProject(document)); invalid.document.artboards[0].layers[0].style.fontFamily = "Web Font";
|
||||
expect(() => parseProject(JSON.stringify(invalid))).toThrow("invalid typography");
|
||||
});
|
||||
|
||||
test("creates safe project file names", () => {
|
||||
expect(projectFileName(" Summer / Study ")).toBe("Summer-Study.image-studio.json");
|
||||
expect(projectFileName("***")).toBe("untitled.image-studio.json");
|
||||
@@ -52,7 +83,7 @@ function projectDocument(): ImageDocument {
|
||||
backgroundColor: "transparent",
|
||||
visible: true,
|
||||
locked: false,
|
||||
layers: [{ id: "layer-1", type: "image", name: "Pixels", visible: true, locked: false, opacity: 1, assetId: "asset-1", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } }],
|
||||
layers: [{ id: "layer-1", type: "image", name: "Pixels", visible: true, locked: false, opacity: 1, assetId: "asset-1", sourceRect: { x: 2, y: 3, w: 6, h: 12 }, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } }],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import { isValidTextStyle, type TextStyle } from "@core/text-layer";
|
||||
|
||||
export const PROJECT_FORMAT = "image-studio-project";
|
||||
export const CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -74,7 +76,7 @@ function assertImageDocument(value: unknown): asserts value is ImageDocument {
|
||||
if (!isRecord(artboard) || typeof artboard.id !== "string" || typeof artboard.name !== "string" || !isRect(artboard.bounds) || !Array.isArray(artboard.layers)) {
|
||||
throw new Error("The project contains an invalid artboard.");
|
||||
}
|
||||
assertLayers(artboard.layers);
|
||||
assertLayers(artboard.layers, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,22 +104,37 @@ function isOwnedAssetSource(source: string): boolean {
|
||||
return source.startsWith("data:");
|
||||
}
|
||||
|
||||
function assertLayers(value: unknown[]): asserts value is Layer[] {
|
||||
function assertLayers(value: unknown[], nested: boolean): asserts value is Layer[] {
|
||||
for (const layer of value) {
|
||||
if (!isRecord(layer) || typeof layer.id !== "string" || typeof layer.name !== "string" || typeof layer.type !== "string" || typeof layer.visible !== "boolean" || typeof layer.locked !== "boolean" || typeof layer.opacity !== "number" || !isTransform(layer.transform)) {
|
||||
throw new Error("The project contains an invalid layer.");
|
||||
}
|
||||
if (layer.type === "group") {
|
||||
if (!Array.isArray(layer.children)) throw new Error("A project group is missing its children.");
|
||||
assertLayers(layer.children);
|
||||
assertLayers(layer.children, true);
|
||||
} else if (layer.type === "adjustment") {
|
||||
if (nested) throw new Error("Adjustment layers must stay at artboard level.");
|
||||
if (!isAdjustment(layer.adjustment)) throw new Error("A project adjustment layer contains invalid settings.");
|
||||
if (layer.layerMask !== undefined || layer.clippingMask !== undefined) throw new Error("Adjustment layers do not support masks.");
|
||||
} else if (layer.type === "text") {
|
||||
if (typeof layer.content !== "string" || !layer.content.trim() || !isRecord(layer.style) || !isValidTextStyle(layer.style as TextStyle)) throw new Error("A project text layer contains invalid typography.");
|
||||
if (layer.layerMask !== undefined || layer.clippingMask !== undefined) throw new Error("Text layers do not support masks. Rasterize text before masking.");
|
||||
} else if ((layer.type === "image" || layer.type === "raster") && typeof layer.assetId !== "string") {
|
||||
throw new Error("A project layer is missing its asset reference.");
|
||||
} else if (layer.type !== "image" && layer.type !== "raster") {
|
||||
} else if ((layer.type === "image" || layer.type === "raster") && layer.sourceRect !== undefined && (!isRect(layer.sourceRect) || layer.sourceRect.w < 1 || layer.sourceRect.h < 1)) {
|
||||
throw new Error("A project layer contains an invalid source crop.");
|
||||
} else if (layer.type !== "image" && layer.type !== "raster" && layer.type !== "text") {
|
||||
throw new Error(`Unsupported layer type: ${layer.type}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isAdjustment(value: unknown): boolean {
|
||||
if (!isRecord(value) || !isRecord(value.colorBalance)) return false;
|
||||
return [value.brightness, value.contrast, value.saturation, value.colorBalance.red, value.colorBalance.green, value.colorBalance.blue]
|
||||
.every((number) => isFiniteNumber(number) && number >= -1 && number <= 1);
|
||||
}
|
||||
|
||||
function walkLayers(layers: Layer[], visit: (layer: Layer) => void): void {
|
||||
for (const layer of layers) {
|
||||
visit(layer);
|
||||
@@ -137,7 +154,7 @@ function isSize(value: unknown): boolean {
|
||||
return isRecord(value) && isFiniteNumber(value.w) && isFiniteNumber(value.h) && value.w >= 0 && value.h >= 0;
|
||||
}
|
||||
|
||||
function isRect(value: unknown): boolean {
|
||||
function isRect(value: unknown): value is Rect {
|
||||
return isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.w) && isFiniteNumber(value.h);
|
||||
}
|
||||
|
||||
|
||||
10
platform/browser/adjustment.test.ts
Normal file
10
platform/browser/adjustment.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adjustPixel } from "./exportArtboardPng";
|
||||
import { neutralColorAdjustment } from "@core/adjustment-layer";
|
||||
|
||||
describe("adjustment pixel calculation", () => {
|
||||
test("neutral adjustment is stable", () => expect(adjustPixel([20, 100, 220], neutralColorAdjustment)).toEqual([20, 100, 220]));
|
||||
test("brightness, contrast, saturation and balance use deterministic ordering", () => {
|
||||
expect(adjustPixel([100, 120, 140], { brightness: .1, contrast: .2, saturation: -.5, colorBalance: { red: .1, green: 0, blue: -.1 } })).toEqual([153, 150, 146]);
|
||||
});
|
||||
});
|
||||
24
platform/browser/exportArtboardPng.test.ts
Normal file
24
platform/browser/exportArtboardPng.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolveLayerDrawImage, resolveTextLayerDraw } from "./exportArtboardPng";
|
||||
|
||||
describe("artboard PNG export", () => {
|
||||
test("draws only cropped source pixels at their retained document position", () => {
|
||||
const draw = resolveLayerDrawImage({
|
||||
id: "layer", type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId: "asset",
|
||||
sourceRect: { x: 10, y: 5, w: 40, h: 20 },
|
||||
transform: { position: { x: 100, y: 50 }, scale: { x: 2, y: 3 }, rotation: 0 },
|
||||
}, { w: 200, h: 100 });
|
||||
|
||||
expect(draw).toEqual({
|
||||
source: { x: 10, y: 5, w: 40, h: 20 },
|
||||
destination: { x: 120, y: 65, w: 80, h: 60 },
|
||||
});
|
||||
});
|
||||
|
||||
test("uses deterministic text data centered for renderer rotation parity", () => {
|
||||
const draw = resolveTextLayerDraw({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 10, y: 20 }, scale: { x: 2, y: 3 }, rotation: 1 }, content: "AB", style: { fontFamily: "Arial", fontSize: 10, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "center", lineHeight: 1.2 } });
|
||||
expect(draw.size).toEqual({ w: 10.8, h: 12 });
|
||||
expect(draw.center).toEqual({ x: 20.8, y: 38 });
|
||||
expect(draw.textX).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@ import type { Artboard } from "@core/artboard";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { Size } from "@core/geometry";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { measureTextLayer } from "@core/text-layer";
|
||||
|
||||
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
|
||||
const width = Math.max(1, Math.round(artboard.bounds.w));
|
||||
@@ -54,7 +56,24 @@ async function drawLayer(
|
||||
context.globalAlpha *= layer.opacity;
|
||||
|
||||
if (layer.type === "group") {
|
||||
for (const child of renderStack(layer.children)) await drawLayer(context, child, layerTree, assets, artboardBounds, options);
|
||||
const groupCanvas = createArtboardCanvas(artboardBounds);
|
||||
const groupContext = translatedContext(groupCanvas, artboardBounds);
|
||||
const groupMaskIds = collectMaskLayerIds(layer.children);
|
||||
for (const child of renderStack(layer.children)) await drawLayer(groupContext, child, layer.children, assets, artboardBounds, { maskLayerIds: groupMaskIds });
|
||||
groupContext.restore();
|
||||
context.drawImage(groupCanvas, artboardBounds.x, artboardBounds.y);
|
||||
context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
if (layer.type === "adjustment") {
|
||||
applyAdjustmentToCanvas(context, layer.adjustment, layer.opacity);
|
||||
context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
if (layer.type === "text") {
|
||||
drawTextLayer(context, layer);
|
||||
context.restore();
|
||||
return;
|
||||
}
|
||||
@@ -66,16 +85,76 @@ async function drawLayer(
|
||||
}
|
||||
|
||||
const image = await loadImage(asset.source);
|
||||
context.drawImage(
|
||||
image,
|
||||
layer.transform.position.x,
|
||||
layer.transform.position.y,
|
||||
asset.intrinsicSize.w * layer.transform.scale.x,
|
||||
asset.intrinsicSize.h * layer.transform.scale.y,
|
||||
);
|
||||
const draw = resolveLayerDrawImage(layer, asset.intrinsicSize);
|
||||
context.drawImage(image, draw.source.x, draw.source.y, draw.source.w, draw.source.h,
|
||||
draw.destination.x, draw.destination.y, draw.destination.w, draw.destination.h);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export function drawTextLayer(context: CanvasRenderingContext2D, layer: Extract<Layer, { type: "text" }>) {
|
||||
const draw = resolveTextLayerDraw(layer);
|
||||
const { size } = draw;
|
||||
context.translate(draw.center.x, draw.center.y);
|
||||
context.rotate(layer.transform.rotation);
|
||||
context.scale(layer.transform.scale.x, layer.transform.scale.y);
|
||||
context.fillStyle = layer.style.color;
|
||||
context.font = `${layer.style.fontStyle} ${layer.style.fontWeight} ${layer.style.fontSize}px ${JSON.stringify(layer.style.fontFamily)}, Arial, sans-serif`;
|
||||
context.textAlign = layer.style.alignment;
|
||||
context.textBaseline = "alphabetic";
|
||||
const x = draw.textX;
|
||||
layer.content.split("\n").forEach((line, index) => context.fillText(line, x, (index + 0.82) * layer.style.fontSize * layer.style.lineHeight - size.h / 2));
|
||||
}
|
||||
|
||||
export function resolveTextLayerDraw(layer: Extract<Layer, { type: "text" }>) {
|
||||
const size = measureTextLayer(layer);
|
||||
return {
|
||||
size,
|
||||
center: { x: layer.transform.position.x + size.w * layer.transform.scale.x / 2, y: layer.transform.position.y + size.h * layer.transform.scale.y / 2 },
|
||||
textX: (layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w) - size.w / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLayerDrawImage(layer: Extract<Layer, { type: "image" | "raster" }>, intrinsicSize: Size) {
|
||||
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
|
||||
return {
|
||||
source,
|
||||
destination: {
|
||||
x: layer.transform.position.x + source.x * layer.transform.scale.x,
|
||||
y: layer.transform.position.y + source.y * layer.transform.scale.y,
|
||||
w: source.w * layer.transform.scale.x,
|
||||
h: source.h * layer.transform.scale.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function adjustPixel(rgb: readonly [number, number, number], adjustment: Extract<Layer, { type: "adjustment" }>["adjustment"]): [number, number, number] {
|
||||
let [red, green, blue] = rgb.map((value) => value / 255) as [number, number, number];
|
||||
red += adjustment.brightness + adjustment.colorBalance.red;
|
||||
green += adjustment.brightness + adjustment.colorBalance.green;
|
||||
blue += adjustment.brightness + adjustment.colorBalance.blue;
|
||||
const contrast = 1 + adjustment.contrast;
|
||||
red = (red - 0.5) * contrast + 0.5;
|
||||
green = (green - 0.5) * contrast + 0.5;
|
||||
blue = (blue - 0.5) * contrast + 0.5;
|
||||
const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722;
|
||||
const saturation = 1 + adjustment.saturation;
|
||||
return [red, green, blue].map((value) => Math.round(Math.max(0, Math.min(1, luminance + (value - luminance) * saturation)) * 255)) as [number, number, number];
|
||||
}
|
||||
|
||||
function applyAdjustmentToCanvas(context: CanvasRenderingContext2D, adjustment: Extract<Layer, { type: "adjustment" }>["adjustment"], opacity: number) {
|
||||
if (opacity <= 0) return;
|
||||
const { width, height } = context.canvas;
|
||||
const image = context.getImageData(0, 0, width, height);
|
||||
for (let index = 0; index < image.data.length; index += 4) {
|
||||
const original: [number, number, number] = [image.data[index]!, image.data[index + 1]!, image.data[index + 2]!];
|
||||
const adjusted = adjustPixel(original, adjustment);
|
||||
image.data[index] = Math.round(original[0] + (adjusted[0] - original[0]) * opacity);
|
||||
image.data[index + 1] = Math.round(original[1] + (adjusted[1] - original[1]) * opacity);
|
||||
image.data[index + 2] = Math.round(original[2] + (adjusted[2] - original[2]) * opacity);
|
||||
}
|
||||
context.putImageData(image, 0, 0);
|
||||
}
|
||||
|
||||
async function drawMaskedLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
layer: Layer,
|
||||
|
||||
111
renderer/adjustment-pass.ts
Normal file
111
renderer/adjustment-pass.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { ColorAdjustment } from "@core/adjustment-layer";
|
||||
import type { ScreenRect, WebGlRendererContext } from "./types";
|
||||
|
||||
export type AdjustmentPass = {
|
||||
apply(adjustment: ColorAdjustment, opacity: number, clip: ScreenRect): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
const vertexSource = `#version 300 es
|
||||
in vec2 a_position;
|
||||
void main(){ gl_Position=vec4(a_position,0.,1.); }`;
|
||||
const fragmentSource = `#version 300 es
|
||||
precision highp float;
|
||||
uniform sampler2D u_pixels; uniform vec2 u_size; uniform float u_brightness,u_contrast,u_saturation,u_opacity; uniform vec3 u_balance;
|
||||
out vec4 outColor;
|
||||
void main(){ vec2 uv=gl_FragCoord.xy/u_size; vec4 original=texture(u_pixels,uv); vec3 c=original.rgb+vec3(u_brightness)+u_balance; c=(c-0.5)*(1.0+u_contrast)+0.5; float l=dot(c,vec3(.2126,.7152,.0722)); c=clamp(vec3(l)+(c-vec3(l))*(1.0+u_saturation),0.0,1.0); outColor=vec4(mix(original.rgb,c,u_opacity),original.a); }`;
|
||||
|
||||
/** Renderer-owned framebuffer adjustment resources, reused across layers/frames. */
|
||||
export function createAdjustmentPass(context: WebGlRendererContext): AdjustmentPass {
|
||||
const { gl, canvas } = context;
|
||||
const program = createProgram(gl, vertexSource, fragmentSource);
|
||||
const texture = gl.createTexture();
|
||||
const buffer = gl.createBuffer();
|
||||
if (!program || !texture || !buffer) throw new Error("Failed to create adjustment pass");
|
||||
|
||||
const position = gl.getAttribLocation(program, "a_position");
|
||||
const size = requiredUniform(gl, program, "u_size");
|
||||
const brightness = requiredUniform(gl, program, "u_brightness");
|
||||
const contrast = requiredUniform(gl, program, "u_contrast");
|
||||
const saturation = requiredUniform(gl, program, "u_saturation");
|
||||
const opacityLocation = requiredUniform(gl, program, "u_opacity");
|
||||
const balance = requiredUniform(gl, program, "u_balance");
|
||||
const pixels = requiredUniform(gl, program, "u_pixels");
|
||||
let allocatedWidth = 0;
|
||||
let allocatedHeight = 0;
|
||||
let disposed = false;
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
|
||||
return {
|
||||
apply(adjustment, opacity, clip) {
|
||||
if (disposed || canvas.width < 1 || canvas.height < 1) return;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
if (allocatedWidth !== canvas.width || allocatedHeight !== canvas.height) {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, canvas.width, canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
allocatedWidth = canvas.width;
|
||||
allocatedHeight = canvas.height;
|
||||
}
|
||||
gl.copyTexSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 0, 0, canvas.width, canvas.height);
|
||||
gl.useProgram(program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.uniform1i(pixels, 0);
|
||||
gl.uniform2f(size, canvas.width, canvas.height);
|
||||
gl.uniform1f(brightness, adjustment.brightness);
|
||||
gl.uniform1f(contrast, adjustment.contrast);
|
||||
gl.uniform1f(saturation, adjustment.saturation);
|
||||
gl.uniform1f(opacityLocation, Math.max(0, Math.min(1, opacity)));
|
||||
gl.uniform3f(balance, adjustment.colorBalance.red, adjustment.colorBalance.green, adjustment.colorBalance.blue);
|
||||
gl.scissor(clip.x, canvas.height - clip.y - clip.h, clip.w, clip.h);
|
||||
gl.disable(gl.BLEND);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.enable(gl.BLEND);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
gl.deleteTexture(texture);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteProgram(program);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requiredUniform(gl: WebGL2RenderingContext, program: WebGLProgram, name: string) {
|
||||
const location = gl.getUniformLocation(program, name);
|
||||
if (!location) throw new Error(`Missing adjustment uniform: ${name}`);
|
||||
return location;
|
||||
}
|
||||
|
||||
function createProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string) {
|
||||
const vertexShader = compile(gl, gl.VERTEX_SHADER, vertex);
|
||||
const fragmentShader = compile(gl, gl.FRAGMENT_SHADER, fragment);
|
||||
if (!vertexShader || !fragmentShader) return undefined;
|
||||
const program = gl.createProgram();
|
||||
if (!program) return undefined;
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
|
||||
gl.deleteProgram(program);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compile(gl: WebGL2RenderingContext, type: number, source: string) {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return undefined;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
||||
gl.deleteShader(shader);
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
|
||||
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
|
||||
|
||||
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
|
||||
|
||||
export type ImageTextureRenderer = {
|
||||
syncAssets(assets: readonly Asset[]): void;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number): boolean;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
|
||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number, sourceRect?: Rect): boolean;
|
||||
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
|
||||
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
dispose(): void;
|
||||
@@ -87,10 +90,11 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
}
|
||||
}
|
||||
},
|
||||
render(asset, rect, clipRect, opacity = 1) {
|
||||
render(asset, rect, clipRect, opacity = 1, rotation = 0, sourceRect) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const rotatedRect = rotatedRectBounds(rect, rotation);
|
||||
const drawRect = clipRect ? intersectScreenRects(rotatedRect, clipRect) : rotatedRect;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -108,12 +112,12 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(opacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(texCoordLocation);
|
||||
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -121,11 +125,13 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1) {
|
||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0, sourceRect) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
|
||||
const contentBounds = rotatedRectBounds(rect, rotation);
|
||||
const maskBounds = rotatedRectBounds(maskRect, rotation);
|
||||
const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -148,17 +154,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(maskedOpacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedPositionLocation);
|
||||
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForRect(rect, maskRect), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -166,12 +172,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) {
|
||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0, sourceRect) {
|
||||
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
|
||||
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
|
||||
const contentBounds = rotatedRectBounds(rect, rotation);
|
||||
const maskBounds = rotatedRectBounds(maskRect, rotation);
|
||||
const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -194,17 +202,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(maskRevealPreviewOpacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewPositionLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForRect(rect, maskRect), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -422,11 +430,10 @@ function texCoordsForRect(drawRect: ScreenRect, sourceRect: ScreenRect) {
|
||||
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||
}
|
||||
|
||||
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) {
|
||||
const x1 = (rect.x / canvas.width) * 2 - 1;
|
||||
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
|
||||
const y1 = 1 - (rect.y / canvas.height) * 2;
|
||||
const y2 = 1 - ((rect.y + rect.h) / canvas.height) * 2;
|
||||
|
||||
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect, rotation = 0) {
|
||||
const [nw, ne, sw, se] = rotatedRectCorners(rect, rotation).map((point) => ({
|
||||
x: (point.x / canvas.width) * 2 - 1,
|
||||
y: 1 - (point.y / canvas.height) * 2,
|
||||
}));
|
||||
return new Float32Array([nw!.x, nw!.y, ne!.x, ne!.y, sw!.x, sw!.y, sw!.x, sw!.y, ne!.x, ne!.y, se!.x, se!.y]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { clearScreenRect } from "./clear-rect";
|
||||
import type { ImageTextureRenderer } from "./image-textures";
|
||||
import { documentRectToScreenRect } from "./screen-rect";
|
||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||
import type { AdjustmentPass } from "./adjustment-pass";
|
||||
import { textLayerRenderAsset } from "./text-asset";
|
||||
|
||||
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
|
||||
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
|
||||
@@ -16,7 +18,7 @@ const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
|
||||
const comparisonDividerColor: RgbaColor = [1, 1, 1, 0.9];
|
||||
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, adjustmentPass: AdjustmentPass) {
|
||||
const documentIndex = createDocumentReadIndex(document);
|
||||
const generationCandidate = selectedGenerationCandidate(editor);
|
||||
|
||||
@@ -24,7 +26,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
|
||||
if (!artboard.visible) continue;
|
||||
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
||||
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
|
||||
renderLayerTree(context, documentIndex, editor, artboard.layers, imageTextureRenderer, clipRect, maskLayerIds);
|
||||
renderLayerTree(context, documentIndex, editor, artboard.layers, imageTextureRenderer, adjustmentPass, clipRect, maskLayerIds);
|
||||
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +41,7 @@ function renderLayerTree(
|
||||
editor: EditorState,
|
||||
layers: readonly Layer[],
|
||||
imageTextureRenderer: ImageTextureRenderer,
|
||||
adjustmentPass: AdjustmentPass,
|
||||
clipRect: ScreenRect,
|
||||
maskLayerIds: ReadonlySet<string>,
|
||||
) {
|
||||
@@ -57,15 +60,31 @@ function renderLayerTree(
|
||||
for (const child of layer.children) stack.push({ layer: child, clipRect: effectiveClipRect, inheritedOpacity: effectiveOpacity });
|
||||
continue;
|
||||
}
|
||||
renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
|
||||
if (layer.type === "adjustment") {
|
||||
adjustmentPass.apply(layer.adjustment, effectiveOpacity, effectiveClipRect);
|
||||
continue;
|
||||
}
|
||||
if (layer.type === "text") {
|
||||
renderTextLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
|
||||
} else {
|
||||
renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTextLayer(context: WebGlRendererContext, documentIndex: DocumentReadIndex, editor: EditorState, layer: Extract<Layer, { type: "text" }>, imageTextureRenderer: ImageTextureRenderer, clipRect: ScreenRect, opacity: number) {
|
||||
if (editor.maskEdit?.viewMode && isIsolatedMaskView(editor.maskEdit.viewMode)) return;
|
||||
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
||||
if (!bounds) return;
|
||||
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
||||
imageTextureRenderer.render(textLayerRenderAsset(layer), rect, clipRect, opacity, layer.transform.rotation);
|
||||
}
|
||||
|
||||
function renderLeafLayer(
|
||||
context: WebGlRendererContext,
|
||||
documentIndex: DocumentReadIndex,
|
||||
editor: EditorState,
|
||||
layer: Exclude<Layer, { type: "group" }>,
|
||||
layer: Extract<Layer, { type: "image" | "raster" }>,
|
||||
imageTextureRenderer: ImageTextureRenderer,
|
||||
effectiveClipRect: ScreenRect,
|
||||
effectiveOpacity: number,
|
||||
@@ -82,7 +101,7 @@ function renderLeafLayer(
|
||||
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
|
||||
const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
|
||||
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
|
||||
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
|
||||
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);
|
||||
@@ -91,17 +110,18 @@ function renderLeafLayer(
|
||||
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
||||
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
|
||||
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
|
||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) {
|
||||
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
|
||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
|
||||
const contentClipRect = intersectScreenRects(effectiveClipRect, rect);
|
||||
if (contentClipRect) imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, contentClipRect);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) {
|
||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity);
|
||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
|
||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect);
|
||||
return;
|
||||
}
|
||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return;
|
||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) return;
|
||||
|
||||
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
||||
if (!fallbackRect) return;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { generationCandidatePreviewAssets, renderLayers } from "./layers";
|
||||
import { renderSelectionOverlay } from "./selection";
|
||||
import { renderTransformControls } from "./transform-controls";
|
||||
import type { WebGlRendererContext } from "./types";
|
||||
import { createAdjustmentPass } from "./adjustment-pass";
|
||||
|
||||
export type RenderFrame = {
|
||||
document: ImageDocument;
|
||||
@@ -34,6 +35,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
const rendererContext: WebGlRendererContext = { gl: context, canvas };
|
||||
const checkerboardRenderer = createCheckerboardRenderer(rendererContext);
|
||||
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
|
||||
const adjustmentPass = createAdjustmentPass(rendererContext);
|
||||
let lastFrame: RenderFrame | undefined;
|
||||
let rerenderQueued = false;
|
||||
const imageTextureRenderer = createImageTextureRenderer(rendererContext, () => {
|
||||
@@ -63,7 +65,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport, checkerboardRenderer);
|
||||
}
|
||||
imageTextureRenderer.syncAssets([...frame.document.assets, ...generationCandidatePreviewAssets(frame.editor)]);
|
||||
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
|
||||
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer, adjustmentPass);
|
||||
|
||||
if (!frame.editor.maskEdit) {
|
||||
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
|
||||
@@ -76,6 +78,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
dispose() {
|
||||
checkerboardRenderer.dispose();
|
||||
imageTextureRenderer.dispose();
|
||||
adjustmentPass.dispose();
|
||||
brushPreviewRenderer?.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
40
renderer/rotated-rect.test.ts
Normal file
40
renderer/rotated-rect.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
|
||||
|
||||
describe("rotated rectangle geometry", () => {
|
||||
test("interprets rotation as radians and keeps the center anchored", () => {
|
||||
const corners = rotatedRectCorners({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2);
|
||||
|
||||
expectPoint(corners[0], { x: 40, y: 10 });
|
||||
expectPoint(corners[1], { x: 40, y: 50 });
|
||||
expectPoint(corners[2], { x: 20, y: 10 });
|
||||
expectPoint(corners[3], { x: 20, y: 50 });
|
||||
expect((corners[0].x + corners[3].x) / 2).toBeCloseTo(30);
|
||||
expect((corners[0].y + corners[3].y) / 2).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
test("expands axis-aligned clipping bounds around a rotated quad", () => {
|
||||
const bounds = rotatedRectBounds({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2);
|
||||
|
||||
expect(bounds.x).toBeCloseTo(20);
|
||||
expect(bounds.y).toBeCloseTo(10);
|
||||
expect(bounds.w).toBeCloseTo(20);
|
||||
expect(bounds.h).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
test("preserves an unrotated rectangle exactly", () => {
|
||||
const rect = { x: 10, y: 20, w: 40, h: 20 };
|
||||
expect(rotatedRectBounds(rect, 0)).toBe(rect);
|
||||
expect(rotatedRectCorners(rect, 0)).toEqual([
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 50, y: 20 },
|
||||
{ x: 10, y: 40 },
|
||||
{ x: 50, y: 40 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function expectPoint(actual: { x: number; y: number }, expected: { x: number; y: number }) {
|
||||
expect(actual.x).toBeCloseTo(expected.x);
|
||||
expect(actual.y).toBeCloseTo(expected.y);
|
||||
}
|
||||
33
renderer/rotated-rect.ts
Normal file
33
renderer/rotated-rect.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ScreenRect } from "./types";
|
||||
|
||||
export type ScreenPoint = { x: number; y: number };
|
||||
|
||||
/** Returns corners in northwest, northeast, southwest, southeast order. */
|
||||
export function rotatedRectCorners(rect: ScreenRect, rotation: number): [ScreenPoint, ScreenPoint, ScreenPoint, ScreenPoint] {
|
||||
if (!Number.isFinite(rotation)) rotation = 0;
|
||||
const center = { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 };
|
||||
const cosine = Math.cos(rotation);
|
||||
const sine = Math.sin(rotation);
|
||||
const rotate = (point: ScreenPoint): ScreenPoint => {
|
||||
const x = point.x - center.x;
|
||||
const y = point.y - center.y;
|
||||
return { x: center.x + x * cosine - y * sine, y: center.y + x * sine + y * cosine };
|
||||
};
|
||||
return [
|
||||
rotate({ x: rect.x, y: rect.y }),
|
||||
rotate({ x: rect.x + rect.w, y: rect.y }),
|
||||
rotate({ x: rect.x, y: rect.y + rect.h }),
|
||||
rotate({ x: rect.x + rect.w, y: rect.y + rect.h }),
|
||||
];
|
||||
}
|
||||
|
||||
/** Axis-aligned screen bounds used to constrain WebGL scissoring around a rotated quad. */
|
||||
export function rotatedRectBounds(rect: ScreenRect, rotation: number): ScreenRect {
|
||||
if (rotation === 0) return rect;
|
||||
const points = rotatedRectCorners(rect, rotation);
|
||||
const xs = points.map((point) => point.x);
|
||||
const ys = points.map((point) => point.y);
|
||||
const x = Math.min(...xs);
|
||||
const y = Math.min(...ys);
|
||||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
12
renderer/text-asset.test.ts
Normal file
12
renderer/text-asset.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { textLayerRenderAsset } from "./text-asset";
|
||||
|
||||
describe("text render data", () => {
|
||||
test("creates an owned SVG texture with matching deterministic dimensions", () => {
|
||||
const asset = textLayerRenderAsset({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "A&B", style: { fontFamily: "Georgia", fontSize: 10, fontWeight: 700, fontStyle: "italic", color: "#abcdef", alignment: "center", lineHeight: 1.2 } });
|
||||
expect(asset.mimeType).toBe("image/svg+xml");
|
||||
expect(asset.source).toStartWith("data:image/svg+xml,");
|
||||
expect(decodeURIComponent(asset.source)).toContain("A&B");
|
||||
expect(asset.intrinsicSize.w).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
19
renderer/text-asset.ts
Normal file
19
renderer/text-asset.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { TextLayer } from "@core/text-layer";
|
||||
import { measureTextLayer } from "@core/text-layer";
|
||||
|
||||
/** Produces a derived SVG texture; text content remains the only authoritative state. */
|
||||
export function textLayerRenderAsset(layer: TextLayer): Asset {
|
||||
const size = measureTextLayer(layer);
|
||||
const lines = layer.content.split("\n");
|
||||
const anchor = layer.style.alignment === "left" ? "start" : layer.style.alignment === "center" ? "middle" : "end";
|
||||
const x = layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w;
|
||||
const family = escapeXml(layer.style.fontFamily);
|
||||
const tspans = lines.map((line, index) => `<tspan x="${x}" y="${(index + 0.82) * layer.style.fontSize * layer.style.lineHeight}">${escapeXml(line || " ")}</tspan>`).join("");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size.w}" height="${size.h}" viewBox="0 0 ${size.w} ${size.h}"><text text-anchor="${anchor}" font-family="${family}" font-size="${layer.style.fontSize}" font-weight="${layer.style.fontWeight}" font-style="${layer.style.fontStyle}" fill="${layer.style.color}">${tspans}</text></svg>`;
|
||||
return { id: `text-render:${layer.id}`, name: layer.name, mimeType: "image/svg+xml", source: `data:image/svg+xml,${encodeURIComponent(svg)}`, intrinsicSize: size };
|
||||
}
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!);
|
||||
}
|
||||
20
renderer/texture-coordinates.test.ts
Normal file
20
renderer/texture-coordinates.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
|
||||
|
||||
describe("texture coordinates", () => {
|
||||
test("maps an asset-pixel crop to normalized UVs", () => {
|
||||
expect(rounded(textureCoordinatesForCrop({ w: 200, h: 100 }, { x: 50, y: 10, w: 100, h: 40 }))).toEqual([
|
||||
0.25, 0.1, 0.75, 0.1, 0.25, 0.5, 0.25, 0.5, 0.75, 0.1, 0.75, 0.5,
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps cropped content onto the corresponding region of an aligned full-size mask", () => {
|
||||
expect(rounded(textureCoordinatesForRect({ x: 50, y: 20, w: 100, h: 40 }, { x: 0, y: 0, w: 200, h: 100 }))).toEqual([
|
||||
0.25, 0.2, 0.75, 0.2, 0.25, 0.6, 0.25, 0.6, 0.75, 0.2, 0.75, 0.6,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function rounded(values: Float32Array) {
|
||||
return [...values].map((value) => Math.round(value * 1_000) / 1_000);
|
||||
}
|
||||
17
renderer/texture-coordinates.ts
Normal file
17
renderer/texture-coordinates.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Rect, Size } from "@core/geometry";
|
||||
|
||||
const fullCoordinates = [0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1] as const;
|
||||
|
||||
export function textureCoordinatesForCrop(size: Size, crop?: Rect): Float32Array {
|
||||
if (!crop) return new Float32Array(fullCoordinates);
|
||||
return textureCoordinatesForRect(crop, { x: 0, y: 0, w: size.w, h: size.h });
|
||||
}
|
||||
|
||||
/** Maps a drawn sub-rectangle onto the texture represented by sourceRect. */
|
||||
export function textureCoordinatesForRect(drawRect: Rect, sourceRect: Rect): Float32Array {
|
||||
const x1 = (drawRect.x - sourceRect.x) / sourceRect.w;
|
||||
const x2 = (drawRect.x + drawRect.w - sourceRect.x) / sourceRect.w;
|
||||
const y1 = (drawRect.y - sourceRect.y) / sourceRect.h;
|
||||
const y2 = (drawRect.y + drawRect.h - sourceRect.y) / sourceRect.h;
|
||||
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { GenerationState, MaskViewMode, SelectionState, ViewportState } from "@editor/state";
|
||||
@@ -13,6 +14,7 @@ import type { Rect } from "@core/geometry";
|
||||
import type { TransformTarget } from "@editor/transform";
|
||||
import type { GenerationWorkflow } from "@operations/generation/workflow";
|
||||
import type { DocumentActions } from "@app/document-actions";
|
||||
import { createDocumentReadIndex } from "@editor/document-indexes";
|
||||
export type BottomControlsAction = "pan" | "zoom";
|
||||
|
||||
export type BottomControlsIslandProps = {
|
||||
@@ -39,6 +41,8 @@ export type BottomControlsIslandProps = {
|
||||
};
|
||||
|
||||
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
|
||||
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
|
||||
const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined;
|
||||
const zoomPercent = Math.round(viewport.zoom * 100);
|
||||
const x = Math.round(viewport.center.x);
|
||||
const y = Math.round(viewport.center.y);
|
||||
@@ -61,7 +65,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
|
||||
) : activeTool === "magicWand" ? (
|
||||
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
|
||||
) : transformBounds && transformTarget ? (
|
||||
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||
<TransformControls bounds={transformBounds} target={transformTarget} documentIndex={documentIndex} layerInfo={selectedLayerInfo} dispatch={dispatch} />
|
||||
) : action === "pan" ? (
|
||||
<PanControls x={x} y={y} />
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
|
||||
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash, TextT } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { ColorAdjustment } from "@core/adjustment-layer";
|
||||
import type { TextLayer, TextStyle } from "@core/text-layer";
|
||||
import { builtInTextFonts } from "@core/text-layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes";
|
||||
@@ -10,8 +13,10 @@ import type { MaskEditState, SelectionState } from "@editor/state";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { resolveLayerDrop } from "@input/index";
|
||||
import type { DocumentActions } from "@app/document-actions";
|
||||
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
|
||||
import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, addTextLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
|
||||
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
|
||||
import { LayerThumbnail } from "./layers/LayerThumbnail";
|
||||
import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel";
|
||||
|
||||
export type LayersSheetProps = {
|
||||
document: ImageDocument;
|
||||
@@ -72,6 +77,7 @@ function LayersSheetBody({
|
||||
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
||||
const canUngroup = selectedLayer?.layer.type === "group";
|
||||
const maskLayerIds = documentIndex.maskLayerIds;
|
||||
const thumbnailByLayerId = useMemo(() => createLayerThumbnailIndex(document, documentIndex.assetById, maskLayerIds), [document, documentIndex, maskLayerIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -88,6 +94,10 @@ function LayersSheetBody({
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
|
||||
<FolderPlus size={24} />
|
||||
</button>
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Add color adjustment" title="Add non-destructive artboard color adjustment" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addAdjustmentLayer(selectedArtboardId, dispatch)}>
|
||||
<SlidersHorizontal size={24} />
|
||||
</button>
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Add text" title="Add text layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addTextLayer(document, selectedArtboardId, selectedLayer, dispatch)}><TextT size={24} /></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mb-4 grid grid-cols-5 gap-2">
|
||||
@@ -107,6 +117,8 @@ function LayersSheetBody({
|
||||
<Trash size={24} />
|
||||
</button>
|
||||
</div>
|
||||
{selectedLayer?.layer.type === "adjustment" ? <AdjustmentInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
|
||||
{selectedLayer?.layer.type === "text" ? <TextInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto pb-2">
|
||||
{document.artboards.map((artboard) => {
|
||||
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
|
||||
@@ -182,6 +194,7 @@ function LayersSheetBody({
|
||||
setEditingTitle={setEditingTitle}
|
||||
maskLayerIds={maskLayerIds}
|
||||
maskEdit={maskEdit}
|
||||
thumbnailByLayerId={thumbnailByLayerId}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
))
|
||||
@@ -195,6 +208,83 @@ function LayersSheetBody({
|
||||
);
|
||||
}
|
||||
|
||||
function TextInspector({ layer, dispatch }: { layer: TextLayer; dispatch: AppStore["dispatch"] }) {
|
||||
const [content, setContent] = useState(layer.content);
|
||||
const [style, setStyle] = useState<TextStyle>({ ...layer.style });
|
||||
useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); }, [layer.id, layer.content, layer.style]);
|
||||
const commit = () => dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style });
|
||||
return <section aria-label="Text settings" className="mb-3 space-y-2 rounded-[1.5rem] bg-white/[0.05] p-3">
|
||||
<textarea aria-label="Text content" className="w-full resize-y rounded-xl bg-black/25 p-2 text-sm outline-none ring-1 ring-white/10" rows={2} value={content} disabled={layer.locked} onChange={(event) => setContent(event.target.value)} onBlur={commit} onKeyDown={(event) => event.stopPropagation()} />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<select aria-label="Font family" className={textFieldClass()} value={style.fontFamily} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontFamily: event.target.value as TextStyle["fontFamily"] })} onBlur={commit}>{builtInTextFonts.map((font) => <option key={font}>{font}</option>)}</select>
|
||||
<input aria-label="Font size" className={textFieldClass()} type="number" min="1" max="1000" value={style.fontSize} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontSize: Number(event.target.value) })} onBlur={commit} />
|
||||
<input aria-label="Text color" className={textFieldClass()} type="color" value={style.color} disabled={layer.locked} onChange={(event) => setStyle({ ...style, color: event.target.value })} onBlur={commit} />
|
||||
<select aria-label="Font weight" className={textFieldClass()} value={style.fontWeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontWeight: Number(event.target.value) as 400 | 700 })} onBlur={commit}><option value="400">Regular</option><option value="700">Bold</option></select>
|
||||
<select aria-label="Font style" className={textFieldClass()} value={style.fontStyle} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontStyle: event.target.value as TextStyle["fontStyle"] })} onBlur={commit}><option value="normal">Normal</option><option value="italic">Italic</option></select>
|
||||
<select aria-label="Text alignment" className={textFieldClass()} value={style.alignment} disabled={layer.locked} onChange={(event) => setStyle({ ...style, alignment: event.target.value as TextStyle["alignment"] })} onBlur={commit}><option value="left">Left</option><option value="center">Center</option><option value="right">Right</option></select>
|
||||
<input aria-label="Line height" className={textFieldClass()} type="number" min="0.5" max="5" step="0.1" value={style.lineHeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, lineHeight: Number(event.target.value) })} onBlur={commit} />
|
||||
</div>
|
||||
<p className="text-[10px] text-white/40">Built-in system fonts only. Missing fonts fall back to Arial/sans-serif; exact glyph shapes can vary by platform.</p>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function textFieldClass() { return "min-w-0 rounded-lg bg-black/25 px-2 py-1.5 text-xs text-white outline-none ring-1 ring-white/10"; }
|
||||
|
||||
function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type: "adjustment" }>; dispatch: AppStore["dispatch"] }) {
|
||||
const [draft, setDraft] = useState<ColorAdjustment>(() => cloneAdjustment(layer.adjustment));
|
||||
useEffect(() => setDraft(cloneAdjustment(layer.adjustment)), [layer.id, layer.adjustment]);
|
||||
|
||||
const fields = [
|
||||
["Brightness", "brightness", draft.brightness],
|
||||
["Contrast", "contrast", draft.contrast],
|
||||
["Saturation", "saturation", draft.saturation],
|
||||
["Red", "red", draft.colorBalance.red],
|
||||
["Green", "green", draft.colorBalance.green],
|
||||
["Blue", "blue", draft.colorBalance.blue],
|
||||
] as const;
|
||||
|
||||
const commit = () => {
|
||||
if (JSON.stringify(draft) === JSON.stringify(layer.adjustment)) return;
|
||||
dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: draft });
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Adjustment settings" className="mb-3 rounded-[1.5rem] bg-white/[0.05] p-3">
|
||||
<p className="mb-2 text-xs text-white/45">
|
||||
Affects visible artboard layers beneath it. Adjustment layers stay at artboard level.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{fields.map(([label, key, value]) => (
|
||||
<label key={key} className="text-[0.7rem] text-white/55">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="mt-1 w-full accent-violet-300"
|
||||
type="range"
|
||||
min="-1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={value}
|
||||
disabled={layer.locked}
|
||||
onChange={(event) => setDraft(withAdjustmentValue(draft, key, Number(event.currentTarget.value)))}
|
||||
onPointerUp={commit}
|
||||
onBlur={commit}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function cloneAdjustment(adjustment: ColorAdjustment): ColorAdjustment {
|
||||
return { ...adjustment, colorBalance: { ...adjustment.colorBalance } };
|
||||
}
|
||||
|
||||
function withAdjustmentValue(adjustment: ColorAdjustment, key: "brightness" | "contrast" | "saturation" | "red" | "green" | "blue", value: number): ColorAdjustment {
|
||||
if (key === "brightness" || key === "contrast" || key === "saturation") return { ...adjustment, [key]: value };
|
||||
return { ...adjustment, colorBalance: { ...adjustment.colorBalance, [key]: value } };
|
||||
}
|
||||
|
||||
function LayerRow({
|
||||
document,
|
||||
documentIndex,
|
||||
@@ -207,6 +297,7 @@ function LayerRow({
|
||||
setEditingTitle,
|
||||
maskLayerIds,
|
||||
maskEdit,
|
||||
thumbnailByLayerId,
|
||||
dispatch,
|
||||
}: {
|
||||
document: ImageDocument;
|
||||
@@ -220,6 +311,7 @@ function LayerRow({
|
||||
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
||||
maskLayerIds: ReadonlySet<string>;
|
||||
maskEdit?: MaskEditState;
|
||||
thumbnailByLayerId: ReadonlyMap<string, LayerThumbnailModel>;
|
||||
dispatch: AppStore["dispatch"];
|
||||
}) {
|
||||
if (maskLayerIds.has(layer.id)) return null;
|
||||
@@ -228,9 +320,11 @@ function LayerRow({
|
||||
const layerInfo = documentIndex.layerInfoById.get(layer.id);
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
||||
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
|
||||
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
||||
const canAddMask = Boolean(layerInfo && (layer.type === "image" || layer.type === "raster") && !layerMask);
|
||||
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
|
||||
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
|
||||
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
|
||||
const rowPadding = 12 + depth * 16;
|
||||
|
||||
return (
|
||||
@@ -260,6 +354,14 @@ function LayerRow({
|
||||
<button type="button" className={editingMask || selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
|
||||
{layer.visible ? <Eye size={24} weight="regular" /> : <EyeSlash size={24} weight="regular" />}
|
||||
</button>
|
||||
<span className={`relative flex shrink-0 items-center ${layer.visible ? "" : "opacity-35 grayscale"}`}>
|
||||
<LayerThumbnail model={thumbnail} label={`${layer.name} thumbnail`} />
|
||||
{maskThumbnail ? (
|
||||
<span className={`-ml-2 rounded-md ${editingMask ? "ring-2 ring-sky-500 ring-offset-2 ring-offset-sky-300" : "ring-1 ring-sky-300/70"}`}>
|
||||
<LayerThumbnail model={maskThumbnail} label={`${layer.name} mask thumbnail${editingMask ? ", currently editing" : ""}`} compact />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
|
||||
<RenameInput
|
||||
value={editingTitle.draft}
|
||||
@@ -298,9 +400,10 @@ function LayerRow({
|
||||
</button>
|
||||
</div>
|
||||
{layerMask ? (
|
||||
<div className="mt-2 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] py-2 pl-4 pr-2 text-sm text-sky-100/70 hover:bg-white/[0.04]">
|
||||
<Stack size={24} weight="fill" className="shrink-0" />
|
||||
<span className="min-w-28 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span>
|
||||
<div role="group" aria-label={`Mask attached to ${layer.name}${editingMask ? ", currently editing" : ""}`} className={`relative mt-2 ml-10 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] border-l-2 py-2 pl-4 pr-2 text-sm transition ${editingMask ? "border-sky-300 bg-sky-300/15 text-sky-50 ring-1 ring-inset ring-sky-300/30" : "border-sky-300/30 text-sky-100/70 hover:bg-white/[0.04]"}`}>
|
||||
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-sky-300/40" />
|
||||
{maskThumbnail ? <LayerThumbnail model={maskThumbnail} label={`${layer.name} mask preview`} /> : <Stack size={24} weight="fill" className="shrink-0" />}
|
||||
<span className="min-w-28 flex-1 truncate font-medium">{maskLayer ? editingMask ? "Editing layer mask" : "Layer mask" : "Layer mask missing"}</span>
|
||||
{maskAsset ? <MaskStatus asset={maskAsset} /> : null}
|
||||
{maskLayer ? (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
@@ -337,7 +440,7 @@ function LayerRow({
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
{maskAsset && maskLayer.type !== "group" ? (
|
||||
{maskAsset && (maskLayer.type === "image" || maskLayer.type === "raster") ? (
|
||||
<MaskOperationButtons maskLayerId={maskLayer.id} maskAsset={maskAsset} dispatch={dispatch} />
|
||||
) : null}
|
||||
</div>
|
||||
@@ -366,6 +469,7 @@ function LayerRow({
|
||||
setEditingTitle={setEditingTitle}
|
||||
maskLayerIds={maskLayerIds}
|
||||
maskEdit={maskEdit}
|
||||
thumbnailByLayerId={thumbnailByLayerId}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -20,6 +20,7 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
|
||||
const precondition = workflow.precondition();
|
||||
const canGenerate = precondition.ready && !busy;
|
||||
const preconditionMessage = precondition.ready ? undefined : precondition.message;
|
||||
const repair = precondition.ready ? undefined : precondition.repair;
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
|
||||
@@ -34,7 +35,16 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
|
||||
>
|
||||
{busy && job?.kind === "generate" ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
{preconditionMessage ? <span className="max-w-72 text-xs text-white/55" role="status">{preconditionMessage}</span> : null}
|
||||
{preconditionMessage ? (
|
||||
<span className="flex max-w-80 items-center gap-2 rounded-full bg-amber-300/10 px-3 py-2 text-xs text-amber-100/75" role="status">
|
||||
<span>{preconditionMessage}</span>
|
||||
{repair === "add-mask" ? (
|
||||
<button type="button" className="shrink-0 rounded-full bg-white px-3 py-1 font-semibold text-black" onClick={() => void workflow.prepareInpaintMask()}>Add mask</button>
|
||||
) : repair === "set-outpaint-padding" ? (
|
||||
<button type="button" className="shrink-0 rounded-full bg-white px-3 py-1 font-semibold text-black" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, left: 128, right: 128 } })}>Add padding</button>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
<GenerationJobStatus generation={generation} />
|
||||
{busy ? <button type="button" className="h-9 rounded-full bg-white/5 px-3 text-xs font-semibold text-white/70 hover:bg-white/10 hover:text-white" onClick={workflow.cancel}>Cancel</button> : null}
|
||||
{candidate ? (
|
||||
@@ -56,25 +66,26 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
|
||||
|
||||
function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
|
||||
return (
|
||||
<div className="subtle-scrollbar flex max-w-[min(28rem,calc(100vw-2rem))] items-center gap-1 overflow-x-auto rounded-full bg-white/[0.04] px-1.5 py-1 ring-1 ring-white/[0.05]" role="group" aria-label="Generation candidates">
|
||||
<div className="subtle-scrollbar flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch gap-2 overflow-x-auto rounded-[1.25rem] bg-black/20 p-2 ring-1 ring-white/[0.08]" role="group" aria-label="Provisional generation candidates">
|
||||
{generation.candidates.map((candidate) => {
|
||||
const selected = candidate.id === (generation.selectedCandidateId ?? generation.candidates[0]?.id);
|
||||
return (
|
||||
<button
|
||||
key={candidate.id}
|
||||
type="button"
|
||||
className={`h-9 w-9 overflow-hidden rounded-full ring-2 transition ${selected ? "ring-white" : "ring-white/10 hover:ring-white/40"}`}
|
||||
title={`Candidate seed ${candidate.seed}`}
|
||||
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-xl ring-2 transition ${selected ? "ring-white" : "ring-white/10 hover:ring-white/40"}`}
|
||||
title={`Provisional candidate · seed ${candidate.seed}`}
|
||||
onClick={() => dispatch(commandIds.generationSelectCandidate, { candidateId: candidate.id })}
|
||||
>
|
||||
<img src={candidate.source} alt="" className="h-full w-full object-cover" />
|
||||
{selected ? <span className="absolute inset-x-1 bottom-1 rounded-full bg-black/70 px-1 py-0.5 text-[0.6rem] font-semibold text-white">Reviewing</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
disabled={generation.candidates.length === 0}
|
||||
className="h-9 shrink-0 rounded-full px-3 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"
|
||||
className="min-h-16 shrink-0 rounded-xl px-3 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"
|
||||
title="Dismiss every candidate and reset comparison"
|
||||
onClick={() => dispatch(commandIds.generationClearCandidates, undefined)}
|
||||
>
|
||||
@@ -105,11 +116,9 @@ function CandidateControls({
|
||||
const disabled = busy;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-1 rounded-full bg-white/[0.04] px-2 py-1 ring-1 ring-white/[0.05]">
|
||||
<div className="flex flex-wrap items-center justify-center gap-1 rounded-[1.25rem] bg-white/[0.04] px-2 py-2 ring-1 ring-white/[0.05]">
|
||||
<CandidatePreview candidate={candidate} />
|
||||
<span className="px-2 text-xs font-medium text-white/55" title={`${candidate.settings.model} · ${candidate.mode}`}>
|
||||
Seed {candidate.seed}
|
||||
</span>
|
||||
<span className="px-2 text-xs font-medium text-white/55" title={`${candidate.settings.model} · ${candidate.mode}`}><strong className="block font-semibold text-white/75">Provisional result</strong>Seed {candidate.seed}</span>
|
||||
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
|
||||
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" onClick={() => rerun("Regenerate", candidate.settings)} />
|
||||
<CandidateButton
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CaretDown, CaretUp } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { GenerationResourcesState } from "@editor/state";
|
||||
import type { GenerateArchitecture, GenerateMode, GenerateSettings } from "@editor/tools";
|
||||
import type { GenerateArchitecture, GenerateIntent, GenerateMode, GenerateSettings } from "@editor/tools";
|
||||
import { resolveGenerationModeOptions, resolveGenerationModelOptions, resolveGenerationStringOptions, resolveGenerationSupportOptions } from "@operations/generation/options";
|
||||
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
|
||||
import { BottomControlSlider } from "./Slider";
|
||||
@@ -74,6 +74,31 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
<div className="grid gap-5 pb-2 tabular-nums">
|
||||
{resources.error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{resources.error}</p> : null}
|
||||
|
||||
<section className={panelSectionClass()}>
|
||||
<div className="px-1">
|
||||
<h2 className="text-base font-semibold text-white/90">Edit with AI</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-white/45">Choose an outcome. Image Studio configures the matching generation workflow.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{intentOptions.map((intent) => (
|
||||
<button
|
||||
key={intent.value}
|
||||
type="button"
|
||||
aria-pressed={settings.mode === intent.mode}
|
||||
className={`rounded-[1.25rem] px-3 py-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${settings.mode === intent.mode ? "bg-white text-black" : "bg-white/[0.05] text-white/75 hover:bg-white/10"}`}
|
||||
onClick={() => dispatch(commandIds.toolChooseGenerateIntent, { intent: intent.value })}
|
||||
>
|
||||
<span className="block text-sm font-semibold">{intent.label}</span>
|
||||
<span className={`mt-1 block text-xs leading-4 ${settings.mode === intent.mode ? "text-black/55" : "text-white/40"}`}>{intent.description}</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" disabled className="rounded-[1.25rem] bg-white/[0.025] px-3 py-3 text-left text-white/30" title="Dedicated object removal is not supported by the current backend. Use Replace and describe the desired background.">
|
||||
<span className="block text-sm font-semibold">Remove</span>
|
||||
<span className="mt-1 block text-xs leading-4">Use Replace for now; describe what should fill the area.</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={panelSectionClass()}>
|
||||
<label className="grid gap-2">
|
||||
<span className={panelLabelClass()}>Prompt</span>
|
||||
@@ -101,43 +126,45 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
|
||||
<section className={panelSectionClass()}>
|
||||
<SectionTitle title="Essentials" />
|
||||
<PanelSelect label="Backend" value={settings.architecture} options={architectures} ariaLabel="Generate backend" onValueChange={(architecture) => dispatch(commandIds.toolSetGenerateSettings, { architecture })} />
|
||||
<PanelSelect label="Model" value={settings.model} options={modelOptions} ariaLabel="Generate model" onValueChange={(model) => dispatch(commandIds.toolSetGenerateSettings, { model })} />
|
||||
{settings.architecture !== "sdxl" ? (
|
||||
<>
|
||||
<PanelSelect label="Text enc." value={settings.textEncoder} options={supportOptions.textEncoders} ariaLabel="Generate text encoder" onValueChange={(textEncoder) => dispatch(commandIds.toolSetGenerateSettings, { textEncoder })} />
|
||||
<PanelSelect label="VAE" value={settings.vae} options={supportOptions.vaes} ariaLabel="Generate VAE" onValueChange={(vae) => dispatch(commandIds.toolSetGenerateSettings, { vae })} />
|
||||
</>
|
||||
) : null}
|
||||
<SizeControl refRoot={sizeRef} open={sizeOpen} setOpen={setSizeOpen} settings={settings} dispatch={dispatch} />
|
||||
<PanelNumber label="Seed" aria-label="Generate seed" min={-1} max={Number.MAX_SAFE_INTEGER} value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
|
||||
{settings.mode === "image-to-image" || settings.mode === "inpaint" ? (
|
||||
<div className={panelRowClass()}>
|
||||
<span className={panelLabelClass()}>Influence</span>
|
||||
<BottomControlSlider min={0} max={100} value={settings.strength} className="w-32" aria-label="Generate strength" onValueChange={(strength) => dispatch(commandIds.toolSetGenerateSettings, { strength })} />
|
||||
<span className="w-10 text-right text-sm text-white">{Math.round(settings.strength)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className={panelSectionClass()}>
|
||||
<button type="button" className={sectionToggleClass()} aria-expanded={advancedOpen} aria-controls="generate-advanced-controls" onClick={() => setAdvancedOpen((open) => !open)}>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-white/85">Advanced</span>
|
||||
<span className="block text-xs text-white/40">Mode, sampler, scheduler, steps, CFG, strength</span>
|
||||
<span className="block text-xs text-white/40">Model, backend, seed, sampling, and workflow internals</span>
|
||||
</span>
|
||||
{advancedOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />}
|
||||
</button>
|
||||
<div id="generate-advanced-controls" className={`grid gap-2 overflow-hidden transition-all duration-200 ${advancedOpen ? "max-h-[32rem] pt-2 opacity-100" : "max-h-0 opacity-0"}`}>
|
||||
<PanelSelect label="Mode" value={settings.mode} options={modeOptions} ariaLabel="Generate mode" onValueChange={(mode) => dispatch(commandIds.toolSetGenerateSettings, { mode })} />
|
||||
<PanelSelect label="Backend" value={settings.architecture} options={architectures} ariaLabel="Generate backend" onValueChange={(architecture) => dispatch(commandIds.toolSetGenerateSettings, { architecture })} />
|
||||
<PanelSelect label="Model" value={settings.model} options={modelOptions} ariaLabel="Generate model" onValueChange={(model) => dispatch(commandIds.toolSetGenerateSettings, { model })} />
|
||||
{settings.architecture !== "sdxl" ? (
|
||||
<>
|
||||
<PanelSelect label="Text enc." value={settings.textEncoder} options={supportOptions.textEncoders} ariaLabel="Generate text encoder" onValueChange={(textEncoder) => dispatch(commandIds.toolSetGenerateSettings, { textEncoder })} />
|
||||
<PanelSelect label="VAE" value={settings.vae} options={supportOptions.vaes} ariaLabel="Generate VAE" onValueChange={(vae) => dispatch(commandIds.toolSetGenerateSettings, { vae })} />
|
||||
</>
|
||||
) : null}
|
||||
<PanelNumber label="Seed" aria-label="Generate seed" min={-1} max={Number.MAX_SAFE_INTEGER} value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
|
||||
<PanelSelect label="Sampler" value={settings.sampler} options={samplerOptions} ariaLabel="Generate sampler" onValueChange={(sampler) => dispatch(commandIds.toolSetGenerateSettings, { sampler })} />
|
||||
<PanelSelect label="Scheduler" value={settings.scheduler} options={schedulerOptions} ariaLabel="Generate scheduler" onValueChange={(scheduler) => dispatch(commandIds.toolSetGenerateSettings, { scheduler })} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PanelNumber label="Steps" aria-label="Generate steps" value={settings.steps} onValueChange={(steps) => dispatch(commandIds.toolSetGenerateSettings, { steps })} />
|
||||
<PanelNumber label="CFG" aria-label="Generate CFG" value={settings.cfg} onValueChange={(cfg) => dispatch(commandIds.toolSetGenerateSettings, { cfg })} />
|
||||
</div>
|
||||
<div className={panelRowClass()}>
|
||||
<span className={panelLabelClass()}>Strength</span>
|
||||
<BottomControlSlider min={0} max={100} value={settings.strength} className="w-32" aria-label="Generate strength" onValueChange={(strength) => dispatch(commandIds.toolSetGenerateSettings, { strength })} />
|
||||
<span className="w-10 text-right text-sm text-white">{Math.round(settings.strength)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={panelSectionClass()}>
|
||||
{settings.mode === "outpaint" ? <section className={panelSectionClass()}>
|
||||
<button type="button" className={sectionToggleClass()} aria-expanded={outpaintOpen} aria-controls="generate-outpaint-controls" onClick={() => setOutpaintOpen((open) => !open)}>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-white/85">Outpaint</span>
|
||||
@@ -154,9 +181,9 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
</div>
|
||||
<PanelNumber label="Feather" aria-label="Outpaint feathering" value={settings.outpaint.feathering} onValueChange={(feathering) => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, feathering } })} />
|
||||
</div>
|
||||
</section>
|
||||
</section> : null}
|
||||
|
||||
<section className={panelSectionClass()}>
|
||||
{settings.mode === "inpaint" ? <section className={panelSectionClass()}>
|
||||
<button type="button" className={sectionToggleClass()} aria-expanded={inpaintOpen} aria-controls="generate-inpaint-controls" onClick={() => setInpaintOpen((open) => !open)}>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-white/85">Inpaint</span>
|
||||
@@ -192,11 +219,18 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
|
||||
<PanelNumber label="Clean" aria-label="Inpaint mask despeckle" value={settings.inpaint.maskDespeckle} max={64} onValueChange={(maskDespeckle) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskDespeckle } })} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const intentOptions: ReadonlyArray<{ value: GenerateIntent; mode: GenerateMode; label: string; description: string }> = [
|
||||
{ value: "create", mode: "text-to-image", label: "Create", description: "Make a new image from your prompt." },
|
||||
{ value: "replace", mode: "inpaint", label: "Replace", description: "Regenerate the masked part of one layer." },
|
||||
{ value: "extend", mode: "outpaint", label: "Extend", description: "Grow one selected image beyond its edges." },
|
||||
{ value: "variations", mode: "image-to-image", label: "Variations", description: "Explore alternatives based on one image." },
|
||||
];
|
||||
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return <div className="px-1 text-xs font-semibold uppercase tracking-[0.18em] text-white/35">{title}</div>;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { BoundingBox } from "@phosphor-icons/react";
|
||||
import { ArrowsOut, BoundingBox, Copy, Crop, MaskHappy } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { TransformTarget } from "@editor/transform";
|
||||
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
|
||||
import { addLayerMask, duplicateLayer } from "@operations/document/layerActions";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { BottomControlDivider } from "./Divider";
|
||||
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
|
||||
|
||||
export type TransformControlsProps = {
|
||||
bounds: Rect;
|
||||
target: TransformTarget;
|
||||
documentIndex: DocumentReadIndex;
|
||||
layerInfo?: IndexedLayerInfo;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
|
||||
type BoundsField = keyof Rect;
|
||||
|
||||
export function TransformControls({ bounds, target, dispatch }: TransformControlsProps) {
|
||||
export function TransformControls({ bounds, target, documentIndex, layerInfo, dispatch }: TransformControlsProps) {
|
||||
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
|
||||
const [opacityDraft, setOpacityDraft] = useState(() => String(Math.round((layerInfo?.layer.opacity ?? 1) * 100)));
|
||||
const [rotationDraft, setRotationDraft] = useState(() => rotationDegrees(layerInfo));
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
const [cropDraft, setCropDraft] = useState(() => cropDraftFor(layerInfo, documentIndex));
|
||||
const [resizeOpen, setResizeOpen] = useState(false);
|
||||
const [resizeDraft, setResizeDraft] = useState(() => ({ w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)), scaleContents: false }));
|
||||
const layer = layerInfo?.layer;
|
||||
const locked = layer?.locked ?? false;
|
||||
const mask = layer ? getLayerMask(layer) : undefined;
|
||||
const rotatedMaskEditingUnsupported = Boolean(layer && (layer.type === "image" || layer.type === "raster") && layer.transform.rotation !== 0);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(draftFromBounds(bounds));
|
||||
}, [bounds.x, bounds.y, bounds.w, bounds.h]);
|
||||
|
||||
useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]);
|
||||
useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]);
|
||||
useEffect(() => setCropDraft(cropDraftFor(layerInfo, documentIndex)), [documentIndex, layer?.id, layer?.type === "image" || layer?.type === "raster" ? layer.sourceRect : undefined]);
|
||||
useEffect(() => setResizeDraft((current) => ({ ...current, w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)) })), [bounds.w, bounds.h, target]);
|
||||
|
||||
const commitField = (field: BoundsField) => {
|
||||
const value = Number.parseFloat(draft[field]);
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -29,6 +49,7 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl
|
||||
return;
|
||||
}
|
||||
|
||||
if (locked) return;
|
||||
dispatch(commandIds.transformSetBounds, {
|
||||
target,
|
||||
bounds: {
|
||||
@@ -44,36 +65,150 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl
|
||||
<BoundingBox size={24} weight="regular" />
|
||||
</span>
|
||||
<BottomControlDivider />
|
||||
<BoundsInput label="X" field="x" draft={draft.x} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="Y" field="y" draft={draft.y} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="X" field="x" draft={draft.x} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="Y" field="y" draft={draft.y} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
||||
<BottomControlDivider />
|
||||
<BoundsInput label="W" field="w" draft={draft.w} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="H" field="h" draft={draft.h} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="W" field="w" draft={draft.w} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="H" field="h" draft={draft.h} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
||||
{layer ? (
|
||||
<>
|
||||
<BottomControlDivider />
|
||||
<SimpleInput
|
||||
label="Opacity"
|
||||
suffix="%"
|
||||
value={opacityDraft}
|
||||
disabled={locked}
|
||||
onChange={setOpacityDraft}
|
||||
onCommit={() => {
|
||||
const value = Number.parseFloat(opacityDraft);
|
||||
if (Number.isFinite(value)) dispatch(commandIds.documentSetLayerOpacity, { layerId: layer.id, opacity: value / 100 });
|
||||
else setOpacityDraft(String(Math.round(layer.opacity * 100)));
|
||||
}}
|
||||
/>
|
||||
<SimpleInput
|
||||
label="Rotation"
|
||||
suffix="°"
|
||||
value={rotationDraft}
|
||||
disabled={locked || layer.type === "group" || layer.type === "adjustment"}
|
||||
title={layer.type === "group" || layer.type === "adjustment" ? "Group rotation is not supported" : undefined}
|
||||
onChange={setRotationDraft}
|
||||
onCommit={() => {
|
||||
const value = Number.parseFloat(rotationDraft);
|
||||
if (Number.isFinite(value)) dispatch(commandIds.transformSetRotation, { target, rotation: value * Math.PI / 180 });
|
||||
else setRotationDraft(rotationDegrees(layerInfo));
|
||||
}}
|
||||
/>
|
||||
<BottomControlDivider />
|
||||
<button type="button" className={actionButtonClass()} disabled={locked} title={locked ? "Unlock the layer to duplicate it" : "Duplicate layer"} onClick={() => duplicateLayer(documentIndex, layerInfo!, dispatch)}>
|
||||
<Copy size={20} /> Duplicate
|
||||
</button>
|
||||
{layer.type === "image" || layer.type === "raster" ? (
|
||||
<button type="button" className={actionButtonClass()} disabled={locked || layer.transform.rotation !== 0} title={layer.transform.rotation !== 0 ? "Reset rotation before cropping" : "Crop visible source pixels"} onClick={() => setCropOpen((open) => !open)}>
|
||||
<Crop size={20} /> Crop
|
||||
</button>
|
||||
) : null}
|
||||
{layer.type === "image" || layer.type === "raster" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass()}
|
||||
disabled={locked || rotatedMaskEditingUnsupported}
|
||||
title={locked ? "Unlock the layer to edit its mask" : rotatedMaskEditingUnsupported ? "Reset rotation to edit the layer mask" : mask ? "Edit layer mask" : "Add layer mask"}
|
||||
onClick={() => {
|
||||
if (mask) dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: mask.maskLayerId });
|
||||
else addLayerMask(documentIndex, layerInfo!, dispatch);
|
||||
}}
|
||||
>
|
||||
<MaskHappy size={20} /> {mask ? "Edit mask" : "Add mask"}
|
||||
</button>
|
||||
) : null}
|
||||
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
|
||||
{cropOpen && (layer.type === "image" || layer.type === "raster") ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
|
||||
</>
|
||||
) : target.type === "artboard" ? (
|
||||
<>
|
||||
<BottomControlDivider />
|
||||
<button type="button" className={actionButtonClass()} disabled={locked} onClick={() => setResizeOpen((open) => !open)}><ArrowsOut size={20} /> Resize canvas</button>
|
||||
{resizeOpen ? <ArtboardResizeEditor draft={resizeDraft} setDraft={setResizeDraft} onCancel={() => setResizeOpen(false)} onApply={() => { const w = Number.parseFloat(resizeDraft.w); const h = Number.parseFloat(resizeDraft.h); if (Number.isFinite(w) && Number.isFinite(h) && w >= 1 && h >= 1) { dispatch(commandIds.documentResizeArtboard, { id: target.id, bounds: { ...bounds, w, h }, scaleContents: resizeDraft.scaleContents }); setResizeOpen(false); } }} /> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CropDraft = Record<BoundsField, string>;
|
||||
|
||||
function CropEditor({ draft, setDraft, onApply, onCancel, onReset }: { draft: CropDraft; setDraft: Dispatch<SetStateAction<CropDraft>>; onApply: () => void; onCancel: () => void; onReset: () => void }) {
|
||||
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Crop source pixels">
|
||||
{(["x", "y", "w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Crop ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
|
||||
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button><button type="button" className={actionButtonClass()} onClick={onReset}>Reset</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: { w: string; h: string; scaleContents: boolean }; setDraft: Dispatch<SetStateAction<{ w: string; h: string; scaleContents: boolean }>>; onApply: () => void; onCancel: () => void }) {
|
||||
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Resize artboard canvas">
|
||||
{(["w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Canvas ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
|
||||
<label className="flex items-center gap-2 text-xs text-white/70"><input type="checkbox" checked={draft.scaleContents} onChange={(event) => setDraft((current) => ({ ...current, scaleContents: event.target.checked }))} />Scale contents</label>
|
||||
<span className="max-w-40 text-[10px] text-white/45">Off changes canvas bounds only.</span>
|
||||
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
|
||||
if (!layerInfo || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) return { x: "0", y: "0", w: "1", h: "1" };
|
||||
const asset = index.assetById.get(layerInfo.layer.assetId);
|
||||
const rect = layerInfo.layer.sourceRect ?? { x: 0, y: 0, w: asset?.intrinsicSize.w ?? 1, h: asset?.intrinsicSize.h ?? 1 };
|
||||
return draftFromBounds(rect);
|
||||
}
|
||||
|
||||
function parseRectDraft(draft: CropDraft): Rect | undefined {
|
||||
const rect = { x: Number.parseFloat(draft.x), y: Number.parseFloat(draft.y), w: Number.parseFloat(draft.w), h: Number.parseFloat(draft.h) };
|
||||
return Object.values(rect).every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? rect : undefined;
|
||||
}
|
||||
|
||||
function SimpleInput({ label, suffix, value, disabled, title, onChange, onCommit }: { label: string; suffix: string; value: string; disabled?: boolean; title?: string; onChange: (value: string) => void; onCommit: () => void }) {
|
||||
return (
|
||||
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`} title={title}>
|
||||
<span className={bottomControlLabelClass()}>{label}</span>
|
||||
<span className="flex items-center">
|
||||
<input className={`${bottomControlInputClass()} w-16`} inputMode="decimal" value={value} disabled={disabled} aria-label={label} onChange={(event) => onChange(event.target.value)} onBlur={onCommit} onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") event.currentTarget.blur(); }} />
|
||||
<span className="-ml-2 text-xs text-white/40">{suffix}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function rotationDegrees(layerInfo?: IndexedLayerInfo) {
|
||||
return String(Math.round((layerInfo?.layer.transform.rotation ?? 0) * 180 / Math.PI));
|
||||
}
|
||||
|
||||
function actionButtonClass() {
|
||||
return "inline-flex h-10 items-center gap-2 rounded-full px-3 text-xs font-semibold text-white/70 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
|
||||
}
|
||||
|
||||
function BoundsInput({
|
||||
label,
|
||||
field,
|
||||
draft,
|
||||
disabled,
|
||||
setDraft,
|
||||
commitField,
|
||||
}: {
|
||||
label: string;
|
||||
field: BoundsField;
|
||||
draft: string;
|
||||
disabled?: boolean;
|
||||
setDraft: Dispatch<SetStateAction<Record<BoundsField, string>>>;
|
||||
commitField: (field: BoundsField) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className={bottomControlFieldClass()}>
|
||||
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`}>
|
||||
<span className={bottomControlLabelClass()}>{label}</span>
|
||||
<input
|
||||
className={bottomControlInputClass()}
|
||||
inputMode="decimal"
|
||||
value={draft}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))}
|
||||
onBlur={() => commitField(field)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
|
||||
31
view/layers/LayerThumbnail.tsx
Normal file
31
view/layers/LayerThumbnail.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { FolderSimple, ImageBroken, SlidersHorizontal, TextT } from "@phosphor-icons/react";
|
||||
import type { LayerThumbnailModel, RasterThumbnailModel } from "./thumbnailModel";
|
||||
|
||||
export function LayerThumbnail({ model, label, compact = false }: { model: LayerThumbnailModel; label: string; compact?: boolean }) {
|
||||
const sizeClass = compact ? "h-7 w-9 rounded-md" : "h-8 w-10 rounded-lg";
|
||||
return (
|
||||
<span role="img" aria-label={label} className={`relative grid shrink-0 place-items-center overflow-hidden bg-black/25 ring-1 ring-inset ring-white/15 ${sizeClass}`}>
|
||||
{model.kind === "raster" ? <RasterPreview model={model} /> : null}
|
||||
{model.kind === "group" ? (
|
||||
<>
|
||||
<span className="absolute inset-1 grid grid-cols-2 gap-px overflow-hidden rounded">
|
||||
{model.previews.map((preview, index) => <RasterPreview key={`${preview.source}-${index}`} model={preview} />)}
|
||||
</span>
|
||||
<FolderSimple size={compact ? 14 : 17} weight="fill" className="relative drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]" />
|
||||
</>
|
||||
) : null}
|
||||
{model.kind === "empty" ? <ImageBroken size={compact ? 14 : 17} className="text-white/35" /> : null}
|
||||
{model.kind === "adjustment" ? <SlidersHorizontal size={compact ? 14 : 17} className="text-violet-200" /> : null}
|
||||
{model.kind === "text" ? <TextT size={compact ? 14 : 17} style={{ color: model.color }} aria-label={model.content} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RasterPreview({ model }: { model: RasterThumbnailModel }) {
|
||||
const { x, y, w, h } = model.viewBox;
|
||||
return (
|
||||
<svg aria-hidden="true" className="h-full w-full bg-[linear-gradient(45deg,rgba(255,255,255,.06)_25%,transparent_25%,transparent_75%,rgba(255,255,255,.06)_75%),linear-gradient(45deg,rgba(255,255,255,.06)_25%,transparent_25%,transparent_75%,rgba(255,255,255,.06)_75%)] bg-[length:8px_8px] bg-[position:0_0,4px_4px]" viewBox={`${x} ${y} ${w} ${h}`} preserveAspectRatio="xMidYMid meet">
|
||||
<image href={model.source} x="0" y="0" width={model.intrinsicWidth} height={model.intrinsicHeight} onError={(event) => event.currentTarget.setAttribute("visibility", "hidden")} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
53
view/layers/thumbnailModel.test.ts
Normal file
53
view/layers/thumbnailModel.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { createLayerThumbnailIndex, resolveLayerThumbnail } from "./thumbnailModel";
|
||||
|
||||
const assets = new Map([
|
||||
["asset-a", { id: "asset-a", name: "A", mimeType: "image/png", source: "data:image/png;base64,a", intrinsicSize: { w: 400, h: 200 } }],
|
||||
["asset-b", { id: "asset-b", name: "B", mimeType: "image/webp", source: "blob:b", intrinsicSize: { w: 80, h: 60 } }],
|
||||
] as const) as ReadonlyMap<string, Asset>;
|
||||
|
||||
describe("layer thumbnail read model", () => {
|
||||
test("preserves and clamps a layer source crop", () => {
|
||||
expect(resolveLayerThumbnail(raster("cropped", "asset-a", { x: 350, y: 20, w: 100, h: 240 }), assets)).toEqual({
|
||||
kind: "raster",
|
||||
source: "data:image/png;base64,a",
|
||||
viewBox: { x: 350, y: 20, w: 50, h: 180 },
|
||||
intrinsicWidth: 400,
|
||||
intrinsicHeight: 200,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a graceful empty model for invalid and missing assets", () => {
|
||||
expect(resolveLayerThumbnail(raster("missing", "unknown"), assets)).toEqual({ kind: "empty" });
|
||||
expect(resolveLayerThumbnail(raster("outside", "asset-a", { x: 500, y: 0, w: 20, h: 20 }), assets)).toEqual({ kind: "empty" });
|
||||
});
|
||||
|
||||
test("collects a bounded visible group preview and excludes attached masks", () => {
|
||||
const group = base("group", "group") as Layer & { type: "group"; children: Layer[] };
|
||||
group.type = "group";
|
||||
group.children = [raster("hidden", "asset-a", undefined, false), raster("mask", "asset-a"), raster("one", "asset-a"), raster("two", "asset-b"), raster("three", "asset-a"), raster("four", "asset-a")];
|
||||
const result = resolveLayerThumbnail(group, assets, new Set(["mask"]));
|
||||
expect(result.kind).toBe("group");
|
||||
if (result.kind === "group") expect(result.previews.map((preview) => preview.source)).toEqual(["data:image/png;base64,a", "blob:b", "data:image/png;base64,a"]);
|
||||
});
|
||||
|
||||
test("indexes nested group previews once for row lookup", () => {
|
||||
const child = { ...base("child-group", "Child"), type: "group" as const, children: [raster("nested", "asset-b")] };
|
||||
const group = { ...base("root", "Root"), type: "group" as const, children: [child] };
|
||||
const document = { id: "doc", version: 1, name: "Doc", assets: [...assets.values()], artboards: [{ id: "artboard", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 }, backgroundColor: "#000000", visible: true, locked: false, layers: [group] }] };
|
||||
const index = createLayerThumbnailIndex(document, assets);
|
||||
expect(index.get("nested")?.kind).toBe("raster");
|
||||
expect(index.get("child-group")).toMatchObject({ kind: "group", previews: [{ source: "blob:b" }] });
|
||||
expect(index.get("root")).toMatchObject({ kind: "group", previews: [{ source: "blob:b" }] });
|
||||
});
|
||||
});
|
||||
|
||||
function raster(id: string, assetId: string, sourceRect?: { x: number; y: number; w: number; h: number }, visible = true): Layer {
|
||||
return { ...base(id, id), type: "raster", assetId, visible, ...(sourceRect ? { sourceRect } : {}) };
|
||||
}
|
||||
|
||||
function base(id: string, name: string) {
|
||||
return { id, name, visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } };
|
||||
}
|
||||
117
view/layers/thumbnailModel.ts
Normal file
117
view/layers/thumbnailModel.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { AssetId, LayerId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
|
||||
export type RasterThumbnailModel = {
|
||||
kind: "raster";
|
||||
source: string;
|
||||
viewBox: Rect;
|
||||
intrinsicWidth: number;
|
||||
intrinsicHeight: number;
|
||||
};
|
||||
|
||||
export type LayerThumbnailModel =
|
||||
| RasterThumbnailModel
|
||||
| { kind: "adjustment" }
|
||||
| { kind: "text"; content: string; color: string }
|
||||
| { kind: "group"; previews: readonly RasterThumbnailModel[] }
|
||||
| { kind: "empty" };
|
||||
|
||||
/** Builds a small, read-only preview descriptor without decoding or rerasterizing assets. */
|
||||
export function resolveLayerThumbnail(
|
||||
layer: Layer,
|
||||
assetById: ReadonlyMap<AssetId, Asset>,
|
||||
excludedLayerIds: ReadonlySet<LayerId> = new Set(),
|
||||
): LayerThumbnailModel {
|
||||
if (layer.type === "adjustment") return { kind: "adjustment" };
|
||||
if (layer.type === "text") return { kind: "text", content: layer.content, color: layer.style.color };
|
||||
if (layer.type !== "group") return resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" };
|
||||
|
||||
const previews: RasterThumbnailModel[] = [];
|
||||
const pending = [...layer.children].reverse();
|
||||
while (pending.length > 0 && previews.length < 3) {
|
||||
const child = pending.pop();
|
||||
if (!child || excludedLayerIds.has(child.id) || !child.visible) continue;
|
||||
if (child.type === "group") {
|
||||
pending.push(...[...child.children].reverse());
|
||||
continue;
|
||||
}
|
||||
const preview = resolveRasterThumbnail(child, assetById);
|
||||
if (preview) previews.push(preview);
|
||||
}
|
||||
return { kind: "group", previews };
|
||||
}
|
||||
|
||||
/** Resolves every layer once, including group mosaics, for linear tree rendering. */
|
||||
export function createLayerThumbnailIndex(
|
||||
document: ImageDocument,
|
||||
assetById: ReadonlyMap<AssetId, Asset>,
|
||||
excludedLayerIds: ReadonlySet<LayerId> = new Set(),
|
||||
): ReadonlyMap<LayerId, LayerThumbnailModel> {
|
||||
const result = new Map<LayerId, LayerThumbnailModel>();
|
||||
const pending: Array<{ layer: Layer; visited: boolean }> = [];
|
||||
for (const artboard of document.artboards) for (const layer of artboard.layers) pending.push({ layer, visited: false });
|
||||
|
||||
while (pending.length > 0) {
|
||||
const frame = pending.pop();
|
||||
if (!frame) continue;
|
||||
const { layer } = frame;
|
||||
if (frame.visited || layer.type !== "group") {
|
||||
if (layer.type !== "group") {
|
||||
if (layer.type === "adjustment") { result.set(layer.id, { kind: "adjustment" }); continue; }
|
||||
if (layer.type === "text") { result.set(layer.id, { kind: "text", content: layer.content, color: layer.style.color }); continue; }
|
||||
result.set(layer.id, resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" });
|
||||
continue;
|
||||
}
|
||||
const previews: RasterThumbnailModel[] = [];
|
||||
for (const child of layer.children) {
|
||||
if (previews.length >= 3) break;
|
||||
if (excludedLayerIds.has(child.id) || !child.visible) continue;
|
||||
const childModel = result.get(child.id);
|
||||
if (childModel?.kind === "raster") previews.push(childModel);
|
||||
if (childModel?.kind === "group") previews.push(...childModel.previews.slice(0, 3 - previews.length));
|
||||
}
|
||||
result.set(layer.id, { kind: "group", previews });
|
||||
continue;
|
||||
}
|
||||
pending.push({ layer, visited: true });
|
||||
for (let index = layer.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = layer.children[index];
|
||||
if (child) pending.push({ layer: child, visited: false });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveRasterThumbnail(layer: Exclude<Layer, { type: "group" }>, assetById: ReadonlyMap<AssetId, Asset>): RasterThumbnailModel | undefined {
|
||||
if (layer.type === "adjustment" || layer.type === "text") return undefined;
|
||||
const asset = assetById.get(layer.assetId);
|
||||
if (!asset || !asset.source.trim() || !positiveFinite(asset.intrinsicSize.w) || !positiveFinite(asset.intrinsicSize.h)) return undefined;
|
||||
|
||||
const fullRect = { x: 0, y: 0, w: asset.intrinsicSize.w, h: asset.intrinsicSize.h };
|
||||
const viewBox = layer.sourceRect ? intersectRect(layer.sourceRect, fullRect) : fullRect;
|
||||
if (!viewBox) return undefined;
|
||||
|
||||
return {
|
||||
kind: "raster",
|
||||
source: asset.source,
|
||||
viewBox,
|
||||
intrinsicWidth: asset.intrinsicSize.w,
|
||||
intrinsicHeight: asset.intrinsicSize.h,
|
||||
};
|
||||
}
|
||||
|
||||
function intersectRect(rect: Rect, bounds: Rect): Rect | undefined {
|
||||
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) || rect.w <= 0 || rect.h <= 0) return undefined;
|
||||
const x = Math.max(rect.x, bounds.x);
|
||||
const y = Math.max(rect.y, bounds.y);
|
||||
const right = Math.min(rect.x + rect.w, bounds.x + bounds.w);
|
||||
const bottom = Math.min(rect.y + rect.h, bounds.y + bounds.h);
|
||||
return right > x && bottom > y ? { x, y, w: right - x, h: bottom - y } : undefined;
|
||||
}
|
||||
|
||||
function positiveFinite(value: number) {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user