mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: major editor overhaul (assets, properties, timeline, fonts) (#709)
* feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { BaseNode } from "./base-node";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants";
|
||||
import {
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/constants/text-constants";
|
||||
|
||||
function scaleFontSize({
|
||||
fontSize,
|
||||
@@ -13,6 +16,107 @@ function scaleFontSize({
|
||||
return fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
}
|
||||
|
||||
function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
|
||||
return `"${fontFamily.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function getMetricAscent({
|
||||
metrics,
|
||||
fallback,
|
||||
}: {
|
||||
metrics: TextMetrics;
|
||||
fallback: number;
|
||||
}): number {
|
||||
return metrics.actualBoundingBoxAscent ?? fallback * 0.8;
|
||||
}
|
||||
|
||||
function getMetricDescent({
|
||||
metrics,
|
||||
fallback,
|
||||
}: {
|
||||
metrics: TextMetrics;
|
||||
fallback: number;
|
||||
}): number {
|
||||
return metrics.actualBoundingBoxDescent ?? fallback * 0.2;
|
||||
}
|
||||
|
||||
interface TextBlockMeasurement {
|
||||
visualCenterOffset: number;
|
||||
height: number;
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
function measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx,
|
||||
fallbackFontSize,
|
||||
}: {
|
||||
lineMetrics: TextMetrics[];
|
||||
lineHeightPx: number;
|
||||
fallbackFontSize: number;
|
||||
}): TextBlockMeasurement {
|
||||
let top = Number.POSITIVE_INFINITY;
|
||||
let bottom = Number.NEGATIVE_INFINITY;
|
||||
let maxWidth = 0;
|
||||
|
||||
for (let i = 0; i < lineMetrics.length; i++) {
|
||||
const metrics = lineMetrics[i];
|
||||
const y = i * lineHeightPx;
|
||||
top = Math.min(
|
||||
top,
|
||||
y - getMetricAscent({ metrics, fallback: fallbackFontSize }),
|
||||
);
|
||||
bottom = Math.max(
|
||||
bottom,
|
||||
y + getMetricDescent({ metrics, fallback: fallbackFontSize }),
|
||||
);
|
||||
maxWidth = Math.max(maxWidth, metrics.width);
|
||||
}
|
||||
|
||||
const height = bottom - top;
|
||||
const visualCenterOffset = (top + bottom) / 2;
|
||||
|
||||
return { visualCenterOffset, height, maxWidth };
|
||||
}
|
||||
|
||||
function drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration,
|
||||
lineWidth,
|
||||
lineY,
|
||||
metrics,
|
||||
scaledFontSize,
|
||||
textAlign,
|
||||
}: {
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
textDecoration: string;
|
||||
lineWidth: number;
|
||||
lineY: number;
|
||||
metrics: TextMetrics;
|
||||
scaledFontSize: number;
|
||||
textAlign: CanvasTextAlign;
|
||||
}): void {
|
||||
if (textDecoration === "none" || !textDecoration) return;
|
||||
|
||||
const thickness = Math.max(1, scaledFontSize * 0.07);
|
||||
const ascent = getMetricAscent({ metrics, fallback: scaledFontSize });
|
||||
const descent = getMetricDescent({ metrics, fallback: scaledFontSize });
|
||||
|
||||
let xStart = -lineWidth / 2;
|
||||
if (textAlign === "left") xStart = 0;
|
||||
if (textAlign === "right") xStart = -lineWidth;
|
||||
|
||||
if (textDecoration === "underline") {
|
||||
const underlineY = lineY + descent + thickness;
|
||||
ctx.fillRect(xStart, underlineY, lineWidth, thickness);
|
||||
}
|
||||
|
||||
if (textDecoration === "line-through") {
|
||||
const strikeY = lineY - (ascent - descent) * 0.35;
|
||||
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
|
||||
}
|
||||
}
|
||||
|
||||
export type TextNodeParams = TextElement & {
|
||||
canvasCenter: { x: number; y: number };
|
||||
canvasHeight: number;
|
||||
@@ -38,6 +142,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
const y = this.params.transform.position.y + this.params.canvasCenter.y;
|
||||
|
||||
renderer.context.translate(x, y);
|
||||
renderer.context.scale(
|
||||
this.params.transform.scale,
|
||||
this.params.transform.scale,
|
||||
);
|
||||
if (this.params.transform.rotate) {
|
||||
renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
@@ -48,39 +156,72 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
fontSize: this.params.fontSize,
|
||||
canvasHeight: this.params.canvasHeight,
|
||||
});
|
||||
renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${this.params.fontFamily}`;
|
||||
const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
|
||||
renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
renderer.context.textAlign = this.params.textAlign;
|
||||
renderer.context.textBaseline = this.params.textBaseline || "middle";
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
|
||||
const letterSpacing = this.params.letterSpacing ?? 0;
|
||||
const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
if ("letterSpacing" in renderer.context) {
|
||||
(
|
||||
renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
|
||||
).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
const lines = this.params.content.split("\n");
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
const baseline = this.params.textBaseline ?? "middle";
|
||||
|
||||
renderer.context.textBaseline = baseline;
|
||||
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
|
||||
const lineCount = lines.length;
|
||||
|
||||
const block = measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx,
|
||||
fallbackFontSize: scaledFontSize,
|
||||
});
|
||||
|
||||
const prevAlpha = renderer.context.globalAlpha;
|
||||
renderer.context.globalCompositeOperation = (
|
||||
this.params.blendMode && this.params.blendMode !== "normal"
|
||||
? this.params.blendMode
|
||||
: "source-over"
|
||||
) as GlobalCompositeOperation;
|
||||
renderer.context.globalAlpha = this.params.opacity;
|
||||
|
||||
if (this.params.backgroundColor) {
|
||||
const metrics = renderer.context.measureText(this.params.content);
|
||||
const ascent = metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8;
|
||||
const descent = metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2;
|
||||
const textW = metrics.width;
|
||||
const textH = ascent + descent;
|
||||
if (this.params.backgroundColor && lineCount > 0) {
|
||||
const padX = 8;
|
||||
const padY = 4;
|
||||
|
||||
renderer.context.fillStyle = this.params.backgroundColor;
|
||||
let bgLeft = -textW / 2;
|
||||
let bgLeft = -block.maxWidth / 2;
|
||||
if (renderer.context.textAlign === "left") bgLeft = 0;
|
||||
if (renderer.context.textAlign === "right") bgLeft = -textW;
|
||||
if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth;
|
||||
|
||||
renderer.context.fillRect(
|
||||
bgLeft - padX,
|
||||
-textH / 2 - padY,
|
||||
textW + padX * 2,
|
||||
textH + padY * 2,
|
||||
-block.height / 2 - padY,
|
||||
block.maxWidth + padX * 2,
|
||||
block.height + padY * 2,
|
||||
);
|
||||
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
}
|
||||
|
||||
renderer.context.fillText(this.params.content, 0, 0);
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const y = i * lineHeightPx - block.visualCenterOffset;
|
||||
renderer.context.fillText(lines[i], 0, y);
|
||||
drawTextDecoration({
|
||||
ctx: renderer.context,
|
||||
textDecoration: this.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[i].width,
|
||||
lineY: y,
|
||||
metrics: lineMetrics[i],
|
||||
scaledFontSize,
|
||||
textAlign: this.params.textAlign,
|
||||
});
|
||||
}
|
||||
|
||||
renderer.context.globalAlpha = prevAlpha;
|
||||
renderer.context.restore();
|
||||
|
||||
Reference in New Issue
Block a user