summaryrefslogtreecommitdiff
path: root/apps/web/src/graphics
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/graphics')
-rw-r--r--apps/web/src/graphics/components/graphic-tab.tsx478
-rw-r--r--apps/web/src/graphics/definitions/index.ts5
-rw-r--r--apps/web/src/graphics/index.ts304
3 files changed, 398 insertions, 389 deletions
diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx
index 13cd7b84..31c4a86d 100644
--- a/apps/web/src/graphics/components/graphic-tab.tsx
+++ b/apps/web/src/graphics/components/graphic-tab.tsx
@@ -1,239 +1,239 @@
-"use client";
-
-import { useRef } from "react";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import {
- useKeyframedParamProperty,
- type KeyframedParamPropertyResult,
-} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
-import type { ParamDefinition, ParamValues } from "@/params";
-import type { GraphicElement } from "@/timeline";
-import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
-import { useElementPreview } from "@/timeline/hooks/use-element-preview";
-import { useEditor } from "@/editor/use-editor";
-import {
- Section,
- SectionContent,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-import type { MediaTime } from "@/wasm";
-
-registerDefaultGraphics();
-
-const DEFAULT_STROKE_WIDTH = 2;
-
-export function GraphicTab({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicElementParamsAtTime({
- element: liveElement,
- localTime,
- });
-
- const shapeParams = definition.params.filter((p) => p.group !== "stroke");
- const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
-
- return (
- <div className="flex flex-col">
- <Section collapsible sectionKey={`${element.id}:graphic`}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {shapeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
- </div>
- );
-}
-
-function StrokeSection({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicElementParamsAtTime({
- element: liveElement,
- localTime,
- });
- const strokeParams = definition.params.filter((p) => p.group === "stroke");
- const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
- const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
-
- const toggleStroke = () => {
- if (isStrokeEnabled) {
- lastStrokeWidth.current = Number(
- element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
- );
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { params: { ...element.params, strokeWidth: 0 } },
- },
- ],
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- params: {
- ...element.params,
- strokeWidth: lastStrokeWidth.current,
- },
- },
- },
- ],
- });
- }
- };
-
- return (
- <Section
- collapsible
- defaultOpen={isStrokeEnabled}
- sectionKey={`${element.id}:stroke`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleStroke();
- }}
- >
- <HugeiconsIcon
- icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Stroke</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
- >
- <SectionFields>
- {strokeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function AnimatedGraphicParamField({
- param,
- trackId,
- element,
- localTime,
- isPlayheadWithinElementRange,
- resolvedParams,
-}: {
- key?: string;
- param: ParamDefinition;
- trackId: string;
- element: GraphicElement;
- localTime: MediaTime;
- isPlayheadWithinElementRange: boolean;
- resolvedParams: ParamValues;
-}) {
- const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
- {
- param,
- trackId,
- elementId: element.id,
- animations: element.animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedParams[param.key] ?? param.default,
- buildBaseUpdates: ({ value }) => ({
- params: {
- ...element.params,
- [param.key]: value,
- },
- }),
- },
- );
-
- return (
- <PropertyParamField
- param={param}
- value={resolvedParams[param.key] ?? param.default}
- onPreview={animatedParam.onPreview}
- onCommit={animatedParam.onCommit}
- keyframe={{
- isActive: animatedParam.isKeyframedAtTime,
- isDisabled: !isPlayheadWithinElementRange,
- onToggle: animatedParam.toggleKeyframe,
- }}
- />
- );
-}
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
+import type { ParamDefinition, ParamValues } from "@/params";
+import type { GraphicElement } from "@/timeline";
+import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
+import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+import { useEditor } from "@/editor/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicElementParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicElementParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ key?: string;
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: MediaTime;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
+ {
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ },
+ );
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/graphics/definitions/index.ts b/apps/web/src/graphics/definitions/index.ts
index 6fd9e446..846a9983 100644
--- a/apps/web/src/graphics/definitions/index.ts
+++ b/apps/web/src/graphics/definitions/index.ts
@@ -16,7 +16,10 @@ export function registerDefaultGraphics(): void {
if (graphicsRegistry.has(definition.id)) {
continue;
}
- graphicsRegistry.register(definition.id, definition);
+ graphicsRegistry.register({
+ key: definition.id,
+ definition,
+ });
}
}
diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts
index 3f277d40..7b8d905e 100644
--- a/apps/web/src/graphics/index.ts
+++ b/apps/web/src/graphics/index.ts
@@ -1,149 +1,155 @@
-import { resolveGraphicParamsAtTime } from "@/animation";
-import type { ElementAnimations } from "@/animation/types";
-import { buildDefaultParamValues } from "@/params/registry";
-import type { ParamValues } from "@/params";
-import { graphicsRegistry } from "./registry";
-import {
- registerDefaultGraphics,
- ellipseGraphicDefinition,
- polygonGraphicDefinition,
- rectangleGraphicDefinition,
- starGraphicDefinition,
-} from "./definitions";
-import {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- type GraphicInstance,
- type GraphicDefinition,
-} from "./types";
-
-const graphicPreviewUrlCache = new Map<string, string>();
-
-const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
-const FALLBACK_FILL_OPACITY = 0.08;
-const FALLBACK_MIN_FONT_SIZE = 12;
-const FALLBACK_FONT_SIZE_RATIO = 0.15;
-
-function buildFallbackPreviewUrl({
- name,
- size,
-}: {
- name: string;
- size: number;
-}): string {
- const svg = `
- <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
- <rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
- <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
- </svg>
- `;
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
-}
-
-export function getGraphicDefinition({
- definitionId,
-}: {
- definitionId: string;
-}): GraphicDefinition {
- registerDefaultGraphics();
- return graphicsRegistry.get(definitionId);
-}
-
-export function buildDefaultGraphicInstance({
- definitionId,
-}: {
- definitionId: string;
-}): GraphicInstance {
- const definition = getGraphicDefinition({ definitionId });
- return {
- definitionId,
- params: buildDefaultParamValues(definition.params),
- };
-}
-
-export function resolveGraphicParams(
- definition: GraphicDefinition,
- params?: ParamValues,
-): ParamValues {
- return {
- ...buildDefaultParamValues(definition.params),
- ...(params ?? {}),
- };
-}
-
-export function resolveGraphicElementParamsAtTime({
- element,
- localTime,
-}: {
- element: {
- definitionId: string;
- params: ParamValues;
- animations?: ElementAnimations;
- };
- localTime: number;
-}): ParamValues {
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- return resolveGraphicParamsAtTime({
- params: resolveGraphicParams(definition, element.params),
- definitions: definition.params,
- animations: element.animations,
- localTime,
- });
-}
-
-export function buildGraphicPreviewUrl({
- definitionId,
- params,
- size = DEFAULT_GRAPHIC_SOURCE_SIZE,
-}: {
- definitionId: string;
- params?: ParamValues;
- size?: number;
-}): string {
- const definition = getGraphicDefinition({ definitionId });
- const resolvedParams = resolveGraphicParams(definition, params);
- const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
- const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
- if (cachedUrl) {
- return cachedUrl;
- }
-
- if (typeof document === "undefined") {
- return buildFallbackPreviewUrl({ name: definition.name, size });
- }
-
- const canvas = document.createElement("canvas");
- canvas.width = size;
- canvas.height = size;
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- return buildFallbackPreviewUrl({ name: definition.name, size });
- }
-
- definition.render({
- ctx,
- params: resolvedParams,
- width: size,
- height: size,
- });
-
- const previewUrl = canvas.toDataURL("image/png");
- graphicPreviewUrlCache.set(cacheKey, previewUrl);
- return previewUrl;
-}
-
-export {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- ellipseGraphicDefinition,
- graphicsRegistry,
- polygonGraphicDefinition,
- rectangleGraphicDefinition,
- registerDefaultGraphics,
- starGraphicDefinition,
-};
-export type {
- GraphicDefinition,
- GraphicInstance,
- GraphicRenderContext,
-} from "./types";
+import { resolveGraphicParamsAtTime } from "@/animation";
+import type { ElementAnimations } from "@/animation/types";
+import { buildDefaultParamValues } from "@/params/registry";
+import type { ParamValues } from "@/params";
+import { graphicsRegistry } from "./registry";
+import {
+ registerDefaultGraphics,
+ ellipseGraphicDefinition,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ starGraphicDefinition,
+} from "./definitions";
+import {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ type GraphicInstance,
+ type GraphicDefinition,
+} from "./types";
+
+const graphicPreviewUrlCache = new Map<string, string>();
+
+const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
+const FALLBACK_FILL_OPACITY = 0.08;
+const FALLBACK_MIN_FONT_SIZE = 12;
+const FALLBACK_FONT_SIZE_RATIO = 0.15;
+
+function buildFallbackPreviewUrl({
+ name,
+ size,
+}: {
+ name: string;
+ size: number;
+}): string {
+ const svg = `
+ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
+ <rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
+ <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
+ </svg>
+ `;
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+export function getGraphicDefinition({
+ definitionId,
+}: {
+ definitionId: string;
+}): GraphicDefinition {
+ registerDefaultGraphics();
+ return graphicsRegistry.get(definitionId);
+}
+
+export function buildDefaultGraphicInstance({
+ definitionId,
+}: {
+ definitionId: string;
+}): GraphicInstance {
+ const definition = getGraphicDefinition({ definitionId });
+ return {
+ definitionId,
+ params: buildDefaultParamValues(definition.params),
+ };
+}
+
+export function resolveGraphicParams({
+ definition,
+ params,
+}: {
+ definition: GraphicDefinition;
+ params?: ParamValues;
+}): ParamValues {
+ return {
+ ...buildDefaultParamValues(definition.params),
+ ...(params ?? {}),
+ };
+}
+
+export function resolveGraphicElementParamsAtTime({
+ element,
+ localTime,
+}: {
+ element: {
+ definitionId: string;
+ params: ParamValues;
+ animations?: ElementAnimations;
+ };
+ localTime: number;
+}): ParamValues {
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ return resolveGraphicParamsAtTime({
+ params: resolveGraphicParams({
+ definition,
+ params: element.params,
+ }),
+ definitions: definition.params,
+ animations: element.animations,
+ localTime,
+ });
+}
+
+export function buildGraphicPreviewUrl({
+ definitionId,
+ params,
+ size = DEFAULT_GRAPHIC_SOURCE_SIZE,
+}: {
+ definitionId: string;
+ params?: ParamValues;
+ size?: number;
+}): string {
+ const definition = getGraphicDefinition({ definitionId });
+ const resolvedParams = resolveGraphicParams({ definition, params });
+ const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
+ const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
+ if (cachedUrl) {
+ return cachedUrl;
+ }
+
+ if (typeof document === "undefined") {
+ return buildFallbackPreviewUrl({ name: definition.name, size });
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return buildFallbackPreviewUrl({ name: definition.name, size });
+ }
+
+ definition.render({
+ ctx,
+ params: resolvedParams,
+ width: size,
+ height: size,
+ });
+
+ const previewUrl = canvas.toDataURL("image/png");
+ graphicPreviewUrlCache.set(cacheKey, previewUrl);
+ return previewUrl;
+}
+
+export {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ ellipseGraphicDefinition,
+ graphicsRegistry,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ registerDefaultGraphics,
+ starGraphicDefinition,
+};
+export type {
+ GraphicDefinition,
+ GraphicInstance,
+ GraphicRenderContext,
+} from "./types";