Fullfører oppgave 19.1: Canvas-primitiv Svelte-komponent

Implementerer det felles canvas-underlaget som whiteboard, storyboard
og fremtidige canvas-views skal bruke. Ren Svelte 5 komponent uten
backend-avhengigheter.

Funksjoner:
- Pan/zoom kamera med CSS transforms (transform-origin: 0 0)
- Viewport culling med 200px margin for smooth scrolling
- Pointer events (unified mus + touch)
- Pinch-zoom og to-finger-pan for touch
- Snap-to-grid (toggle med G-tast eller toolbar)
- Fullskjermsmodus (fixed positioning)
- Lasso-seleksjon og shift+klikk multi-select
- Edge-pan ved drag nær kanter
- Responsivt: 44px touch targets på mobil, tilpasset toolbar
- zoomToFit() for å sentrere alle objekter
- Consumer-rendering via Svelte 5 snippets

Filer:
- frontend/src/lib/components/canvas/Canvas.svelte
- frontend/src/lib/components/canvas/types.ts
- frontend/src/lib/components/canvas/index.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 07:18:29 +00:00
parent e1c2a0cd08
commit 3be2a57f88
5 changed files with 790 additions and 4 deletions

View file

@ -190,10 +190,14 @@ Storyboard-consumeren bruker `onObjectMove` til å kalle en SpacetimeDB-reducer
## 10. Implementeringsstrategi
### Fase 1: Kjerne-primitiv
### Fase 1: Kjerne-primitiv ✅ (implementert)
- `<Canvas>` Svelte-komponent med kamera (pan/zoom), viewport culling, og objekt-drag
- Touch-støtte (pinch-zoom, to-finger-pan)
- BlockShell fullskjerm-toggle
- Fullskjerm-toggle (i canvas-toolbar, `position: fixed`)
- **Filer:** `src/lib/components/canvas/Canvas.svelte`, `types.ts`, `index.ts`
- **Bruk:** `import { Canvas } from '$lib/components/canvas'`
- **API:** `renderObject` snippet for consumer-rendering, events via props (`onObjectMove`, `onCameraChange`, `onSelectionChange`)
- **Eksport:** `getCamera()`, `setCamera()`, `zoomToFit()` metoder
### Fase 2: Storyboard som første consumer
- `<StoryboardCard>` rendrer meldingsboks-kort på canvaset

View file

@ -0,0 +1,660 @@
<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,
} 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;
}
let {
objects,
renderObject,
grid = { enabled: false, size: 20 },
initialCamera = { x: 0, y: 0, zoom: 1.0 },
onObjectMove,
onCameraChange,
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 dragStartWorld = { x: 0, y: 0 };
let dragObjectStart = { x: 0, y: 0 };
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 };
}
}
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);
};
});
// --- Zoom ---
function handleWheel(e: WheelEvent) {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.92 : 1.08;
zoomAt(e.clientX, e.clientY, zoomFactor);
}
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;
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
if (e.button === 0) {
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]);
containerEl.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
// Left button on empty space = lasso or deselect
if (!spaceHeld) {
if (e.shiftKey) {
// Start lasso
isLassoing = true;
lassoStart = { x: sx, y: sy };
lassoEnd = { x: sx, y: sy };
} else {
// 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);
let newX = dragObjectStart.x + (world.x - dragStartWorld.x);
let newY = dragObjectStart.y + (world.y - dragStartWorld.y);
if (grid.enabled) {
newX = snap(newX, grid.size);
newY = snap(newY, grid.size);
}
onObjectMove?.(dragTargetId, 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;
}
}
// --- 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}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
role="application"
aria-label="Canvas"
>
<!-- Toolbar -->
<div class="canvas-toolbar">
<button
class="canvas-toolbar-btn"
onclick={() => zoomAt(viewport.width / 2, viewport.height / 2, 1.2)}
title="Zoom inn"
aria-label="Zoom inn"
>+</button>
<span class="canvas-toolbar-zoom">{Math.round(camera.zoom * 100)}%</span>
<button
class="canvas-toolbar-btn"
onclick={() => zoomAt(viewport.width / 2, viewport.height / 2, 0.8)}
title="Zoom ut"
aria-label="Zoom ut"
></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 })}
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"
>
{@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: #f8f9fa;
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: 50;
background: #f8f9fa;
}
.canvas-world {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
will-change: transform;
}
.canvas-object {
position: absolute;
cursor: default;
}
.canvas-object-selected {
outline: 2px solid #3b82f6;
outline-offset: 2px;
border-radius: 4px;
}
/* Grid pattern */
.canvas-grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(to right, rgba(0,0,0,0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.05) 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: white;
border: 1px solid #e5e7eb;
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: #374151;
transition: background 0.15s;
}
.canvas-toolbar-btn:hover {
background: #f3f4f6;
}
.canvas-toolbar-active {
background: #eff6ff;
color: #3b82f6;
}
.canvas-toolbar-zoom {
font-size: 12px;
color: #6b7280;
min-width: 40px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.canvas-toolbar-sep {
width: 1px;
height: 20px;
background: #e5e7eb;
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>

View file

@ -0,0 +1,2 @@
export { default as Canvas } from './Canvas.svelte';
export * from './types.js';

View file

@ -0,0 +1,121 @@
/**
* Canvas Primitive shared types for the freeform canvas component.
* Used by whiteboard, storyboard, and future canvas-based views.
*/
/** Camera state: 2D affine transformation */
export interface Camera {
x: number; // pan offset X (world coords)
y: number; // pan offset Y (world coords)
zoom: number; // scale factor (1.0 = 100%)
}
/** Generic canvas object — consumer determines rendering */
export interface CanvasObject {
id: string;
x: number;
y: number;
width: number;
height: number;
}
/** Axis-aligned bounding box for intersection tests */
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
/** Events emitted by the canvas for consumer integration (e.g. SpacetimeDB sync) */
export interface CanvasEvents {
onObjectMove?: (id: string, x: number, y: number) => void;
onObjectResize?: (id: string, w: number, h: number) => void;
onCameraChange?: (camera: Camera) => void;
onSelectionChange?: (ids: string[]) => void;
}
/** Snap-to-grid configuration */
export interface GridConfig {
enabled: boolean;
size: number; // grid cell size in world pixels (default 20)
}
/** Viewport size in screen pixels */
export interface ViewportSize {
width: number;
height: number;
}
// --- Utility functions ---
/** Snap a value to the nearest grid point */
export function snap(value: number, gridSize: number): number {
return Math.round(value / gridSize) * gridSize;
}
/** Convert screen coordinates to world coordinates */
export function screenToWorld(screenX: number, screenY: number, camera: Camera): { x: number; y: number } {
return {
x: (screenX - camera.x) / camera.zoom,
y: (screenY - camera.y) / camera.zoom,
};
}
/** Convert world coordinates to screen coordinates */
export function worldToScreen(worldX: number, worldY: number, camera: Camera): { x: number; y: number } {
return {
x: worldX * camera.zoom + camera.x,
y: worldY * camera.zoom + camera.y,
};
}
/** Get the visible world-space rectangle for the current viewport */
export function getVisibleWorldRect(camera: Camera, viewport: ViewportSize): Rect {
const topLeft = screenToWorld(0, 0, camera);
const bottomRight = screenToWorld(viewport.width, viewport.height, camera);
return {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
};
}
/** Check if two rectangles intersect */
export function intersects(a: Rect, b: Rect): boolean {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
/** Filter objects to only those visible in the viewport (with margin for smooth scrolling) */
export function visibleObjects<T extends CanvasObject>(
objects: T[],
camera: Camera,
viewport: ViewportSize,
margin: number = 200
): T[] {
const worldRect = getVisibleWorldRect(camera, viewport);
// Expand by margin in world space
const expanded: Rect = {
x: worldRect.x - margin,
y: worldRect.y - margin,
width: worldRect.width + margin * 2,
height: worldRect.height + margin * 2,
};
return objects.filter(obj =>
intersects({ x: obj.x, y: obj.y, width: obj.width, height: obj.height }, expanded)
);
}
/** Clamp zoom to allowed range */
export const ZOOM_MIN = 0.1;
export const ZOOM_MAX = 3.0;
export function clampZoom(zoom: number): number {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
}

View file

@ -212,8 +212,7 @@ Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md`
Ref: `docs/retninger/arbeidsflaten.md`, `docs/features/canvas_primitiv.md`
- [~] 19.1 Canvas-primitiv Svelte-komponent: pan/zoom kamera med CSS transforms, viewport culling, pointer events (mus + touch), snap-to-grid (valgfritt), fullskjermsmodus. Ref: `docs/features/canvas_primitiv.md`.
> Påbegynt: 2026-03-18T07:13
- [x] 19.1 Canvas-primitiv Svelte-komponent: pan/zoom kamera med CSS transforms, viewport culling, pointer events (mus + touch), snap-to-grid (valgfritt), fullskjermsmodus. Ref: `docs/features/canvas_primitiv.md`.
- [ ] 19.2 BlockShell wrapper-komponent: header med tittel + fullskjerm/resize/lukk-knapper, drag-handles for repositionering, resize-handles, drop-sone rendering (highlight ved drag-over). Responsivt (min-size, max-size).
- [ ] 19.3 Arbeidsflaten layout: skriv om `/collection/[id]` fra vertikal stack til Canvas + BlockShell. Last brukerens lagrede arrangement eller bruk defaults fra samlingens traits. Persist arrangement i bruker-edge metadata. Desktop: spatial canvas, mobil: stacked/tabs. Ref: `docs/retninger/arbeidsflaten.md` § "Tre lag".
- [ ] 19.4 Kontekst-header: header tilhører flaten, viser gjeldende node som nedtrekksmeny/kontekst-velger. Mest brukte noder øverst (frekvens/recency), søkbart. Verktøymeny for å instansiere nye paneler. Ref: `docs/retninger/arbeidsflaten.md` § "Kontekst-header".