Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions src/features/editor/plugins/doubleClickSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
94 changes: 94 additions & 0 deletions src/features/editor/plugins/trailingParagraph.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
49 changes: 49 additions & 0 deletions src/features/editor/plugins/trailingParagraph.ts
Original file line number Diff line number Diff line change
@@ -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;
},
},
},
}),
);
2 changes: 2 additions & 0 deletions src/features/editor/utils/createMilkdownEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down