From 0b2f41ddb0e1dbe55e3ef038e1b82af805ece37e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sat, 15 Aug 2026 01:19:43 -0300 Subject: [PATCH] Append a paragraph when clicking below the document Handling `mousedown` rather than a click, and leaving the event unhandled afterwards, lets the click place the caret once: the paragraph is already rendered under the pointer by the time the browser hit-tests it, and the drag-selection the gesture may become still starts normally. A projection left open elsewhere in the document is finalized first. A doc-changing transaction that moves the selection out of one drops the session and strands its Markdown source as literal text. --- CHANGELOG.md | 1 + docs/specification.md | 1 + .../plugins/doubleClickSelection.test.tsx | 7 +- .../editor/plugins/trailingParagraph.test.tsx | 94 +++++++++++++++++++ .../editor/plugins/trailingParagraph.ts | 49 ++++++++++ .../editor/utils/createMilkdownEditor.ts | 2 + 6 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 src/features/editor/plugins/trailingParagraph.test.tsx create mode 100644 src/features/editor/plugins/trailingParagraph.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 79782e2..5b9e0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Added +- Add a paragraph at the end of the document by clicking the empty space below it. - Close folder contexts from the File menu. - Add keyboard shortcuts for formatting task lists and toggling task items. diff --git a/docs/specification.md b/docs/specification.md index 8bb1a2e..343f5d1 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -142,6 +142,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - Tables render as editable table blocks. Basic table editing uses visual table interaction; pipe-delimited Markdown is not exposed in the editor surface. - Code blocks render as styled monospace blocks with syntax highlighting when available. Focused code blocks edit code content directly. Language metadata controls are deferred. - Footnote definitions render as editable definition blocks with a persistent subtle definition marker. +- Clicking the empty space below the document appends an empty paragraph and places the caret in it, unless the document already ends with one. ### Inline Content diff --git a/src/features/editor/plugins/doubleClickSelection.test.tsx b/src/features/editor/plugins/doubleClickSelection.test.tsx index 8aaf54d..c38577e 100644 --- a/src/features/editor/plugins/doubleClickSelection.test.tsx +++ b/src/features/editor/plugins/doubleClickSelection.test.tsx @@ -23,10 +23,11 @@ const mountEditor = setupMilkdownEditorMount(); const dispatchEditorDoubleClick = (view: EditorView, position: number, button = 0) => { const posAtCoords = vi.spyOn(view, "posAtCoords").mockReturnValue({ inside: -1, pos: position }); const eventOptions = { button, clientX: 20, clientY: 20 }; + const target = view.dom.firstElementChild ?? view.dom; - dispatchMouseEvent(view.dom, "mousedown", eventOptions); - dispatchMouseEvent(view.dom, "mouseup", eventOptions); - const secondMouseDown = dispatchMouseEvent(view.dom, "mousedown", eventOptions); + dispatchMouseEvent(target, "mousedown", eventOptions); + dispatchMouseEvent(target, "mouseup", eventOptions); + const secondMouseDown = dispatchMouseEvent(target, "mousedown", eventOptions); posAtCoords.mockRestore(); diff --git a/src/features/editor/plugins/trailingParagraph.test.tsx b/src/features/editor/plugins/trailingParagraph.test.tsx new file mode 100644 index 0000000..ff51525 --- /dev/null +++ b/src/features/editor/plugins/trailingParagraph.test.tsx @@ -0,0 +1,94 @@ +import type { EditorView } from "@milkdown/kit/prose/view"; +import { describe, expect, it, vi } from "vitest"; + +import { + BASIC_TABLE_MARKDOWN, + BOLD_PLAIN_MARKDOWN, + HELLO_WORLD_TEXT, +} from "@/test/fixtures/editorMarkdown"; +import { dispatchMouseDown } from "@/test/utils/events"; +import { setupMilkdownEditorMount } from "@/test/utils/milkdown"; +import { getEditorDomElement, setSelectionAtElementTextEnd } from "@/test/utils/prosemirror"; + +import { hasActiveSourceProjection } from "./sourceProjection"; + +const LAST_BLOCK_BOTTOM_PX = 100; + +const mountEditor = setupMilkdownEditorMount(); + +const dispatchClickBelowDocument = (view: EditorView, clientY = LAST_BLOCK_BOTTOM_PX + 50) => { + const lastElement = view.dom.lastElementChild; + + if (!lastElement) { + throw new Error("Expected the editor to render a last block element."); + } + + vi.spyOn(lastElement, "getBoundingClientRect").mockReturnValue({ + bottom: LAST_BLOCK_BOTTOM_PX, + height: LAST_BLOCK_BOTTOM_PX, + left: 0, + right: 160, + top: 0, + width: 160, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + return dispatchMouseDown(view.dom, { button: 0, clientX: 20, clientY }); +}; + +const getLastChildDescription = (view: EditorView) => { + const lastChild = view.state.doc.lastChild; + + return { size: lastChild?.content.size, type: lastChild?.type.name }; +}; + +describe("trailing paragraph plugin", () => { + it.each([ + { initialMarkdown: HELLO_WORLD_TEXT, name: "text" }, + { initialMarkdown: BASIC_TABLE_MARKDOWN, name: "a table" }, + ])("appends a paragraph when the document ends with $name", async ({ initialMarkdown }) => { + const onContentChanged = vi.fn(); + const mounted = await mountEditor(initialMarkdown, { onContentChanged }); + const childCount = mounted.view.state.doc.childCount; + + dispatchClickBelowDocument(mounted.view); + + expect(mounted.view.state.doc.childCount).toBe(childCount + 1); + expect(getLastChildDescription(mounted.view)).toEqual({ size: 0, type: "paragraph" }); + expect(mounted.view.state.selection.from).toBe(mounted.view.state.doc.content.size - 1); + expect(onContentChanged).toHaveBeenCalled(); + }); + + it("keeps an open source projection formatted", async () => { + const mounted = await mountEditor(BOLD_PLAIN_MARKDOWN); + setSelectionAtElementTextEnd(mounted.view, getEditorDomElement(mounted, "strong")); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + + dispatchClickBelowDocument(mounted.view); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(false); + expect(mounted.getMarkdown()).toContain(BOLD_PLAIN_MARKDOWN); + }); + + it("leaves the document alone when it already ends with an empty paragraph", async () => { + const mounted = await mountEditor(HELLO_WORLD_TEXT); + + dispatchClickBelowDocument(mounted.view); + + const childCount = mounted.view.state.doc.childCount; + dispatchClickBelowDocument(mounted.view); + + expect(mounted.view.state.doc.childCount).toBe(childCount); + }); + + it("leaves a click inside the last line native", async () => { + const mounted = await mountEditor(HELLO_WORLD_TEXT); + + dispatchClickBelowDocument(mounted.view, LAST_BLOCK_BOTTOM_PX - 10); + + expect(mounted.view.state.doc.childCount).toBe(1); + }); +}); diff --git a/src/features/editor/plugins/trailingParagraph.ts b/src/features/editor/plugins/trailingParagraph.ts new file mode 100644 index 0000000..6d68d01 --- /dev/null +++ b/src/features/editor/plugins/trailingParagraph.ts @@ -0,0 +1,49 @@ +import { Plugin, Selection } from "@milkdown/kit/prose/state"; +import { $prose } from "@milkdown/kit/utils"; + +import { finalizeSourceProjection, hasActiveSourceProjection } from "./sourceProjection"; + +export const createLeafdownTrailingParagraphPlugin = () => + $prose( + () => + new Plugin({ + props: { + handleDOMEvents: { + mousedown: (view, event) => { + if (event.button !== 0 || event.target !== view.dom) { + return false; + } + + const { doc, schema } = view.state; + const lastNode = doc.lastChild; + const lastElement = view.dom.lastElementChild; + + if ( + !lastNode || + !lastElement || + (lastNode.type === schema.nodes.paragraph && lastNode.content.size === 0) || + event.clientY <= lastElement.getBoundingClientRect().bottom + ) { + return false; + } + + const paragraph = schema.nodes.paragraph?.createAndFill(); + + if (!paragraph) { + return false; + } + + if (hasActiveSourceProjection(view.state)) { + finalizeSourceProjection(view); + } + + const transaction = view.state.tr.insert(view.state.doc.content.size, paragraph); + + view.dispatch(transaction.setSelection(Selection.atEnd(transaction.doc))); + + return false; + }, + }, + }, + }), + ); diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts index 62ce351..2a0130f 100644 --- a/src/features/editor/utils/createMilkdownEditor.ts +++ b/src/features/editor/utils/createMilkdownEditor.ts @@ -59,6 +59,7 @@ import { } from "../plugins/sourceProjection"; import { createLeafdownTableKeyboardPlugin } from "../plugins/tableKeyboard"; import { createLeafdownTaskListCheckboxPlugin } from "../plugins/taskListCheckbox"; +import { createLeafdownTrailingParagraphPlugin } from "../plugins/trailingParagraph"; import { normalizeProseMirrorClipboardHtml } from "./clipboardHtml"; import { createLeafdownHighlightParser } from "./highlighting"; import type { MarkdownLinkContext } from "./linkActivation"; @@ -164,6 +165,7 @@ export const createMilkdownEditor = async ({ .use(createLeafdownAutoPairPlugin(isAutoPairEnabled)) .use(createLeafdownCommandStatePlugin((state) => onCommandStateChanged?.(state))) .use(createLeafdownTaskListCheckboxPlugin()) + .use(createLeafdownTrailingParagraphPlugin()) .use(createLeafdownDirtyTrackerPlugin(() => onContentChanged?.())) .config((ctx) => { ctx.set(rootCtx, root);