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

View File

@@ -22,3 +22,5 @@ export type { LayerMask } from "./layer-mask";
export { getLayerMask, hasLayerMask } from "./layer-mask-utils";
export type { LayerGroup } from "./layer-group";
export type { RasterLayer } from "./raster-layer";
export type { TextAlignment, TextFontFamily, TextFontStyle, TextFontWeight, TextLayer, TextStyle } from "./text-layer";
export { builtInTextFonts, isValidTextStyle, measureTextLayer } from "./text-layer";

View File

@@ -2,5 +2,6 @@ import type { ImageLayer } from "./image-layer";
import type { LayerGroup } from "./layer-group";
import type { RasterLayer } from "./raster-layer";
import type { AdjustmentLayer } from "./adjustment-layer";
import type { TextLayer } from "./text-layer";
export type Layer = ImageLayer | RasterLayer | LayerGroup | AdjustmentLayer;
export type Layer = ImageLayer | RasterLayer | LayerGroup | AdjustmentLayer | TextLayer;

13
core/text-layer.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import { isValidTextStyle, measureTextLayer, type TextLayer } from "./text-layer";
const layer: TextLayer = { id: "text-1", type: "text", name: "Title", visible: true, locked: false, opacity: 1, transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "Hello\nWorld", style: { fontFamily: "Arial", fontSize: 20, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "left", lineHeight: 1.25 } };
describe("text layer metrics", () => {
test("uses deterministic multiline bounds", () => expect(measureTextLayer(layer)).toEqual({ w: 54, h: 50 }));
test("validates the closed built-in typography policy", () => {
expect(isValidTextStyle(layer.style)).toBeTrue();
expect(isValidTextStyle({ ...layer.style, fontFamily: "Remote Font" as never })).toBeFalse();
expect(isValidTextStyle({ ...layer.style, color: "red" })).toBeFalse();
});
});

44
core/text-layer.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { BaseLayer } from "./base-layer";
import type { Size } from "./geometry";
export const builtInTextFonts = ["Arial", "Georgia", "Courier New", "Trebuchet MS"] as const;
export type TextFontFamily = typeof builtInTextFonts[number];
export type TextAlignment = "left" | "center" | "right";
export type TextFontStyle = "normal" | "italic";
export type TextFontWeight = 400 | 700;
export type TextStyle = {
fontFamily: TextFontFamily;
fontSize: number;
fontWeight: TextFontWeight;
fontStyle: TextFontStyle;
color: string;
alignment: TextAlignment;
lineHeight: number;
};
export type TextLayer = BaseLayer & {
type: "text";
content: string;
style: TextStyle;
};
/** Deterministic layout box shared by selection, renderer and export. */
export function measureTextLayer(layer: Pick<TextLayer, "content" | "style">): Size {
const lines = layer.content.split("\n");
const weightFactor = layer.style.fontWeight === 700 ? 1.04 : 1;
const italicFactor = layer.style.fontStyle === "italic" ? 1.03 : 1;
const familyFactor = layer.style.fontFamily === "Courier New" ? 0.62 : layer.style.fontFamily === "Georgia" ? 0.56 : 0.54;
const longest = Math.max(1, ...lines.map((line) => [...line].length));
return {
w: Math.max(1, longest * layer.style.fontSize * familyFactor * weightFactor * italicFactor),
h: Math.max(1, lines.length * layer.style.fontSize * layer.style.lineHeight),
};
}
export function isValidTextStyle(style: TextStyle): boolean {
return builtInTextFonts.includes(style.fontFamily) && Number.isFinite(style.fontSize) && style.fontSize >= 1 && style.fontSize <= 1000
&& (style.fontWeight === 400 || style.fontWeight === 700) && (style.fontStyle === "normal" || style.fontStyle === "italic")
&& /^#[0-9a-f]{6}$/i.test(style.color) && (style.alignment === "left" || style.alignment === "center" || style.alignment === "right")
&& Number.isFinite(style.lineHeight) && style.lineHeight >= 0.5 && style.lineHeight <= 5;
}

View File

@@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import { measureTextLayer } from "@core/text-layer";
export type IndexedLayerInfo = {
artboardId: ArtboardId;
@@ -77,6 +78,10 @@ export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: L
return unionLayerBounds(index, layer.children);
case "adjustment":
return undefined;
case "text": {
const size = measureTextLayer(layer);
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
}
case "image":
case "raster": {
const asset = index.assetById.get(layer.assetId);

View File

@@ -3,6 +3,7 @@ import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { ArtboardId, LayerId } from "@core/id";
import type { TransformTarget } from "./transform";
import { measureTextLayer } from "@core/text-layer";
export function resolveTransformTargetBounds(document: ImageDocument, target: TransformTarget): Rect | undefined {
switch (target.type) {
@@ -62,6 +63,12 @@ function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undef
h: source.h * layer.transform.scale.y,
};
}
case "text": {
const size = measureTextLayer(layer);
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
}
case "adjustment":
return undefined;
}
}

View File

@@ -1,5 +1,6 @@
import type { Rect, Size, Vec2D } from "@core/geometry";
import type { InputArtboardId, InputDocument, InputLayer, InputLayerId } from "./read-model";
import { measureTextLayer } from "@core/text-layer";
export type InputViewportState = {
center: Vec2D;
@@ -90,6 +91,12 @@ function resolveLayerBounds(document: InputDocument, layer: InputLayer): Rect |
h: asset.intrinsicSize.h * layer.transform.scale.y,
};
}
case "text": {
const size = measureTextLayer(layer);
return { x: layer.transform.position.x, y: layer.transform.position.y, w: size.w * layer.transform.scale.x, h: size.h * layer.transform.scale.y };
}
case "adjustment":
return undefined;
}
}

View File

@@ -1,4 +1,5 @@
import type { Rect, Size, Transform } from "@core/geometry";
import type { TextStyle } from "@core/text-layer";
export type InputLayerId = string;
export type InputArtboardId = string;
@@ -17,6 +18,7 @@ type InputBaseLayer = {
export type InputLayer =
| (InputBaseLayer & { type: "group"; children: InputLayer[] })
| (InputBaseLayer & { type: "adjustment" })
| (InputBaseLayer & { type: "text"; content: string; style: TextStyle })
| (InputBaseLayer & { type: "image" | "raster"; assetId: string });
export type InputDocument = {

View File

@@ -67,6 +67,16 @@ export function addAdjustmentLayer(artboardId: ArtboardId, dispatch: AppStore["d
dispatch(commandIds.documentAddAdjustmentLayer, { artboardId, layer: { id: crypto.randomUUID(), type: "adjustment", name: "Color adjustment", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, adjustment: { ...neutralColorAdjustment, colorBalance: { ...neutralColorAdjustment.colorBalance } } } });
}
export function addTextLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
if (!artboard) return;
dispatch(commandIds.documentAddTextLayer, { artboardId, parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined, layer: {
id: crypto.randomUUID(), type: "text", name: "Text", visible: true, locked: false, opacity: 1,
transform: { position: { x: artboard.bounds.x + 40, y: artboard.bounds.y + 40 }, scale: { x: 1, y: 1 }, rotation: 0 },
content: "Text", style: { fontFamily: "Arial", fontSize: 48, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "left", lineHeight: 1.2 },
} });
}
export function groupLayers(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
}
@@ -110,7 +120,7 @@ export function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerIn
export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
const layer = layerInfo.layer;
if (layer.type === "group" || layer.type === "adjustment") return;
if (layer.type === "group" || layer.type === "adjustment" || layer.type === "text") return;
const asset = documentIndex.assetById.get(layer.assetId);
const bounds = resolveIndexedLayerBounds(documentIndex, layer);

View File

@@ -134,7 +134,7 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
const documentIndex = createDocumentReadIndex(document);
const layerInfo = documentIndex.layerInfoById.get(selection.layerIds[0]);
if (!layerInfo || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") throw new Error("Select one image or raster layer to inpaint.");
if (!layerInfo || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) throw new Error("Select one image or raster layer to inpaint. Text must be rasterized first.");
const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
if (!asset) throw new Error("The selected layer is missing its source image.");
@@ -142,7 +142,7 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId);
if (!maskLayer || maskLayer.type === "group" || maskLayer.type === "adjustment") throw new Error("The selected layer mask is missing.");
if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) throw new Error("The selected layer mask is missing.");
const maskAsset = documentIndex.assetById.get(maskLayer.assetId);
if (!maskAsset) throw new Error("The selected layer mask is missing its image data.");

View File

@@ -85,7 +85,7 @@ function resolveTarget(document: ImageDocument, selection: SelectionState, artbo
const layerId = selection.layerIds[0];
const index = createDocumentReadIndex(document);
const layerInfo = layerId ? index.layerInfoById.get(layerId) : undefined;
if (!layerInfo || layerInfo.artboardId !== artboardId || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") {
if (!layerInfo || layerInfo.artboardId !== artboardId || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) {
throw new Error("Select one image or raster layer before placing generated output.");
}
const asset = index.assetById.get(layerInfo.layer.assetId);

View File

@@ -27,7 +27,7 @@ export function checkGenerationPreconditions(
const index = createDocumentReadIndex(document);
const layerInfo = index.layerInfoById.get(selection.layerIds[0]);
if (!layerInfo || layerInfo.artboardId !== artboard.id || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") {
if (!layerInfo || layerInfo.artboardId !== artboard.id || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) {
return missing(modeSelectionMessage(settings.mode));
}
@@ -47,7 +47,7 @@ export function checkGenerationPreconditions(
const layerMask = getLayerMask(layerInfo.layer);
if (!layerMask?.enabled) return missing("Paint a mask over the area you want AI to replace.", "add-mask");
const maskLayer = index.layerById.get(layerMask.maskLayerId);
if (!maskLayer || maskLayer.type === "group" || maskLayer.type === "adjustment") return missing("The selected layer mask is missing.");
if (!maskLayer || (maskLayer.type !== "image" && maskLayer.type !== "raster")) return missing("The selected layer mask is missing.");
const maskAsset = index.assetById.get(maskLayer.assetId);
if (!maskAsset) return missing("The selected layer mask is missing its image data.");
if (Math.round(asset.intrinsicSize.w) !== Math.round(maskAsset.intrinsicSize.w) || Math.round(asset.intrinsicSize.h) !== Math.round(maskAsset.intrinsicSize.h)) {

View File

@@ -210,11 +210,11 @@ function resolveSelectedImage(document: ImageDocument, selection: SelectionState
const layerId = selection.layerIds[0];
if (!layerId) return undefined;
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined;
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset ? { layer, asset, maskAsset } : undefined;
}

View File

@@ -57,7 +57,7 @@ export function createGenerationWorkflow(store: AppStore, dependencies: Generati
if (!layerId) return;
const artboard = state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId);
const layer = artboard ? findLayer(artboard.layers, layerId) : undefined;
if (!layer || layer.type === "group" || layer.type === "adjustment") return;
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return;
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return;
const source = await dependencies.createRefinementMask(asset.intrinsicSize.w, asset.intrinsicSize.h);

View File

@@ -14,19 +14,19 @@ export function resolveChromaKeyTarget(document: ImageDocument, selection: Selec
const layerId = selection.layerIds[0];
if (selection.layerIds.length !== 1 || !layerId) return undefined;
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined;
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
}
export async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof resolveChromaKeyTarget>>, settings: ChromaKeySettings, dispatch: AppStore["dispatch"]) {
const source = await createChromaKeyMask(target.asset.source, target.asset.intrinsicSize.w, target.asset.intrinsicSize.h, settings);
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group" && target.maskLayer.type !== "adjustment") {
if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) {
dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } });
dispatch(commandIds.workspaceSetPanel, { panel: "none" });
return;

View File

@@ -17,7 +17,7 @@ export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverri
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group" && target.maskLayer.type !== "adjustment") {
if (target.maskAsset && target.maskLayer && (target.maskLayer.type === "image" || target.maskLayer.type === "raster")) {
store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "magicWand" } });
return true;
}
@@ -39,12 +39,12 @@ function resolveTarget(document: ImageDocument, editor: EditorState) {
const layerId = editor.selection.layerIds[0];
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.type === "group" || layer.type === "adjustment") return undefined;
if (!layer || (layer.type !== "image" && layer.type !== "raster")) return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
const layerMask = getLayerMask(layer);
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
}

View File

@@ -56,6 +56,14 @@ describe("project format", () => {
expect(() => parseProject(serializeProject(document))).toThrow("artboard level");
});
test("round-trips valid text and rejects unsupported typography", () => {
const document = projectDocument();
document.artboards[0]!.layers.unshift({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: .8, transform: { position: { x: 1, y: 2 }, scale: { x: 1, y: 1 }, rotation: .2 }, content: "Hello", style: { fontFamily: "Arial", fontSize: 20, fontWeight: 700, fontStyle: "italic", color: "#123456", alignment: "right", lineHeight: 1.2 } });
expect(parseProject(serializeProject(document)).document.artboards[0]?.layers[0]?.type).toBe("text");
const invalid = JSON.parse(serializeProject(document)); invalid.document.artboards[0].layers[0].style.fontFamily = "Web Font";
expect(() => parseProject(JSON.stringify(invalid))).toThrow("invalid typography");
});
test("creates safe project file names", () => {
expect(projectFileName(" Summer / Study ")).toBe("Summer-Study.image-studio.json");
expect(projectFileName("***")).toBe("untitled.image-studio.json");

View File

@@ -1,6 +1,7 @@
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { Rect } from "@core/geometry";
import { isValidTextStyle, type TextStyle } from "@core/text-layer";
export const PROJECT_FORMAT = "image-studio-project";
export const CURRENT_PROJECT_VERSION = 1;
@@ -115,11 +116,14 @@ function assertLayers(value: unknown[], nested: boolean): asserts value is Layer
if (nested) throw new Error("Adjustment layers must stay at artboard level.");
if (!isAdjustment(layer.adjustment)) throw new Error("A project adjustment layer contains invalid settings.");
if (layer.layerMask !== undefined || layer.clippingMask !== undefined) throw new Error("Adjustment layers do not support masks.");
} else if (layer.type === "text") {
if (typeof layer.content !== "string" || !layer.content.trim() || !isRecord(layer.style) || !isValidTextStyle(layer.style as TextStyle)) throw new Error("A project text layer contains invalid typography.");
if (layer.layerMask !== undefined || layer.clippingMask !== undefined) throw new Error("Text layers do not support masks. Rasterize text before masking.");
} else if ((layer.type === "image" || layer.type === "raster") && typeof layer.assetId !== "string") {
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" && layer.type !== "text") {
throw new Error(`Unsupported layer type: ${layer.type}.`);
}
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { resolveLayerDrawImage } from "./exportArtboardPng";
import { resolveLayerDrawImage, resolveTextLayerDraw } from "./exportArtboardPng";
describe("artboard PNG export", () => {
test("draws only cropped source pixels at their retained document position", () => {
@@ -14,4 +14,11 @@ describe("artboard PNG export", () => {
destination: { x: 120, y: 65, w: 80, h: 60 },
});
});
test("uses deterministic text data centered for renderer rotation parity", () => {
const draw = resolveTextLayerDraw({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 10, y: 20 }, scale: { x: 2, y: 3 }, rotation: 1 }, content: "AB", style: { fontFamily: "Arial", fontSize: 10, fontWeight: 400, fontStyle: "normal", color: "#ffffff", alignment: "center", lineHeight: 1.2 } });
expect(draw.size).toEqual({ w: 10.8, h: 12 });
expect(draw.center).toEqual({ x: 20.8, y: 38 });
expect(draw.textX).toBe(0);
});
});

View File

@@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { Size } from "@core/geometry";
import { getLayerMask } from "@core/layer-mask-utils";
import { measureTextLayer } from "@core/text-layer";
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
const width = Math.max(1, Math.round(artboard.bounds.w));
@@ -71,6 +72,12 @@ async function drawLayer(
return;
}
if (layer.type === "text") {
drawTextLayer(context, layer);
context.restore();
return;
}
const asset = assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) {
context.restore();
@@ -84,6 +91,29 @@ async function drawLayer(
context.restore();
}
export function drawTextLayer(context: CanvasRenderingContext2D, layer: Extract<Layer, { type: "text" }>) {
const draw = resolveTextLayerDraw(layer);
const { size } = draw;
context.translate(draw.center.x, draw.center.y);
context.rotate(layer.transform.rotation);
context.scale(layer.transform.scale.x, layer.transform.scale.y);
context.fillStyle = layer.style.color;
context.font = `${layer.style.fontStyle} ${layer.style.fontWeight} ${layer.style.fontSize}px ${JSON.stringify(layer.style.fontFamily)}, Arial, sans-serif`;
context.textAlign = layer.style.alignment;
context.textBaseline = "alphabetic";
const x = draw.textX;
layer.content.split("\n").forEach((line, index) => context.fillText(line, x, (index + 0.82) * layer.style.fontSize * layer.style.lineHeight - size.h / 2));
}
export function resolveTextLayerDraw(layer: Extract<Layer, { type: "text" }>) {
const size = measureTextLayer(layer);
return {
size,
center: { x: layer.transform.position.x + size.w * layer.transform.scale.x / 2, y: layer.transform.position.y + size.h * layer.transform.scale.y / 2 },
textX: (layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w) - size.w / 2,
};
}
export function resolveLayerDrawImage(layer: Extract<Layer, { type: "image" | "raster" }>, intrinsicSize: Size) {
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
return {

View File

@@ -10,6 +10,7 @@ import type { ImageTextureRenderer } from "./image-textures";
import { documentRectToScreenRect } from "./screen-rect";
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
import type { AdjustmentPass } from "./adjustment-pass";
import { textLayerRenderAsset } from "./text-asset";
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
@@ -63,10 +64,22 @@ function renderLayerTree(
adjustmentPass.apply(layer.adjustment, effectiveOpacity, effectiveClipRect);
continue;
}
renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
if (layer.type === "text") {
renderTextLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
} else {
renderLeafLayer(context, documentIndex, editor, layer, imageTextureRenderer, effectiveClipRect, effectiveOpacity);
}
}
}
function renderTextLayer(context: WebGlRendererContext, documentIndex: DocumentReadIndex, editor: EditorState, layer: Extract<Layer, { type: "text" }>, imageTextureRenderer: ImageTextureRenderer, clipRect: ScreenRect, opacity: number) {
if (editor.maskEdit?.viewMode && isIsolatedMaskView(editor.maskEdit.viewMode)) return;
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
if (!bounds) return;
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
imageTextureRenderer.render(textLayerRenderAsset(layer), rect, clipRect, opacity, layer.transform.rotation);
}
function renderLeafLayer(
context: WebGlRendererContext,
documentIndex: DocumentReadIndex,
@@ -88,7 +101,7 @@ function renderLeafLayer(
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
const layerMask = getLayerMask(layer);
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);

View File

@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test";
import { textLayerRenderAsset } from "./text-asset";
describe("text render data", () => {
test("creates an owned SVG texture with matching deterministic dimensions", () => {
const asset = textLayerRenderAsset({ id: "text", type: "text", name: "Text", visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, content: "A&B", style: { fontFamily: "Georgia", fontSize: 10, fontWeight: 700, fontStyle: "italic", color: "#abcdef", alignment: "center", lineHeight: 1.2 } });
expect(asset.mimeType).toBe("image/svg+xml");
expect(asset.source).toStartWith("data:image/svg+xml,");
expect(decodeURIComponent(asset.source)).toContain("A&amp;B");
expect(asset.intrinsicSize.w).toBeGreaterThan(0);
});
});

19
renderer/text-asset.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Asset } from "@core/asset";
import type { TextLayer } from "@core/text-layer";
import { measureTextLayer } from "@core/text-layer";
/** Produces a derived SVG texture; text content remains the only authoritative state. */
export function textLayerRenderAsset(layer: TextLayer): Asset {
const size = measureTextLayer(layer);
const lines = layer.content.split("\n");
const anchor = layer.style.alignment === "left" ? "start" : layer.style.alignment === "center" ? "middle" : "end";
const x = layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w;
const family = escapeXml(layer.style.fontFamily);
const tspans = lines.map((line, index) => `<tspan x="${x}" y="${(index + 0.82) * layer.style.fontSize * layer.style.lineHeight}">${escapeXml(line || " ")}</tspan>`).join("");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size.w}" height="${size.h}" viewBox="0 0 ${size.w} ${size.h}"><text text-anchor="${anchor}" font-family="${family}" font-size="${layer.style.fontSize}" font-weight="${layer.style.fontWeight}" font-style="${layer.style.fontStyle}" fill="${layer.style.color}">${tspans}</text></svg>`;
return { id: `text-render:${layer.id}`, name: layer.name, mimeType: "image/svg+xml", source: `data:image/svg+xml,${encodeURIComponent(svg)}`, intrinsicSize: size };
}
function escapeXml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[character]!);
}

View File

@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash } from "@phosphor-icons/react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash, TextT } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { ColorAdjustment } from "@core/adjustment-layer";
import type { TextLayer, TextStyle } from "@core/text-layer";
import { builtInTextFonts } from "@core/text-layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId } from "@core/id";
import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes";
@@ -11,7 +13,7 @@ import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveLayerDrop } from "@input/index";
import type { DocumentActions } from "@app/document-actions";
import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, addTextLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
import { LayerThumbnail } from "./layers/LayerThumbnail";
import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel";
@@ -95,6 +97,7 @@ function LayersSheetBody({
<button type="button" className={toolbarButtonClass()} aria-label="Add color adjustment" title="Add non-destructive artboard color adjustment" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addAdjustmentLayer(selectedArtboardId, dispatch)}>
<SlidersHorizontal size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add text" title="Add text layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addTextLayer(document, selectedArtboardId, selectedLayer, dispatch)}><TextT size={24} /></button>
</div>
</header>
<div className="mb-4 grid grid-cols-5 gap-2">
@@ -115,6 +118,7 @@ function LayersSheetBody({
</button>
</div>
{selectedLayer?.layer.type === "adjustment" ? <AdjustmentInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
{selectedLayer?.layer.type === "text" ? <TextInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
<div className="min-h-0 flex-1 overflow-auto pb-2">
{document.artboards.map((artboard) => {
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
@@ -204,6 +208,28 @@ function LayersSheetBody({
);
}
function TextInspector({ layer, dispatch }: { layer: TextLayer; dispatch: AppStore["dispatch"] }) {
const [content, setContent] = useState(layer.content);
const [style, setStyle] = useState<TextStyle>({ ...layer.style });
useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); }, [layer.id, layer.content, layer.style]);
const commit = () => dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style });
return <section aria-label="Text settings" className="mb-3 space-y-2 rounded-[1.5rem] bg-white/[0.05] p-3">
<textarea aria-label="Text content" className="w-full resize-y rounded-xl bg-black/25 p-2 text-sm outline-none ring-1 ring-white/10" rows={2} value={content} disabled={layer.locked} onChange={(event) => setContent(event.target.value)} onBlur={commit} onKeyDown={(event) => event.stopPropagation()} />
<div className="grid grid-cols-3 gap-2">
<select aria-label="Font family" className={textFieldClass()} value={style.fontFamily} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontFamily: event.target.value as TextStyle["fontFamily"] })} onBlur={commit}>{builtInTextFonts.map((font) => <option key={font}>{font}</option>)}</select>
<input aria-label="Font size" className={textFieldClass()} type="number" min="1" max="1000" value={style.fontSize} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontSize: Number(event.target.value) })} onBlur={commit} />
<input aria-label="Text color" className={textFieldClass()} type="color" value={style.color} disabled={layer.locked} onChange={(event) => setStyle({ ...style, color: event.target.value })} onBlur={commit} />
<select aria-label="Font weight" className={textFieldClass()} value={style.fontWeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontWeight: Number(event.target.value) as 400 | 700 })} onBlur={commit}><option value="400">Regular</option><option value="700">Bold</option></select>
<select aria-label="Font style" className={textFieldClass()} value={style.fontStyle} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontStyle: event.target.value as TextStyle["fontStyle"] })} onBlur={commit}><option value="normal">Normal</option><option value="italic">Italic</option></select>
<select aria-label="Text alignment" className={textFieldClass()} value={style.alignment} disabled={layer.locked} onChange={(event) => setStyle({ ...style, alignment: event.target.value as TextStyle["alignment"] })} onBlur={commit}><option value="left">Left</option><option value="center">Center</option><option value="right">Right</option></select>
<input aria-label="Line height" className={textFieldClass()} type="number" min="0.5" max="5" step="0.1" value={style.lineHeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, lineHeight: Number(event.target.value) })} onBlur={commit} />
</div>
<p className="text-[10px] text-white/40">Built-in system fonts only. Missing fonts fall back to Arial/sans-serif; exact glyph shapes can vary by platform.</p>
</section>;
}
function textFieldClass() { return "min-w-0 rounded-lg bg-black/25 px-2 py-1.5 text-xs text-white outline-none ring-1 ring-white/10"; }
function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type: "adjustment" }>; dispatch: AppStore["dispatch"] }) {
const [draft, setDraft] = useState<ColorAdjustment>(() => cloneAdjustment(layer.adjustment));
useEffect(() => setDraft(cloneAdjustment(layer.adjustment)), [layer.id, layer.adjustment]);
@@ -294,8 +320,8 @@ function LayerRow({
const layerInfo = documentIndex.layerInfoById.get(layer.id);
const layerMask = getLayerMask(layer);
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && layer.type !== "adjustment" && !layerMask);
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && (layer.type === "image" || layer.type === "raster") && !layerMask);
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
@@ -414,7 +440,7 @@ function LayerRow({
>
Hide
</button>
{maskAsset && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? (
{maskAsset && (maskLayer.type === "image" || maskLayer.type === "raster") ? (
<MaskOperationButtons maskLayerId={maskLayer.id} maskAsset={maskAsset} dispatch={dispatch} />
) : null}
</div>

View File

@@ -31,7 +31,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
const layer = layerInfo?.layer;
const locked = layer?.locked ?? false;
const mask = layer ? getLayerMask(layer) : undefined;
const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && layer.type !== "adjustment" && layer.transform.rotation !== 0);
const rotatedMaskEditingUnsupported = Boolean(layer && (layer.type === "image" || layer.type === "raster") && layer.transform.rotation !== 0);
useEffect(() => {
setDraft(draftFromBounds(bounds));
@@ -102,12 +102,12 @@ 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)}>
<Copy size={20} /> Duplicate
</button>
{layer.type !== "group" && layer.type !== "adjustment" ? (
{layer.type === "image" || layer.type === "raster" ? (
<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 !== "adjustment" ? (
{layer.type === "image" || layer.type === "raster" ? (
<button
type="button"
className={actionButtonClass()}
@@ -122,7 +122,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
</button>
) : null}
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
{cropOpen && layer.type !== "group" && layer.type !== "adjustment" ? <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}
{cropOpen && (layer.type === "image" || layer.type === "raster") ? <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" ? (
<>
@@ -154,7 +154,7 @@ function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: {
}
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
if (!layerInfo || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") return { x: "0", y: "0", w: "1", h: "1" };
if (!layerInfo || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) 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);

View File

@@ -1,4 +1,4 @@
import { FolderSimple, ImageBroken, SlidersHorizontal } from "@phosphor-icons/react";
import { FolderSimple, ImageBroken, SlidersHorizontal, TextT } from "@phosphor-icons/react";
import type { LayerThumbnailModel, RasterThumbnailModel } from "./thumbnailModel";
export function LayerThumbnail({ model, label, compact = false }: { model: LayerThumbnailModel; label: string; compact?: boolean }) {
@@ -16,6 +16,7 @@ export function LayerThumbnail({ model, label, compact = false }: { model: Layer
) : null}
{model.kind === "empty" ? <ImageBroken size={compact ? 14 : 17} className="text-white/35" /> : null}
{model.kind === "adjustment" ? <SlidersHorizontal size={compact ? 14 : 17} className="text-violet-200" /> : null}
{model.kind === "text" ? <TextT size={compact ? 14 : 17} style={{ color: model.color }} aria-label={model.content} /> : null}
</span>
);
}

View File

@@ -15,6 +15,7 @@ export type RasterThumbnailModel = {
export type LayerThumbnailModel =
| RasterThumbnailModel
| { kind: "adjustment" }
| { kind: "text"; content: string; color: string }
| { kind: "group"; previews: readonly RasterThumbnailModel[] }
| { kind: "empty" };
@@ -25,6 +26,7 @@ export function resolveLayerThumbnail(
excludedLayerIds: ReadonlySet<LayerId> = new Set(),
): LayerThumbnailModel {
if (layer.type === "adjustment") return { kind: "adjustment" };
if (layer.type === "text") return { kind: "text", content: layer.content, color: layer.style.color };
if (layer.type !== "group") return resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" };
const previews: RasterThumbnailModel[] = [];
@@ -59,6 +61,7 @@ export function createLayerThumbnailIndex(
if (frame.visited || layer.type !== "group") {
if (layer.type !== "group") {
if (layer.type === "adjustment") { result.set(layer.id, { kind: "adjustment" }); continue; }
if (layer.type === "text") { result.set(layer.id, { kind: "text", content: layer.content, color: layer.style.color }); continue; }
result.set(layer.id, resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" });
continue;
}
@@ -83,7 +86,7 @@ export function createLayerThumbnailIndex(
}
function resolveRasterThumbnail(layer: Exclude<Layer, { type: "group" }>, assetById: ReadonlyMap<AssetId, Asset>): RasterThumbnailModel | undefined {
if (layer.type === "adjustment") return undefined;
if (layer.type === "adjustment" || layer.type === "text") return undefined;
const asset = assetById.get(layer.assetId);
if (!asset || !asset.source.trim() || !positiveFinite(asset.intrinsicSize.w) || !positiveFinite(asset.intrinsicSize.h)) return undefined;