dd622f83c1
Address review feedback on #471 from @coderabbitai. The BMP-only codepoint ranges missed two classes of characters: - Non-BMP Han extensions (CJK Unified Ideographs Extension B, C, D, E, F) such as 𠀀. A long string of Extension-B characters would still be tokenized as a single unbreakable unit and overflow the box. - Halfwidth Katakana (U+FF65-U+FF9F) such as カ. Same failure mode. Switch to Unicode script property escapes (\\p{Script=Han}, \\p{Script=Hiragana}, \\p{Script=Katakana}, \\p{Script=Hangul}) which cover these cases without enumerating ranges. tsconfig target is ES2020; property escapes require ES2018+ so this is safe. Verified coverage: 漢 あ ア 가 𠀀 カ all match; A and digits do not.
452 lines
12 KiB
TypeScript
452 lines
12 KiB
TypeScript
import { type AnnotationRegion, type ArrowDirection } from "@/components/video-editor/types";
|
|
import {
|
|
applyMosaicToImageData,
|
|
getBlurOverlayColor,
|
|
getNormalizedBlurIntensity,
|
|
getNormalizedMosaicBlockSize,
|
|
normalizeBlurType,
|
|
} from "@/lib/blurEffects";
|
|
|
|
let blurScratchCanvas: HTMLCanvasElement | null = null;
|
|
let blurScratchCtx: CanvasRenderingContext2D | null = null;
|
|
|
|
// Matches a single code point whose script is Han (including non-BMP
|
|
// Extension A-F), Hiragana, Katakana (including halfwidth forms), or
|
|
// Hangul. Used to split CJK text at character boundaries during wrap,
|
|
// since CJK scripts have no word-separating whitespace. Unicode script
|
|
// property escapes require ES2018+; tsconfig target is ES2020.
|
|
const CJK_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
|
|
function tokenizeForWrap(line: string): string[] {
|
|
// Split Latin text on whitespace (preserving the whitespace as its own token,
|
|
// matching the original behavior), and split CJK runs into individual
|
|
// characters so each one becomes a breakable unit. This mirrors the editor's
|
|
// CSS `word-break: break-word` handling for CJK content.
|
|
const tokens: string[] = [];
|
|
let buffer = "";
|
|
const chars = Array.from(line);
|
|
const flushBuffer = () => {
|
|
if (buffer) {
|
|
tokens.push(...buffer.split(/(\s+)/).filter((s) => s.length > 0));
|
|
buffer = "";
|
|
}
|
|
};
|
|
for (const ch of chars) {
|
|
if (CJK_CHAR.test(ch)) {
|
|
flushBuffer();
|
|
tokens.push(ch);
|
|
} else {
|
|
buffer += ch;
|
|
}
|
|
}
|
|
flushBuffer();
|
|
return tokens;
|
|
}
|
|
|
|
// SVG path data for each arrow direction
|
|
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
|
|
up: ["M 50 20 L 50 80", "M 50 20 L 35 35", "M 50 20 L 65 35"],
|
|
down: ["M 50 20 L 50 80", "M 50 80 L 35 65", "M 50 80 L 65 65"],
|
|
left: ["M 80 50 L 20 50", "M 20 50 L 35 35", "M 20 50 L 35 65"],
|
|
right: ["M 20 50 L 80 50", "M 80 50 L 65 35", "M 80 50 L 65 65"],
|
|
"up-right": ["M 25 75 L 75 25", "M 75 25 L 60 30", "M 75 25 L 70 40"],
|
|
"up-left": ["M 75 75 L 25 25", "M 25 25 L 40 30", "M 25 25 L 30 40"],
|
|
"down-right": ["M 25 25 L 75 75", "M 75 75 L 70 60", "M 75 75 L 60 70"],
|
|
"down-left": ["M 75 25 L 25 75", "M 25 75 L 30 60", "M 25 75 L 40 70"],
|
|
};
|
|
|
|
function parseSvgPath(
|
|
pathString: string,
|
|
scaleX: number,
|
|
scaleY: number,
|
|
): Array<{ cmd: string; args: number[] }> {
|
|
const commands: Array<{ cmd: string; args: number[] }> = [];
|
|
const parts = pathString.trim().split(/\s+/);
|
|
|
|
let i = 0;
|
|
while (i < parts.length) {
|
|
const cmd = parts[i];
|
|
if (cmd === "M" || cmd === "L") {
|
|
const x = parseFloat(parts[i + 1]) * scaleX;
|
|
const y = parseFloat(parts[i + 2]) * scaleY;
|
|
commands.push({ cmd, args: [x, y] });
|
|
i += 3;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
|
|
return commands;
|
|
}
|
|
|
|
function renderArrow(
|
|
ctx: CanvasRenderingContext2D,
|
|
direction: ArrowDirection,
|
|
color: string,
|
|
strokeWidth: number,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
_scaleFactor: number,
|
|
) {
|
|
const paths = ARROW_PATHS[direction];
|
|
if (!paths) return;
|
|
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
|
|
const padding = 8 * _scaleFactor;
|
|
const availableWidth = Math.max(0, width - padding * 2);
|
|
const availableHeight = Math.max(0, height - padding * 2);
|
|
|
|
const scale = Math.min(availableWidth / 100, availableHeight / 100);
|
|
|
|
const offsetX = padding + (availableWidth - 100 * scale) / 2;
|
|
const offsetY = padding + (availableHeight - 100 * scale) / 2;
|
|
|
|
// Apply centering offset
|
|
ctx.translate(offsetX, offsetY);
|
|
|
|
// Apply shadow filter
|
|
ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
|
|
ctx.shadowBlur = 8 * scale;
|
|
ctx.shadowOffsetX = 0;
|
|
ctx.shadowOffsetY = 4 * scale;
|
|
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = strokeWidth * scale;
|
|
ctx.lineCap = "round";
|
|
ctx.lineJoin = "round";
|
|
|
|
// Draw all paths as a single shape to avoid overlapping shadows/strokes
|
|
ctx.beginPath();
|
|
|
|
for (const pathString of paths) {
|
|
const commands = parseSvgPath(pathString, scale, scale);
|
|
|
|
for (const { cmd, args } of commands) {
|
|
if (cmd === "M") {
|
|
ctx.moveTo(args[0], args[1]);
|
|
} else if (cmd === "L") {
|
|
ctx.lineTo(args[0], args[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawBlurPath(
|
|
ctx: CanvasRenderingContext2D,
|
|
annotation: AnnotationRegion,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
) {
|
|
const shape = annotation.blurData?.shape || "rectangle";
|
|
if (shape === "rectangle") {
|
|
ctx.beginPath();
|
|
ctx.rect(x, y, width, height);
|
|
return;
|
|
}
|
|
|
|
if (shape === "oval") {
|
|
ctx.beginPath();
|
|
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
|
return;
|
|
}
|
|
|
|
const points = annotation.blurData?.freehandPoints;
|
|
if (shape === "freehand" && points && points.length >= 3) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + (points[0].x / 100) * width, y + (points[0].y / 100) * height);
|
|
for (let i = 1; i < points.length; i++) {
|
|
ctx.lineTo(x + (points[i].x / 100) * width, y + (points[i].y / 100) * height);
|
|
}
|
|
ctx.closePath();
|
|
return;
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.rect(x, y, width, height);
|
|
}
|
|
|
|
function renderBlur(
|
|
ctx: CanvasRenderingContext2D,
|
|
annotation: AnnotationRegion,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
scaleFactor: number,
|
|
) {
|
|
const canvas = ctx.canvas;
|
|
const blurType = normalizeBlurType(annotation.blurData?.type);
|
|
|
|
const blurRadius = Math.max(
|
|
1,
|
|
Math.round(getNormalizedBlurIntensity(annotation.blurData) * scaleFactor),
|
|
);
|
|
const samplePadding =
|
|
blurType === "mosaic"
|
|
? Math.max(0, Math.ceil(getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor)))
|
|
: Math.max(2, Math.ceil(blurRadius * 2));
|
|
const sx = Math.max(0, Math.floor(x) - samplePadding);
|
|
const sy = Math.max(0, Math.floor(y) - samplePadding);
|
|
const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
|
|
const ey = Math.min(canvas.height, Math.ceil(y + height) + samplePadding);
|
|
const sw = Math.max(0, ex - sx);
|
|
const sh = Math.max(0, ey - sy);
|
|
if (sw <= 0 || sh <= 0) return;
|
|
|
|
if (!blurScratchCanvas || !blurScratchCtx) {
|
|
blurScratchCanvas = document.createElement("canvas");
|
|
blurScratchCtx = blurScratchCanvas.getContext("2d");
|
|
}
|
|
if (!blurScratchCanvas || !blurScratchCtx) return;
|
|
|
|
blurScratchCanvas.width = sw;
|
|
blurScratchCanvas.height = sh;
|
|
blurScratchCtx.clearRect(0, 0, sw, sh);
|
|
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
|
|
if (blurType === "mosaic") {
|
|
const imageData = blurScratchCtx.getImageData(0, 0, sw, sh);
|
|
applyMosaicToImageData(
|
|
imageData,
|
|
getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor),
|
|
);
|
|
blurScratchCtx.putImageData(imageData, 0, 0);
|
|
}
|
|
|
|
ctx.save();
|
|
drawBlurPath(ctx, annotation, x, y, width, height);
|
|
ctx.clip();
|
|
ctx.filter = blurType === "mosaic" ? "none" : `blur(${blurRadius}px)`;
|
|
ctx.drawImage(blurScratchCanvas, sx, sy);
|
|
ctx.filter = "none";
|
|
ctx.fillStyle = getBlurOverlayColor(annotation.blurData);
|
|
ctx.fillRect(sx, sy, sw, sh);
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderText(
|
|
ctx: CanvasRenderingContext2D,
|
|
annotation: AnnotationRegion,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
scaleFactor: number,
|
|
) {
|
|
const style = annotation.style;
|
|
|
|
ctx.save();
|
|
|
|
// Clip text to annotation box bounds (matches editor's overflow: hidden)
|
|
ctx.beginPath();
|
|
ctx.rect(x, y, width, height);
|
|
ctx.clip();
|
|
|
|
const fontWeight = style.fontWeight === "bold" ? "bold" : "normal";
|
|
const fontStyle = style.fontStyle === "italic" ? "italic" : "normal";
|
|
const scaledFontSize = style.fontSize * scaleFactor;
|
|
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${style.fontFamily}`;
|
|
ctx.textBaseline = "middle";
|
|
|
|
const containerPadding = 8 * scaleFactor;
|
|
|
|
let textX = x;
|
|
let textY = y + height / 2;
|
|
|
|
if (style.textAlign === "center") {
|
|
textX = x + width / 2;
|
|
ctx.textAlign = "center";
|
|
} else if (style.textAlign === "right") {
|
|
textX = x + width - containerPadding;
|
|
ctx.textAlign = "right";
|
|
} else {
|
|
textX = x + containerPadding;
|
|
ctx.textAlign = "left";
|
|
}
|
|
|
|
const availableWidth = width - containerPadding * 2;
|
|
const rawLines = annotation.content.split("\n");
|
|
const lines: string[] = [];
|
|
for (const rawLine of rawLines) {
|
|
if (!rawLine) {
|
|
lines.push("");
|
|
continue;
|
|
}
|
|
const tokens = tokenizeForWrap(rawLine);
|
|
let current = "";
|
|
for (const token of tokens) {
|
|
const test = current + token;
|
|
if (current && ctx.measureText(test).width > availableWidth) {
|
|
lines.push(current);
|
|
current = token.trimStart();
|
|
} else {
|
|
current = test;
|
|
}
|
|
}
|
|
if (current) lines.push(current);
|
|
}
|
|
const lineHeight = scaledFontSize * 1.4;
|
|
|
|
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
|
|
|
|
lines.forEach((line, index) => {
|
|
const currentY = startY + index * lineHeight;
|
|
|
|
if (style.backgroundColor && style.backgroundColor !== "transparent") {
|
|
const metrics = ctx.measureText(line);
|
|
const verticalPadding = scaledFontSize * 0.1;
|
|
const horizontalPadding = scaledFontSize * 0.2;
|
|
const borderRadius = 4 * scaleFactor;
|
|
|
|
let bgX = textX - horizontalPadding;
|
|
const bgWidth = metrics.width + horizontalPadding * 2;
|
|
|
|
const contentHeight = scaledFontSize * 1.4;
|
|
const bgHeight = contentHeight + verticalPadding * 2;
|
|
const bgY = currentY - bgHeight / 2;
|
|
|
|
if (style.textAlign === "center") {
|
|
bgX = textX - bgWidth / 2;
|
|
} else if (style.textAlign === "right") {
|
|
bgX = textX - bgWidth;
|
|
}
|
|
|
|
ctx.fillStyle = style.backgroundColor;
|
|
ctx.beginPath();
|
|
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.fillStyle = style.color;
|
|
ctx.fillText(line, textX, currentY);
|
|
|
|
if (style.textDecoration === "underline") {
|
|
const metrics = ctx.measureText(line);
|
|
let underlineX = textX;
|
|
const underlineY = currentY + scaledFontSize * 0.15;
|
|
|
|
if (style.textAlign === "center") {
|
|
underlineX = textX - metrics.width / 2;
|
|
} else if (style.textAlign === "right") {
|
|
underlineX = textX - metrics.width;
|
|
}
|
|
|
|
ctx.strokeStyle = style.color;
|
|
ctx.lineWidth = Math.max(1, scaledFontSize / 16);
|
|
ctx.beginPath();
|
|
ctx.moveTo(underlineX, underlineY);
|
|
ctx.lineTo(underlineX + metrics.width, underlineY);
|
|
ctx.stroke();
|
|
}
|
|
});
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
async function renderImage(
|
|
ctx: CanvasRenderingContext2D,
|
|
annotation: AnnotationRegion,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
): Promise<void> {
|
|
if (!annotation.content || !annotation.content.startsWith("data:image")) {
|
|
return;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
// Preserve aspect ratio - contain the image within the bounds
|
|
const imgAspect = img.width / img.height;
|
|
const boxAspect = width / height;
|
|
|
|
let drawWidth = width;
|
|
let drawHeight = height;
|
|
let drawX = x;
|
|
let drawY = y;
|
|
|
|
if (imgAspect > boxAspect) {
|
|
drawHeight = width / imgAspect;
|
|
drawY = y + (height - drawHeight) / 2;
|
|
} else {
|
|
drawWidth = height * imgAspect;
|
|
drawX = x + (width - drawWidth) / 2;
|
|
}
|
|
|
|
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
|
resolve();
|
|
};
|
|
img.onerror = () => {
|
|
console.error("[AnnotationRenderer] Failed to load image annotation");
|
|
resolve();
|
|
};
|
|
img.src = annotation.content;
|
|
});
|
|
}
|
|
|
|
export async function renderAnnotations(
|
|
ctx: CanvasRenderingContext2D,
|
|
annotations: AnnotationRegion[],
|
|
canvasWidth: number,
|
|
canvasHeight: number,
|
|
currentTimeMs: number,
|
|
scaleFactor: number = 1.0,
|
|
): Promise<void> {
|
|
// Filter active annotations at current time
|
|
const activeAnnotations = annotations.filter(
|
|
(ann) => currentTimeMs >= ann.startMs && currentTimeMs < ann.endMs,
|
|
);
|
|
|
|
// Sort by z-index (lower first, so higher z-index draws on top)
|
|
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
|
|
|
|
for (const annotation of sortedAnnotations) {
|
|
const x = (annotation.position.x / 100) * canvasWidth;
|
|
const y = (annotation.position.y / 100) * canvasHeight;
|
|
const width = (annotation.size.width / 100) * canvasWidth;
|
|
const height = (annotation.size.height / 100) * canvasHeight;
|
|
|
|
switch (annotation.type) {
|
|
case "text":
|
|
renderText(ctx, annotation, x, y, width, height, scaleFactor);
|
|
break;
|
|
|
|
case "image":
|
|
await renderImage(ctx, annotation, x, y, width, height);
|
|
break;
|
|
|
|
case "figure":
|
|
if (annotation.figureData) {
|
|
renderArrow(
|
|
ctx,
|
|
annotation.figureData.arrowDirection,
|
|
annotation.figureData.color,
|
|
annotation.figureData.strokeWidth,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
scaleFactor,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case "blur":
|
|
renderBlur(ctx, annotation, x, y, width, height, scaleFactor);
|
|
break;
|
|
}
|
|
}
|
|
}
|