refactor: Message input now use ProseMirror directly

Remove Delta/Typewriter-editor dependency and implement ProseMirror-based
rich text conversion. Add methods to access the editor view and improve
the architecture of text input components.
This commit is contained in:
Shibo Lyu 2025-04-12 02:48:45 +08:00
parent f5c74e02ea
commit a08122663e
5 changed files with 87 additions and 88 deletions

View file

@ -3,6 +3,9 @@
import InputFrame from '$lib/components/InputFrame.svelte'; import InputFrame from '$lib/components/InputFrame.svelte';
import type { Props as ClientInputProps } from './RichTextInput/ClientInput.svelte'; import type { Props as ClientInputProps } from './RichTextInput/ClientInput.svelte';
import { tw } from '$lib/tw'; import { tw } from '$lib/tw';
import type { EditorView } from 'prosemirror-view';
import type { Component } from 'svelte';
import ClientInput from './RichTextInput/ClientInput.svelte';
interface Props extends ClientInputProps { interface Props extends ClientInputProps {
class?: string; class?: string;
@ -15,6 +18,11 @@
const { default: ClientInput } = await import('./RichTextInput/ClientInput.svelte'); const { default: ClientInput } = await import('./RichTextInput/ClientInput.svelte');
return ClientInput; return ClientInput;
}; };
let clientInput: ReturnType<typeof ClientInput> | null = $state(null);
export function getEditorView(): EditorView | null {
return clientInput?.getEditorView() ?? null;
}
</script> </script>
<InputFrame class={tw('overflow-y-auto', className)}> <InputFrame class={tw('overflow-y-auto', className)}>
@ -27,7 +35,7 @@
{/if} {/if}
</div> </div>
{:then ClientInput} {:then ClientInput}
<ClientInput {placeholder} {...clientInputProps}> <ClientInput {placeholder} {...clientInputProps} bind:this={clientInput}>
{@render children?.()} {@render children?.()}
</ClientInput> </ClientInput>
{/await} {/await}

View file

@ -13,6 +13,7 @@
const { onDocChange, placeholder = '', children, ...stateConfiguration }: Props = $props(); const { onDocChange, placeholder = '', children, ...stateConfiguration }: Props = $props();
let domEl: HTMLDivElement; let domEl: HTMLDivElement;
let editorView: EditorView;
let isEmpty = $state(!children); let isEmpty = $state(!children);
@ -21,26 +22,29 @@
domEl.replaceChildren(); domEl.replaceChildren();
onDocChange?.(initialDoc); onDocChange?.(initialDoc);
let state = createProseMirrorEditorState({ initialDoc, ...stateConfiguration }); const state = createProseMirrorEditorState({ initialDoc, ...stateConfiguration });
let view = new EditorView( editorView = new EditorView(
{ mount: domEl }, { mount: domEl },
{ {
state, state,
dispatchTransaction: (tr) => { dispatchTransaction: (tr) => {
state = state.apply(tr); const newState = state.apply(tr);
view.updateState(state); editorView.updateState(newState);
onDocChange?.(state.doc); onDocChange?.(newState.doc);
const doc = state.doc; isEmpty = newState.doc.textContent.length === 0;
isEmpty = doc.textContent.length === 0;
} }
} }
); );
return () => { return () => {
view.destroy(); editorView.destroy();
}; };
}); });
export function getEditorView(): EditorView | null {
return editorView;
}
</script> </script>
<div <div

View file

@ -1,78 +1,59 @@
import type { BlahRichText, BlahRichTextSpanAttributes } from '@blah-im/core/richText'; import type { BlahRichText, BlahRichTextSpanAttributes } from '@blah-im/core/richText';
import canonicalize from 'canonicalize'; import type { Node } from 'prosemirror-model';
import type { AttributeMap, Delta } from 'typewriter-editor';
function isObjectEmpty(obj: object) { function isObjectEmpty(obj: object) {
for (const _ in obj) return false; for (const _ in obj) return false;
return true; return true;
} }
function deltaAttributesToBlahRichTextSpanAttributes( export function proseMirrorDocToBlahRichText(doc: Node): BlahRichText {
attributes?: AttributeMap
): BlahRichTextSpanAttributes | null {
if (!attributes) return null;
const blahRichTextSpanAttributes: BlahRichTextSpanAttributes = {};
if (attributes.bold) blahRichTextSpanAttributes.b = true;
if (attributes.italic) blahRichTextSpanAttributes.i = true;
if (attributes.code) blahRichTextSpanAttributes.m = true;
if (attributes.link) blahRichTextSpanAttributes.link = attributes.link;
if (attributes.underline) blahRichTextSpanAttributes.u = true;
if (attributes.strikethrough) blahRichTextSpanAttributes.s = true;
return isObjectEmpty(blahRichTextSpanAttributes) ? null : blahRichTextSpanAttributes;
}
export function deltaToBlahRichText(delta: Delta, trim: boolean = true): BlahRichText {
const spans: BlahRichText = []; const spans: BlahRichText = [];
for (const paragraph of doc.content.content) {
if (!paragraph.type.isBlock) continue;
let lastText = ''; for (const inline of paragraph.content.content) {
let lastAttributes: BlahRichTextSpanAttributes | null = null; if (!inline.type.isText) continue;
let canonicalizedLastAttributes: string = 'null';
function commitSpan(trim?: 'start' | 'end'): boolean { const text = inline.text ?? '';
const trimmedLastText = const attributes: BlahRichTextSpanAttributes = {};
trim === 'start' ? lastText.trimStart() : trim === 'end' ? lastText.trimEnd() : lastText; const marks = inline.marks ?? [];
if (trimmedLastText === '') return false; for (const mark of marks) {
spans.push(lastAttributes === null ? trimmedLastText : [trimmedLastText, lastAttributes]); switch (mark.type.name) {
return true; case 'strong':
} attributes.b = true;
break;
let isFirstSpan = true; case 'em':
for (const op of delta.ops) { attributes.i = true;
// Not sure in what cases op.insert would not be a string, but let's be safe break;
if (typeof op.insert !== 'string') continue; case 'code':
attributes.m = true;
const attributes = deltaAttributesToBlahRichTextSpanAttributes(op.attributes); break;
const canonicalizedAttributes = canonicalize(attributes) ?? 'null'; case 'link':
attributes.link = mark.attrs.href;
if (canonicalizedAttributes === canonicalizedLastAttributes) { break;
lastText += op.insert; case 'underline':
continue; attributes.u = true;
break;
case 'strikethrough':
attributes.s = true;
break;
case 'tag':
attributes.tag = true;
break;
case 'spoiler':
attributes.spoiler = true;
break;
}
}
if (isObjectEmpty(attributes)) {
spans.push(text);
} else {
spans.push([text, attributes]);
}
} }
const commited = commitSpan(trim && isFirstSpan ? 'start' : undefined); // TODO: Proper multi-paragraph support
if (commited) isFirstSpan = false; spans.push('\n');
lastText = op.insert;
lastAttributes = attributes;
canonicalizedLastAttributes = canonicalizedAttributes;
}
const lastCommited = commitSpan(trim ? 'end' : undefined);
if (trim && !lastCommited) {
// The last segment is empty, so we need to trim the one before it
let lastSpan = spans.pop();
if (!lastSpan) return spans;
if (typeof lastSpan === 'string') {
lastSpan = lastSpan.trimEnd();
if (lastSpan !== '') spans.push(lastSpan);
} else {
lastSpan[0] = lastSpan[0].trimEnd();
if (lastSpan[0] !== '') spans.push(lastSpan);
}
} }
return spans; return spans;

View file

@ -2,8 +2,9 @@
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import RichTextInput from '$lib/components/RichTextInput.svelte'; import RichTextInput from '$lib/components/RichTextInput.svelte';
import type { BlahRichText } from '@blah-im/core/richText'; import type { BlahRichText } from '@blah-im/core/richText';
import { deltaToBlahRichText } from '$lib/richText'; import { proseMirrorDocToBlahRichText } from '$lib/richText';
import type { Delta, Editor } from 'typewriter-editor'; import type { Node } from 'prosemirror-model';
import { messageSchema } from '$lib/components/RichTextInput/schema';
let { let {
onSendMessage onSendMessage
@ -11,25 +12,30 @@
onSendMessage: (message: BlahRichText) => void; onSendMessage: (message: BlahRichText) => void;
} = $props(); } = $props();
let editor: Editor | undefined = $state();
let delta: Delta | undefined = $state();
let plainText: string = $state('');
let form: HTMLFormElement | null = $state(null); let form: HTMLFormElement | null = $state(null);
function onKeyboardSubmit() { let doc: Node | null = $state(null);
editor?.select(null); let input: ReturnType<typeof RichTextInput>;
function onKeyboardSubmit(newDoc: Node) {
doc = newDoc;
form?.requestSubmit(); form?.requestSubmit();
} }
async function submit(event: SubmitEvent) { async function submit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (plainText.trim() === '' || !delta) return; if (!doc || doc.textContent.trim() === '') return;
const brt = deltaToBlahRichText(delta); const brt = proseMirrorDocToBlahRichText(doc);
onSendMessage(brt); onSendMessage(brt);
plainText = ''; const view = input.getEditorView();
if (view) {
const tr = view.state.tr;
tr.delete(0, view.state.doc.content.size);
view.dispatch(tr);
}
} }
</script> </script>
@ -56,13 +62,13 @@
<span class="sr-only">Attach</span> <span class="sr-only">Attach</span>
</Button> </Button>
<RichTextInput <RichTextInput
bind:editor bind:this={input}
bind:delta schema={messageSchema}
bind:plainText
placeholder="Message" placeholder="Message"
class="max-h-40 flex-1" class="max-h-40 flex-1"
keyboardSubmitMethod="enter" keyboardSubmitMethod="enter"
{onKeyboardSubmit} {onKeyboardSubmit}
onDocChange={(newDoc) => (doc = newDoc)}
/> />
<Button class="p-1.5" variant="primary" type="submit"> <Button class="p-1.5" variant="primary" type="submit">
<svg <svg

View file

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import RichTextInput from '$lib/components/RichTextInput.svelte'; import RichTextInput from '$lib/components/RichTextInput.svelte';
import { messageSchema } from '$lib/components/RichTextInput/schema'; import { messageSchema } from '$lib/components/RichTextInput/schema';
// import { deltaToBlahRichText } from '$lib/richText'; import { proseMirrorDocToBlahRichText } from '$lib/richText';
import type { Node } from 'prosemirror-model'; import type { Node } from 'prosemirror-model';
let doc: Node | undefined = $state(); let doc: Node | undefined = $state();
// let brt = $derived(delta ? deltaToBlahRichText(delta) : null); let brt = $derived(doc ? proseMirrorDocToBlahRichText(doc) : null);
</script> </script>
<RichTextInput schema={messageSchema} onDocChange={(newDoc) => (doc = newDoc)} class="m-4 max-h-32"> <RichTextInput schema={messageSchema} onDocChange={(newDoc) => (doc = newDoc)} class="m-4 max-h-32">
@ -25,7 +25,7 @@
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<h2 class="text-lg">Blah Rich Text</h2> <h2 class="text-lg">Blah Rich Text</h2>
<div class="min-h-0 flex-1 overflow-auto select-text"> <div class="min-h-0 flex-1 overflow-auto select-text">
<!-- <pre><code>{JSON.stringify(brt, null, 2)}</code></pre> --> <pre><code>{JSON.stringify(brt, null, 2)}</code></pre>
</div> </div>
</div> </div>
</div> </div>