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:
parent
3b7dd3cb68
commit
4d03ab7271
3 changed files with 292 additions and 2 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
284
frontend/src/lib/components/DrawingInput.svelte
Normal file
284
frontend/src/lib/components/DrawingInput.svelte
Normal 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}
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue