From 93d1e3383ce47e45157d70f9ba6251239dae6bc2 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:24:02 +0100 Subject: feat: major editor overhaul (assets, properties, timeline, fonts) (#709) * feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env --- apps/web/src/lib/stickers/providers/emoji.ts | 119 ++++++++++++++++++ apps/web/src/lib/stickers/providers/flags.ts | 167 ++++++++++++++++++++++++++ apps/web/src/lib/stickers/providers/icons.ts | 122 +++++++++++++++++++ apps/web/src/lib/stickers/providers/index.ts | 26 ++++ apps/web/src/lib/stickers/providers/shapes.ts | 118 ++++++++++++++++++ 5 files changed, 552 insertions(+) create mode 100644 apps/web/src/lib/stickers/providers/emoji.ts create mode 100644 apps/web/src/lib/stickers/providers/flags.ts create mode 100644 apps/web/src/lib/stickers/providers/icons.ts create mode 100644 apps/web/src/lib/stickers/providers/index.ts create mode 100644 apps/web/src/lib/stickers/providers/shapes.ts (limited to 'apps/web/src/lib/stickers/providers') diff --git a/apps/web/src/lib/stickers/providers/emoji.ts b/apps/web/src/lib/stickers/providers/emoji.ts new file mode 100644 index 00000000..cc198236 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/emoji.ts @@ -0,0 +1,119 @@ +import { + POPULAR_COLLECTIONS, + getIconSvgUrl, + searchIcons, +} from "@/lib/iconify-api"; +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const EMOJI_PROVIDER_ID = "emoji"; +const DEFAULT_SEARCH_LIMIT = 100; + +const EMOJI_PREFIXES = POPULAR_COLLECTIONS.emoji.map( + (collection) => collection.prefix, +); + +const DEFAULT_EMOJI_BROWSE = [ + "noto:grinning-face", + "noto:smiling-face-with-heart-eyes", + "noto:fire", + "noto:rocket", + "noto:party-popper", + "noto:clapping-hands", + "noto:sparkles", + "noto:red-heart", + "noto:thumbs-up", + "noto:eyes", + "noto:thinking-face", + "noto:hundred-points", +]; + +function getDisplayNameFromIconName({ + iconName, +}: { + iconName: string; +}): string { + const parts = iconName.split(":"); + const rawName = parts[parts.length - 1] ?? iconName; + return rawName.replaceAll("-", " ").replaceAll("_", " "); +} + +function toStickerItem({ iconName }: { iconName: string }): StickerItem { + return { + id: buildStickerId({ + providerId: EMOJI_PROVIDER_ID, + providerValue: iconName, + }), + provider: EMOJI_PROVIDER_ID, + name: getDisplayNameFromIconName({ iconName }), + previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), + metadata: { iconName }, + }; +} + +function computeHasMore({ + total, + limit, + start = 0, +}: { + total: number; + limit: number; + start?: number; +}): boolean { + return start + limit < total; +} + +export const emojiProvider: StickerProvider = { + id: EMOJI_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise { + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + const searchResult = await searchIcons(query, limit, EMOJI_PREFIXES); + return { + items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), + total: searchResult.total, + hasMore: computeHasMore({ + total: searchResult.total, + limit: searchResult.limit, + start: searchResult.start, + }), + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise { + const limit = options?.limit ?? DEFAULT_EMOJI_BROWSE.length; + const items = DEFAULT_EMOJI_BROWSE.slice(0, limit).map((iconName) => + toStickerItem({ iconName }), + ); + return { + items, + total: items.length, + hasMore: false, + }; + }, + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return getIconSvgUrl(providerValue, { + width: options?.width, + height: options?.height, + }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/flags.ts b/apps/web/src/lib/stickers/providers/flags.ts new file mode 100644 index 00000000..91cc5489 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/flags.ts @@ -0,0 +1,167 @@ +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const FLAGS_PROVIDER_ID = "flags"; +const FLAGS_DATASET_URL = "/countries.json"; +const DEFAULT_SEARCH_LIMIT = 100; +const DEFAULT_FLAGS_BASE_URL = "/flags"; + +type CountryRecord = { + name: string; + code: string; + languages?: string[]; + flag_colors?: string[]; + region?: string; +}; + +let countriesPromise: Promise | null = null; + +function getFlagsBaseUrl(): string { + return DEFAULT_FLAGS_BASE_URL.replace(/\/$/, ""); +} + +function buildFlagUrl({ code }: { code: string }): string { + const normalizedCode = code.toUpperCase(); + return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`; +} + +async function loadCountries(): Promise { + if (countriesPromise) { + return countriesPromise; + } + + countriesPromise = fetch(FLAGS_DATASET_URL) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to load countries: ${response.status}`); + } + return (await response.json()) as CountryRecord[]; + }) + .catch((error) => { + console.error("Failed to load countries dataset:", error); + return []; + }); + + return countriesPromise; +} + +function toStickerItem({ country }: { country: CountryRecord }): StickerItem { + const normalizedCode = country.code.toUpperCase(); + return { + id: buildStickerId({ + providerId: FLAGS_PROVIDER_ID, + providerValue: normalizedCode, + }), + provider: FLAGS_PROVIDER_ID, + name: country.name, + previewUrl: buildFlagUrl({ code: normalizedCode }), + metadata: { + code: normalizedCode, + region: country.region ?? null, + languages: country.languages ?? [], + flagColors: country.flag_colors ?? [], + }, + }; +} + +function normalizeQuery({ query }: { query: string }): string { + return query.trim().toLowerCase(); +} + +function filterCountriesByQuery({ + countries, + query, +}: { + countries: CountryRecord[]; + query: string; +}): CountryRecord[] { + if (!query) { + return countries; + } + + return countries.filter((country) => { + const normalizedName = country.name.toLowerCase(); + const normalizedCode = country.code.toLowerCase(); + const normalizedRegion = country.region?.toLowerCase() ?? ""; + return ( + normalizedName.includes(query) || + normalizedCode.includes(query) || + normalizedRegion.includes(query) + ); + }); +} + +function paginateCountries({ + countries, + options, +}: { + countries: CountryRecord[]; + options?: { page?: number; limit?: number }; +}): { items: CountryRecord[]; hasMore: boolean; total: number } { + const page = Math.max(1, options?.page ?? 1); + const limit = Math.max(1, options?.limit ?? DEFAULT_SEARCH_LIMIT); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const pagedItems = countries.slice(startIndex, endIndex); + return { + items: pagedItems, + hasMore: endIndex < countries.length, + total: countries.length, + }; +} + +export const flagsProvider: StickerProvider = { + id: FLAGS_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise { + const countries = await loadCountries(); + const normalizedQuery = normalizeQuery({ query }); + const filteredCountries = filterCountriesByQuery({ + countries, + query: normalizedQuery, + }); + const paged = paginateCountries({ + countries: filteredCountries, + options: { + page: 1, + limit: options?.limit ?? DEFAULT_SEARCH_LIMIT, + }, + }); + return { + items: paged.items.map((country) => toStickerItem({ country })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise { + const countries = await loadCountries(); + const paged = paginateCountries({ countries, options }); + return { + items: paged.items.map((country) => toStickerItem({ country })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + resolveUrl({ + stickerId, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return buildFlagUrl({ code: providerValue }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/icons.ts b/apps/web/src/lib/stickers/providers/icons.ts new file mode 100644 index 00000000..06b2a4c2 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/icons.ts @@ -0,0 +1,122 @@ +import { + POPULAR_COLLECTIONS, + getIconSvgUrl, + searchIcons, +} from "@/lib/iconify-api"; +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const ICONS_PROVIDER_ID = "icons"; +const DEFAULT_SEARCH_LIMIT = 100; + +const ICONS_PREFIXES = Array.from( + new Set( + [...POPULAR_COLLECTIONS.general, ...POPULAR_COLLECTIONS.brands].map( + (collection) => collection.prefix, + ), + ), +); + +const DEFAULT_ICONS_BROWSE = [ + "mdi:home", + "mdi:star", + "mdi:heart", + "mdi:check-circle", + "mdi:account", + "mdi:camera", + "mdi:music", + "mdi:map-marker", + "mdi:calendar", + "mdi:lightning-bolt", + "mdi:cog", + "mdi:rocket", +]; + +function getDisplayNameFromIconName({ + iconName, +}: { + iconName: string; +}): string { + const [, rawName = iconName] = iconName.split(":"); + return rawName.replaceAll("-", " ").replaceAll("_", " "); +} + +function toStickerItem({ iconName }: { iconName: string }): StickerItem { + return { + id: buildStickerId({ + providerId: ICONS_PROVIDER_ID, + providerValue: iconName, + }), + provider: ICONS_PROVIDER_ID, + name: getDisplayNameFromIconName({ iconName }), + previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), + metadata: { iconName }, + }; +} + +function computeHasMore({ + total, + limit, + start = 0, +}: { + total: number; + limit: number; + start?: number; +}): boolean { + return start + limit < total; +} + +export const iconsProvider: StickerProvider = { + id: ICONS_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise { + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + const searchResult = await searchIcons(query, limit, ICONS_PREFIXES); + return { + items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), + total: searchResult.total, + hasMore: computeHasMore({ + total: searchResult.total, + limit: searchResult.limit, + start: searchResult.start, + }), + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise { + const limit = options?.limit ?? DEFAULT_ICONS_BROWSE.length; + const items = DEFAULT_ICONS_BROWSE.slice(0, limit).map((iconName) => + toStickerItem({ iconName }), + ); + return { + items, + total: items.length, + hasMore: false, + }; + }, + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return getIconSvgUrl(providerValue, { + width: options?.width, + height: options?.height, + }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts new file mode 100644 index 00000000..3c4faee9 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/index.ts @@ -0,0 +1,26 @@ +import { hasProvider, registerProvider } from "../registry"; +import type { StickerProvider } from "@/types/stickers"; +import { emojiProvider } from "./emoji"; +import { flagsProvider } from "./flags"; +import { iconsProvider } from "./icons"; +import { shapesProvider } from "./shapes"; + +const defaultProviders: StickerProvider[] = [ + iconsProvider, + emojiProvider, + flagsProvider, + shapesProvider, +]; + +export function registerDefaultStickerProviders({ + providersToRegister = defaultProviders, +}: { + providersToRegister?: StickerProvider[]; +} = {}): void { + for (const provider of providersToRegister) { + if (hasProvider({ providerId: provider.id })) { + continue; + } + registerProvider({ provider }); + } +} diff --git a/apps/web/src/lib/stickers/providers/shapes.ts b/apps/web/src/lib/stickers/providers/shapes.ts new file mode 100644 index 00000000..81873dcb --- /dev/null +++ b/apps/web/src/lib/stickers/providers/shapes.ts @@ -0,0 +1,118 @@ +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const SHAPES_PROVIDER_ID = "shapes"; + +const SHAPES = [ + { key: "circle", name: "Circle" }, + { key: "square", name: "Square" }, + { key: "triangle", name: "Triangle" }, + { key: "star", name: "Star" }, + { key: "hexagon", name: "Hexagon" }, + { key: "diamond", name: "Diamond" }, +] as const; + +function buildShapeUrl({ shapeKey }: { shapeKey: string }): string { + return `/shapes/${shapeKey}.svg`; +} + +function toStickerItem({ + shape, +}: { + shape: (typeof SHAPES)[number]; +}): StickerItem { + return { + id: buildStickerId({ + providerId: SHAPES_PROVIDER_ID, + providerValue: shape.key, + }), + provider: SHAPES_PROVIDER_ID, + name: shape.name, + previewUrl: buildShapeUrl({ shapeKey: shape.key }), + metadata: { shape: shape.key }, + }; +} + +function filterShapesByQuery({ + query, +}: { + query: string; +}): Array<(typeof SHAPES)[number]> { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return [...SHAPES]; + } + + return SHAPES.filter((shape) => + shape.name.toLowerCase().includes(normalizedQuery), + ); +} + +function paginateShapes({ + shapes, + options, +}: { + shapes: Array<(typeof SHAPES)[number]>; + options?: { page?: number; limit?: number }; +}): { items: Array<(typeof SHAPES)[number]>; hasMore: boolean; total: number } { + const page = Math.max(1, options?.page ?? 1); + const limit = Math.max(1, options?.limit ?? SHAPES.length); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const pagedItems = shapes.slice(startIndex, endIndex); + return { + items: pagedItems, + hasMore: endIndex < shapes.length, + total: shapes.length, + }; +} + +export const shapesProvider: StickerProvider = { + id: SHAPES_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise { + const filteredShapes = filterShapesByQuery({ query }); + const paged = paginateShapes({ + shapes: filteredShapes, + options: { page: 1, limit: options?.limit ?? SHAPES.length }, + }); + return { + items: paged.items.map((shape) => toStickerItem({ shape })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise { + const paged = paginateShapes({ + shapes: [...SHAPES], + options, + }); + return { + items: paged.items.map((shape) => toStickerItem({ shape })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + resolveUrl({ + stickerId, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return buildShapeUrl({ shapeKey: providerValue }); + }, +}; -- cgit v1.3.1