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

View file

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

View file

@ -1,78 +1,59 @@
import type { BlahRichText, BlahRichTextSpanAttributes } from '@blah-im/core/richText';
import canonicalize from 'canonicalize';
import type { AttributeMap, Delta } from 'typewriter-editor';
import type { Node } from 'prosemirror-model';
function isObjectEmpty(obj: object) {
for (const _ in obj) return false;
return true;
}
function deltaAttributesToBlahRichTextSpanAttributes(
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 {
export function proseMirrorDocToBlahRichText(doc: Node): BlahRichText {
const spans: BlahRichText = [];
for (const paragraph of doc.content.content) {
if (!paragraph.type.isBlock) continue;
let lastText = '';
let lastAttributes: BlahRichTextSpanAttributes | null = null;
let canonicalizedLastAttributes: string = 'null';
for (const inline of paragraph.content.content) {
if (!inline.type.isText) continue;
function commitSpan(trim?: 'start' | 'end'): boolean {
const trimmedLastText =
trim === 'start' ? lastText.trimStart() : trim === 'end' ? lastText.trimEnd() : lastText;
if (trimmedLastText === '') return false;
spans.push(lastAttributes === null ? trimmedLastText : [trimmedLastText, lastAttributes]);
return true;
}
let isFirstSpan = true;
for (const op of delta.ops) {
// Not sure in what cases op.insert would not be a string, but let's be safe
if (typeof op.insert !== 'string') continue;
const attributes = deltaAttributesToBlahRichTextSpanAttributes(op.attributes);
const canonicalizedAttributes = canonicalize(attributes) ?? 'null';
if (canonicalizedAttributes === canonicalizedLastAttributes) {
lastText += op.insert;
continue;
const text = inline.text ?? '';
const attributes: BlahRichTextSpanAttributes = {};
const marks = inline.marks ?? [];
for (const mark of marks) {
switch (mark.type.name) {
case 'strong':
attributes.b = true;
break;
case 'em':
attributes.i = true;
break;
case 'code':
attributes.m = true;
break;
case 'link':
attributes.link = mark.attrs.href;
break;
case 'underline':
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);
if (commited) isFirstSpan = false;
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);
}
// TODO: Proper multi-paragraph support
spans.push('\n');
}
return spans;

View file

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

View file

@ -1,12 +1,12 @@
<script lang="ts">
import RichTextInput from '$lib/components/RichTextInput.svelte';
import { messageSchema } from '$lib/components/RichTextInput/schema';
// import { deltaToBlahRichText } from '$lib/richText';
import { proseMirrorDocToBlahRichText } from '$lib/richText';
import type { Node } from 'prosemirror-model';
let doc: Node | undefined = $state();
// let brt = $derived(delta ? deltaToBlahRichText(delta) : null);
let brt = $derived(doc ? proseMirrorDocToBlahRichText(doc) : null);
</script>
<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">
<h2 class="text-lg">Blah Rich Text</h2>
<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>