Files
odysseus/static/js/editor/text-layer.js
T

204 lines
7.9 KiB
JavaScript

/** Retained text-layer model and raster-cache renderer. */
export const DEFAULT_TEXT_DATA = Object.freeze({
content: 'Text',
fontFamily: 'Arial',
fontSize: 48,
fontWeight: '400',
fontStyle: 'normal',
align: 'left',
color: '#ffffff',
strokeColor: '#000000',
strokeWidth: 0,
lineHeight: 1.2,
letterSpacing: 0,
frameWidth: 320,
frameHeight: 0,
autoWidth: true,
verticalAlign: 'top',
transform: Object.freeze({ scaleX: 1, scaleY: 1, rotation: 0, flipH: false, flipV: false }),
});
function finite(value, fallback, min = -Infinity, max = Infinity) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
export function normalizeTextData(value = {}) {
const transform = value.transform || {};
return {
content: String(value.content ?? DEFAULT_TEXT_DATA.content),
fontFamily: String(value.fontFamily || DEFAULT_TEXT_DATA.fontFamily),
fontSize: finite(value.fontSize, DEFAULT_TEXT_DATA.fontSize, 1, 2000),
fontWeight: String(value.fontWeight || DEFAULT_TEXT_DATA.fontWeight),
fontStyle: value.fontStyle === 'italic' ? 'italic' : 'normal',
align: ['left', 'center', 'right'].includes(value.align) ? value.align : 'left',
color: String(value.color || DEFAULT_TEXT_DATA.color),
strokeColor: String(value.strokeColor || DEFAULT_TEXT_DATA.strokeColor),
strokeWidth: finite(value.strokeWidth, DEFAULT_TEXT_DATA.strokeWidth, 0, 100),
lineHeight: finite(value.lineHeight, DEFAULT_TEXT_DATA.lineHeight, 0.5, 5),
letterSpacing: finite(value.letterSpacing, DEFAULT_TEXT_DATA.letterSpacing, -100, 500),
frameWidth: finite(value.frameWidth, DEFAULT_TEXT_DATA.frameWidth, 1, 10000),
frameHeight: finite(value.frameHeight, DEFAULT_TEXT_DATA.frameHeight, 0, 10000),
autoWidth: value.autoWidth !== false,
verticalAlign: ['top', 'middle', 'bottom'].includes(value.verticalAlign) ? value.verticalAlign : 'top',
transform: {
scaleX: finite(transform.scaleX, 1, 0.01, 100),
scaleY: finite(transform.scaleY, 1, 0.01, 100),
rotation: finite(transform.rotation, 0, -36000, 36000),
flipH: !!transform.flipH,
flipV: !!transform.flipV,
},
};
}
function fontString(text) {
const family = text.fontFamily.includes(',')
? text.fontFamily
: `"${text.fontFamily.replace(/["\\]/g, '')}", Arial, sans-serif`;
return `${text.fontStyle} ${text.fontWeight} ${text.fontSize}px ${family}`;
}
function measureSpacedText(ctx, value, spacing) {
const chars = [...String(value || '')];
if (!chars.length) return 0;
return chars.reduce((width, char) => width + ctx.measureText(char).width, 0) + spacing * Math.max(0, chars.length - 1);
}
function drawSpacedText(ctx, value, x, y, spacing, stroke = false) {
const chars = [...String(value || '')];
if (!chars.length) return;
const width = measureSpacedText(ctx, value, spacing);
let cursor = ctx.textAlign === 'center' ? x - width / 2 : ctx.textAlign === 'right' ? x - width : x;
const draw = stroke ? ctx.strokeText.bind(ctx) : ctx.fillText.bind(ctx);
for (const char of chars) {
draw(char, cursor, y);
cursor += ctx.measureText(char).width + spacing;
}
}
function wrapText(ctx, content, width, spacing) {
const result = [];
for (const paragraph of String(content).split('\n')) {
if (!width) {
result.push(paragraph);
continue;
}
const words = paragraph.split(/(\s+)/).filter(Boolean);
if (!words.length) {
result.push('');
continue;
}
let line = '';
for (const word of words) {
const candidate = line + word;
if (line && measureSpacedText(ctx, candidate, spacing) > width) {
result.push(line.trimEnd());
line = word.trimStart();
} else {
line = candidate;
}
}
result.push(line);
}
return result.length ? result : [''];
}
function baseTextCanvas(text) {
const measure = document.createElement('canvas');
const measureCtx = measure.getContext('2d');
measureCtx.font = fontString(text);
const padding = Math.ceil(Math.max(4, text.strokeWidth + 3));
const requestedWidth = text.autoWidth ? 0 : Math.max(1, text.frameWidth);
const lines = wrapText(measureCtx, text.content, requestedWidth, text.letterSpacing);
const measuredWidth = Math.max(1, ...lines.map(line => measureSpacedText(measureCtx, line || ' ', text.letterSpacing)));
const contentWidth = text.autoWidth ? measuredWidth : requestedWidth;
const lineHeightPx = text.fontSize * text.lineHeight;
const contentHeight = Math.max(1, lines.length * lineHeightPx);
const frameHeight = text.frameHeight > 0 ? Math.max(contentHeight, text.frameHeight) : contentHeight;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.ceil(contentWidth + padding * 2));
canvas.height = Math.max(1, Math.ceil(frameHeight + padding * 2));
const ctx = canvas.getContext('2d');
ctx.font = fontString(text);
ctx.textBaseline = 'top';
ctx.textAlign = text.align;
ctx.fillStyle = text.color;
ctx.strokeStyle = text.strokeColor;
ctx.lineWidth = text.strokeWidth * 2;
ctx.lineJoin = 'round';
const x = text.align === 'center' ? canvas.width / 2 : text.align === 'right' ? canvas.width - padding : padding;
const verticalOffset = text.verticalAlign === 'middle'
? (frameHeight - contentHeight) / 2
: text.verticalAlign === 'bottom' ? frameHeight - contentHeight : 0;
lines.forEach((line, index) => {
const y = padding + verticalOffset + index * lineHeightPx;
if (text.strokeWidth > 0) drawSpacedText(ctx, line, x, y, text.letterSpacing, true);
drawSpacedText(ctx, line, x, y, text.letterSpacing, false);
});
return canvas;
}
export function renderTextLayer(layer) {
if (!layer?.canvas || !layer?.ctx) return null;
layer.kind = 'text';
layer.text = normalizeTextData(layer.text);
const base = baseTextCanvas(layer.text);
const transform = layer.text.transform;
const scaledWidth = Math.max(1, base.width * transform.scaleX);
const scaledHeight = Math.max(1, base.height * transform.scaleY);
const radians = (transform.rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const width = Math.max(1, Math.ceil(scaledWidth * cos + scaledHeight * sin));
const height = Math.max(1, Math.ceil(scaledWidth * sin + scaledHeight * cos));
const rendered = document.createElement('canvas');
rendered.width = width;
rendered.height = height;
const ctx = rendered.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.translate(width / 2, height / 2);
ctx.rotate(radians);
ctx.scale(transform.flipH ? -1 : 1, transform.flipV ? -1 : 1);
ctx.drawImage(base, -scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
layer.canvas.width = width;
layer.canvas.height = height;
layer.ctx = layer.canvas.getContext('2d');
layer.ctx.clearRect(0, 0, width, height);
layer.ctx.drawImage(rendered, 0, 0);
layer._adjCache = null;
layer._adjCacheKey = null;
layer._adjFinal = null;
layer._adjFinalKey = null;
return layer.canvas;
}
/** Mark a retained text cache as ordinary pixels after a destructive edit. */
export function rasterizeTextLayer(layer) {
if (!layer || layer.kind !== 'text') return false;
layer.kind = 'raster';
layer.text = null;
return true;
}
export function scaleTextLayer(layer, scaleX, scaleY) {
layer.text = normalizeTextData(layer.text);
layer.text.transform.scaleX *= scaleX;
layer.text.transform.scaleY *= scaleY;
return renderTextLayer(layer);
}
export function rotateTextLayer(layer, degrees) {
layer.text = normalizeTextData(layer.text);
layer.text.transform.rotation += degrees;
return renderTextLayer(layer);
}
export function flipTextLayer(layer, axis) {
layer.text = normalizeTextData(layer.text);
if (axis === 'h') layer.text.transform.flipH = !layer.text.transform.flipH;
else layer.text.transform.flipV = !layer.text.transform.flipV;
return renderTextLayer(layer);
}