synops/reference/Editor.v1.svelte
vegard 0a467066ba Synops v2: arkitektur, retninger og dokumentasjon
Nystart basert på arkitektonisk innsikt fra Sidelinja v1.
Koden er ny, visjon og primitiver er validert gjennom tidligere arbeid.

Inneholder:
- Komplett arkitekturdokumentasjon (docs/arkitektur.md)
- 6 vedtatte retninger (docs/retninger/)
- Alle concepts, features, proposals og erfaringer fra v1
- Server-oppsett og drift (docs/setup/)
- LiteLLM-konfigurasjon (API-nøkler via env)
- Editor.svelte referanse fra v1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:43:08 +01:00

741 lines
19 KiB
Svelte

<script lang="ts">
/**
* Universell editor-komponent.
* Fase 1: Tiptap med plaintext, #-mentions, markdown formatting.
*
* Modi:
* compact — chat-input (Enter = submit, ingen synlig toolbar)
* extended — notater/lengre tekst (Enter = newline, toolbar synlig)
*
* Bruk:
* <Editor mode="compact" onSubmit={(content) => ...} placeholder="Skriv en melding..." />
* <Editor mode="extended" bind:content onUpdate={() => ...} placeholder="Skriv her..." />
*/
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Mention from '@tiptap/extension-mention';
interface Entity {
id: string;
name: string;
type: string;
aliases: string[];
}
let {
mode = 'compact',
placeholder = '',
content = $bindable(''),
autofocus = false,
onSubmit = (_html: string, _json: Record<string, unknown>, _mentions: Entity[]) => {},
onUpdate = () => {},
onExpand = () => {}
}: {
mode?: 'compact' | 'extended';
placeholder?: string;
content?: string;
autofocus?: boolean;
onSubmit?: (html: string, json: Record<string, unknown>, mentions: Entity[]) => void;
onUpdate?: () => void;
onExpand?: () => void;
} = $props();
let editorEl: HTMLDivElement;
let editor = $state<Editor | null>(null);
let mentions: Entity[] = [];
let expanded = $state(false);
let hasContent = $state(false);
let rawMode = $state(false);
let rawContent = $state('');
// Mention suggestions state
let suggestions = $state<Entity[]>([]);
let selectedIndex = $state(0);
let suggestionsEl: HTMLDivElement | undefined;
let mentionPopupPos = $state<{ top: number; left: number } | null>(null);
let mentionCommandFn: ((item: any) => void) | null = null;
const typeColors: Record<string, string> = {
person: '#3b82f6',
organisasjon: '#f59e0b',
sted: '#10b981',
tema: '#8b5cf6',
konsept: '#ec4899'
};
function getEditorContent() {
if (!editor) return { html: '', json: {} };
return {
html: editor.getHTML(),
json: editor.getJSON()
};
}
function clearEditor() {
editor?.commands.clearContent();
mentions = [];
hasContent = false;
expanded = false;
}
async function searchEntities(query: string): Promise<Entity[]> {
try {
const res = await fetch(`/api/entities?q=${encodeURIComponent(query)}&limit=8`);
if (res.ok) return await res.json();
} catch { /* ignore */ }
return [];
}
function handleSubmit() {
if (!editor || !hasContent) return;
const { html, json } = getEditorContent();
onSubmit(html, json, [...mentions]);
clearEditor();
}
onMount(() => {
editor = new Editor({
element: editorEl,
extensions: [
StarterKit,
Placeholder.configure({
placeholder
}),
Mention.configure({
HTMLAttributes: {
class: 'mention'
},
suggestion: {
char: '#',
items: async ({ query }: { query: string }) => {
if (query.length < 1) return [];
return await searchEntities(query);
},
render: () => {
return {
onStart: (props: any) => {
mentionCommandFn = props.command;
suggestions = props.items;
selectedIndex = 0;
updatePopupPosition(props.clientRect);
},
onUpdate: (props: any) => {
mentionCommandFn = props.command;
suggestions = props.items;
selectedIndex = 0;
updatePopupPosition(props.clientRect);
},
onKeyDown: (props: any) => {
if (props.event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % suggestions.length;
return true;
}
if (props.event.key === 'ArrowUp') {
selectedIndex = (selectedIndex - 1 + suggestions.length) % suggestions.length;
return true;
}
if (props.event.key === 'Enter' || props.event.key === 'Tab') {
if (suggestions.length > 0) {
mentionCommandFn?.(suggestions[selectedIndex]);
return true;
}
}
if (props.event.key === 'Escape') {
suggestions = [];
return true;
}
return false;
},
onExit: () => {
mentionCommandFn = null;
suggestions = [];
mentionPopupPos = null;
}
};
},
command: ({ editor: ed, range, props: item }: any) => {
ed.chain()
.focus()
.insertContentAt(range, [
{ type: 'mention', attrs: { id: item.id, label: item.name } },
{ type: 'text', text: ' ' }
])
.run();
mentions.push(item);
}
}
})
],
content: content || '',
editorProps: {
attributes: {
class: `editor-content ${mode}`,
'data-mode': mode
},
handleKeyDown: (_view, event) => {
// Compact mode: Enter = submit (unless shift held or suggestions open)
if (mode === 'compact' && event.key === 'Enter' && !event.shiftKey) {
if (suggestions.length > 0) return false; // Let mention handler deal with it
event.preventDefault();
handleSubmit();
return true;
}
return false;
}
},
onUpdate: ({ editor: ed }) => {
content = ed.getHTML();
hasContent = !ed.isEmpty;
onUpdate();
},
autofocus: autofocus ? 'end' : false
});
});
onDestroy(() => {
editor?.destroy();
});
function updatePopupPosition(clientRect: (() => DOMRect | null) | null) {
if (!clientRect) return;
const rect = clientRect();
if (!rect) return;
const editorRect = editorEl.getBoundingClientRect();
mentionPopupPos = {
left: rect.left - editorRect.left,
top: rect.top - editorRect.top - 4
};
}
function toggleExpand() {
expanded = !expanded;
if (expanded) onExpand();
if (!expanded) rawMode = false;
editor?.commands.focus();
}
function toggleRawMode() {
if (!editor) return;
if (rawMode) {
// Switching from raw → rendered: push textarea content into Tiptap
editor.commands.setContent(rawContent);
content = rawContent;
hasContent = !editor.isEmpty;
rawMode = false;
editor.commands.focus();
} else {
// Switching from rendered → raw: snapshot HTML into textarea
rawContent = editor.getHTML();
rawMode = true;
}
}
function handleRawInput(e: Event) {
rawContent = (e.target as HTMLTextAreaElement).value;
content = rawContent;
hasContent = rawContent.replace(/<[^>]*>/g, '').trim().length > 0;
}
function handleGlobalKeydown(e: KeyboardEvent) {
// Ctrl+/ or Cmd+/ toggles raw mode
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
toggleRawMode();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="editor-wrapper" class:compact={mode === 'compact'} class:extended={mode === 'extended'} class:expanded onkeydown={handleGlobalKeydown}>
{#if mode === 'extended' || expanded}
<div class="toolbar">
{#if !rawMode}
<button type="button" class="tool" class:active={editor?.isActive('bold')}
onclick={() => editor?.chain().focus().toggleBold().run()}
title="Bold (Ctrl+B)">B</button>
<button type="button" class="tool" class:active={editor?.isActive('italic')}
onclick={() => editor?.chain().focus().toggleItalic().run()}
title="Italic (Ctrl+I)"><em>I</em></button>
<button type="button" class="tool" class:active={editor?.isActive('strike')}
onclick={() => editor?.chain().focus().toggleStrike().run()}
title="Strikethrough"></button>
<button type="button" class="tool" class:active={editor?.isActive('code')}
onclick={() => editor?.chain().focus().toggleCode().run()}
title="Inline code">{'<>'}</button>
<span class="separator"></span>
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 1 })}
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
title="Heading 1">H1</button>
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 2 })}
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
title="Heading 2">H2</button>
<button type="button" class="tool" class:active={editor?.isActive('heading', { level: 3 })}
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
title="Heading 3">H3</button>
<span class="separator"></span>
<button type="button" class="tool" class:active={editor?.isActive('bulletList')}
onclick={() => editor?.chain().focus().toggleBulletList().run()}
title="Bullet list"></button>
<button type="button" class="tool" class:active={editor?.isActive('orderedList')}
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
title="Numbered list">1.</button>
<button type="button" class="tool" class:active={editor?.isActive('blockquote')}
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
title="Quote">&#10077;</button>
<button type="button" class="tool" class:active={editor?.isActive('codeBlock')}
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
title="Code block">{'{ }'}</button>
<span class="separator"></span>
{/if}
<button type="button" class="tool raw-toggle" class:active={rawMode}
onclick={toggleRawMode}
title="Bytt raw/rendered (Ctrl+/)">{'</>'}</button>
</div>
{/if}
<div class="editor-container">
{#if rawMode}
<textarea
class="raw-editor"
value={rawContent}
oninput={handleRawInput}
spellcheck={false}
></textarea>
{:else}
<div bind:this={editorEl} class="editor-mount"></div>
{/if}
{#if mode === 'compact' && !expanded}
<button type="button" class="expand-btn" onclick={toggleExpand} title="Utvid editor">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
</button>
<button
type="button"
class="send-btn"
onclick={handleSubmit}
disabled={!hasContent}
aria-label="Send"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
{/if}
</div>
{#if mode === 'compact' && expanded}
<div class="editor-bottom-bar">
<button type="button" class="expand-btn" onclick={toggleExpand} title="Minimer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 14 10 14 10 20"></polyline>
<polyline points="20 10 14 10 14 4"></polyline>
<line x1="14" y1="10" x2="21" y2="3"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
</button>
<button
type="button"
class="send-btn"
onclick={handleSubmit}
disabled={!hasContent}
aria-label="Send"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
</div>
{/if}
<!-- Mention suggestions popup -->
{#if suggestions.length > 0 && mentionPopupPos}
<div
class="mention-popup"
bind:this={suggestionsEl}
style:left="{mentionPopupPos.left}px"
style:bottom="calc(100% - {mentionPopupPos.top}px)"
>
{#each suggestions as entity, i (entity.id)}
<button
type="button"
class="suggestion"
class:selected={i === selectedIndex}
onmousedown={(e) => {
e.preventDefault();
mentionCommandFn?.(entity);
}}
>
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
<span class="name">{entity.name}</span>
{#if entity.aliases?.length > 0}
<span class="alias">{entity.aliases[0]}</span>
{/if}
<span class="type">{entity.type}</span>
</button>
{/each}
</div>
{/if}
</div>
<style>
.editor-wrapper {
position: relative;
}
.editor-container {
display: flex;
align-items: flex-end;
gap: 0.4rem;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
padding: 0.4rem;
}
.compact .editor-container {
align-items: center;
}
.extended .editor-container,
.expanded .editor-container {
flex-direction: column;
align-items: stretch;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.editor-mount {
flex: 1;
min-width: 0;
}
/* Tiptap editor content styling */
.editor-wrapper :global(.editor-content) {
outline: none;
color: #e1e4e8;
font-size: 0.85rem;
font-family: inherit;
line-height: 1.5;
word-break: break-word;
}
.editor-wrapper :global(.editor-content.compact) {
max-height: 120px;
overflow-y: auto;
}
.expanded :global(.editor-content.compact) {
min-height: 120px;
max-height: 40vh;
}
.editor-wrapper :global(.editor-content.extended) {
min-height: 200px;
max-height: 60vh;
overflow-y: auto;
}
/* Raw editor (source view) */
.raw-editor {
flex: 1;
min-width: 0;
min-height: 200px;
max-height: 60vh;
background: transparent;
color: #a0a8b8;
border: none;
outline: none;
resize: none;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
line-height: 1.5;
padding: 0;
tab-size: 2;
}
.compact .raw-editor {
min-height: 120px;
max-height: 40vh;
}
.editor-wrapper :global(.editor-content p) {
margin: 0;
}
.editor-wrapper :global(.editor-content p + p) {
margin-top: 0.4em;
}
/* Heading styles (extended mode) */
.editor-wrapper :global(.editor-content h1) {
font-size: 1.4rem;
font-weight: 700;
margin: 0.8em 0 0.3em;
color: #f1f3f5;
}
.editor-wrapper :global(.editor-content h2) {
font-size: 1.15rem;
font-weight: 600;
margin: 0.7em 0 0.25em;
color: #f1f3f5;
}
.editor-wrapper :global(.editor-content h3) {
font-size: 1rem;
font-weight: 600;
margin: 0.6em 0 0.2em;
color: #f1f3f5;
}
/* List styles */
.editor-wrapper :global(.editor-content ul),
.editor-wrapper :global(.editor-content ol) {
padding-left: 1.2em;
margin: 0.3em 0;
}
.editor-wrapper :global(.editor-content li) {
margin: 0.1em 0;
}
/* Blockquote */
.editor-wrapper :global(.editor-content blockquote) {
border-left: 3px solid #3b82f6;
padding-left: 0.75em;
margin: 0.4em 0;
color: #a0a8b8;
}
/* Code */
.editor-wrapper :global(.editor-content code) {
background: #1e2235;
padding: 0.15em 0.3em;
border-radius: 3px;
font-size: 0.8rem;
font-family: 'JetBrains Mono', monospace;
}
.editor-wrapper :global(.editor-content pre) {
background: #1e2235;
padding: 0.6em;
border-radius: 6px;
margin: 0.4em 0;
overflow-x: auto;
}
.editor-wrapper :global(.editor-content pre code) {
background: none;
padding: 0;
}
/* Inline formatting */
.editor-wrapper :global(.editor-content strong) {
font-weight: 700;
color: #f1f3f5;
}
.editor-wrapper :global(.editor-content em) {
font-style: italic;
}
.editor-wrapper :global(.editor-content s) {
text-decoration: line-through;
color: #8b92a5;
}
/* Mentions */
.editor-wrapper :global(.mention) {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
padding: 0.1em 0.3em;
border-radius: 3px;
font-weight: 500;
cursor: pointer;
}
.editor-wrapper :global(.mention::before) {
content: '#';
}
/* Placeholder */
.editor-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
color: #8b92a5;
float: left;
height: 0;
pointer-events: none;
}
/* Toolbar */
.toolbar {
display: flex;
gap: 0.15rem;
padding: 0.3rem 0.4rem;
background: #0f1117;
border: 1px solid #2d3148;
border-bottom: none;
border-radius: 6px 6px 0 0;
flex-wrap: wrap;
}
.tool {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 26px;
padding: 0 0.35rem;
background: none;
border: none;
border-radius: 4px;
color: #8b92a5;
font-family: inherit;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
}
.tool:hover {
background: #1e2235;
color: #e1e4e8;
}
.tool.active {
background: #1e2235;
color: #3b82f6;
}
.raw-toggle {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
letter-spacing: -0.02em;
}
.separator {
width: 1px;
height: 18px;
background: #2d3148;
align-self: center;
margin: 0 0.15rem;
}
/* Bottom bar (expanded compact mode) */
.editor-bottom-bar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.4rem 0;
}
/* Send button (compact mode) */
.send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #3b82f6;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
flex-shrink: 0;
}
.send-btn:disabled {
background: #1e2235;
color: #8b92a5;
cursor: not-allowed;
}
.send-btn:not(:disabled):hover {
background: #2563eb;
}
/* Expand button */
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: 4px;
color: #8b92a5;
cursor: pointer;
flex-shrink: 0;
}
.expand-btn:hover {
background: #1e2235;
color: #e1e4e8;
}
/* Mention popup */
.mention-popup {
position: absolute;
background: #161822;
border: 1px solid #2d3148;
border-radius: 6px;
max-height: 200px;
overflow-y: auto;
z-index: 50;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
min-width: 200px;
}
.suggestion {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.5rem;
width: 100%;
border: none;
background: none;
color: #e1e4e8;
font-family: inherit;
font-size: 0.8rem;
cursor: pointer;
text-align: left;
}
.suggestion:hover, .suggestion.selected {
background: #1e2235;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alias {
color: #8b92a5;
font-size: 0.7rem;
}
.type {
color: #8b92a5;
font-size: 0.7rem;
flex-shrink: 0;
}
</style>