synops/frontend/src/lib/components/NodeEditor.svelte
vegard daaafd34ab TipTap-editor med create_node-intensjon (oppgave 3.5)
Legger til en TipTap-basert rik tekst-editor i mottaksflaten som sender
create_node-intensjoner til maskinrommet ved submit.

- TipTap med StarterKit (tekst, markdown), Link-extension og Placeholder
- NodeEditor.svelte: tittel + innhold, Ctrl+Enter for submit
- API-klient (lib/api.ts) som kaller maskinrommet via /api proxy
- Authentik access_token eksponert i session for API-kall
- Vite proxy rewrite fikset (/api → maskinrommet root)
- HTML-innhold lagres i metadata.document, ren tekst i content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:24:25 +01:00

124 lines
3.2 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
interface Props {
onsubmit: (data: { title: string; content: string; html: string }) => Promise<void>;
disabled?: boolean;
}
let { onsubmit, disabled = false }: Props = $props();
let editorElement: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state();
let title = $state('');
let submitting = $state(false);
let error = $state('');
onMount(() => {
editor = new Editor({
element: editorElement!,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
horizontalRule: false
}),
Link.configure({
openOnClick: false,
HTMLAttributes: { class: 'text-blue-600 underline' }
}),
Placeholder.configure({
placeholder: 'Skriv noe…'
})
],
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[80px] px-3 py-2'
}
},
onTransaction: () => {
// Force Svelte reactivity
editor = editor;
}
});
});
onDestroy(() => {
editor?.destroy();
});
const isEmpty = $derived(!title.trim() && (!editor || editor.isEmpty));
async function handleSubmit() {
if (isEmpty || submitting || disabled) return;
submitting = true;
error = '';
try {
const content = editor?.getText() ?? '';
const html = editor?.getHTML() ?? '';
await onsubmit({ title: title.trim(), content, html });
// Clear editor on success
title = '';
editor?.commands.clearContent();
} catch (e) {
error = e instanceof Error ? e.message : 'Noe gikk galt';
} finally {
submitting = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="rounded-lg border border-gray-200 bg-white shadow-sm" onkeydown={handleKeydown}>
<input
type="text"
bind:value={title}
placeholder="Tittel (valgfritt)"
disabled={disabled || submitting}
class="w-full border-b border-gray-100 bg-transparent px-3 py-2 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:outline-none"
/>
<div bind:this={editorElement}></div>
{#if error}
<p class="px-3 py-1 text-xs text-red-600">{error}</p>
{/if}
<div class="flex items-center justify-between border-t border-gray-100 px-3 py-2">
<span class="text-xs text-gray-400">
{#if editor}
Markdown · Ctrl+B/I · Ctrl+Enter for å sende
{/if}
</span>
<button
onclick={handleSubmit}
disabled={isEmpty || submitting || disabled}
class="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? 'Sender…' : 'Opprett node'}
</button>
</div>
</div>
<style>
:global(.tiptap p.is-editor-empty:first-child::before) {
color: #9ca3af;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
</style>