diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-02-23 03:24:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-23 03:24:02 +0100 |
| commit | 93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch) | |
| tree | 1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/lib/preview | |
| parent | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff) | |
| download | opencut-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')
| -rw-r--r-- | apps/web/src/lib/preview/element-bounds.ts | 219 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/hit-test.ts | 60 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/preview-coords.ts | 76 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/preview-snap.ts | 292 |
4 files changed, 647 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; +} diff --git a/apps/web/src/lib/preview/hit-test.ts b/apps/web/src/lib/preview/hit-test.ts new file mode 100644 index 00000000..c2fde87b --- /dev/null +++ b/apps/web/src/lib/preview/hit-test.ts @@ -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; +} diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts new file mode 100644 index 00000000..08c16735 --- /dev/null +++ b/apps/web/src/lib/preview/preview-coords.ts @@ -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, + }; +} diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts new file mode 100644 index 00000000..bd53f0ca --- /dev/null +++ b/apps/web/src/lib/preview/preview-snap.ts @@ -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 }; +} |
