diff --git a/src/renderer/editor/codemirror/commands.ts b/src/renderer/editor/codemirror/commands.ts index cc37066..4706ce3 100644 --- a/src/renderer/editor/codemirror/commands.ts +++ b/src/renderer/editor/codemirror/commands.ts @@ -380,6 +380,23 @@ type LinePrefixOptions = { prefixForLine: (lineIndex: number) => string; }; +const isFenceDelimiterLine = (text: string): boolean => { + return /^```/.test(text.trim()); +}; + +const isInsideFencedCode = ( + doc: import("@codemirror/state").Text, + lineNumber: number +): boolean => { + let inFence = false; + for (let currentLine = 1; currentLine < lineNumber; currentLine += 1) { + if (isFenceDelimiterLine(doc.line(currentLine).text)) { + inFence = !inFence; + } + } + return inFence; +}; + const getSelectedLineNumbers = ( view: import("@codemirror/view").EditorView ): { start: number; end: number } => { @@ -480,6 +497,48 @@ export const toggleBlockquoteCommand = ( prefixForLine: () => "> ", }); +export const continueBlockquoteCommand = ( + view: import("@codemirror/view").EditorView +): boolean => { + const { from, to } = view.state.selection.main; + if (from !== to) { + return false; + } + + const line = view.state.doc.lineAt(from); + const match = line.text.match(/^(\s*(?:>\s*)+)(.*)$/); + if (!match) { + return false; + } + + const [, marker, content] = match; + const indent = line.text.match(/^\s*/)?.[0] ?? ""; + if (indent.length >= 4 || isInsideFencedCode(view.state.doc, line.number)) { + return false; + } + + if (content.trim() === "") { + view.dispatch({ + changes: { + from: line.from, + to: line.to, + insert: indent, + }, + selection: { anchor: line.from + indent.length }, + scrollIntoView: true, + }); + return true; + } + + const insert = `\n${marker}`; + view.dispatch({ + changes: { from, to, insert }, + selection: { anchor: from + insert.length }, + scrollIntoView: true, + }); + return true; +}; + export const toggleFencedCodeBlockCommand = ( view: import("@codemirror/view").EditorView ): boolean => { diff --git a/src/renderer/editor/codemirror/extensions.ts b/src/renderer/editor/codemirror/extensions.ts index fa0aebd..cc41bad 100644 --- a/src/renderer/editor/codemirror/extensions.ts +++ b/src/renderer/editor/codemirror/extensions.ts @@ -16,6 +16,7 @@ import { buildThemeExtension } from "./theme"; import { hybridMarkdown } from "./hybridMarkdown"; import { linkClickHandler } from "./links"; import { createReactSearchPanel } from "./searchPanel"; +import { continueBlockquoteCommand } from "./commands"; type ExtensionOptions = { renderMode: RenderMode; @@ -37,7 +38,14 @@ export type ExtensionBundle = { }; export const buildBaseKeymap = - (): import("@codemirror/view").KeyBinding[] => [...searchKeymap]; + (): import("@codemirror/view").KeyBinding[] => [ + { + key: "Enter", + preventDefault: true, + run: continueBlockquoteCommand, + }, + ...searchKeymap, + ]; export const renderModeExtension = (mode: RenderMode): Extension => { if (mode === "hybrid") { diff --git a/tests/controllerShortcuts.test.ts b/tests/controllerShortcuts.test.ts index 46ff10c..c8437b7 100644 --- a/tests/controllerShortcuts.test.ts +++ b/tests/controllerShortcuts.test.ts @@ -1,5 +1,6 @@ import { strict as assert } from "assert"; import { + continueBlockquoteCommand, createSnippetCommand, toggleBlockquoteCommand, toggleFencedCodeBlockCommand, @@ -74,6 +75,7 @@ class FakeView { | { from: number; to?: number; insert: string } | Array<{ from: number; to?: number; insert: string }>; selection?: { anchor: number; head?: number }; + scrollIntoView?: boolean; }): void { const changes = Array.isArray(spec.changes) ? spec.changes @@ -185,6 +187,63 @@ runTest("blockquote command toggles selected lines", () => { assert.equal(view.text, "one\ntwo"); }); +runTest("enter continues blockquotes", () => { + const view = new FakeView("> quote", { from: 7, to: 7 }); + + const handled = continueBlockquoteCommand(view as unknown as EditorView); + + assert.equal(handled, true); + assert.equal(view.text, "> quote\n> "); + assert.deepEqual(view.state.selection.main, { from: 10, to: 10 }); +}); + +runTest("enter preserves spaced nested blockquote markers", () => { + const view = new FakeView("> > nested", { from: 10, to: 10 }); + + const handled = continueBlockquoteCommand(view as unknown as EditorView); + + assert.equal(handled, true); + assert.equal(view.text, "> > nested\n> > "); + assert.deepEqual(view.state.selection.main, { from: 15, to: 15 }); +}); + +runTest("enter exits an empty blockquote", () => { + const view = new FakeView(" > ", { from: 6, to: 6 }); + + const handled = continueBlockquoteCommand(view as unknown as EditorView); + + assert.equal(handled, true); + assert.equal(view.text, " "); + assert.deepEqual(view.state.selection.main, { from: 2, to: 2 }); +}); + +runTest("enter falls through for fenced code lines shaped like quotes", () => { + const view = new FakeView("```\n> flag\n```", { from: 10, to: 10 }); + + const handled = continueBlockquoteCommand(view as unknown as EditorView); + + assert.equal(handled, false); + assert.equal(view.text, "```\n> flag\n```"); +}); + +runTest("enter falls through for indented code lines shaped like quotes", () => { + const view = new FakeView(" > flag", { from: 10, to: 10 }); + + const handled = continueBlockquoteCommand(view as unknown as EditorView); + + assert.equal(handled, false); + assert.equal(view.text, " > flag"); +}); + +runTest("enter falls through outside blockquotes", () => { + const view = new FakeView("plain", { from: 5, to: 5 }); + + const handled = continueBlockquoteCommand(view as unknown as EditorView); + + assert.equal(handled, false); + assert.equal(view.text, "plain"); +}); + runTest("code block command wraps and unwraps selected lines", () => { const view = new FakeView("const x = 1;", { from: 0, to: 12 });