From 93d1e3383ce47e45157d70f9ba6251239dae6bc2 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:24:02 +0100 Subject: 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 --- apps/web/src/lib/preview/preview-snap.ts | 292 +++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 apps/web/src/lib/preview/preview-snap.ts (limited to 'apps/web/src/lib/preview/preview-snap.ts') 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(); + + 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 }; +} -- cgit v1.3.1