260 lines
8.6 KiB
Svelte
260 lines
8.6 KiB
Svelte
<script lang="ts">
|
||
import { onMount, onDestroy, tick } from 'svelte';
|
||
import { Editor } from '@tiptap/core';
|
||
import StarterKit from '@tiptap/starter-kit';
|
||
import Image from '@tiptap/extension-image';
|
||
import Link from '@tiptap/extension-link';
|
||
import Placeholder from '@tiptap/extension-placeholder';
|
||
import { Markdown } from 'tiptap-markdown';
|
||
import { TiptapMention } from '$lib/editor/tiptapMention';
|
||
import { createTiptapWikiLink } from '$lib/editor/tiptapWikiLink';
|
||
import { TiptapSlashCommand } from '$lib/editor/tiptapSlashCommand';
|
||
import { storeImage, getImageUrl, resolveImageUrls, objectUrlToKaImg } from '$lib/db/imageStore';
|
||
import BubbleMenu from '@tiptap/extension-bubble-menu';
|
||
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
||
import '$lib/editor/editor.css';
|
||
|
||
interface Props {
|
||
content?: string;
|
||
placeholder?: string;
|
||
minHeight?: string;
|
||
/** true = private pages only, false = business only, null/undefined = all */
|
||
wikiScope?: boolean | null;
|
||
onchange?: (markdown: string) => void;
|
||
onsave?: () => void;
|
||
onsavearchive?: () => void;
|
||
}
|
||
let { content = '', placeholder = '', minHeight = '80px', wikiScope = null, onchange, onsave, onsavearchive }: Props = $props();
|
||
|
||
let element: HTMLDivElement;
|
||
let bubbleMenuEl: HTMLDivElement;
|
||
let tableMenuEl: HTMLDivElement;
|
||
let wrapper: HTMLDivElement;
|
||
let editor: Editor | null = null;
|
||
let skipNextUpdate = false;
|
||
let lastExternalContent = $state(content);
|
||
// Gate bubble menus: only show after user has intentionally selected text.
|
||
// Uses a debounce timer to ignore iOS transient selections triggered by a tap.
|
||
let hasSelection = false;
|
||
let selectionTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
export function getMarkdown(): string {
|
||
if (!editor) return '';
|
||
const raw = editor.storage.markdown.getMarkdown();
|
||
return objectUrlToKaImg(raw);
|
||
}
|
||
|
||
export function clear() {
|
||
if (!editor) return;
|
||
editor.commands.clearContent(true);
|
||
}
|
||
|
||
function emitChange() {
|
||
if (!editor || skipNextUpdate) {
|
||
skipNextUpdate = false;
|
||
return;
|
||
}
|
||
const md = getMarkdown();
|
||
onchange?.(md);
|
||
}
|
||
|
||
function handlePaste(_view: any, event: ClipboardEvent): boolean {
|
||
const items = event.clipboardData?.items;
|
||
if (!items) return false;
|
||
|
||
for (const item of items) {
|
||
if (item.type.startsWith('image/')) {
|
||
event.preventDefault();
|
||
const blob = item.getAsFile();
|
||
if (blob) {
|
||
storeImage(blob)
|
||
.then((id) => getImageUrl(id))
|
||
.then((url) => {
|
||
if (url && editor) {
|
||
editor.chain().focus().setImage({ src: url }).run();
|
||
}
|
||
});
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
onMount(async () => {
|
||
let initialContent = content;
|
||
if (content.includes('ka-img:')) {
|
||
initialContent = await resolveImageUrls(content);
|
||
}
|
||
|
||
// Wait for bind:this refs (bubbleMenuEl, tableMenuEl, wrapper) to be in DOM
|
||
await tick();
|
||
|
||
editor = new Editor({
|
||
element,
|
||
extensions: [
|
||
StarterKit,
|
||
Image.configure({ inline: true, allowBase64: false }),
|
||
Link.configure({ autolink: true, openOnClick: false }),
|
||
Placeholder.configure({ placeholder }),
|
||
Markdown.configure({ html: false, transformCopiedText: true, transformPastedText: true }),
|
||
TiptapMention,
|
||
createTiptapWikiLink(wikiScope),
|
||
TiptapSlashCommand,
|
||
Table.configure({ resizable: false }),
|
||
TableRow,
|
||
TableCell,
|
||
TableHeader,
|
||
BubbleMenu.configure({
|
||
element: bubbleMenuEl,
|
||
shouldShow: ({ editor: e, from, to }) => {
|
||
if (!hasSelection) return false;
|
||
if (e.isActive('image')) return false;
|
||
if (e.isActive('table')) return false;
|
||
return from !== to;
|
||
},
|
||
}),
|
||
BubbleMenu.configure({
|
||
element: tableMenuEl,
|
||
pluginKey: 'tableMenu',
|
||
shouldShow: ({ editor: e }) => {
|
||
if (!hasSelection) return false;
|
||
return e.isActive('table');
|
||
},
|
||
}),
|
||
],
|
||
content: initialContent,
|
||
editorProps: {
|
||
handlePaste,
|
||
handleKeyDown: (_view, event) => {
|
||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
|
||
onsavearchive?.();
|
||
return true;
|
||
}
|
||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||
onsave?.();
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
attributes: {
|
||
style: `min-height: ${minHeight}`
|
||
}
|
||
},
|
||
onUpdate: () => emitChange(),
|
||
onSelectionUpdate: ({ editor: e }) => {
|
||
const { from, to, empty } = e.state.selection;
|
||
if (selectionTimer) clearTimeout(selectionTimer);
|
||
// Only count as intentional selection if range spans at least 2 chars.
|
||
// Filters out iOS tap-induced caret placements (from === to or range of 1).
|
||
if (!empty && to - from >= 2) {
|
||
selectionTimer = setTimeout(() => {
|
||
hasSelection = true;
|
||
if (!e.isActive('table')) bubbleMenuEl?.classList.add('ka-bubble-active');
|
||
if (e.isActive('table')) tableMenuEl?.classList.add('ka-bubble-active');
|
||
}, 50);
|
||
} else if (e.isActive('table')) {
|
||
// Table cursor (caret inside table) — show table menu without text selection
|
||
hasSelection = true;
|
||
tableMenuEl?.classList.add('ka-bubble-active');
|
||
bubbleMenuEl?.classList.remove('ka-bubble-active');
|
||
} else {
|
||
hasSelection = false;
|
||
bubbleMenuEl?.classList.remove('ka-bubble-active');
|
||
tableMenuEl?.classList.remove('ka-bubble-active');
|
||
}
|
||
},
|
||
onBlur: () => {
|
||
if (selectionTimer) clearTimeout(selectionTimer);
|
||
hasSelection = false;
|
||
bubbleMenuEl?.classList.remove('ka-bubble-active');
|
||
tableMenuEl?.classList.remove('ka-bubble-active');
|
||
},
|
||
onTransaction: ({ editor: e }) => {
|
||
// Force Svelte reactivity by reassigning
|
||
editor = e;
|
||
}
|
||
});
|
||
|
||
// Bind to DOM — Tiptap needs this for Svelte
|
||
element.querySelector('.ProseMirror')?.setAttribute('data-ready', 'true');
|
||
});
|
||
|
||
// React to external content changes
|
||
$effect(() => {
|
||
if (!editor || content === lastExternalContent) return;
|
||
lastExternalContent = content;
|
||
|
||
const currentMd = getMarkdown();
|
||
if (content === currentMd) return;
|
||
|
||
skipNextUpdate = true;
|
||
if (content.includes('ka-img:')) {
|
||
resolveImageUrls(content).then(resolved => {
|
||
editor?.commands.setContent(resolved);
|
||
});
|
||
} else {
|
||
editor.commands.setContent(content);
|
||
}
|
||
});
|
||
|
||
onDestroy(() => {
|
||
if (selectionTimer) clearTimeout(selectionTimer);
|
||
editor?.destroy();
|
||
});
|
||
</script>
|
||
|
||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||
<div class="ka-editor-wrapper" bind:this={wrapper} onclick={(e) => e.stopPropagation()}>
|
||
<div bind:this={element}></div>
|
||
<div bind:this={tableMenuEl} class="ka-bubble-menu ka-table-menu">
|
||
<span class="bubble-label">Zeile</span>
|
||
<button class="bubble-btn" title="Zeile darunter einfügen"
|
||
onmousedown={(e) => { e.preventDefault(); editor?.commands.addRowAfter(); }}
|
||
>+</button>
|
||
<button class="bubble-btn" title="Zeile löschen"
|
||
onmousedown={(e) => { e.preventDefault(); editor?.commands.deleteRow(); }}
|
||
>−</button>
|
||
<span class="bubble-sep"></span>
|
||
<span class="bubble-label">Spalte</span>
|
||
<button class="bubble-btn" title="Spalte rechts einfügen"
|
||
onmousedown={(e) => { e.preventDefault(); editor?.commands.addColumnAfter(); }}
|
||
>+</button>
|
||
<button class="bubble-btn" title="Spalte löschen"
|
||
onmousedown={(e) => { e.preventDefault(); editor?.commands.deleteColumn(); }}
|
||
>−</button>
|
||
<span class="bubble-sep"></span>
|
||
<button class="bubble-btn bubble-btn-danger" title="Tabelle löschen"
|
||
onmousedown={(e) => { e.preventDefault(); editor?.commands.deleteTable(); }}
|
||
>✕</button>
|
||
</div>
|
||
<div bind:this={bubbleMenuEl} class="ka-bubble-menu">
|
||
<button class="bubble-btn"
|
||
class:bubble-btn-active={editor?.isActive('bold')}
|
||
onpointerdown={(e) => { e.preventDefault(); editor?.chain().focus().toggleBold().run(); }}
|
||
title="Bold"
|
||
>B</button>
|
||
<button class="bubble-btn bubble-btn-italic"
|
||
class:bubble-btn-active={editor?.isActive('italic')}
|
||
onpointerdown={(e) => { e.preventDefault(); editor?.chain().focus().toggleItalic().run(); }}
|
||
title="Italic"
|
||
>I</button>
|
||
<button class="bubble-btn"
|
||
class:bubble-btn-active={editor?.isActive('heading', { level: 2 })}
|
||
onpointerdown={(e) => { e.preventDefault(); editor?.chain().focus().toggleHeading({ level: 2 }).run(); }}
|
||
title="Heading 2"
|
||
>H2</button>
|
||
<button class="bubble-btn"
|
||
class:bubble-btn-active={editor?.isActive('link')}
|
||
onpointerdown={(e) => {
|
||
e.preventDefault();
|
||
const href = editor?.getAttributes('link').href ?? '';
|
||
const url = window.prompt('URL:', href);
|
||
if (url) editor?.chain().focus().setLink({ href: url }).run();
|
||
else if (url === '') editor?.chain().focus().unsetLink().run();
|
||
}}
|
||
title="Link"
|
||
>↗</button>
|
||
</div>
|
||
</div>
|