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
This commit is contained in:
Maze
2026-02-23 03:24:02 +01:00
committed by GitHub
parent fca99d6126
commit 93d1e3383c
215 changed files with 26980 additions and 8364 deletions
+1
View File
@@ -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";
@@ -0,0 +1,23 @@
export class PreviewTracker<T> {
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;
}
}
+2
View File
@@ -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";
@@ -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 });
}
}
}
@@ -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<Omit<Bookmark, "time">>,
) {
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 });
}
}
}
@@ -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,
});
}
}
@@ -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,
});
}
}
@@ -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;
}
@@ -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,
});
}
}
}
@@ -1,3 +1,5 @@
export * from "./track";
export * from "./element";
export * from "./clipboard";
export { TracksSnapshotCommand } from "./tracks-snapshot";
@@ -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);
}
}
+90
View File
@@ -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<string>();
let cachedAtlas: FontAtlas | null = null;
let atlasFetchPromise: Promise<FontAtlas | null> | 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<FontAtlas | null> {
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<FontAtlas | null> {
return fetchAtlas().then((atlas) => {
if (atlas) preloadChunkImages({ atlas });
return atlas;
});
}
export async function loadFullFont({
family,
weights = [400, 700],
}: {
family: string;
weights?: number[];
}): Promise<void> {
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<void>((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<void> {
const googleFonts = families.filter((family) => !SYSTEM_FONTS.has(family));
await Promise.all(googleFonts.map((family) => loadFullFont({ family })));
}
+219
View File
@@ -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;
}
+60
View File
@@ -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;
}
@@ -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,
};
}
+292
View File
@@ -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 };
}
@@ -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");
});
});
+175
View File
@@ -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<StickerSearchResult> {
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<StickerSearchResult> =>
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<StickerSearchResult> {
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<StickerSearchResult> =>
result.status === "fulfilled",
)
.map((result) => result.value);
return mergeSearchResults({
results: fulfilledResults,
});
}
export { resolveStickerId };
export type {
StickerItem,
StickerProvider,
StickerResolveOptions,
StickerSearchResult,
} from "./types";
@@ -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<StickerSearchResult> {
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<StickerSearchResult> {
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,
});
},
};
@@ -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<CountryRecord[]> | 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<CountryRecord[]> {
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<StickerSearchResult> {
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<StickerSearchResult> {
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 });
},
};
@@ -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<StickerSearchResult> {
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<StickerSearchResult> {
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,
});
},
};
@@ -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 });
}
}
@@ -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<StickerSearchResult> {
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<StickerSearchResult> {
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 });
},
};
+31
View File
@@ -0,0 +1,31 @@
import type { StickerProvider } from "@/types/stickers";
const providers = new Map<string, StickerProvider>();
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());
}
+22
View File
@@ -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,
});
}
+34
View File
@@ -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}`;
}
+8
View File
@@ -0,0 +1,8 @@
export type {
StickerItem,
StickerProvider,
StickerProviderBrowseOptions,
StickerProviderSearchOptions,
StickerResolveOptions,
StickerSearchResult,
} from "@/types/stickers";
+100 -12
View File
@@ -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<Omit<Bookmark, "time">>;
}): 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
);
});
}
+19
View File
@@ -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),
);
}
+85 -11
View File
@@ -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<string>();
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "text" && element.fontFamily) {
families.add(element.fontFamily);
}
}
}
return [...families];
}