summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/preview/element-bounds.ts
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-02-23 03:24:02 +0100
committerGitHub <noreply@github.com>2026-02-23 03:24:02 +0100
commit93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch)
tree1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/lib/preview/element-bounds.ts
parentfca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff)
downloadopencut-93d1e3383ce47e45157d70f9ba6251239dae6bc2.tar.gz
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
Diffstat (limited to 'apps/web/src/lib/preview/element-bounds.ts')
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts219
1 files changed, 219 insertions, 0 deletions
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
new file mode 100644
index 00000000..ec26c14d
--- /dev/null
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -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;
+}