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;

View file

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

View file

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