refactor: Replace Typewriter editor with ProseMirror

Switch the rich text editor implementation from Typewriter to
ProseMirror for better document model and editing capabilities.
This commit is contained in:
Shibo Lyu 2025-04-11 02:58:06 +08:00
parent 48f4721e5b
commit db186636a3
7 changed files with 84 additions and 157 deletions

View file

@ -1,30 +1,14 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import type { Delta, Editor } from 'typewriter-editor';
import InputFrame from '$lib/components/InputFrame.svelte'; import InputFrame from '$lib/components/InputFrame.svelte';
import type { Props as ClientInputProps } from './RichTextInput/ClientInput.svelte';
import { tw } from '$lib/tw'; import { tw } from '$lib/tw';
interface Props { interface Props extends ClientInputProps {
delta?: Delta;
plainText?: string;
keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined;
onKeyboardSubmit?: () => void;
placeholder?: string;
editor?: Editor;
class?: string; class?: string;
children?: import('svelte').Snippet;
} }
let { let { class: className = '', placeholder, children, ...clientInputProps }: Props = $props();
delta = $bindable(undefined),
plainText = $bindable(undefined),
keyboardSubmitMethod = undefined,
onKeyboardSubmit,
placeholder = '',
editor = $bindable(),
class: className = '',
children
}: Props = $props();
const loadClientComponent = async () => { const loadClientComponent = async () => {
if (!browser) return; if (!browser) return;
@ -35,18 +19,15 @@
<InputFrame class={tw('overflow-y-auto', className)}> <InputFrame class={tw('overflow-y-auto', className)}>
{#await loadClientComponent()} {#await loadClientComponent()}
<div class="rich-text opacity-50"> <div class="rich-text">
<p>{placeholder}</p> {#if children}
{@render children()}
{:else}
<p class="opacity-50">{placeholder}</p>
{/if}
</div> </div>
{:then ClientInput} {:then ClientInput}
<ClientInput <ClientInput {placeholder} {...clientInputProps}>
bind:delta
bind:plainText
{placeholder}
bind:editor
{keyboardSubmitMethod}
{onKeyboardSubmit}
>
{@render children?.()} {@render children?.()}
</ClientInput> </ClientInput>
{/await} {/await}

View file

@ -1,84 +1,64 @@
<script lang="ts"> <script lang="ts">
import { Delta, Editor, asRoot, h } from 'typewriter-editor'; import { DOMParser, type Node } from 'prosemirror-model';
import { keyboardSubmit } from './keyboardSubmitModule'; import { createProseMirrorEditorState, type EditorStateConfiguration } from './editorState';
import { tw } from '$lib/tw';
import { EditorView } from 'prosemirror-view';
interface Props { export interface Props extends Omit<EditorStateConfiguration, 'initialDoc'> {
delta?: Delta; onDocChange?: (doc: Node) => void;
editor?: Editor;
plainText?: string | undefined;
placeholder?: string; placeholder?: string;
keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined;
onKeyboardSubmit?: () => void;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} }
let { const { onDocChange, placeholder = '', children, ...stateConfiguration }: Props = $props();
delta = $bindable(new Delta()),
editor = $bindable(initEditor()),
plainText = $bindable(undefined),
placeholder = '',
keyboardSubmitMethod = undefined,
onKeyboardSubmit,
children
}: Props = $props();
function initEditor() { let domEl: HTMLDivElement;
const modules = keyboardSubmitMethod
? { let isEmpty = $state(!children);
keyboardSubmit: keyboardSubmit(
() => onKeyboardSubmit && onKeyboardSubmit(), $effect(() => {
keyboardSubmitMethod const initialDoc = DOMParser.fromSchema(stateConfiguration.schema).parse(domEl);
) domEl.replaceChildren();
onDocChange?.(initialDoc);
let state = createProseMirrorEditorState({ initialDoc, ...stateConfiguration });
let view = new EditorView(
{ mount: domEl },
{
state,
dispatchTransaction: (tr) => {
state = state.apply(tr);
view.updateState(state);
onDocChange?.(state.doc);
const doc = state.doc;
isEmpty = doc.textContent.length === 0;
} }
: undefined;
const editor = new Editor({ modules });
editor.typeset.formats.add({
name: 'underline',
selector: 'span[data-weblah-brt=underline]',
styleSelector: '[style*="text-decoration:underline"], [style*="text-decoration: underline"]',
commands: (editor) => () => editor.toggleTextFormat({ underline: true }),
shortcuts: 'Mod+U',
render: (attributes, children) => h('span', { 'data-weblah-brt': 'underline' }, children)
});
editor.typeset.formats.add({
name: 'strikethrough',
selector: 's',
styleSelector:
'[style*="text-decoration:line-through"], [style*="text-decoration: line-through"]',
commands: (editor) => () => editor.toggleTextFormat({ strikethrough: true }),
shortcuts: 'Mod+Shift+X',
render: (attributes, children) => h('s', null, children)
});
editor.on('change', () => {
delta = editor.getDelta();
if (typeof plainText === 'string') plainText = editor.getText();
});
return editor;
} }
);
$effect.pre(() => { return () => {
if (keyboardSubmitMethod || typeof keyboardSubmitMethod === 'undefined') editor = initEditor(); view.destroy();
}); };
$effect.pre(() => {
editor.setDelta(delta ?? new Delta());
});
$effect.pre(() => {
if (typeof plainText === 'string' && plainText !== editor.getText()) editor.setText(plainText);
}); });
</script> </script>
<div <div
class="rich-text relative w-full outline-hidden before:absolute before:hidden before:leading-tight before:opacity-50 before:content-[attr(data-weblah-placeholder)] data-weblah-is-empty:before:block" class={tw(
use:asRoot={editor} 'rich-text relative w-full outline-hidden',
data-weblah-is-empty={!delta || (delta.ops.length === 1 && delta.ops[0].insert === '\n') 'before:absolute before:hidden before:leading-tight before:opacity-50 before:content-[attr(data-weblah-placeholder)] data-weblah-is-empty:before:block'
? 'true' )}
: undefined} bind:this={domEl}
data-weblah-is-empty={isEmpty ? 'true' : undefined}
data-weblah-placeholder={placeholder} data-weblah-placeholder={placeholder}
role="textbox" role="textbox"
tabindex="0" tabindex="0"
> >
{@render children?.()} {@render children?.()}
</div> </div>
<style>
:global {
@import 'prosemirror-view/style/prosemirror.css';
}
</style>

View file

@ -1,25 +0,0 @@
import { DOMParser, Node, Schema } from 'prosemirror-model';
import type { EditorState } from 'prosemirror-state';
import { createEditorState, type EditorConfiguration } from './editorState';
export class RichTextEditState {
state: EditorState;
doc: Node | undefined = $state();
plainText = $state('');
constructor(
config: EditorConfiguration,
schema?: Schema,
initialNodeOrPlainText?: Node | string
) {
if (initialNodeOrPlainText) {
if (typeof initialNodeOrPlainText === 'string') {
this.plainText = initialNodeOrPlainText;
} else {
this.doc = initialNodeOrPlainText;
}
}
this.state = createEditorState(config, schema);
}
}

View file

@ -2,21 +2,23 @@ import { EditorState, type Command } from 'prosemirror-state';
import { history, undo, redo } from 'prosemirror-history'; import { history, undo, redo } from 'prosemirror-history';
import { keymap } from 'prosemirror-keymap'; import { keymap } from 'prosemirror-keymap';
import { baseKeymap } from 'prosemirror-commands'; import { baseKeymap } from 'prosemirror-commands';
import type { Schema } from 'prosemirror-model'; import type { Schema, Node } from 'prosemirror-model';
import { messageSchema } from './schema'; export type EditorStateConfiguration = {
initialDoc?: Node | string;
export type EditorConfiguration = { schema: Schema;
keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined; keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined;
onKeyboardSubmit?: () => void; onKeyboardSubmit?: (doc: Node) => void;
}; };
export function createEditorState( export function createProseMirrorEditorState({
{ keyboardSubmitMethod, onKeyboardSubmit }: EditorConfiguration, keyboardSubmitMethod,
schema: Schema = messageSchema onKeyboardSubmit,
) { initialDoc,
const submitCommand: Command = () => { schema
onKeyboardSubmit?.(); }: EditorStateConfiguration) {
const submitCommand: Command = (state) => {
onKeyboardSubmit?.(state.doc);
return true; return true;
}; };
const newlineCommand: Command = baseKeymap.Enter; const newlineCommand: Command = baseKeymap.Enter;
@ -32,15 +34,17 @@ export function createEditorState(
} }
const state = EditorState.create({ const state = EditorState.create({
doc: typeof initialDoc === 'string' ? schema.text(initialDoc) : initialDoc,
schema, schema,
plugins: [ plugins: [
history(), history(),
keymap({ keymap({
'Mod-z': undo, 'Mod-z': undo,
'Mod-y': redo, 'Mod-y': redo,
'Mod-Shift-z': redo 'Mod-Shift-z': redo,
}), ...baseKeymap,
keymap({ ...baseKeymap, ...submitOrNewlineKeyMap }) ...submitOrNewlineKeyMap
})
] ]
}); });

View file

@ -1,15 +0,0 @@
import type { ModuleInitializer } from 'typewriter-editor';
export const keyboardSubmit = function keyboardSubmit(
onSubmit: () => void,
method: 'enter' | 'shiftEnter'
): ModuleInitializer {
return () => ({
commands: {
keyboardSubmit: onSubmit
},
shortcuts: {
[method === 'enter' ? 'Enter' : 'Shift+Enter']: 'keyboardSubmit'
}
});
};

View file

@ -5,7 +5,8 @@ export const messageSchema = new Schema({
nodes: { nodes: {
doc: { content: 'block+' }, doc: { content: 'block+' },
paragragh: { paragragh: {
content: 'inline*' content: 'inline*',
...basicNodes.paragraph
}, },
text: basicNodes.text text: basicNodes.text
}, },

View file

@ -1,30 +1,31 @@
<script lang="ts"> <script lang="ts">
import RichTextInput from '$lib/components/RichTextInput.svelte'; import RichTextInput from '$lib/components/RichTextInput.svelte';
import { deltaToBlahRichText } from '$lib/richText'; import { messageSchema } from '$lib/components/RichTextInput/schema';
import type { Delta } from 'typewriter-editor'; // import { deltaToBlahRichText } from '$lib/richText';
import type { Node } from 'prosemirror-model';
let delta: Delta | undefined = $state(); let doc: Node | undefined = $state();
let brt = $derived(delta ? deltaToBlahRichText(delta) : null); // let brt = $derived(delta ? deltaToBlahRichText(delta) : null);
</script> </script>
<RichTextInput bind:delta class="m-4 max-h-32"> <RichTextInput schema={messageSchema} onDocChange={(newDoc) => (doc = newDoc)} class="m-4 max-h-32">
<p>A <strong>quick</strong> brown <em>fox</em> jumps over the lazy dog.</p> <p>A <strong>quick</strong> brown <em>fox</em> jumps over the lazy dog.</p>
<p>A test engineer <a href="https://example.com">tests</a> the <code>RichTextInput</code>.</p> <p>A test engineer <a href="https://example.com">tests</a> the <code>RichTextInput</code>.</p>
</RichTextInput> </RichTextInput>
<div class="flex min-h-0 flex-1 gap-4 p-4"> <div class="flex min-h-0 flex-1 gap-4 p-4">
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<h2 class="text-lg">Delta (Editor's internal representation)</h2> <h2 class="text-lg">ProseMirror <code>doc</code></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(delta, null, 2)}</code></pre> <pre><code>{JSON.stringify(doc?.toJSON(), null, 2)}</code></pre>
</div> </div>
</div> </div>
<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>