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/preview-snap.ts | |
| 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/preview-snap.ts')
| -rw-r--r-- | apps/web/src/lib/preview/preview-snap.ts | 292 |
1 files changed, 292 insertions, 0 deletions
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 }; +} |
