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
|
||||
|
||||
### 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
|
||||
|
|
|
|||
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`
|
||||
|
||||
- [~] 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".
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue