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:
parent
e1c2a0cd08
commit
3be2a57f88
5 changed files with 790 additions and 4 deletions
|
|
@ -190,10 +190,14 @@ Storyboard-consumeren bruker `onObjectMove` til å kalle en SpacetimeDB-reducer
|
||||||
|
|
||||||
## 10. Implementeringsstrategi
|
## 10. Implementeringsstrategi
|
||||||
|
|
||||||
### Fase 1: Kjerne-primitiv
|
### Fase 1: Kjerne-primitiv ✅ (implementert)
|
||||||
- `<Canvas>` Svelte-komponent med kamera (pan/zoom), viewport culling, og objekt-drag
|
- `<Canvas>` Svelte-komponent med kamera (pan/zoom), viewport culling, og objekt-drag
|
||||||
- Touch-støtte (pinch-zoom, to-finger-pan)
|
- 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
|
### Fase 2: Storyboard som første consumer
|
||||||
- `<StoryboardCard>` rendrer meldingsboks-kort på canvaset
|
- `<StoryboardCard>` rendrer meldingsboks-kort på canvaset
|
||||||
|
|
|
||||||
660
frontend/src/lib/components/canvas/Canvas.svelte
Normal file
660
frontend/src/lib/components/canvas/Canvas.svelte
Normal 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>
|
||||||
2
frontend/src/lib/components/canvas/index.ts
Normal file
2
frontend/src/lib/components/canvas/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as Canvas } from './Canvas.svelte';
|
||||||
|
export * from './types.js';
|
||||||
121
frontend/src/lib/components/canvas/types.ts
Normal file
121
frontend/src/lib/components/canvas/types.ts
Normal 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));
|
||||||
|
}
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -212,8 +212,7 @@ Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md`
|
||||||
|
|
||||||
Ref: `docs/retninger/arbeidsflaten.md`, `docs/features/canvas_primitiv.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`.
|
- [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`.
|
||||||
> Påbegynt: 2026-03-18T07:13
|
|
||||||
- [ ] 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.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.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".
|
- [ ] 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".
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue