From 45990bbd62bfb57819e6f49e2d3507472bbd38bc Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 17 Aug 2026 13:02:42 -0500 Subject: [PATCH] Add strikethrough shortcut, fenced code toggle, and CodeMirror-aware undo/redo - Bind Ctrl+Shift+S to strikethrough - Smart code toggle: fenced block for multi-line selections, inline otherwise - Link editing: selecting an existing link now selects its URL for editing - Route undo/redo shortcuts through IPC so CodeMirror history is used when the editor has focus instead of Electron's native menu-role undo/redo - Add Indent/Outdent toolbar buttons --- src/main/index.ts | 20 ++++++++++++++ src/main/menu.ts | 12 ++++++-- src/main/preload.ts | 4 +++ src/renderer/book.ts | 18 ++++++++++-- src/renderer/index.html | 4 ++- src/renderer/md-editor.ts | 3 +- src/renderer/md-format.ts | 56 ++++++++++++++++++++++++++++++++++++-- tests/run-format-test.mjs | 40 ++++++++++++++++++++++++++- tests/selftest-scenario.js | 40 ++++++++++++++++++++++++++- 9 files changed, 185 insertions(+), 12 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8bb9e15..3c6ff14 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -58,6 +58,7 @@ function createWindow() { preload: path.join(__dirname, "preload.js"), }, }); + forwardEditShortcuts(mainWindow); mainWindow.loadFile(path.join(__dirname, "index.html")); mainWindow.webContents.on("did-finish-load", () => { console.log("[folio] renderer loaded"); @@ -114,6 +115,25 @@ function notifyChange() { getWindow()?.webContents.send("folio:chapters-changed"); } +// Route undo/redo shortcuts to the renderer so CodeMirror's own history is +// used when the editor has focus, instead of Electron's native (menu-role) +// undo/redo which does not understand the editor's history. +function forwardEditShortcuts(win: BrowserWindow) { + win.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown" || !input.key) return; + const mod = input.control || input.meta; + if (!mod) return; + const key = input.key.toLowerCase(); + if (key === "z" && !input.shift) { + event.preventDefault(); + win.webContents.send("folio:undo-requested"); + } else if ((key === "z" && input.shift) || (key === "y" && !input.shift)) { + event.preventDefault(); + win.webContents.send("folio:redo-requested"); + } + }); +} + async function openBookAt(p: string): Promise { try { const meta = loadBook(p); diff --git a/src/main/menu.ts b/src/main/menu.ts index b9596c9..66fd612 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -66,8 +66,16 @@ export function buildMenu( const editMenu: MenuItem = { label: "Edit", submenu: [ - { role: "undo" }, - { role: "redo" }, + { + label: "Undo", + accelerator: "CmdOrCtrl+Z", + click: () => getWindow()?.webContents.send("folio:undo-requested"), + }, + { + label: "Redo", + accelerator: isMac ? "Cmd+Shift+Z" : "Ctrl+Shift+Z", + click: () => getWindow()?.webContents.send("folio:redo-requested"), + }, { type: "separator" }, { role: "cut" }, { role: "copy" }, diff --git a/src/main/preload.ts b/src/main/preload.ts index 201893c..601f4d0 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -47,4 +47,8 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.on("folio:export-markdown-requested", () => cb()), onExportZipRequested: (cb: () => void) => ipcRenderer.on("folio:export-zip-requested", () => cb()), + onUndoRequested: (cb: () => void) => + ipcRenderer.on("folio:undo-requested", () => cb()), + onRedoRequested: (cb: () => void) => + ipcRenderer.on("folio:redo-requested", () => cb()), }); diff --git a/src/renderer/book.ts b/src/renderer/book.ts index d66365e..e119aa8 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -2,7 +2,7 @@ import type { BookMeta } from "../main/project.js"; import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; import { createMdEditor, editorText, setEditorText } from "./md-editor.js"; import type { EditorView } from "@codemirror/view"; -import { redo, undo } from "@codemirror/commands"; +import { redo, undo, indentLess, indentMore } from "@codemirror/commands"; import { clearFormatting, headingLevelAt, @@ -11,7 +11,7 @@ import { toggleBlockquote, toggleBold, toggleBulletList, - toggleInlineCode, + toggleCode, toggleItalic, toggleLink, toggleOrderedList, @@ -45,6 +45,8 @@ export type FolioAPI = { onRevealRequested: (cb: (p: string) => void) => void; onExportMarkdownRequested: (cb: () => void) => void; onExportZipRequested: (cb: () => void) => void; + onUndoRequested: (cb: () => void) => void; + onRedoRequested: (cb: () => void) => void; }; const $ = (id: string) => document.getElementById(id) as HTMLElement; @@ -102,11 +104,13 @@ export function initBook(api: FolioAPI) { bold: toggleBold, italic: toggleItalic, strike: toggleStrikethrough, - code: toggleInlineCode, + code: toggleCode, link: toggleLink, bullet: toggleBulletList, number: toggleOrderedList, quote: toggleBlockquote, + indent: indentMore, + outdent: indentLess, hr: insertHorizontalRule, clear: clearFormatting, }; @@ -537,6 +541,14 @@ export function initBook(api: FolioAPI) { api.onRevealRequested((p) => void api.revealInFolder(p)); api.onExportMarkdownRequested(() => void exportMarkdown()); api.onExportZipRequested(() => void exportZip()); + api.onUndoRequested(() => { + if (editorView.hasFocus) undo(editorView); + else document.execCommand("undo"); + }); + api.onRedoRequested(() => { + if (editorView.hasFocus) redo(editorView); + else document.execCommand("redo"); + }); setChapterButtons(false); void renderRecents(); diff --git a/src/renderer/index.html b/src/renderer/index.html index b625eeb..0b98fd9 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -68,13 +68,15 @@ - +
+ +
diff --git a/src/renderer/md-editor.ts b/src/renderer/md-editor.ts index 129ea60..138aa4a 100644 --- a/src/renderer/md-editor.ts +++ b/src/renderer/md-editor.ts @@ -17,7 +17,7 @@ import { import { markdown } from "@codemirror/lang-markdown"; import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search"; import { tags } from "@lezer/highlight"; -import { toggleBold, toggleItalic, toggleLink } from "./md-format.js"; +import { toggleBold, toggleItalic, toggleLink, toggleStrikethrough } from "./md-format.js"; export interface MdEditorCallbacks { onChange: (text: string) => void; @@ -27,6 +27,7 @@ export interface MdEditorCallbacks { const formatBindings: KeyBinding[] = [ { key: "Mod-b", run: toggleBold, preventDefault: true }, { key: "Mod-i", run: toggleItalic, preventDefault: true }, + { key: "Mod-Shift-s", run: toggleStrikethrough, preventDefault: true }, { key: "Mod-k", run: toggleLink, preventDefault: true }, ]; diff --git a/src/renderer/md-format.ts b/src/renderer/md-format.ts index c260d3a..3e9c54c 100644 --- a/src/renderer/md-format.ts +++ b/src/renderer/md-format.ts @@ -98,6 +98,44 @@ export const toggleItalic: FormatCommand = toggleInline("*"); export const toggleStrikethrough: FormatCommand = toggleInline("~~"); export const toggleInlineCode: FormatCommand = toggleInline("`"); +/* ------------------------------------------------------------------ */ +/* Code: inline for single-line/empty selection, fenced for blocks */ +/* ------------------------------------------------------------------ */ + +const fenceLineRe = /^```[a-zA-Z0-9_-]*[ \t]*$/; + +export const toggleCode: FormatCommand = (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const first = state.doc.lineAt(from); + const last = state.doc.lineAt(to); + + if (first.number === last.number) { + return toggleInlineCode(target); + } + + let start = first.from; + let end = last.to; + let region = slice(state, start, end); + let lines = region.split("\n"); + + // Selection starts at an opening fence: treat it as a fenced-block toggle. + if (lines.length > 1 && fenceLineRe.test(lines[0])) { + if (!fenceLineRe.test(lines[lines.length - 1]) && last.number < state.doc.lines) { + const next = state.doc.line(last.number + 1); + if (fenceLineRe.test(next.text)) end = next.to; + } + region = slice(state, start, end); + lines = region.split("\n"); + const body = lines.slice(1, lines.length > 1 && fenceLineRe.test(lines[lines.length - 1]) ? -1 : lines.length); + dispatch({ changes: { from: start, to: end, insert: body.join("\n") } }); + return true; + } + + dispatch({ changes: { from: start, to: end, insert: "```\n" + region + "\n```" } }); + return true; +}; + /* ------------------------------------------------------------------ */ /* Links */ /* ------------------------------------------------------------------ */ @@ -106,6 +144,19 @@ export const toggleLink: FormatCommand = (target) => { const { state, dispatch } = target; const { from, to } = state.selection.main; + const text = slice(state, from, to); + + // Selection is already a link → select its URL so the user can edit it. + const link = /^\[([^\]\n]*)\]\(([^)\n]*)\)$/.exec(text); + if (link) { + const urlFrom = from + 1 + link[1].length + 2; + dispatch({ + selection: EditorSelection.range(urlFrom, urlFrom + link[2].length), + scrollIntoView: true, + }); + return true; + } + if (from === to) { const pos = from; dispatch({ @@ -115,10 +166,9 @@ export const toggleLink: FormatCommand = (target) => { return true; } - const label = slice(state, from, to); - const urlStart = from + label.length + 3; + const urlStart = from + text.length + 3; dispatch({ - changes: { from, to, insert: `[${label}](url)` }, + changes: { from, to, insert: `[${text}](url)` }, selection: EditorSelection.range(urlStart, urlStart + 3), }); return true; diff --git a/tests/run-format-test.mjs b/tests/run-format-test.mjs index d6fc8db..37bfd7f 100644 --- a/tests/run-format-test.mjs +++ b/tests/run-format-test.mjs @@ -28,6 +28,7 @@ const { toggleItalic, toggleStrikethrough, toggleInlineCode, + toggleCode, toggleLink, setHeading, toggleBulletList, @@ -55,7 +56,12 @@ function target(doc, anchor, head = anchor) { return true; }, }; - return { t, text: () => state.doc.toString(), sel: () => state.selection.main }; + return { + t, + text: () => state.doc.toString(), + sel: () => state.selection.main, + select: (a, h = a) => (state = state.update({ selection: { anchor: a, head: h } }).state), + }; } let failures = 0; @@ -147,6 +153,38 @@ function eq(actual, expected, msg) { eq(text(), "a[](url)b", "link on empty cursor inserts empty label"); eq(sel().from, 2, "link cursor inside label brackets"); } +{ + const { t, text, sel } = target("see [docs](old-url) here", 4, 19); + toggleLink(t); + eq(text(), "see [docs](old-url) here", "link edit does not change text"); + eq(sel().from, 11, "link edit selects url (from)"); + eq(sel().to, 18, "link edit selects url (to)"); +} + +// ---- code: inline vs fenced ---- +{ + const { t, text } = target("var x = 1;", 0, 9); + toggleCode(t); + eq(text(), "`var x = 1`;", "code on single line uses inline markers"); +} +{ + const { t, text, select } = target("line one\nline two", 0, 16); + toggleCode(t); + eq(text(), "```\nline one\nline two\n```", "code on multiple lines uses a fenced block"); + select(0, 25); + toggleCode(t); + eq(text(), "line one\nline two", "fenced code toggles back off"); +} +{ + const { t, text } = target("before\n```\ncode a\ncode b\n```\nafter", 7, 24); + toggleCode(t); + eq(text(), "before\ncode a\ncode b\nafter", "fenced code unwraps when selection starts at the fence"); +} +{ + const { t, text } = target("abc", 1); + toggleCode(t); + eq(text(), "a``bc", "code on empty cursor inserts inline markers"); +} // ---- headings ---- { diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index 16357f3..f97618c 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -141,12 +141,50 @@ const BOOK_DIR = __BOOKDIR_VALUE__; await new Promise((r) => setTimeout(r, 50)); out.redoOk = cm.state.doc.toString().startsWith("## "); + cm.dispatch({ selection: { anchor: 15, head: 20 } }); // "World" + cm.contentDOM.dispatchEvent( + new KeyboardEvent("keydown", { + key: "s", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ); + await new Promise((r) => setTimeout(r, 50)); + out.strikeShortcutOk = cm.state.doc.toString().includes("~~World~~"); + + cm.dispatch({ + changes: { from: 0, to: 0, insert: "line one\nline two\n" }, + selection: { anchor: 0, head: 16 }, + }); + document.querySelector('[data-cmd="code"]').click(); + await new Promise((r) => setTimeout(r, 50)); + out.fencedCodeOk = cm.state.doc.toString().startsWith("```\nline one\nline two\n```"); + await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce out.formattingAutosaved = (await window.folio.getChapterContent(typedId)).includes("**Hello**"); } - out.all = true; + const okFlags = [ + "dndMoved", + "kbMoved", + "modalShown", + "uiCreated", + "hasEditor", + "editorAutosaved", + "toolbarBoldOk", + "focusAfterBold", + "shortcutBoldOk", + "headingOk", + "undoOk", + "redoOk", + "strikeShortcutOk", + "fencedCodeOk", + "formattingAutosaved", + ]; + out.all = okFlags.every((f) => out[f] === true); } catch (e) { out.error = String((e && e.message) || e); }