feat: add crop and canvas resize workflows
This commit is contained in:
@@ -18,11 +18,13 @@ import {
|
|||||||
documentRenameArtboardCommand,
|
documentRenameArtboardCommand,
|
||||||
documentRenameLayerCommand,
|
documentRenameLayerCommand,
|
||||||
documentSetArtboardBoundsCommand,
|
documentSetArtboardBoundsCommand,
|
||||||
|
documentResizeArtboardCommand,
|
||||||
documentSetArtboardLockedCommand,
|
documentSetArtboardLockedCommand,
|
||||||
documentSetArtboardVisibleCommand,
|
documentSetArtboardVisibleCommand,
|
||||||
documentSetLayerClippingMaskCommand,
|
documentSetLayerClippingMaskCommand,
|
||||||
documentSetLayerLockedCommand,
|
documentSetLayerLockedCommand,
|
||||||
documentSetLayerOpacityCommand,
|
documentSetLayerOpacityCommand,
|
||||||
|
documentSetLayerSourceRectCommand,
|
||||||
documentSetLayerVisibleCommand,
|
documentSetLayerVisibleCommand,
|
||||||
documentUpdateAssetSourceCommand,
|
documentUpdateAssetSourceCommand,
|
||||||
documentUngroupLayerCommand,
|
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", () => {
|
test("updates asset sources", () => {
|
||||||
const state = documentAddAssetCommand.execute(
|
const state = documentAddAssetCommand.execute(
|
||||||
{ state: createInitialAppState("Test") },
|
{ 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) {
|
function group(id: string, name: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export type DocumentSetArtboardBoundsPayload = {
|
|||||||
bounds: Rect;
|
bounds: Rect;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentResizeArtboardPayload = {
|
||||||
|
id: ArtboardId;
|
||||||
|
bounds: Rect;
|
||||||
|
scaleContents: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type DocumentRemoveArtboardPayload = {
|
export type DocumentRemoveArtboardPayload = {
|
||||||
id: ArtboardId;
|
id: ArtboardId;
|
||||||
};
|
};
|
||||||
@@ -104,6 +110,11 @@ export type DocumentSetLayerOpacityPayload = {
|
|||||||
opacity: number;
|
opacity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocumentSetLayerSourceRectPayload = {
|
||||||
|
layerId: LayerId;
|
||||||
|
sourceRect?: Rect;
|
||||||
|
};
|
||||||
|
|
||||||
export type DocumentDuplicateLayerPayload = {
|
export type DocumentDuplicateLayerPayload = {
|
||||||
layerId: LayerId;
|
layerId: LayerId;
|
||||||
idByLayerId: Record<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> = {
|
export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
|
||||||
id: commandIds.documentRemoveArtboard,
|
id: commandIds.documentRemoveArtboard,
|
||||||
name: "Remove artboard",
|
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> = {
|
export const documentDuplicateLayerCommand: Command<DocumentDuplicateLayerPayload> = {
|
||||||
id: commandIds.documentDuplicateLayer,
|
id: commandIds.documentDuplicateLayer,
|
||||||
name: "Duplicate layer",
|
name: "Duplicate layer",
|
||||||
@@ -665,6 +720,7 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
|
|||||||
export const documentCommands = [
|
export const documentCommands = [
|
||||||
documentAddArtboardCommand,
|
documentAddArtboardCommand,
|
||||||
documentSetArtboardBoundsCommand,
|
documentSetArtboardBoundsCommand,
|
||||||
|
documentResizeArtboardCommand,
|
||||||
documentRemoveArtboardCommand,
|
documentRemoveArtboardCommand,
|
||||||
documentSetArtboardVisibleCommand,
|
documentSetArtboardVisibleCommand,
|
||||||
documentSetArtboardLockedCommand,
|
documentSetArtboardLockedCommand,
|
||||||
@@ -681,6 +737,7 @@ export const documentCommands = [
|
|||||||
documentSetLayerVisibleCommand,
|
documentSetLayerVisibleCommand,
|
||||||
documentSetLayerLockedCommand,
|
documentSetLayerLockedCommand,
|
||||||
documentSetLayerOpacityCommand,
|
documentSetLayerOpacityCommand,
|
||||||
|
documentSetLayerSourceRectCommand,
|
||||||
documentDuplicateLayerCommand,
|
documentDuplicateLayerCommand,
|
||||||
documentRenameLayerCommand,
|
documentRenameLayerCommand,
|
||||||
documentSetLayerClippingMaskCommand,
|
documentSetLayerClippingMaskCommand,
|
||||||
@@ -688,3 +745,33 @@ export const documentCommands = [
|
|||||||
documentApplyLayerMaskOperationCommand,
|
documentApplyLayerMaskOperationCommand,
|
||||||
documentRemoveLayerMaskCommand,
|
documentRemoveLayerMaskCommand,
|
||||||
] satisfies Command<unknown>[];
|
] 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",
|
projectOpen: "project.open",
|
||||||
documentAddArtboard: "document.addArtboard",
|
documentAddArtboard: "document.addArtboard",
|
||||||
documentSetArtboardBounds: "document.setArtboardBounds",
|
documentSetArtboardBounds: "document.setArtboardBounds",
|
||||||
|
documentResizeArtboard: "document.resizeArtboard",
|
||||||
documentRemoveArtboard: "document.removeArtboard",
|
documentRemoveArtboard: "document.removeArtboard",
|
||||||
documentSetArtboardVisible: "document.setArtboardVisible",
|
documentSetArtboardVisible: "document.setArtboardVisible",
|
||||||
documentSetArtboardLocked: "document.setArtboardLocked",
|
documentSetArtboardLocked: "document.setArtboardLocked",
|
||||||
@@ -18,6 +19,7 @@ export const commandIds = {
|
|||||||
documentSetLayerVisible: "document.setLayerVisible",
|
documentSetLayerVisible: "document.setLayerVisible",
|
||||||
documentSetLayerLocked: "document.setLayerLocked",
|
documentSetLayerLocked: "document.setLayerLocked",
|
||||||
documentSetLayerOpacity: "document.setLayerOpacity",
|
documentSetLayerOpacity: "document.setLayerOpacity",
|
||||||
|
documentSetLayerSourceRect: "document.setLayerSourceRect",
|
||||||
documentDuplicateLayer: "document.duplicateLayer",
|
documentDuplicateLayer: "document.duplicateLayer",
|
||||||
documentRenameLayer: "document.renameLayer",
|
documentRenameLayer: "document.renameLayer",
|
||||||
documentSetLayerClippingMask: "document.setLayerClippingMask",
|
documentSetLayerClippingMask: "document.setLayerClippingMask",
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import type {
|
|||||||
DocumentRenameArtboardPayload,
|
DocumentRenameArtboardPayload,
|
||||||
DocumentRenameLayerPayload,
|
DocumentRenameLayerPayload,
|
||||||
DocumentSetArtboardBoundsPayload,
|
DocumentSetArtboardBoundsPayload,
|
||||||
|
DocumentResizeArtboardPayload,
|
||||||
DocumentSetArtboardLockedPayload,
|
DocumentSetArtboardLockedPayload,
|
||||||
DocumentSetArtboardVisiblePayload,
|
DocumentSetArtboardVisiblePayload,
|
||||||
DocumentSetLayerClippingMaskPayload,
|
DocumentSetLayerClippingMaskPayload,
|
||||||
DocumentSetLayerLockedPayload,
|
DocumentSetLayerLockedPayload,
|
||||||
DocumentSetLayerOpacityPayload,
|
DocumentSetLayerOpacityPayload,
|
||||||
|
DocumentSetLayerSourceRectPayload,
|
||||||
DocumentSetLayerVisiblePayload,
|
DocumentSetLayerVisiblePayload,
|
||||||
DocumentUpdateAssetSourcePayload,
|
DocumentUpdateAssetSourcePayload,
|
||||||
DocumentUngroupLayerPayload,
|
DocumentUngroupLayerPayload,
|
||||||
@@ -63,6 +65,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.projectOpen]: ProjectOpenPayload;
|
[commandIds.projectOpen]: ProjectOpenPayload;
|
||||||
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
||||||
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
||||||
|
[commandIds.documentResizeArtboard]: DocumentResizeArtboardPayload;
|
||||||
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
|
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
|
||||||
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
|
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
|
||||||
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
|
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
|
||||||
@@ -82,6 +85,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
|
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
|
||||||
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
|
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
|
||||||
[commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload;
|
[commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload;
|
||||||
|
[commandIds.documentSetLayerSourceRect]: DocumentSetLayerSourceRectPayload;
|
||||||
[commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload;
|
[commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload;
|
||||||
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
|
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
|
||||||
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;
|
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerI
|
|||||||
return layers.map((layer) => {
|
return layers.map((layer) => {
|
||||||
if (layer.id === layerId && layer.type !== "group") {
|
if (layer.id === layerId && layer.type !== "group") {
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
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;
|
return layer.type === "group" ? { ...layer, children: mapLeafBounds(document, layer.children, layerId, bounds) } : layer;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { BaseLayer } from "./base-layer";
|
import type { BaseLayer } from "./base-layer";
|
||||||
import type { AssetId } from "./id";
|
import type { AssetId } from "./id";
|
||||||
|
import type { Rect } from "./geometry";
|
||||||
|
|
||||||
export type ImageLayer = BaseLayer & {
|
export type ImageLayer = BaseLayer & {
|
||||||
type: "image";
|
type: "image";
|
||||||
assetId: AssetId;
|
assetId: AssetId;
|
||||||
|
/** Non-destructive source pixels displayed by this layer. Absence means the full asset. */
|
||||||
|
sourceRect?: Rect;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { BaseLayer } from "./base-layer";
|
import type { BaseLayer } from "./base-layer";
|
||||||
import type { AssetId } from "./id";
|
import type { AssetId } from "./id";
|
||||||
|
import type { Rect } from "./geometry";
|
||||||
|
|
||||||
export type RasterLayer = BaseLayer & {
|
export type RasterLayer = BaseLayer & {
|
||||||
type: "raster";
|
type: "raster";
|
||||||
assetId: AssetId;
|
assetId: AssetId;
|
||||||
|
/** Non-destructive source pixels displayed by this layer. Absence means the full asset. */
|
||||||
|
sourceRect?: Rect;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ describe("document read indexes", () => {
|
|||||||
expect(resolveIndexedLayerBounds(index, raster("missing", "Missing", "missing-asset"))).toBeUndefined();
|
expect(resolveIndexedLayerBounds(index, raster("missing", "Missing", "missing-asset"))).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resolves a cropped layer from its retained source-pixel position", () => {
|
||||||
|
const cropped = { ...document, artboards: [{ ...document.artboards[0]!, layers: [{ ...raster("cropped", "Cropped", "asset-target", { x: 10, y: 20 }, { x: 2, y: 3 }), sourceRect: { x: 5, y: 4, w: 20, h: 10 } }] }] };
|
||||||
|
expect(resolveIndexedLayerBounds(createDocumentReadIndex(cropped), "cropped")).toEqual({ x: 20, y: 32, w: 40, h: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
test("visits layers back to front without mutating source order", () => {
|
test("visits layers back to front without mutating source order", () => {
|
||||||
const layers = document.artboards[0]!.layers;
|
const layers = document.artboards[0]!.layers;
|
||||||
const visited: string[] = [];
|
const visited: string[] = [];
|
||||||
|
|||||||
@@ -79,12 +79,13 @@ export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: L
|
|||||||
case "raster": {
|
case "raster": {
|
||||||
const asset = index.assetById.get(layer.assetId);
|
const asset = index.assetById.get(layer.assetId);
|
||||||
if (!asset) return undefined;
|
if (!asset) return undefined;
|
||||||
|
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: layer.transform.position.x,
|
x: layer.transform.position.x + source.x * layer.transform.scale.x,
|
||||||
y: layer.transform.position.y,
|
y: layer.transform.position.y + source.y * layer.transform.scale.y,
|
||||||
w: asset.intrinsicSize.w * layer.transform.scale.x,
|
w: source.w * layer.transform.scale.x,
|
||||||
h: asset.intrinsicSize.h * layer.transform.scale.y,
|
h: source.h * layer.transform.scale.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ describe("transform targets", () => {
|
|||||||
expect(layer?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
|
expect(layer?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("applies visible bounds to a cropped layer while retaining its source crop", () => {
|
||||||
|
const cropped = { ...document, artboards: [{ ...document.artboards[0]!, layers: [{ ...document.artboards[0]!.layers[0]!, sourceRect: { x: 10, y: 5, w: 40, h: 20 } }] }] };
|
||||||
|
const next = applyTransformTargetBounds(cropped, { type: "layer", id: "l1" }, { x: 100, y: 80, w: 200, h: 60 });
|
||||||
|
expect(next.artboards[0]?.layers[0]).toMatchObject({ sourceRect: { x: 10, y: 5, w: 40, h: 20 }, transform: { position: { x: 50, y: 65 }, scale: { x: 5, y: 3 } } });
|
||||||
|
expect(resolveTransformTargetBounds(next, { type: "layer", id: "l1" })).toEqual({ x: 100, y: 80, w: 200, h: 60 });
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps attached mask bounds in sync with transformed layers", () => {
|
test("keeps attached mask bounds in sync with transformed layers", () => {
|
||||||
const maskedDocument: ImageDocument = {
|
const maskedDocument: ImageDocument = {
|
||||||
...document,
|
...document,
|
||||||
|
|||||||
@@ -53,12 +53,13 @@ function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undef
|
|||||||
case "raster": {
|
case "raster": {
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
if (!asset) return undefined;
|
if (!asset) return undefined;
|
||||||
|
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: layer.transform.position.x,
|
x: layer.transform.position.x + source.x * layer.transform.scale.x,
|
||||||
y: layer.transform.position.y,
|
y: layer.transform.position.y + source.y * layer.transform.scale.y,
|
||||||
w: asset.intrinsicSize.w * layer.transform.scale.x,
|
w: source.w * layer.transform.scale.x,
|
||||||
h: asset.intrinsicSize.h * layer.transform.scale.y,
|
h: source.h * layer.transform.scale.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ describe("project format", () => {
|
|||||||
expect(() => serializeProject(missingAsset)).toThrow("references missing asset");
|
expect(() => serializeProject(missingAsset)).toThrow("references missing asset");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects invalid persisted layer crops", () => {
|
||||||
|
const document = projectDocument();
|
||||||
|
const layer = document.artboards[0]!.layers[0]!;
|
||||||
|
if (layer.type === "group") throw new Error("Expected image layer");
|
||||||
|
layer.sourceRect = { x: 0, y: 0, w: 0, h: 10 };
|
||||||
|
const source = JSON.stringify({ format: "image-studio-project", version: 1, savedAt: "2026-07-10T12:00:00.000Z", document });
|
||||||
|
expect(() => parseProject(source)).toThrow("invalid source crop");
|
||||||
|
});
|
||||||
|
|
||||||
test("creates safe project file names", () => {
|
test("creates safe project file names", () => {
|
||||||
expect(projectFileName(" Summer / Study ")).toBe("Summer-Study.image-studio.json");
|
expect(projectFileName(" Summer / Study ")).toBe("Summer-Study.image-studio.json");
|
||||||
expect(projectFileName("***")).toBe("untitled.image-studio.json");
|
expect(projectFileName("***")).toBe("untitled.image-studio.json");
|
||||||
@@ -52,7 +61,7 @@ function projectDocument(): ImageDocument {
|
|||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
visible: true,
|
visible: true,
|
||||||
locked: false,
|
locked: false,
|
||||||
layers: [{ id: "layer-1", type: "image", name: "Pixels", visible: true, locked: false, opacity: 1, assetId: "asset-1", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } }],
|
layers: [{ id: "layer-1", type: "image", name: "Pixels", visible: true, locked: false, opacity: 1, assetId: "asset-1", sourceRect: { x: 2, y: 3, w: 6, h: 12 }, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } }],
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { Rect } from "@core/geometry";
|
||||||
|
|
||||||
export const PROJECT_FORMAT = "image-studio-project";
|
export const PROJECT_FORMAT = "image-studio-project";
|
||||||
export const CURRENT_PROJECT_VERSION = 1;
|
export const CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -112,6 +113,8 @@ function assertLayers(value: unknown[]): asserts value is Layer[] {
|
|||||||
assertLayers(layer.children);
|
assertLayers(layer.children);
|
||||||
} else if ((layer.type === "image" || layer.type === "raster") && typeof layer.assetId !== "string") {
|
} else if ((layer.type === "image" || layer.type === "raster") && typeof layer.assetId !== "string") {
|
||||||
throw new Error("A project layer is missing its asset reference.");
|
throw new Error("A project layer is missing its asset reference.");
|
||||||
|
} else if ((layer.type === "image" || layer.type === "raster") && layer.sourceRect !== undefined && (!isRect(layer.sourceRect) || layer.sourceRect.w < 1 || layer.sourceRect.h < 1)) {
|
||||||
|
throw new Error("A project layer contains an invalid source crop.");
|
||||||
} else if (layer.type !== "image" && layer.type !== "raster") {
|
} else if (layer.type !== "image" && layer.type !== "raster") {
|
||||||
throw new Error(`Unsupported layer type: ${layer.type}.`);
|
throw new Error(`Unsupported layer type: ${layer.type}.`);
|
||||||
}
|
}
|
||||||
@@ -137,7 +140,7 @@ function isSize(value: unknown): boolean {
|
|||||||
return isRecord(value) && isFiniteNumber(value.w) && isFiniteNumber(value.h) && value.w >= 0 && value.h >= 0;
|
return isRecord(value) && isFiniteNumber(value.w) && isFiniteNumber(value.h) && value.w >= 0 && value.h >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRect(value: unknown): boolean {
|
function isRect(value: unknown): value is Rect {
|
||||||
return isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.w) && isFiniteNumber(value.h);
|
return isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.w) && isFiniteNumber(value.h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
platform/browser/exportArtboardPng.test.ts
Normal file
17
platform/browser/exportArtboardPng.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { resolveLayerDrawImage } from "./exportArtboardPng";
|
||||||
|
|
||||||
|
describe("artboard PNG export", () => {
|
||||||
|
test("draws only cropped source pixels at their retained document position", () => {
|
||||||
|
const draw = resolveLayerDrawImage({
|
||||||
|
id: "layer", type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId: "asset",
|
||||||
|
sourceRect: { x: 10, y: 5, w: 40, h: 20 },
|
||||||
|
transform: { position: { x: 100, y: 50 }, scale: { x: 2, y: 3 }, rotation: 0 },
|
||||||
|
}, { w: 200, h: 100 });
|
||||||
|
|
||||||
|
expect(draw).toEqual({
|
||||||
|
source: { x: 10, y: 5, w: 40, h: 20 },
|
||||||
|
destination: { x: 120, y: 65, w: 80, h: 60 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { Artboard } from "@core/artboard";
|
|||||||
import type { Asset } from "@core/asset";
|
import type { Asset } from "@core/asset";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { Size } from "@core/geometry";
|
||||||
import { getLayerMask } from "@core/layer-mask-utils";
|
import { getLayerMask } from "@core/layer-mask-utils";
|
||||||
|
|
||||||
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
|
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
|
||||||
@@ -66,16 +67,25 @@ async function drawLayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const image = await loadImage(asset.source);
|
const image = await loadImage(asset.source);
|
||||||
context.drawImage(
|
const draw = resolveLayerDrawImage(layer, asset.intrinsicSize);
|
||||||
image,
|
context.drawImage(image, draw.source.x, draw.source.y, draw.source.w, draw.source.h,
|
||||||
layer.transform.position.x,
|
draw.destination.x, draw.destination.y, draw.destination.w, draw.destination.h);
|
||||||
layer.transform.position.y,
|
|
||||||
asset.intrinsicSize.w * layer.transform.scale.x,
|
|
||||||
asset.intrinsicSize.h * layer.transform.scale.y,
|
|
||||||
);
|
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveLayerDrawImage(layer: Exclude<Layer, { type: "group" }>, intrinsicSize: Size) {
|
||||||
|
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
destination: {
|
||||||
|
x: layer.transform.position.x + source.x * layer.transform.scale.x,
|
||||||
|
y: layer.transform.position.y + source.y * layer.transform.scale.y,
|
||||||
|
w: source.w * layer.transform.scale.x,
|
||||||
|
h: source.h * layer.transform.scale.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function drawMaskedLayer(
|
async function drawMaskedLayer(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs";
|
import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs";
|
||||||
import type { Asset } from "@core/asset";
|
import type { Asset } from "@core/asset";
|
||||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||||
|
import type { Rect } from "@core/geometry";
|
||||||
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
|
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
|
||||||
|
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
|
||||||
|
|
||||||
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
|
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
|
||||||
|
|
||||||
export type ImageTextureRenderer = {
|
export type ImageTextureRenderer = {
|
||||||
syncAssets(assets: readonly Asset[]): void;
|
syncAssets(assets: readonly Asset[]): void;
|
||||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
|
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
|
||||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
|
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
|
||||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number): boolean;
|
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number, sourceRect?: Rect): boolean;
|
||||||
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
|
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
|
||||||
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
|
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -88,7 +90,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
render(asset, rect, clipRect, opacity = 1, rotation = 0) {
|
render(asset, rect, clipRect, opacity = 1, rotation = 0, sourceRect) {
|
||||||
const clampedOpacity = clampOpacity(opacity);
|
const clampedOpacity = clampOpacity(opacity);
|
||||||
if (clampedOpacity <= 0) return true;
|
if (clampedOpacity <= 0) return true;
|
||||||
const rotatedRect = rotatedRectBounds(rect, rotation);
|
const rotatedRect = rotatedRectBounds(rect, rotation);
|
||||||
@@ -115,7 +117,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
|
||||||
gl.enableVertexAttribArray(texCoordLocation);
|
gl.enableVertexAttribArray(texCoordLocation);
|
||||||
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.disable(gl.BLEND);
|
gl.disable(gl.BLEND);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0) {
|
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0, sourceRect) {
|
||||||
const clampedOpacity = clampOpacity(opacity);
|
const clampedOpacity = clampOpacity(opacity);
|
||||||
if (clampedOpacity <= 0) return true;
|
if (clampedOpacity <= 0) return true;
|
||||||
const contentBounds = rotatedRectBounds(rect, rotation);
|
const contentBounds = rotatedRectBounds(rect, rotation);
|
||||||
@@ -157,12 +159,12 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
|
||||||
gl.enableVertexAttribArray(maskedTexCoordLocation);
|
gl.enableVertexAttribArray(maskedTexCoordLocation);
|
||||||
gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForRect(rect, maskRect), gl.DYNAMIC_DRAW);
|
||||||
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
|
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
|
||||||
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
@@ -170,7 +172,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.disable(gl.BLEND);
|
gl.disable(gl.BLEND);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0) {
|
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0, sourceRect) {
|
||||||
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
|
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
|
||||||
if (clampedOpacity <= 0) return true;
|
if (clampedOpacity <= 0) return true;
|
||||||
|
|
||||||
@@ -205,12 +207,12 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
|
||||||
gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation);
|
gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation);
|
||||||
gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForRect(rect, maskRect), gl.DYNAMIC_DRAW);
|
||||||
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
|
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
|
||||||
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -91,17 +91,18 @@ function renderLeafLayer(
|
|||||||
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
||||||
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
|
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
|
||||||
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
|
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
|
||||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
|
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
|
||||||
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
|
const contentClipRect = intersectScreenRects(effectiveClipRect, rect);
|
||||||
|
if (contentClipRect) imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, contentClipRect);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
|
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
|
||||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation);
|
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) return;
|
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) return;
|
||||||
|
|
||||||
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
||||||
if (!fallbackRect) return;
|
if (!fallbackRect) return;
|
||||||
|
|||||||
20
renderer/texture-coordinates.test.ts
Normal file
20
renderer/texture-coordinates.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
|
||||||
|
|
||||||
|
describe("texture coordinates", () => {
|
||||||
|
test("maps an asset-pixel crop to normalized UVs", () => {
|
||||||
|
expect(rounded(textureCoordinatesForCrop({ w: 200, h: 100 }, { x: 50, y: 10, w: 100, h: 40 }))).toEqual([
|
||||||
|
0.25, 0.1, 0.75, 0.1, 0.25, 0.5, 0.25, 0.5, 0.75, 0.1, 0.75, 0.5,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps cropped content onto the corresponding region of an aligned full-size mask", () => {
|
||||||
|
expect(rounded(textureCoordinatesForRect({ x: 50, y: 20, w: 100, h: 40 }, { x: 0, y: 0, w: 200, h: 100 }))).toEqual([
|
||||||
|
0.25, 0.2, 0.75, 0.2, 0.25, 0.6, 0.25, 0.6, 0.75, 0.2, 0.75, 0.6,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function rounded(values: Float32Array) {
|
||||||
|
return [...values].map((value) => Math.round(value * 1_000) / 1_000);
|
||||||
|
}
|
||||||
17
renderer/texture-coordinates.ts
Normal file
17
renderer/texture-coordinates.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Rect, Size } from "@core/geometry";
|
||||||
|
|
||||||
|
const fullCoordinates = [0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1] as const;
|
||||||
|
|
||||||
|
export function textureCoordinatesForCrop(size: Size, crop?: Rect): Float32Array {
|
||||||
|
if (!crop) return new Float32Array(fullCoordinates);
|
||||||
|
return textureCoordinatesForRect(crop, { x: 0, y: 0, w: size.w, h: size.h });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a drawn sub-rectangle onto the texture represented by sourceRect. */
|
||||||
|
export function textureCoordinatesForRect(drawRect: Rect, sourceRect: Rect): Float32Array {
|
||||||
|
const x1 = (drawRect.x - sourceRect.x) / sourceRect.w;
|
||||||
|
const x2 = (drawRect.x + drawRect.w - sourceRect.x) / sourceRect.w;
|
||||||
|
const y1 = (drawRect.y - sourceRect.y) / sourceRect.h;
|
||||||
|
const y2 = (drawRect.y + drawRect.h - sourceRect.y) / sourceRect.h;
|
||||||
|
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { BoundingBox, Copy, MaskHappy } from "@phosphor-icons/react";
|
import { ArrowsOut, BoundingBox, Copy, Crop, MaskHappy } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
@@ -24,6 +24,10 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
|||||||
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
|
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
|
||||||
const [opacityDraft, setOpacityDraft] = useState(() => String(Math.round((layerInfo?.layer.opacity ?? 1) * 100)));
|
const [opacityDraft, setOpacityDraft] = useState(() => String(Math.round((layerInfo?.layer.opacity ?? 1) * 100)));
|
||||||
const [rotationDraft, setRotationDraft] = useState(() => rotationDegrees(layerInfo));
|
const [rotationDraft, setRotationDraft] = useState(() => rotationDegrees(layerInfo));
|
||||||
|
const [cropOpen, setCropOpen] = useState(false);
|
||||||
|
const [cropDraft, setCropDraft] = useState(() => cropDraftFor(layerInfo, documentIndex));
|
||||||
|
const [resizeOpen, setResizeOpen] = useState(false);
|
||||||
|
const [resizeDraft, setResizeDraft] = useState(() => ({ w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)), scaleContents: false }));
|
||||||
const layer = layerInfo?.layer;
|
const layer = layerInfo?.layer;
|
||||||
const locked = layer?.locked ?? false;
|
const locked = layer?.locked ?? false;
|
||||||
const mask = layer ? getLayerMask(layer) : undefined;
|
const mask = layer ? getLayerMask(layer) : undefined;
|
||||||
@@ -35,6 +39,8 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
|||||||
|
|
||||||
useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]);
|
useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]);
|
||||||
useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]);
|
useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]);
|
||||||
|
useEffect(() => setCropDraft(cropDraftFor(layerInfo, documentIndex)), [documentIndex, layer?.id, layer?.type === "group" ? undefined : layer?.sourceRect]);
|
||||||
|
useEffect(() => setResizeDraft((current) => ({ ...current, w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)) })), [bounds.w, bounds.h, target]);
|
||||||
|
|
||||||
const commitField = (field: BoundsField) => {
|
const commitField = (field: BoundsField) => {
|
||||||
const value = Number.parseFloat(draft[field]);
|
const value = Number.parseFloat(draft[field]);
|
||||||
@@ -96,6 +102,11 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
|||||||
<button type="button" className={actionButtonClass()} disabled={locked} title={locked ? "Unlock the layer to duplicate it" : "Duplicate layer"} onClick={() => duplicateLayer(documentIndex, layerInfo!, dispatch)}>
|
<button type="button" className={actionButtonClass()} disabled={locked} title={locked ? "Unlock the layer to duplicate it" : "Duplicate layer"} onClick={() => duplicateLayer(documentIndex, layerInfo!, dispatch)}>
|
||||||
<Copy size={20} /> Duplicate
|
<Copy size={20} /> Duplicate
|
||||||
</button>
|
</button>
|
||||||
|
{layer.type !== "group" ? (
|
||||||
|
<button type="button" className={actionButtonClass()} disabled={locked || layer.transform.rotation !== 0} title={layer.transform.rotation !== 0 ? "Reset rotation before cropping" : "Crop visible source pixels"} onClick={() => setCropOpen((open) => !open)}>
|
||||||
|
<Crop size={20} /> Crop
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{layer.type !== "group" ? (
|
{layer.type !== "group" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -111,12 +122,49 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
|
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
|
||||||
|
{cropOpen && layer.type !== "group" ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
|
||||||
|
</>
|
||||||
|
) : target.type === "artboard" ? (
|
||||||
|
<>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<button type="button" className={actionButtonClass()} disabled={locked} onClick={() => setResizeOpen((open) => !open)}><ArrowsOut size={20} /> Resize canvas</button>
|
||||||
|
{resizeOpen ? <ArtboardResizeEditor draft={resizeDraft} setDraft={setResizeDraft} onCancel={() => setResizeOpen(false)} onApply={() => { const w = Number.parseFloat(resizeDraft.w); const h = Number.parseFloat(resizeDraft.h); if (Number.isFinite(w) && Number.isFinite(h) && w >= 1 && h >= 1) { dispatch(commandIds.documentResizeArtboard, { id: target.id, bounds: { ...bounds, w, h }, scaleContents: resizeDraft.scaleContents }); setResizeOpen(false); } }} /> : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CropDraft = Record<BoundsField, string>;
|
||||||
|
|
||||||
|
function CropEditor({ draft, setDraft, onApply, onCancel, onReset }: { draft: CropDraft; setDraft: Dispatch<SetStateAction<CropDraft>>; onApply: () => void; onCancel: () => void; onReset: () => void }) {
|
||||||
|
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Crop source pixels">
|
||||||
|
{(["x", "y", "w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Crop ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
|
||||||
|
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button><button type="button" className={actionButtonClass()} onClick={onReset}>Reset</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: { w: string; h: string; scaleContents: boolean }; setDraft: Dispatch<SetStateAction<{ w: string; h: string; scaleContents: boolean }>>; onApply: () => void; onCancel: () => void }) {
|
||||||
|
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Resize artboard canvas">
|
||||||
|
{(["w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Canvas ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
|
||||||
|
<label className="flex items-center gap-2 text-xs text-white/70"><input type="checkbox" checked={draft.scaleContents} onChange={(event) => setDraft((current) => ({ ...current, scaleContents: event.target.checked }))} />Scale contents</label>
|
||||||
|
<span className="max-w-40 text-[10px] text-white/45">Off changes canvas bounds only.</span>
|
||||||
|
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
|
||||||
|
if (!layerInfo || layerInfo.layer.type === "group") return { x: "0", y: "0", w: "1", h: "1" };
|
||||||
|
const asset = index.assetById.get(layerInfo.layer.assetId);
|
||||||
|
const rect = layerInfo.layer.sourceRect ?? { x: 0, y: 0, w: asset?.intrinsicSize.w ?? 1, h: asset?.intrinsicSize.h ?? 1 };
|
||||||
|
return draftFromBounds(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRectDraft(draft: CropDraft): Rect | undefined {
|
||||||
|
const rect = { x: Number.parseFloat(draft.x), y: Number.parseFloat(draft.y), w: Number.parseFloat(draft.w), h: Number.parseFloat(draft.h) };
|
||||||
|
return Object.values(rect).every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? rect : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function SimpleInput({ label, suffix, value, disabled, title, onChange, onCommit }: { label: string; suffix: string; value: string; disabled?: boolean; title?: string; onChange: (value: string) => void; onCommit: () => void }) {
|
function SimpleInput({ label, suffix, value, disabled, title, onChange, onCommit }: { label: string; suffix: string; value: string; disabled?: boolean; title?: string; onChange: (value: string) => void; onCommit: () => void }) {
|
||||||
return (
|
return (
|
||||||
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`} title={title}>
|
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`} title={title}>
|
||||||
|
|||||||
Reference in New Issue
Block a user