- Refactor LayersSheet component to support mask editing state and improve layer visibility handling. - Introduce functions to collect mask layer IDs and count display layers excluding masks. - Update BrushControls to include mask view mode options and a Done button for exiting mask editing. - Modify brush session handling to support brush previews when editing masks. - Implement a new BrushPreviewRenderer for rendering brush strokes with visual feedback. - Add document geometry utilities for transforming points and resolving layer bounds. - Ensure proper cleanup of brush preview on pointer leave and other interactions.
799 lines
27 KiB
TypeScript
799 lines
27 KiB
TypeScript
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 type { RasterLayer } from "@core/raster-layer";
|
|
import type { LayerGroup } from "@core/layer-group";
|
|
import type { Command } from "./command";
|
|
import { commandIds } from "./ids";
|
|
|
|
export type DocumentAddArtboardPayload = {
|
|
id: ArtboardId;
|
|
name: string;
|
|
bounds: Rect;
|
|
};
|
|
|
|
export type DocumentSetArtboardBoundsPayload = {
|
|
id: ArtboardId;
|
|
bounds: Rect;
|
|
};
|
|
|
|
export type DocumentRemoveArtboardPayload = {
|
|
id: ArtboardId;
|
|
};
|
|
|
|
export type DocumentSetArtboardVisiblePayload = {
|
|
id: ArtboardId;
|
|
visible: boolean;
|
|
};
|
|
|
|
export type DocumentSetArtboardLockedPayload = {
|
|
id: ArtboardId;
|
|
locked: boolean;
|
|
};
|
|
|
|
export type DocumentRenameArtboardPayload = {
|
|
id: ArtboardId;
|
|
name: string;
|
|
};
|
|
|
|
export type DocumentAddAssetPayload = {
|
|
asset: Asset;
|
|
};
|
|
|
|
export type DocumentUpdateAssetSourcePayload = {
|
|
assetId: AssetId;
|
|
source: string;
|
|
};
|
|
|
|
export type DocumentAddImageLayerPayload = {
|
|
artboardId: ArtboardId;
|
|
parentGroupId?: LayerId;
|
|
layer: ImageLayer;
|
|
};
|
|
|
|
export type DocumentAddRasterLayerPayload = {
|
|
artboardId: ArtboardId;
|
|
parentGroupId?: LayerId;
|
|
layer: RasterLayer;
|
|
};
|
|
|
|
export type DocumentAddGroupLayerPayload = {
|
|
artboardId: ArtboardId;
|
|
parentGroupId?: LayerId;
|
|
group: LayerGroup;
|
|
};
|
|
|
|
export type DocumentMoveLayerPayload = {
|
|
layerId: LayerId;
|
|
toArtboardId: ArtboardId;
|
|
toParentGroupId?: LayerId;
|
|
toIndex: number;
|
|
};
|
|
|
|
export type DocumentGroupLayersPayload = {
|
|
artboardId: ArtboardId;
|
|
layerIds: LayerId[];
|
|
group: LayerGroup;
|
|
};
|
|
|
|
export type DocumentUngroupLayerPayload = {
|
|
groupId: LayerId;
|
|
};
|
|
|
|
export type DocumentRemoveLayerPayload = {
|
|
layerId: LayerId;
|
|
};
|
|
|
|
export type DocumentSetLayerVisiblePayload = {
|
|
layerId: LayerId;
|
|
visible: boolean;
|
|
};
|
|
|
|
export type DocumentSetLayerLockedPayload = {
|
|
layerId: LayerId;
|
|
locked: boolean;
|
|
};
|
|
|
|
export type DocumentRenameLayerPayload = {
|
|
layerId: LayerId;
|
|
name: string;
|
|
};
|
|
|
|
export type DocumentSetLayerClippingMaskPayload = {
|
|
layerId: LayerId;
|
|
maskLayerId?: LayerId;
|
|
};
|
|
|
|
export type DocumentAddLayerMaskPayload = {
|
|
layerId: LayerId;
|
|
asset: Asset;
|
|
maskLayer: RasterLayer;
|
|
};
|
|
|
|
export type DocumentRemoveLayerMaskPayload = {
|
|
layerId: LayerId;
|
|
};
|
|
|
|
export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
|
id: commandIds.documentAddArtboard,
|
|
name: "Add artboard",
|
|
execute({ state }, payload) {
|
|
return {
|
|
...state,
|
|
document: {
|
|
...state.document,
|
|
artboards: [
|
|
...state.document.artboards,
|
|
{
|
|
id: payload.id,
|
|
name: payload.name,
|
|
bounds: payload.bounds,
|
|
backgroundColor: "transparent",
|
|
visible: true,
|
|
locked: false,
|
|
layers: [],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentSetArtboardBoundsCommand: Command<DocumentSetArtboardBoundsPayload> = {
|
|
id: commandIds.documentSetArtboardBounds,
|
|
name: "Set artboard bounds",
|
|
execute({ state }, payload) {
|
|
return {
|
|
...state,
|
|
document: {
|
|
...state.document,
|
|
artboards: state.document.artboards.map((artboard) =>
|
|
artboard.id === payload.id ? { ...artboard, bounds: { ...payload.bounds } } : artboard,
|
|
),
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
|
|
id: commandIds.documentRemoveArtboard,
|
|
name: "Remove artboard",
|
|
execute({ state }, payload) {
|
|
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
|
|
const document = {
|
|
...state.document,
|
|
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
|
|
};
|
|
|
|
return {
|
|
...state,
|
|
document,
|
|
editor: {
|
|
...state.editor,
|
|
selection: removedSelectedArtboard ? { layerIds: [] } : state.editor.selection,
|
|
maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentSetArtboardVisibleCommand: Command<DocumentSetArtboardVisiblePayload> = {
|
|
id: commandIds.documentSetArtboardVisible,
|
|
name: "Set artboard visible",
|
|
execute({ state }, payload) {
|
|
return {
|
|
...state,
|
|
document: {
|
|
...state.document,
|
|
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, visible: payload.visible } : artboard)),
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentSetArtboardLockedCommand: Command<DocumentSetArtboardLockedPayload> = {
|
|
id: commandIds.documentSetArtboardLocked,
|
|
name: "Set artboard locked",
|
|
execute({ state }, payload) {
|
|
return {
|
|
...state,
|
|
document: {
|
|
...state.document,
|
|
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, locked: payload.locked } : artboard)),
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentRenameArtboardCommand: Command<DocumentRenameArtboardPayload> = {
|
|
id: commandIds.documentRenameArtboard,
|
|
name: "Rename artboard",
|
|
execute({ state }, payload) {
|
|
const name = payload.name.trim();
|
|
if (!name) return state;
|
|
return {
|
|
...state,
|
|
document: {
|
|
...state.document,
|
|
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, name } : artboard)),
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentAddAssetCommand: Command<DocumentAddAssetPayload> = {
|
|
id: commandIds.documentAddAsset,
|
|
name: "Add asset",
|
|
execute({ state }, payload) {
|
|
if (state.document.assets.some((asset) => asset.id === payload.asset.id)) return state;
|
|
|
|
return {
|
|
...state,
|
|
document: {
|
|
...state.document,
|
|
assets: [...state.document.assets, payload.asset],
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentUpdateAssetSourceCommand: Command<DocumentUpdateAssetSourcePayload> = {
|
|
id: commandIds.documentUpdateAssetSource,
|
|
name: "Update asset source",
|
|
execute({ state }, payload) {
|
|
return {
|
|
...state,
|
|
document: {
|
|
...state.document,
|
|
assets: state.document.assets.map((asset) => (asset.id === payload.assetId ? { ...asset, source: payload.source } : asset)),
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload> = {
|
|
id: commandIds.documentAddImageLayer,
|
|
name: "Add image layer",
|
|
execute({ state }, payload) {
|
|
return {
|
|
...state,
|
|
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer),
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentAddRasterLayerCommand: Command<DocumentAddRasterLayerPayload> = {
|
|
id: commandIds.documentAddRasterLayer,
|
|
name: "Add raster layer",
|
|
execute({ state }, payload) {
|
|
return {
|
|
...state,
|
|
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer),
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentAddGroupLayerCommand: Command<DocumentAddGroupLayerPayload> = {
|
|
id: commandIds.documentAddGroupLayer,
|
|
name: "Add group layer",
|
|
execute({ state }, payload) {
|
|
return {
|
|
...state,
|
|
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.group),
|
|
editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.group.id] } },
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
|
|
id: commandIds.documentMoveLayer,
|
|
name: "Move layer",
|
|
execute({ state }, payload) {
|
|
if (payload.toParentGroupId && !findGroup(state.document, payload.toParentGroupId)) return state;
|
|
|
|
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
|
if (!removed.layer) return state;
|
|
|
|
const maskLayerId = removed.layer.clippingMask?.maskLayerId;
|
|
const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined;
|
|
const documentAfterRemoval = removedMask?.document ?? removed.document;
|
|
if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state;
|
|
|
|
const documentWithMask = removedMask?.layer
|
|
? insertLayer(documentAfterRemoval, payload.toArtboardId, payload.toParentGroupId, removedMask.layer, payload.toIndex)
|
|
: documentAfterRemoval;
|
|
|
|
return {
|
|
...state,
|
|
document: insertLayer(documentWithMask, payload.toArtboardId, payload.toParentGroupId, removed.layer, payload.toIndex + (removedMask?.layer ? 1 : 0)),
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
|
|
id: commandIds.documentGroupLayers,
|
|
name: "Group layers",
|
|
execute({ state }, payload) {
|
|
const requestedIds = [...new Set(payload.layerIds)];
|
|
if (requestedIds.length === 0) return state;
|
|
|
|
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
|
|
if (!artboard) return state;
|
|
|
|
const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])];
|
|
|
|
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
|
|
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 document = {
|
|
...state.document,
|
|
artboards: state.document.artboards.map((candidate) =>
|
|
candidate.id === payload.artboardId
|
|
? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] }
|
|
: candidate,
|
|
),
|
|
};
|
|
|
|
return {
|
|
...state,
|
|
document,
|
|
editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [group.id] } },
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentUngroupLayerCommand: Command<DocumentUngroupLayerPayload> = {
|
|
id: commandIds.documentUngroupLayer,
|
|
name: "Ungroup layer",
|
|
execute({ state }, payload) {
|
|
const result = ungroupLayerInDocument(state.document, payload.groupId);
|
|
if (!result.changed) return state;
|
|
|
|
return {
|
|
...state,
|
|
document: result.document,
|
|
editor: { ...state.editor, selection: { artboardId: result.artboardId, layerIds: result.children.map((layer) => layer.id) } },
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentSetLayerVisibleCommand: Command<DocumentSetLayerVisiblePayload> = {
|
|
id: commandIds.documentSetLayerVisible,
|
|
name: "Set layer visible",
|
|
execute({ state }, payload) {
|
|
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, visible: payload.visible })) };
|
|
},
|
|
};
|
|
|
|
export const documentSetLayerLockedCommand: Command<DocumentSetLayerLockedPayload> = {
|
|
id: commandIds.documentSetLayerLocked,
|
|
name: "Set layer locked",
|
|
execute({ state }, payload) {
|
|
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, locked: payload.locked })) };
|
|
},
|
|
};
|
|
|
|
export const documentRenameLayerCommand: Command<DocumentRenameLayerPayload> = {
|
|
id: commandIds.documentRenameLayer,
|
|
name: "Rename layer",
|
|
execute({ state }, payload) {
|
|
const name = payload.name.trim();
|
|
if (!name) return state;
|
|
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, name })) };
|
|
},
|
|
};
|
|
|
|
export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippingMaskPayload> = {
|
|
id: commandIds.documentSetLayerClippingMask,
|
|
name: "Set layer clipping mask",
|
|
execute({ state }, payload) {
|
|
if (payload.maskLayerId === payload.layerId) return state;
|
|
|
|
if (!payload.maskLayerId) {
|
|
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId;
|
|
return {
|
|
...state,
|
|
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
|
|
editor: {
|
|
...state.editor,
|
|
maskEdit: previousMaskId && isMaskEditFor(state.editor.maskEdit, payload.layerId, previousMaskId) ? undefined : state.editor.maskEdit,
|
|
},
|
|
};
|
|
}
|
|
|
|
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
|
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
|
if (!targetLocation || !maskLocation) return state;
|
|
if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state;
|
|
|
|
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
|
if (!removed.layer) return state;
|
|
|
|
const maskLocationAfterRemoval = findLayerLocation(removed.document, payload.maskLayerId);
|
|
if (!maskLocationAfterRemoval) return state;
|
|
|
|
return {
|
|
...state,
|
|
document: insertLayer(
|
|
removed.document,
|
|
maskLocationAfterRemoval.artboardId,
|
|
maskLocationAfterRemoval.parentGroupId,
|
|
{ ...removed.layer, clippingMask: { maskLayerId: payload.maskLayerId } },
|
|
maskLocationAfterRemoval.index + 1,
|
|
),
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> = {
|
|
id: commandIds.documentAddLayerMask,
|
|
name: "Add layer mask",
|
|
execute({ state }, payload) {
|
|
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
|
if (!targetLocation || targetLocation.layer.type === "group") return state;
|
|
|
|
const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId;
|
|
if (existingMaskId) {
|
|
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
|
|
if (existingMaskLocation?.layer.type === "group") return state;
|
|
if (existingMaskLocation) {
|
|
return {
|
|
...state,
|
|
editor: {
|
|
...state.editor,
|
|
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
|
|
maskEdit: { targetLayerId: payload.layerId, maskLayerId: existingMaskId },
|
|
tools: {
|
|
...state.editor.tools,
|
|
activeTool: "brush",
|
|
interactionMode: { type: "tool", tool: "brush" },
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
if (payload.maskLayer.id === payload.layerId) return state;
|
|
if (state.document.assets.some((asset) => asset.id === payload.asset.id)) return state;
|
|
if (findLayerLocation(state.document, payload.maskLayer.id)) return state;
|
|
|
|
const maskLayer: RasterLayer = {
|
|
...payload.maskLayer,
|
|
visible: true,
|
|
locked: false,
|
|
opacity: 1,
|
|
clippingMask: undefined,
|
|
};
|
|
const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] };
|
|
const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index);
|
|
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } }));
|
|
|
|
return {
|
|
...state,
|
|
document,
|
|
editor: {
|
|
...state.editor,
|
|
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
|
|
maskEdit: { targetLayerId: payload.layerId, maskLayerId: maskLayer.id },
|
|
tools: {
|
|
...state.editor.tools,
|
|
activeTool: "brush",
|
|
interactionMode: { type: "tool", tool: "brush" },
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayload> = {
|
|
id: commandIds.documentRemoveLayerMask,
|
|
name: "Remove layer mask",
|
|
execute({ state }, payload) {
|
|
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
|
const maskLayerId = targetLocation?.layer.clippingMask?.maskLayerId;
|
|
if (!targetLocation || !maskLayerId) {
|
|
return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state;
|
|
}
|
|
|
|
const unmasked = mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer));
|
|
const document = removeUnreferencedMaskLayer(unmasked, maskLayerId);
|
|
|
|
return {
|
|
...state,
|
|
document,
|
|
editor: {
|
|
...state.editor,
|
|
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
|
|
maskEdit: isMaskEditFor(state.editor.maskEdit, payload.layerId, maskLayerId) ? undefined : state.editor.maskEdit,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
|
|
id: commandIds.documentRemoveLayer,
|
|
name: "Remove layer",
|
|
execute({ state }, payload) {
|
|
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
|
if (!removed.layer) return state;
|
|
|
|
const removedLayerIds = collectLayerIds(removed.layer);
|
|
const removedMaskLayerIds = collectClippingMaskIds([removed.layer]);
|
|
const cleanedReferences = removeMissingMaskReferences(removed.document);
|
|
const document = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
|
|
const selection = {
|
|
...state.editor.selection,
|
|
layerIds: state.editor.selection.layerIds.filter((id) => !removedLayerIds.has(id)),
|
|
};
|
|
|
|
return {
|
|
...state,
|
|
document,
|
|
editor: {
|
|
...state.editor,
|
|
selection,
|
|
maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentCommands = [
|
|
documentAddArtboardCommand,
|
|
documentSetArtboardBoundsCommand,
|
|
documentRemoveArtboardCommand,
|
|
documentSetArtboardVisibleCommand,
|
|
documentSetArtboardLockedCommand,
|
|
documentRenameArtboardCommand,
|
|
documentAddAssetCommand,
|
|
documentUpdateAssetSourceCommand,
|
|
documentAddImageLayerCommand,
|
|
documentAddRasterLayerCommand,
|
|
documentAddGroupLayerCommand,
|
|
documentMoveLayerCommand,
|
|
documentGroupLayersCommand,
|
|
documentUngroupLayerCommand,
|
|
documentRemoveLayerCommand,
|
|
documentSetLayerVisibleCommand,
|
|
documentSetLayerLockedCommand,
|
|
documentRenameLayerCommand,
|
|
documentSetLayerClippingMaskCommand,
|
|
documentAddLayerMaskCommand,
|
|
documentRemoveLayerMaskCommand,
|
|
] satisfies Command<unknown>[];
|
|
|
|
type LayerLocation = {
|
|
artboardId: ArtboardId;
|
|
parentGroupId?: LayerId;
|
|
index: number;
|
|
layer: 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 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 };
|
|
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 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.clippingMask;
|
|
return next;
|
|
}
|
|
|
|
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) => {
|
|
if (!layer.clippingMask || existingLayerIds.has(layer.clippingMask.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?.clippingMask?.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) {
|
|
if (layer.clippingMask) ids.add(layer.clippingMask.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) => (layerIdSet.has(layer.id) && layer.clippingMask ? [layer.clippingMask.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);
|
|
});
|
|
}
|