mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-04-30 16:21:09 +00:00
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:
parent
f5c74e02ea
commit
a08122663e
5 changed files with 87 additions and 88 deletions
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue