rspace-online/lib/pinned-view.ts

276 lines
6.4 KiB
TypeScript

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<FolkShape, PinnedShapeState>();
#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;
}