feat: add crop and canvas resize workflows
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user