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">
|
<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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 { 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
|
||||||
|
})
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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: {
|
nodes: {
|
||||||
doc: { content: 'block+' },
|
doc: { content: 'block+' },
|
||||||
paragragh: {
|
paragragh: {
|
||||||
content: 'inline*'
|
content: 'inline*',
|
||||||
|
...basicNodes.paragraph
|
||||||
},
|
},
|
||||||
text: basicNodes.text
|
text: basicNodes.text
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Reference in a new issue