feat: add ComfyUI integration for image generation and management
- Implemented ComfyGenerateRequest type and associated functions for generating images using various architectures and modes. - Added functions for listing generation options and handling image uploads. - Created workflows for different generation modes including SDXL, Z-Image, Z-Image Turbo, and Anima. - Introduced GenerationJobStatus component to display the status of ongoing generation jobs. - Developed MaskControls for managing mask operations and displaying mask analysis. - Created palette items for tool selection, layer management, and generation settings.
This commit is contained in:
302
commands/document-tree.ts
Normal file
302
commands/document-tree.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { LayerGroup } from "@core/layer-group";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
|
||||
export type LayerLocation = {
|
||||
artboardId: ArtboardId;
|
||||
parentGroupId?: LayerId;
|
||||
index: number;
|
||||
layer: Layer;
|
||||
siblings: readonly Layer[];
|
||||
};
|
||||
|
||||
export function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
|
||||
for (const artboard of document.artboards) {
|
||||
const location = findLayerLocationInTree(artboard.layers, layerId, artboard.id);
|
||||
if (location) return location;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isReferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): boolean {
|
||||
return document.artboards.some((artboard) => isReferencedMaskLayerInTree(artboard.layers, maskLayerId));
|
||||
}
|
||||
|
||||
export function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean {
|
||||
for (const layer of layers) {
|
||||
if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
|
||||
if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerLocation | undefined {
|
||||
for (let index = 0; index < layers.length; index++) {
|
||||
const layer = layers[index];
|
||||
if (!layer) continue;
|
||||
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer, siblings: layers };
|
||||
if (layer.type === "group") {
|
||||
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function mapLayerInDocument(document: ImageDocument, layerId: LayerId, mapLayer: (layer: Layer) => Layer): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapLayerInTree(artboard.layers, layerId, mapLayer) })),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLayerInTree(layers: Layer[], layerId: LayerId, mapLayer: (layer: Layer) => Layer): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.id === layerId) return mapLayer(layer);
|
||||
if (layer.type === "group") return { ...layer, children: mapLayerInTree(layer.children, layerId, mapLayer) };
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
|
||||
export function insertLayer(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layer: Layer, index?: number): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
if (artboard.id !== artboardId) return artboard;
|
||||
if (!parentGroupId) return { ...artboard, layers: insertAt(artboard.layers, layer, index) };
|
||||
return { ...artboard, layers: insertLayerInGroup(artboard.layers, parentGroupId, layer, index) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, index?: number): Layer[] {
|
||||
return layers.map((candidate) => {
|
||||
if (candidate.type === "group" && candidate.id === groupId) return { ...candidate, children: insertAt(candidate.children, layer, index) };
|
||||
if (candidate.type === "group") return { ...candidate, children: insertLayerInGroup(candidate.children, groupId, layer, index) };
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
|
||||
export 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) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export 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;
|
||||
});
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
|
||||
let removed: Layer | undefined;
|
||||
return {
|
||||
document: {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
const result = removeLayerFromTree(artboard.layers, layerId);
|
||||
if (result.layer) removed = result.layer;
|
||||
return { ...artboard, layers: result.layers };
|
||||
}),
|
||||
},
|
||||
layer: removed,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeLayerFromTree(layers: Layer[], layerId: LayerId): { layers: Layer[]; layer?: Layer } {
|
||||
let removed: Layer | undefined;
|
||||
const next: Layer[] = [];
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) {
|
||||
removed = layer;
|
||||
continue;
|
||||
}
|
||||
if (layer.type === "group") {
|
||||
const result = removeLayerFromTree(layer.children, layerId);
|
||||
if (result.layer) removed = result.layer;
|
||||
next.push({ ...layer, children: result.layers });
|
||||
} else {
|
||||
next.push(layer);
|
||||
}
|
||||
}
|
||||
return { layers: next, layer: removed };
|
||||
}
|
||||
|
||||
export function ungroupLayerInDocument(document: ImageDocument, groupId: LayerId): { document: ImageDocument; changed: boolean; artboardId?: ArtboardId; children: Layer[] } {
|
||||
let changed = false;
|
||||
let artboardId: ArtboardId | undefined;
|
||||
let children: Layer[] = [];
|
||||
const next = {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
const result = ungroupLayerInTree(artboard.layers, groupId);
|
||||
if (result.changed) {
|
||||
changed = true;
|
||||
artboardId = artboard.id;
|
||||
children = result.children;
|
||||
}
|
||||
return { ...artboard, layers: result.layers };
|
||||
}),
|
||||
};
|
||||
return { document: next, changed, artboardId, children };
|
||||
}
|
||||
|
||||
export function ungroupLayerInTree(layers: Layer[], groupId: LayerId): { layers: Layer[]; changed: boolean; children: Layer[] } {
|
||||
const next: Layer[] = [];
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "group" && layer.id === groupId) return { layers: [...next, ...layer.children, ...layers.slice(next.length + 1)], changed: true, children: layer.children };
|
||||
if (layer.type === "group") {
|
||||
const result = ungroupLayerInTree(layer.children, groupId);
|
||||
if (result.changed) return { layers: [...next, { ...layer, children: result.layers }, ...layers.slice(next.length + 1)], changed: true, children: result.children };
|
||||
}
|
||||
next.push(layer);
|
||||
}
|
||||
return { layers, changed: false, children: [] };
|
||||
}
|
||||
|
||||
export function insertAt(layers: Layer[], layer: Layer, index = layers.length) {
|
||||
const clamped = Math.max(0, Math.min(index, layers.length));
|
||||
return [...layers.slice(0, clamped), layer, ...layers.slice(clamped)];
|
||||
}
|
||||
|
||||
export function findGroup(document: ImageDocument, groupId: LayerId): LayerGroup | undefined {
|
||||
for (const artboard of document.artboards) {
|
||||
const group = findGroupInTree(artboard.layers, groupId);
|
||||
if (group) return group;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "group" && layer.id === groupId) return layer;
|
||||
if (layer.type === "group") {
|
||||
const child = findGroupInTree(layer.children, groupId);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function removeLayerMaskReference(layer: Layer): Layer {
|
||||
const next = { ...layer };
|
||||
delete next.layerMask;
|
||||
delete next.clippingMask;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function withLayerMask(layer: Layer, maskLayerId: LayerId): Layer {
|
||||
return {
|
||||
...layer,
|
||||
layerMask: {
|
||||
kind: "raster",
|
||||
maskLayerId,
|
||||
enabled: true,
|
||||
inverted: false,
|
||||
},
|
||||
clippingMask: { maskLayerId },
|
||||
};
|
||||
}
|
||||
|
||||
export function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
|
||||
if (isMaskLayerReferenced(document, maskLayerId)) return document;
|
||||
return removeLayerFromDocument(document, maskLayerId).document;
|
||||
}
|
||||
|
||||
export function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): boolean {
|
||||
return collectClippingMaskIds(document.artboards.flatMap((artboard) => artboard.layers)).has(maskLayerId);
|
||||
}
|
||||
|
||||
export function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
|
||||
const existingLayerIds = collectDocumentLayerIds(document);
|
||||
return mapAllLayersInDocument(document, (layer) => {
|
||||
const mask = getLayerMask(layer);
|
||||
if (!mask || existingLayerIds.has(mask.maskLayerId)) return layer;
|
||||
return removeLayerMaskReference(layer);
|
||||
});
|
||||
}
|
||||
|
||||
export function isMaskEditFor(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
|
||||
return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId;
|
||||
}
|
||||
|
||||
export function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, document: ImageDocument) {
|
||||
if (!maskEdit) return false;
|
||||
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
|
||||
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
|
||||
return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
|
||||
}
|
||||
|
||||
export function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> {
|
||||
const ids = new Set<LayerId>();
|
||||
for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function collectLayerIds(layer: Layer, ids = new Set<LayerId>()): Set<LayerId> {
|
||||
ids.add(layer.id);
|
||||
if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
||||
for (const layer of layers) collectLayerIds(layer, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
||||
for (const layer of layers) {
|
||||
const mask = getLayerMask(layer);
|
||||
if (mask) ids.add(mask.maskLayerId);
|
||||
if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
|
||||
const layerIdSet = new Set(layerIds);
|
||||
return layers.flatMap((layer) => {
|
||||
const mask = getLayerMask(layer);
|
||||
return layerIdSet.has(layer.id) && mask ? [mask.maskLayerId] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapAllLayersInTree(artboard.layers, mapLayer) })),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) => Layer): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
const mapped = layer.type === "group" ? { ...layer, children: mapAllLayersInTree(layer.children, mapLayer) } : layer;
|
||||
return mapLayer(mapped);
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds } from "./document-tree";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { RasterLayer } from "@core/raster-layer";
|
||||
import type { LayerGroup } from "@core/layer-group";
|
||||
@@ -625,300 +625,3 @@ export const documentCommands = [
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentRemoveLayerMaskCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
type LayerLocation = {
|
||||
artboardId: ArtboardId;
|
||||
parentGroupId?: LayerId;
|
||||
index: number;
|
||||
layer: Layer;
|
||||
siblings: readonly Layer[];
|
||||
};
|
||||
|
||||
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
|
||||
for (const artboard of document.artboards) {
|
||||
const location = findLayerLocationInTree(artboard.layers, layerId, artboard.id);
|
||||
if (location) return location;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isReferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): boolean {
|
||||
return document.artboards.some((artboard) => isReferencedMaskLayerInTree(artboard.layers, maskLayerId));
|
||||
}
|
||||
|
||||
function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean {
|
||||
for (const layer of layers) {
|
||||
if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
|
||||
if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerLocation | undefined {
|
||||
for (let index = 0; index < layers.length; index++) {
|
||||
const layer = layers[index];
|
||||
if (!layer) continue;
|
||||
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer, siblings: layers };
|
||||
if (layer.type === "group") {
|
||||
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapLayerInDocument(document: ImageDocument, layerId: LayerId, mapLayer: (layer: Layer) => Layer): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapLayerInTree(artboard.layers, layerId, mapLayer) })),
|
||||
};
|
||||
}
|
||||
|
||||
function mapLayerInTree(layers: Layer[], layerId: LayerId, mapLayer: (layer: Layer) => Layer): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.id === layerId) return mapLayer(layer);
|
||||
if (layer.type === "group") return { ...layer, children: mapLayerInTree(layer.children, layerId, mapLayer) };
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
|
||||
function insertLayer(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layer: Layer, index?: number): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
if (artboard.id !== artboardId) return artboard;
|
||||
if (!parentGroupId) return { ...artboard, layers: insertAt(artboard.layers, layer, index) };
|
||||
return { ...artboard, layers: insertLayerInGroup(artboard.layers, parentGroupId, layer, index) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, index?: number): Layer[] {
|
||||
return layers.map((candidate) => {
|
||||
if (candidate.type === "group" && candidate.id === groupId) return { ...candidate, children: insertAt(candidate.children, layer, index) };
|
||||
if (candidate.type === "group") return { ...candidate, children: insertLayerInGroup(candidate.children, groupId, layer, index) };
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
|
||||
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 } {
|
||||
let removed: Layer | undefined;
|
||||
return {
|
||||
document: {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
const result = removeLayerFromTree(artboard.layers, layerId);
|
||||
if (result.layer) removed = result.layer;
|
||||
return { ...artboard, layers: result.layers };
|
||||
}),
|
||||
},
|
||||
layer: removed,
|
||||
};
|
||||
}
|
||||
|
||||
function removeLayerFromTree(layers: Layer[], layerId: LayerId): { layers: Layer[]; layer?: Layer } {
|
||||
let removed: Layer | undefined;
|
||||
const next: Layer[] = [];
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) {
|
||||
removed = layer;
|
||||
continue;
|
||||
}
|
||||
if (layer.type === "group") {
|
||||
const result = removeLayerFromTree(layer.children, layerId);
|
||||
if (result.layer) removed = result.layer;
|
||||
next.push({ ...layer, children: result.layers });
|
||||
} else {
|
||||
next.push(layer);
|
||||
}
|
||||
}
|
||||
return { layers: next, layer: removed };
|
||||
}
|
||||
|
||||
function ungroupLayerInDocument(document: ImageDocument, groupId: LayerId): { document: ImageDocument; changed: boolean; artboardId?: ArtboardId; children: Layer[] } {
|
||||
let changed = false;
|
||||
let artboardId: ArtboardId | undefined;
|
||||
let children: Layer[] = [];
|
||||
const next = {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
const result = ungroupLayerInTree(artboard.layers, groupId);
|
||||
if (result.changed) {
|
||||
changed = true;
|
||||
artboardId = artboard.id;
|
||||
children = result.children;
|
||||
}
|
||||
return { ...artboard, layers: result.layers };
|
||||
}),
|
||||
};
|
||||
return { document: next, changed, artboardId, children };
|
||||
}
|
||||
|
||||
function ungroupLayerInTree(layers: Layer[], groupId: LayerId): { layers: Layer[]; changed: boolean; children: Layer[] } {
|
||||
const next: Layer[] = [];
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "group" && layer.id === groupId) return { layers: [...next, ...layer.children, ...layers.slice(next.length + 1)], changed: true, children: layer.children };
|
||||
if (layer.type === "group") {
|
||||
const result = ungroupLayerInTree(layer.children, groupId);
|
||||
if (result.changed) return { layers: [...next, { ...layer, children: result.layers }, ...layers.slice(next.length + 1)], changed: true, children: result.children };
|
||||
}
|
||||
next.push(layer);
|
||||
}
|
||||
return { layers, changed: false, children: [] };
|
||||
}
|
||||
|
||||
function insertAt(layers: Layer[], layer: Layer, index = layers.length) {
|
||||
const clamped = Math.max(0, Math.min(index, layers.length));
|
||||
return [...layers.slice(0, clamped), layer, ...layers.slice(clamped)];
|
||||
}
|
||||
|
||||
function findGroup(document: ImageDocument, groupId: LayerId): LayerGroup | undefined {
|
||||
for (const artboard of document.artboards) {
|
||||
const group = findGroupInTree(artboard.layers, groupId);
|
||||
if (group) return group;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "group" && layer.id === groupId) return layer;
|
||||
if (layer.type === "group") {
|
||||
const child = findGroupInTree(layer.children, groupId);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function removeLayerMaskReference(layer: Layer): Layer {
|
||||
const next = { ...layer };
|
||||
delete next.layerMask;
|
||||
delete next.clippingMask;
|
||||
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 {
|
||||
if (isMaskLayerReferenced(document, maskLayerId)) return document;
|
||||
return removeLayerFromDocument(document, maskLayerId).document;
|
||||
}
|
||||
|
||||
function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): boolean {
|
||||
return collectClippingMaskIds(document.artboards.flatMap((artboard) => artboard.layers)).has(maskLayerId);
|
||||
}
|
||||
|
||||
function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
|
||||
const existingLayerIds = collectDocumentLayerIds(document);
|
||||
return mapAllLayersInDocument(document, (layer) => {
|
||||
const mask = getLayerMask(layer);
|
||||
if (!mask || existingLayerIds.has(mask.maskLayerId)) return layer;
|
||||
return removeLayerMaskReference(layer);
|
||||
});
|
||||
}
|
||||
|
||||
function isMaskEditFor(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
|
||||
return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId;
|
||||
}
|
||||
|
||||
function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, document: ImageDocument) {
|
||||
if (!maskEdit) return false;
|
||||
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
|
||||
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
|
||||
return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
|
||||
}
|
||||
|
||||
function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> {
|
||||
const ids = new Set<LayerId>();
|
||||
for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectLayerIds(layer: Layer, ids = new Set<LayerId>()): Set<LayerId> {
|
||||
ids.add(layer.id);
|
||||
if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
||||
for (const layer of layers) collectLayerIds(layer, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
|
||||
for (const layer of layers) {
|
||||
const mask = getLayerMask(layer);
|
||||
if (mask) ids.add(mask.maskLayerId);
|
||||
if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
|
||||
const layerIdSet = new Set(layerIds);
|
||||
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 {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapAllLayersInTree(artboard.layers, mapLayer) })),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) => Layer): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
const mapped = layer.type === "group" ? { ...layer, children: mapAllLayersInTree(layer.children, mapLayer) } : layer;
|
||||
return mapLayer(mapped);
|
||||
});
|
||||
}
|
||||
|
||||
16
commands/editor.ts
Normal file
16
commands/editor.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type EditorSetPointerSessionPayload = { type: "pan" } | undefined;
|
||||
|
||||
export const editorSetPointerSessionCommand: Command<EditorSetPointerSessionPayload> = {
|
||||
id: commandIds.editorSetPointerSession,
|
||||
name: "Set pointer session",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
if (state.editor.pointerSession?.type === payload?.type) return state;
|
||||
return { ...state, editor: { ...state.editor, pointerSession: payload } };
|
||||
},
|
||||
};
|
||||
|
||||
export const editorCommands = [editorSetPointerSessionCommand] satisfies Command<unknown>[];
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
generationRemoveCandidateCommand,
|
||||
generationReplaceCandidatePixelsCommand,
|
||||
generationSetCompareModeCommand,
|
||||
generationFailJobCommand,
|
||||
generationStartJobCommand,
|
||||
generationSucceedJobCommand,
|
||||
} from "./generation";
|
||||
|
||||
describe("generation commands", () => {
|
||||
@@ -64,6 +67,8 @@ describe("generation commands", () => {
|
||||
candidates: [generationCandidate("candidate-2")],
|
||||
selectedCandidateId: "candidate-2",
|
||||
compareMode: "split",
|
||||
jobs: [],
|
||||
resources: { status: "idle" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,8 +104,27 @@ describe("generation commands", () => {
|
||||
candidates: [generationCandidate("candidate-2", true)],
|
||||
selectedCandidateId: "candidate-2",
|
||||
compareMode: "before",
|
||||
jobs: [],
|
||||
resources: { status: "idle" },
|
||||
});
|
||||
});
|
||||
|
||||
test("tracks one durable generation job through completion", () => {
|
||||
const state = createInitialAppState("Test");
|
||||
const running = generationStartJobCommand.execute({ state }, { jobId: "job-1", kind: "generate", label: "Generating", startedAt: 100 });
|
||||
const duplicate = generationStartJobCommand.execute({ state: running }, { jobId: "job-2", kind: "regenerate", label: "Regenerate", startedAt: 101 });
|
||||
const completed = generationSucceedJobCommand.execute({ state: duplicate }, { jobId: "job-1", finishedAt: 150 });
|
||||
|
||||
expect(duplicate).toBe(running);
|
||||
expect(completed.editor.generation.jobs[0]).toEqual({ id: "job-1", kind: "generate", label: "Generating", status: "succeeded", startedAt: 100, finishedAt: 150, error: undefined });
|
||||
});
|
||||
|
||||
test("preserves generation errors in authoritative state", () => {
|
||||
const running = generationStartJobCommand.execute({ state: createInitialAppState("Test") }, { jobId: "job-1", kind: "replace", label: "Replacing pixels", startedAt: 100 });
|
||||
const failed = generationFailJobCommand.execute({ state: running }, { jobId: "job-1", finishedAt: 125, error: "Backend unavailable" });
|
||||
|
||||
expect(failed.editor.generation.jobs[0]).toMatchObject({ id: "job-1", status: "failed", error: "Backend unavailable", finishedAt: 125 });
|
||||
});
|
||||
});
|
||||
|
||||
function documentWithSourceLayer() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||
import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, LayerId } from "@core/id";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { GenerationCandidate, GenerationCompareMode, GenerationState } from "@editor/state";
|
||||
import type { AppState, GenerationCandidate, GenerationCompareMode, GenerationJobKind, GenerationOptions, GenerationState } from "@editor/state";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
@@ -13,7 +13,7 @@ export type GenerationAddCandidatePayload = {
|
||||
};
|
||||
|
||||
export type GenerationSelectCandidatePayload = {
|
||||
candidateId?: string;
|
||||
candidateId?: GenerationCandidateId;
|
||||
};
|
||||
|
||||
export type GenerationSetCompareModePayload = {
|
||||
@@ -21,22 +21,29 @@ export type GenerationSetCompareModePayload = {
|
||||
};
|
||||
|
||||
export type GenerationRemoveCandidatePayload = {
|
||||
candidateId: string;
|
||||
candidateId: GenerationCandidateId;
|
||||
};
|
||||
|
||||
export type GenerationApplyCandidateAsLayerPayload = {
|
||||
candidateId: string;
|
||||
candidateId: GenerationCandidateId;
|
||||
assetId: AssetId;
|
||||
layerId: LayerId;
|
||||
};
|
||||
|
||||
export type GenerationReplaceCandidatePixelsPayload = {
|
||||
candidateId: string;
|
||||
candidateId: GenerationCandidateId;
|
||||
source: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
|
||||
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
|
||||
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
|
||||
export type GenerationSetResourcesPayload = { options: GenerationOptions };
|
||||
export type GenerationFailResourcesPayload = { error: string };
|
||||
|
||||
const maxCandidates = 12;
|
||||
const maxJobs = 20;
|
||||
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
|
||||
|
||||
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
|
||||
@@ -50,6 +57,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: {
|
||||
...state.editor.generation,
|
||||
candidates,
|
||||
selectedCandidateId: payload.candidate.id,
|
||||
compareMode: "result",
|
||||
@@ -126,7 +134,7 @@ export const generationClearCandidatesCommand: Command = {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
generation: { candidates: [], selectedCandidateId: undefined, compareMode: "result" },
|
||||
generation: { ...state.editor.generation, candidates: [], selectedCandidateId: undefined, compareMode: "result" },
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -210,6 +218,66 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
|
||||
},
|
||||
};
|
||||
|
||||
export const generationStartJobCommand: Command<GenerationStartJobPayload> = {
|
||||
id: commandIds.generationStartJob,
|
||||
name: "Start generation job",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
if (!payload.jobId || !payload.label.trim() || !Number.isFinite(payload.startedAt)) return state;
|
||||
if (state.editor.generation.jobs.some((job) => job.status === "running" || job.id === payload.jobId)) return state;
|
||||
const job: GenerationState["jobs"][number] = { id: payload.jobId, kind: payload.kind, label: payload.label.trim(), status: "running", startedAt: payload.startedAt };
|
||||
return updateJobs(state, [job, ...state.editor.generation.jobs].slice(0, maxJobs));
|
||||
},
|
||||
};
|
||||
|
||||
export const generationSucceedJobCommand: Command<GenerationSucceedJobPayload> = {
|
||||
id: commandIds.generationSucceedJob,
|
||||
name: "Complete generation job",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
return settleJob(state, payload.jobId, payload.finishedAt, "succeeded");
|
||||
},
|
||||
};
|
||||
|
||||
export const generationFailJobCommand: Command<GenerationFailJobPayload> = {
|
||||
id: commandIds.generationFailJob,
|
||||
name: "Fail generation job",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
if (!payload.error.trim()) return state;
|
||||
return settleJob(state, payload.jobId, payload.finishedAt, "failed", payload.error.trim());
|
||||
},
|
||||
};
|
||||
|
||||
export const generationLoadResourcesCommand: Command = {
|
||||
id: commandIds.generationLoadResources,
|
||||
name: "Load generation resources",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }) {
|
||||
if (state.editor.generation.resources.status === "loading") return state;
|
||||
return updateResources(state, { status: "loading" });
|
||||
},
|
||||
};
|
||||
|
||||
export const generationSetResourcesCommand: Command<GenerationSetResourcesPayload> = {
|
||||
id: commandIds.generationSetResources,
|
||||
name: "Set generation resources",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
return updateResources(state, { status: "ready", options: payload.options });
|
||||
},
|
||||
};
|
||||
|
||||
export const generationFailResourcesCommand: Command<GenerationFailResourcesPayload> = {
|
||||
id: commandIds.generationFailResources,
|
||||
name: "Fail generation resources",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
if (!payload.error.trim()) return state;
|
||||
return updateResources(state, { status: "failed", error: payload.error.trim() });
|
||||
},
|
||||
};
|
||||
|
||||
export const generationCommands = [
|
||||
generationAddCandidateCommand,
|
||||
generationSelectCandidateCommand,
|
||||
@@ -218,8 +286,29 @@ export const generationCommands = [
|
||||
generationClearCandidatesCommand,
|
||||
generationApplyCandidateAsLayerCommand,
|
||||
generationReplaceCandidatePixelsCommand,
|
||||
generationStartJobCommand,
|
||||
generationSucceedJobCommand,
|
||||
generationFailJobCommand,
|
||||
generationLoadResourcesCommand,
|
||||
generationSetResourcesCommand,
|
||||
generationFailResourcesCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
function updateJobs(state: AppState, jobs: GenerationState["jobs"]): AppState {
|
||||
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, jobs } } };
|
||||
}
|
||||
|
||||
function updateResources(state: AppState, resources: GenerationState["resources"]): AppState {
|
||||
return { ...state, editor: { ...state.editor, generation: { ...state.editor.generation, resources } } };
|
||||
}
|
||||
|
||||
function settleJob(state: AppState, jobId: GenerationJobId, finishedAt: number, status: "succeeded" | "failed", error?: string): AppState {
|
||||
if (!Number.isFinite(finishedAt)) return state;
|
||||
const job = state.editor.generation.jobs.find((candidate) => candidate.id === jobId);
|
||||
if (!job || job.status !== "running" || finishedAt < job.startedAt) return state;
|
||||
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === jobId ? { ...candidate, status, finishedAt, error } : candidate));
|
||||
}
|
||||
|
||||
type LayerLocation = {
|
||||
artboardId: ArtboardId;
|
||||
layer: Layer;
|
||||
@@ -251,7 +340,7 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function removeGenerationCandidate(generation: GenerationState, candidateId: string): GenerationState {
|
||||
function removeGenerationCandidate(generation: GenerationState, candidateId: GenerationCandidateId): GenerationState {
|
||||
const removedIndex = generation.candidates.findIndex((candidate) => candidate.id === candidateId);
|
||||
if (removedIndex < 0) return generation;
|
||||
|
||||
@@ -264,6 +353,7 @@ function removeGenerationCandidate(generation: GenerationState, candidateId: str
|
||||
: candidates[Math.min(removedIndex, candidates.length - 1)]?.id;
|
||||
|
||||
return {
|
||||
...generation,
|
||||
candidates,
|
||||
selectedCandidateId,
|
||||
compareMode: candidates.length > 0 ? generation.compareMode : "result",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { generationStartJobCommand, generationSucceedJobCommand } from "./generation";
|
||||
import { createAppStore } from "@editor/store";
|
||||
import { documentAddArtboardCommand } from "./document";
|
||||
import { historyCommands } from "./history";
|
||||
@@ -7,7 +8,7 @@ import { commandIds } from "./ids";
|
||||
import { createCommandRegistry } from "./registry";
|
||||
import { transformCommands } from "./transform";
|
||||
|
||||
const registry = createCommandRegistry([documentAddArtboardCommand, ...historyCommands, ...transformCommands]);
|
||||
const registry = createCommandRegistry([documentAddArtboardCommand, generationStartJobCommand, generationSucceedJobCommand, ...historyCommands, ...transformCommands]);
|
||||
|
||||
describe("history commands", () => {
|
||||
test("records document changes and undoes/redoes them", () => {
|
||||
@@ -97,6 +98,17 @@ describe("history commands", () => {
|
||||
expect(store.getState().document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 100, h: 80 });
|
||||
expect(store.getState().history.past).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("does not rewind generation job lifecycle during document undo", () => {
|
||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
||||
store.dispatch(commandIds.documentAddArtboard, { id: "a1", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 } });
|
||||
store.dispatch(commandIds.generationStartJob, { jobId: "job-1", kind: "generate", label: "Generating", startedAt: 100 });
|
||||
store.dispatch(commandIds.generationSucceedJob, { jobId: "job-1", finishedAt: 150 });
|
||||
|
||||
store.dispatch(commandIds.historyUndo, undefined);
|
||||
|
||||
expect(store.getState().editor.generation.jobs[0]?.status).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
function artboardState() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Command } from "./command";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export const historyUndoCommand: Command = {
|
||||
@@ -12,7 +13,7 @@ export const historyUndoCommand: Command = {
|
||||
return {
|
||||
...state,
|
||||
document: previous.document,
|
||||
editor: previous.editor,
|
||||
editor: preserveGenerationJobs(previous.editor, state.editor),
|
||||
history: {
|
||||
past: state.history.past.slice(0, -1),
|
||||
future: [{ document: state.document, editor: state.editor }, ...state.history.future],
|
||||
@@ -32,7 +33,7 @@ export const historyRedoCommand: Command = {
|
||||
return {
|
||||
...state,
|
||||
document: next.document,
|
||||
editor: next.editor,
|
||||
editor: preserveGenerationJobs(next.editor, state.editor),
|
||||
history: {
|
||||
past: [...state.history.past, { document: state.document, editor: state.editor }],
|
||||
future: state.history.future.slice(1),
|
||||
@@ -42,3 +43,13 @@ export const historyRedoCommand: Command = {
|
||||
};
|
||||
|
||||
export const historyCommands = [historyUndoCommand, historyRedoCommand] satisfies Command<unknown>[];
|
||||
|
||||
function preserveGenerationJobs(target: EditorState, current: EditorState): EditorState {
|
||||
return {
|
||||
...target,
|
||||
generation: {
|
||||
...target.generation,
|
||||
jobs: current.generation.jobs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ export const commandIds = {
|
||||
generationClearCandidates: "generation.clearCandidates",
|
||||
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
|
||||
generationReplaceCandidatePixels: "generation.replaceCandidatePixels",
|
||||
generationStartJob: "generation.startJob",
|
||||
generationSucceedJob: "generation.succeedJob",
|
||||
generationFailJob: "generation.failJob",
|
||||
generationLoadResources: "generation.loadResources",
|
||||
generationSetResources: "generation.setResources",
|
||||
generationFailResources: "generation.failResources",
|
||||
transformBegin: "transform.begin",
|
||||
transformUpdate: "transform.update",
|
||||
transformSetBounds: "transform.setBounds",
|
||||
@@ -59,4 +65,6 @@ export const commandIds = {
|
||||
commandPaletteClose: "commandPalette.close",
|
||||
commandPaletteSetQuery: "commandPalette.setQuery",
|
||||
commandPaletteSetSelectedIndex: "commandPalette.setSelectedIndex",
|
||||
workspaceSetPanel: "workspace.setPanel",
|
||||
editorSetPointerSession: "editor.setPointerSession",
|
||||
} as const;
|
||||
|
||||
@@ -30,6 +30,11 @@ import type {
|
||||
GenerationReplaceCandidatePixelsPayload,
|
||||
GenerationSelectCandidatePayload,
|
||||
GenerationSetCompareModePayload,
|
||||
GenerationStartJobPayload,
|
||||
GenerationSucceedJobPayload,
|
||||
GenerationFailJobPayload,
|
||||
GenerationSetResourcesPayload,
|
||||
GenerationFailResourcesPayload,
|
||||
} from "./generation";
|
||||
import type {
|
||||
CommandPaletteOpenPayload,
|
||||
@@ -39,6 +44,8 @@ import type {
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
import type { WorkspaceSetPanelPayload } from "./workspace";
|
||||
import type { EditorSetPointerSessionPayload } from "./editor";
|
||||
import type {
|
||||
ViewportFitArtboardPayload,
|
||||
ViewportPanPayload,
|
||||
@@ -92,6 +99,12 @@ export type CommandPayloads = {
|
||||
[commandIds.generationClearCandidates]: void;
|
||||
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
|
||||
[commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload;
|
||||
[commandIds.generationStartJob]: GenerationStartJobPayload;
|
||||
[commandIds.generationSucceedJob]: GenerationSucceedJobPayload;
|
||||
[commandIds.generationFailJob]: GenerationFailJobPayload;
|
||||
[commandIds.generationLoadResources]: void;
|
||||
[commandIds.generationSetResources]: GenerationSetResourcesPayload;
|
||||
[commandIds.generationFailResources]: GenerationFailResourcesPayload;
|
||||
[commandIds.transformBegin]: TransformBeginPayload;
|
||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
||||
@@ -108,6 +121,8 @@ export type CommandPayloads = {
|
||||
[commandIds.commandPaletteClose]: void;
|
||||
[commandIds.commandPaletteSetQuery]: CommandPaletteSetQueryPayload;
|
||||
[commandIds.commandPaletteSetSelectedIndex]: CommandPaletteSetSelectedIndexPayload;
|
||||
[commandIds.workspaceSetPanel]: WorkspaceSetPanelPayload;
|
||||
[commandIds.editorSetPointerSession]: EditorSetPointerSessionPayload;
|
||||
};
|
||||
|
||||
export type CommandId = keyof CommandPayloads;
|
||||
|
||||
@@ -44,6 +44,8 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
||||
id: commandIds.toolSetActive,
|
||||
name: "Set active tool",
|
||||
execute({ state }, payload) {
|
||||
const previousNonGenerateTool = payload.tool === "generate" ? state.editor.workspace.previousNonGenerateTool : payload.tool;
|
||||
const panel = payload.tool === "generate" ? "generate" : state.editor.workspace.panel === "generate" ? "none" : state.editor.workspace.panel;
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
@@ -55,6 +57,7 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
||||
},
|
||||
brushPreview: undefined,
|
||||
brushStrokePreview: undefined,
|
||||
workspace: { panel, previousNonGenerateTool },
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
64
commands/transform-document.ts
Normal file
64
commands/transform-document.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { LayerId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { TransformTarget } from "@editor/transform";
|
||||
|
||||
export function applyTransformTargetBounds(document: ImageDocument, target: TransformTarget, bounds: Rect): ImageDocument {
|
||||
if (target.type === "artboard") return { ...document, artboards: document.artboards.map((artboard) => artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard) };
|
||||
const layer = findLayer(document, target.id);
|
||||
if (!layer) return document;
|
||||
return layer.type === "group" ? applyGroupBounds(document, layer.id, bounds) : applyLeafBounds(document, layer.id, bounds);
|
||||
}
|
||||
|
||||
function applyLeafBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
|
||||
const layer = findLayer(document, layerId);
|
||||
if (!layer) return document;
|
||||
const mask = getLayerMask(layer);
|
||||
return [layerId, ...(mask ? [mask.maskLayerId] : [])].reduce((next, id) => ({
|
||||
...next,
|
||||
artboards: next.artboards.map((artboard) => ({ ...artboard, layers: mapLeafBounds(next, artboard.layers, id, bounds) })),
|
||||
}), document);
|
||||
}
|
||||
|
||||
function applyGroupBounds(document: ImageDocument, groupId: LayerId, bounds: Rect): ImageDocument {
|
||||
const initial = resolveTransformTargetBounds(document, { type: "layer", id: groupId });
|
||||
if (!initial || initial.w === 0 || initial.h === 0) return document;
|
||||
const scale = { x: bounds.w / initial.w, y: bounds.h / initial.h };
|
||||
return { ...document, artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapGroupBounds(document, artboard.layers, groupId, initial, bounds, scale) })) };
|
||||
}
|
||||
|
||||
function mapGroupBounds(document: ImageDocument, layers: Layer[], groupId: LayerId, initial: 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) => scaleSubtree(document, child, initial, bounds, scale)) };
|
||||
return layer.type === "group" ? { ...layer, children: mapGroupBounds(document, layer.children, groupId, initial, bounds, scale) } : layer;
|
||||
});
|
||||
}
|
||||
|
||||
function scaleSubtree(document: ImageDocument, layer: Layer, initial: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
|
||||
if (layer.type === "group") return { ...layer, children: layer.children.map((child) => scaleSubtree(document, child, initial, bounds, scale)) };
|
||||
if (!document.assets.some((asset) => asset.id === layer.assetId)) return layer;
|
||||
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x + (layer.transform.position.x - initial.x) * scale.x, y: bounds.y + (layer.transform.position.y - initial.y) * scale.y }, scale: { x: layer.transform.scale.x * scale.x, y: layer.transform.scale.y * scale.y } } };
|
||||
}
|
||||
|
||||
function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.id === layerId && layer.type !== "group") {
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
return asset ? { ...layer, transform: { ...layer.transform, position: { x: bounds.x, y: bounds.y }, scale: { x: bounds.w / asset.intrinsicSize.w, y: bounds.h / asset.intrinsicSize.h } } } : layer;
|
||||
}
|
||||
return layer.type === "group" ? { ...layer, children: mapLeafBounds(document, layer.children, layerId, bounds) } : layer;
|
||||
});
|
||||
}
|
||||
|
||||
function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined {
|
||||
const visit = (layers: readonly Layer[]): Layer | undefined => {
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) return layer;
|
||||
if (layer.type === "group") { const child = visit(layer.children); if (child) return child; }
|
||||
}
|
||||
};
|
||||
for (const artboard of document.artboards) { const layer = visit(artboard.layers); if (layer) return layer; }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import { applyTransformTargetBounds } from "@editor/transform-targets";
|
||||
import { applyTransformTargetBounds } from "./transform-document";
|
||||
import type { TransformHandle, TransformTarget } from "@editor/transform";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
32
commands/workspace.ts
Normal file
32
commands/workspace.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { WorkspacePanel } from "@editor/state";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
export type WorkspaceSetPanelPayload = { panel: WorkspacePanel };
|
||||
|
||||
export const workspaceSetPanelCommand: Command<WorkspaceSetPanelPayload> = {
|
||||
id: commandIds.workspaceSetPanel,
|
||||
name: "Set workspace panel",
|
||||
history: { mode: "ignore" },
|
||||
execute({ state }, payload) {
|
||||
if (!workspacePanels.has(payload.panel)) return state;
|
||||
const currentTool = state.editor.tools.activeTool;
|
||||
const previousNonGenerateTool = currentTool === "generate" ? state.editor.workspace.previousNonGenerateTool : currentTool;
|
||||
const nextTool = payload.panel === "generate" ? "generate" : currentTool === "generate" ? previousNonGenerateTool : currentTool;
|
||||
if (state.editor.workspace.panel === payload.panel && currentTool === nextTool) return state;
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
workspace: { panel: payload.panel, previousNonGenerateTool },
|
||||
tools: nextTool === currentTool ? state.editor.tools : { ...state.editor.tools, activeTool: nextTool, interactionMode: { type: "tool", tool: nextTool } },
|
||||
brushPreview: nextTool === currentTool ? state.editor.brushPreview : undefined,
|
||||
brushStrokePreview: nextTool === currentTool ? state.editor.brushStrokePreview : undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const workspacePanels = new Set<WorkspacePanel>(["none", "generate", "layers"]);
|
||||
|
||||
export const workspaceCommands = [workspaceSetPanelCommand] satisfies Command<unknown>[];
|
||||
Reference in New Issue
Block a user