mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-05-01 00:31:08 +00:00
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:
parent
48f4721e5b
commit
db186636a3
7 changed files with 84 additions and 157 deletions
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
});
|
||||
};
|
|
@ -5,7 +5,8 @@ export const messageSchema = new Schema({
|
|||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
paragragh: {
|
||||
content: 'inline*'
|
||||
content: 'inline*',
|
||||
...basicNodes.paragraph
|
||||
},
|
||||
text: basicNodes.text
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue