mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 })));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
StickerItem,
|
||||
StickerProvider,
|
||||
StickerProviderBrowseOptions,
|
||||
StickerProviderSearchOptions,
|
||||
StickerResolveOptions,
|
||||
StickerSearchResult,
|
||||
} from "@/types/stickers";
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user