276 lines
6.4 KiB
TypeScript
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;
|
|
}
|