From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/graphics/components/graphic-tab.tsx | 238 +++++++++++++++++++++++ apps/web/src/graphics/definitions/ellipse.ts | 73 +++++++ apps/web/src/graphics/definitions/index.ts | 29 +++ apps/web/src/graphics/definitions/polygon.ts | 211 ++++++++++++++++++++ apps/web/src/graphics/definitions/rectangle.ts | 85 ++++++++ apps/web/src/graphics/definitions/shared.ts | 16 ++ apps/web/src/graphics/definitions/star.ts | 109 +++++++++++ apps/web/src/graphics/index.ts | 125 ++++++++++++ apps/web/src/graphics/registry.ts | 10 + apps/web/src/graphics/stroke.ts | 102 ++++++++++ apps/web/src/graphics/types.ts | 23 +++ 11 files changed, 1021 insertions(+) create mode 100644 apps/web/src/graphics/components/graphic-tab.tsx create mode 100644 apps/web/src/graphics/definitions/ellipse.ts create mode 100644 apps/web/src/graphics/definitions/index.ts create mode 100644 apps/web/src/graphics/definitions/polygon.ts create mode 100644 apps/web/src/graphics/definitions/rectangle.ts create mode 100644 apps/web/src/graphics/definitions/shared.ts create mode 100644 apps/web/src/graphics/definitions/star.ts create mode 100644 apps/web/src/graphics/index.ts create mode 100644 apps/web/src/graphics/registry.ts create mode 100644 apps/web/src/graphics/stroke.ts create mode 100644 apps/web/src/graphics/types.ts (limited to 'apps/web/src/graphics') diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx new file mode 100644 index 00000000..60599772 --- /dev/null +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -0,0 +1,238 @@ +"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 { resolveGraphicParamsAtTime } from "@/animation"; +import type { ParamDefinition, ParamValues } from "@/params"; +import type { GraphicElement } from "@/timeline"; +import { graphicsRegistry, registerDefaultGraphics } 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"; + +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 = resolveGraphicParamsAtTime({ + element: liveElement, + localTime, + }); + + const shapeParams = definition.params.filter((p) => p.group !== "stroke"); + const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); + + return ( +
+
+ + {definition.name} + + + + {shapeParams.map((param) => ( + + ))} + + +
+ {hasStrokeParams && } +
+ ); +} + +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 = resolveGraphicParamsAtTime({ + 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 ( +
+ { + event.stopPropagation(); + toggleStroke(); + }} + > + + + } + > + Stroke + + + + {strokeParams.map((param) => ( + + ))} + + +
+ ); +} + +function AnimatedGraphicParamField({ + param, + trackId, + element, + localTime, + isPlayheadWithinElementRange, + resolvedParams, +}: { + param: ParamDefinition; + trackId: string; + element: GraphicElement; + localTime: number; + 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 ( + + ); +} diff --git a/apps/web/src/graphics/definitions/ellipse.ts b/apps/web/src/graphics/definitions/ellipse.ts new file mode 100644 index 00000000..7ae9e8e3 --- /dev/null +++ b/apps/web/src/graphics/definitions/ellipse.ts @@ -0,0 +1,73 @@ +import type { ParamDefinition } from "@/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface EllipseParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; +} + +const ELLIPSE_PARAMS: ParamDefinition[] = [ + { + key: "fill", + label: "Fill", + type: "color", + default: "#ffffff", + }, + { + key: "stroke", + label: "Color", + type: "color", + default: "#000000", + group: "stroke", + }, + { + key: "strokeWidth", + label: "Width", + type: "number", + default: 0, + min: 0, + max: 64, + step: 1, + shortLabel: "W", + group: "stroke", + }, + STROKE_ALIGN_PARAM, +]; + +export const ellipseGraphicDefinition: GraphicDefinition = { + id: "ellipse", + name: "Ellipse", + keywords: ["ellipse", "circle", "oval"], + params: ELLIPSE_PARAMS, + render({ ctx, params, width, height }) { + const fill = String(params.fill ?? "#ffffff"); + const stroke = String(params.stroke ?? "#000000"); + const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0)); + const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign; + const inset = strokeAlign === "center" ? strokeWidth / 2 : 0; + const centerX = width / 2; + const centerY = height / 2; + const radiusX = Math.max(1, width / 2 - inset); + const radiusY = Math.max(1, height / 2 - inset); + + ctx.clearRect(0, 0, width, height); + const path = new Path2D(); + path.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2); + ctx.fillStyle = fill; + ctx.fill(path); + + if (strokeWidth > 0) { + applyAlignedStroke({ + ctx, + path, + strokeWidth, + strokeAlign, + strokeColor: stroke, + }); + } + }, +}; diff --git a/apps/web/src/graphics/definitions/index.ts b/apps/web/src/graphics/definitions/index.ts new file mode 100644 index 00000000..6fd9e446 --- /dev/null +++ b/apps/web/src/graphics/definitions/index.ts @@ -0,0 +1,29 @@ +import { graphicsRegistry } from "../registry"; +import { ellipseGraphicDefinition } from "./ellipse"; +import { polygonGraphicDefinition } from "./polygon"; +import { rectangleGraphicDefinition } from "./rectangle"; +import { starGraphicDefinition } from "./star"; + +const defaultGraphicDefinitions = [ + rectangleGraphicDefinition, + ellipseGraphicDefinition, + polygonGraphicDefinition, + starGraphicDefinition, +]; + +export function registerDefaultGraphics(): void { + for (const definition of defaultGraphicDefinitions) { + if (graphicsRegistry.has(definition.id)) { + continue; + } + graphicsRegistry.register(definition.id, definition); + } +} + +export { + ellipseGraphicDefinition, + polygonGraphicDefinition, + rectangleGraphicDefinition, + starGraphicDefinition, +}; +export { STROKE_ALIGN_PARAM } from "./shared"; diff --git a/apps/web/src/graphics/definitions/polygon.ts b/apps/web/src/graphics/definitions/polygon.ts new file mode 100644 index 00000000..11273fed --- /dev/null +++ b/apps/web/src/graphics/definitions/polygon.ts @@ -0,0 +1,211 @@ +import type { ParamDefinition } from "@/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface Point { + x: number; + y: number; +} + +interface PolygonParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + sides: number; + cornerRadius: number; +} + +const POLYGON_PARAMS: ParamDefinition[] = [ + { + key: "fill", + label: "Fill", + type: "color", + default: "#ffffff", + }, + { + key: "stroke", + label: "Color", + type: "color", + default: "#000000", + group: "stroke", + }, + { + key: "strokeWidth", + label: "Width", + type: "number", + default: 0, + min: 0, + max: 64, + step: 1, + shortLabel: "W", + group: "stroke", + }, + STROKE_ALIGN_PARAM, + { + key: "sides", + label: "Sides", + type: "number", + default: 5, + min: 3, + max: 12, + step: 1, + shortLabel: "S", + }, + { + key: "cornerRadius", + label: "Corner radius", + type: "number", + default: 0, + min: 0, + max: 50, + step: 1, + shortLabel: "R", + }, +]; + +function buildPolygonVertices({ + centerX, + centerY, + radius, + sides, +}: { + centerX: number; + centerY: number; + radius: number; + sides: number; +}): Point[] { + return Array.from({ length: sides }, (_, index) => { + const angle = -Math.PI / 2 + (index * Math.PI * 2) / sides; + return { + x: centerX + Math.cos(angle) * radius, + y: centerY + Math.sin(angle) * radius, + }; + }); +} + +function distance({ a, b }: { a: Point; b: Point }): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function normalize(point: Point): Point { + const length = Math.hypot(point.x, point.y) || 1; + return { + x: point.x / length, + y: point.y / length, + }; +} + +function traceRoundedPolygonPath({ + path, + vertices, + radius, +}: { + path: Path2D; + vertices: Point[]; + radius: number; +}): void { + if (vertices.length < 3) { + return; + } + + if (radius <= 0) { + path.moveTo(vertices[0].x, vertices[0].y); + for (let index = 1; index < vertices.length; index++) { + path.lineTo(vertices[index].x, vertices[index].y); + } + path.closePath(); + return; + } + + for (let index = 0; index < vertices.length; index++) { + const previous = vertices[(index - 1 + vertices.length) % vertices.length]; + const current = vertices[index]; + const next = vertices[(index + 1) % vertices.length]; + const toPrevious = normalize({ + x: previous.x - current.x, + y: previous.y - current.y, + }); + const toNext = normalize({ + x: next.x - current.x, + y: next.y - current.y, + }); + const angle = Math.acos( + Math.max(-1, Math.min(1, toPrevious.x * toNext.x + toPrevious.y * toNext.y)), + ); + const maxOffset = + Math.min(distance({ a: previous, b: current }), distance({ a: current, b: next })) / 2; + const tangentOffset = Math.min(radius / Math.tan(angle / 2), maxOffset); + const start = { + x: current.x + toPrevious.x * tangentOffset, + y: current.y + toPrevious.y * tangentOffset, + }; + const end = { + x: current.x + toNext.x * tangentOffset, + y: current.y + toNext.y * tangentOffset, + }; + + if (index === 0) { + path.moveTo(start.x, start.y); + } else { + path.lineTo(start.x, start.y); + } + + path.arcTo( + current.x, + current.y, + end.x, + end.y, + Math.min(radius, maxOffset), + ); + } + + path.closePath(); +} + +export const polygonGraphicDefinition: GraphicDefinition = { + id: "polygon", + name: "Polygon", + keywords: ["polygon", "triangle", "pentagon", "hexagon", "diamond"], + params: POLYGON_PARAMS, + render({ ctx, params, width, height }) { + const fill = String(params.fill ?? "#ffffff"); + const stroke = String(params.stroke ?? "#000000"); + const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0)); + const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign; + const sides = Math.max(3, Math.min(12, Math.round(Number(params.sides ?? 5)))); + const inset = strokeAlign === "center" ? strokeWidth / 2 : 0; + const radius = Math.max(1, Math.min(width, height) / 2 - inset); + const maxCornerRadius = radius * Math.sin(Math.PI / sides); + const cornerRadiusPercent = Math.max(0, Number(params.cornerRadius ?? 0)); + const cornerRadius = + maxCornerRadius * Math.min(cornerRadiusPercent, 50) / 50; + const vertices = buildPolygonVertices({ + centerX: width / 2, + centerY: height / 2, + radius, + sides, + }); + + ctx.clearRect(0, 0, width, height); + const path = new Path2D(); + traceRoundedPolygonPath({ + path, + vertices, + radius: cornerRadius, + }); + ctx.fillStyle = fill; + ctx.fill(path); + + if (strokeWidth > 0) { + applyAlignedStroke({ + ctx, + path, + strokeWidth, + strokeAlign, + strokeColor: stroke, + }); + } + }, +}; diff --git a/apps/web/src/graphics/definitions/rectangle.ts b/apps/web/src/graphics/definitions/rectangle.ts new file mode 100644 index 00000000..3cd0a449 --- /dev/null +++ b/apps/web/src/graphics/definitions/rectangle.ts @@ -0,0 +1,85 @@ +import type { ParamDefinition } from "@/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface RectangleParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + cornerRadius: number; +} + +const RECTANGLE_PARAMS: ParamDefinition[] = [ + { + key: "fill", + label: "Fill", + type: "color", + default: "#ffffff", + }, + { + key: "stroke", + label: "Color", + type: "color", + default: "#000000", + group: "stroke", + }, + { + key: "strokeWidth", + label: "Width", + type: "number", + default: 0, + min: 0, + max: 64, + step: 1, + shortLabel: "W", + group: "stroke", + }, + STROKE_ALIGN_PARAM, + { + key: "cornerRadius", + label: "Corner radius", + type: "number", + default: 0, + min: 0, + max: 50, + step: 1, + shortLabel: "R", + }, +]; + +export const rectangleGraphicDefinition: GraphicDefinition = { + id: "rectangle", + name: "Rectangle", + keywords: ["rectangle", "square", "box"], + params: RECTANGLE_PARAMS, + render({ ctx, params, width, height }) { + const fill = String(params.fill ?? "#ffffff"); + const stroke = String(params.stroke ?? "#000000"); + const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0)); + const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign; + const inset = strokeAlign === "center" ? strokeWidth / 2 : 0; + const drawWidth = Math.max(1, width - inset * 2); + const drawHeight = Math.max(1, height - inset * 2); + const radiusPercent = Math.max(0, Number(params.cornerRadius ?? 0)); + const radius = + (Math.min(drawWidth, drawHeight) / 2) * Math.min(radiusPercent, 50) / 50; + + ctx.clearRect(0, 0, width, height); + const path = new Path2D(); + path.roundRect(inset, inset, drawWidth, drawHeight, radius); + ctx.fillStyle = fill; + ctx.fill(path); + + if (strokeWidth > 0) { + applyAlignedStroke({ + ctx, + path, + strokeWidth, + strokeAlign, + strokeColor: stroke, + }); + } + }, +}; diff --git a/apps/web/src/graphics/definitions/shared.ts b/apps/web/src/graphics/definitions/shared.ts new file mode 100644 index 00000000..2bd92875 --- /dev/null +++ b/apps/web/src/graphics/definitions/shared.ts @@ -0,0 +1,16 @@ +import type { ParamDefinition } from "@/params"; + +export type GraphicStrokeAlign = "inside" | "center" | "outside"; + +export const STROKE_ALIGN_PARAM: ParamDefinition<"strokeAlign"> = { + key: "strokeAlign", + label: "Stroke align", + type: "select", + default: "center", + group: "stroke", + options: [ + { value: "inside", label: "Inside" }, + { value: "center", label: "Center" }, + { value: "outside", label: "Outside" }, + ], +}; diff --git a/apps/web/src/graphics/definitions/star.ts b/apps/web/src/graphics/definitions/star.ts new file mode 100644 index 00000000..3aa40fee --- /dev/null +++ b/apps/web/src/graphics/definitions/star.ts @@ -0,0 +1,109 @@ +import type { ParamDefinition } from "@/params"; +import { applyAlignedStroke } from "../stroke"; +import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared"; +import type { GraphicDefinition } from "../types"; + +interface StarParams { + fill: string; + stroke: string; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + points: number; + depth: number; +} + +const STAR_PARAMS: ParamDefinition[] = [ + { + key: "fill", + label: "Fill", + type: "color", + default: "#ffffff", + }, + { + key: "stroke", + label: "Color", + type: "color", + default: "#000000", + group: "stroke", + }, + { + key: "strokeWidth", + label: "Width", + type: "number", + default: 0, + min: 0, + max: 64, + step: 1, + shortLabel: "W", + group: "stroke", + }, + STROKE_ALIGN_PARAM, + { + key: "points", + label: "Points", + type: "number", + default: 5, + min: 3, + max: 12, + step: 1, + shortLabel: "P", + }, + { + key: "depth", + label: "Depth", + type: "number", + default: 45, + min: 1, + max: 99, + step: 1, + shortLabel: "D", + }, +]; + +export const starGraphicDefinition: GraphicDefinition = { + id: "star", + name: "Star", + keywords: ["star", "sparkle", "burst"], + params: STAR_PARAMS, + render({ ctx, params, width, height }) { + const fill = String(params.fill ?? "#ffffff"); + const stroke = String(params.stroke ?? "#000000"); + const strokeWidth = Math.max(0, Number(params.strokeWidth ?? 0)); + const strokeAlign = (params.strokeAlign ?? "center") as GraphicStrokeAlign; + const points = Math.max(3, Math.min(12, Math.round(Number(params.points ?? 5)))); + const depth = Math.max(1, Math.min(99, Number(params.depth ?? 45))) / 100; + const inset = strokeAlign === "center" ? strokeWidth / 2 : 0; + const outerRadius = Math.max(1, Math.min(width, height) / 2 - inset); + const innerRadius = outerRadius * depth; + const centerX = width / 2; + const centerY = height / 2; + + ctx.clearRect(0, 0, width, height); + const path = new Path2D(); + for (let index = 0; index < points * 2; index++) { + const radius = index % 2 === 0 ? outerRadius : innerRadius; + const angle = -Math.PI / 2 + (index * Math.PI) / points; + const x = centerX + Math.cos(angle) * radius; + const y = centerY + Math.sin(angle) * radius; + + if (index === 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + path.closePath(); + ctx.fillStyle = fill; + ctx.fill(path); + + if (strokeWidth > 0) { + applyAlignedStroke({ + ctx, + path, + strokeWidth, + strokeAlign, + strokeColor: stroke, + }); + } + }, +}; diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts new file mode 100644 index 00000000..c69a4ab0 --- /dev/null +++ b/apps/web/src/graphics/index.ts @@ -0,0 +1,125 @@ +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(); + +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 = ` + + + ${name} + + `; + 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 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"; diff --git a/apps/web/src/graphics/registry.ts b/apps/web/src/graphics/registry.ts new file mode 100644 index 00000000..ea40a43a --- /dev/null +++ b/apps/web/src/graphics/registry.ts @@ -0,0 +1,10 @@ +import { DefinitionRegistry } from "@/params/registry"; +import type { GraphicDefinition } from "./types"; + +export class GraphicsRegistry extends DefinitionRegistry { + constructor() { + super("graphic"); + } +} + +export const graphicsRegistry = new GraphicsRegistry(); diff --git a/apps/web/src/graphics/stroke.ts b/apps/web/src/graphics/stroke.ts new file mode 100644 index 00000000..230d9904 --- /dev/null +++ b/apps/web/src/graphics/stroke.ts @@ -0,0 +1,102 @@ +import type { GraphicStrokeAlign } from "./definitions/shared"; + +type GraphicRenderContext = + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D; + +function createTempCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + try { + return new OffscreenCanvas(width, height); + } catch { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } +} + +function applyStroke({ + ctx, + path, + strokeWidth, + strokeColor, +}: { + ctx: GraphicRenderContext; + path: Path2D; + strokeWidth: number; + strokeColor: string; +}) { + ctx.strokeStyle = strokeColor; + ctx.lineWidth = strokeWidth; + ctx.stroke(path); +} + +export function applyAlignedStroke({ + ctx, + path, + strokeWidth, + strokeAlign, + strokeColor, +}: { + ctx: GraphicRenderContext; + path: Path2D; + strokeWidth: number; + strokeAlign: GraphicStrokeAlign; + strokeColor: string; +}): void { + if (strokeWidth <= 0) { + return; + } + + if (strokeAlign === "inside") { + ctx.save(); + ctx.clip(path); + applyStroke({ + ctx, + path, + strokeWidth: strokeWidth * 2, + strokeColor, + }); + ctx.restore(); + return; + } + + if (strokeAlign === "outside") { + const strokeCanvas = createTempCanvas({ + width: ctx.canvas.width, + height: ctx.canvas.height, + }); + const strokeCtx = strokeCanvas.getContext("2d") as GraphicRenderContext | null; + if (!strokeCtx) { + return; + } + + applyStroke({ + ctx: strokeCtx, + path, + strokeWidth: strokeWidth * 2, + strokeColor, + }); + + // Keep only the outer half of the doubled stroke so alpha fills do not + // leave a visible inner stroke behind. + strokeCtx.globalCompositeOperation = "destination-out"; + strokeCtx.fill(path); + + ctx.drawImage(strokeCanvas, 0, 0); + return; + } + + applyStroke({ + ctx, + path, + strokeWidth, + strokeColor, + }); +} diff --git a/apps/web/src/graphics/types.ts b/apps/web/src/graphics/types.ts new file mode 100644 index 00000000..1c6cc10e --- /dev/null +++ b/apps/web/src/graphics/types.ts @@ -0,0 +1,23 @@ +import type { ParamDefinition, ParamValues } from "@/params"; + +export const DEFAULT_GRAPHIC_SOURCE_SIZE = 512; + +export interface GraphicRenderContext { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + params: ParamValues; + width: number; + height: number; +} + +export interface GraphicDefinition { + id: string; + name: string; + keywords: string[]; + params: ParamDefinition[]; + render(context: GraphicRenderContext): void; +} + +export interface GraphicInstance { + definitionId: string; + params: ParamValues; +} -- cgit v1.3.1