import { isCanvasState, type CanvasState } from "@dada/shared-contracts"; import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js"; import type { DynamicTemplateId } from "./dynamic-provider.js"; type CanvasElement = CanvasState["elements"][number]; export interface CanvasPoint { x: number; y: number; } export interface CanvasRect extends CanvasPoint { height: number; width: number; } export interface CanvasElementIdentity { createdAt: string; elementId: string; } export type CanvasLayerCommand = "back" | "backward" | "forward" | "front"; interface StaticStickerInput { assetId: string; identity: CanvasElementIdentity; opacity?: number; position: CanvasPoint; resourceVersion: string; rotation?: number; scale?: CanvasElement["scale"]; style_parameters?: CanvasElement["style_parameters"]; z_index?: number; zIndex: number; } const snapThreshold = 0.015; const hitHalfExtent = 0.08; function styleNumber(element: CanvasElement, key: string, fallback: number) { const value = element.style_parameters?.[key]; return typeof value === "number" ? value : fallback; } function textLineUnits(line: string) { return Array.from(line).reduce((total, character) => total + (/^[\x00-\x7F]$/.test(character) ? 0.62 : 1), 0); } export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint { if (element.type === "color_card") { const vertical = element.style_id === "style_01" || element.style_id === "style_02"; return { x: Math.max(hitHalfExtent, (vertical ? 62 : 175) / state.pixel_width) * element.scale.x, y: Math.max(hitHalfExtent, (vertical ? 155 : 50) / state.pixel_height) * element.scale.y, }; } if (element.type === "dynamic_sticker") { const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId]; const halfWidth = model?.halfSize.width ?? 170; const halfHeight = model?.halfSize.height ?? 62; return { x: Math.max(hitHalfExtent, halfWidth / state.pixel_width) * element.scale.x, y: Math.max(hitHalfExtent, halfHeight / state.pixel_height) * element.scale.y, }; } if (element.type !== "text_template") { return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y }; } const lines = (element.content ?? "").split("\n"); const fontSize = element.font_size ?? 48; const letterSpacing = styleNumber(element, "letter_spacing", 1); const lineHeight = styleNumber(element, "line_height", 1.2); const strokeWidth = styleNumber(element, "stroke_width", 0); const longestLine = Math.max(1, ...lines.map((line) => textLineUnits(line))); const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length)); const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2; const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2; return { x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x, y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y, }; } function cloneState(state: CanvasState) { return structuredClone(state); } function requireState(state: CanvasState) { if (!isCanvasState(state)) throw new Error("canvas_state_invalid"); return cloneState(state); } function clamp(value: number, minimum: number, maximum: number) { return Math.max(minimum, Math.min(maximum, value)); } function normalizedRotation(value: number) { const normalized = ((value + 180) % 360 + 360) % 360 - 180; return normalized === -180 ? 180 : normalized; } function samePoint(left: CanvasPoint | undefined, right: CanvasPoint) { return Boolean(left && Math.abs(left.x - right.x) < 0.001 && Math.abs(left.y - right.y) < 0.001); } function reindex(elements: CanvasElement[]) { return elements.map((element, zIndex) => ({ ...element, z_index: zIndex })); } export function createStaticStickerElement(input: StaticStickerInput): CanvasElement { return { created_at: input.identity.createdAt, element_id: input.identity.elementId, opacity: input.opacity ?? 1, position: structuredClone(input.position), resource_version: input.resourceVersion, rotation: input.rotation ?? 0, scale: structuredClone(input.scale ?? { x: 1, y: 1 }), style_parameters: structuredClone(input.style_parameters ?? { flip_horizontal: false }), template_or_asset_id: input.assetId, type: "static_sticker", z_index: input.z_index ?? input.zIndex, }; } export class CanvasElementController { private current: CanvasState; private selection: string[] = []; private cyclePoint: CanvasPoint | undefined = undefined; private cycleSignature = ""; private cycleIndex = -1; constructor(initial: CanvasState) { this.current = requireState(initial); } get value() { return cloneState(this.current); } get selectedIds() { return [...this.selection]; } replaceState(next: CanvasState) { this.current = requireState(next); const existing = new Set(this.current.elements.map((element) => element.element_id)); this.selection = this.selection.filter((elementId) => existing.has(elementId)); this.pointerMoved(); } clearSelection() { this.selection = []; this.pointerMoved(); } pointerMoved() { this.cyclePoint = undefined; this.cycleSignature = ""; this.cycleIndex = -1; } selectById(elementId: string, append = false) { if (!this.current.elements.some((element) => element.element_id === elementId)) return this.selectedIds; if (!append) this.selection = [elementId]; else if (this.selection.includes(elementId)) this.selection = this.selection.filter((selected) => selected !== elementId); else this.selection = [...this.selection, elementId]; return this.selectedIds; } selectIds(elementIds: readonly string[]) { const existing = new Set(this.current.elements.map((element) => element.element_id)); this.selection = [...new Set(elementIds.filter((elementId) => existing.has(elementId)))]; this.pointerMoved(); return this.selectedIds; } candidatesAt(point: CanvasPoint) { return this.current.elements .filter((element) => { const { x: halfWidth, y: halfHeight } = elementHalfExtents(this.current, element); return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth && point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight; }) .sort((left, right) => right.z_index - left.z_index) .map((element) => structuredClone(element)); } selectAt(point: CanvasPoint, options: { append?: boolean } = {}) { const candidates = this.candidatesAt(point); if (candidates.length === 0) { if (!options.append) this.selection = []; this.pointerMoved(); return this.selectedIds; } if (options.append) { this.pointerMoved(); return this.selectById(candidates[0]!.element_id, true); } const signature = candidates.map((candidate) => candidate.element_id).join("|"); if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length; else this.cycleIndex = 0; this.cyclePoint = { ...point }; this.cycleSignature = signature; this.selection = [candidates[this.cycleIndex]!.element_id]; return this.selectedIds; } marqueeSelect(rectangle: CanvasRect, append = false) { const minimumX = Math.min(rectangle.x, rectangle.x + rectangle.width); const maximumX = Math.max(rectangle.x, rectangle.x + rectangle.width); const minimumY = Math.min(rectangle.y, rectangle.y + rectangle.height); const maximumY = Math.max(rectangle.y, rectangle.y + rectangle.height); const hits = this.current.elements .filter((element) => element.position.x >= minimumX && element.position.x <= maximumX && element.position.y >= minimumY && element.position.y <= maximumY) .sort((left, right) => left.z_index - right.z_index) .map((element) => element.element_id); this.selection = append ? [...new Set([...this.selection, ...hits])] : hits; this.pointerMoved(); return this.selectedIds; } add(element: CanvasElement) { if (this.current.elements.length >= 50) throw new Error("canvas_element_limit_reached"); const nextElement = structuredClone(element); nextElement.z_index = this.current.elements.length; this.current = requireState({ ...this.current, elements: [...this.current.elements, nextElement] }); this.selection = [nextElement.element_id]; this.pointerMoved(); return this.value; } replaceElement(element: CanvasElement) { const index = this.current.elements.findIndex((candidate) => candidate.element_id === element.element_id); if (index < 0) throw new Error("canvas_element_not_found"); const elements = [...this.current.elements]; elements[index] = structuredClone(element); this.current = requireState({ ...this.current, elements }); this.selection = [element.element_id]; this.pointerMoved(); return this.value; } private updateSelected(mapper: (element: CanvasElement) => CanvasElement) { const selected = new Set(this.selection); this.current = requireState({ ...this.current, elements: this.current.elements.map((element) => selected.has(element.element_id) ? mapper(structuredClone(element)) : element), }); this.pointerMoved(); return this.value; } moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) { const selected = new Set(this.selection); const primary = this.current.elements.find((element) => selected.has(element.element_id)); if (!primary) return { guides: [] as string[], state: this.value }; let nextX = primary.position.x + delta.x; let nextY = primary.position.y + delta.y; const guides: string[] = []; const snapAxis = (value: number, axis: "x" | "y") => { const canvasTargets = [0, 0.5, 1]; const target = canvasTargets.find((candidate) => Math.abs(value - candidate) <= snapThreshold); if (target !== undefined) { guides.push(target === 0.5 ? `canvas-center-${axis}` : `canvas-edge-${axis}`); return target; } const other = this.current.elements.find((element) => !selected.has(element.element_id) && Math.abs(value - element.position[axis]) <= snapThreshold); if (other) { guides.push(`element-${axis}:${other.element_id}`); return other.position[axis]; } return value; }; if (options.snap !== false) { nextX = snapAxis(nextX, "x"); nextY = snapAxis(nextY, "y"); } const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y }; const state = this.updateSelected((element) => ({ ...element, position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) }, })); return { guides, state }; } scaleSelected(factor: number) { if (!Number.isFinite(factor) || factor <= 0) throw new Error("canvas_scale_invalid"); return this.updateSelected((element) => ({ ...element, scale: { x: clamp(element.scale.x * factor, 0.01, 100), y: clamp(element.scale.y * factor, 0.01, 100) }, })); } rotateSelected(degrees: number) { if (!Number.isFinite(degrees)) throw new Error("canvas_rotation_invalid"); return this.updateSelected((element) => ({ ...element, rotation: normalizedRotation(element.rotation + degrees) })); } flipSelectedHorizontal() { return this.updateSelected((element) => element.type === "static_sticker" ? { ...element, style_parameters: { ...element.style_parameters, flip_horizontal: element.style_parameters?.flip_horizontal !== true }, } : element); } setSelectedOpacity(opacity: number) { if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("canvas_opacity_invalid"); return this.updateSelected((element) => element.type === "static_sticker" ? { ...element, opacity } : element); } copySelected() { const selected = new Set(this.selection); return this.current.elements.filter((element) => selected.has(element.element_id)).map((element) => structuredClone(element)); } pasteElements(elements: readonly CanvasElement[], identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) { if (this.current.elements.length + elements.length > 50) throw new Error("canvas_element_limit_reached"); const pasted = elements.map((source, index) => { const identity = identityFactory(source, index); return { ...structuredClone(source), created_at: identity.createdAt, element_id: identity.elementId, position: { x: clamp(source.position.x + 0.02, 0, 1), y: clamp(source.position.y + 0.02, 0, 1) }, z_index: this.current.elements.length + index, }; }); this.current = requireState({ ...this.current, elements: [...this.current.elements, ...pasted] }); this.selection = pasted.map((element) => element.element_id); this.pointerMoved(); return this.value; } duplicateSelected(identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) { return this.pasteElements(this.copySelected(), identityFactory); } deleteSelected() { const selected = new Set(this.selection); this.current = requireState({ ...this.current, elements: reindex(this.current.elements.filter((element) => !selected.has(element.element_id))) }); this.selection = []; this.pointerMoved(); return this.value; } changeLayer(command: CanvasLayerCommand) { const selected = new Set(this.selection); const ordered = [...this.current.elements].sort((left, right) => left.z_index - right.z_index); if (command === "front" || command === "back") { const selectedElements = ordered.filter((element) => selected.has(element.element_id)); const unselectedElements = ordered.filter((element) => !selected.has(element.element_id)); ordered.splice(0, ordered.length, ...(command === "front" ? [...unselectedElements, ...selectedElements] : [...selectedElements, ...unselectedElements])); } else if (command === "forward") { for (let index = ordered.length - 2; index >= 0; index -= 1) { if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index + 1]!.element_id)) [ordered[index], ordered[index + 1]] = [ordered[index + 1]!, ordered[index]!]; } } else { for (let index = 1; index < ordered.length; index += 1) { if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index - 1]!.element_id)) [ordered[index - 1], ordered[index]] = [ordered[index]!, ordered[index - 1]!]; } } this.current = requireState({ ...this.current, elements: reindex(ordered) }); this.pointerMoved(); return this.value; } }