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:
Maze
2026-02-23 03:24:02 +01:00
committed by GitHub
parent fca99d6126
commit 93d1e3383c
215 changed files with 26980 additions and 8364 deletions
+219
View File
@@ -0,0 +1,219 @@
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
import { isMainTrack } from "@/lib/timeline";
import {
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
export interface ElementBounds {
cx: number;
cy: number;
width: number;
height: number;
rotation: number;
}
export interface ElementWithBounds {
trackId: string;
elementId: string;
element: TimelineElement;
bounds: ElementBounds;
}
function getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth,
sourceHeight,
transform,
}: {
canvasWidth: number;
canvasHeight: number;
sourceWidth: number;
sourceHeight: number;
transform: {
scale: number;
position: { x: number; y: number };
rotate: number;
};
}): ElementBounds {
const containScale = Math.min(
canvasWidth / sourceWidth,
canvasHeight / sourceHeight,
);
const scaledWidth = sourceWidth * containScale * transform.scale;
const scaledHeight = sourceHeight * containScale * transform.scale;
const cx = canvasWidth / 2 + transform.position.x;
const cy = canvasHeight / 2 + transform.position.y;
return {
cx,
cy,
width: scaledWidth,
height: scaledHeight,
rotation: transform.rotate,
};
}
export function getElementBounds({
element,
canvasSize,
mediaAsset,
}: {
element: TimelineElement;
canvasSize: { width: number; height: number };
mediaAsset?: MediaAsset | null;
}): ElementBounds | null {
if (element.type === "audio") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
if (element.type === "video" || element.type === "image") {
const sourceWidth = mediaAsset?.width ?? canvasWidth;
const sourceHeight = mediaAsset?.height ?? canvasHeight;
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth,
sourceHeight,
transform: element.transform,
});
}
if (element.type === "sticker") {
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth: 200,
sourceHeight: 200,
transform: element.transform,
});
}
if (element.type === "text") {
const scaledFontSize =
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const letterSpacing = element.letterSpacing ?? 0;
const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
const lineHeightPx = scaledFontSize * lineHeight;
let measuredWidth = 100;
let measuredHeight = scaledFontSize;
const canvas = document.createElement("canvas");
canvas.width = 4096;
canvas.height = 4096;
const ctx = canvas.getContext("2d");
if (ctx) {
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
ctx.textAlign = element.textAlign as CanvasTextAlign;
if ("letterSpacing" in ctx) {
(
ctx as CanvasRenderingContext2D & { letterSpacing: string }
).letterSpacing = `${letterSpacing}px`;
}
const lines = element.content.split("\n");
const lineMetrics = lines.map((line) => ctx.measureText(line));
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 - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8),
);
bottom = Math.max(
bottom,
y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2),
);
maxWidth = Math.max(maxWidth, metrics.width);
}
measuredWidth = maxWidth;
measuredHeight = bottom - top;
}
const width = measuredWidth * element.transform.scale;
const height = measuredHeight * element.transform.scale;
return {
cx: canvasWidth / 2 + element.transform.position.x,
cy: canvasHeight / 2 + element.transform.position.y,
width,
height,
rotation: element.transform.rotate,
};
}
return null;
}
export function getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
}: {
tracks: TimelineTrack[];
currentTime: number;
canvasSize: { width: number; height: number };
mediaAssets: MediaAsset[];
}): ElementWithBounds[] {
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
const visibleTracks = tracks.filter(
(track) => !("hidden" in track && track.hidden),
);
const orderedTracks = [
...visibleTracks.filter((track) => !isMainTrack(track)),
...visibleTracks.filter((track) => isMainTrack(track)),
].reverse();
const result: ElementWithBounds[] = [];
for (const track of orderedTracks) {
const elements = track.elements
.filter((element) => !("hidden" in element && element.hidden))
.filter(
(element) =>
currentTime >= element.startTime &&
currentTime < element.startTime + element.duration,
)
.slice()
.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
return a.id.localeCompare(b.id);
});
for (const element of elements) {
const mediaAsset =
element.type === "video" || element.type === "image"
? mediaMap.get(element.mediaId)
: undefined;
const bounds = getElementBounds({
element,
canvasSize,
mediaAsset,
});
if (bounds) {
result.push({
trackId: track.id,
elementId: element.id,
element,
bounds,
});
}
}
}
return result;
}
+60
View File
@@ -0,0 +1,60 @@
import type { ElementWithBounds } from "./element-bounds";
function pointInRotatedRect({
px,
py,
cx,
cy,
width,
height,
rotation,
}: {
px: number;
py: number;
cx: number;
cy: number;
width: number;
height: number;
rotation: number;
}): boolean {
const angleRad = (rotation * Math.PI) / 180;
const cos = Math.cos(-angleRad);
const sin = Math.sin(-angleRad);
const dx = px - cx;
const dy = py - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
const halfW = width / 2;
const halfH = height / 2;
return (
localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH
);
}
export function hitTest({
canvasX,
canvasY,
elementsWithBounds,
}: {
canvasX: number;
canvasY: number;
elementsWithBounds: ElementWithBounds[];
}): ElementWithBounds | null {
for (let i = elementsWithBounds.length - 1; i >= 0; i--) {
const { bounds } = elementsWithBounds[i];
if (
pointInRotatedRect({
px: canvasX,
py: canvasY,
cx: bounds.cx,
cy: bounds.cy,
width: bounds.width,
height: bounds.height,
rotation: bounds.rotation,
})
) {
return elementsWithBounds[i];
}
}
return null;
}
@@ -0,0 +1,76 @@
export function screenToCanvas({
clientX,
clientY,
canvas,
}: {
clientX: number;
clientY: number;
canvas: HTMLCanvasElement;
}): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY,
};
}
export function canvasToOverlay({
canvasX,
canvasY,
canvasRect,
containerRect,
canvasSize,
}: {
canvasX: number;
canvasY: number;
canvasRect: DOMRect;
containerRect: DOMRect;
canvasSize: { width: number; height: number };
}): { x: number; y: number } {
const scaleX = canvasRect.width / canvasSize.width;
const scaleY = canvasRect.height / canvasSize.height;
return {
x: canvasRect.left - containerRect.left + canvasX * scaleX,
y: canvasRect.top - containerRect.top + canvasY * scaleY,
};
}
export function positionToOverlay({
positionX,
positionY,
canvasRect,
containerRect,
canvasSize,
}: {
positionX: number;
positionY: number;
canvasRect: DOMRect;
containerRect: DOMRect;
canvasSize: { width: number; height: number };
}): { x: number; y: number } {
const scaleX = canvasRect.width / canvasSize.width;
const scaleY = canvasRect.height / canvasSize.height;
const centerScreenX =
canvasRect.left - containerRect.left + (canvasSize.width / 2) * scaleX;
const centerScreenY =
canvasRect.top - containerRect.top + (canvasSize.height / 2) * scaleY;
return {
x: centerScreenX + positionX * scaleX,
y: centerScreenY + positionY * scaleY,
};
}
export function getDisplayScale({
canvasRect,
canvasSize,
}: {
canvasRect: DOMRect;
canvasSize: { width: number; height: number };
}): { x: number; y: number } {
return {
x: canvasRect.width / canvasSize.width,
y: canvasRect.height / canvasSize.height,
};
}
+292
View File
@@ -0,0 +1,292 @@
export interface SnapLine {
type: "horizontal" | "vertical";
position: number;
}
const SNAP_THRESHOLD = 10;
const ROTATION_SNAP_STEP_DEGREES = 90;
const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
export const MIN_SCALE = 0.01;
export interface SnapResult {
snappedPosition: { x: number; y: number };
activeLines: SnapLine[];
}
export function snapPosition({
proposedPosition,
canvasSize,
elementSize,
}: {
proposedPosition: { x: number; y: number };
canvasSize: { width: number; height: number };
elementSize: { width: number; height: number };
}): SnapResult {
const centerX = 0;
const centerY = 0;
const left = -canvasSize.width / 2;
const right = canvasSize.width / 2;
const top = -canvasSize.height / 2;
const bottom = canvasSize.height / 2;
const halfWidth = elementSize.width / 2;
const halfHeight = elementSize.height / 2;
const activeLines: SnapLine[] = [];
type AxisSnapCandidate = {
snappedPosition: number;
line: SnapLine;
distance: number;
};
function getClosestAxisSnap({
candidates,
}: {
candidates: AxisSnapCandidate[];
}): AxisSnapCandidate | null {
const snapCandidatesWithinThreshold = candidates.filter(
(candidate) => candidate.distance <= SNAP_THRESHOLD,
);
if (snapCandidatesWithinThreshold.length === 0) {
return null;
}
return snapCandidatesWithinThreshold.reduce((closest, current) =>
current.distance < closest.distance ? current : closest,
);
}
const verticalTargets = [left, centerX, right];
const horizontalTargets = [top, centerY, bottom];
const xCandidates: AxisSnapCandidate[] = [];
for (const targetX of verticalTargets) {
xCandidates.push({
snappedPosition: targetX,
line: { type: "vertical", position: targetX },
distance: Math.abs(proposedPosition.x - targetX),
});
xCandidates.push({
snappedPosition: targetX + halfWidth,
line: { type: "vertical", position: targetX },
distance: Math.abs(proposedPosition.x - halfWidth - targetX),
});
xCandidates.push({
snappedPosition: targetX - halfWidth,
line: { type: "vertical", position: targetX },
distance: Math.abs(proposedPosition.x + halfWidth - targetX),
});
}
const yCandidates: AxisSnapCandidate[] = [];
for (const targetY of horizontalTargets) {
yCandidates.push({
snappedPosition: targetY,
line: { type: "horizontal", position: targetY },
distance: Math.abs(proposedPosition.y - targetY),
});
yCandidates.push({
snappedPosition: targetY + halfHeight,
line: { type: "horizontal", position: targetY },
distance: Math.abs(proposedPosition.y - halfHeight - targetY),
});
yCandidates.push({
snappedPosition: targetY - halfHeight,
line: { type: "horizontal", position: targetY },
distance: Math.abs(proposedPosition.y + halfHeight - targetY),
});
}
const closestX = getClosestAxisSnap({ candidates: xCandidates });
const closestY = getClosestAxisSnap({ candidates: yCandidates });
const x = closestX?.snappedPosition ?? proposedPosition.x;
const y = closestY?.snappedPosition ?? proposedPosition.y;
if (closestX) {
activeLines.push(closestX.line);
}
if (closestY) {
activeLines.push(closestY.line);
}
return {
snappedPosition: { x, y },
activeLines,
};
}
export interface ScaleSnapResult {
snappedScale: number;
activeLines: SnapLine[];
}
export function snapScale({
proposedScale,
position,
baseWidth,
baseHeight,
canvasSize,
}: {
proposedScale: number;
position: { x: number; y: number };
baseWidth: number;
baseHeight: number;
canvasSize: { width: number; height: number };
}): ScaleSnapResult {
const centerX = 0;
const centerY = 0;
const left = -canvasSize.width / 2;
const right = canvasSize.width / 2;
const top = -canvasSize.height / 2;
const bottom = canvasSize.height / 2;
const leftEdge = position.x - (baseWidth * proposedScale) / 2;
const rightEdge = position.x + (baseWidth * proposedScale) / 2;
const topEdge = position.y - (baseHeight * proposedScale) / 2;
const bottomEdge = position.y + (baseHeight * proposedScale) / 2;
interface SnapCandidate {
scale: number;
distance: number;
lines: SnapLine[];
}
const candidates: SnapCandidate[] = [];
const verticalTargets = [
{ position: left, line: { type: "vertical" as const, position: left } },
{
position: centerX,
line: { type: "vertical" as const, position: centerX },
},
{ position: right, line: { type: "vertical" as const, position: right } },
];
for (const target of verticalTargets) {
const distanceLeft = Math.abs(leftEdge - target.position);
if (distanceLeft <= SNAP_THRESHOLD) {
const scale = (2 * (position.x - target.position)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
scale,
distance: distanceLeft,
lines: [target.line],
});
}
}
const distanceRight = Math.abs(rightEdge - target.position);
if (distanceRight <= SNAP_THRESHOLD) {
const scale = (2 * (target.position - position.x)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
scale,
distance: distanceRight,
lines: [target.line],
});
}
}
}
const horizontalTargets = [
{ position: top, line: { type: "horizontal" as const, position: top } },
{
position: centerY,
line: { type: "horizontal" as const, position: centerY },
},
{
position: bottom,
line: { type: "horizontal" as const, position: bottom },
},
];
for (const target of horizontalTargets) {
const distanceTop = Math.abs(topEdge - target.position);
if (distanceTop <= SNAP_THRESHOLD) {
const scale = (2 * (position.y - target.position)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({
scale,
distance: distanceTop,
lines: [target.line],
});
}
}
const distanceBottom = Math.abs(bottomEdge - target.position);
if (distanceBottom <= SNAP_THRESHOLD) {
const scale = (2 * (target.position - position.y)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({
scale,
distance: distanceBottom,
lines: [target.line],
});
}
}
}
if (candidates.length === 0) {
return { snappedScale: proposedScale, activeLines: [] };
}
const best = candidates.reduce((acc, candidate) =>
candidate.distance < acc.distance ? candidate : acc,
);
const snappedLeft = position.x - (baseWidth * best.scale) / 2;
const snappedRight = position.x + (baseWidth * best.scale) / 2;
const snappedTop = position.y - (baseHeight * best.scale) / 2;
const snappedBottom = position.y + (baseHeight * best.scale) / 2;
const activeLines: SnapLine[] = [];
const seenKeys = new Set<string>();
function addLine({ line }: { line: SnapLine }) {
const key = `${line.type}-${line.position}`;
if (!seenKeys.has(key)) {
seenKeys.add(key);
activeLines.push(line);
}
}
for (const target of verticalTargets) {
if (
Math.abs(snappedLeft - target.position) <= 1 ||
Math.abs(snappedRight - target.position) <= 1
) {
addLine({ line: target.line });
}
}
for (const target of horizontalTargets) {
if (
Math.abs(snappedTop - target.position) <= 1 ||
Math.abs(snappedBottom - target.position) <= 1
) {
addLine({ line: target.line });
}
}
return {
snappedScale: best.scale,
activeLines,
};
}
export interface RotationSnapResult {
snappedRotation: number;
isSnapped: boolean;
}
export function snapRotation({
proposedRotation,
}: {
proposedRotation: number;
}): RotationSnapResult {
const nearestRotationSnap =
Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
ROTATION_SNAP_STEP_DEGREES;
const distanceToNearestSnap = Math.abs(
proposedRotation - nearestRotationSnap,
);
if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
return { snappedRotation: nearestRotationSnap, isSnapped: true };
}
return { snappedRotation: proposedRotation, isSnapped: false };
}