Compare commits

...

6 Commits

Author SHA1 Message Date
syntaxbullet
37aa719047 feat: add first-class text layers 2026-07-11 12:38:34 +02:00
syntaxbullet
606426c885 feat: add non-destructive adjustment layers 2026-07-11 12:31:06 +02:00
syntaxbullet
b928fb599b feat: add layer and mask thumbnails 2026-07-11 12:20:26 +02:00
syntaxbullet
9fb3a19912 feat: add crop and canvas resize workflows 2026-07-11 12:16:33 +02:00
syntaxbullet
4d7358af4b feat: add contextual layer inspector 2026-07-11 12:07:36 +02:00
syntaxbullet
bef2fb3051 feat: unify AI edit workflow 2026-07-11 11:58:16 +02:00
59 changed files with 1778 additions and 144 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View 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&amp;B");
expect(asset.intrinsicSize.w).toBeGreaterThan(0);
});
});

19
renderer/text-asset.ts Normal file
View 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[character]!);
}

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

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

View File

@@ -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} />
) : (

View File

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

View File

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

View File

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

View File

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

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

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

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