Ka-Note/ka-note/client/src/lib/components/MarkdownEditor.svelte

260 lines
8.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>