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/commands/index.ts | 1 + apps/web/src/lib/commands/preview-tracker.ts | 23 ++ apps/web/src/lib/commands/scene/index.ts | 2 + apps/web/src/lib/commands/scene/move-bookmark.ts | 59 +++++ apps/web/src/lib/commands/scene/update-bookmark.ts | 55 ++++ .../src/lib/commands/timeline/clipboard/paste.ts | 4 +- .../timeline/element/duplicate-elements.ts | 8 +- .../commands/timeline/element/insert-element.ts | 4 +- .../commands/timeline/element/split-elements.ts | 8 +- apps/web/src/lib/commands/timeline/index.ts | 2 + .../src/lib/commands/timeline/tracks-snapshot.ts | 20 ++ apps/web/src/lib/fonts/google-fonts.ts | 90 +++++++ apps/web/src/lib/preview/element-bounds.ts | 219 ++++++++++++++++ apps/web/src/lib/preview/hit-test.ts | 60 +++++ apps/web/src/lib/preview/preview-coords.ts | 76 ++++++ apps/web/src/lib/preview/preview-snap.ts | 292 +++++++++++++++++++++ .../src/lib/stickers/__tests__/sticker-id.test.ts | 34 +++ apps/web/src/lib/stickers/index.ts | 175 ++++++++++++ apps/web/src/lib/stickers/providers/emoji.ts | 119 +++++++++ apps/web/src/lib/stickers/providers/flags.ts | 167 ++++++++++++ apps/web/src/lib/stickers/providers/icons.ts | 122 +++++++++ apps/web/src/lib/stickers/providers/index.ts | 26 ++ apps/web/src/lib/stickers/providers/shapes.ts | 118 +++++++++ apps/web/src/lib/stickers/registry.ts | 31 +++ apps/web/src/lib/stickers/resolver.ts | 22 ++ apps/web/src/lib/stickers/sticker-id.ts | 34 +++ apps/web/src/lib/stickers/types.ts | 8 + apps/web/src/lib/timeline/bookmarks.ts | 112 +++++++- apps/web/src/lib/timeline/drag-utils.ts | 19 ++ apps/web/src/lib/timeline/element-utils.ts | 96 ++++++- 30 files changed, 1976 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/lib/commands/preview-tracker.ts create mode 100644 apps/web/src/lib/commands/scene/move-bookmark.ts create mode 100644 apps/web/src/lib/commands/scene/update-bookmark.ts create mode 100644 apps/web/src/lib/commands/timeline/tracks-snapshot.ts create mode 100644 apps/web/src/lib/fonts/google-fonts.ts create mode 100644 apps/web/src/lib/preview/element-bounds.ts create mode 100644 apps/web/src/lib/preview/hit-test.ts create mode 100644 apps/web/src/lib/preview/preview-coords.ts create mode 100644 apps/web/src/lib/preview/preview-snap.ts create mode 100644 apps/web/src/lib/stickers/__tests__/sticker-id.test.ts create mode 100644 apps/web/src/lib/stickers/index.ts create mode 100644 apps/web/src/lib/stickers/providers/emoji.ts create mode 100644 apps/web/src/lib/stickers/providers/flags.ts create mode 100644 apps/web/src/lib/stickers/providers/icons.ts create mode 100644 apps/web/src/lib/stickers/providers/index.ts create mode 100644 apps/web/src/lib/stickers/providers/shapes.ts create mode 100644 apps/web/src/lib/stickers/registry.ts create mode 100644 apps/web/src/lib/stickers/resolver.ts create mode 100644 apps/web/src/lib/stickers/sticker-id.ts create mode 100644 apps/web/src/lib/stickers/types.ts create mode 100644 apps/web/src/lib/timeline/drag-utils.ts (limited to 'apps/web/src/lib') diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts index 03de07c4..a2119fc1 100644 --- a/apps/web/src/lib/commands/index.ts +++ b/apps/web/src/lib/commands/index.ts @@ -1,5 +1,6 @@ export { Command } from "./base-command"; export { BatchCommand } from "./batch-command"; +export { PreviewTracker } from "./preview-tracker"; export * from "./timeline"; export * from "./media"; diff --git a/apps/web/src/lib/commands/preview-tracker.ts b/apps/web/src/lib/commands/preview-tracker.ts new file mode 100644 index 00000000..68c8dee3 --- /dev/null +++ b/apps/web/src/lib/commands/preview-tracker.ts @@ -0,0 +1,23 @@ +export class PreviewTracker { + private snapshot: T | null = null; + + begin({ state }: { state: T }): void { + if (this.snapshot === null) { + this.snapshot = structuredClone(state); + } + } + + isActive(): boolean { + return this.snapshot !== null; + } + + getSnapshot(): T | null { + return this.snapshot; + } + + end(): T | null { + const snapshot = this.snapshot; + this.snapshot = null; + return snapshot; + } +} diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts index 5bf9406a..2b843c99 100644 --- a/apps/web/src/lib/commands/scene/index.ts +++ b/apps/web/src/lib/commands/scene/index.ts @@ -3,3 +3,5 @@ export { DeleteSceneCommand } from "./delete-scene"; export { RenameSceneCommand } from "./rename-scene"; export { ToggleBookmarkCommand } from "./toggle-bookmark"; export { RemoveBookmarkCommand } from "./remove-bookmark"; +export { UpdateBookmarkCommand } from "./update-bookmark"; +export { MoveBookmarkCommand } from "./move-bookmark"; diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts new file mode 100644 index 00000000..64382f03 --- /dev/null +++ b/apps/web/src/lib/commands/scene/move-bookmark.ts @@ -0,0 +1,59 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/types/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class MoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private fromTime: number, + private toTime: number, + ) { + super(); + } + + execute(): void { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const fromFrameTime = getFrameTime({ + time: this.fromTime, + fps: activeProject.settings.fps, + }); + const toFrameTime = getFrameTime({ + time: this.toTime, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = moveBookmarkInArray({ + bookmarks: activeScene.bookmarks, + fromTime: fromFrameTime, + toTime: toFrameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts new file mode 100644 index 00000000..5ce91a36 --- /dev/null +++ b/apps/web/src/lib/commands/scene/update-bookmark.ts @@ -0,0 +1,55 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { Bookmark, TScene } from "@/types/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class UpdateBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private time: number, + private updates: Partial>, + ) { + super(); + } + + execute(): void { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = updateBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime, + updates: this.updates, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 39feb8ce..49554e0e 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -130,7 +130,9 @@ export class PasteCommand extends Command { if (this.savedState) { const editor = EditorCore.getInstance(); editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ elements: this.previousSelection }); + editor.selection.setSelectedElements({ + elements: this.previousSelection, + }); } } diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index 4bec6444..cf1bd94c 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -84,7 +84,9 @@ export class DuplicateElementsCommand extends Command { editor.timeline.updateTracks(updatedTracks); if (this.duplicatedElements.length > 0) { - editor.selection.setSelectedElements({ elements: this.duplicatedElements }); + editor.selection.setSelectedElements({ + elements: this.duplicatedElements, + }); } } @@ -92,7 +94,9 @@ export class DuplicateElementsCommand extends Command { if (this.savedState) { const editor = EditorCore.getInstance(); editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ elements: this.previousSelection }); + editor.selection.setSelectedElements({ + elements: this.previousSelection, + }); } } diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index 6067d72b..e1551471 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -163,8 +163,8 @@ export class InsertElementCommand extends Command { return false; } - if (element.type === "sticker" && !element.iconName) { - console.error("Sticker element must have iconName"); + if (element.type === "sticker" && !element.stickerId) { + console.error("Sticker element must have stickerId"); return false; } diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index 13d4d730..abe133c9 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -119,7 +119,9 @@ export class SplitElementsCommand extends Command { editor.timeline.updateTracks(updatedTracks); if (this.rightSideElements.length > 0) { - editor.selection.setSelectedElements({ elements: this.rightSideElements }); + editor.selection.setSelectedElements({ + elements: this.rightSideElements, + }); } } @@ -127,7 +129,9 @@ export class SplitElementsCommand extends Command { if (this.savedState) { const editor = EditorCore.getInstance(); editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ elements: this.previousSelection }); + editor.selection.setSelectedElements({ + elements: this.previousSelection, + }); } } } diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts index 30bc81ef..cb285d94 100644 --- a/apps/web/src/lib/commands/timeline/index.ts +++ b/apps/web/src/lib/commands/timeline/index.ts @@ -1,3 +1,5 @@ export * from "./track"; export * from "./element"; export * from "./clipboard"; + +export { TracksSnapshotCommand } from "./tracks-snapshot"; diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts new file mode 100644 index 00000000..2ba059c7 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts @@ -0,0 +1,20 @@ +import { Command } from "@/lib/commands/base-command"; +import type { TimelineTrack } from "@/types/timeline"; +import { EditorCore } from "@/core"; + +export class TracksSnapshotCommand extends Command { + constructor( + private before: TimelineTrack[], + private after: TimelineTrack[], + ) { + super(); + } + + execute(): void { + EditorCore.getInstance().timeline.updateTracks(this.after); + } + + undo(): void { + EditorCore.getInstance().timeline.updateTracks(this.before); + } +} diff --git a/apps/web/src/lib/fonts/google-fonts.ts b/apps/web/src/lib/fonts/google-fonts.ts new file mode 100644 index 00000000..7e55dffd --- /dev/null +++ b/apps/web/src/lib/fonts/google-fonts.ts @@ -0,0 +1,90 @@ +import type { FontAtlas } from "@/types/fonts"; +import { SYSTEM_FONTS } from "@/constants/font-constants"; + +const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2"; + +const fullLoaded = new Set(); + +let cachedAtlas: FontAtlas | null = null; +let atlasFetchPromise: Promise | null = null; + +function encodeFamily(family: string): string { + return family.replace(/ /g, "+"); +} + +export function getCachedFontAtlas(): FontAtlas | null { + return cachedAtlas; +} + +export function clearFontAtlasCache(): void { + cachedAtlas = null; + atlasFetchPromise = null; +} + +async function fetchAtlas(): Promise { + if (cachedAtlas) return cachedAtlas; + if (atlasFetchPromise) return atlasFetchPromise; + + atlasFetchPromise = fetch("/fonts/font-atlas.json") + .then(async (response) => { + if (!response.ok) return null; + const data: FontAtlas = await response.json(); + cachedAtlas = data; + return data; + }) + .catch(() => null); + + return atlasFetchPromise; +} + +function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void { + const maxChunk = Math.max( + ...Object.values(atlas.fonts).map((entry) => entry.ch), + ); + for (let i = 0; i <= maxChunk; i++) { + const img = new Image(); + img.src = `/fonts/font-chunk-${i}.avif`; + } +} + +export function prefetchFontAtlas(): Promise { + return fetchAtlas().then((atlas) => { + if (atlas) preloadChunkImages({ atlas }); + return atlas; + }); +} + +export async function loadFullFont({ + family, + weights = [400, 700], +}: { + family: string; + weights?: number[]; +}): Promise { + if (fullLoaded.has(family)) return; + + const url = `${GOOGLE_FONTS_CSS}?family=${encodeFamily(family)}:wght@${weights.join(";")}&display=swap`; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + document.head.appendChild(link); + await new Promise((resolve) => { + link.addEventListener("load", () => resolve(), { once: true }); + link.addEventListener("error", () => resolve(), { once: true }); + }); + await Promise.all( + weights.map((weight) => + document.fonts.load(`${weight} 16px "${family.replace(/"/g, '\\"')}"`), + ), + ); + fullLoaded.add(family); +} + +export async function loadFonts({ + families, +}: { + families: string[]; +}): Promise { + const googleFonts = families.filter((family) => !SYSTEM_FONTS.has(family)); + await Promise.all(googleFonts.map((family) => loadFullFont({ family }))); +} 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(); + + 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 }; +} diff --git a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts new file mode 100644 index 00000000..573dc251 --- /dev/null +++ b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { buildStickerId, parseStickerId } from "../sticker-id"; + +describe("sticker-id strict mode", () => { + test("parses provider-prefixed IDs", () => { + expect(parseStickerId({ stickerId: "icons:mdi:home" })).toEqual({ + providerId: "icons", + providerValue: "mdi:home", + }); + expect(parseStickerId({ stickerId: "emoji:noto:grinning-face" })).toEqual({ + providerId: "emoji", + providerValue: "noto:grinning-face", + }); + }); + + test("throws for IDs without provider prefix", () => { + expect(() => parseStickerId({ stickerId: "home" })).toThrow(); + }); + + test("throws for malformed IDs", () => { + expect(() => parseStickerId({ stickerId: "" })).toThrow(); + expect(() => parseStickerId({ stickerId: "icons:" })).toThrow(); + expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow(); + }); + + test("builds sticker IDs unchanged", () => { + expect( + buildStickerId({ + providerId: "flags", + providerValue: "US", + }), + ).toBe("flags:US"); + }); +}); diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts new file mode 100644 index 00000000..1f924c6d --- /dev/null +++ b/apps/web/src/lib/stickers/index.ts @@ -0,0 +1,175 @@ +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import type { StickerCategory } from "@/types/stickers"; +import { getAllProviders, getProvider } from "./registry"; +import { resolveStickerId } from "./resolver"; +import { registerDefaultStickerProviders } from "./providers"; +import type { StickerProvider, StickerSearchResult } from "./types"; + +const DEFAULT_SEARCH_LIMIT = 100; + +function mergeSearchResults({ + results, +}: { + results: StickerSearchResult[]; +}): StickerSearchResult { + const deduplicatedItems = new Map< + string, + StickerSearchResult["items"][number] + >(); + let total = 0; + let hasMore = false; + + for (const result of results) { + total += result.total; + hasMore = hasMore || result.hasMore; + for (const item of result.items) { + if (!deduplicatedItems.has(item.id)) { + deduplicatedItems.set(item.id, item); + } + } + } + + return { + items: Array.from(deduplicatedItems.values()), + total, + hasMore, + }; +} + +function getProviderByCategory({ + category, +}: { + category: StickerCategory; +}): StickerProvider | null { + if (category === "all") { + return null; + } + + try { + return getProvider({ providerId: category }); + } catch { + return null; + } +} + +export async function searchStickers({ + query, + category, + limit = DEFAULT_SEARCH_LIMIT, +}: { + query: string; + category: StickerCategory; + limit?: number; +}): Promise { + registerDefaultStickerProviders({}); + + const effectiveCategory = category in STICKER_CATEGORIES ? category : "all"; + if (effectiveCategory !== "all") { + const provider = getProviderByCategory({ category: effectiveCategory }); + if (!provider) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + return provider.search({ + query, + options: { limit }, + }); + } + + const providers = getAllProviders(); + if (providers.length === 0) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + + const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); + const settledResults = await Promise.allSettled( + providers.map((provider) => + provider.search({ + query, + options: { limit: perProviderLimit }, + }), + ), + ); + + const fulfilledResults = settledResults + .filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled", + ) + .map((result) => result.value); + + return mergeSearchResults({ + results: fulfilledResults, + }); +} + +export async function browseStickers({ + category, + page = 1, + limit = DEFAULT_SEARCH_LIMIT, +}: { + category: StickerCategory; + page?: number; + limit?: number; +}): Promise { + registerDefaultStickerProviders({}); + + const effectiveCategory = category in STICKER_CATEGORIES ? category : "all"; + if (effectiveCategory !== "all") { + const provider = getProviderByCategory({ category: effectiveCategory }); + if (!provider) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + return provider.browse({ + options: { page, limit }, + }); + } + + const providers = getAllProviders(); + if (providers.length === 0) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + + const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); + const settledResults = await Promise.allSettled( + providers.map((provider) => + provider.browse({ + options: { page, limit: perProviderLimit }, + }), + ), + ); + + const fulfilledResults = settledResults + .filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled", + ) + .map((result) => result.value); + + return mergeSearchResults({ + results: fulfilledResults, + }); +} + +export { resolveStickerId }; +export type { + StickerItem, + StickerProvider, + StickerResolveOptions, + StickerSearchResult, +} from "./types"; diff --git a/apps/web/src/lib/stickers/providers/emoji.ts b/apps/web/src/lib/stickers/providers/emoji.ts new file mode 100644 index 00000000..cc198236 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/emoji.ts @@ -0,0 +1,119 @@ +import { + POPULAR_COLLECTIONS, + getIconSvgUrl, + searchIcons, +} from "@/lib/iconify-api"; +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const EMOJI_PROVIDER_ID = "emoji"; +const DEFAULT_SEARCH_LIMIT = 100; + +const EMOJI_PREFIXES = POPULAR_COLLECTIONS.emoji.map( + (collection) => collection.prefix, +); + +const DEFAULT_EMOJI_BROWSE = [ + "noto:grinning-face", + "noto:smiling-face-with-heart-eyes", + "noto:fire", + "noto:rocket", + "noto:party-popper", + "noto:clapping-hands", + "noto:sparkles", + "noto:red-heart", + "noto:thumbs-up", + "noto:eyes", + "noto:thinking-face", + "noto:hundred-points", +]; + +function getDisplayNameFromIconName({ + iconName, +}: { + iconName: string; +}): string { + const parts = iconName.split(":"); + const rawName = parts[parts.length - 1] ?? iconName; + return rawName.replaceAll("-", " ").replaceAll("_", " "); +} + +function toStickerItem({ iconName }: { iconName: string }): StickerItem { + return { + id: buildStickerId({ + providerId: EMOJI_PROVIDER_ID, + providerValue: iconName, + }), + provider: EMOJI_PROVIDER_ID, + name: getDisplayNameFromIconName({ iconName }), + previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), + metadata: { iconName }, + }; +} + +function computeHasMore({ + total, + limit, + start = 0, +}: { + total: number; + limit: number; + start?: number; +}): boolean { + return start + limit < total; +} + +export const emojiProvider: StickerProvider = { + id: EMOJI_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise { + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + const searchResult = await searchIcons(query, limit, EMOJI_PREFIXES); + return { + items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), + total: searchResult.total, + hasMore: computeHasMore({ + total: searchResult.total, + limit: searchResult.limit, + start: searchResult.start, + }), + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise { + const limit = options?.limit ?? DEFAULT_EMOJI_BROWSE.length; + const items = DEFAULT_EMOJI_BROWSE.slice(0, limit).map((iconName) => + toStickerItem({ iconName }), + ); + return { + items, + total: items.length, + hasMore: false, + }; + }, + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return getIconSvgUrl(providerValue, { + width: options?.width, + height: options?.height, + }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/flags.ts b/apps/web/src/lib/stickers/providers/flags.ts new file mode 100644 index 00000000..91cc5489 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/flags.ts @@ -0,0 +1,167 @@ +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const FLAGS_PROVIDER_ID = "flags"; +const FLAGS_DATASET_URL = "/countries.json"; +const DEFAULT_SEARCH_LIMIT = 100; +const DEFAULT_FLAGS_BASE_URL = "/flags"; + +type CountryRecord = { + name: string; + code: string; + languages?: string[]; + flag_colors?: string[]; + region?: string; +}; + +let countriesPromise: Promise | null = null; + +function getFlagsBaseUrl(): string { + return DEFAULT_FLAGS_BASE_URL.replace(/\/$/, ""); +} + +function buildFlagUrl({ code }: { code: string }): string { + const normalizedCode = code.toUpperCase(); + return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`; +} + +async function loadCountries(): Promise { + if (countriesPromise) { + return countriesPromise; + } + + countriesPromise = fetch(FLAGS_DATASET_URL) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to load countries: ${response.status}`); + } + return (await response.json()) as CountryRecord[]; + }) + .catch((error) => { + console.error("Failed to load countries dataset:", error); + return []; + }); + + return countriesPromise; +} + +function toStickerItem({ country }: { country: CountryRecord }): StickerItem { + const normalizedCode = country.code.toUpperCase(); + return { + id: buildStickerId({ + providerId: FLAGS_PROVIDER_ID, + providerValue: normalizedCode, + }), + provider: FLAGS_PROVIDER_ID, + name: country.name, + previewUrl: buildFlagUrl({ code: normalizedCode }), + metadata: { + code: normalizedCode, + region: country.region ?? null, + languages: country.languages ?? [], + flagColors: country.flag_colors ?? [], + }, + }; +} + +function normalizeQuery({ query }: { query: string }): string { + return query.trim().toLowerCase(); +} + +function filterCountriesByQuery({ + countries, + query, +}: { + countries: CountryRecord[]; + query: string; +}): CountryRecord[] { + if (!query) { + return countries; + } + + return countries.filter((country) => { + const normalizedName = country.name.toLowerCase(); + const normalizedCode = country.code.toLowerCase(); + const normalizedRegion = country.region?.toLowerCase() ?? ""; + return ( + normalizedName.includes(query) || + normalizedCode.includes(query) || + normalizedRegion.includes(query) + ); + }); +} + +function paginateCountries({ + countries, + options, +}: { + countries: CountryRecord[]; + options?: { page?: number; limit?: number }; +}): { items: CountryRecord[]; hasMore: boolean; total: number } { + const page = Math.max(1, options?.page ?? 1); + const limit = Math.max(1, options?.limit ?? DEFAULT_SEARCH_LIMIT); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const pagedItems = countries.slice(startIndex, endIndex); + return { + items: pagedItems, + hasMore: endIndex < countries.length, + total: countries.length, + }; +} + +export const flagsProvider: StickerProvider = { + id: FLAGS_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise { + const countries = await loadCountries(); + const normalizedQuery = normalizeQuery({ query }); + const filteredCountries = filterCountriesByQuery({ + countries, + query: normalizedQuery, + }); + const paged = paginateCountries({ + countries: filteredCountries, + options: { + page: 1, + limit: options?.limit ?? DEFAULT_SEARCH_LIMIT, + }, + }); + return { + items: paged.items.map((country) => toStickerItem({ country })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise { + const countries = await loadCountries(); + const paged = paginateCountries({ countries, options }); + return { + items: paged.items.map((country) => toStickerItem({ country })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + resolveUrl({ + stickerId, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return buildFlagUrl({ code: providerValue }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/icons.ts b/apps/web/src/lib/stickers/providers/icons.ts new file mode 100644 index 00000000..06b2a4c2 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/icons.ts @@ -0,0 +1,122 @@ +import { + POPULAR_COLLECTIONS, + getIconSvgUrl, + searchIcons, +} from "@/lib/iconify-api"; +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const ICONS_PROVIDER_ID = "icons"; +const DEFAULT_SEARCH_LIMIT = 100; + +const ICONS_PREFIXES = Array.from( + new Set( + [...POPULAR_COLLECTIONS.general, ...POPULAR_COLLECTIONS.brands].map( + (collection) => collection.prefix, + ), + ), +); + +const DEFAULT_ICONS_BROWSE = [ + "mdi:home", + "mdi:star", + "mdi:heart", + "mdi:check-circle", + "mdi:account", + "mdi:camera", + "mdi:music", + "mdi:map-marker", + "mdi:calendar", + "mdi:lightning-bolt", + "mdi:cog", + "mdi:rocket", +]; + +function getDisplayNameFromIconName({ + iconName, +}: { + iconName: string; +}): string { + const [, rawName = iconName] = iconName.split(":"); + return rawName.replaceAll("-", " ").replaceAll("_", " "); +} + +function toStickerItem({ iconName }: { iconName: string }): StickerItem { + return { + id: buildStickerId({ + providerId: ICONS_PROVIDER_ID, + providerValue: iconName, + }), + provider: ICONS_PROVIDER_ID, + name: getDisplayNameFromIconName({ iconName }), + previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), + metadata: { iconName }, + }; +} + +function computeHasMore({ + total, + limit, + start = 0, +}: { + total: number; + limit: number; + start?: number; +}): boolean { + return start + limit < total; +} + +export const iconsProvider: StickerProvider = { + id: ICONS_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise { + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + const searchResult = await searchIcons(query, limit, ICONS_PREFIXES); + return { + items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), + total: searchResult.total, + hasMore: computeHasMore({ + total: searchResult.total, + limit: searchResult.limit, + start: searchResult.start, + }), + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise { + const limit = options?.limit ?? DEFAULT_ICONS_BROWSE.length; + const items = DEFAULT_ICONS_BROWSE.slice(0, limit).map((iconName) => + toStickerItem({ iconName }), + ); + return { + items, + total: items.length, + hasMore: false, + }; + }, + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return getIconSvgUrl(providerValue, { + width: options?.width, + height: options?.height, + }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts new file mode 100644 index 00000000..3c4faee9 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/index.ts @@ -0,0 +1,26 @@ +import { hasProvider, registerProvider } from "../registry"; +import type { StickerProvider } from "@/types/stickers"; +import { emojiProvider } from "./emoji"; +import { flagsProvider } from "./flags"; +import { iconsProvider } from "./icons"; +import { shapesProvider } from "./shapes"; + +const defaultProviders: StickerProvider[] = [ + iconsProvider, + emojiProvider, + flagsProvider, + shapesProvider, +]; + +export function registerDefaultStickerProviders({ + providersToRegister = defaultProviders, +}: { + providersToRegister?: StickerProvider[]; +} = {}): void { + for (const provider of providersToRegister) { + if (hasProvider({ providerId: provider.id })) { + continue; + } + registerProvider({ provider }); + } +} diff --git a/apps/web/src/lib/stickers/providers/shapes.ts b/apps/web/src/lib/stickers/providers/shapes.ts new file mode 100644 index 00000000..81873dcb --- /dev/null +++ b/apps/web/src/lib/stickers/providers/shapes.ts @@ -0,0 +1,118 @@ +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const SHAPES_PROVIDER_ID = "shapes"; + +const SHAPES = [ + { key: "circle", name: "Circle" }, + { key: "square", name: "Square" }, + { key: "triangle", name: "Triangle" }, + { key: "star", name: "Star" }, + { key: "hexagon", name: "Hexagon" }, + { key: "diamond", name: "Diamond" }, +] as const; + +function buildShapeUrl({ shapeKey }: { shapeKey: string }): string { + return `/shapes/${shapeKey}.svg`; +} + +function toStickerItem({ + shape, +}: { + shape: (typeof SHAPES)[number]; +}): StickerItem { + return { + id: buildStickerId({ + providerId: SHAPES_PROVIDER_ID, + providerValue: shape.key, + }), + provider: SHAPES_PROVIDER_ID, + name: shape.name, + previewUrl: buildShapeUrl({ shapeKey: shape.key }), + metadata: { shape: shape.key }, + }; +} + +function filterShapesByQuery({ + query, +}: { + query: string; +}): Array<(typeof SHAPES)[number]> { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return [...SHAPES]; + } + + return SHAPES.filter((shape) => + shape.name.toLowerCase().includes(normalizedQuery), + ); +} + +function paginateShapes({ + shapes, + options, +}: { + shapes: Array<(typeof SHAPES)[number]>; + options?: { page?: number; limit?: number }; +}): { items: Array<(typeof SHAPES)[number]>; hasMore: boolean; total: number } { + const page = Math.max(1, options?.page ?? 1); + const limit = Math.max(1, options?.limit ?? SHAPES.length); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const pagedItems = shapes.slice(startIndex, endIndex); + return { + items: pagedItems, + hasMore: endIndex < shapes.length, + total: shapes.length, + }; +} + +export const shapesProvider: StickerProvider = { + id: SHAPES_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise { + const filteredShapes = filterShapesByQuery({ query }); + const paged = paginateShapes({ + shapes: filteredShapes, + options: { page: 1, limit: options?.limit ?? SHAPES.length }, + }); + return { + items: paged.items.map((shape) => toStickerItem({ shape })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise { + const paged = paginateShapes({ + shapes: [...SHAPES], + options, + }); + return { + items: paged.items.map((shape) => toStickerItem({ shape })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + resolveUrl({ + stickerId, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return buildShapeUrl({ shapeKey: providerValue }); + }, +}; diff --git a/apps/web/src/lib/stickers/registry.ts b/apps/web/src/lib/stickers/registry.ts new file mode 100644 index 00000000..0fbad594 --- /dev/null +++ b/apps/web/src/lib/stickers/registry.ts @@ -0,0 +1,31 @@ +import type { StickerProvider } from "@/types/stickers"; + +const providers = new Map(); + +export function registerProvider({ + provider, +}: { + provider: StickerProvider; +}): void { + providers.set(provider.id, provider); +} + +export function hasProvider({ providerId }: { providerId: string }): boolean { + return providers.has(providerId); +} + +export function getProvider({ + providerId, +}: { + providerId: string; +}): StickerProvider { + const provider = providers.get(providerId); + if (!provider) { + throw new Error(`Unknown sticker provider: ${providerId}`); + } + return provider; +} + +export function getAllProviders(): StickerProvider[] { + return Array.from(providers.values()); +} diff --git a/apps/web/src/lib/stickers/resolver.ts b/apps/web/src/lib/stickers/resolver.ts new file mode 100644 index 00000000..3252cd60 --- /dev/null +++ b/apps/web/src/lib/stickers/resolver.ts @@ -0,0 +1,22 @@ +import { getProvider } from "./registry"; +import { parseStickerId } from "./sticker-id"; +import { registerDefaultStickerProviders } from "./providers"; +import type { StickerResolveOptions } from "@/types/stickers"; + +export function resolveStickerId({ + stickerId, + options, +}: { + stickerId: string; + options?: StickerResolveOptions; +}): string { + registerDefaultStickerProviders(); + + const parsedStickerId = parseStickerId({ stickerId }); + return getProvider({ + providerId: parsedStickerId.providerId, + }).resolveUrl({ + stickerId, + options, + }); +} diff --git a/apps/web/src/lib/stickers/sticker-id.ts b/apps/web/src/lib/stickers/sticker-id.ts new file mode 100644 index 00000000..8cb9364b --- /dev/null +++ b/apps/web/src/lib/stickers/sticker-id.ts @@ -0,0 +1,34 @@ +export function parseStickerId({ stickerId }: { stickerId: string }): { + providerId: string; + providerValue: string; +} { + const normalizedStickerId = stickerId.trim(); + if (!normalizedStickerId) { + throw new Error("Sticker ID must be a non-empty string"); + } + + const separatorIndex = normalizedStickerId.indexOf(":"); + if ( + separatorIndex <= 0 || + separatorIndex === normalizedStickerId.length - 1 + ) { + throw new Error( + `Invalid sticker ID format: "${stickerId}". Expected "provider:value".`, + ); + } + + const providerId = normalizedStickerId.slice(0, separatorIndex).trim(); + const providerValue = normalizedStickerId.slice(separatorIndex + 1).trim(); + + return { providerId, providerValue }; +} + +export function buildStickerId({ + providerId, + providerValue, +}: { + providerId: string; + providerValue: string; +}): string { + return `${providerId}:${providerValue}`; +} diff --git a/apps/web/src/lib/stickers/types.ts b/apps/web/src/lib/stickers/types.ts new file mode 100644 index 00000000..91cbcaae --- /dev/null +++ b/apps/web/src/lib/stickers/types.ts @@ -0,0 +1,8 @@ +export type { + StickerItem, + StickerProvider, + StickerProviderBrowseOptions, + StickerProviderSearchOptions, + StickerResolveOptions, + StickerSearchResult, +} from "@/types/stickers"; diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts index 0b06c2f6..efaa8f74 100644 --- a/apps/web/src/lib/timeline/bookmarks.ts +++ b/apps/web/src/lib/timeline/bookmarks.ts @@ -1,14 +1,27 @@ +import type { Bookmark } from "@/types/timeline"; import { roundToFrame } from "@/lib/time"; +export const BOOKMARK_TIME_EPSILON = 0.001; + +function bookmarkTimeEqual({ + bookmarkTime, + frameTime, +}: { + bookmarkTime: number; + frameTime: number; +}): boolean { + return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON; +} + export function findBookmarkIndex({ bookmarks, frameTime, }: { - bookmarks: number[]; + bookmarks: Bookmark[]; frameTime: number; }): number { - return bookmarks.findIndex( - (bookmark) => Math.abs(bookmark - frameTime) < 0.001, + return bookmarks.findIndex((bookmark) => + bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), ); } @@ -16,40 +29,84 @@ export function isBookmarkAtTime({ bookmarks, frameTime, }: { - bookmarks: number[]; + bookmarks: Bookmark[]; frameTime: number; }): boolean { - return bookmarks.some((bookmark) => Math.abs(bookmark - frameTime) < 0.001); + return bookmarks.some((bookmark) => + bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), + ); } export function toggleBookmarkInArray({ bookmarks, frameTime, }: { - bookmarks: number[]; + bookmarks: Bookmark[]; frameTime: number; -}): number[] { +}): Bookmark[] { const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); if (bookmarkIndex !== -1) { - return bookmarks.filter((_, i) => i !== bookmarkIndex); + return bookmarks.filter((_, index) => index !== bookmarkIndex); } - return [...bookmarks, frameTime].sort((a, b) => a - b); + const newBookmarks = [...bookmarks, { time: frameTime }]; + return newBookmarks.slice().sort((a, b) => a.time - b.time); } export function removeBookmarkFromArray({ bookmarks, frameTime, }: { - bookmarks: number[]; + bookmarks: Bookmark[]; frameTime: number; -}): number[] { +}): Bookmark[] { return bookmarks.filter( - (bookmark) => Math.abs(bookmark - frameTime) >= 0.001, + (bookmark) => + !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), ); } +export function updateBookmarkInArray({ + bookmarks, + frameTime, + updates, +}: { + bookmarks: Bookmark[]; + frameTime: number; + updates: Partial>; +}): Bookmark[] { + const index = findBookmarkIndex({ bookmarks, frameTime }); + if (index === -1) { + return bookmarks; + } + + const updated = { ...bookmarks[index], ...updates }; + const result = [...bookmarks]; + result[index] = updated; + return result; +} + +export function moveBookmarkInArray({ + bookmarks, + fromTime, + toTime, +}: { + bookmarks: Bookmark[]; + fromTime: number; + toTime: number; +}): Bookmark[] { + const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); + if (index === -1) { + return bookmarks; + } + + const updated = { ...bookmarks[index], time: toTime }; + const result = [...bookmarks]; + result[index] = updated; + return result.slice().sort((a, b) => a.time - b.time); +} + export function getFrameTime({ time, fps, @@ -59,3 +116,34 @@ export function getFrameTime({ }): number { return roundToFrame({ time, fps }); } + +export function getBookmarkAtTime({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): Bookmark | null { + const index = findBookmarkIndex({ bookmarks, frameTime }); + return index === -1 ? null : bookmarks[index]; +} + +export function getBookmarksActiveAtTime({ + bookmarks, + time, +}: { + bookmarks: Bookmark[]; + time: number; +}): Bookmark[] { + return bookmarks.filter((bookmark) => { + const start = bookmark.time; + const end = + bookmark.duration != null && bookmark.duration > 0 + ? start + bookmark.duration + : start; + return ( + time >= start - BOOKMARK_TIME_EPSILON && + time <= end + BOOKMARK_TIME_EPSILON + ); + }); +} diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts new file mode 100644 index 00000000..43d896ae --- /dev/null +++ b/apps/web/src/lib/timeline/drag-utils.ts @@ -0,0 +1,19 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +export function getMouseTimeFromClientX({ + clientX, + containerRect, + zoomLevel, + scrollLeft, +}: { + clientX: number; + containerRect: DOMRect; + zoomLevel: number; + scrollLeft: number; +}): number { + const mouseX = clientX - containerRect.left + scrollLeft; + return Math.max( + 0, + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ); +} diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index a167e548..fa74b14e 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -1,5 +1,10 @@ import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + DEFAULT_BLEND_MODE, + DEFAULT_OPACITY, + DEFAULT_TRANSFORM, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; import type { CreateTimelineElement, CreateVideoElement, @@ -16,6 +21,7 @@ import type { StickerElement, UploadAudioElement, } from "@/types/timeline"; +import type { MediaType } from "@/types/assets"; export function canElementHaveAudio( element: TimelineElement, @@ -23,6 +29,17 @@ export function canElementHaveAudio( return element.type === "audio" || element.type === "video"; } +export function isVisualElement( + element: TimelineElement, +): element is VideoElement | ImageElement | TextElement | StickerElement { + return ( + element.type === "video" || + element.type === "image" || + element.type === "text" || + element.type === "sticker" + ); +} + export function canElementBeHidden( element: TimelineElement, ): element is VideoElement | ImageElement | TextElement | StickerElement { @@ -142,28 +159,36 @@ export function buildTextElement({ fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, + letterSpacing: t.letterSpacing ?? DEFAULT_TEXT_ELEMENT.letterSpacing, + lineHeight: t.lineHeight ?? DEFAULT_TEXT_ELEMENT.lineHeight, transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform, opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity, + blendMode: t.blendMode ?? DEFAULT_BLEND_MODE, }; } export function buildStickerElement({ - iconName, + stickerId, + name, startTime, }: { - iconName: string; + stickerId: string; + name?: string; startTime: number; }): CreateStickerElement { + const stickerNameFromId = + stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId; return { type: "sticker", - name: iconName.split(":")[1] || iconName, - iconName, + name: name ?? stickerNameFromId, + stickerId, duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, startTime, trimStart: 0, trimEnd: 0, - transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, - opacity: 1, + transform: { ...DEFAULT_TRANSFORM }, + opacity: DEFAULT_OPACITY, + blendMode: DEFAULT_BLEND_MODE, }; } @@ -188,8 +213,9 @@ export function buildVideoElement({ trimEnd: 0, muted: false, hidden: false, - transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, - opacity: 1, + transform: { ...DEFAULT_TRANSFORM }, + opacity: DEFAULT_OPACITY, + blendMode: DEFAULT_BLEND_MODE, }; } @@ -213,8 +239,9 @@ export function buildImageElement({ trimStart: 0, trimEnd: 0, hidden: false, - transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, - opacity: 1, + transform: { ...DEFAULT_TRANSFORM }, + opacity: DEFAULT_OPACITY, + blendMode: DEFAULT_BLEND_MODE, }; } @@ -249,6 +276,37 @@ export function buildUploadAudioElement({ return element; } +export function buildElementFromMedia({ + mediaId, + mediaType, + name, + duration, + startTime, + buffer, +}: { + mediaId: string; + mediaType: MediaType; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; +}): CreateTimelineElement { + switch (mediaType) { + case "audio": + return buildUploadAudioElement({ + mediaId, + name, + duration, + startTime, + buffer, + }); + case "video": + return buildVideoElement({ mediaId, name, duration, startTime }); + case "image": + return buildImageElement({ mediaId, name, duration, startTime }); + } +} + export function buildLibraryAudioElement({ sourceUrl, name, @@ -302,3 +360,19 @@ export function getElementsAtTime({ return result; } + +export function collectFontFamilies({ + tracks, +}: { + tracks: TimelineTrack[]; +}): string[] { + const families = new Set(); + for (const track of tracks) { + for (const element of track.elements) { + if (element.type === "text" && element.fontFamily) { + families.add(element.fontFamily); + } + } + } + return [...families]; +} -- cgit v1.3.1