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
This commit is contained in:
avi 2026-08-17 13:02:42 -05:00
commit 45990bbd62
9 changed files with 185 additions and 12 deletions

View file

@ -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<BookResult | ErrResult> {
try {
const meta = loadBook(p);

View file

@ -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" },

View file

@ -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()),
});

View file

@ -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();

View file

@ -68,13 +68,15 @@
<button class="toolbar-btn b" data-cmd="bold" title="Bold (Ctrl+B)">B</button>
<button class="toolbar-btn i" data-cmd="italic" title="Italic (Ctrl+I)">I</button>
<button class="toolbar-btn s" data-cmd="strike" title="Strikethrough">S</button>
<button class="toolbar-btn code" data-cmd="code" title="Inline code">code</button>
<button class="toolbar-btn code" data-cmd="code" title="Inline code / code block">code</button>
<button class="toolbar-btn" data-cmd="link" title="Link (Ctrl+K)">&#8599;</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="bullet" title="Bulleted list">&#8226;</button>
<button class="toolbar-btn" data-cmd="number" title="Numbered list">1.</button>
<button class="toolbar-btn" data-cmd="quote" title="Blockquote">&#10077;</button>
<button class="toolbar-btn" data-cmd="indent" title="Indent (Tab)">&#8594;</button>
<button class="toolbar-btn" data-cmd="outdent" title="Outdent (Shift+Tab)">&#8592;</button>
<button class="toolbar-btn" data-cmd="hr" title="Horizontal rule">&#8213;</button>
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">&#9003;</button>
</div>

View file

@ -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 },
];

View file

@ -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;