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

View file

@ -1,84 +1,64 @@
<script lang="ts">
import { Delta, Editor, asRoot, h } from 'typewriter-editor';
import { keyboardSubmit } from './keyboardSubmitModule';
import { DOMParser, type Node } from 'prosemirror-model';
import { createProseMirrorEditorState, type EditorStateConfiguration } from './editorState';
import { tw } from '$lib/tw';
import { EditorView } from 'prosemirror-view';
interface Props {
delta?: Delta;
editor?: Editor;
plainText?: string | undefined;
export interface Props extends Omit<EditorStateConfiguration, 'initialDoc'> {
onDocChange?: (doc: Node) => void;
placeholder?: string;
keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined;
onKeyboardSubmit?: () => void;
children?: import('svelte').Snippet;
}
let {
delta = $bindable(new Delta()),
editor = $bindable(initEditor()),
plainText = $bindable(undefined),
placeholder = '',
keyboardSubmitMethod = undefined,
onKeyboardSubmit,
children
}: Props = $props();
const { onDocChange, placeholder = '', children, ...stateConfiguration }: Props = $props();
function initEditor() {
const modules = keyboardSubmitMethod
? {
keyboardSubmit: keyboardSubmit(
() => onKeyboardSubmit && onKeyboardSubmit(),
keyboardSubmitMethod
)
let domEl: HTMLDivElement;
let isEmpty = $state(!children);
$effect(() => {
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(() => {
if (keyboardSubmitMethod || typeof keyboardSubmitMethod === 'undefined') editor = initEditor();
});
$effect.pre(() => {
editor.setDelta(delta ?? new Delta());
});
$effect.pre(() => {
if (typeof plainText === 'string' && plainText !== editor.getText()) editor.setText(plainText);
return () => {
view.destroy();
};
});
</script>
<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"
use:asRoot={editor}
data-weblah-is-empty={!delta || (delta.ops.length === 1 && delta.ops[0].insert === '\n')
? 'true'
: undefined}
class={tw(
'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'
)}
bind:this={domEl}
data-weblah-is-empty={isEmpty ? 'true' : undefined}
data-weblah-placeholder={placeholder}
role="textbox"
tabindex="0"
>
{@render children?.()}
</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 { keymap } from 'prosemirror-keymap';
import { baseKeymap } from 'prosemirror-commands';
import type { Schema } from 'prosemirror-model';
import type { Schema, Node } from 'prosemirror-model';
import { messageSchema } from './schema';
export type EditorConfiguration = {
export type EditorStateConfiguration = {
initialDoc?: Node | string;
schema: Schema;
keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined;
onKeyboardSubmit?: () => void;
onKeyboardSubmit?: (doc: Node) => void;
};
export function createEditorState(
{ keyboardSubmitMethod, onKeyboardSubmit }: EditorConfiguration,
schema: Schema = messageSchema
) {
const submitCommand: Command = () => {
onKeyboardSubmit?.();
export function createProseMirrorEditorState({
keyboardSubmitMethod,
onKeyboardSubmit,
initialDoc,
schema
}: EditorStateConfiguration) {
const submitCommand: Command = (state) => {
onKeyboardSubmit?.(state.doc);
return true;
};
const newlineCommand: Command = baseKeymap.Enter;
@ -32,15 +34,17 @@ export function createEditorState(
}
const state = EditorState.create({
doc: typeof initialDoc === 'string' ? schema.text(initialDoc) : initialDoc,
schema,
plugins: [
history(),
keymap({
'Mod-z': undo,
'Mod-y': redo,
'Mod-Shift-z': redo
}),
keymap({ ...baseKeymap, ...submitOrNewlineKeyMap })
'Mod-Shift-z': redo,
...baseKeymap,
...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: {
doc: { content: 'block+' },
paragragh: {
content: 'inline*'
content: 'inline*',
...basicNodes.paragraph
},
text: basicNodes.text
},

View file

@ -1,30 +1,31 @@
<script lang="ts">
import RichTextInput from '$lib/components/RichTextInput.svelte';
import { deltaToBlahRichText } from '$lib/richText';
import type { Delta } from 'typewriter-editor';
import { messageSchema } from '$lib/components/RichTextInput/schema';
// 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>
<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 test engineer <a href="https://example.com">tests</a> the <code>RichTextInput</code>.</p>
</RichTextInput>
<div class="flex min-h-0 flex-1 gap-4 p-4">
<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">
<pre><code>{JSON.stringify(delta, null, 2)}</code></pre>
<pre><code>{JSON.stringify(doc?.toJSON(), null, 2)}</code></pre>
</div>
</div>
<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>