mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 01:04:39 +08:00
fix: code splitting and editor fixes (#2211)
* fix table * fix code splitting * fix: editor ready check * fix codeblock/mermaid gap cursor * fix callout
This commit is contained in:
@@ -162,6 +162,28 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Empty callout: delete the whole node so Backspace inside it isn't
|
||||
// a no-op (isolating: true blocks the default join with the block
|
||||
// above).
|
||||
const calloutDepth = $from.depth - 1;
|
||||
if (calloutDepth >= 0) {
|
||||
const calloutNode = $from.node(calloutDepth);
|
||||
if (
|
||||
calloutNode.type === this.type &&
|
||||
calloutNode.childCount === 1 &&
|
||||
calloutNode.firstChild?.content.size === 0
|
||||
) {
|
||||
const calloutPos = $from.before(calloutDepth);
|
||||
const { tr } = state;
|
||||
tr.delete(calloutPos, calloutPos + calloutNode.nodeSize);
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(calloutPos), -1),
|
||||
);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const previousPosition = $from.before($from.depth) - 1;
|
||||
|
||||
// If nothing above to join with
|
||||
@@ -207,6 +229,56 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Exit the callout into a fresh paragraph below when the cursor sits
|
||||
// in an empty trailing child. An empty callout (single empty
|
||||
// paragraph) exits on the first Enter and keeps the empty callout
|
||||
// intact; a callout with content needs the double-Enter pattern
|
||||
// (first Enter splits, second Enter on the new trailing empty exits
|
||||
// and removes that trailing paragraph).
|
||||
Enter: ({ editor }) => {
|
||||
const { state, view } = editor;
|
||||
const { selection } = state;
|
||||
if (!selection.empty) return false;
|
||||
|
||||
const { $from } = selection;
|
||||
const calloutDepth = $from.depth - 1;
|
||||
if (calloutDepth < 0) return false;
|
||||
|
||||
const calloutNode = $from.node(calloutDepth);
|
||||
if (calloutNode.type !== this.type) return false;
|
||||
if ($from.parent.content.size !== 0) return false;
|
||||
if ($from.index(calloutDepth) !== calloutNode.childCount - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paragraphType = state.schema.nodes.paragraph;
|
||||
const containerDepth = calloutDepth - 1;
|
||||
const container = $from.node(containerDepth);
|
||||
const indexAfter = $from.indexAfter(containerDepth);
|
||||
if (
|
||||
!container.canReplaceWith(indexAfter, indexAfter, paragraphType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const calloutEnd = $from.after(calloutDepth);
|
||||
const paragraph = paragraphType.create();
|
||||
const { tr } = state;
|
||||
|
||||
if (calloutNode.childCount === 1) {
|
||||
tr.insert(calloutEnd, paragraph);
|
||||
tr.setSelection(TextSelection.create(tr.doc, calloutEnd + 1));
|
||||
} else {
|
||||
tr.delete($from.before(), $from.after());
|
||||
const insertPos = tr.mapping.map(calloutEnd);
|
||||
tr.insert(insertPos, paragraph);
|
||||
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
||||
}
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
|
||||
import CodeBlock from '@tiptap/extension-code-block';
|
||||
import { Plugin, Selection, TextSelection } from '@tiptap/pm/state';
|
||||
import { GapCursor } from '@tiptap/pm/gapcursor';
|
||||
|
||||
import { LowlightPlugin } from './lowlight-plugin.js';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
@@ -19,7 +21,11 @@ const TAB_CHAR = '\u00A0\u00A0';
|
||||
* @see https://tiptap.dev/api/nodes/code-block-lowlight
|
||||
*/
|
||||
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||
// Run ahead of Gapcursor (100) so the mermaid arrow-into-source plugin
|
||||
// can intercept before gapcursor takes over.
|
||||
priority: 101,
|
||||
selectable: true,
|
||||
isolating: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -35,8 +41,86 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const isMermaid = (node: any) =>
|
||||
node?.type === this.type && node.attrs.language === 'mermaid';
|
||||
|
||||
return {
|
||||
...this.parent?.(),
|
||||
// Stop at the gap (or enter mermaid source) instead of jumping
|
||||
// straight into the next block, so the user can place a cursor
|
||||
// between two adjacent isolating blocks.
|
||||
ArrowDown: ({ editor }) => {
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) return false;
|
||||
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
|
||||
|
||||
const after = $from.after();
|
||||
if (after >= doc.content.size) {
|
||||
return editor.commands.exitCode();
|
||||
}
|
||||
|
||||
const $after = doc.resolve(after);
|
||||
const nodeAfter = $after.nodeAfter;
|
||||
|
||||
if (isMermaid(nodeAfter)) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
nodeAfter?.type.spec.isolating &&
|
||||
!nodeAfter.type.spec.atom
|
||||
) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(new GapCursor(tr.doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
},
|
||||
// Mirror of ArrowDown; upstream has no ArrowUp handler.
|
||||
ArrowUp: ({ editor }) => {
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) return false;
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
|
||||
const before = $from.before();
|
||||
if (before <= 0) return false;
|
||||
|
||||
const $before = doc.resolve(before);
|
||||
const nodeBefore = $before.nodeBefore;
|
||||
|
||||
if (isMermaid(nodeBefore)) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(TextSelection.create(tr.doc, before - 1));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
nodeBefore?.type.spec.isolating &&
|
||||
!nodeBefore.type.spec.atom
|
||||
) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(new GapCursor(tr.doc.resolve(before)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
'Mod-a': () => {
|
||||
if (this.editor.isActive('codeBlock')) {
|
||||
const { state } = this.editor;
|
||||
@@ -84,6 +168,7 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const codeBlockType = this.type;
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
LowlightPlugin({
|
||||
@@ -91,6 +176,60 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||
lowlight: this.options.lowlight,
|
||||
defaultLanguage: this.options.defaultLanguage,
|
||||
}),
|
||||
// Mermaid hides its <pre> when unselected, so the browser's native
|
||||
// vertical caret movement skips past it. Land the cursor inside the
|
||||
// source explicitly.
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
|
||||
return false;
|
||||
}
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
if (
|
||||
!selection.empty ||
|
||||
!(selection instanceof TextSelection)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const { $from } = selection;
|
||||
if ($from.depth === 0 || $from.parent.type === codeBlockType) {
|
||||
return false;
|
||||
}
|
||||
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
|
||||
if (!view.endOfTextblock(dir)) return false;
|
||||
|
||||
const isMermaid = (node: any) =>
|
||||
node?.type === codeBlockType && node.attrs.language === 'mermaid';
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
const beforePos = $from.before();
|
||||
const prev = state.doc.resolve(beforePos).nodeBefore;
|
||||
if (!isMermaid(prev)) return false;
|
||||
const endPos = beforePos - 1;
|
||||
view.dispatch(
|
||||
state.tr.setSelection(
|
||||
TextSelection.create(state.doc, endPos),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
|
||||
const afterPos = $from.after();
|
||||
const next = state.doc.resolve(afterPos).nodeAfter;
|
||||
if (!isMermaid(next)) return false;
|
||||
const startPos = afterPos + 1;
|
||||
view.dispatch(
|
||||
state.tr.setSelection(
|
||||
TextSelection.create(state.doc, startPos),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -338,6 +338,15 @@ export const isRowGripSelected = ({
|
||||
return !!gripRow;
|
||||
};
|
||||
|
||||
// TipTap's `editor.view` proxy throws if accessed before mount or after destroy.
|
||||
// Guard floating-menu callbacks (getReferencedVirtualElement, shouldShow) with
|
||||
// this before touching `editor.view.nodeDOM(...)`.
|
||||
export function isEditorReady(
|
||||
editor: Editor | null | undefined,
|
||||
): editor is Editor {
|
||||
return !!editor && editor.isInitialized;
|
||||
}
|
||||
|
||||
export function isTextSelected(editor: Editor) {
|
||||
const {
|
||||
state: {
|
||||
|
||||
Reference in New Issue
Block a user