import { parseCssColor, type ParsedColor } from "./colorValue"; import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog"; import type { DomEditSelection } from "./domEditing"; import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import type { TimelineElement } from "../../player"; import { roundToCenti } from "../../utils/rounding"; export type { BackgroundRemovalProgress, BackgroundRemovalResult, PropertyPanelProps, } from "./propertyPanelTypes"; export function stripQueryAndHash(value: string): string { const queryIndex = value.indexOf("?"); const hashIndex = value.indexOf("#"); if (queryIndex < 0) return hashIndex < 0 ? value : value.slice(0, hashIndex); if (hashIndex < 0) return value.slice(0, queryIndex); return value.slice(0, Math.min(queryIndex, hashIndex)); } export function isSelectedElementHidden( elements: readonly TimelineElement[], selectedElementId: string | null, ): boolean { if (!selectedElementId) return false; return ( elements.find((element) => (element.key ?? element.id) === selectedElementId)?.hidden === true ); } /* ------------------------------------------------------------------ */ /* Font types & constants (shared by font and section modules) */ /* ------------------------------------------------------------------ */ export const GENERIC_FONT_FAMILIES = new Set([ "inherit", "initial", "revert", "revert-layer", "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-sans-serif", "ui-serif", "ui-monospace", "ui-rounded", "emoji", "math", "fangsong", ]); export const DEFAULT_FONT_FAMILIES = [ ...COMMON_LOCAL_FONT_FAMILIES, "Inter", "system-ui", "sans-serif", "serif", "monospace", ]; export interface LocalFontData { family: string; fullName?: string; postscriptName?: string; style?: string; blob?: () => Promise; } export type FontSource = "Current" | "Document" | "Imported" | "Local" | "Google" | "System"; export interface FontOption { family: string; source: FontSource; } declare global { interface Window { queryLocalFonts?: () => Promise; } } export function sanitizeFontFilePart(value: string): string { return value .replace(/[^\w .-]+/g, " ") .replace(/\s+/g, " ") .trim(); } export function localFontSortScore(font: LocalFontData): number { const style = font.style?.toLowerCase() ?? ""; const fullName = font.fullName?.toLowerCase() ?? ""; if (style === "regular" || fullName.endsWith(" regular")) return 0; if (style === "normal" || fullName.endsWith(" normal")) return 1; if (style === "medium" || fullName.endsWith(" medium")) return 2; return 3; } export function uniqueFontFamilies(values: string[]): string[] { const seen = new Set(); return values.reduce((result, value) => { const family = value.trim(); if (!family) return result; const key = family.toLowerCase(); if (seen.has(key)) return result; seen.add(key); result.push(family); return result; }, []); } export function uniqueFontOptions(values: FontOption[]): FontOption[] { const seen = new Set(); return values.reduce((result, value) => { const family = value.family.trim(); if (!family) return result; const key = family.toLowerCase(); if (seen.has(key)) return result; seen.add(key); result.push({ family, source: value.source }); return result; }, []); } export function sortFontOptions(options: FontOption[]): FontOption[] { return [...options].sort((a, b) => { const rankDelta = fontSourceRank(a.source) - fontSourceRank(b.source); if (rankDelta !== 0) return rankDelta; const commonA = COMMON_LOCAL_FONT_FAMILIES.findIndex( (f) => f.toLowerCase() === a.family.toLowerCase(), ); const commonB = COMMON_LOCAL_FONT_FAMILIES.findIndex( (f) => f.toLowerCase() === b.family.toLowerCase(), ); const commonDelta = (commonA === -1 ? Number.MAX_SAFE_INTEGER : commonA) - (commonB === -1 ? Number.MAX_SAFE_INTEGER : commonB); return commonDelta === 0 ? a.family.localeCompare(b.family) : commonDelta; }); } function fontSourceRank(source: FontSource): number { if (source === "Current") return 0; if (source === "Document") return 1; if (source === "Imported") return 2; if (source === "Google") return 3; if (source === "Local") return 4; return 5; } /* ------------------------------------------------------------------ */ /* Shared constants */ /* ------------------------------------------------------------------ */ export const FIELD = "min-w-0 rounded-md bg-panel-input px-3 py-[7px] text-panel-text-1 transition-colors focus-within:ring-1 focus-within:ring-panel-accent/30"; export const LABEL = "text-[11px] font-medium text-panel-text-3"; export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3"; export const EMPTY_STYLES: Record = {}; const EMPTY_FILTER_VALUE = "none"; const BOX_SHADOW_PRESETS = { none: "none", soft: "0 12px 36px rgba(0, 0, 0, 0.28)", lift: "0 18px 54px rgba(0, 0, 0, 0.38)", glow: "0 0 0 1px rgba(60, 230, 172, 0.34), 0 18px 56px rgba(60, 230, 172, 0.2)", } as const; export type BoxShadowPreset = keyof typeof BOX_SHADOW_PRESETS | "custom"; export { buildClipPathValue, buildInsetClipPathSides, buildInsetClipPathValue, getClipPathInsetPx, inferClipPathPreset, parseInsetClipPathSides, type ClipPathInsetSides, } from "./clipPathHelpers"; /* ------------------------------------------------------------------ */ /* Shared types */ /* ------------------------------------------------------------------ */ export interface ParsedNumericToken { value: number; unit: string; } /* ------------------------------------------------------------------ */ /* Pure utility functions */ /* ------------------------------------------------------------------ */ export function colorFromCss(value: string): ParsedColor { return parseCssColor(value) ?? { red: 0, green: 0, blue: 0, alpha: 1 }; } export function parseNumericValue(value: string | undefined): number | null { if (!value) return null; const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : null; } export function formatTimingValue(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return "0.00s"; return `${seconds.toFixed(2)}s`; } export function formatNumericValue(value: number): string { const rounded = roundToCenti(value); return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); } export function parseNumericToken(value: string | undefined): ParsedNumericToken | null { if (!value) return null; const match = value.trim().match(/^(-?\d+(?:\.\d+)?)([a-z%]*)$/i); if (!match) return null; const parsed = Number.parseFloat(match[1]); if (!Number.isFinite(parsed)) return null; return { value: parsed, unit: match[2] ?? "" }; } export function parsePxMetricValue(value: string): number | null { const token = parseNumericToken(value); if (!token) return null; if (token.unit && token.unit.toLowerCase() !== "px") return null; return token.value; } function clampPanelNumber(value: number, min: number, max: number, fallback: number): number { if (!Number.isFinite(value)) return fallback; return Math.max(min, Math.min(max, value)); } export function normalizePanelPxValue( value: string, options: { min?: number; max?: number; fallback?: number } = {}, ): string | null { const token = parseNumericToken(value.trim()); if (!token) return null; if (token.unit && token.unit.toLowerCase() !== "px") return null; const next = clampPanelNumber( token.value, options.min ?? Number.NEGATIVE_INFINITY, options.max ?? Number.POSITIVE_INFINITY, options.fallback ?? 0, ); return `${formatNumericValue(next)}px`; } export function formatPxMetricValue(value: number): string { return `${formatNumericValue(value)}px`; } export function normalizeTextMetricValue( property: "letter-spacing" | "line-height", value: string, ) { const trimmed = value.trim(); if (!trimmed || trimmed === "normal") return trimmed || "normal"; const token = parseNumericToken(trimmed); if (!token) return trimmed; if (property === "letter-spacing") { return token.unit ? trimmed : `${formatNumericValue(token.value)}px`; } if (token.unit) return trimmed; return token.value > 4 ? `${formatNumericValue(token.value)}px` : formatNumericValue(token.value); } function splitCssFunctions(value: string): string[] { const source = value.trim(); const functions: string[] = []; let depth = 0; let start = 0; for (let index = 0; index < source.length; index += 1) { const char = source[index]; if (char === "(") depth += 1; if (char === ")") depth = Math.max(0, depth - 1); if (/\s/.test(char) && depth === 0) { const part = source.slice(start, index).trim(); if (part) functions.push(part); start = index + 1; } } const lastPart = source.slice(start).trim(); if (lastPart) functions.push(lastPart); return functions; } export function getCssFilterFunctionPx(value: string | undefined, name: string): number { const normalized = value?.trim(); if (!normalized || normalized === EMPTY_FILTER_VALUE) return 0; const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = new RegExp(`(?:^|\\s)${escapedName}\\((-?\\d+(?:\\.\\d+)?)px\\)`, "i").exec( normalized, ); if (!match) return 0; const parsed = Number.parseFloat(match[1]); return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; } export function setCssFilterFunctionPx( value: string | undefined, name: string, nextPx: number, ): string { const nextValue = clampPanelNumber(nextPx, 0, 200, 0); const functions = splitCssFunctions(value && value.trim() !== EMPTY_FILTER_VALUE ? value : ""); const lowerName = name.toLowerCase(); const filtered = functions.filter((entry) => !entry.toLowerCase().startsWith(`${lowerName}(`)); if (nextValue > 0) filtered.push(`${name}(${formatNumericValue(nextValue)}px)`); return filtered.length > 0 ? filtered.join(" ") : EMPTY_FILTER_VALUE; } export function inferBoxShadowPreset(value: string | undefined): BoxShadowPreset { const normalized = value?.trim() || "none"; for (const [preset, shadow] of Object.entries(BOX_SHADOW_PRESETS)) { if (normalized === shadow) return preset as BoxShadowPreset; } return normalized === "none" ? "none" : "custom"; } export function buildBoxShadowPresetValue( preset: BoxShadowPreset, fallback: string | undefined, ): string { if (preset === "custom") return fallback?.trim() || "none"; return BOX_SHADOW_PRESETS[preset]; } export function buildStrokeWidthStyleUpdates( nextWidth: string, currentBorderStyle: string | undefined, ): Array<[property: string, value: string]> { const updates: Array<[property: string, value: string]> = [["border-width", nextWidth]]; const token = parseNumericToken(nextWidth); const style = currentBorderStyle?.trim().toLowerCase() || "none"; if (token && token.value > 0 && (style === "none" || style === "hidden")) { updates.push(["border-style", "solid"]); } return updates; } export function buildStrokeStyleUpdates( nextStyle: string, currentBorderWidth: string | undefined, ): Array<[property: string, value: string]> { const updates: Array<[property: string, value: string]> = [["border-style", nextStyle]]; const style = nextStyle.trim().toLowerCase(); if (!style || style === "none" || style === "hidden") return updates; const token = parseNumericToken(currentBorderWidth?.trim() || "0"); if (!token || token.value <= 0) { updates.push(["border-width", "1px"]); } return updates; } export function adjustNumericToken( value: string, direction: 1 | -1, modifiers?: { shiftKey?: boolean; altKey?: boolean }, ): string | null { const token = parseNumericToken(value); if (!token) return null; const baseStep = modifiers?.altKey ? 0.1 : modifiers?.shiftKey ? 10 : 1; const nextValue = token.value + baseStep * direction; return `${formatNumericValue(nextValue)}${token.unit}`; } export function extractBackgroundImageUrl(value: string | undefined): string { if (!value) return ""; const lowerValue = value.toLowerCase(); const urlStart = lowerValue.indexOf("url("); if (urlStart < 0) return ""; let index = urlStart + 4; while ( index < value.length && (value[index] === " " || value[index] === "\n" || value[index] === "\r" || value[index] === "\t" || value[index] === "\f") ) { index += 1; } const quote = value[index] === '"' || value[index] === "'" ? value[index] : null; if (quote) { index += 1; const endQuote = value.indexOf(quote, index); return endQuote >= index ? value.slice(index, endQuote) : ""; } const endParen = value.indexOf(")", index); if (endParen < index) return ""; return value.slice(index, endParen).trim(); } // ── GSAP runtime value readers (used by PropertyPanel) ──────────────────── // Core transform channels the panel ALWAYS reads live — even before a just-set // value (e.g. rotationX) has re-parsed into `gsapAnimations`. Without this the // cube + fields drop the prop and flicker to 0 on every commit; gsap.getProperty // reflects the in-place instant patch, so it's the true current value. // fallow-ignore-next-line complexity const ALWAYS_READ_CHANNELS = [ "x", "y", "rotation", "rotationX", "rotationY", "rotationZ", "z", "scale", "transformPerspective", "opacity", ]; /** Every property key the panel should read for an element: animated props + the * always-read transform channels. */ function collectPanelPropKeys(gsapAnimations: GsapAnimation[]): Set { const keys = new Set(ALWAYS_READ_CHANNELS); for (const anim of gsapAnimations) { if (anim.keyframes) { for (const kf of anim.keyframes.keyframes) { for (const p of Object.keys(kf.properties)) keys.add(p); } } for (const p of Object.keys(anim.properties)) keys.add(p); } return keys; } export function readGsapRuntimeValuesForPanel( gsapAnimId: string | null, gsapAnimations: GsapAnimation[], element: DomEditSelection, previewIframeRef: React.RefObject, ): Record | null { if (!gsapAnimId || gsapAnimations.length === 0) return null; const iframe = previewIframeRef?.current; if (!iframe?.contentWindow) return null; const selector = element.id ? `#${element.id}` : element.selector; if (!selector) return null; try { const gsap = ( iframe.contentWindow as unknown as { gsap?: { getProperty: (el: Element, prop: string) => number | string }; } ).gsap; if (!gsap?.getProperty) return null; const el = iframe.contentDocument?.querySelector(selector); if (!el) return null; const propKeys = collectPanelPropKeys(gsapAnimations); const result: Record = {}; for (const prop of propKeys) { const v = Number(gsap.getProperty(el, prop)); if (Number.isFinite(v)) result[prop] = roundToCenti(v); } return Object.keys(result).length > 0 ? result : null; } catch { return null; } } export function readGsapBorderRadiusForPanel( gsapRuntimeValues: Record | null, gsapAnimations: GsapAnimation[], element: DomEditSelection, previewIframeRef: React.RefObject, ): { tl: number; tr: number; br: number; bl: number } | null { if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) { const hasBRProp = gsapAnimations.some( (a) => "borderRadius" in a.properties || a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties), ); if (!hasBRProp) return null; } const iframe = previewIframeRef?.current; const selector = element.id ? `#${element.id}` : element.selector; if (!iframe?.contentDocument || !selector) return null; try { const el = iframe.contentDocument.querySelector(selector); if (!el || !iframe.contentWindow) return null; const cs = iframe.contentWindow.getComputedStyle(el); const parse = (v: string) => Number.parseFloat(v) || 0; return { tl: parse(cs.borderTopLeftRadius), tr: parse(cs.borderTopRightRadius), br: parse(cs.borderBottomRightRadius), bl: parse(cs.borderBottomLeftRadius), }; } catch { return null; } }