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

142 lines
3.7 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } 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 { storeImage, getImageUrl, resolveImageUrls, objectUrlToKaImg } from '$lib/db/imageStore';
interface Props {
content?: string;
placeholder?: string;
minHeight?: string;
onchange?: (markdown: string) => void;
onsave?: () => void;
onsavearchive?: () => void;
}
let { content = '', placeholder = '', minHeight = '80px', onchange, onsave, onsavearchive }: Props = $props();
let element: HTMLDivElement;
let editor: Editor | null = null;
let skipNextUpdate = false;
let lastExternalContent = $state(content);
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);
}
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
],
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(),
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(() => {
editor?.destroy();
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="ka-editor-wrapper" onclick={(e) => e.stopPropagation()}>
<div bind:this={element}></div>
</div>