feat: add crop and canvas resize workflows

This commit is contained in:
syntaxbullet
2026-07-11 12:16:33 +02:00
parent 4d7358af4b
commit 9fb3a19912
20 changed files with 312 additions and 35 deletions

View File

@@ -18,11 +18,13 @@ import {
documentRenameArtboardCommand,
documentRenameLayerCommand,
documentSetArtboardBoundsCommand,
documentResizeArtboardCommand,
documentSetArtboardLockedCommand,
documentSetArtboardVisibleCommand,
documentSetLayerClippingMaskCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerSourceRectCommand,
documentSetLayerVisibleCommand,
documentUpdateAssetSourceCommand,
documentUngroupLayerCommand,
@@ -59,6 +61,32 @@ describe("document commands", () => {
]);
});
test("crops a raster layer non-destructively and clamps the crop to its asset", () => {
const state = documentWithRaster();
const next = documentSetLayerSourceRectCommand.execute({ state }, { layerId: "r1", sourceRect: { x: 20, y: 10, w: 200, h: 100 } });
expect(next.document.artboards[0]?.layers[0]).toMatchObject({ sourceRect: { x: 20, y: 10, w: 80, h: 40 } });
expect(next.document.assets[0]?.intrinsicSize).toEqual({ w: 100, h: 50 });
const reset = documentSetLayerSourceRectCommand.execute({ state: next }, { layerId: "r1" });
expect(reset.document.artboards[0]?.layers[0]).not.toHaveProperty("sourceRect");
});
test("resizes artboard bounds without scaling contents", () => {
const state = documentWithRaster();
const next = documentResizeArtboardCommand.execute({ state }, { id: "a1", bounds: { x: 5, y: 10, w: 640, h: 480 }, scaleContents: false });
expect(next.document.artboards[0]?.bounds).toEqual({ x: 5, y: 10, w: 640, h: 480 });
expect(next.document.artboards[0]?.layers[0]?.transform).toEqual(state.document.artboards[0]?.layers[0]?.transform);
});
test("resizes an artboard and scales nested contents including masks", () => {
const state = documentWithRaster();
const leaf = state.document.artboards[0]!.layers[0]!;
state.document.artboards[0]!.layers = [{ ...group("g", "Group"), children: [leaf] }];
const next = documentResizeArtboardCommand.execute({ state }, { id: "a1", bounds: { x: 10, y: 20, w: 640, h: 120 }, scaleContents: true });
const nested = next.document.artboards[0]?.layers[0];
expect(nested?.type === "group" ? nested.children[0]?.transform : undefined).toEqual({ position: { x: 30, y: 30 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
});
test("updates asset sources", () => {
const state = documentAddAssetCommand.execute(
{ state: createInitialAppState("Test") },
@@ -403,6 +431,12 @@ function documentWithLayers(layers: Layer[]) {
};
}
function documentWithRaster() {
const state = documentWithLayers([{ ...raster("r1", "Raster", "asset-1"), transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 } }]);
state.document.assets = [{ id: "asset-1", name: "Raster", mimeType: "image/png", source: "asset://raster", intrinsicSize: { w: 100, h: 50 } }];
return state;
}
function group(id: string, name: string) {
return {
id,

View File

@@ -22,6 +22,12 @@ export type DocumentSetArtboardBoundsPayload = {
bounds: Rect;
};
export type DocumentResizeArtboardPayload = {
id: ArtboardId;
bounds: Rect;
scaleContents: boolean;
};
export type DocumentRemoveArtboardPayload = {
id: ArtboardId;
};
@@ -104,6 +110,11 @@ export type DocumentSetLayerOpacityPayload = {
opacity: number;
};
export type DocumentSetLayerSourceRectPayload = {
layerId: LayerId;
sourceRect?: Rect;
};
export type DocumentDuplicateLayerPayload = {
layerId: LayerId;
idByLayerId: Record<LayerId, LayerId>;
@@ -189,6 +200,29 @@ export const documentSetArtboardBoundsCommand: Command<DocumentSetArtboardBounds
},
};
export const documentResizeArtboardCommand: Command<DocumentResizeArtboardPayload> = {
id: commandIds.documentResizeArtboard,
name: "Resize artboard",
execute({ state }, payload) {
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.id);
const bounds = validRect(payload.bounds);
if (!artboard || artboard.locked || !bounds) return state;
const scaleX = bounds.w / artboard.bounds.w;
const scaleY = bounds.h / artboard.bounds.h;
return {
...state,
document: {
...state.document,
artboards: state.document.artboards.map((candidate) => candidate.id !== payload.id ? candidate : {
...candidate,
bounds,
layers: payload.scaleContents ? scaleLayerTree(candidate.layers, artboard.bounds, bounds, scaleX, scaleY) : candidate.layers,
}),
},
};
},
};
export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
id: commandIds.documentRemoveArtboard,
name: "Remove artboard",
@@ -424,6 +458,27 @@ export const documentSetLayerOpacityCommand: Command<DocumentSetLayerOpacityPayl
},
};
export const documentSetLayerSourceRectCommand: Command<DocumentSetLayerSourceRectPayload> = {
id: commandIds.documentSetLayerSourceRect,
name: "Crop layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.type === "group" || location.layer.locked || location.layer.transform.rotation !== 0) return state;
const leaf = location.layer;
const asset = state.document.assets.find((candidate) => candidate.id === leaf.assetId);
if (!asset) return state;
const sourceRect = payload.sourceRect ? clampSourceRect(payload.sourceRect, asset.intrinsicSize.w, asset.intrinsicSize.h) : undefined;
if (payload.sourceRect && !sourceRect) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
if (layer.type === "group") return layer;
if (sourceRect) return { ...layer, sourceRect };
const uncropped = { ...layer };
delete uncropped.sourceRect;
return uncropped;
}) };
},
};
export const documentDuplicateLayerCommand: Command<DocumentDuplicateLayerPayload> = {
id: commandIds.documentDuplicateLayer,
name: "Duplicate layer",
@@ -665,6 +720,7 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
export const documentCommands = [
documentAddArtboardCommand,
documentSetArtboardBoundsCommand,
documentResizeArtboardCommand,
documentRemoveArtboardCommand,
documentSetArtboardVisibleCommand,
documentSetArtboardLockedCommand,
@@ -681,6 +737,7 @@ export const documentCommands = [
documentSetLayerVisibleCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerSourceRectCommand,
documentDuplicateLayerCommand,
documentRenameLayerCommand,
documentSetLayerClippingMaskCommand,
@@ -688,3 +745,33 @@ export const documentCommands = [
documentApplyLayerMaskOperationCommand,
documentRemoveLayerMaskCommand,
] satisfies Command<unknown>[];
function validRect(rect: Rect): Rect | undefined {
return [rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? { ...rect } : undefined;
}
function clampSourceRect(rect: Rect, width: number, height: number): Rect | undefined {
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite)) return undefined;
const x = Math.max(0, Math.min(width - 1, rect.x));
const y = Math.max(0, Math.min(height - 1, rect.y));
const w = Math.min(width - x, rect.w);
const h = Math.min(height - y, rect.h);
return w >= 1 && h >= 1 ? { x, y, w, h } : undefined;
}
function scaleLayerTree(layers: Layer[], before: Rect, after: Rect, scaleX: number, scaleY: number): Layer[] {
return layers.map((layer) => layer.type === "group" ? {
...layer,
children: scaleLayerTree(layer.children, before, after, scaleX, scaleY),
} : {
...layer,
transform: {
...layer.transform,
position: {
x: after.x + (layer.transform.position.x - before.x) * scaleX,
y: after.y + (layer.transform.position.y - before.y) * scaleY,
},
scale: { x: layer.transform.scale.x * scaleX, y: layer.transform.scale.y * scaleY },
},
});
}

View File

@@ -2,6 +2,7 @@ export const commandIds = {
projectOpen: "project.open",
documentAddArtboard: "document.addArtboard",
documentSetArtboardBounds: "document.setArtboardBounds",
documentResizeArtboard: "document.resizeArtboard",
documentRemoveArtboard: "document.removeArtboard",
documentSetArtboardVisible: "document.setArtboardVisible",
documentSetArtboardLocked: "document.setArtboardLocked",
@@ -18,6 +19,7 @@ export const commandIds = {
documentSetLayerVisible: "document.setLayerVisible",
documentSetLayerLocked: "document.setLayerLocked",
documentSetLayerOpacity: "document.setLayerOpacity",
documentSetLayerSourceRect: "document.setLayerSourceRect",
documentDuplicateLayer: "document.duplicateLayer",
documentRenameLayer: "document.renameLayer",
documentSetLayerClippingMask: "document.setLayerClippingMask",

View File

@@ -16,11 +16,13 @@ import type {
DocumentRenameArtboardPayload,
DocumentRenameLayerPayload,
DocumentSetArtboardBoundsPayload,
DocumentResizeArtboardPayload,
DocumentSetArtboardLockedPayload,
DocumentSetArtboardVisiblePayload,
DocumentSetLayerClippingMaskPayload,
DocumentSetLayerLockedPayload,
DocumentSetLayerOpacityPayload,
DocumentSetLayerSourceRectPayload,
DocumentSetLayerVisiblePayload,
DocumentUpdateAssetSourcePayload,
DocumentUngroupLayerPayload,
@@ -63,6 +65,7 @@ export type CommandPayloads = {
[commandIds.projectOpen]: ProjectOpenPayload;
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
[commandIds.documentResizeArtboard]: DocumentResizeArtboardPayload;
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
@@ -82,6 +85,7 @@ export type CommandPayloads = {
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
[commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload;
[commandIds.documentSetLayerSourceRect]: DocumentSetLayerSourceRectPayload;
[commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload;
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;

View File

@@ -47,7 +47,10 @@ function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerI
return layers.map((layer) => {
if (layer.id === layerId && layer.type !== "group") {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
return asset ? { ...layer, transform: { ...layer.transform, position: { x: bounds.x, y: bounds.y }, scale: { x: bounds.w / asset.intrinsicSize.w, y: bounds.h / asset.intrinsicSize.h } } } : layer;
if (!asset) return layer;
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
const scale = { x: bounds.w / source.w, y: bounds.h / source.h };
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x - source.x * scale.x, y: bounds.y - source.y * scale.y }, scale } };
}
return layer.type === "group" ? { ...layer, children: mapLeafBounds(document, layer.children, layerId, bounds) } : layer;
});