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:
parent
c09f4adc30
commit
45990bbd62
9 changed files with 185 additions and 12 deletions
|
|
@ -58,6 +58,7 @@ function createWindow() {
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
forwardEditShortcuts(mainWindow);
|
||||||
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
||||||
mainWindow.webContents.on("did-finish-load", () => {
|
mainWindow.webContents.on("did-finish-load", () => {
|
||||||
console.log("[folio] renderer loaded");
|
console.log("[folio] renderer loaded");
|
||||||
|
|
@ -114,6 +115,25 @@ function notifyChange() {
|
||||||
getWindow()?.webContents.send("folio:chapters-changed");
|
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> {
|
async function openBookAt(p: string): Promise<BookResult | ErrResult> {
|
||||||
try {
|
try {
|
||||||
const meta = loadBook(p);
|
const meta = loadBook(p);
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,16 @@ export function buildMenu(
|
||||||
const editMenu: MenuItem = {
|
const editMenu: MenuItem = {
|
||||||
label: "Edit",
|
label: "Edit",
|
||||||
submenu: [
|
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" },
|
{ type: "separator" },
|
||||||
{ role: "cut" },
|
{ role: "cut" },
|
||||||
{ role: "copy" },
|
{ role: "copy" },
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,8 @@ contextBridge.exposeInMainWorld("folio", {
|
||||||
ipcRenderer.on("folio:export-markdown-requested", () => cb()),
|
ipcRenderer.on("folio:export-markdown-requested", () => cb()),
|
||||||
onExportZipRequested: (cb: () => void) =>
|
onExportZipRequested: (cb: () => void) =>
|
||||||
ipcRenderer.on("folio:export-zip-requested", () => cb()),
|
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()),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { BookMeta } from "../main/project.js";
|
||||||
import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
|
import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
|
||||||
import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
|
import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
import { redo, undo } from "@codemirror/commands";
|
import { redo, undo, indentLess, indentMore } from "@codemirror/commands";
|
||||||
import {
|
import {
|
||||||
clearFormatting,
|
clearFormatting,
|
||||||
headingLevelAt,
|
headingLevelAt,
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
toggleBlockquote,
|
toggleBlockquote,
|
||||||
toggleBold,
|
toggleBold,
|
||||||
toggleBulletList,
|
toggleBulletList,
|
||||||
toggleInlineCode,
|
toggleCode,
|
||||||
toggleItalic,
|
toggleItalic,
|
||||||
toggleLink,
|
toggleLink,
|
||||||
toggleOrderedList,
|
toggleOrderedList,
|
||||||
|
|
@ -45,6 +45,8 @@ export type FolioAPI = {
|
||||||
onRevealRequested: (cb: (p: string) => void) => void;
|
onRevealRequested: (cb: (p: string) => void) => void;
|
||||||
onExportMarkdownRequested: (cb: () => void) => void;
|
onExportMarkdownRequested: (cb: () => void) => void;
|
||||||
onExportZipRequested: (cb: () => void) => void;
|
onExportZipRequested: (cb: () => void) => void;
|
||||||
|
onUndoRequested: (cb: () => void) => void;
|
||||||
|
onRedoRequested: (cb: () => void) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
||||||
|
|
@ -102,11 +104,13 @@ export function initBook(api: FolioAPI) {
|
||||||
bold: toggleBold,
|
bold: toggleBold,
|
||||||
italic: toggleItalic,
|
italic: toggleItalic,
|
||||||
strike: toggleStrikethrough,
|
strike: toggleStrikethrough,
|
||||||
code: toggleInlineCode,
|
code: toggleCode,
|
||||||
link: toggleLink,
|
link: toggleLink,
|
||||||
bullet: toggleBulletList,
|
bullet: toggleBulletList,
|
||||||
number: toggleOrderedList,
|
number: toggleOrderedList,
|
||||||
quote: toggleBlockquote,
|
quote: toggleBlockquote,
|
||||||
|
indent: indentMore,
|
||||||
|
outdent: indentLess,
|
||||||
hr: insertHorizontalRule,
|
hr: insertHorizontalRule,
|
||||||
clear: clearFormatting,
|
clear: clearFormatting,
|
||||||
};
|
};
|
||||||
|
|
@ -537,6 +541,14 @@ export function initBook(api: FolioAPI) {
|
||||||
api.onRevealRequested((p) => void api.revealInFolder(p));
|
api.onRevealRequested((p) => void api.revealInFolder(p));
|
||||||
api.onExportMarkdownRequested(() => void exportMarkdown());
|
api.onExportMarkdownRequested(() => void exportMarkdown());
|
||||||
api.onExportZipRequested(() => void exportZip());
|
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);
|
setChapterButtons(false);
|
||||||
void renderRecents();
|
void renderRecents();
|
||||||
|
|
|
||||||
|
|
@ -68,13 +68,15 @@
|
||||||
<button class="toolbar-btn b" data-cmd="bold" title="Bold (Ctrl+B)">B</button>
|
<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 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 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)">↗</button>
|
<button class="toolbar-btn" data-cmd="link" title="Link (Ctrl+K)">↗</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<button class="toolbar-btn" data-cmd="bullet" title="Bulleted list">•</button>
|
<button class="toolbar-btn" data-cmd="bullet" title="Bulleted list">•</button>
|
||||||
<button class="toolbar-btn" data-cmd="number" title="Numbered list">1.</button>
|
<button class="toolbar-btn" data-cmd="number" title="Numbered list">1.</button>
|
||||||
<button class="toolbar-btn" data-cmd="quote" title="Blockquote">❝</button>
|
<button class="toolbar-btn" data-cmd="quote" title="Blockquote">❝</button>
|
||||||
|
<button class="toolbar-btn" data-cmd="indent" title="Indent (Tab)">→</button>
|
||||||
|
<button class="toolbar-btn" data-cmd="outdent" title="Outdent (Shift+Tab)">←</button>
|
||||||
<button class="toolbar-btn" data-cmd="hr" title="Horizontal rule">―</button>
|
<button class="toolbar-btn" data-cmd="hr" title="Horizontal rule">―</button>
|
||||||
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">⌫</button>
|
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">⌫</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
import { markdown } from "@codemirror/lang-markdown";
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
|
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
|
||||||
import { tags } from "@lezer/highlight";
|
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 {
|
export interface MdEditorCallbacks {
|
||||||
onChange: (text: string) => void;
|
onChange: (text: string) => void;
|
||||||
|
|
@ -27,6 +27,7 @@ export interface MdEditorCallbacks {
|
||||||
const formatBindings: KeyBinding[] = [
|
const formatBindings: KeyBinding[] = [
|
||||||
{ key: "Mod-b", run: toggleBold, preventDefault: true },
|
{ key: "Mod-b", run: toggleBold, preventDefault: true },
|
||||||
{ key: "Mod-i", run: toggleItalic, preventDefault: true },
|
{ key: "Mod-i", run: toggleItalic, preventDefault: true },
|
||||||
|
{ key: "Mod-Shift-s", run: toggleStrikethrough, preventDefault: true },
|
||||||
{ key: "Mod-k", run: toggleLink, preventDefault: true },
|
{ key: "Mod-k", run: toggleLink, preventDefault: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,44 @@ export const toggleItalic: FormatCommand = toggleInline("*");
|
||||||
export const toggleStrikethrough: FormatCommand = toggleInline("~~");
|
export const toggleStrikethrough: FormatCommand = toggleInline("~~");
|
||||||
export const toggleInlineCode: 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 */
|
/* Links */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
@ -106,6 +144,19 @@ export const toggleLink: FormatCommand = (target) => {
|
||||||
const { state, dispatch } = target;
|
const { state, dispatch } = target;
|
||||||
const { from, to } = state.selection.main;
|
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) {
|
if (from === to) {
|
||||||
const pos = from;
|
const pos = from;
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
@ -115,10 +166,9 @@ export const toggleLink: FormatCommand = (target) => {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = slice(state, from, to);
|
const urlStart = from + text.length + 3;
|
||||||
const urlStart = from + label.length + 3;
|
|
||||||
dispatch({
|
dispatch({
|
||||||
changes: { from, to, insert: `[${label}](url)` },
|
changes: { from, to, insert: `[${text}](url)` },
|
||||||
selection: EditorSelection.range(urlStart, urlStart + 3),
|
selection: EditorSelection.range(urlStart, urlStart + 3),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const {
|
||||||
toggleItalic,
|
toggleItalic,
|
||||||
toggleStrikethrough,
|
toggleStrikethrough,
|
||||||
toggleInlineCode,
|
toggleInlineCode,
|
||||||
|
toggleCode,
|
||||||
toggleLink,
|
toggleLink,
|
||||||
setHeading,
|
setHeading,
|
||||||
toggleBulletList,
|
toggleBulletList,
|
||||||
|
|
@ -55,7 +56,12 @@ function target(doc, anchor, head = anchor) {
|
||||||
return true;
|
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;
|
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(text(), "a[](url)b", "link on empty cursor inserts empty label");
|
||||||
eq(sel().from, 2, "link cursor inside label brackets");
|
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 ----
|
// ---- headings ----
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -141,12 +141,50 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
out.redoOk = cm.state.doc.toString().startsWith("## ");
|
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
|
await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce
|
||||||
out.formattingAutosaved =
|
out.formattingAutosaved =
|
||||||
(await window.folio.getChapterContent(typedId)).includes("**Hello**");
|
(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) {
|
} catch (e) {
|
||||||
out.error = String((e && e.message) || e);
|
out.error = String((e && e.message) || e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue