Compare commits

...

2 Commits

Author SHA1 Message Date
syntaxbullet
c3a75cf0d6 feat: add command palette functionality and shortcuts
- Implemented command palette with keyboard shortcut (⌘K) for opening.
- Added commands for opening, closing, setting query, and selecting items in the command palette.
- Created tests for command palette commands and input handling.
- Enhanced layer actions with functions for adding, grouping, and deleting layers.
- Updated UI components to integrate command palette and shortcuts.
2026-07-05 17:15:20 +02:00
syntaxbullet
5eaf37ba28 feat: enhance layer mask handling and opacity management
- Introduced `getLayerMask` utility to streamline layer mask retrieval.
- Updated layer rendering logic to incorporate layer masks and opacity adjustments.
- Added functionality to prevent dropping a group into its descendants.
- Enhanced image texture rendering to support opacity parameters across various rendering functions.
- Implemented group layer bounds application for better scaling and positioning.
- Added tests to ensure correct behavior when handling layer masks and group layers.
- Created new types for asset generation provenance to track generated assets more effectively.
2026-07-05 16:06:23 +02:00
44 changed files with 1706 additions and 240 deletions

View File

@@ -2,6 +2,7 @@ import { documentCommands } from "@commands/document";
import { generationCommands } from "@commands/generation"; import { generationCommands } from "@commands/generation";
import { historyCommands } from "@commands/history"; import { historyCommands } from "@commands/history";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import { commandPaletteCommands } from "@commands/palette";
import { createCommandRegistry } from "@commands/registry"; import { createCommandRegistry } from "@commands/registry";
import { selectionCommands } from "@commands/selection"; import { selectionCommands } from "@commands/selection";
import { toolCommands } from "@commands/tool"; import { toolCommands } from "@commands/tool";
@@ -13,7 +14,7 @@ import { createAppStore } from "@editor/store";
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>; export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) { export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands]); const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands]);
const store = createAppStore(createInitialAppState(options?.documentName), registry); const store = createAppStore(createInitialAppState(options?.documentName), registry);
if (options?.createDefaultArtboard !== false) { if (options?.createDefaultArtboard !== false) {

View File

@@ -121,6 +121,11 @@ function snapshot(state: AppState): HistorySnapshot {
...state.editor, ...state.editor,
brushPreview: undefined, brushPreview: undefined,
brushStrokePreview: undefined, brushStrokePreview: undefined,
commandPalette: {
open: false,
query: "",
selectedIndex: 0,
},
}, },
}; };
} }

View File

@@ -154,6 +154,35 @@ describe("document commands", () => {
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]); expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]);
}); });
test("groups nested sibling layers in place", () => {
const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("a", "A"), group("b", "B"), group("c", "C")] }]);
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
const parent = grouped.document.artboards[0]?.layers[0];
const nestedGroup = parent?.type === "group" ? parent.children[0] : undefined;
expect(parent?.type).toBe("group");
expect(parent?.type === "group" ? parent.children.map((layer) => layer.id) : []).toEqual(["g", "c"]);
expect(nestedGroup?.type === "group" ? nestedGroup.children.map((layer) => layer.id) : []).toEqual(["a", "b"]);
expect(grouped.editor.selection).toEqual({ artboardId: "a1", layerIds: ["g"] });
});
test("does not group layers from different parents", () => {
const state = documentWithLayers([group("a", "A"), { ...group("parent", "Parent"), children: [group("b", "B")] }]);
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
expect(grouped).toBe(state);
});
test("does not move groups into their own descendants", () => {
const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("child", "Child")] }]);
const moved = documentMoveLayerCommand.execute({ state }, { layerId: "parent", toArtboardId: "a1", toParentGroupId: "child", toIndex: 0 });
expect(moved).toBe(state);
});
test("removes layers", () => { test("removes layers", () => {
const state = documentWithLayers([group("a", "A"), group("b", "B")]); const state = documentWithLayers([group("a", "A"), group("b", "B")]);
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } }; const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } };
@@ -219,6 +248,12 @@ describe("document commands", () => {
expect(masked.document.assets).toContainEqual(maskAsset()); expect(masked.document.assets).toContainEqual(maskAsset());
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]); expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]);
expect(masked.document.artboards[0]?.layers[1]?.layerMask).toEqual({
kind: "raster",
maskLayerId: "mask",
enabled: true,
inverted: false,
});
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" }); expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" }); expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(masked.editor.tools.activeTool).toBe("brush"); expect(masked.editor.tools.activeTool).toBe("brush");
@@ -233,6 +268,7 @@ describe("document commands", () => {
const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" }); const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" });
expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]); expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]);
expect(unmasked.document.artboards[0]?.layers[0]?.layerMask).toBeUndefined();
expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined(); expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
expect(unmasked.editor.maskEdit).toBeUndefined(); expect(unmasked.editor.maskEdit).toBeUndefined();
}); });

View File

@@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer"; import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { RasterLayer } from "@core/raster-layer"; import type { RasterLayer } from "@core/raster-layer";
import type { LayerGroup } from "@core/layer-group"; import type { LayerGroup } from "@core/layer-group";
import type { Command } from "./command"; import type { Command } from "./command";
@@ -316,7 +317,7 @@ export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
const removed = removeLayerFromDocument(state.document, payload.layerId); const removed = removeLayerFromDocument(state.document, payload.layerId);
if (!removed.layer) return state; if (!removed.layer) return state;
const maskLayerId = removed.layer.clippingMask?.maskLayerId; const maskLayerId = getLayerMask(removed.layer)?.maskLayerId;
const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined; const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined;
const documentAfterRemoval = removedMask?.document ?? removed.document; const documentAfterRemoval = removedMask?.document ?? removed.document;
if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state; if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state;
@@ -338,25 +339,29 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
execute({ state }, payload) { execute({ state }, payload) {
const requestedIds = [...new Set(payload.layerIds)]; const requestedIds = [...new Set(payload.layerIds)];
if (requestedIds.length === 0) return state; if (requestedIds.length === 0) return state;
if (findLayerLocation(state.document, payload.group.id)) return state;
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId); const requestedLocations = requestedIds.flatMap((layerId) => {
if (!artboard) return state; const location = findLayerLocation(state.document, layerId);
return location ? [location] : [];
});
if (requestedLocations.length !== requestedIds.length) return state;
const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])]; const firstLocation = requestedLocations[0];
if (!firstLocation || firstLocation.artboardId !== payload.artboardId) return state;
if (requestedLocations.some((location) => location.artboardId !== firstLocation.artboardId || location.parentGroupId !== firstLocation.parentGroupId)) return state;
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id)); const uniqueIds = new Set([...requestedIds, ...collectAttachedMaskIds(firstLocation.siblings, requestedIds)]);
const selected = firstLocation.siblings.filter((layer) => uniqueIds.has(layer.id));
if (selected.length === 0) return state; if (selected.length === 0) return state;
const firstIndex = artboard.layers.findIndex((layer) => layer.id === selected[0]?.id);
const group: LayerGroup = { ...payload.group, children: selected }; const group: LayerGroup = { ...payload.group, children: selected };
const document = { const document = replaceLayerListInDocument(
...state.document, state.document,
artboards: state.document.artboards.map((candidate) => firstLocation.artboardId,
candidate.id === payload.artboardId firstLocation.parentGroupId,
? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] } replaceSelectedLayersWithGroup(firstLocation.siblings, uniqueIds, group),
: candidate, );
),
};
return { return {
...state, ...state,
@@ -414,7 +419,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
if (payload.maskLayerId === payload.layerId) return state; if (payload.maskLayerId === payload.layerId) return state;
if (!payload.maskLayerId) { if (!payload.maskLayerId) {
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId; const previousMaskId = getLayerMask(findLayerLocation(state.document, payload.layerId)?.layer)?.maskLayerId;
return { return {
...state, ...state,
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)), document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
@@ -442,7 +447,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
removed.document, removed.document,
maskLocationAfterRemoval.artboardId, maskLocationAfterRemoval.artboardId,
maskLocationAfterRemoval.parentGroupId, maskLocationAfterRemoval.parentGroupId,
{ ...removed.layer, clippingMask: { maskLayerId: payload.maskLayerId } }, withLayerMask(removed.layer, payload.maskLayerId),
maskLocationAfterRemoval.index + 1, maskLocationAfterRemoval.index + 1,
), ),
}; };
@@ -456,7 +461,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
const targetLocation = findLayerLocation(state.document, payload.layerId); const targetLocation = findLayerLocation(state.document, payload.layerId);
if (!targetLocation || targetLocation.layer.type === "group") return state; if (!targetLocation || targetLocation.layer.type === "group") return state;
const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId; const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) { if (existingMaskId) {
const existingMaskLocation = findLayerLocation(state.document, existingMaskId); const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
if (existingMaskLocation?.layer.type === "group") return state; if (existingMaskLocation?.layer.type === "group") return state;
@@ -486,11 +491,12 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
visible: true, visible: true,
locked: false, locked: false,
opacity: 1, opacity: 1,
layerMask: undefined,
clippingMask: undefined, clippingMask: undefined,
}; };
const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] }; const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] };
const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index); const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index);
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } })); const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => withLayerMask(layer, maskLayer.id));
return { return {
...state, ...state,
@@ -546,7 +552,7 @@ export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayl
name: "Remove layer mask", name: "Remove layer mask",
execute({ state }, payload) { execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId); const targetLocation = findLayerLocation(state.document, payload.layerId);
const maskLayerId = targetLocation?.layer.clippingMask?.maskLayerId; const maskLayerId = targetLocation?.layer ? getLayerMask(targetLocation.layer)?.maskLayerId : undefined;
if (!targetLocation || !maskLayerId) { if (!targetLocation || !maskLayerId) {
return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state; return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state;
} }
@@ -624,6 +630,7 @@ type LayerLocation = {
parentGroupId?: LayerId; parentGroupId?: LayerId;
index: number; index: number;
layer: Layer; layer: Layer;
siblings: readonly Layer[];
}; };
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined { function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
@@ -640,7 +647,7 @@ function isReferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): b
function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean { function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean {
for (const layer of layers) { for (const layer of layers) {
if (layer.clippingMask?.maskLayerId === maskLayerId) return true; if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true; if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true;
} }
return false; return false;
@@ -650,7 +657,7 @@ function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId:
for (let index = 0; index < layers.length; index++) { for (let index = 0; index < layers.length; index++) {
const layer = layers[index]; const layer = layers[index];
if (!layer) continue; if (!layer) continue;
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer }; if (layer.id === layerId) return { artboardId, parentGroupId, index, layer, siblings: layers };
if (layer.type === "group") { if (layer.type === "group") {
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id); const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
if (child) return child; if (child) return child;
@@ -693,6 +700,41 @@ function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, ind
}); });
} }
function replaceLayerListInDocument(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layers: Layer[]): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => {
if (artboard.id !== artboardId) return artboard;
if (!parentGroupId) return { ...artboard, layers };
return { ...artboard, layers: replaceLayerListInGroup(artboard.layers, parentGroupId, layers) };
}),
};
}
function replaceLayerListInGroup(layers: Layer[], groupId: LayerId, children: Layer[]): Layer[] {
return layers.map((layer) => {
if (layer.type === "group" && layer.id === groupId) return { ...layer, children };
if (layer.type === "group") return { ...layer, children: replaceLayerListInGroup(layer.children, groupId, children) };
return layer;
});
}
function replaceSelectedLayersWithGroup(layers: readonly Layer[], selectedLayerIds: ReadonlySet<LayerId>, group: LayerGroup): Layer[] {
const next: Layer[] = [];
let inserted = false;
for (const layer of layers) {
if (!selectedLayerIds.has(layer.id)) {
next.push(layer);
continue;
}
if (!inserted) {
next.push(group);
inserted = true;
}
}
return next;
}
function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } { function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
let removed: Layer | undefined; let removed: Layer | undefined;
return { return {
@@ -785,10 +827,24 @@ function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefi
function removeLayerMaskReference(layer: Layer): Layer { function removeLayerMaskReference(layer: Layer): Layer {
const next = { ...layer }; const next = { ...layer };
delete next.layerMask;
delete next.clippingMask; delete next.clippingMask;
return next; return next;
} }
function withLayerMask(layer: Layer, maskLayerId: LayerId): Layer {
return {
...layer,
layerMask: {
kind: "raster",
maskLayerId,
enabled: true,
inverted: false,
},
clippingMask: { maskLayerId },
};
}
function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument { function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
if (isMaskLayerReferenced(document, maskLayerId)) return document; if (isMaskLayerReferenced(document, maskLayerId)) return document;
return removeLayerFromDocument(document, maskLayerId).document; return removeLayerFromDocument(document, maskLayerId).document;
@@ -801,7 +857,8 @@ function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): b
function removeMissingMaskReferences(document: ImageDocument): ImageDocument { function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
const existingLayerIds = collectDocumentLayerIds(document); const existingLayerIds = collectDocumentLayerIds(document);
return mapAllLayersInDocument(document, (layer) => { return mapAllLayersInDocument(document, (layer) => {
if (!layer.clippingMask || existingLayerIds.has(layer.clippingMask.maskLayerId)) return layer; const mask = getLayerMask(layer);
if (!mask || existingLayerIds.has(mask.maskLayerId)) return layer;
return removeLayerMaskReference(layer); return removeLayerMaskReference(layer);
}); });
} }
@@ -814,7 +871,7 @@ function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerI
if (!maskEdit) return false; if (!maskEdit) return false;
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer; const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer; const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
return Boolean(target?.clippingMask?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group"); return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
} }
function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> { function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> {
@@ -836,7 +893,8 @@ function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId
function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> { function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) { for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); const mask = getLayerMask(layer);
if (mask) ids.add(mask.maskLayerId);
if (layer.type === "group") collectClippingMaskIds(layer.children, ids); if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
} }
return ids; return ids;
@@ -844,7 +902,10 @@ function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>
function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] { function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
const layerIdSet = new Set(layerIds); const layerIdSet = new Set(layerIds);
return layers.flatMap((layer) => (layerIdSet.has(layer.id) && layer.clippingMask ? [layer.clippingMask.maskLayerId] : [])); return layers.flatMap((layer) => {
const mask = getLayerMask(layer);
return layerIdSet.has(layer.id) && mask ? [mask.maskLayerId] : [];
});
} }
function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument { function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {

View File

@@ -6,6 +6,7 @@ import {
generationApplyCandidateAsLayerCommand, generationApplyCandidateAsLayerCommand,
generationRemoveCandidateCommand, generationRemoveCandidateCommand,
generationReplaceCandidatePixelsCommand, generationReplaceCandidatePixelsCommand,
generationSetCompareModeCommand,
} from "./generation"; } from "./generation";
describe("generation commands", () => { describe("generation commands", () => {
@@ -18,8 +19,20 @@ describe("generation commands", () => {
expect(added.editor.generation.candidates).toEqual([candidate]); expect(added.editor.generation.candidates).toEqual([candidate]);
expect(added.editor.generation.selectedCandidateId).toBe(candidate.id); expect(added.editor.generation.selectedCandidateId).toBe(candidate.id);
expect(added.editor.generation.compareMode).toBe("result");
expect(removed.editor.generation.candidates).toEqual([]); expect(removed.editor.generation.candidates).toEqual([]);
expect(removed.editor.generation.selectedCandidateId).toBeUndefined(); expect(removed.editor.generation.selectedCandidateId).toBeUndefined();
expect(removed.editor.generation.compareMode).toBe("result");
});
test("sets generation compare mode without touching candidates", () => {
const state = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate: generationCandidate("candidate-1") });
const next = generationSetCompareModeCommand.execute({ state }, { mode: "split" });
expect(next.editor.generation.candidates).toEqual([generationCandidate("candidate-1")]);
expect(next.editor.generation.selectedCandidateId).toBe("candidate-1");
expect(next.editor.generation.compareMode).toBe("split");
}); });
test("applies candidates as top-level layers", () => { test("applies candidates as top-level layers", () => {
@@ -31,9 +44,15 @@ describe("generation commands", () => {
); );
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.source).toBe("generated-source"); expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.source).toBe("generated-source");
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.provenance).toMatchObject({
kind: "generated",
candidateId: "candidate-1",
acceptance: "layer",
seed: 123,
});
expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer"); expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] }); expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] });
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined }); expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
}); });
test("replaces source asset pixels for inpaint candidates", () => { test("replaces source asset pixels for inpaint candidates", () => {
@@ -46,8 +65,17 @@ describe("generation commands", () => {
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.source).toBe("composited-source"); expect(next.document.assets.find((asset) => asset.id === "source-asset")?.source).toBe("composited-source");
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.mimeType).toBe("image/png"); expect(next.document.assets.find((asset) => asset.id === "source-asset")?.mimeType).toBe("image/png");
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.provenance).toMatchObject({
kind: "generated",
candidateId: "candidate-1",
acceptance: "replacement",
inpaint: {
sourceAssetId: "source-asset",
maskAssetId: "mask-asset",
},
});
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] }); expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] });
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined }); expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
}); });
}); });

View File

@@ -1,9 +1,10 @@
import type { Asset } from "@core/asset"; import type { Asset } from "@core/asset";
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer"; import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import type { GenerationCandidate, GenerationState } from "@editor/state"; import type { GenerationCandidate, GenerationCompareMode, GenerationState } from "@editor/state";
import type { Command } from "./command"; import type { Command } from "./command";
import { commandIds } from "./ids"; import { commandIds } from "./ids";
@@ -15,6 +16,10 @@ export type GenerationSelectCandidatePayload = {
candidateId?: string; candidateId?: string;
}; };
export type GenerationSetCompareModePayload = {
mode: GenerationCompareMode;
};
export type GenerationRemoveCandidatePayload = { export type GenerationRemoveCandidatePayload = {
candidateId: string; candidateId: string;
}; };
@@ -33,6 +38,7 @@ export type GenerationReplaceCandidatePixelsPayload = {
}; };
const maxCandidates = 12; const maxCandidates = 12;
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = { export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
id: commandIds.generationAddCandidate, id: commandIds.generationAddCandidate,
@@ -47,6 +53,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
generation: { generation: {
candidates, candidates,
selectedCandidateId: payload.candidate.id, selectedCandidateId: payload.candidate.id,
compareMode: "result",
}, },
}, },
}; };
@@ -73,6 +80,26 @@ export const generationSelectCandidateCommand: Command<GenerationSelectCandidate
}, },
}; };
export const generationSetCompareModeCommand: Command<GenerationSetCompareModePayload> = {
id: commandIds.generationSetCompareMode,
name: "Set generation compare mode",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!generationCompareModes.has(payload.mode)) return state;
if (state.editor.generation.compareMode === payload.mode) return state;
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
compareMode: payload.mode,
},
},
};
},
};
export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidatePayload> = { export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidatePayload> = {
id: commandIds.generationRemoveCandidate, id: commandIds.generationRemoveCandidate,
name: "Remove generation candidate", name: "Remove generation candidate",
@@ -88,6 +115,7 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
generation: { generation: {
candidates, candidates,
selectedCandidateId, selectedCandidateId,
compareMode: candidates.length > 0 ? state.editor.generation.compareMode : "result",
}, },
}, },
}; };
@@ -99,12 +127,12 @@ export const generationClearCandidatesCommand: Command = {
name: "Clear generation candidates", name: "Clear generation candidates",
history: { mode: "ignore" }, history: { mode: "ignore" },
execute({ state }) { execute({ state }) {
if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId) return state; if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId && state.editor.generation.compareMode === "result") return state;
return { return {
...state, ...state,
editor: { editor: {
...state.editor, ...state.editor,
generation: { candidates: [], selectedCandidateId: undefined }, generation: { candidates: [], selectedCandidateId: undefined, compareMode: "result" },
}, },
}; };
}, },
@@ -125,6 +153,7 @@ export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCand
mimeType: candidate.mimeType, mimeType: candidate.mimeType,
source: candidate.source, source: candidate.source,
intrinsicSize: { ...candidate.intrinsicSize }, intrinsicSize: { ...candidate.intrinsicSize },
provenance: generationProvenance(candidate, payload.variant ? "variant-layer" : "layer"),
}; };
const layer: ImageLayer = { const layer: ImageLayer = {
id: payload.layerId, id: payload.layerId,
@@ -167,7 +196,16 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
...state, ...state,
document: { document: {
...state.document, ...state.document,
assets: state.document.assets.map((asset) => asset.id === targetAsset.id ? { ...asset, source: payload.source, mimeType: payload.mimeType ?? asset.mimeType } : asset), assets: state.document.assets.map((asset) =>
asset.id === targetAsset.id
? {
...asset,
source: payload.source,
mimeType: payload.mimeType ?? asset.mimeType,
provenance: generationProvenance(candidate, "replacement"),
}
: asset,
),
}, },
editor: { editor: {
...state.editor, ...state.editor,
@@ -181,6 +219,7 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
export const generationCommands = [ export const generationCommands = [
generationAddCandidateCommand, generationAddCandidateCommand,
generationSelectCandidateCommand, generationSelectCandidateCommand,
generationSetCompareModeCommand,
generationRemoveCandidateCommand, generationRemoveCandidateCommand,
generationClearCandidatesCommand, generationClearCandidatesCommand,
generationApplyCandidateAsLayerCommand, generationApplyCandidateAsLayerCommand,
@@ -219,6 +258,58 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
} }
function clearCommittedGenerationPreview(generation: GenerationState): GenerationState { function clearCommittedGenerationPreview(generation: GenerationState): GenerationState {
if (generation.candidates.length === 0 && !generation.selectedCandidateId) return generation; if (generation.candidates.length === 0 && !generation.selectedCandidateId && generation.compareMode === "result") return generation;
return { candidates: [], selectedCandidateId: undefined }; return { candidates: [], selectedCandidateId: undefined, compareMode: "result" };
}
function generationProvenance(candidate: GenerationCandidate, acceptance: GeneratedAssetAcceptance): AssetGenerationProvenance {
return {
kind: "generated",
candidateId: candidate.id,
mode: candidate.mode,
acceptance,
prompt: candidate.settings.prompt,
negativePrompt: candidate.settings.negativePrompt,
seed: candidate.seed,
outputSize: { ...candidate.intrinsicSize },
settings: {
architecture: candidate.settings.architecture,
model: candidate.settings.model,
textEncoder: candidate.settings.textEncoder,
vae: candidate.settings.vae,
strength: candidate.settings.strength,
steps: candidate.settings.steps,
cfg: candidate.settings.cfg,
sampler: candidate.settings.sampler,
scheduler: candidate.settings.scheduler,
width: candidate.settings.width,
height: candidate.settings.height,
},
inpaint: candidate.inpaint
? {
targetLayerId: candidate.inpaint.targetLayerId,
maskLayerId: candidate.inpaint.maskLayerId,
sourceAssetId: candidate.inpaint.sourceAssetId,
maskAssetId: candidate.inpaint.maskAssetId,
crop: {
assetBounds: { ...candidate.inpaint.crop.assetBounds },
documentBounds: { ...candidate.inpaint.crop.documentBounds },
padding: candidate.inpaint.crop.padding,
maskedAreaOnly: candidate.inpaint.crop.maskedAreaOnly,
},
mask: {
polarity: candidate.inpaint.mask.polarity,
activeBounds: { ...candidate.inpaint.mask.activeBounds },
},
backend: {
growMaskBy: candidate.inpaint.backend.growMaskBy,
maskedContent: candidate.inpaint.backend.maskedContent,
maskBlur: candidate.inpaint.backend.maskBlur,
maskFeather: candidate.inpaint.backend.maskFeather,
maskExpand: candidate.inpaint.backend.maskExpand,
cropPadding: candidate.inpaint.backend.cropPadding,
},
}
: undefined,
};
} }

View File

@@ -38,6 +38,7 @@ export const commandIds = {
toolExitTemporaryPan: "tool.exitTemporaryPan", toolExitTemporaryPan: "tool.exitTemporaryPan",
generationAddCandidate: "generation.addCandidate", generationAddCandidate: "generation.addCandidate",
generationSelectCandidate: "generation.selectCandidate", generationSelectCandidate: "generation.selectCandidate",
generationSetCompareMode: "generation.setCompareMode",
generationRemoveCandidate: "generation.removeCandidate", generationRemoveCandidate: "generation.removeCandidate",
generationClearCandidates: "generation.clearCandidates", generationClearCandidates: "generation.clearCandidates",
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer", generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
@@ -54,4 +55,8 @@ export const commandIds = {
viewportFitArtboard: "viewport.fitArtboard", viewportFitArtboard: "viewport.fitArtboard",
historyUndo: "history.undo", historyUndo: "history.undo",
historyRedo: "history.redo", historyRedo: "history.redo",
commandPaletteOpen: "commandPalette.open",
commandPaletteClose: "commandPalette.close",
commandPaletteSetQuery: "commandPalette.setQuery",
commandPaletteSetSelectedIndex: "commandPalette.setSelectedIndex",
} as const; } as const;

View File

@@ -58,6 +58,7 @@ export {
generationRemoveCandidateCommand, generationRemoveCandidateCommand,
generationReplaceCandidatePixelsCommand, generationReplaceCandidatePixelsCommand,
generationSelectCandidateCommand, generationSelectCandidateCommand,
generationSetCompareModeCommand,
} from "./generation"; } from "./generation";
export type { export type {
GenerationAddCandidatePayload, GenerationAddCandidatePayload,
@@ -65,10 +66,23 @@ export type {
GenerationRemoveCandidatePayload, GenerationRemoveCandidatePayload,
GenerationReplaceCandidatePixelsPayload, GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload, GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
} from "./generation"; } from "./generation";
export type { CommandDispatcher, Dispatch } from "./dispatcher"; export type { CommandDispatcher, Dispatch } from "./dispatcher";
export type { CommandId, CommandPayloads } from "./payloads"; export type { CommandId, CommandPayloads } from "./payloads";
export { createCommandDispatcher } from "./dispatcher"; export { createCommandDispatcher } from "./dispatcher";
export {
commandPaletteCloseCommand,
commandPaletteCommands,
commandPaletteOpenCommand,
commandPaletteSetQueryCommand,
commandPaletteSetSelectedIndexCommand,
} from "./palette";
export type {
CommandPaletteOpenPayload,
CommandPaletteSetQueryPayload,
CommandPaletteSetSelectedIndexPayload,
} from "./palette";
export type { CommandRegistry } from "./registry"; export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry"; export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection"; export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";

40
commands/palette.test.ts Normal file
View File

@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import {
commandPaletteCloseCommand,
commandPaletteOpenCommand,
commandPaletteSetQueryCommand,
commandPaletteSetSelectedIndexCommand,
} from "./palette";
describe("command palette commands", () => {
test("opens with a reset query and selection", () => {
const initial = commandPaletteSetQueryCommand.execute({ state: createInitialAppState("Test") }, { query: "brush" });
const next = commandPaletteOpenCommand.execute({ state: initial }, undefined);
expect(next.editor.commandPalette).toEqual({ open: true, query: "", selectedIndex: 0 });
});
test("sets query and returns selected item to the first result", () => {
const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, { query: "layer", selectedIndex: 4 });
const next = commandPaletteSetQueryCommand.execute({ state: opened }, { query: "zoom" });
expect(next.editor.commandPalette).toEqual({ open: true, query: "zoom", selectedIndex: 0 });
});
test("clamps selected index to a non-negative integer", () => {
const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, undefined);
const fractional = commandPaletteSetSelectedIndexCommand.execute({ state: opened }, { selectedIndex: 3.8 });
const negative = commandPaletteSetSelectedIndexCommand.execute({ state: opened }, { selectedIndex: -1 });
expect(fractional.editor.commandPalette.selectedIndex).toBe(3);
expect(negative.editor.commandPalette.selectedIndex).toBe(0);
});
test("closes and clears transient palette text", () => {
const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, { query: "debug", selectedIndex: 2 });
const next = commandPaletteCloseCommand.execute({ state: opened }, undefined);
expect(next.editor.commandPalette).toEqual({ open: false, query: "", selectedIndex: 0 });
});
});

121
commands/palette.ts Normal file
View File

@@ -0,0 +1,121 @@
import type { Command } from "./command";
import { commandIds } from "./ids";
export type CommandPaletteOpenPayload = {
query?: string;
selectedIndex?: number;
} | undefined;
export type CommandPaletteSetQueryPayload = {
query: string;
};
export type CommandPaletteSetSelectedIndexPayload = {
selectedIndex: number;
};
export const commandPaletteOpenCommand: Command<CommandPaletteOpenPayload> = {
id: commandIds.commandPaletteOpen,
name: "Open command palette",
history: { mode: "ignore" },
execute({ state }, payload) {
const commandPalette = {
open: true,
query: payload?.query ?? "",
selectedIndex: normalizeSelectedIndex(payload?.selectedIndex ?? 0),
};
if (
state.editor.commandPalette.open === commandPalette.open &&
state.editor.commandPalette.query === commandPalette.query &&
state.editor.commandPalette.selectedIndex === commandPalette.selectedIndex
) {
return state;
}
return {
...state,
editor: {
...state.editor,
commandPalette,
},
};
},
};
export const commandPaletteCloseCommand: Command = {
id: commandIds.commandPaletteClose,
name: "Close command palette",
history: { mode: "ignore" },
execute({ state }) {
const commandPalette = { open: false, query: "", selectedIndex: 0 };
if (
state.editor.commandPalette.open === commandPalette.open &&
state.editor.commandPalette.query === commandPalette.query &&
state.editor.commandPalette.selectedIndex === commandPalette.selectedIndex
) {
return state;
}
return {
...state,
editor: {
...state.editor,
commandPalette,
},
};
},
};
export const commandPaletteSetQueryCommand: Command<CommandPaletteSetQueryPayload> = {
id: commandIds.commandPaletteSetQuery,
name: "Set command palette query",
history: { mode: "ignore" },
execute({ state }, payload) {
if (state.editor.commandPalette.query === payload.query && state.editor.commandPalette.selectedIndex === 0) return state;
return {
...state,
editor: {
...state.editor,
commandPalette: {
...state.editor.commandPalette,
query: payload.query,
selectedIndex: 0,
},
},
};
},
};
export const commandPaletteSetSelectedIndexCommand: Command<CommandPaletteSetSelectedIndexPayload> = {
id: commandIds.commandPaletteSetSelectedIndex,
name: "Set command palette selected index",
history: { mode: "ignore" },
execute({ state }, payload) {
const selectedIndex = normalizeSelectedIndex(payload.selectedIndex);
if (state.editor.commandPalette.selectedIndex === selectedIndex) return state;
return {
...state,
editor: {
...state.editor,
commandPalette: {
...state.editor.commandPalette,
selectedIndex,
},
},
};
},
};
export const commandPaletteCommands = [
commandPaletteOpenCommand,
commandPaletteCloseCommand,
commandPaletteSetQueryCommand,
commandPaletteSetSelectedIndexCommand,
] satisfies Command<unknown>[];
function normalizeSelectedIndex(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.floor(value));
}

View File

@@ -29,7 +29,13 @@ import type {
GenerationRemoveCandidatePayload, GenerationRemoveCandidatePayload,
GenerationReplaceCandidatePixelsPayload, GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload, GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
} from "./generation"; } from "./generation";
import type {
CommandPaletteOpenPayload,
CommandPaletteSetQueryPayload,
CommandPaletteSetSelectedIndexPayload,
} from "./palette";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool"; import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
@@ -81,6 +87,7 @@ export type CommandPayloads = {
[commandIds.toolExitTemporaryPan]: void; [commandIds.toolExitTemporaryPan]: void;
[commandIds.generationAddCandidate]: GenerationAddCandidatePayload; [commandIds.generationAddCandidate]: GenerationAddCandidatePayload;
[commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload; [commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload;
[commandIds.generationSetCompareMode]: GenerationSetCompareModePayload;
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload; [commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
[commandIds.generationClearCandidates]: void; [commandIds.generationClearCandidates]: void;
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload; [commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
@@ -97,6 +104,10 @@ export type CommandPayloads = {
[commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined; [commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined;
[commandIds.historyUndo]: void; [commandIds.historyUndo]: void;
[commandIds.historyRedo]: void; [commandIds.historyRedo]: void;
[commandIds.commandPaletteOpen]: CommandPaletteOpenPayload;
[commandIds.commandPaletteClose]: void;
[commandIds.commandPaletteSetQuery]: CommandPaletteSetQueryPayload;
[commandIds.commandPaletteSetSelectedIndex]: CommandPaletteSetSelectedIndexPayload;
}; };
export type CommandId = keyof CommandPayloads; export type CommandId = keyof CommandPayloads;

View File

@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry"; import type { Vec2D } from "@core/geometry";
import type { LayerId, ArtboardId, AssetId } from "@core/id"; import type { LayerId, ArtboardId, AssetId } from "@core/id";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { MaskViewMode } from "@editor/state"; import type { MaskViewMode } from "@editor/state";
import { generateArchitectureDefaults } from "@editor/tools"; import { generateArchitectureDefaults } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools"; import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
@@ -253,7 +254,7 @@ export const toolEnterMaskEditCommand: Command<ToolEnterMaskEditPayload> = {
const targetLocation = findLayerLocation(state.document, payload.targetLayerId); const targetLocation = findLayerLocation(state.document, payload.targetLayerId);
const maskLocation = findLayerLocation(state.document, payload.maskLayerId); const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state; if (!targetLocation || !maskLocation) return state;
if (targetLocation.layer.clippingMask?.maskLayerId !== payload.maskLayerId) return state; if (getLayerMask(targetLocation.layer)?.maskLayerId !== payload.maskLayerId) return state;
if (maskLocation.layer.type === "group") return state; if (maskLocation.layer.type === "group") return state;
return { return {

56
core/asset-provenance.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { Rect, Size } from "./geometry";
import type { AssetId, LayerId } from "./id";
export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
export type GeneratedAssetAcceptance = "layer" | "variant-layer" | "replacement";
export type AssetGenerationProvenance = {
kind: "generated";
candidateId: string;
mode: GeneratedAssetMode;
acceptance: GeneratedAssetAcceptance;
prompt: string;
negativePrompt: string;
seed: number;
outputSize: Size;
settings: {
architecture: string;
model: string;
textEncoder: string;
vae: string;
strength: number;
steps: number;
cfg: number;
sampler: string;
scheduler: string;
width: number;
height: number;
};
inpaint?: {
targetLayerId: LayerId;
maskLayerId: LayerId;
sourceAssetId: AssetId;
maskAssetId: AssetId;
crop: {
assetBounds: Rect;
documentBounds: Rect;
padding: number;
maskedAreaOnly: boolean;
};
mask: {
polarity: "hidden" | "revealed";
activeBounds: Rect;
};
backend: {
growMaskBy: number;
maskedContent: string;
maskBlur: number;
maskFeather: number;
maskExpand: number;
cropPadding: number;
};
};
};
export type AssetProvenance = AssetGenerationProvenance;

View File

@@ -1,5 +1,6 @@
import type { Size } from "./geometry"; import type { Size } from "./geometry";
import type { AssetId } from "./id"; import type { AssetId } from "./id";
import type { AssetProvenance } from "./asset-provenance";
export type Asset = { export type Asset = {
id: AssetId; id: AssetId;
@@ -7,4 +8,5 @@ export type Asset = {
mimeType: string; mimeType: string;
source: string; source: string;
intrinsicSize: Size; intrinsicSize: Size;
provenance?: AssetProvenance;
}; };

View File

@@ -1,5 +1,6 @@
import type { Transform } from "./geometry"; import type { Transform } from "./geometry";
import type { LayerId } from "./id"; import type { LayerId } from "./id";
import type { LayerMask } from "./layer-mask";
export type LayerClippingMask = { export type LayerClippingMask = {
maskLayerId: LayerId; maskLayerId: LayerId;
@@ -12,5 +13,7 @@ export type BaseLayer = {
locked: boolean; locked: boolean;
opacity: number; opacity: number;
transform: Transform; transform: Transform;
layerMask?: LayerMask;
/** @deprecated Use layerMask. Kept readable for older documents. */
clippingMask?: LayerClippingMask; clippingMask?: LayerClippingMask;
}; };

View File

@@ -1,5 +1,6 @@
export type { Artboard } from "./artboard"; export type { Artboard } from "./artboard";
export type { Asset } from "./asset"; export type { Asset } from "./asset";
export type { AssetGenerationProvenance, AssetProvenance, GeneratedAssetAcceptance, GeneratedAssetMode } from "./asset-provenance";
export type { BaseLayer, LayerClippingMask } from "./base-layer"; export type { BaseLayer, LayerClippingMask } from "./base-layer";
export type { ImageDocument } from "./document"; export type { ImageDocument } from "./document";
export type { export type {
@@ -15,5 +16,7 @@ export type {
export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id"; export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id";
export type { ImageLayer } from "./image-layer"; export type { ImageLayer } from "./image-layer";
export type { Layer } from "./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 { LayerGroup } from "./layer-group";
export type { RasterLayer } from "./raster-layer"; export type { RasterLayer } from "./raster-layer";

18
core/layer-mask-utils.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Layer } from "./layer";
import type { LayerMask } from "./layer-mask";
export function getLayerMask(layer: Layer | undefined): LayerMask | undefined {
if (layer.layerMask) return layer.layerMask;
if (!layer.clippingMask) return undefined;
return {
kind: "raster",
maskLayerId: layer.clippingMask.maskLayerId,
enabled: true,
inverted: false,
};
}
export function hasLayerMask(layer: Layer): boolean {
return Boolean(getLayerMask(layer));
}

8
core/layer-mask.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { LayerId } from "./id";
export type LayerMask = {
kind: "raster";
maskLayerId: LayerId;
enabled: boolean;
inverted: boolean;
};

View File

@@ -22,7 +22,10 @@ const document: ImageDocument = {
locked: false, locked: false,
layers: [ layers: [
raster("mask", "Mask", "asset-mask"), raster("mask", "Mask", "asset-mask"),
{ ...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }), clippingMask: { maskLayerId: "mask" } }, {
...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }),
layerMask: { kind: "raster", maskLayerId: "mask", enabled: true, inverted: false },
},
group("group", "Group", [ group("group", "Group", [
raster("nested-mask", "Nested Mask", "asset-mask"), raster("nested-mask", "Nested Mask", "asset-mask"),
{ ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } }, { ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } },

View File

@@ -3,6 +3,7 @@ import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
export type IndexedLayerInfo = { export type IndexedLayerInfo = {
artboardId: ArtboardId; artboardId: ArtboardId;
@@ -113,9 +114,10 @@ function indexLayerTree(options: {
index, index,
}); });
if (layer.clippingMask) { const layerMask = getLayerMask(layer);
options.documentMaskLayerIds.add(layer.clippingMask.maskLayerId); if (layerMask) {
layerListMaskLayerIds.add(layer.clippingMask.maskLayerId); options.documentMaskLayerIds.add(layerMask.maskLayerId);
layerListMaskLayerIds.add(layerMask.maskLayerId);
} }
if (layer.type === "group") { if (layer.type === "group") {

View File

@@ -15,6 +15,12 @@ export const initialEditorState: EditorState = {
generation: { generation: {
candidates: [], candidates: [],
selectedCandidateId: undefined, selectedCandidateId: undefined,
compareMode: "result",
},
commandPalette: {
open: false,
query: "",
selectedIndex: 0,
}, },
transformSession: undefined, transformSession: undefined,
maskEdit: undefined, maskEdit: undefined,

View File

@@ -79,9 +79,18 @@ export type GenerationCandidate = {
}; };
}; };
export type GenerationCompareMode = "result" | "before" | "split";
export type GenerationState = { export type GenerationState = {
candidates: GenerationCandidate[]; candidates: GenerationCandidate[];
selectedCandidateId?: string; selectedCandidateId?: string;
compareMode: GenerationCompareMode;
};
export type CommandPaletteState = {
open: boolean;
query: string;
selectedIndex: number;
}; };
export type EditorState = { export type EditorState = {
@@ -89,6 +98,7 @@ export type EditorState = {
selection: SelectionState; selection: SelectionState;
tools: ToolState; tools: ToolState;
generation: GenerationState; generation: GenerationState;
commandPalette: CommandPaletteState;
transformSession?: TransformSession; transformSession?: TransformSession;
maskEdit?: MaskEditState; maskEdit?: MaskEditState;
brushPreview?: BrushPreviewState; brushPreview?: BrushPreviewState;

View File

@@ -44,6 +44,10 @@ describe("transform targets", () => {
expect(resolveTransformTargetBounds(document, { type: "layer", id: "l1" })).toEqual({ x: 10, y: 20, w: 100, h: 200 }); expect(resolveTransformTargetBounds(document, { type: "layer", id: "l1" })).toEqual({ x: 10, y: 20, w: 100, h: 200 });
}); });
test("resolves group bounds from descendant layers", () => {
expect(resolveTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" })).toEqual({ x: 10, y: 20, w: 300, h: 200 });
});
test("applies image layer bounds to transform", () => { test("applies image layer bounds to transform", () => {
const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 }); const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 });
const layer = next.artboards[0]?.layers[0]; const layer = next.artboards[0]?.layers[0];
@@ -81,8 +85,49 @@ describe("transform targets", () => {
expect(next.artboards[0]?.layers[1]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 }); expect(next.artboards[0]?.layers[1]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
}); });
test("applies group bounds to descendant layer transforms", () => {
const next = applyTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" }, { x: 20, y: 40, w: 600, h: 100 });
const group = next.artboards[0]?.layers[0];
const first = group?.type === "group" ? group.children[0] : undefined;
const second = group?.type === "group" ? group.children[1] : undefined;
expect(first?.transform).toEqual({ position: { x: 20, y: 40 }, scale: { x: 1, y: 1 }, rotation: 0 });
expect(second?.transform).toEqual({ position: { x: 420, y: 90 }, scale: { x: 1, y: 0.5 }, rotation: 0 });
});
test("applies artboard bounds", () => { test("applies artboard bounds", () => {
const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 }); const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 });
expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 }); expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 });
}); });
}); });
function groupDocument(): ImageDocument {
return {
...document,
artboards: [
{
...document.artboards[0]!,
layers: [
{
id: "g1",
type: "group",
name: "Group",
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [
document.artboards[0]!.layers[0]!,
{
...document.artboards[0]!.layers[0]!,
id: "l2",
name: "Second Layer",
transform: { position: { x: 210, y: 120 }, scale: { x: 0.5, y: 1 }, rotation: 0 },
},
],
},
],
},
],
};
}

View File

@@ -1,6 +1,7 @@
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId, LayerId } from "@core/id"; import type { ArtboardId, LayerId } from "@core/id";
import type { TransformTarget } from "./transform"; import type { TransformTarget } from "./transform";
@@ -35,7 +36,11 @@ export function selectedTransformTarget(document: ImageDocument, selection: { ar
function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument { function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
const layer = findLayer(document, layerId); const layer = findLayer(document, layerId);
const targetLayerIds = layer?.clippingMask ? [layerId, layer.clippingMask.maskLayerId] : [layerId]; if (!layer) return document;
if (layer.type === "group") return applyGroupLayerBounds(document, layer.id, bounds);
const layerMask = getLayerMask(layer);
const targetLayerIds = layerMask ? [layerId, layerMask.maskLayerId] : [layerId];
return targetLayerIds.reduce( return targetLayerIds.reduce(
(nextDocument, targetLayerId) => ({ (nextDocument, targetLayerId) => ({
@@ -49,6 +54,67 @@ function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rec
); );
} }
function applyGroupLayerBounds(document: ImageDocument, groupId: LayerId, bounds: Rect): ImageDocument {
const group = findLayer(document, groupId);
if (!group || group.type !== "group") return document;
const initialBounds = resolveLayerBounds(document, group);
if (!initialBounds || initialBounds.w === 0 || initialBounds.h === 0) return document;
const scale = {
x: bounds.w / initialBounds.w,
y: bounds.h / initialBounds.h,
};
return {
...document,
artboards: document.artboards.map((artboard) => ({
...artboard,
layers: applyGroupLayerBoundsInTree(document, artboard.layers, groupId, initialBounds, bounds, scale),
})),
};
}
function applyGroupLayerBoundsInTree(document: ImageDocument, layers: Layer[], groupId: LayerId, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer[] {
return layers.map((layer) => {
if (layer.type === "group" && layer.id === groupId) {
return {
...layer,
children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)),
};
}
if (layer.type === "group") return { ...layer, children: applyGroupLayerBoundsInTree(document, layer.children, groupId, initialBounds, bounds, scale) };
return layer;
});
}
function scaleLayerSubtree(document: ImageDocument, layer: Layer, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
if (layer.type === "group") {
return {
...layer,
children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)),
};
}
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return layer;
return {
...layer,
transform: {
...layer.transform,
position: {
x: bounds.x + (layer.transform.position.x - initialBounds.x) * scale.x,
y: bounds.y + (layer.transform.position.y - initialBounds.y) * scale.y,
},
scale: {
x: layer.transform.scale.x * scale.x,
y: layer.transform.scale.y * scale.y,
},
},
};
}
function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] { function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
return layers.map((layer) => { return layers.map((layer) => {
if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) { if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) {

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import { handleCommandPaletteKey } from "./command-palette";
describe("command palette input", () => {
test("opens the command palette with the platform shortcut", () => {
const dispatched: unknown[] = [];
const consumed = handleCommandPaletteKey({
event: { key: "k", code: "KeyK", altKey: false, ctrlKey: false, metaKey: true, shiftKey: false },
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return undefined as never;
},
});
expect(consumed).toBe(true);
expect(dispatched).toEqual([{ commandId: commandIds.commandPaletteOpen, payload: undefined }]);
});
test("ignores unmodified K so the chroma key tool keeps its shortcut", () => {
const consumed = handleCommandPaletteKey({
event: { key: "k", code: "KeyK", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
dispatch: () => undefined as never,
});
expect(consumed).toBe(false);
});
});

12
input/command-palette.ts Normal file
View File

@@ -0,0 +1,12 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { KeybindEvent } from "./keyboard";
export function handleCommandPaletteKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean {
if (options.event.altKey || options.event.shiftKey) return false;
const modifier = options.event.metaKey || options.event.ctrlKey;
if (!modifier || options.event.key.toLowerCase() !== "k") return false;
options.dispatch(commandIds.commandPaletteOpen, undefined);
return true;
}

View File

@@ -5,6 +5,7 @@ export {
} from "./dom"; } from "./dom";
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
export { handleKeybind, keybindFromEvent } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard";
export { handleCommandPaletteKey } from "./command-palette";
export { handleHistoryKey } from "./history"; export { handleHistoryKey } from "./history";
export { handleToolKey } from "./tool-keybinds"; export { handleToolKey } from "./tool-keybinds";
export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel"; export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";

View File

@@ -41,6 +41,10 @@ describe("layers panel input", () => {
}); });
}); });
test("does not resolve dropping a group into one of its descendants", () => {
expect(resolveLayerDrop({ document, sourceLayerId: "g", target: { artboardId: "a1", layer: group("c") }, verticalRatio: 0.5 })).toBeUndefined();
});
test("dispatches delete commands for selected layers", () => { test("dispatches delete commands for selected layers", () => {
const dispatched: unknown[] = []; const dispatched: unknown[] = [];
const consumed = handleDeleteSelectionKey({ const consumed = handleDeleteSelectionKey({

View File

@@ -41,6 +41,7 @@ export function resolveLayerDrop(options: {
const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id); const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id);
const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId); const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId);
if (!targetInfo || !sourceInfo || options.sourceLayerId === options.target.layer.id) return undefined; if (!targetInfo || !sourceInfo || options.sourceLayerId === options.target.layer.id) return undefined;
if (isDescendantLayer(sourceInfo.layer, options.target.layer.id)) return undefined;
const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio)); const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio));
const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66; const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66;
@@ -97,3 +98,11 @@ function findLayerInfo(layers: Layer[], layerId: LayerId, artboardId: ArtboardId
} }
return undefined; return undefined;
} }
function isDescendantLayer(layer: Layer, descendantLayerId: LayerId): boolean {
if (layer.type !== "group") return false;
for (const child of layer.children) {
if (child.id === descendantLayerId || isDescendantLayer(child, descendantLayerId)) return true;
}
return false;
}

View File

@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher"; import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry"; import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
import type { PointerInputEvent } from "./pointer"; import type { PointerInputEvent } from "./pointer";
@@ -65,9 +66,9 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[],
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> { function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> {
for (const layer of layers) { for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); const layerMask = getLayerMask(layer);
if (layerMask) ids.add(layerMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids); if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
} }
return ids; return ids;
} }

View File

@@ -5,11 +5,11 @@ export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
export type ImageTextureRenderer = { export type ImageTextureRenderer = {
syncAssets(assets: readonly Asset[]): void; syncAssets(assets: readonly Asset[]): void;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean; render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect): 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): boolean; renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number): boolean;
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean; renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect): boolean; renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
dispose(): void; dispose(): void;
}; };
@@ -39,12 +39,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
const positionLocation = gl.getAttribLocation(program, "a_position"); const positionLocation = gl.getAttribLocation(program, "a_position");
const texCoordLocation = gl.getAttribLocation(program, "a_texCoord"); const texCoordLocation = gl.getAttribLocation(program, "a_texCoord");
const samplerLocation = gl.getUniformLocation(program, "u_image"); const samplerLocation = gl.getUniformLocation(program, "u_image");
const opacityLocation = gl.getUniformLocation(program, "u_opacity");
const maskedProgram = createMaskedProgram(gl); const maskedProgram = createMaskedProgram(gl);
const maskedPositionLocation = gl.getAttribLocation(maskedProgram, "a_position"); const maskedPositionLocation = gl.getAttribLocation(maskedProgram, "a_position");
const maskedTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_texCoord"); const maskedTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_texCoord");
const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord"); const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord");
const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image"); const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image");
const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask"); const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask");
const maskedOpacityLocation = gl.getUniformLocation(maskedProgram, "u_opacity");
const maskRevealPreviewProgram = createMaskRevealPreviewProgram(gl); const maskRevealPreviewProgram = createMaskRevealPreviewProgram(gl);
const maskRevealPreviewPositionLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_position"); const maskRevealPreviewPositionLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_position");
const maskRevealPreviewTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_texCoord"); const maskRevealPreviewTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_texCoord");
@@ -69,8 +71,10 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
!texCoordBuffer || !texCoordBuffer ||
!maskTexCoordBuffer || !maskTexCoordBuffer ||
!samplerLocation || !samplerLocation ||
!opacityLocation ||
!maskedSamplerLocation || !maskedSamplerLocation ||
!maskedMaskSamplerLocation || !maskedMaskSamplerLocation ||
!maskedOpacityLocation ||
!maskRevealPreviewSamplerLocation || !maskRevealPreviewSamplerLocation ||
!maskRevealPreviewMaskSamplerLocation || !maskRevealPreviewMaskSamplerLocation ||
!maskRevealPreviewOpacityLocation || !maskRevealPreviewOpacityLocation ||
@@ -91,7 +95,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
} }
} }
}, },
render(asset, rect, clipRect) { render(asset, rect, clipRect, opacity = 1) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
@@ -107,6 +113,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(samplerLocation, 0); gl.uniform1i(samplerLocation, 0);
gl.uniform1f(opacityLocation, clampedOpacity);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); 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), gl.DYNAMIC_DRAW);
@@ -122,7 +129,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND); gl.disable(gl.BLEND);
return true; return true;
}, },
renderMasked(asset, rect, maskAsset, maskRect, clipRect) { renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined; const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
@@ -144,6 +153,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.activeTexture(gl.TEXTURE1); gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, maskTexture); gl.bindTexture(gl.TEXTURE_2D, maskTexture);
gl.uniform1i(maskedMaskSamplerLocation, 1); gl.uniform1i(maskedMaskSamplerLocation, 1);
gl.uniform1f(maskedOpacityLocation, clampedOpacity);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); 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, drawRect), gl.DYNAMIC_DRAW);
@@ -164,8 +174,8 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND); gl.disable(gl.BLEND);
return true; return true;
}, },
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect) { renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) {
const clampedOpacity = Math.max(0, Math.min(1, opacity)); const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
if (clampedOpacity <= 0) return true; if (clampedOpacity <= 0) return true;
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
@@ -248,7 +258,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND); gl.disable(gl.BLEND);
return true; return true;
}, },
renderTinted(asset, rect, color, clipRect) { renderTinted(asset, rect, color, clipRect, opacity = 1) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
@@ -264,7 +276,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(tintedSamplerLocation, 0); gl.uniform1i(tintedSamplerLocation, 0);
gl.uniform4fv(tintedColorLocation, color); gl.uniform4fv(tintedColorLocation, [color[0], color[1], color[2], color[3] * clampedOpacity]);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); 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), gl.DYNAMIC_DRAW);
@@ -401,6 +413,10 @@ function enablePremultipliedAlphaBlending(gl: WebGL2RenderingContext) {
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
} }
function clampOpacity(opacity: number) {
return Math.max(0, Math.min(1, opacity));
}
function fullTexCoords() { function fullTexCoords() {
return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]); return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]);
} }
@@ -442,10 +458,11 @@ function createProgram(gl: WebGL2RenderingContext) {
`#version 300 es `#version 300 es
precision mediump float; precision mediump float;
uniform sampler2D u_image; uniform sampler2D u_image;
uniform float u_opacity;
in vec2 v_texCoord; in vec2 v_texCoord;
out vec4 outColor; out vec4 outColor;
void main() { void main() {
outColor = texture(u_image, v_texCoord); outColor = texture(u_image, v_texCoord) * u_opacity;
}`, }`,
); );
const program = gl.createProgram(); const program = gl.createProgram();
@@ -691,6 +708,7 @@ function createMaskedProgram(gl: WebGL2RenderingContext) {
precision mediump float; precision mediump float;
uniform sampler2D u_image; uniform sampler2D u_image;
uniform sampler2D u_mask; uniform sampler2D u_mask;
uniform float u_opacity;
in vec2 v_texCoord; in vec2 v_texCoord;
in vec2 v_maskTexCoord; in vec2 v_maskTexCoord;
out vec4 outColor; out vec4 outColor;
@@ -699,7 +717,7 @@ function createMaskedProgram(gl: WebGL2RenderingContext) {
vec4 maskColor = texture(u_mask, v_maskTexCoord); vec4 maskColor = texture(u_mask, v_maskTexCoord);
float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722)); float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722));
float alpha = color.a * maskAlpha; float alpha = color.a * maskAlpha;
outColor = vec4(color.rgb * maskAlpha, alpha); outColor = vec4(color.rgb * maskAlpha, alpha) * u_opacity;
}`, }`,
); );
const program = gl.createProgram(); const program = gl.createProgram();

View File

@@ -2,6 +2,7 @@ import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes"; import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes";
import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state"; import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state";
import { clearScreenRect } from "./clear-rect"; import { clearScreenRect } from "./clear-rect";
@@ -12,6 +13,7 @@ import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45]; const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
const comparisonDividerColor: RgbaColor = [1, 1, 1, 0.9];
const maskRevealPreviewOpacity = 0.28; const maskRevealPreviewOpacity = 0.28;
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) { export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
@@ -22,7 +24,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
if (!artboard.visible) continue; if (!artboard.visible) continue;
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport); const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds; const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds)); forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds, 1));
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect); if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
} }
} }
@@ -39,17 +41,20 @@ function renderLayer(
imageTextureRenderer: ImageTextureRenderer, imageTextureRenderer: ImageTextureRenderer,
clipRect: ScreenRect, clipRect: ScreenRect,
maskLayerIds: ReadonlySet<string>, maskLayerIds: ReadonlySet<string>,
inheritedOpacity: number,
) { ) {
const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id; const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id;
const maskViewMode = editor.maskEdit?.viewMode ?? "composite"; const maskViewMode = editor.maskEdit?.viewMode ?? "composite";
const isolatedMaskView = isIsolatedMaskView(maskViewMode); const isolatedMaskView = isIsolatedMaskView(maskViewMode);
if (!layer.visible || maskLayerIds.has(layer.id)) return; if (!layer.visible || maskLayerIds.has(layer.id)) return;
const effectiveOpacity = inheritedOpacity * layer.opacity;
if (effectiveOpacity <= 0) return;
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect); const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect);
if (!effectiveClipRect) return; if (!effectiveClipRect) return;
if (layer.type === "group") { if (layer.type === "group") {
forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds)); forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds, effectiveOpacity));
return; return;
} }
@@ -60,33 +65,34 @@ function renderLayer(
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor); const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
const maskLayer = !editingMaskLayer && layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined; 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 !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined; const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined; const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId); const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite"; const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
if (asset && maskAsset && maskRect && activeMaskTarget) { if (asset && maskAsset && maskRect && activeMaskTarget) {
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return; if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return; if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect)) { if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) {
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect); imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
return; return;
} }
} }
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect)) { if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) {
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect); if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity);
return; return;
} }
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return; if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return;
const fallbackRect = intersectScreenRects(rect, effectiveClipRect); const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
if (!fallbackRect) return; if (!fallbackRect) return;
clearScreenRect(context, fallbackRect, imageLayerColor); clearScreenRect(context, fallbackRect, withOpacity(imageLayerColor, effectiveOpacity));
const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, effectiveClipRect); const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, effectiveClipRect);
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor); if (insetRect) clearScreenRect(context, insetRect, withOpacity(imageLayerInsetColor, effectiveOpacity));
} }
const emptyLayerIds = new Set<string>(); const emptyLayerIds = new Set<string>();
@@ -98,9 +104,10 @@ function resolveLayerClipRect(
layer: Layer, layer: Layer,
clipRect: ScreenRect, clipRect: ScreenRect,
): ScreenRect | undefined { ): ScreenRect | undefined {
if (!layer.clippingMask) return clipRect; const layerMask = getLayerMask(layer);
if (!layerMask?.enabled) return clipRect;
const maskBounds = resolveIndexedLayerBounds(documentIndex, layer.clippingMask.maskLayerId); const maskBounds = resolveIndexedLayerBounds(documentIndex, layerMask.maskLayerId);
if (!maskBounds) return clipRect; if (!maskBounds) return clipRect;
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport)); return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
@@ -118,6 +125,29 @@ function renderGenerationCandidatePreview(
clipRect: ScreenRect, clipRect: ScreenRect,
) { ) {
const rect = documentRectToScreenRect(context.canvas, generationCandidateBounds(candidate), editor.viewport); const rect = documentRectToScreenRect(context.canvas, generationCandidateBounds(candidate), editor.viewport);
const compareMode = editor.generation.compareMode ?? "result";
if (compareMode === "before") return;
if (compareMode === "split") {
const splitClipRect = intersectScreenRects(clipRect, {
x: rect.x + rect.w / 2,
y: rect.y,
w: rect.w / 2,
h: rect.h,
});
if (!splitClipRect) return;
imageTextureRenderer.render(generationCandidateAsset(candidate), rect, splitClipRect);
const divider = intersectScreenRects(clipRect, {
x: rect.x + rect.w / 2 - 1,
y: rect.y,
w: 2,
h: rect.h,
});
if (divider) clearScreenRect(context, divider, comparisonDividerColor);
return;
}
imageTextureRenderer.render(generationCandidateAsset(candidate), rect, clipRect); imageTextureRenderer.render(generationCandidateAsset(candidate), rect, clipRect);
} }
@@ -162,3 +192,7 @@ function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefi
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
} }
function withOpacity(color: RgbaColor, opacity: number): RgbaColor {
return [color[0], color[1], color[2], color[3] * opacity];
}

View File

@@ -5,6 +5,7 @@ import { commandIds } from "@commands/ids";
import { BottomControlsIsland } from "./BottomControlsIsland"; import { BottomControlsIsland } from "./BottomControlsIsland";
import { brushUnavailableHint } from "./canvas/brush"; import { brushUnavailableHint } from "./canvas/brush";
import { CanvasViewport } from "./CanvasViewport"; import { CanvasViewport } from "./CanvasViewport";
import { CommandPalette } from "./CommandPalette";
import { GenerateSheet } from "./GenerateSheet"; import { GenerateSheet } from "./GenerateSheet";
import { LayersSheet } from "./LayersSheet"; import { LayersSheet } from "./LayersSheet";
import { ShortcutsDisplay } from "./ShortcutsDisplay"; import { ShortcutsDisplay } from "./ShortcutsDisplay";
@@ -12,7 +13,7 @@ import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { AppState } from "@editor/state"; import type { AppState } from "@editor/state";
import type { ToolId } from "@editor/tools"; import type { ToolId } from "@editor/tools";
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index"; import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { shallowEqual, useAppState } from "./useAppState"; import { shallowEqual, useAppState } from "./useAppState";
import { downloadArtboardPng } from "./exportArtboardPng"; import { downloadArtboardPng } from "./exportArtboardPng";
import { useImageImport } from "./useImageImport"; import { useImageImport } from "./useImageImport";
@@ -25,7 +26,7 @@ export type AppProps = {
export function App({ app }: AppProps) { export function App({ app }: AppProps) {
const shellState = useAppState(app.store, selectAppShellState, shallowEqual); const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
const { document, selection, viewport, tools, generation, transformSession, maskEdit } = shellState; const { document, selection, viewport, tools, generation, commandPalette, transformSession, maskEdit } = shellState;
const viewportActivityIsland = useViewportActivityIsland(viewport); const viewportActivityIsland = useViewportActivityIsland(viewport);
const imageImport = useImageImport(app.store); const imageImport = useImageImport(app.store);
const [layersOpen, setLayersOpen] = useState(false); const [layersOpen, setLayersOpen] = useState(false);
@@ -65,6 +66,15 @@ export function App({ app }: AppProps) {
else openGenerate(); else openGenerate();
}, [closeGenerate, generateOpen, openGenerate]); }, [closeGenerate, generateOpen, openGenerate]);
const openLayers = useCallback(() => {
closeGenerate();
setLayersOpen(true);
}, [closeGenerate]);
const closeLayers = useCallback(() => {
setLayersOpen(false);
}, []);
const toggleLayers = useCallback(() => { const toggleLayers = useCallback(() => {
const nextOpen = !layersOpen; const nextOpen = !layersOpen;
if (nextOpen) closeGenerate(); if (nextOpen) closeGenerate();
@@ -73,6 +83,21 @@ export function App({ app }: AppProps) {
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
const keybindEvent = keybindEventFromKeyboardEvent(event);
const paletteConsumed = handleCommandPaletteKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (paletteConsumed) {
event.preventDefault();
return;
}
if (app.store.getState().editor.commandPalette.open) {
if (event.key === "Escape") {
app.store.dispatch(commandIds.commandPaletteClose, undefined);
event.preventDefault();
}
return;
}
const target = event.target; const target = event.target;
const editableTarget = const editableTarget =
target instanceof HTMLElement && target instanceof HTMLElement &&
@@ -80,7 +105,7 @@ export function App({ app }: AppProps) {
if (editableTarget) return; if (editableTarget) return;
const historyConsumed = handleHistoryKey({ event: keybindEventFromKeyboardEvent(event), dispatch: app.store.dispatch }); const historyConsumed = handleHistoryKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (historyConsumed) { if (historyConsumed) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -95,7 +120,6 @@ export function App({ app }: AppProps) {
} }
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
const keybindEvent = keybindEventFromKeyboardEvent(event);
const toolConsumed = handleToolKey({ event: keybindEvent, dispatch: app.store.dispatch }); const toolConsumed = handleToolKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (toolConsumed) { if (toolConsumed) {
@@ -126,6 +150,21 @@ export function App({ app }: AppProps) {
return ( return (
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground"> <main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
{imageImport.input} {imageImport.input}
<CommandPalette
state={commandPalette}
document={document}
selection={selection}
viewport={viewport}
tools={tools}
generation={generation}
layersOpen={layersOpen}
activeArtboard={activeArtboard}
dispatch={app.store.dispatch}
openFilePicker={imageImport.openFilePicker}
openGenerate={openGenerate}
openLayers={openLayers}
closeLayers={closeLayers}
/>
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl"> <header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
<div className="pointer-events-auto flex items-center gap-2"> <div className="pointer-events-auto flex items-center gap-2">
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}> <button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
@@ -201,6 +240,7 @@ type AppShellState = {
viewport: AppState["editor"]["viewport"]; viewport: AppState["editor"]["viewport"];
tools: AppState["editor"]["tools"]; tools: AppState["editor"]["tools"];
generation: AppState["editor"]["generation"]; generation: AppState["editor"]["generation"];
commandPalette: AppState["editor"]["commandPalette"];
transformSession: AppState["editor"]["transformSession"]; transformSession: AppState["editor"]["transformSession"];
maskEdit: AppState["editor"]["maskEdit"]; maskEdit: AppState["editor"]["maskEdit"];
}; };
@@ -212,6 +252,7 @@ function selectAppShellState(state: AppState): AppShellState {
viewport: state.editor.viewport, viewport: state.editor.viewport,
tools: state.editor.tools, tools: state.editor.tools,
generation: state.editor.generation, generation: state.editor.generation,
commandPalette: state.editor.commandPalette,
transformSession: state.editor.transformSession, transformSession: state.editor.transformSession,
maskEdit: state.editor.maskEdit, maskEdit: state.editor.maskEdit,
}; };

617
view/CommandPalette.tsx Normal file
View File

@@ -0,0 +1,617 @@
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from "react";
import {
ArrowDown,
ArrowUp,
Command,
CornersOut,
Cursor,
DownloadSimple,
DropHalf,
Eraser,
Eye,
EyeSlash,
FolderOpen,
FolderPlus,
Hand,
Lock,
LockOpen,
MagicWand,
MagnifyingGlass,
Minus,
PaintBrush,
Plus,
Sparkle,
Stack,
Trash,
} from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { Artboard } from "@core/artboard";
import type { ImageDocument } from "@core/document";
import type { GenerationCompareMode, GenerationState, SelectionState, ViewportState, CommandPaletteState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools";
import { createDocumentReadIndex, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
import { downloadArtboardPng } from "./exportArtboardPng";
import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "./layerActions";
import { labelForTool } from "./toolLabels";
export type CommandPaletteProps = {
state: CommandPaletteState;
document: ImageDocument;
selection: SelectionState;
viewport: ViewportState;
tools: ToolState;
generation: GenerationState;
layersOpen: boolean;
activeArtboard?: Artboard;
dispatch: AppStore["dispatch"];
openFilePicker: () => void;
openGenerate: () => void;
openLayers: () => void;
closeLayers: () => void;
};
type PaletteItem = {
id: string;
title: string;
section: string;
subtitle?: string;
keywords?: string[];
disabled?: boolean;
icon: ReactNode;
run: () => void;
};
export function CommandPalette({
state,
document,
selection,
viewport,
tools,
generation,
layersOpen,
activeArtboard,
dispatch,
openFilePicker,
openGenerate,
openLayers,
closeLayers,
}: CommandPaletteProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
const activeArtboardId = selection.artboardId ?? document.artboards[0]?.id;
const selectedLayerId = selection.layerIds[0];
const selectedLayer = selectedLayerId ? documentIndex.layerInfoById.get(selectedLayerId) : undefined;
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
const canUngroup = selectedLayer?.layer.type === "group";
const items = useMemo(
() =>
createPaletteItems({
document,
documentIndex,
activeArtboard,
activeArtboardId,
selectedLayer,
canGroup,
canUngroup,
selection,
viewport,
tools,
generation,
layersOpen,
dispatch,
openFilePicker,
openGenerate,
openLayers,
closeLayers,
}),
[
activeArtboard,
activeArtboardId,
canGroup,
canUngroup,
closeLayers,
dispatch,
document,
documentIndex,
generation,
layersOpen,
openFilePicker,
openGenerate,
openLayers,
selectedLayer,
selection,
tools,
viewport,
],
);
const filteredItems = useMemo(() => filterItems(items, state.query), [items, state.query]);
const activeIndex = filteredItems.length > 0 ? Math.min(state.selectedIndex, filteredItems.length - 1) : 0;
useEffect(() => {
if (!state.open) return;
const frame = window.requestAnimationFrame(() => inputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [state.open]);
const close = useCallback(() => {
dispatch(commandIds.commandPaletteClose, undefined);
}, [dispatch]);
const runItem = useCallback(
(item: PaletteItem | undefined) => {
if (!item || item.disabled) return;
dispatch(commandIds.commandPaletteClose, undefined);
item.run();
},
[dispatch],
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
const nextIndex = filteredItems.length > 0 ? (activeIndex + 1) % filteredItems.length : 0;
dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: nextIndex });
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
const nextIndex = filteredItems.length > 0 ? (activeIndex - 1 + filteredItems.length) % filteredItems.length : 0;
dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: nextIndex });
return;
}
if (event.key === "Enter") {
event.preventDefault();
runItem(filteredItems[activeIndex]);
}
},
[activeIndex, close, dispatch, filteredItems, runItem],
);
if (!state.open) return null;
return (
<div
className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-black/20 px-3 pt-[10vh] backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Command palette"
onMouseDown={(event) => {
if (event.target === event.currentTarget) close();
}}
>
<div className="w-full max-w-2xl overflow-hidden rounded-2xl bg-slate-950/[0.92] text-white shadow-2xl ring-1 ring-white/[0.12]">
<div className="flex h-16 items-center gap-3 border-b border-white/10 px-4">
<MagnifyingGlass size={22} className="shrink-0 text-white/45" />
<input
ref={inputRef}
value={state.query}
onChange={(event) => dispatch(commandIds.commandPaletteSetQuery, { query: event.currentTarget.value })}
onKeyDown={handleInputKeyDown}
placeholder="Search commands"
aria-label="Search commands"
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
/>
<span className="hidden items-center gap-1 rounded-full bg-white/[0.07] px-2 py-1 text-xs font-semibold text-white/45 sm:inline-flex">
<Command size={14} weight="bold" /> K
</span>
</div>
<div className="subtle-scrollbar max-h-[min(32rem,70vh)] overflow-auto p-2">
{filteredItems.length > 0 ? (
filteredItems.map((item, index) => {
const active = index === activeIndex;
return (
<button
key={item.id}
type="button"
className={paletteItemClass(active, Boolean(item.disabled))}
disabled={item.disabled}
onMouseEnter={() => dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: index })}
onClick={() => runItem(item)}
>
<span className={paletteIconClass(active)}>{item.icon}</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold">{item.title}</span>
<span className={paletteSubtitleClass(active)}>{item.subtitle ?? item.section}</span>
</span>
<span className={paletteSectionClass(active)}>
{item.section}
</span>
</button>
);
})
) : (
<div className="grid h-28 place-items-center text-sm text-white/45">No commands</div>
)}
</div>
</div>
</div>
);
}
function createPaletteItems(options: {
document: ImageDocument;
documentIndex: DocumentReadIndex;
activeArtboard?: Artboard;
activeArtboardId?: string;
selectedLayer?: IndexedLayerInfo;
canGroup: boolean;
canUngroup: boolean;
selection: SelectionState;
viewport: ViewportState;
tools: ToolState;
generation: GenerationState;
layersOpen: boolean;
dispatch: AppStore["dispatch"];
openFilePicker: () => void;
openGenerate: () => void;
openLayers: () => void;
closeLayers: () => void;
}): PaletteItem[] {
const {
document,
documentIndex,
activeArtboard,
activeArtboardId,
selectedLayer,
canGroup,
canUngroup,
selection,
viewport,
tools,
generation,
layersOpen,
dispatch,
openFilePicker,
openGenerate,
openLayers,
closeLayers,
} = options;
const hasCandidates = generation.candidates.length > 0;
const items: PaletteItem[] = [];
items.push(
...availableToolIds.map((tool) => ({
id: `tool-${tool}`,
section: "Tools",
title: `Switch to ${labelForTool(tool)}`,
subtitle: tool === tools.activeTool ? "Current tool" : undefined,
keywords: [tool],
icon: toolIcon(tool),
run: () => {
if (tool === "generate") openGenerate();
else dispatch(commandIds.toolSetActive, { tool });
},
})),
);
items.push(
{
id: "import-image",
section: "File",
title: "Import image",
subtitle: "Add an image as a layer",
keywords: ["open", "file", "layer"],
icon: <FolderOpen size={20} />,
run: openFilePicker,
},
{
id: "export-artboard",
section: "File",
title: "Export artboard as PNG",
subtitle: activeArtboard ? activeArtboard.name : "No artboard selected",
keywords: ["download", "png"],
disabled: !activeArtboard,
icon: <DownloadSimple size={20} />,
run: () => {
if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets);
},
},
);
items.push(
{
id: layersOpen ? "close-layers" : "open-layers",
section: "Layers",
title: layersOpen ? "Close layers panel" : "Open layers panel",
keywords: ["panel", "stack"],
icon: <Stack size={20} weight={layersOpen ? "fill" : "regular"} />,
run: layersOpen ? closeLayers : openLayers,
},
{
id: "add-artboard",
section: "Layers",
title: "Add artboard",
icon: <Plus size={20} />,
run: () => addArtboard(document, dispatch),
},
{
id: "add-empty-layer",
section: "Layers",
title: "Add empty layer",
subtitle: activeArtboardId ? undefined : "No artboard available",
keywords: ["new", "raster"],
disabled: !activeArtboardId,
icon: <Plus size={20} />,
run: () => {
if (activeArtboardId) addEmptyLayer(document, activeArtboardId, selectedLayer, dispatch);
},
},
{
id: "add-group",
section: "Layers",
title: "Add group",
subtitle: activeArtboardId ? undefined : "No artboard available",
disabled: !activeArtboardId,
icon: <FolderPlus size={20} />,
run: () => {
if (activeArtboardId) addGroupLayer(activeArtboardId, dispatch);
},
},
{
id: "group-selection",
section: "Layers",
title: "Group selected layers",
subtitle: canGroup ? `${selection.layerIds.length} selected` : "Select one or more layers",
disabled: !canGroup,
icon: <Stack size={20} />,
run: () => {
if (selection.artboardId) groupLayers(selection.artboardId, selection.layerIds, dispatch);
},
},
{
id: "ungroup-selection",
section: "Layers",
title: "Ungroup selected group",
subtitle: selectedLayer?.layer.name,
disabled: !canUngroup || !selectedLayer,
icon: <Stack size={20} weight="fill" />,
run: () => {
if (selectedLayer?.layer.type === "group") dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id });
},
},
{
id: "move-layer-up",
section: "Layers",
title: "Move selected layer up",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: <ArrowUp size={20} />,
run: () => {
if (selectedLayer) moveLayer(documentIndex, selectedLayer, -1, dispatch);
},
},
{
id: "move-layer-down",
section: "Layers",
title: "Move selected layer down",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: <ArrowDown size={20} />,
run: () => {
if (selectedLayer) moveLayer(documentIndex, selectedLayer, 1, dispatch);
},
},
{
id: "toggle-layer-visible",
section: "Layers",
title: selectedLayer?.layer.visible === false ? "Show selected layer" : "Hide selected layer",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: selectedLayer?.layer.visible === false ? <Eye size={20} /> : <EyeSlash size={20} />,
run: () => {
if (selectedLayer) dispatch(commandIds.documentSetLayerVisible, { layerId: selectedLayer.layer.id, visible: !selectedLayer.layer.visible });
},
},
{
id: "toggle-layer-lock",
section: "Layers",
title: selectedLayer?.layer.locked ? "Unlock selected layer" : "Lock selected layer",
subtitle: selectedLayer?.layer.name,
disabled: !selectedLayer,
icon: selectedLayer?.layer.locked ? <LockOpen size={20} /> : <Lock size={20} />,
run: () => {
if (selectedLayer) dispatch(commandIds.documentSetLayerLocked, { layerId: selectedLayer.layer.id, locked: !selectedLayer.layer.locked });
},
},
{
id: "delete-selection",
section: "Layers",
title: selectedLayer ? "Delete selected layer" : "Delete selected artboard",
subtitle: selectedLayer?.layer.name ?? activeArtboard?.name,
disabled: !selectedLayer && !selection.artboardId,
icon: <Trash size={20} />,
run: () => deleteSelection(selection, selectedLayer, dispatch),
},
);
items.push(
{
id: "open-generate",
section: "Generate",
title: "Open generate panel",
subtitle: tools.activeTool === "generate" ? "Current tool" : undefined,
keywords: ["ai"],
icon: <Sparkle size={20} />,
run: openGenerate,
},
...generateModeItems.map((modeItem) => ({
id: `generate-mode-${modeItem.mode}`,
section: "Generate",
title: modeItem.title,
subtitle: tools.generate.mode === modeItem.mode ? "Current mode" : undefined,
keywords: ["mode", modeItem.mode],
icon: <Sparkle size={20} />,
run: () => {
openGenerate();
dispatch(commandIds.toolSetGenerateSettings, { mode: modeItem.mode });
},
})),
{
id: "generate-random-seed",
section: "Generate",
title: "Use random seed",
subtitle: tools.generate.seed === -1 ? "Current seed" : `Seed ${tools.generate.seed}`,
keywords: ["seed"],
icon: <Sparkle size={20} />,
run: () => dispatch(commandIds.toolSetGenerateSettings, { seed: -1 }),
},
{
id: "clear-generation-candidates",
section: "Generate",
title: "Clear candidates",
subtitle: hasCandidates ? `${generation.candidates.length} candidate${generation.candidates.length === 1 ? "" : "s"}` : "No candidates",
disabled: !hasCandidates,
icon: <Trash size={20} />,
run: () => dispatch(commandIds.generationClearCandidates, undefined),
},
...generationCompareItems.map((compareItem) => ({
id: `generation-compare-${compareItem.mode}`,
section: "Generate",
title: compareItem.title,
subtitle: generation.compareMode === compareItem.mode ? "Current compare mode" : undefined,
disabled: !hasCandidates,
icon: <Sparkle size={20} />,
run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }),
})),
);
items.push(
{
id: "zoom-in",
section: "Zoom",
title: "Zoom in",
subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Plus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }),
},
{
id: "zoom-out",
section: "Zoom",
title: "Zoom out",
subtitle: `${Math.round(viewport.zoom * 100)}%`,
icon: <Minus size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }),
},
{
id: "zoom-100",
section: "Zoom",
title: "Zoom to 100%",
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }),
},
{
id: "fit-artboard",
section: "Zoom",
title: "Fit artboard",
subtitle: activeArtboard?.name,
disabled: !activeArtboard,
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportFitArtboard, undefined),
},
);
items.push(
{
id: "debug-reset-viewport",
section: "Debug",
title: "Reset viewport",
icon: <CornersOut size={20} />,
run: () => dispatch(commandIds.viewportReset, undefined),
},
{
id: "debug-clear-selection",
section: "Debug",
title: "Clear selection",
subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected",
disabled: selection.layerIds.length === 0 && !selection.artboardId,
icon: <Cursor size={20} />,
run: () => dispatch(commandIds.selectionClear, undefined),
},
{
id: "debug-clear-candidates",
section: "Debug",
title: "Clear generation state",
disabled: !hasCandidates,
icon: <Trash size={20} />,
run: () => dispatch(commandIds.generationClearCandidates, undefined),
},
);
return items;
}
const generateModeItems: Array<{ mode: GenerateMode; title: string }> = [
{ mode: "text-to-image", title: "Text-to-image mode" },
{ mode: "image-to-image", title: "Image-to-image mode" },
{ mode: "inpaint", title: "Inpaint mode" },
{ mode: "outpaint", title: "Outpaint mode" },
];
const generationCompareItems: Array<{ mode: GenerationCompareMode; title: string }> = [
{ mode: "result", title: "Show result" },
{ mode: "before", title: "Show before" },
{ mode: "split", title: "Split compare" },
];
function filterItems(items: PaletteItem[], query: string) {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return items;
return items.filter((item) => {
const haystack = [item.title, item.subtitle, item.section, ...(item.keywords ?? [])].filter(Boolean).join(" ").toLowerCase();
return terms.every((term) => haystack.includes(term));
});
}
function toolIcon(tool: ToolId) {
switch (tool) {
case "select":
return <Cursor size={20} />;
case "generate":
return <Sparkle size={20} />;
case "brush":
return <PaintBrush size={20} />;
case "eraser":
return <Eraser size={20} />;
case "chromaKey":
return <DropHalf size={20} />;
case "magicWand":
return <MagicWand size={20} />;
case "pan":
return <Hand size={20} />;
}
}
function paletteItemClass(active: boolean, disabled: boolean) {
const base = "flex min-h-14 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition focus:outline-none";
if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`;
return active ? `${base} bg-white text-black` : `${base} text-white/75 hover:bg-white/[0.08] hover:text-white`;
}
function paletteIconClass(active: boolean) {
return active
? "grid size-10 shrink-0 place-items-center rounded-xl bg-black/10 text-black/60"
: "grid size-10 shrink-0 place-items-center rounded-xl bg-white/[0.06] text-white/60";
}
function paletteSubtitleClass(active: boolean) {
return active ? "block truncate text-xs text-black/55" : "block truncate text-xs text-white/40";
}
function paletteSectionClass(active: boolean) {
return active
? "shrink-0 rounded-full bg-black/10 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-black/45"
: "shrink-0 rounded-full bg-white/[0.06] px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-white/35";
}

View File

@@ -4,12 +4,14 @@ import { commandIds } from "@commands/ids";
import type { Asset } from "@core/asset"; import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId } from "@core/id"; import type { ArtboardId } from "@core/id";
import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes"; import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes";
import type { MaskEditState, SelectionState } from "@editor/state"; import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import { resolveLayerDrop } from "@input/index"; import { resolveLayerDrop } from "@input/index";
import { downloadArtboardPng } from "./exportArtboardPng"; import { downloadArtboardPng } from "./exportArtboardPng";
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "./layerActions";
import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster"; import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster";
export type LayersSheetProps = { export type LayersSheetProps = {
@@ -75,17 +77,17 @@ function LayersSheetBody({
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span> <span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="min-w-0 flex-1 text-center">Artboard</span> <span className="min-w-0 flex-1 text-center">Artboard</span>
</button> </button>
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addLayer(document, selectedArtboardId, selectedLayer, dispatch)}> <button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addEmptyLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span> <span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="min-w-0 flex-1 text-center">Layer</span> <span className="min-w-0 flex-1 text-center">Layer</span>
</button> </button>
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroup(selectedArtboardId, dispatch)}> <button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
<FolderPlus size={24} /> <FolderPlus size={24} />
</button> </button>
</div> </div>
</header> </header>
<div className="mb-4 grid grid-cols-5 gap-2"> <div className="mb-4 grid grid-cols-5 gap-2">
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupSelection(selection.artboardId, selection.layerIds, dispatch)}> <button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupLayers(selection.artboardId, selection.layerIds, dispatch)}>
<Stack size={24} /> <Stack size={24} />
</button> </button>
<button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}> <button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
@@ -220,10 +222,11 @@ function LayerRow({
const selected = selectedLayerIds.includes(layer.id); const selected = selectedLayerIds.includes(layer.id);
const layerInfo = documentIndex.layerInfoById.get(layer.id); const layerInfo = documentIndex.layerInfoById.get(layer.id);
const maskLayer = layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined; 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 maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask); const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId); const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
const rowPadding = 12 + depth * 16; const rowPadding = 12 + depth * 16;
return ( return (
@@ -273,7 +276,7 @@ function LayerRow({
{layer.name} {layer.name}
</button> </button>
)} )}
{layer.clippingMask ? ( {layerMask ? (
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}> <span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<Stack size={13} weight="fill" /> Mask <Stack size={13} weight="fill" /> Mask
</span> </span>
@@ -290,7 +293,7 @@ function LayerRow({
{layer.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />} {layer.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />}
</button> </button>
</div> </div>
{layer.clippingMask ? ( {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]"> <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" /> <Stack size={24} weight="fill" className="shrink-0" />
<span className="min-w-28 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span> <span className="min-w-28 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span>
@@ -303,7 +306,7 @@ function LayerRow({
onClick={() => onClick={() =>
editingMask editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined) ? dispatch(commandIds.toolExitMaskEdit, undefined)
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId }) : dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId })
} }
> >
{editingMask ? "Done" : "Edit"} {editingMask ? "Done" : "Edit"}
@@ -313,7 +316,7 @@ function LayerRow({
className={maskActionButtonClass()} className={maskActionButtonClass()}
title="Paint reveal" title="Paint reveal"
onClick={() => { onClick={() => {
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId }); dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId });
dispatch(commandIds.toolSetActive, { tool: "brush" }); dispatch(commandIds.toolSetActive, { tool: "brush" });
}} }}
> >
@@ -324,7 +327,7 @@ function LayerRow({
className={maskActionButtonClass()} className={maskActionButtonClass()}
title="Paint hide" title="Paint hide"
onClick={() => { onClick={() => {
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId }); dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId });
dispatch(commandIds.toolSetActive, { tool: "eraser" }); dispatch(commandIds.toolSetActive, { tool: "eraser" });
}} }}
> >
@@ -468,46 +471,6 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
); );
} }
function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
const layer = layerInfo.layer;
if (layer.type === "group") return;
const asset = documentIndex.assetById.get(layer.assetId);
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
if (!asset || !bounds) return;
const assetId = crypto.randomUUID();
const maskLayerId = crypto.randomUUID();
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
dispatch(commandIds.documentAddLayerMask, {
layerId: layer.id,
asset: {
id: assetId,
name: `${layer.name} Mask`,
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
maskLayer: {
id: maskLayerId,
type: "raster",
name: `${layer.name} Mask`,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: { x: bounds.x, y: bounds.y },
scale: { x: bounds.w / width, y: bounds.h / height },
rotation: layer.transform.rotation,
},
},
});
}
function dropLayer( function dropLayer(
document: ImageDocument, document: ImageDocument,
sourceLayerId: string, sourceLayerId: string,
@@ -525,110 +488,6 @@ function dropLayer(
if (command) dispatch(commandIds.documentMoveLayer, command); if (command) dispatch(commandIds.documentMoveLayer, command);
} }
function deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
if (selectedLayer) {
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
return;
}
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
}
function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
const index = document.artboards.length + 1;
dispatch(commandIds.documentAddArtboard, {
id: crypto.randomUUID(),
name: `Artboard ${index}`,
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
});
}
function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
if (!artboard) return;
const assetId = crypto.randomUUID();
const layerId = crypto.randomUUID();
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
dispatch(commandIds.documentAddAsset, {
asset: {
id: assetId,
name: "Empty Layer",
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
});
dispatch(commandIds.documentAddRasterLayer, {
artboardId,
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
layer: {
id: layerId,
type: "raster",
name: "Layer",
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
},
});
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
}
function addGroup(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
}
function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
}
function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
const siblings = info.siblings;
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
const blocks = siblings.flatMap((layer, index) => {
if (maskLayerIds.has(layer.id)) return [];
const maskIndex = layer.clippingMask ? siblings.findIndex((candidate) => candidate.id === layer.clippingMask?.maskLayerId) : -1;
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
return [{ layerId: layer.id, start, end, size: end - start + 1 }];
});
const currentBlockIndex = blocks.findIndex((block) => block.layerId === info.layer.id);
const currentBlock = blocks[currentBlockIndex];
const targetBlock = blocks[currentBlockIndex + direction];
if (!currentBlock || !targetBlock) return;
const insertionIndex = direction === -1 ? targetBlock.start : targetBlock.end + 1;
const removedBeforeInsertion = currentBlock.end < insertionIndex ? currentBlock.size : currentBlock.start < insertionIndex ? insertionIndex - currentBlock.start : 0;
dispatch(commandIds.documentMoveLayer, {
layerId: info.layer.id,
toArtboardId: info.artboardId,
toParentGroupId: info.parentGroupId,
toIndex: insertionIndex - removedBeforeInsertion,
});
}
const emptyLayerIds = new Set<string>();
function createGroup(name: string): Extract<Layer, { type: "group" }> {
return {
id: crypto.randomUUID(),
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [],
};
}
function toolbarButtonClass() { function toolbarButtonClass() {
return "inline-flex size-12 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"; return "inline-flex size-12 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
} }

View File

@@ -18,6 +18,7 @@ const shortcuts: Shortcut[] = [
{ keys: ["P"], label: "Pan" }, { keys: ["P"], label: "Pan" },
{ keys: ["Space"], label: "Hold to pan" }, { keys: ["Space"], label: "Hold to pan" },
{ keys: ["L"], label: "Layers" }, { keys: ["L"], label: "Layers" },
{ keys: ["⌘", "K"], label: "Commands" },
{ keys: ["⌘", "O"], label: "Open image" }, { keys: ["⌘", "O"], label: "Open image" },
{ keys: ["⌘", "Z"], label: "Undo" }, { keys: ["⌘", "Z"], label: "Undo" },
{ keys: ["⇧", "⌘", "Z"], label: "Redo" }, { keys: ["⇧", "⌘", "Z"], label: "Redo" },

View File

@@ -3,6 +3,7 @@ import { DropHalf } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import type { ChromaKeySettings } from "@editor/tools"; import type { ChromaKeySettings } from "@editor/tools";
@@ -160,7 +161,8 @@ function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionSta
if (!layer || layer.type === "group") return undefined; if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined; 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 !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
} }

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { GenerationCandidate, GenerationState, SelectionState, ViewportState } from "@editor/state"; import type { GenerationCandidate, GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools"; import type { GenerateSettings } from "@editor/tools";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import { createMaskedPixelReplacementSource } from "../generate/candidateActions"; import { createMaskedPixelReplacementSource } from "../generate/candidateActions";
@@ -62,6 +62,7 @@ export function GenerateActionControls({ document, selection, viewport, settings
<CandidateControls <CandidateControls
document={document} document={document}
candidate={candidate} candidate={candidate}
compareMode={generation.compareMode ?? "result"}
settings={settings} settings={settings}
busy={busy} busy={busy}
setBusy={setBusy} setBusy={setBusy}
@@ -100,6 +101,7 @@ function CandidatePicker({ generation, dispatch }: { generation: GenerationState
function CandidateControls({ function CandidateControls({
document, document,
candidate, candidate,
compareMode,
settings, settings,
busy, busy,
setBusy, setBusy,
@@ -108,6 +110,7 @@ function CandidateControls({
}: { }: {
document: ImageDocument; document: ImageDocument;
candidate: GenerationCandidate; candidate: GenerationCandidate;
compareMode: GenerationCompareMode;
settings: GenerateSettings; settings: GenerateSettings;
busy?: string; busy?: string;
setBusy: (busy: string | undefined) => void; setBusy: (busy: string | undefined) => void;
@@ -128,6 +131,7 @@ function CandidateControls({
<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-full bg-white/[0.04] px-2 py-1 ring-1 ring-white/[0.05]">
<CandidatePreview candidate={candidate} /> <CandidatePreview candidate={candidate} />
<span className="px-2 text-xs font-medium text-white/55">Seed {candidate.seed}</span> <span className="px-2 text-xs font-medium text-white/55">Seed {candidate.seed}</span>
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" busy={busy === "Regenerate"} onClick={() => rerun("Regenerate", candidate.settings)} /> <CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" busy={busy === "Regenerate"} onClick={() => rerun("Regenerate", candidate.settings)} />
<CandidateButton <CandidateButton
disabled={disabled} disabled={disabled}
@@ -138,11 +142,11 @@ function CandidateControls({
/> />
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} /> <CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} /> <CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
<CandidateButton disabled={disabled} label="Apply layer" title="Apply candidate as a normal layer" onClick={() => applyCandidateAsLayer(candidate, false, dispatch)} /> <CandidateButton disabled={disabled} label="Accept layer" title="Accept candidate as a normal layer" onClick={() => applyCandidateAsLayer(candidate, false, dispatch)} />
<CandidateButton <CandidateButton
disabled={disabled} disabled={disabled}
label="Apply + refine" label="Accept + mask"
title="Apply candidate as a layer with a fresh refinement mask" title="Accept candidate as a layer with a fresh refinement mask"
busy={busy === "Refine"} busy={busy === "Refine"}
onClick={() => { onClick={() => {
setBusy("Refine"); setBusy("Refine");
@@ -152,10 +156,10 @@ function CandidateControls({
.finally(() => setBusy(undefined)); .finally(() => setBusy(undefined));
}} }}
/> />
<CandidateButton disabled={disabled} label="Stack variant" title="Stack candidate as another variant layer" onClick={() => applyCandidateAsLayer(candidate, true, dispatch)} /> <CandidateButton disabled={disabled} label="Accept variant" title="Stack candidate as another variant layer" onClick={() => applyCandidateAsLayer(candidate, true, dispatch)} />
<CandidateButton <CandidateButton
disabled={disabled || !candidate.inpaint} disabled={disabled || !candidate.inpaint}
label="Replace" label="Accept replace"
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"} title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
busy={busy === "Replace"} busy={busy === "Replace"}
onClick={() => { onClick={() => {
@@ -183,6 +187,36 @@ function CandidateControls({
); );
} }
function CandidateCompareControls({ compareMode, disabled, dispatch }: { compareMode: GenerationCompareMode; disabled: boolean; dispatch: AppStore["dispatch"] }) {
return (
<span className="flex items-center gap-1 rounded-full bg-black/20 p-1" aria-label="Compare candidate">
{generationCompareOptions.map((option) => {
const active = compareMode === option.mode;
return (
<button
key={option.mode}
type="button"
className={`h-7 rounded-full px-2 text-[0.7rem] font-semibold transition ${
active ? "bg-white text-black" : "text-white/55 hover:bg-white/10 hover:text-white"
} disabled:pointer-events-none disabled:opacity-35`}
disabled={disabled}
title={option.title}
onClick={() => dispatch(commandIds.generationSetCompareMode, { mode: option.mode })}
>
{option.label}
</button>
);
})}
</span>
);
}
const generationCompareOptions: Array<{ mode: GenerationCompareMode; label: string; title: string }> = [
{ mode: "result", label: "After", title: "Show the generated result over the document" },
{ mode: "before", label: "Before", title: "Hide the generated result and show the source document" },
{ mode: "split", label: "Split", title: "Compare source on the left with result on the right" },
];
function CandidatePreview({ candidate }: { candidate: GenerationCandidate }) { function CandidatePreview({ candidate }: { candidate: GenerationCandidate }) {
if (!candidate.inputImage) { if (!candidate.inputImage) {
return <img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />; return <img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />;

View File

@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry"; import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { resolveTransformTargetBounds } from "@editor/transform-targets";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import type { EditorState } from "@editor/state"; import type { EditorState } from "@editor/state";
@@ -41,7 +42,8 @@ function resolveTarget(document: ImageDocument, editor: EditorState) {
if (!layer || layer.type === "group") return undefined; if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined; 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 !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined; return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
} }

View File

@@ -177,6 +177,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
}, },
], ],
selectedCandidateId: "candidate", selectedCandidateId: "candidate",
compareMode: "result",
}, },
}, },
}), }),

View File

@@ -2,6 +2,7 @@ import type { Artboard } from "@core/artboard";
import type { Asset } from "@core/asset"; import type { Asset } from "@core/asset";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
const width = Math.max(1, Math.round(artboard.bounds.w)); const width = Math.max(1, Math.round(artboard.bounds.w));
@@ -41,8 +42,9 @@ async function drawLayer(
) { ) {
if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return; if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return;
if (!options.ignoreOwnMask && layer.clippingMask) { const layerMask = getLayerMask(layer);
const maskLayer = findLayer(layerTree, layer.clippingMask.maskLayerId); if (!options.ignoreOwnMask && layerMask?.enabled) {
const maskLayer = findLayer(layerTree, layerMask.maskLayerId);
if (!maskLayer) return; if (!maskLayer) return;
await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds); await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds);
return; return;
@@ -122,7 +124,8 @@ function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) {
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) { function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
for (const layer of layers) { for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); const layerMask = getLayerMask(layer);
if (layerMask) ids.add(layerMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids); if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
} }
return ids; return ids;

View File

@@ -2,6 +2,7 @@ import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry"; import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { SelectionState } from "@editor/state"; import type { SelectionState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools"; import type { GenerateSettings } from "@editor/tools";
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes"; import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
@@ -137,9 +138,10 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
const asset = documentIndex.assetById.get(layerInfo.layer.assetId); const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
if (!asset) throw new Error("The selected layer is missing its source image."); if (!asset) throw new Error("The selected layer is missing its source image.");
if (!layerInfo.layer.clippingMask) throw new Error("Add a layer mask before running inpaint."); const layerMask = getLayerMask(layerInfo.layer);
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
const maskLayer = documentIndex.layerById.get(layerInfo.layer.clippingMask.maskLayerId); 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 === "group") throw new Error("The selected layer mask is missing.");
const maskAsset = documentIndex.assetById.get(maskLayer.assetId); const maskAsset = documentIndex.assetById.get(maskLayer.assetId);

View File

@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Transform } from "@core/geometry"; import type { Transform } from "@core/geometry";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { AppStore } from "@editor/store"; import type { AppStore } from "@editor/store";
import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state"; import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state";
import type { GenerateSettings } from "@editor/tools"; import type { GenerateSettings } from "@editor/tools";
@@ -224,7 +225,8 @@ function resolveSelectedImage(document: ImageDocument, selection: SelectionState
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId); const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group") return undefined; if (!layer || layer.type === "group") return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined; const 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 !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset ? { layer, asset, maskAsset } : undefined; return asset ? { layer, asset, maskAsset } : undefined;
} }

159
view/layerActions.ts Normal file
View File

@@ -0,0 +1,159 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { ArtboardId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
import { resolveIndexedLayerBounds } from "@editor/document-indexes";
import type { SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
export function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
const index = document.artboards.length + 1;
dispatch(commandIds.documentAddArtboard, {
id: crypto.randomUUID(),
name: `Artboard ${index}`,
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
});
}
export function addEmptyLayer(
document: ImageDocument,
artboardId: ArtboardId,
selectedLayer: IndexedLayerInfo | undefined,
dispatch: AppStore["dispatch"],
) {
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
if (!artboard) return;
const assetId = crypto.randomUUID();
const layerId = crypto.randomUUID();
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
dispatch(commandIds.documentAddAsset, {
asset: {
id: assetId,
name: "Empty Layer",
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
});
dispatch(commandIds.documentAddRasterLayer, {
artboardId,
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
layer: {
id: layerId,
type: "raster",
name: "Layer",
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
},
});
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
}
export function addGroupLayer(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
}
export function groupLayers(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
}
export function deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
if (selectedLayer) {
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
return;
}
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
}
export function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
const siblings = info.siblings;
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
const blocks = siblings.flatMap((layer, index) => {
if (maskLayerIds.has(layer.id)) return [];
const layerMask = getLayerMask(layer);
const maskIndex = layerMask ? siblings.findIndex((candidate) => candidate.id === layerMask.maskLayerId) : -1;
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
return [{ layerId: layer.id, start, end, size: end - start + 1 }];
});
const currentBlockIndex = blocks.findIndex((block) => block.layerId === info.layer.id);
const currentBlock = blocks[currentBlockIndex];
const targetBlock = blocks[currentBlockIndex + direction];
if (!currentBlock || !targetBlock) return;
const insertionIndex = direction === -1 ? targetBlock.start : targetBlock.end + 1;
const removedBeforeInsertion = currentBlock.end < insertionIndex ? currentBlock.size : currentBlock.start < insertionIndex ? insertionIndex - currentBlock.start : 0;
dispatch(commandIds.documentMoveLayer, {
layerId: info.layer.id,
toArtboardId: info.artboardId,
toParentGroupId: info.parentGroupId,
toIndex: insertionIndex - removedBeforeInsertion,
});
}
export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
const layer = layerInfo.layer;
if (layer.type === "group") return;
const asset = documentIndex.assetById.get(layer.assetId);
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
if (!asset || !bounds) return;
const assetId = crypto.randomUUID();
const maskLayerId = crypto.randomUUID();
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
dispatch(commandIds.documentAddLayerMask, {
layerId: layer.id,
asset: {
id: assetId,
name: `${layer.name} Mask`,
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
maskLayer: {
id: maskLayerId,
type: "raster",
name: `${layer.name} Mask`,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: { x: bounds.x, y: bounds.y },
scale: { x: bounds.w / width, y: bounds.h / height },
rotation: layer.transform.rotation,
},
},
});
}
const emptyLayerIds = new Set<string>();
function createGroup(name: string): Extract<Layer, { type: "group" }> {
return {
id: crypto.randomUUID(),
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [],
};
}