209 lines
4.9 KiB
TypeScript
209 lines
4.9 KiB
TypeScript
import { buildCanvasFont, hexToRgba, type IdDrawStyle } from "./id-config";
|
|
|
|
export type TextMetricsLike = {
|
|
width: number;
|
|
actualBoundingBoxAscent?: number;
|
|
actualBoundingBoxDescent?: number;
|
|
};
|
|
|
|
export type Canvas2D = {
|
|
font: string;
|
|
fillStyle: string | CanvasGradient | CanvasPattern;
|
|
strokeStyle: string | CanvasGradient | CanvasPattern;
|
|
lineWidth: number;
|
|
textAlign: CanvasTextAlign;
|
|
textBaseline: CanvasTextBaseline;
|
|
shadowColor: string;
|
|
shadowBlur: number;
|
|
shadowOffsetX: number;
|
|
shadowOffsetY: number;
|
|
lineJoin: CanvasLineJoin;
|
|
miterLimit: number;
|
|
fillText(text: string, x: number, y: number): void;
|
|
strokeText(text: string, x: number, y: number): void;
|
|
measureText(text: string): TextMetricsLike;
|
|
save(): void;
|
|
restore(): void;
|
|
setLineDash(segments: number[]): void;
|
|
strokeRect(x: number, y: number, w: number, h: number): void;
|
|
};
|
|
|
|
export type TextBox = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export type GlyphMetrics = {
|
|
width: number;
|
|
ascent: number;
|
|
descent: number;
|
|
height: number;
|
|
};
|
|
|
|
export function positionFromPercent(
|
|
xPercent: number,
|
|
yPercent: number,
|
|
imageWidth: number,
|
|
imageHeight: number,
|
|
): { x: number; y: number } {
|
|
return {
|
|
x: (xPercent / 100) * imageWidth,
|
|
y: (yPercent / 100) * imageHeight,
|
|
};
|
|
}
|
|
|
|
function applyFont(ctx: Canvas2D, style: IdDrawStyle): void {
|
|
ctx.font = buildCanvasFont(style);
|
|
ctx.textAlign = style.align;
|
|
ctx.textBaseline = "alphabetic";
|
|
}
|
|
|
|
export function measureGlyphs(
|
|
ctx: Canvas2D,
|
|
text: string,
|
|
style: IdDrawStyle,
|
|
): GlyphMetrics {
|
|
applyFont(ctx, style);
|
|
const metrics = ctx.measureText(text);
|
|
const ascent = metrics.actualBoundingBoxAscent ?? style.fontSize * 0.8;
|
|
const descent = metrics.actualBoundingBoxDescent ?? style.fontSize * 0.2;
|
|
return {
|
|
width: metrics.width,
|
|
ascent,
|
|
descent,
|
|
height: ascent + descent,
|
|
};
|
|
}
|
|
|
|
export function alphabeticBaselineY(
|
|
visualCenterY: number,
|
|
glyphs: GlyphMetrics,
|
|
): number {
|
|
return visualCenterY + (glyphs.ascent - glyphs.descent) / 2;
|
|
}
|
|
|
|
export function applyIdStyle(ctx: Canvas2D, style: IdDrawStyle): void {
|
|
applyFont(ctx, style);
|
|
ctx.fillStyle = style.color;
|
|
}
|
|
|
|
function applyShadow(ctx: Canvas2D, style: IdDrawStyle): void {
|
|
if (!style.shadowEnabled) {
|
|
ctx.shadowColor = "transparent";
|
|
ctx.shadowBlur = 0;
|
|
ctx.shadowOffsetX = 0;
|
|
ctx.shadowOffsetY = 0;
|
|
return;
|
|
}
|
|
|
|
ctx.shadowColor = hexToRgba(style.shadowColor, style.shadowOpacity);
|
|
ctx.shadowBlur = style.shadowBlur;
|
|
ctx.shadowOffsetX = style.shadowOffsetX;
|
|
ctx.shadowOffsetY = style.shadowOffsetY;
|
|
}
|
|
|
|
export function drawIdText(
|
|
ctx: Canvas2D,
|
|
text: string,
|
|
imageWidth: number,
|
|
imageHeight: number,
|
|
style: IdDrawStyle,
|
|
): void {
|
|
const { x, y } = positionFromPercent(
|
|
style.xPercent,
|
|
style.yPercent,
|
|
imageWidth,
|
|
imageHeight,
|
|
);
|
|
const glyphs = measureGlyphs(ctx, text, style);
|
|
const baselineY = alphabeticBaselineY(y, glyphs);
|
|
|
|
ctx.save();
|
|
applyIdStyle(ctx, style);
|
|
applyShadow(ctx, style);
|
|
|
|
if (style.outlineEnabled && style.outlineWidth > 0) {
|
|
ctx.strokeStyle = hexToRgba(style.outlineColor, style.outlineOpacity);
|
|
ctx.lineWidth = style.outlineWidth;
|
|
ctx.lineJoin = "round";
|
|
ctx.miterLimit = 2;
|
|
ctx.strokeText(text, x, baselineY);
|
|
ctx.shadowColor = "transparent";
|
|
}
|
|
|
|
ctx.fillText(text, x, baselineY);
|
|
ctx.restore();
|
|
}
|
|
|
|
export function measureIdTextBox(
|
|
ctx: Canvas2D,
|
|
text: string,
|
|
imageWidth: number,
|
|
imageHeight: number,
|
|
style: IdDrawStyle,
|
|
): TextBox {
|
|
const glyphs = measureGlyphs(ctx, text, style);
|
|
const { x, y } = positionFromPercent(
|
|
style.xPercent,
|
|
style.yPercent,
|
|
imageWidth,
|
|
imageHeight,
|
|
);
|
|
|
|
let left = x;
|
|
if (style.align === "center") left = x - glyphs.width / 2;
|
|
if (style.align === "right") left = x - glyphs.width;
|
|
|
|
const outlinePad =
|
|
style.outlineEnabled && style.outlineWidth > 0
|
|
? style.outlineWidth / 2
|
|
: 0;
|
|
|
|
return {
|
|
left: left - outlinePad,
|
|
top: y - glyphs.height / 2 - outlinePad,
|
|
width: glyphs.width + outlinePad * 2,
|
|
height: glyphs.height + outlinePad * 2,
|
|
};
|
|
}
|
|
|
|
export function drawIdSelection(
|
|
ctx: Canvas2D,
|
|
box: TextBox,
|
|
imageWidth: number,
|
|
): void {
|
|
const pad = Math.max(6, imageWidth / 400);
|
|
ctx.save();
|
|
ctx.shadowColor = "transparent";
|
|
ctx.strokeStyle = "rgba(99, 102, 241, 0.95)";
|
|
ctx.lineWidth = Math.max(1, imageWidth / 700);
|
|
ctx.setLineDash([
|
|
Math.max(4, imageWidth / 180),
|
|
Math.max(3, imageWidth / 220),
|
|
]);
|
|
ctx.strokeRect(
|
|
box.left - pad,
|
|
box.top - pad,
|
|
box.width + pad * 2,
|
|
box.height + pad * 2,
|
|
);
|
|
ctx.restore();
|
|
}
|
|
|
|
export function hitTestTextBox(
|
|
box: TextBox,
|
|
x: number,
|
|
y: number,
|
|
imageWidth: number,
|
|
): boolean {
|
|
const pad = Math.max(10, imageWidth / 80);
|
|
return (
|
|
x >= box.left - pad &&
|
|
x <= box.left + box.width + pad &&
|
|
y >= box.top - pad &&
|
|
y <= box.top + box.height + pad
|
|
);
|
|
}
|