feat: add contextual layer inspector
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
documentAddRasterLayerCommand,
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentGroupLayersCommand,
|
||||
documentDuplicateLayerCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
documentRemoveLayerCommand,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
documentSetArtboardVisibleCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentSetLayerOpacityCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentUpdateAssetSourceCommand,
|
||||
documentUngroupLayerCommand,
|
||||
@@ -335,6 +337,46 @@ describe("document commands", () => {
|
||||
expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true);
|
||||
});
|
||||
|
||||
test("sets normalized opacity and refuses locked layer edits", () => {
|
||||
const state = documentWithLayers([raster("a", "A")]);
|
||||
const translucent = documentSetLayerOpacityCommand.execute({ state }, { layerId: "a", opacity: 0.35 });
|
||||
const clamped = documentSetLayerOpacityCommand.execute({ state: translucent }, { layerId: "a", opacity: 2 });
|
||||
const lockedState = documentSetLayerLockedCommand.execute({ state: clamped }, { layerId: "a", locked: true });
|
||||
const ignored = documentSetLayerOpacityCommand.execute({ state: lockedState }, { layerId: "a", opacity: 0 });
|
||||
|
||||
expect(translucent.document.artboards[0]?.layers[0]?.opacity).toBe(0.35);
|
||||
expect(clamped.document.artboards[0]?.layers[0]?.opacity).toBe(1);
|
||||
expect(ignored).toBe(lockedState);
|
||||
});
|
||||
|
||||
test("duplicates a nested group with remapped child mask references", () => {
|
||||
const maskedTarget = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } };
|
||||
const parent = { ...group("parent", "Parent"), children: [raster("mask", "Mask"), maskedTarget] };
|
||||
const state = documentWithLayers([parent]);
|
||||
const next = documentDuplicateLayerCommand.execute(
|
||||
{ state },
|
||||
{ layerId: "parent", idByLayerId: { parent: "parent-copy", mask: "mask-copy", target: "target-copy" } },
|
||||
);
|
||||
|
||||
const duplicate = next.document.artboards[0]?.layers[1];
|
||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["parent", "parent-copy"]);
|
||||
expect(duplicate?.type === "group" ? duplicate.children.map((layer) => layer.id) : []).toEqual(["mask-copy", "target-copy"]);
|
||||
expect(duplicate?.type === "group" ? duplicate.children[1]?.layerMask?.maskLayerId : undefined).toBe("mask-copy");
|
||||
expect(next.editor.selection.layerIds).toEqual(["parent-copy"]);
|
||||
});
|
||||
|
||||
test("duplicates an attached top-level mask beside its target", () => {
|
||||
const target = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } };
|
||||
const state = documentWithLayers([raster("mask", "Mask"), target]);
|
||||
const next = documentDuplicateLayerCommand.execute(
|
||||
{ state },
|
||||
{ layerId: "target", idByLayerId: { mask: "mask-copy", target: "target-copy" } },
|
||||
);
|
||||
|
||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target", "mask-copy", "target-copy"]);
|
||||
expect(next.document.artboards[0]?.layers[3]?.layerMask?.maskLayerId).toBe("mask-copy");
|
||||
});
|
||||
|
||||
test("sets artboard bounds", () => {
|
||||
const state = documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { RasterLayer } from "@core/raster-layer";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { LayerGroup } from "@core/layer-group";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
@@ -98,6 +99,16 @@ export type DocumentSetLayerLockedPayload = {
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
export type DocumentSetLayerOpacityPayload = {
|
||||
layerId: LayerId;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export type DocumentDuplicateLayerPayload = {
|
||||
layerId: LayerId;
|
||||
idByLayerId: Record<LayerId, LayerId>;
|
||||
};
|
||||
|
||||
export type DocumentRenameLayerPayload = {
|
||||
layerId: LayerId;
|
||||
name: string;
|
||||
@@ -402,6 +413,56 @@ export const documentSetLayerLockedCommand: Command<DocumentSetLayerLockedPayloa
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetLayerOpacityCommand: Command<DocumentSetLayerOpacityPayload> = {
|
||||
id: commandIds.documentSetLayerOpacity,
|
||||
name: "Set layer opacity",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || location.layer.locked || !Number.isFinite(payload.opacity)) return state;
|
||||
const opacity = Math.min(1, Math.max(0, payload.opacity));
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, opacity })) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentDuplicateLayerCommand: Command<DocumentDuplicateLayerPayload> = {
|
||||
id: commandIds.documentDuplicateLayer,
|
||||
name: "Duplicate layer",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || location.layer.locked) return state;
|
||||
const maskId = getLayerMask(location.layer)?.maskLayerId;
|
||||
const mask = maskId ? location.siblings.find((layer) => layer.id === maskId) : undefined;
|
||||
const sourceLayers = mask ? [mask, location.layer] : [location.layer];
|
||||
const sourceIds = new Set(sourceLayers.flatMap((layer) => [...collectLayerIds(layer)]));
|
||||
const mappedIds = [...sourceIds].map((id) => payload.idByLayerId[id]);
|
||||
if (mappedIds.some((id) => !id) || new Set(mappedIds).size !== mappedIds.length || mappedIds.some((id) => findLayerLocation(state.document, id!))) return state;
|
||||
|
||||
const duplicates = sourceLayers.map((layer) => duplicateLayerTree(layer, payload.idByLayerId));
|
||||
const insertionIndex = Math.max(...sourceLayers.map((layer) => location.siblings.findIndex((candidate) => candidate.id === layer.id))) + 1;
|
||||
const siblings = [...location.siblings.slice(0, insertionIndex), ...duplicates, ...location.siblings.slice(insertionIndex)];
|
||||
const duplicatedLayerId = payload.idByLayerId[payload.layerId];
|
||||
if (!duplicatedLayerId) return state;
|
||||
return {
|
||||
...state,
|
||||
document: replaceLayerListInDocument(state.document, location.artboardId, location.parentGroupId, siblings),
|
||||
editor: { ...state.editor, selection: { artboardId: location.artboardId, layerIds: [duplicatedLayerId] } },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function duplicateLayerTree(layer: Layer, idByLayerId: Record<LayerId, LayerId>): Layer {
|
||||
const layerMask = getLayerMask(layer);
|
||||
const duplicatedMaskId = layerMask ? idByLayerId[layerMask.maskLayerId] : undefined;
|
||||
const duplicated = {
|
||||
...layer,
|
||||
id: idByLayerId[layer.id]!,
|
||||
name: `${layer.name} copy`,
|
||||
transform: { ...layer.transform, position: { ...layer.transform.position }, scale: { ...layer.transform.scale } },
|
||||
...(layerMask && duplicatedMaskId ? { layerMask: { ...layerMask, maskLayerId: duplicatedMaskId }, clippingMask: layer.clippingMask ? { maskLayerId: duplicatedMaskId } : undefined } : {}),
|
||||
};
|
||||
return layer.type === "group" ? { ...duplicated, type: "group", children: layer.children.map((child) => duplicateLayerTree(child, idByLayerId)) } : duplicated;
|
||||
}
|
||||
|
||||
export const documentRenameLayerCommand: Command<DocumentRenameLayerPayload> = {
|
||||
id: commandIds.documentRenameLayer,
|
||||
name: "Rename layer",
|
||||
@@ -619,6 +680,8 @@ export const documentCommands = [
|
||||
documentRemoveLayerCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentSetLayerOpacityCommand,
|
||||
documentDuplicateLayerCommand,
|
||||
documentRenameLayerCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentAddLayerMaskCommand,
|
||||
|
||||
@@ -17,6 +17,8 @@ export const commandIds = {
|
||||
documentRemoveLayer: "document.removeLayer",
|
||||
documentSetLayerVisible: "document.setLayerVisible",
|
||||
documentSetLayerLocked: "document.setLayerLocked",
|
||||
documentSetLayerOpacity: "document.setLayerOpacity",
|
||||
documentDuplicateLayer: "document.duplicateLayer",
|
||||
documentRenameLayer: "document.renameLayer",
|
||||
documentSetLayerClippingMask: "document.setLayerClippingMask",
|
||||
documentAddLayerMask: "document.addLayerMask",
|
||||
@@ -56,6 +58,7 @@ export const commandIds = {
|
||||
transformBegin: "transform.begin",
|
||||
transformUpdate: "transform.update",
|
||||
transformSetBounds: "transform.setBounds",
|
||||
transformSetRotation: "transform.setRotation",
|
||||
transformEnd: "transform.end",
|
||||
viewportPan: "viewport.pan",
|
||||
viewportSetZoom: "viewport.setZoom",
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
documentApplyLayerMaskOperationCommand,
|
||||
documentCommands,
|
||||
documentGroupLayersCommand,
|
||||
documentDuplicateLayerCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
documentRemoveLayerCommand,
|
||||
@@ -22,6 +23,7 @@ export {
|
||||
documentSetArtboardVisibleCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentSetLayerOpacityCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentUpdateAssetSourceCommand,
|
||||
documentUngroupLayerCommand,
|
||||
@@ -35,6 +37,7 @@ export type {
|
||||
DocumentAddRasterLayerPayload,
|
||||
DocumentApplyLayerMaskOperationPayload,
|
||||
DocumentGroupLayersPayload,
|
||||
DocumentDuplicateLayerPayload,
|
||||
DocumentMoveLayerPayload,
|
||||
DocumentRemoveArtboardPayload,
|
||||
DocumentRemoveLayerPayload,
|
||||
@@ -46,6 +49,7 @@ export type {
|
||||
DocumentSetArtboardVisiblePayload,
|
||||
DocumentSetLayerClippingMaskPayload,
|
||||
DocumentSetLayerLockedPayload,
|
||||
DocumentSetLayerOpacityPayload,
|
||||
DocumentSetLayerVisiblePayload,
|
||||
DocumentUpdateAssetSourcePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
@@ -90,8 +94,8 @@ export { createCommandRegistry } from "./registry";
|
||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
export { toolChooseGenerateIntentCommand, toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
|
||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
|
||||
export type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
export {
|
||||
viewportCommands,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
DocumentAddRasterLayerPayload,
|
||||
DocumentApplyLayerMaskOperationPayload,
|
||||
DocumentGroupLayersPayload,
|
||||
DocumentDuplicateLayerPayload,
|
||||
DocumentMoveLayerPayload,
|
||||
DocumentRemoveArtboardPayload,
|
||||
DocumentRemoveLayerPayload,
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
DocumentSetArtboardVisiblePayload,
|
||||
DocumentSetLayerClippingMaskPayload,
|
||||
DocumentSetLayerLockedPayload,
|
||||
DocumentSetLayerOpacityPayload,
|
||||
DocumentSetLayerVisiblePayload,
|
||||
DocumentUpdateAssetSourcePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
@@ -45,7 +47,7 @@ import type {
|
||||
} from "./palette";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
|
||||
import type { WorkspaceSetPanelPayload } from "./workspace";
|
||||
import type { EditorSetPointerSessionPayload } from "./editor";
|
||||
import type { ProjectOpenPayload } from "./project";
|
||||
@@ -79,6 +81,8 @@ export type CommandPayloads = {
|
||||
[commandIds.documentRemoveLayer]: DocumentRemoveLayerPayload;
|
||||
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
|
||||
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
|
||||
[commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload;
|
||||
[commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload;
|
||||
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
|
||||
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;
|
||||
[commandIds.selectionSet]: SelectionSetPayload;
|
||||
@@ -115,6 +119,7 @@ export type CommandPayloads = {
|
||||
[commandIds.transformBegin]: TransformBeginPayload;
|
||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
||||
[commandIds.transformSetRotation]: TransformSetRotationPayload;
|
||||
[commandIds.transformEnd]: void;
|
||||
[commandIds.viewportPan]: ViewportPanPayload;
|
||||
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand } from "./document";
|
||||
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
|
||||
import { documentAddAssetCommand, documentAddRasterLayerCommand, documentAddLayerMaskCommand, documentSetLayerLockedCommand } from "./document";
|
||||
|
||||
function artboardState() {
|
||||
return documentAddArtboardCommand.execute(
|
||||
@@ -79,6 +80,19 @@ describe("transform commands", () => {
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 4, y: 19, w: 1, h: 1 });
|
||||
});
|
||||
|
||||
test("sets leaf rotation and keeps its attached mask aligned", () => {
|
||||
let state = artboardState();
|
||||
state = documentAddAssetCommand.execute({ state }, { asset: { id: "asset", name: "Asset", mimeType: "image/png", source: "asset", intrinsicSize: { w: 10, h: 10 } } });
|
||||
state = documentAddRasterLayerCommand.execute({ state }, { artboardId: "a1", layer: { id: "layer", type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId: "asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } });
|
||||
state = documentAddLayerMaskCommand.execute({ state }, { layerId: "layer", asset: { id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 10, h: 10 } }, maskLayer: { id: "mask", type: "raster", name: "Mask", visible: true, locked: false, opacity: 1, assetId: "mask-asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } });
|
||||
|
||||
const rotated = transformSetRotationCommand.execute({ state }, { target: { type: "layer", id: "layer" }, rotation: Math.PI / 2 });
|
||||
expect(rotated.document.artboards[0]?.layers.map((layer) => layer.transform.rotation)).toEqual([Math.PI / 2, Math.PI / 2]);
|
||||
|
||||
const locked = documentSetLayerLockedCommand.execute({ state: rotated }, { layerId: "layer", locked: true });
|
||||
expect(transformSetRotationCommand.execute({ state: locked }, { target: { type: "layer", id: "layer" }, rotation: 0 })).toBe(locked);
|
||||
});
|
||||
|
||||
test("ends transform session", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { applyTransformTargetBounds } from "./transform-document";
|
||||
import type { TransformHandle, TransformTarget } from "@editor/transform";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
import type { AppState } from "@editor/state";
|
||||
|
||||
export type TransformBeginPayload = {
|
||||
target: TransformTarget;
|
||||
@@ -21,6 +23,11 @@ export type TransformSetBoundsPayload = {
|
||||
bounds: Rect;
|
||||
};
|
||||
|
||||
export type TransformSetRotationPayload = {
|
||||
target: TransformTarget;
|
||||
rotation: number;
|
||||
};
|
||||
|
||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||
id: commandIds.transformBegin,
|
||||
name: "Begin transform",
|
||||
@@ -70,6 +77,7 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
|
||||
id: commandIds.transformSetBounds,
|
||||
name: "Set transform bounds",
|
||||
execute({ state }, payload) {
|
||||
if (isTargetLocked(state, payload.target)) return state;
|
||||
return {
|
||||
...state,
|
||||
document: applyTransformTargetBounds(state.document, payload.target, normalizeRect(payload.bounds)),
|
||||
@@ -77,6 +85,42 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const transformSetRotationCommand: Command<TransformSetRotationPayload> = {
|
||||
id: commandIds.transformSetRotation,
|
||||
name: "Set transform rotation",
|
||||
execute({ state }, payload) {
|
||||
if (payload.target.type !== "layer" || !Number.isFinite(payload.rotation) || isTargetLocked(state, payload.target)) return state;
|
||||
const location = state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, payload.target.id)).find(Boolean);
|
||||
if (!location || location.type === "group") return state;
|
||||
const maskId = "layerMask" in location ? location.layerMask?.maskLayerId : undefined;
|
||||
const ids = new Set([payload.target.id, ...(maskId ? [maskId] : [])]);
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) => ({ ...artboard, layers: mapRotation(artboard.layers, ids, payload.rotation) })),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function isTargetLocked(state: AppState, target: TransformTarget) {
|
||||
if (target.type === "artboard") return state.document.artboards.find((artboard) => artboard.id === target.id)?.locked !== false;
|
||||
return state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, target.id)).find(Boolean)?.locked !== false;
|
||||
}
|
||||
|
||||
function findLayerInTree(layers: Layer[], id: string): Layer[] {
|
||||
return layers.flatMap((layer) => layer.id === id ? [layer] : layer.type === "group" ? findLayerInTree(layer.children, id) : []);
|
||||
}
|
||||
|
||||
function mapRotation(layers: Layer[], ids: ReadonlySet<string>, rotation: number): Layer[] {
|
||||
return layers.map((layer) => ({
|
||||
...layer,
|
||||
...(ids.has(layer.id) ? { transform: { ...layer.transform, rotation } } : {}),
|
||||
...(layer.type === "group" ? { children: mapRotation(layer.children, ids, rotation) } : {}),
|
||||
})) as Layer[];
|
||||
}
|
||||
|
||||
export const transformEndCommand: Command = {
|
||||
id: commandIds.transformEnd,
|
||||
name: "End transform",
|
||||
@@ -94,7 +138,7 @@ export const transformEndCommand: Command = {
|
||||
},
|
||||
};
|
||||
|
||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformSetRotationCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||
|
||||
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
|
||||
if (handle === "body") {
|
||||
|
||||
@@ -143,6 +143,25 @@ export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: Indexe
|
||||
});
|
||||
}
|
||||
|
||||
export function duplicateLayer(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
|
||||
const ids = collectDuplicateIds(layerInfo.layer, layerInfo.siblings);
|
||||
dispatch(commandIds.documentDuplicateLayer, {
|
||||
layerId: layerInfo.layer.id,
|
||||
idByLayerId: Object.fromEntries(ids.map((id) => [id, crypto.randomUUID()])),
|
||||
});
|
||||
}
|
||||
|
||||
function collectDuplicateIds(layer: Layer, siblings: readonly Layer[]): string[] {
|
||||
const ids = collectTreeIds(layer);
|
||||
const maskId = getLayerMask(layer)?.maskLayerId;
|
||||
const mask = maskId ? siblings.find((candidate) => candidate.id === maskId) : undefined;
|
||||
return mask ? [...collectTreeIds(mask), ...ids] : ids;
|
||||
}
|
||||
|
||||
function collectTreeIds(layer: Layer): string[] {
|
||||
return [layer.id, ...(layer.type === "group" ? layer.children.flatMap(collectTreeIds) : [])];
|
||||
}
|
||||
|
||||
const emptyLayerIds = new Set<string>();
|
||||
|
||||
function createGroup(name: string): Extract<Layer, { type: "group" }> {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
|
||||
|
||||
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
|
||||
|
||||
export type ImageTextureRenderer = {
|
||||
syncAssets(assets: readonly Asset[]): void;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number): boolean;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
|
||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number): 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;
|
||||
dispose(): void;
|
||||
@@ -87,10 +88,11 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
}
|
||||
}
|
||||
},
|
||||
render(asset, rect, clipRect, opacity = 1) {
|
||||
render(asset, rect, clipRect, opacity = 1, rotation = 0) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const rotatedRect = rotatedRectBounds(rect, rotation);
|
||||
const drawRect = clipRect ? intersectScreenRects(rotatedRect, clipRect) : rotatedRect;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -108,7 +110,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(opacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -121,11 +123,13 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1) {
|
||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
|
||||
const contentBounds = rotatedRectBounds(rect, rotation);
|
||||
const maskBounds = rotatedRectBounds(maskRect, rotation);
|
||||
const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -148,17 +152,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(maskedOpacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedPositionLocation);
|
||||
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -166,12 +170,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) {
|
||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0) {
|
||||
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
|
||||
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
|
||||
const contentBounds = rotatedRectBounds(rect, rotation);
|
||||
const maskBounds = rotatedRectBounds(maskRect, rotation);
|
||||
const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -194,17 +200,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(maskRevealPreviewOpacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewPositionLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -422,11 +428,10 @@ function texCoordsForRect(drawRect: ScreenRect, sourceRect: ScreenRect) {
|
||||
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||
}
|
||||
|
||||
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) {
|
||||
const x1 = (rect.x / canvas.width) * 2 - 1;
|
||||
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
|
||||
const y1 = 1 - (rect.y / canvas.height) * 2;
|
||||
const y2 = 1 - ((rect.y + rect.h) / canvas.height) * 2;
|
||||
|
||||
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect, rotation = 0) {
|
||||
const [nw, ne, sw, se] = rotatedRectCorners(rect, rotation).map((point) => ({
|
||||
x: (point.x / canvas.width) * 2 - 1,
|
||||
y: 1 - (point.y / canvas.height) * 2,
|
||||
}));
|
||||
return new Float32Array([nw!.x, nw!.y, ne!.x, ne!.y, sw!.x, sw!.y, sw!.x, sw!.y, ne!.x, ne!.y, se!.x, se!.y]);
|
||||
}
|
||||
|
||||
@@ -91,17 +91,17 @@ function renderLeafLayer(
|
||||
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
||||
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
|
||||
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
|
||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) {
|
||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
|
||||
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) {
|
||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity);
|
||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
|
||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation);
|
||||
return;
|
||||
}
|
||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return;
|
||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) return;
|
||||
|
||||
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
||||
if (!fallbackRect) return;
|
||||
|
||||
40
renderer/rotated-rect.test.ts
Normal file
40
renderer/rotated-rect.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
|
||||
|
||||
describe("rotated rectangle geometry", () => {
|
||||
test("interprets rotation as radians and keeps the center anchored", () => {
|
||||
const corners = rotatedRectCorners({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2);
|
||||
|
||||
expectPoint(corners[0], { x: 40, y: 10 });
|
||||
expectPoint(corners[1], { x: 40, y: 50 });
|
||||
expectPoint(corners[2], { x: 20, y: 10 });
|
||||
expectPoint(corners[3], { x: 20, y: 50 });
|
||||
expect((corners[0].x + corners[3].x) / 2).toBeCloseTo(30);
|
||||
expect((corners[0].y + corners[3].y) / 2).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
test("expands axis-aligned clipping bounds around a rotated quad", () => {
|
||||
const bounds = rotatedRectBounds({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2);
|
||||
|
||||
expect(bounds.x).toBeCloseTo(20);
|
||||
expect(bounds.y).toBeCloseTo(10);
|
||||
expect(bounds.w).toBeCloseTo(20);
|
||||
expect(bounds.h).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
test("preserves an unrotated rectangle exactly", () => {
|
||||
const rect = { x: 10, y: 20, w: 40, h: 20 };
|
||||
expect(rotatedRectBounds(rect, 0)).toBe(rect);
|
||||
expect(rotatedRectCorners(rect, 0)).toEqual([
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 50, y: 20 },
|
||||
{ x: 10, y: 40 },
|
||||
{ x: 50, y: 40 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function expectPoint(actual: { x: number; y: number }, expected: { x: number; y: number }) {
|
||||
expect(actual.x).toBeCloseTo(expected.x);
|
||||
expect(actual.y).toBeCloseTo(expected.y);
|
||||
}
|
||||
33
renderer/rotated-rect.ts
Normal file
33
renderer/rotated-rect.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ScreenRect } from "./types";
|
||||
|
||||
export type ScreenPoint = { x: number; y: number };
|
||||
|
||||
/** Returns corners in northwest, northeast, southwest, southeast order. */
|
||||
export function rotatedRectCorners(rect: ScreenRect, rotation: number): [ScreenPoint, ScreenPoint, ScreenPoint, ScreenPoint] {
|
||||
if (!Number.isFinite(rotation)) rotation = 0;
|
||||
const center = { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 };
|
||||
const cosine = Math.cos(rotation);
|
||||
const sine = Math.sin(rotation);
|
||||
const rotate = (point: ScreenPoint): ScreenPoint => {
|
||||
const x = point.x - center.x;
|
||||
const y = point.y - center.y;
|
||||
return { x: center.x + x * cosine - y * sine, y: center.y + x * sine + y * cosine };
|
||||
};
|
||||
return [
|
||||
rotate({ x: rect.x, y: rect.y }),
|
||||
rotate({ x: rect.x + rect.w, y: rect.y }),
|
||||
rotate({ x: rect.x, y: rect.y + rect.h }),
|
||||
rotate({ x: rect.x + rect.w, y: rect.y + rect.h }),
|
||||
];
|
||||
}
|
||||
|
||||
/** Axis-aligned screen bounds used to constrain WebGL scissoring around a rotated quad. */
|
||||
export function rotatedRectBounds(rect: ScreenRect, rotation: number): ScreenRect {
|
||||
if (rotation === 0) return rect;
|
||||
const points = rotatedRectCorners(rect, rotation);
|
||||
const xs = points.map((point) => point.x);
|
||||
const ys = points.map((point) => point.y);
|
||||
const x = Math.min(...xs);
|
||||
const y = Math.min(...ys);
|
||||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { GenerationState, MaskViewMode, SelectionState, ViewportState } from "@editor/state";
|
||||
@@ -13,6 +14,7 @@ import type { Rect } from "@core/geometry";
|
||||
import type { TransformTarget } from "@editor/transform";
|
||||
import type { GenerationWorkflow } from "@operations/generation/workflow";
|
||||
import type { DocumentActions } from "@app/document-actions";
|
||||
import { createDocumentReadIndex } from "@editor/document-indexes";
|
||||
export type BottomControlsAction = "pan" | "zoom";
|
||||
|
||||
export type BottomControlsIslandProps = {
|
||||
@@ -39,6 +41,8 @@ export type BottomControlsIslandProps = {
|
||||
};
|
||||
|
||||
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
|
||||
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
|
||||
const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined;
|
||||
const zoomPercent = Math.round(viewport.zoom * 100);
|
||||
const x = Math.round(viewport.center.x);
|
||||
const y = Math.round(viewport.center.y);
|
||||
@@ -61,7 +65,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
|
||||
) : activeTool === "magicWand" ? (
|
||||
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
|
||||
) : transformBounds && transformTarget ? (
|
||||
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||
<TransformControls bounds={transformBounds} target={transformTarget} documentIndex={documentIndex} layerInfo={selectedLayerInfo} dispatch={dispatch} />
|
||||
) : action === "pan" ? (
|
||||
<PanControls x={x} y={y} />
|
||||
) : (
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { BoundingBox } from "@phosphor-icons/react";
|
||||
import { BoundingBox, Copy, MaskHappy } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { TransformTarget } from "@editor/transform";
|
||||
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
|
||||
import { addLayerMask, duplicateLayer } from "@operations/document/layerActions";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { BottomControlDivider } from "./Divider";
|
||||
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
|
||||
|
||||
export type TransformControlsProps = {
|
||||
bounds: Rect;
|
||||
target: TransformTarget;
|
||||
documentIndex: DocumentReadIndex;
|
||||
layerInfo?: IndexedLayerInfo;
|
||||
dispatch: AppStore["dispatch"];
|
||||
};
|
||||
|
||||
type BoundsField = keyof Rect;
|
||||
|
||||
export function TransformControls({ bounds, target, dispatch }: TransformControlsProps) {
|
||||
export function TransformControls({ bounds, target, documentIndex, layerInfo, dispatch }: TransformControlsProps) {
|
||||
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
|
||||
const [opacityDraft, setOpacityDraft] = useState(() => String(Math.round((layerInfo?.layer.opacity ?? 1) * 100)));
|
||||
const [rotationDraft, setRotationDraft] = useState(() => rotationDegrees(layerInfo));
|
||||
const layer = layerInfo?.layer;
|
||||
const locked = layer?.locked ?? false;
|
||||
const mask = layer ? getLayerMask(layer) : undefined;
|
||||
const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && layer.transform.rotation !== 0);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(draftFromBounds(bounds));
|
||||
}, [bounds.x, bounds.y, bounds.w, bounds.h]);
|
||||
|
||||
useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]);
|
||||
useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]);
|
||||
|
||||
const commitField = (field: BoundsField) => {
|
||||
const value = Number.parseFloat(draft[field]);
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -29,6 +43,7 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl
|
||||
return;
|
||||
}
|
||||
|
||||
if (locked) return;
|
||||
dispatch(commandIds.transformSetBounds, {
|
||||
target,
|
||||
bounds: {
|
||||
@@ -44,36 +59,108 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl
|
||||
<BoundingBox size={24} weight="regular" />
|
||||
</span>
|
||||
<BottomControlDivider />
|
||||
<BoundsInput label="X" field="x" draft={draft.x} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="Y" field="y" draft={draft.y} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="X" field="x" draft={draft.x} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="Y" field="y" draft={draft.y} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
||||
<BottomControlDivider />
|
||||
<BoundsInput label="W" field="w" draft={draft.w} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="H" field="h" draft={draft.h} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="W" field="w" draft={draft.w} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
||||
<BoundsInput label="H" field="h" draft={draft.h} disabled={locked} setDraft={setDraft} commitField={commitField} />
|
||||
{layer ? (
|
||||
<>
|
||||
<BottomControlDivider />
|
||||
<SimpleInput
|
||||
label="Opacity"
|
||||
suffix="%"
|
||||
value={opacityDraft}
|
||||
disabled={locked}
|
||||
onChange={setOpacityDraft}
|
||||
onCommit={() => {
|
||||
const value = Number.parseFloat(opacityDraft);
|
||||
if (Number.isFinite(value)) dispatch(commandIds.documentSetLayerOpacity, { layerId: layer.id, opacity: value / 100 });
|
||||
else setOpacityDraft(String(Math.round(layer.opacity * 100)));
|
||||
}}
|
||||
/>
|
||||
<SimpleInput
|
||||
label="Rotation"
|
||||
suffix="°"
|
||||
value={rotationDraft}
|
||||
disabled={locked || layer.type === "group"}
|
||||
title={layer.type === "group" ? "Group rotation is not supported" : undefined}
|
||||
onChange={setRotationDraft}
|
||||
onCommit={() => {
|
||||
const value = Number.parseFloat(rotationDraft);
|
||||
if (Number.isFinite(value)) dispatch(commandIds.transformSetRotation, { target, rotation: value * Math.PI / 180 });
|
||||
else setRotationDraft(rotationDegrees(layerInfo));
|
||||
}}
|
||||
/>
|
||||
<BottomControlDivider />
|
||||
<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
|
||||
</button>
|
||||
{layer.type !== "group" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass()}
|
||||
disabled={locked || rotatedMaskEditingUnsupported}
|
||||
title={locked ? "Unlock the layer to edit its mask" : rotatedMaskEditingUnsupported ? "Reset rotation to edit the layer mask" : mask ? "Edit layer mask" : "Add layer mask"}
|
||||
onClick={() => {
|
||||
if (mask) dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: mask.maskLayerId });
|
||||
else addLayerMask(documentIndex, layerInfo!, dispatch);
|
||||
}}
|
||||
>
|
||||
<MaskHappy size={20} /> {mask ? "Edit mask" : "Add mask"}
|
||||
</button>
|
||||
) : null}
|
||||
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`} title={title}>
|
||||
<span className={bottomControlLabelClass()}>{label}</span>
|
||||
<span className="flex items-center">
|
||||
<input className={`${bottomControlInputClass()} w-16`} inputMode="decimal" value={value} disabled={disabled} aria-label={label} onChange={(event) => onChange(event.target.value)} onBlur={onCommit} onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") event.currentTarget.blur(); }} />
|
||||
<span className="-ml-2 text-xs text-white/40">{suffix}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function rotationDegrees(layerInfo?: IndexedLayerInfo) {
|
||||
return String(Math.round((layerInfo?.layer.transform.rotation ?? 0) * 180 / Math.PI));
|
||||
}
|
||||
|
||||
function actionButtonClass() {
|
||||
return "inline-flex h-10 items-center gap-2 rounded-full px-3 text-xs font-semibold text-white/70 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
|
||||
}
|
||||
|
||||
function BoundsInput({
|
||||
label,
|
||||
field,
|
||||
draft,
|
||||
disabled,
|
||||
setDraft,
|
||||
commitField,
|
||||
}: {
|
||||
label: string;
|
||||
field: BoundsField;
|
||||
draft: string;
|
||||
disabled?: boolean;
|
||||
setDraft: Dispatch<SetStateAction<Record<BoundsField, string>>>;
|
||||
commitField: (field: BoundsField) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className={bottomControlFieldClass()}>
|
||||
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`}>
|
||||
<span className={bottomControlLabelClass()}>{label}</span>
|
||||
<input
|
||||
className={bottomControlInputClass()}
|
||||
inputMode="decimal"
|
||||
value={draft}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))}
|
||||
onBlur={() => commitField(field)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
|
||||
Reference in New Issue
Block a user