mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-05-01 00:31:08 +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 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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,79 +1,60 @@
|
||||||
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;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if (isObjectEmpty(attributes)) {
|
||||||
const commited = commitSpan(trim && isFirstSpan ? 'start' : undefined);
|
spans.push(text);
|
||||||
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 {
|
} else {
|
||||||
lastSpan[0] = lastSpan[0].trimEnd();
|
spans.push([text, attributes]);
|
||||||
if (lastSpan[0] !== '') spans.push(lastSpan);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Proper multi-paragraph support
|
||||||
|
spans.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
return spans;
|
return spans;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Reference in a new issue