synops/frontend/src/lib/components/canvas/Canvas.svelte
vegard 3cc17c5784 Fix: Canvas stjeler ikke klikk fra panel-innhold lenger
Canvas handlePointerDown ignorerer klikk inne i .blockshell-content.
Inputs, knapper, selects og lister i paneler fungerer normalt.
Bare headeren starter Canvas-drag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 03:14:56 +00:00

758 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { type Snippet } from 'svelte';
import {
type Camera,
type CanvasObject,
type GridConfig,
type ViewportSize,
visibleObjects,
screenToWorld,
snap,
clampZoom,
ZOOM_MIN,
ZOOM_MAX,
ZOOM_FIT_MIN,
} from './types.js';
/**
* Canvas Primitive — felles fritt-canvas underlag.
*
* Håndterer kamera (pan/zoom), viewport culling, objekt-plassering
* og interaksjon. Consumer bestemmer hva som rendres via renderObject snippet.
*/
interface Props {
/** Objects to render on the canvas */
objects: CanvasObject[];
/** Render callback for each visible object */
renderObject: Snippet<[CanvasObject]>;
/** Grid configuration */
grid?: GridConfig;
/** Initial camera position */
initialCamera?: Camera;
/** Callback when an object is moved (drag-drop) */
onObjectMove?: (id: string, x: number, y: number) => void;
/** Callback when camera changes */
onCameraChange?: (camera: Camera) => void;
/** Callback when selection changes */
onSelectionChange?: (ids: string[]) => void;
/** Callback when grid is toggled */
onGridChange?: (enabled: boolean) => void;
}
let {
objects,
renderObject,
grid = { enabled: false, size: 20 },
initialCamera = { x: 0, y: 0, zoom: 1.0 },
onObjectMove,
onCameraChange,
onGridChange,
onSelectionChange,
}: Props = $props();
// --- State ---
let camera = $state<Camera>({ ...initialCamera });
let selectedIds = $state<Set<string>>(new Set());
let isFullscreen = $state(false);
let viewport = $state<ViewportSize>({ width: 0, height: 0 });
// Interaction state (not reactive — internal bookkeeping)
let isPanning = $state(false);
let isDragging = $state(false);
let isLassoing = $state(false);
let spaceHeld = $state(false);
let dragTargetId: string | null = null;
let topZCounter = $state(1);
let zOrderMap = $state(new Map<string, number>());
let dragStartWorld = { x: 0, y: 0 };
let dragObjectStart = { x: 0, y: 0 };
let dragGroupStarts = new Map<string, { x: number; y: number }>();
let panStart = { x: 0, y: 0 };
let cameraStart = { x: 0, y: 0 };
let lassoStart = { x: 0, y: 0 };
let lassoEnd = $state({ x: 0, y: 0 });
// Touch tracking for pinch-zoom
let activeTouches = new Map<number, { x: number; y: number }>();
let lastPinchDist = 0;
let lastPinchCenter = { x: 0, y: 0 };
// DOM refs
let containerEl: HTMLDivElement | undefined = $state();
// --- Derived ---
let visibleObjs = $derived(visibleObjects(objects, camera, viewport));
let transformStyle = $derived(
`translate(${camera.x}px, ${camera.y}px) scale(${camera.zoom})`
);
let lassoRect = $derived.by(() => {
if (!isLassoing) return null;
const x = Math.min(lassoStart.x, lassoEnd.x);
const y = Math.min(lassoStart.y, lassoEnd.y);
const w = Math.abs(lassoEnd.x - lassoStart.x);
const h = Math.abs(lassoEnd.y - lassoStart.y);
return { x, y, width: w, height: h };
});
// Notify consumers of camera changes
$effect(() => {
// Access camera fields to create dependency
const _ = camera.x + camera.y + camera.zoom;
onCameraChange?.(camera);
});
// ResizeObserver for viewport size
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
viewport.width = entry.contentRect.width;
viewport.height = entry.contentRect.height;
}
});
ro.observe(containerEl);
return () => ro.disconnect();
});
// Keyboard listeners
$effect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.code === 'Space' && !e.repeat) {
spaceHeld = true;
e.preventDefault();
}
if (e.code === 'Escape' && isFullscreen) {
isFullscreen = false;
}
// 'g' toggles grid snap
if (e.code === 'KeyG' && !e.ctrlKey && !e.metaKey) {
grid = { ...grid, enabled: !grid.enabled };
onGridChange?.(grid.enabled);
}
// Piltaster = pan
const PAN_STEP = 50;
if (e.code === 'ArrowUp') { camera = { ...camera, y: camera.y + PAN_STEP }; e.preventDefault(); }
if (e.code === 'ArrowDown') { camera = { ...camera, y: camera.y - PAN_STEP }; e.preventDefault(); }
if (e.code === 'ArrowLeft') { camera = { ...camera, x: camera.x + PAN_STEP }; e.preventDefault(); }
if (e.code === 'ArrowRight') { camera = { ...camera, x: camera.x - PAN_STEP }; e.preventDefault(); }
}
function onKeyUp(e: KeyboardEvent) {
if (e.code === 'Space') {
spaceHeld = false;
}
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
});
// --- Scroll: Ctrl = zoom, uten Ctrl = pan ---
function handleWheel(e: WheelEvent) {
// Ikke fang scroll inne i paneler (la innhold scrolle)
if ((e.target as HTMLElement).closest('[data-canvas-object-id]')) return;
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
// Ctrl+scroll = zoom
const zoomFactor = e.deltaY > 0 ? 0.92 : 1.08;
zoomAt(e.clientX, e.clientY, zoomFactor);
} else {
// Scroll = pan
camera = {
...camera,
x: camera.x - e.deltaX - (e.shiftKey ? e.deltaY : 0),
y: camera.y - (e.shiftKey ? 0 : e.deltaY),
};
}
}
function zoomAt(screenX: number, screenY: number, factor: number) {
const rect = containerEl?.getBoundingClientRect();
if (!rect) return;
const cx = screenX - rect.left;
const cy = screenY - rect.top;
const newZoom = clampZoom(camera.zoom * factor);
const scale = newZoom / camera.zoom;
camera = {
x: cx - scale * (cx - camera.x),
y: cy - scale * (cy - camera.y),
zoom: newZoom,
};
}
// --- Pointer events ---
function handlePointerDown(e: PointerEvent) {
if (!containerEl) return;
// Ignore clicks on toolbar and other UI overlays
if ((e.target as HTMLElement).closest('.canvas-toolbar')) return;
const rect = containerEl.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
// Middle button or space+left = pan
if (e.button === 1 || (e.button === 0 && spaceHeld)) {
isPanning = true;
panStart = { x: e.clientX, y: e.clientY };
cameraStart = { x: camera.x, y: camera.y };
containerEl.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
// Left button on object = drag object (but not panel content)
if (e.button === 0) {
// La klikk inne i panel-innhold passere (inputs, knapper, lister osv.)
// Bare headeren (.blockshell-header) skal starte Canvas-drag
const inContent = (e.target as HTMLElement).closest('.blockshell-content');
if (inContent) return;
const target = (e.target as HTMLElement).closest('[data-canvas-object-id]');
if (target) {
const id = target.getAttribute('data-canvas-object-id')!;
isDragging = true;
dragTargetId = id;
const world = screenToWorld(sx, sy, camera);
dragStartWorld = { x: world.x, y: world.y };
const obj = objects.find(o => o.id === id);
if (obj) {
dragObjectStart = { x: obj.x, y: obj.y };
}
// Selection logic
if (e.shiftKey) {
const newSet = new Set(selectedIds);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
selectedIds = newSet;
} else if (!selectedIds.has(id)) {
selectedIds = new Set([id]);
}
onSelectionChange?.([...selectedIds]);
// Bring to front
topZCounter++;
zOrderMap = new Map(zOrderMap);
for (const sid of selectedIds) {
zOrderMap.set(sid, topZCounter);
}
// Record start positions for all selected objects (group drag)
dragGroupStarts = new Map();
for (const sid of selectedIds) {
const sobj = objects.find(o => o.id === sid);
if (sobj) dragGroupStarts.set(sid, { x: sobj.x, y: sobj.y });
}
containerEl.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
// Left button on empty space = pan (or lasso with shift)
if (e.shiftKey) {
// Shift+drag = lasso selection
isLassoing = true;
lassoStart = { x: sx, y: sy };
lassoEnd = { x: sx, y: sy };
} else {
// Normal drag on empty space = pan
isPanning = true;
panStart = { x: e.clientX, y: e.clientY };
cameraStart = { x: camera.x, y: camera.y };
// Deselect all
selectedIds = new Set();
onSelectionChange?.([]);
}
containerEl.setPointerCapture(e.pointerId);
e.preventDefault();
}
}
function handlePointerMove(e: PointerEvent) {
if (!containerEl) return;
if (isPanning) {
const dx = e.clientX - panStart.x;
const dy = e.clientY - panStart.y;
camera = {
...camera,
x: cameraStart.x + dx,
y: cameraStart.y + dy,
};
return;
}
if (isDragging && dragTargetId) {
const rect = containerEl.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const world = screenToWorld(sx, sy, camera);
const dx = world.x - dragStartWorld.x;
const dy = world.y - dragStartWorld.y;
// Move all selected objects together
for (const [sid, start] of dragGroupStarts) {
let newX = start.x + dx;
let newY = start.y + dy;
if (grid.enabled) {
newX = snap(newX, grid.size);
newY = snap(newY, grid.size);
}
onObjectMove?.(sid, newX, newY);
}
// Edge-pan: scroll canvas when dragging near edges
const edgeMargin = 40;
const edgeSpeed = 8;
let panDx = 0, panDy = 0;
if (sx < edgeMargin) panDx = edgeSpeed;
if (sx > rect.width - edgeMargin) panDx = -edgeSpeed;
if (sy < edgeMargin) panDy = edgeSpeed;
if (sy > rect.height - edgeMargin) panDy = -edgeSpeed;
if (panDx || panDy) {
camera = { ...camera, x: camera.x + panDx, y: camera.y + panDy };
}
return;
}
if (isLassoing) {
const rect = containerEl.getBoundingClientRect();
lassoEnd = { x: e.clientX - rect.left, y: e.clientY - rect.top };
return;
}
}
function handlePointerUp(e: PointerEvent) {
if (isPanning) {
isPanning = false;
return;
}
if (isDragging) {
isDragging = false;
dragTargetId = null;
return;
}
if (isLassoing && lassoRect) {
// Select objects within lasso (in screen space)
const newSelection = new Set(selectedIds);
for (const obj of visibleObjs) {
const screenPos = {
x: obj.x * camera.zoom + camera.x,
y: obj.y * camera.zoom + camera.y,
width: obj.width * camera.zoom,
height: obj.height * camera.zoom,
};
// Check if object intersects lasso rect
if (
screenPos.x < lassoRect.x + lassoRect.width &&
screenPos.x + screenPos.width > lassoRect.x &&
screenPos.y < lassoRect.y + lassoRect.height &&
screenPos.y + screenPos.height > lassoRect.y
) {
newSelection.add(obj.id);
}
}
selectedIds = newSelection;
onSelectionChange?.([...selectedIds]);
isLassoing = false;
return;
}
}
// --- Touch: pinch-zoom and two-finger pan ---
function handleTouchStart(e: TouchEvent) {
for (const touch of e.changedTouches) {
activeTouches.set(touch.identifier, { x: touch.clientX, y: touch.clientY });
}
if (activeTouches.size === 2) {
const [a, b] = [...activeTouches.values()];
lastPinchDist = Math.hypot(b.x - a.x, b.y - a.y);
lastPinchCenter = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
e.preventDefault();
}
}
function handleTouchMove(e: TouchEvent) {
for (const touch of e.changedTouches) {
activeTouches.set(touch.identifier, { x: touch.clientX, y: touch.clientY });
}
if (activeTouches.size === 2) {
const [a, b] = [...activeTouches.values()];
const dist = Math.hypot(b.x - a.x, b.y - a.y);
const center = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
// Pinch zoom
if (lastPinchDist > 0) {
const factor = dist / lastPinchDist;
zoomAt(center.x, center.y, factor);
}
// Two-finger pan
const dx = center.x - lastPinchCenter.x;
const dy = center.y - lastPinchCenter.y;
camera = { ...camera, x: camera.x + dx, y: camera.y + dy };
lastPinchDist = dist;
lastPinchCenter = center;
e.preventDefault();
}
}
function handleTouchEnd(e: TouchEvent) {
for (const touch of e.changedTouches) {
activeTouches.delete(touch.identifier);
}
if (activeTouches.size < 2) {
lastPinchDist = 0;
}
}
// --- Toolbar zoom (zoom toward viewport center) ---
function zoomStep(delta: number) {
const cx = viewport.width / 2;
const cy = viewport.height / 2;
const newZoom = clampZoom(camera.zoom * (1 + delta));
const scale = newZoom / camera.zoom;
camera = {
x: cx - scale * (cx - camera.x),
y: cy - scale * (cy - camera.y),
zoom: newZoom,
};
}
// --- Reset zoom (ankret til viewport-sentrum) ---
function resetZoom() {
const cx = viewport.width / 2;
const cy = viewport.height / 2;
const scale = 1.0 / camera.zoom;
camera = {
x: cx - scale * (cx - camera.x),
y: cy - scale * (cy - camera.y),
zoom: 1.0,
};
}
// --- Fullscreen ---
function toggleFullscreen() {
isFullscreen = !isFullscreen;
}
// --- Public API via bindable ---
export function getCamera(): Camera {
return { ...camera };
}
export function setCamera(cam: Camera) {
camera = { ...cam, zoom: clampZoom(cam.zoom) };
}
export function zoomToFit(padding: number = 50) {
if (objects.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const obj of objects) {
minX = Math.min(minX, obj.x);
minY = Math.min(minY, obj.y);
maxX = Math.max(maxX, obj.x + obj.width);
maxY = Math.max(maxY, obj.y + obj.height);
}
const contentW = maxX - minX;
const contentH = maxY - minY;
if (contentW <= 0 || contentH <= 0) return;
const zoomX = (viewport.width - padding * 2) / contentW;
const zoomY = (viewport.height - padding * 2) / contentH;
const zoom = clampZoom(Math.min(zoomX, zoomY));
camera = {
x: (viewport.width - contentW * zoom) / 2 - minX * zoom,
y: (viewport.height - contentH * zoom) / 2 - minY * zoom,
zoom,
};
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="canvas-container"
class:canvas-fullscreen={isFullscreen}
class:canvas-panning={isPanning || spaceHeld}
onwheel={handleWheel}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
ondblclick={(e) => {
if (!(e.target as HTMLElement).closest('[data-canvas-object-id]') &&
!(e.target as HTMLElement).closest('.canvas-toolbar')) {
resetZoom();
}
}}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
role="application"
aria-label="Canvas"
>
<!-- Toolbar -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="canvas-toolbar" onclick={(e) => e.stopPropagation()} onpointerdown={(e) => e.stopPropagation()}>
<button
class="canvas-toolbar-btn"
onclick={() => zoomStep(-0.15)}
title="Zoom ut (15%)"
aria-label="Zoom ut"
></button>
<button class="canvas-toolbar-zoom" onclick={() => { resetZoom(); }} title="Tilbakestill til 100%">{Math.round(camera.zoom * 100)}%</button>
<button
class="canvas-toolbar-btn"
onclick={() => zoomStep(0.15)}
title="Zoom inn (+15%)"
aria-label="Zoom inn"
>+</button>
<button
class="canvas-toolbar-btn"
onclick={() => zoomToFit()}
title="Tilpass visning"
aria-label="Tilpass visning"
></button>
<div class="canvas-toolbar-sep"></div>
<button
class="canvas-toolbar-btn"
class:canvas-toolbar-active={grid.enabled}
onclick={() => { grid = { ...grid, enabled: !grid.enabled }; onGridChange?.(grid.enabled); }}
title="Snap-to-grid (G)"
aria-label="Snap-to-grid"
>#</button>
<button
class="canvas-toolbar-btn"
onclick={toggleFullscreen}
title="Fullskjerm"
aria-label="Fullskjerm"
>{isFullscreen ? '⊖' : '⊕'}</button>
</div>
<!-- Grid background (when enabled) -->
{#if grid.enabled}
<div
class="canvas-grid"
style:background-size="{grid.size * camera.zoom}px {grid.size * camera.zoom}px"
style:background-position="{camera.x}px {camera.y}px"
></div>
{/if}
<!-- World layer — CSS transformed -->
<div
class="canvas-world"
style:transform={transformStyle}
>
{#each visibleObjs as obj (obj.id)}
<div
class="canvas-object"
class:canvas-object-selected={selectedIds.has(obj.id)}
data-canvas-object-id={obj.id}
style:left="{obj.x}px"
style:top="{obj.y}px"
style:width="{obj.width}px"
style:height="{obj.height}px"
style:z-index={zOrderMap.get(obj.id) ?? 1}
>
{@render renderObject(obj)}
</div>
{/each}
</div>
<!-- Lasso selection overlay (screen space) -->
{#if isLassoing && lassoRect}
<div
class="canvas-lasso"
style:left="{lassoRect.x}px"
style:top="{lassoRect.y}px"
style:width="{lassoRect.width}px"
style:height="{lassoRect.height}px"
></div>
{/if}
<!-- Mobile: touch hint -->
{#if viewport.width < 768}
<div class="canvas-touch-hint">
Bruk to fingre for å panorere og zoome
</div>
{/if}
</div>
<style>
.canvas-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--color-bg, #0a0a0b);
touch-action: none; /* We handle all touch ourselves */
user-select: none;
cursor: default;
}
.canvas-container.canvas-panning {
cursor: grab;
}
.canvas-container.canvas-fullscreen {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--color-bg, #0a0a0b);
}
.canvas-world {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
will-change: transform;
}
.canvas-object {
position: absolute;
cursor: default;
overflow: visible;
}
.canvas-object-selected {
outline: 1px solid rgba(99, 102, 241, 0.4);
outline-offset: 1px;
border-radius: 4px;
}
/* Grid pattern */
.canvas-grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(to right, rgba(99, 102, 241, 0.12) 1px, transparent 1px),
linear-gradient(to bottom, rgba(99, 102, 241, 0.12) 1px, transparent 1px);
}
/* Lasso */
.canvas-lasso {
position: absolute;
border: 1px dashed #3b82f6;
background: rgba(59, 130, 246, 0.08);
pointer-events: none;
z-index: 10;
}
/* Toolbar */
.canvas-toolbar {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--color-surface, #1c1c20);
border: 1px solid var(--color-border, #2a2a2e);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.canvas-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
color: var(--color-text-muted, #8a8a96);
transition: background 0.15s;
}
.canvas-toolbar-btn:hover {
background: var(--color-surface-hover, #242428);
}
.canvas-toolbar-active {
background: var(--color-accent-glow, rgba(99, 102, 241, 0.15));
color: var(--color-accent, #3b82f6);
}
.canvas-toolbar-zoom {
font-size: 12px;
color: var(--color-text-muted, #8a8a96);
min-width: 40px;
text-align: center;
font-variant-numeric: tabular-nums;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
padding: 2px 4px;
}
.canvas-toolbar-zoom:hover {
color: var(--color-text, #e8e8ec);
background: var(--color-surface-hover, #242428);
}
.canvas-toolbar-sep {
width: 1px;
height: 20px;
background: var(--color-border, #2a2a2e);
margin: 0 4px;
}
/* Touch hint for mobile */
.canvas-touch-hint {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
padding: 4px 12px;
background: rgba(0,0,0,0.6);
color: white;
font-size: 12px;
border-radius: 16px;
pointer-events: none;
opacity: 0.8;
}
/* Responsive touch targets */
@media (max-width: 768px) {
.canvas-toolbar-btn {
width: 44px;
height: 44px;
font-size: 20px;
}
.canvas-toolbar {
bottom: 16px;
gap: 2px;
padding: 4px;
}
}
@media (min-width: 768px) and (max-width: 1024px) {
.canvas-toolbar-btn {
width: 38px;
height: 38px;
}
}
</style>