feat: add first-class text layers

This commit is contained in:
syntaxbullet
2026-07-11 12:38:34 +02:00
parent 606426c885
commit 37aa719047
33 changed files with 315 additions and 41 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

View 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" }] });
});
});

View File

@@ -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 } };
}