diff --git a/src/renderer/editor/codemirror/commands.ts b/src/renderer/editor/codemirror/commands.ts index cc37066..96db19c 100644 --- a/src/renderer/editor/codemirror/commands.ts +++ b/src/renderer/editor/codemirror/commands.ts @@ -380,6 +380,48 @@ 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 previousNonBlankLineText = ( + doc: import("@codemirror/state").Text, + lineNumber: number +): string | null => { + for (let currentLine = lineNumber - 1; currentLine >= 1; currentLine -= 1) { + const text = doc.line(currentLine).text; + if (text.trim() !== "") { + return text; + } + } + return null; +}; + +const isLikelyIndentedCodeLine = ( + doc: import("@codemirror/state").Text, + lineNumber: number, + indent: string +): boolean => { + if (indent.length < 4) { + return false; + } + const previousLine = previousNonBlankLineText(doc, lineNumber); + return previousLine === null || !/^\s*(?:[-*+]|\d+[.)])\s+/.test(previousLine); +}; + const getSelectedLineNumbers = ( view: import("@codemirror/view").EditorView ): { start: number; end: number } => { @@ -456,6 +498,51 @@ export const toggleUnorderedListCommand = ( prefixForLine: () => "- ", }); +export const continueUnorderedListCommand = ( + 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 [, indent, marker, content] = match; + if ( + /^(\s*)[-*+]\s+\[[ xX]\]\s+/.test(line.text) || + isInsideFencedCode(view.state.doc, line.number) || + isLikelyIndentedCodeLine(view.state.doc, line.number, indent) + ) { + 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${indent}${marker} `; + view.dispatch({ + changes: { from, to, insert }, + selection: { anchor: from + insert.length }, + scrollIntoView: true, + }); + return true; +}; + export const toggleOrderedListCommand = ( view: import("@codemirror/view").EditorView ): boolean => diff --git a/src/renderer/editor/codemirror/extensions.ts b/src/renderer/editor/codemirror/extensions.ts index fa0aebd..49e3396 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 { continueUnorderedListCommand } from "./commands"; type ExtensionOptions = { renderMode: RenderMode; @@ -37,7 +38,13 @@ export type ExtensionBundle = { }; export const buildBaseKeymap = - (): import("@codemirror/view").KeyBinding[] => [...searchKeymap]; + (): import("@codemirror/view").KeyBinding[] => [ + { + key: "Enter", + run: continueUnorderedListCommand, + }, + ...searchKeymap, + ]; export const renderModeExtension = (mode: RenderMode): Extension => { if (mode === "hybrid") { @@ -49,7 +56,7 @@ export const renderModeExtension = (mode: RenderMode): Extension => { export const keymapExtension = ( bindings: import("@codemirror/view").KeyBinding[], base: import("@codemirror/view").KeyBinding[] -): Extension => keymap.of([...bindings, ...base]); +): Extension => keymap.of([...base, ...bindings]); export const createCmExtensions = ( options: ExtensionOptions diff --git a/tests/controllerShortcuts.test.ts b/tests/controllerShortcuts.test.ts index 46ff10c..3329d01 100644 --- a/tests/controllerShortcuts.test.ts +++ b/tests/controllerShortcuts.test.ts @@ -1,5 +1,6 @@ import { strict as assert } from "assert"; import { + continueUnorderedListCommand, 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 @@ -147,6 +149,62 @@ runTest("unordered list command toggles selected lines", () => { assert.equal(view.text, "one\ntwo"); }); +runTest("enter continues unordered list markers", () => { + const view = new FakeView("- first", { from: 7, to: 7 }); + + const handled = continueUnorderedListCommand(view as unknown as EditorView); + + assert.equal(handled, true); + assert.equal(view.text, "- first\n- "); + assert.deepEqual(view.state.selection.main, { from: 10, to: 10 }); +}); + +runTest("enter exits an empty unordered list item", () => { + const view = new FakeView(" - ", { from: 6, to: 6 }); + + const handled = continueUnorderedListCommand(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 outside unordered lists", () => { + const view = new FakeView("plain", { from: 5, to: 5 }); + + const handled = continueUnorderedListCommand(view as unknown as EditorView); + + assert.equal(handled, false); + assert.equal(view.text, "plain"); +}); + +runTest("enter falls through for task list items", () => { + const view = new FakeView("- [ ] task", { from: 10, to: 10 }); + + const handled = continueUnorderedListCommand(view as unknown as EditorView); + + assert.equal(handled, false); + assert.equal(view.text, "- [ ] task"); +}); + +runTest("enter falls through for fenced code lines shaped like bullets", () => { + const view = new FakeView("```\n- flag\n```", { from: 10, to: 10 }); + + const handled = continueUnorderedListCommand(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 bullets", () => { + const view = new FakeView(" - flag", { from: 10, to: 10 }); + + const handled = continueUnorderedListCommand(view as unknown as EditorView); + + assert.equal(handled, false); + assert.equal(view.text, " - flag"); +}); + runTest("line prefix commands preserve indentation when toggled off", () => { const view = new FakeView(" - nested", { from: 0, to: 10 });