From 34e57448502feb5f6c2ea1e4def09680336f2e37 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:07:07 +0100 Subject: [PATCH] fix(editor): allow gap cursors between top-level blocks --- .../editor/extensions/document.test.ts | 112 ++++++++++++++++++ .../features/editor/extensions/document.ts | 8 ++ .../features/editor/extensions/extensions.ts | 6 +- 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/features/editor/extensions/document.test.ts create mode 100644 apps/client/src/features/editor/extensions/document.ts diff --git a/apps/client/src/features/editor/extensions/document.test.ts b/apps/client/src/features/editor/extensions/document.test.ts new file mode 100644 index 000000000..38bfa0fda --- /dev/null +++ b/apps/client/src/features/editor/extensions/document.test.ts @@ -0,0 +1,112 @@ +import { Editor, Node } from "@tiptap/core"; +import { GapCursor } from "@tiptap/pm/gapcursor"; +import { NodeSelection, TextSelection } from "@tiptap/pm/state"; +import { StarterKit } from "@tiptap/starter-kit"; +import { describe, expect, it } from "vitest"; +import { TiptapDocument } from "./document"; + +const Footnotes = Node.create({ + name: "footnotes", + group: "", + content: "paragraph*", + isolating: true, + renderHTML() { + return ["ol", { class: "footnotes" }, 0]; + }, +}); + +const AtomBlock = Node.create({ + name: "atomBlock", + group: "block", + atom: true, + renderHTML() { + return ["div", { "data-atom-block": "" }]; + }, +}); + +const IsolatingBlock = Node.create({ + name: "isolatingBlock", + group: "block", + content: "paragraph+", + isolating: true, + renderHTML() { + return ["div", { "data-isolating-block": "" }, 0]; + }, +}); + +function createEditor(content: object[]) { + const element = document.createElement("div"); + document.body.appendChild(element); + + return new Editor({ + element, + extensions: [ + TiptapDocument, + StarterKit.configure({ document: false }), + Footnotes, + AtomBlock, + IsolatingBlock, + ], + content: { type: "doc", content }, + }); +} + +function pressKey(editor: Editor, key: string, keyCode: number) { + editor.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { + key, + keyCode, + bubbles: true, + cancelable: true, + }), + ); +} + +describe("TiptapDocument", () => { + it("stops on the gap when arrowing down from a selected block node", () => { + const editor = createEditor([ + { type: "atomBlock" }, + { type: "atomBlock" }, + { type: "paragraph" }, + ]); + const gapPos = editor.state.doc.child(0).nodeSize; + editor.view.dispatch( + editor.state.tr.setSelection( + NodeSelection.create(editor.state.doc, 0), + ), + ); + + pressKey(editor, "ArrowDown", 40); + + expect(editor.state.selection).toBeInstanceOf(GapCursor); + expect(editor.state.selection.head).toBe(gapPos); + + editor.destroy(); + }); + + it("stops on the gap when arrowing right out of an isolating block", () => { + const paragraph = (text: string) => ({ + type: "paragraph", + content: [{ type: "text", text }], + }); + const editor = createEditor([ + { type: "isolatingBlock", content: [paragraph("a")] }, + { type: "isolatingBlock", content: [paragraph("b")] }, + { type: "paragraph" }, + ]); + const gapPos = editor.state.doc.child(0).nodeSize; + const endOfFirstText = gapPos - 2; + editor.view.dispatch( + editor.state.tr.setSelection( + TextSelection.create(editor.state.doc, endOfFirstText), + ), + ); + + pressKey(editor, "ArrowRight", 39); + + expect(editor.state.selection).toBeInstanceOf(GapCursor); + expect(editor.state.selection.head).toBe(gapPos); + + editor.destroy(); + }); +}); diff --git a/apps/client/src/features/editor/extensions/document.ts b/apps/client/src/features/editor/extensions/document.ts new file mode 100644 index 000000000..4969ea65d --- /dev/null +++ b/apps/client/src/features/editor/extensions/document.ts @@ -0,0 +1,8 @@ +import { Document } from "@tiptap/extension-document"; + +// With `block+ footnotes?`, ProseMirror's defaultType after the first block is +// `footnotes` (not a textblock), so GapCursor.valid() rejects every top-level gap. +export const TiptapDocument = Document.extend({ + content: "block+ footnotes?", + allowGapCursor: true, +}); diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index c72456e6e..7c685ac65 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -1,6 +1,6 @@ import { markInputRule } from "@tiptap/core"; import { StarterKit } from "@tiptap/starter-kit"; -import { Document } from "@tiptap/extension-document"; +import { TiptapDocument } from "@/features/editor/extensions/document"; import { Code } from "@tiptap/extension-code"; import { TextAlign } from "@tiptap/extension-text-align"; import { TaskList, TaskItem } from "@tiptap/extension-list"; @@ -148,9 +148,7 @@ export const mainExtensions = [ codeBlock: false, code: false, }), - Document.extend({ - content: "block+ footnotes?", - }), + TiptapDocument, // Override TipTap's Code extension to fix the inline code input rule. // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character // before the opening backtick as part of the match, causing markInputRule