Tegne-input: enkel canvas-basert skisseflate i chat-input (oppgave 29.10)

Ny DrawingInput-komponent som lar brukeren tegne en rask skisse direkte
fra chat-inputen. Åpner fullskjerm-canvas med fargevelger, penseltykkelse,
angre og tøm. Eksporterer som PNG → CAS → media-node med metadata
source=drawing. Følger samme mønster som VoiceRecorder/VideoRecorder.

Ikke whiteboard — dette er "post-it-skisse som input".
This commit is contained in:
vegard 2026-03-18 22:43:19 +00:00
parent 3b7dd3cb68
commit 4d03ab7271
3 changed files with 292 additions and 2 deletions

View file

@ -2,6 +2,7 @@
import VoiceRecorder from './VoiceRecorder.svelte'; import VoiceRecorder from './VoiceRecorder.svelte';
import VideoRecorder from './VideoRecorder.svelte'; import VideoRecorder from './VideoRecorder.svelte';
import LocationShare from './LocationShare.svelte'; import LocationShare from './LocationShare.svelte';
import DrawingInput from './DrawingInput.svelte';
import { uploadMedia, casUrl } from '$lib/api'; import { uploadMedia, casUrl } from '$lib/api';
interface Props { interface Props {
@ -150,6 +151,12 @@
disabled={disabled || submitting} disabled={disabled || submitting}
onerror={(msg) => { error = msg; }} onerror={(msg) => { error = msg; }}
/> />
<DrawingInput
{accessToken}
sourceId={contextId}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
/>
<button <button
onclick={handleSubmit} onclick={handleSubmit}
disabled={isEmpty || submitting || disabled} disabled={isEmpty || submitting || disabled}

View file

@ -0,0 +1,284 @@
<script lang="ts">
import { uploadMedia } from '$lib/api';
interface Props {
accessToken?: string;
onrecorded?: (result: { media_node_id: string; cas_hash: string }) => void;
onerror?: (message: string) => void;
sourceId?: string;
disabled?: boolean;
}
let { accessToken, onrecorded, onerror, sourceId, disabled = false }: Props = $props();
type DrawState = 'idle' | 'drawing' | 'uploading';
let drawState: DrawState = $state('idle');
let canvasEl: HTMLCanvasElement | undefined = $state();
let ctx: CanvasRenderingContext2D | null = $state(null);
let isPointerDown = $state(false);
let hasStrokes = $state(false);
let brushColor = $state('#000000');
let brushSize = $state(3);
// Stroke history for undo
let strokes: ImageData[] = [];
function openCanvas() {
drawState = 'drawing';
hasStrokes = false;
strokes = [];
// Wait for DOM to render the canvas
requestAnimationFrame(() => {
if (!canvasEl) return;
setupCanvas();
});
}
function setupCanvas() {
if (!canvasEl) return;
const container = canvasEl.parentElement;
if (!container) return;
// Size canvas to container
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.floor(rect.width);
const h = Math.min(Math.floor(rect.width * 0.6), 400);
canvasEl.width = w * dpr;
canvasEl.height = h * dpr;
canvasEl.style.width = w + 'px';
canvasEl.style.height = h + 'px';
ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
// White background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
function getPos(e: PointerEvent): { x: number; y: number } {
if (!canvasEl) return { x: 0, y: 0 };
const rect = canvasEl.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
function onPointerDown(e: PointerEvent) {
if (!ctx || !canvasEl) return;
e.preventDefault();
canvasEl.setPointerCapture(e.pointerId);
isPointerDown = true;
// Save state for undo
strokes.push(ctx.getImageData(0, 0, canvasEl.width, canvasEl.height));
const pos = getPos(e);
ctx.strokeStyle = brushColor;
ctx.lineWidth = brushSize;
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
}
function onPointerMove(e: PointerEvent) {
if (!isPointerDown || !ctx) return;
e.preventDefault();
const pos = getPos(e);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
}
function onPointerUp(e: PointerEvent) {
if (!isPointerDown) return;
e.preventDefault();
isPointerDown = false;
hasStrokes = true;
}
function undo() {
if (!ctx || !canvasEl || strokes.length === 0) return;
const prev = strokes.pop()!;
ctx.putImageData(prev, 0, 0);
if (strokes.length === 0) hasStrokes = false;
}
function clearCanvas() {
if (!ctx || !canvasEl) return;
const dpr = window.devicePixelRatio || 1;
const w = canvasEl.width / dpr;
const h = canvasEl.height / dpr;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
strokes = [];
hasStrokes = false;
}
function cancel() {
drawState = 'idle';
strokes = [];
}
async function exportAndUpload() {
if (!canvasEl || !accessToken) return;
drawState = 'uploading';
try {
const blob = await new Promise<Blob | null>((resolve) => {
canvasEl!.toBlob(resolve, 'image/png');
});
if (!blob) {
onerror?.('Kunne ikke eksportere tegning');
drawState = 'drawing';
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
const file = new File([blob], `skisse-${timestamp}.png`, { type: 'image/png' });
const result = await uploadMedia(accessToken, {
file,
title: 'Skisse',
visibility: 'hidden',
source_id: sourceId,
metadata_extra: { source: 'drawing' },
});
onrecorded?.({
media_node_id: result.media_node_id,
cas_hash: result.cas_hash,
});
drawState = 'idle';
strokes = [];
} catch (e) {
onerror?.(e instanceof Error ? e.message : 'Feil ved opplasting av tegning');
drawState = 'drawing';
}
}
const colors = ['#000000', '#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6'];
</script>
{#if drawState === 'idle'}
<button
onclick={openCanvas}
disabled={disabled || !accessToken}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-amber-50 hover:text-amber-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Tegn en skisse"
title="Tegn en skisse"
>
<!-- Pencil icon -->
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
{:else if drawState === 'uploading'}
<div class="flex items-center gap-1.5">
<svg class="h-4 w-4 animate-spin text-amber-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span class="text-xs text-amber-600">Laster opp skisse…</span>
</div>
{/if}
{#if drawState === 'drawing'}
<!-- Fullscreen overlay for drawing -->
<div class="fixed inset-0 z-50 flex flex-col bg-white">
<!-- Toolbar -->
<div class="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<div class="flex items-center gap-2">
<button
onclick={cancel}
class="rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
>
Avbryt
</button>
</div>
<span class="text-sm font-medium text-gray-700">Skisse</span>
<div class="flex items-center gap-2">
<button
onclick={undo}
disabled={!hasStrokes}
class="rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-30"
title="Angre"
>
<!-- Undo icon -->
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a5 5 0 015 5v2M3 10l4-4M3 10l4 4" />
</svg>
</button>
<button
onclick={clearCanvas}
disabled={!hasStrokes}
class="rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-30"
title="Tøm"
>
Tøm
</button>
<button
onclick={exportAndUpload}
disabled={!hasStrokes}
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-40"
>
Send
</button>
</div>
</div>
<!-- Color & size picker -->
<div class="flex items-center gap-3 border-b border-gray-100 px-3 py-2">
<div class="flex items-center gap-1">
{#each colors as color}
<button
onclick={() => { brushColor = color; }}
class="h-6 w-6 rounded-full border-2 transition-transform"
class:scale-110={brushColor === color}
class:border-gray-400={brushColor === color}
class:border-transparent={brushColor !== color}
style="background-color: {color}"
aria-label="Farge {color}"
></button>
{/each}
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">Tykkelse</span>
<input
type="range"
min="1"
max="20"
bind:value={brushSize}
class="h-1 w-20 accent-gray-600"
/>
<span class="w-5 text-right text-xs text-gray-500">{brushSize}</span>
</div>
</div>
<!-- Canvas area -->
<div class="flex flex-1 items-center justify-center bg-gray-50 p-4">
<div class="w-full max-w-2xl rounded-lg border border-gray-200 bg-white shadow-sm">
<canvas
bind:this={canvasEl}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointerleave={onPointerUp}
class="touch-none cursor-crosshair rounded-lg"
style="display: block; width: 100%;"
></canvas>
</div>
</div>
</div>
{/if}

View file

@ -409,8 +409,7 @@ noden er det som lever videre.
- [x] 29.9 Lokasjon-input: "Del posisjon"-knapp i input-komponenten → Geolocation API → node med `metadata.location: { "lat": 59.91, "lon": 10.75 }`. Kart-visning i node-detaljer (Leaflet/OpenStreetMap). Valgfritt: reverse geocoding via Nominatim for adresse. - [x] 29.9 Lokasjon-input: "Del posisjon"-knapp i input-komponenten → Geolocation API → node med `metadata.location: { "lat": 59.91, "lon": 10.75 }`. Kart-visning i node-detaljer (Leaflet/OpenStreetMap). Valgfritt: reverse geocoding via Nominatim for adresse.
### Håndskrift/tegning ### Håndskrift/tegning
- [~] 29.10 Tegne-input: enkel canvas-basert tegneflate i input-komponenten. Eksporter som PNG → CAS → media-node. Ikke whiteboard (det er et eget verktøy) — dette er "rask skisse som input", som en post-it. - [x] 29.10 Tegne-input: enkel canvas-basert tegneflate i input-komponenten. Eksporter som PNG → CAS → media-node. Ikke whiteboard (det er et eget verktøy) — dette er "rask skisse som input", som en post-it.
> Påbegynt: 2026-03-18T22:40
### Kalender-import ### Kalender-import
- [ ] 29.11 ICS-import: `synops-calendar` CLI som parser ICS-fil og oppretter noder med `scheduled`-edges. Input: `--file <ics> --collection-id <uuid>`. Duplikatdeteksjon via UID. Oppdatering ved re-import. - [ ] 29.11 ICS-import: `synops-calendar` CLI som parser ICS-fil og oppretter noder med `scheduled`-edges. Input: `--file <ics> --collection-id <uuid>`. Duplikatdeteksjon via UID. Oppdatering ved re-import.