import type { FolkShape } from "./folk-shape"; export type PinPosition = | "current" | "top-left" | "top-center" | "top-right" | "center-left" | "center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right"; interface PinnedShapeState { shape: FolkShape; position: PinPosition; offset: { x: number; y: number }; originalX: number; originalY: number; } /** * PinnedViewManager - Keeps shapes fixed to viewport positions as the canvas moves/zooms * * Usage: * const manager = new PinnedViewManager(canvasElement); * manager.pin(shape, 'top-right'); * manager.unpin(shape); */ export class PinnedViewManager { #canvas: HTMLElement; #pinnedShapes = new Map(); #rafId: number | null = null; #lastCanvasTransform = { x: 0, y: 0, scale: 1 }; constructor(canvas: HTMLElement) { this.#canvas = canvas; this.#startTracking(); } /** * Pin a shape to a viewport position * @param shape - The shape to pin * @param position - Where to pin it (default: 'current' keeps current screen position) */ pin(shape: FolkShape, position: PinPosition = "current"): void { if (this.#pinnedShapes.has(shape)) { // Update position if already pinned const state = this.#pinnedShapes.get(shape)!; state.position = position; this.#updateOffset(state); return; } const state: PinnedShapeState = { shape, position, offset: { x: 0, y: 0 }, originalX: shape.x, originalY: shape.y, }; this.#updateOffset(state); this.#pinnedShapes.set(shape, state); // Add pinned attribute for styling shape.setAttribute("data-pinned", position); // Dispatch event shape.dispatchEvent( new CustomEvent("pin", { detail: { pinned: true, position }, bubbles: true, }) ); } /** * Unpin a shape, returning it to normal canvas behavior */ unpin(shape: FolkShape): void { const state = this.#pinnedShapes.get(shape); if (!state) return; this.#pinnedShapes.delete(shape); shape.removeAttribute("data-pinned"); // Dispatch event shape.dispatchEvent( new CustomEvent("pin", { detail: { pinned: false }, bubbles: true, }) ); } /** * Check if a shape is pinned */ isPinned(shape: FolkShape): boolean { return this.#pinnedShapes.has(shape); } /** * Get the pin position of a shape */ getPinPosition(shape: FolkShape): PinPosition | null { return this.#pinnedShapes.get(shape)?.position || null; } /** * Toggle pin state of a shape */ togglePin(shape: FolkShape, position: PinPosition = "current"): void { if (this.isPinned(shape)) { this.unpin(shape); } else { this.pin(shape, position); } } /** * Clean up - call when destroying the canvas */ destroy(): void { if (this.#rafId !== null) { cancelAnimationFrame(this.#rafId); } this.#pinnedShapes.clear(); } #updateOffset(state: PinnedShapeState): void { const { shape, position } = state; const viewport = this.#getViewport(); const padding = 20; switch (position) { case "current": // Keep current screen position state.offset = { x: shape.x, y: shape.y }; break; case "top-left": state.offset = { x: padding, y: padding }; break; case "top-center": state.offset = { x: viewport.width / 2 - shape.width / 2, y: padding, }; break; case "top-right": state.offset = { x: viewport.width - shape.width - padding, y: padding, }; break; case "center-left": state.offset = { x: padding, y: viewport.height / 2 - shape.height / 2, }; break; case "center": state.offset = { x: viewport.width / 2 - shape.width / 2, y: viewport.height / 2 - shape.height / 2, }; break; case "center-right": state.offset = { x: viewport.width - shape.width - padding, y: viewport.height / 2 - shape.height / 2, }; break; case "bottom-left": state.offset = { x: padding, y: viewport.height - shape.height - padding, }; break; case "bottom-center": state.offset = { x: viewport.width / 2 - shape.width / 2, y: viewport.height - shape.height - padding, }; break; case "bottom-right": state.offset = { x: viewport.width - shape.width - padding, y: viewport.height - shape.height - padding, }; break; } } #getViewport(): { width: number; height: number } { return { width: window.innerWidth, height: window.innerHeight, }; } #getCanvasTransform(): { x: number; y: number; scale: number } { const transform = this.#canvas.style.transform; if (!transform) { return { x: 0, y: 0, scale: 1 }; } // Parse scale const scaleMatch = transform.match(/scale\(([^)]+)\)/); const scale = scaleMatch ? parseFloat(scaleMatch[1]) : 1; // Parse translate const translateMatch = transform.match( /translate\(([^,]+),\s*([^)]+)\)/ ); const x = translateMatch ? parseFloat(translateMatch[1]) : 0; const y = translateMatch ? parseFloat(translateMatch[2]) : 0; return { x, y, scale }; } #startTracking(): void { const update = () => { const currentTransform = this.#getCanvasTransform(); // Only update if transform changed if ( currentTransform.x !== this.#lastCanvasTransform.x || currentTransform.y !== this.#lastCanvasTransform.y || currentTransform.scale !== this.#lastCanvasTransform.scale ) { this.#lastCanvasTransform = currentTransform; this.#updatePinnedShapes(); } this.#rafId = requestAnimationFrame(update); }; this.#rafId = requestAnimationFrame(update); } #updatePinnedShapes(): void { const { x: canvasX, y: canvasY, scale } = this.#lastCanvasTransform; for (const [, state] of this.#pinnedShapes) { const { shape, offset, position } = state; if (position === "current") { // For 'current' position, compensate for canvas movement shape.x = (offset.x - canvasX) / scale; shape.y = (offset.y - canvasY) / scale; } else { // For fixed positions, recalculate offset and apply this.#updateOffset(state); shape.x = (offset.x - canvasX) / scale; shape.y = (offset.y - canvasY) / scale; } } } } // Singleton instance for convenience let defaultManager: PinnedViewManager | null = null; /** * Get or create a PinnedViewManager for the given canvas */ export function getPinnedViewManager(canvas: HTMLElement): PinnedViewManager { if (!defaultManager) { defaultManager = new PinnedViewManager(canvas); } return defaultManager; }