diff --git a/docs/features/canvas_primitiv.md b/docs/features/canvas_primitiv.md index 317e306..c35c404 100644 --- a/docs/features/canvas_primitiv.md +++ b/docs/features/canvas_primitiv.md @@ -190,10 +190,14 @@ Storyboard-consumeren bruker `onObjectMove` til å kalle en SpacetimeDB-reducer ## 10. Implementeringsstrategi -### Fase 1: Kjerne-primitiv +### Fase 1: Kjerne-primitiv ✅ (implementert) - `` 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 - `` rendrer meldingsboks-kort på canvaset diff --git a/frontend/src/lib/components/canvas/Canvas.svelte b/frontend/src/lib/components/canvas/Canvas.svelte new file mode 100644 index 0000000..1790820 --- /dev/null +++ b/frontend/src/lib/components/canvas/Canvas.svelte @@ -0,0 +1,660 @@ + + + +
+ +
+ + {Math.round(camera.zoom * 100)}% + + +
+ + +
+ + + {#if grid.enabled} +
+ {/if} + + +
+ {#each visibleObjs as obj (obj.id)} +
+ {@render renderObject(obj)} +
+ {/each} +
+ + + {#if isLassoing && lassoRect} +
+ {/if} + + + {#if viewport.width < 768} +
+ Bruk to fingre for å panorere og zoome +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/canvas/index.ts b/frontend/src/lib/components/canvas/index.ts new file mode 100644 index 0000000..86cdc0a --- /dev/null +++ b/frontend/src/lib/components/canvas/index.ts @@ -0,0 +1,2 @@ +export { default as Canvas } from './Canvas.svelte'; +export * from './types.js'; diff --git a/frontend/src/lib/components/canvas/types.ts b/frontend/src/lib/components/canvas/types.ts new file mode 100644 index 0000000..f311421 --- /dev/null +++ b/frontend/src/lib/components/canvas/types.ts @@ -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( + 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)); +} diff --git a/tasks.md b/tasks.md index fb39514..40c1fda 100644 --- a/tasks.md +++ b/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".