feat: add first-class text layers
This commit is contained in:
@@ -9,6 +9,8 @@ import type { RasterLayer } from "@core/raster-layer";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { LayerGroup } from "@core/layer-group";
|
||||
import type { AdjustmentLayer, ColorAdjustment } from "@core/adjustment-layer";
|
||||
import type { TextLayer, TextStyle } from "@core/text-layer";
|
||||
import { isValidTextStyle } from "@core/text-layer";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
@@ -76,6 +78,8 @@ export type DocumentAddGroupLayerPayload = {
|
||||
};
|
||||
export type DocumentAddAdjustmentLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: AdjustmentLayer };
|
||||
export type DocumentSetAdjustmentPayload = { layerId: LayerId; adjustment: ColorAdjustment };
|
||||
export type DocumentAddTextLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: TextLayer };
|
||||
export type DocumentSetTextLayerPayload = { layerId: LayerId; content: string; style: TextStyle };
|
||||
|
||||
export type DocumentMoveLayerPayload = {
|
||||
layerId: LayerId;
|
||||
@@ -365,6 +369,26 @@ export const documentAddAdjustmentLayerCommand: Command<DocumentAddAdjustmentLay
|
||||
},
|
||||
};
|
||||
|
||||
export const documentAddTextLayerCommand: Command<DocumentAddTextLayerPayload> = {
|
||||
id: commandIds.documentAddTextLayer,
|
||||
name: "Add text layer",
|
||||
execute({ state }, payload) {
|
||||
if (!payload.layer.content.trim() || !isValidTextStyle(payload.layer.style)) return state;
|
||||
const document = insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer);
|
||||
return { ...state, document, editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetTextLayerCommand: Command<DocumentSetTextLayerPayload> = {
|
||||
id: commandIds.documentSetTextLayer,
|
||||
name: "Edit text layer",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || location.layer.type !== "text" || location.layer.locked || !payload.content.trim() || !isValidTextStyle(payload.style)) return state;
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "text" ? { ...layer, content: payload.content, style: { ...payload.style } } : layer) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetAdjustmentCommand: Command<DocumentSetAdjustmentPayload> = {
|
||||
id: commandIds.documentSetAdjustment,
|
||||
name: "Edit adjustment layer",
|
||||
@@ -488,14 +512,14 @@ export const documentSetLayerSourceRectCommand: Command<DocumentSetLayerSourceRe
|
||||
name: "Crop layer",
|
||||
execute({ state }, payload) {
|
||||
const location = findLayerLocation(state.document, payload.layerId);
|
||||
if (!location || location.layer.type === "group" || location.layer.type === "adjustment" || location.layer.locked || location.layer.transform.rotation !== 0) return state;
|
||||
if (!location || (location.layer.type !== "image" && location.layer.type !== "raster") || 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" || layer.type === "adjustment") return layer;
|
||||
if (layer.type !== "image" && layer.type !== "raster") return layer;
|
||||
if (sourceRect) return { ...layer, sourceRect };
|
||||
const uncropped = { ...layer };
|
||||
delete uncropped.sourceRect;
|
||||
@@ -574,7 +598,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
|
||||
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
||||
if (!targetLocation || !maskLocation) return state;
|
||||
if (targetLocation.layer.type === "adjustment") return state;
|
||||
if (targetLocation.layer.type === "adjustment" || targetLocation.layer.type === "text") return state;
|
||||
if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state;
|
||||
|
||||
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||
@@ -601,7 +625,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
||||
name: "Add layer mask",
|
||||
execute({ state }, payload) {
|
||||
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||
if (!targetLocation || targetLocation.layer.type === "group" || targetLocation.layer.type === "adjustment") return state;
|
||||
if (!targetLocation || (targetLocation.layer.type !== "image" && targetLocation.layer.type !== "raster")) return state;
|
||||
|
||||
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
|
||||
if (existingMaskId) {
|
||||
@@ -664,7 +688,7 @@ export const documentApplyLayerMaskOperationCommand: Command<DocumentApplyLayerM
|
||||
if (!payload.source.trim()) return state;
|
||||
|
||||
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
||||
if (!maskLocation || maskLocation.layer.type === "group" || maskLocation.layer.type === "adjustment") return state;
|
||||
if (!maskLocation || (maskLocation.layer.type !== "image" && maskLocation.layer.type !== "raster")) return state;
|
||||
if (!isReferencedMaskLayer(state.document, payload.maskLayerId)) return state;
|
||||
const maskAssetId = maskLocation.layer.assetId;
|
||||
|
||||
@@ -757,6 +781,8 @@ export const documentCommands = [
|
||||
documentAddRasterLayerCommand,
|
||||
documentAddGroupLayerCommand,
|
||||
documentAddAdjustmentLayerCommand,
|
||||
documentAddTextLayerCommand,
|
||||
documentSetTextLayerCommand,
|
||||
documentSetAdjustmentCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentGroupLayersCommand,
|
||||
|
||||
@@ -13,6 +13,8 @@ export const commandIds = {
|
||||
documentAddRasterLayer: "document.addRasterLayer",
|
||||
documentAddGroupLayer: "document.addGroupLayer",
|
||||
documentAddAdjustmentLayer: "document.addAdjustmentLayer",
|
||||
documentAddTextLayer: "document.addTextLayer",
|
||||
documentSetTextLayer: "document.setTextLayer",
|
||||
documentSetAdjustment: "document.setAdjustment",
|
||||
documentMoveLayer: "document.moveLayer",
|
||||
documentGroupLayers: "document.groupLayers",
|
||||
|
||||
@@ -7,6 +7,8 @@ export {
|
||||
documentAddGroupLayerCommand,
|
||||
documentAddAdjustmentLayerCommand,
|
||||
documentSetAdjustmentCommand,
|
||||
documentAddTextLayerCommand,
|
||||
documentSetTextLayerCommand,
|
||||
documentAddImageLayerCommand,
|
||||
documentAddLayerMaskCommand,
|
||||
documentAddRasterLayerCommand,
|
||||
@@ -36,6 +38,8 @@ export type {
|
||||
DocumentAddGroupLayerPayload,
|
||||
DocumentAddAdjustmentLayerPayload,
|
||||
DocumentSetAdjustmentPayload,
|
||||
DocumentAddTextLayerPayload,
|
||||
DocumentSetTextLayerPayload,
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentAddLayerMaskPayload,
|
||||
DocumentAddRasterLayerPayload,
|
||||
|
||||
@@ -28,6 +28,8 @@ import type {
|
||||
DocumentSetLayerVisiblePayload,
|
||||
DocumentUpdateAssetSourcePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
DocumentAddTextLayerPayload,
|
||||
DocumentSetTextLayerPayload,
|
||||
} from "./document";
|
||||
import type {
|
||||
GenerationAddCandidatePayload,
|
||||
@@ -79,6 +81,8 @@ export type CommandPayloads = {
|
||||
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
||||
[commandIds.documentAddAdjustmentLayer]: DocumentAddAdjustmentLayerPayload;
|
||||
[commandIds.documentSetAdjustment]: DocumentSetAdjustmentPayload;
|
||||
[commandIds.documentAddTextLayer]: DocumentAddTextLayerPayload;
|
||||
[commandIds.documentSetTextLayer]: DocumentSetTextLayerPayload;
|
||||
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
|
||||
[commandIds.documentApplyLayerMaskOperation]: DocumentApplyLayerMaskOperationPayload;
|
||||
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
|
||||
|
||||
23
commands/text-layer.test.ts
Normal file
23
commands/text-layer.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand, documentAddTextLayerCommand, documentGroupLayersCommand, documentSetTextLayerCommand } from "./document";
|
||||
import type { TextLayer } from "@core/text-layer";
|
||||
|
||||
const text: TextLayer = { id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 5, y: 6 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "Hello", style: { fontFamily: "Arial", fontSize: 24, fontWeight: 400, fontStyle: "normal", color: "#112233", alignment: "left", lineHeight: 1.2 } };
|
||||
|
||||
describe("text layer commands", () => {
|
||||
test("creates, selects and edits authoritative text", () => {
|
||||
const base = documentAddArtboardCommand.execute({ state: createInitialAppState("Test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } });
|
||||
const added = documentAddTextLayerCommand.execute({ state: base }, { artboardId: "board", layer: text });
|
||||
expect(added.editor.selection.layerIds).toEqual(["text"]);
|
||||
const edited = documentSetTextLayerCommand.execute({ state: added }, { layerId: "text", content: "Changed", style: { ...text.style, fontWeight: 700 } });
|
||||
expect(edited.document.artboards[0]?.layers[0]).toMatchObject({ type: "text", content: "Changed", style: { fontWeight: 700 } });
|
||||
});
|
||||
|
||||
test("can be grouped as a real tree node", () => {
|
||||
const base = documentAddArtboardCommand.execute({ state: createInitialAppState("Test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } });
|
||||
const added = documentAddTextLayerCommand.execute({ state: base }, { artboardId: "board", layer: text });
|
||||
const grouped = documentGroupLayersCommand.execute({ state: added }, { artboardId: "board", layerIds: ["text"], group: { id: "group", type: "group", name: "Group", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, children: [] } });
|
||||
expect(grouped.document.artboards[0]?.layers[0]).toMatchObject({ type: "group", children: [{ id: "text", type: "text" }] });
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { TransformTarget } from "@editor/transform";
|
||||
import { measureTextLayer } from "@core/text-layer";
|
||||
|
||||
export function applyTransformTargetBounds(document: ImageDocument, target: TransformTarget, bounds: Rect): ImageDocument {
|
||||
if (target.type === "artboard") return { ...document, artboards: document.artboards.map((artboard) => artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard) };
|
||||
@@ -40,16 +41,16 @@ function mapGroupBounds(document: ImageDocument, layers: Layer[], groupId: Layer
|
||||
function scaleSubtree(document: ImageDocument, layer: Layer, initial: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
|
||||
if (layer.type === "group") return { ...layer, children: layer.children.map((child) => scaleSubtree(document, child, initial, bounds, scale)) };
|
||||
if (layer.type === "adjustment") return layer;
|
||||
if (!document.assets.some((asset) => asset.id === layer.assetId)) return layer;
|
||||
if (layer.type !== "text" && !document.assets.some((asset) => asset.id === layer.assetId)) return layer;
|
||||
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x + (layer.transform.position.x - initial.x) * scale.x, y: bounds.y + (layer.transform.position.y - initial.y) * scale.y }, scale: { x: layer.transform.scale.x * scale.x, y: layer.transform.scale.y * scale.y } } };
|
||||
}
|
||||
|
||||
function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.id === layerId && layer.type !== "group" && layer.type !== "adjustment") {
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return layer;
|
||||
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
|
||||
const asset = layer.type === "text" ? undefined : document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (layer.type !== "text" && !asset) return layer;
|
||||
const source = layer.type === "text" ? { x: 0, y: 0, ...measureTextLayer(layer) } : 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 } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user