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"),
|
||||
},
|
||||
});
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)">↗</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<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="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="clear" title="Clear formatting">⌫</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue