feat: add first-class text layers
This commit is contained in:
@@ -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);
|
||||
|
||||
12
renderer/text-asset.test.ts
Normal file
12
renderer/text-asset.test.ts
Normal 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&B");
|
||||
expect(asset.intrinsicSize.w).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
19
renderer/text-asset.ts
Normal file
19
renderer/text-asset.ts
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!);
|
||||
}
|
||||
Reference in New Issue
Block a user