Add Markdown formatting toolbar and editor shortcuts

This commit is contained in:
avi 2026-08-17 12:28:49 -05:00
commit c09f4adc30
8 changed files with 718 additions and 2 deletions

View file

@ -2,6 +2,21 @@ 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 {
clearFormatting,
headingLevelAt,
insertHorizontalRule,
setHeading,
toggleBlockquote,
toggleBold,
toggleBulletList,
toggleInlineCode,
toggleItalic,
toggleLink,
toggleOrderedList,
toggleStrikethrough,
} from "./md-format.js";
export type FolioAPI = {
newBook: () => Promise<any>;
@ -74,6 +89,45 @@ export function initBook(api: FolioAPI) {
const editorView: EditorView = createMdEditor(editorHost, {
onChange: (text) => handleEditorChange(text),
onSelection: (view) => {
headingSelect.value = String(headingLevelAt(view.state, view.state.selection.main.head));
},
});
const toolbar = $("editorToolbar");
const headingSelect = $("headingSelect") as HTMLSelectElement;
const formatCommands: Record<string, (view: EditorView) => boolean> = {
undo,
redo,
bold: toggleBold,
italic: toggleItalic,
strike: toggleStrikethrough,
code: toggleInlineCode,
link: toggleLink,
bullet: toggleBulletList,
number: toggleOrderedList,
quote: toggleBlockquote,
hr: insertHorizontalRule,
clear: clearFormatting,
};
toolbar.addEventListener("pointerdown", (e) => {
if ((e.target as HTMLElement).closest("button, select")) e.preventDefault();
});
toolbar.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest("button[data-cmd]") as HTMLButtonElement | null;
if (!btn) return;
const cmd = formatCommands[btn.dataset.cmd ?? ""];
if (cmd) {
cmd(editorView);
editorView.focus();
}
});
headingSelect.addEventListener("change", () => {
setHeading(Number(headingSelect.value))(editorView);
editorView.focus();
});
function setChapterButtons(enabled: boolean) {

View file

@ -445,6 +445,86 @@ html, body {
overflow: hidden;
}
/* ---- editor toolbar ---- */
.editor-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
padding: 6px 12px;
border-bottom: 1px solid var(--color-border);
background: var(--color-panel);
user-select: none;
-webkit-user-select: none;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 2px;
padding-right: 8px;
margin-right: 8px;
border-right: 1px solid var(--color-border);
}
.toolbar-group:last-child {
border-right: none;
margin-right: 0;
padding-right: 0;
}
.toolbar-btn {
min-width: 30px;
height: 28px;
padding: 0 7px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.toolbar-btn:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.toolbar-btn:active {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.toolbar-btn.b { font-weight: 700; }
.toolbar-btn.i { font-style: italic; }
.toolbar-btn.s { text-decoration: line-through; }
.toolbar-btn.code {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.75rem;
}
.toolbar-select {
height: 28px;
max-width: 130px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.82rem;
padding: 0 6px;
cursor: pointer;
outline: none;
}
.toolbar-select:focus {
border-color: var(--color-accent);
}
.cm-editor {
flex: 1;
min-width: 0;

View file

@ -48,6 +48,37 @@
<button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button>
</div>
</header>
<div class="editor-toolbar" id="editorToolbar">
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="undo" title="Undo (Ctrl+Z)">&#8630;</button>
<button class="toolbar-btn" data-cmd="redo" title="Redo (Ctrl+Shift+Z)">&#8631;</button>
</div>
<div class="toolbar-group">
<select id="headingSelect" class="toolbar-select" title="Paragraph / heading">
<option value="0">Paragraph</option>
<option value="1">Heading 1</option>
<option value="2">Heading 2</option>
<option value="3">Heading 3</option>
<option value="4">Heading 4</option>
<option value="5">Heading 5</option>
<option value="6">Heading 6</option>
</select>
</div>
<div class="toolbar-group">
<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" 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="hr" title="Horizontal rule">&#8213;</button>
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">&#9003;</button>
</div>
</div>
<div class="editor-panes" id="editorHost"></div>
</div>
<div class="editor-placeholder hidden" id="editorPlaceholder">

View file

@ -5,6 +5,7 @@ import {
dropCursor,
highlightActiveLine,
keymap,
type KeyBinding,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import {
@ -16,11 +17,19 @@ 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";
export interface MdEditorCallbacks {
onChange: (text: string) => void;
onSelection?: (view: EditorView) => void;
}
const formatBindings: KeyBinding[] = [
{ key: "Mod-b", run: toggleBold, preventDefault: true },
{ key: "Mod-i", run: toggleItalic, preventDefault: true },
{ key: "Mod-k", run: toggleLink, preventDefault: true },
];
const mdHighlight = HighlightStyle.define([
{ tag: tags.heading1, class: "cm-h1" },
{ tag: tags.heading2, class: "cm-h2" },
@ -106,9 +115,12 @@ export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks):
search({ top: true }),
syntaxHighlighting(mdHighlight),
folioTheme,
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]),
keymap.of([...formatBindings, ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]),
EditorView.updateListener.of((update) => {
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
if (callbacks.onSelection && (update.docChanged || update.selectionSet)) {
callbacks.onSelection(update.view);
}
}),
],
}),

267
src/renderer/md-format.ts Normal file
View file

@ -0,0 +1,267 @@
import {
EditorSelection,
type EditorState,
type Transaction,
type TransactionSpec,
} from "@codemirror/state";
/**
* Minimal command target: the functions in this module only need read access
* to an editor state and a way to dispatch a transaction. Both `EditorView`
* and a plain { state, dispatch } object satisfy it, which keeps the module
* testable without a DOM.
*/
export interface FormatTarget {
state: EditorState;
dispatch: (tr: Transaction | TransactionSpec) => void;
}
type FormatCommand = (target: FormatTarget) => boolean;
function slice(state: EditorState, from: number, to: number): string {
return state.doc.sliceString(from, to);
}
function lineRange(state: EditorState, from: number, to: number): { start: number; end: number } {
const first = state.doc.lineAt(from);
const last = state.doc.lineAt(to);
return { start: first.from, end: last.to };
}
/* ------------------------------------------------------------------ */
/* Inline markers: bold, italic, strikethrough, inline code */
/* ------------------------------------------------------------------ */
function toggleInline(marker: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const len = marker.length;
const single = len === 1;
const before = from - len >= 0 ? slice(state, from - len, from) : "";
const after = slice(state, to, to + len);
const isolatedBefore =
!single || from - len - 1 < 0 || slice(state, from - len - 1, from - len) !== marker;
const isolatedAfter =
!single || to + len >= state.doc.length || slice(state, to + len, to + len + 1) !== marker;
if (before === marker && after === marker && isolatedBefore && isolatedAfter) {
dispatch({
changes: [
{ from: from - len, to: from, insert: "" },
{ from: to, to: to + len, insert: "" },
],
selection: EditorSelection.single(from - len, to - len),
});
return true;
}
const text = slice(state, from, to);
const textStartsMarked = text.startsWith(marker);
const textEndsMarked = text.endsWith(marker);
const isolatedInnerBefore =
!single || text.length < len * 2 + 1 || text.charAt(len) !== marker;
const isolatedInnerAfter =
!single || text.length < len * 2 + 1 || text.charAt(text.length - len - 1) !== marker;
if (
text.length >= len * 2 &&
textStartsMarked &&
textEndsMarked &&
isolatedInnerBefore &&
isolatedInnerAfter
) {
dispatch({
changes: [
{ from, to: from + len, insert: "" },
{ from: to - len, to, insert: "" },
],
selection: EditorSelection.single(from, to - len * 2),
});
return true;
}
dispatch({
changes: [
{ from, to: from, insert: marker },
{ from: to, to: to, insert: marker },
],
selection: EditorSelection.single(from + len, to + len),
});
return true;
};
}
export const toggleBold: FormatCommand = toggleInline("**");
export const toggleItalic: FormatCommand = toggleInline("*");
export const toggleStrikethrough: FormatCommand = toggleInline("~~");
export const toggleInlineCode: FormatCommand = toggleInline("`");
/* ------------------------------------------------------------------ */
/* Links */
/* ------------------------------------------------------------------ */
export const toggleLink: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
if (from === to) {
const pos = from;
dispatch({
changes: { from: pos, to: pos, insert: "[](url)" },
selection: EditorSelection.cursor(pos + 1),
});
return true;
}
const label = slice(state, from, to);
const urlStart = from + label.length + 3;
dispatch({
changes: { from, to, insert: `[${label}](url)` },
selection: EditorSelection.range(urlStart, urlStart + 3),
});
return true;
};
/* ------------------------------------------------------------------ */
/* Headings */
/* ------------------------------------------------------------------ */
const headingRe = /^(#{1,6})[ \t]/;
export function setHeading(level: number): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from } = state.selection.main;
const line = state.doc.lineAt(from);
const match = line.text.match(headingRe);
const existing = match ? match[1].length : 0;
if (level === 0) {
if (!existing) return false;
dispatch({ changes: { from: line.from, to: line.from + existing + 1, insert: "" } });
return true;
}
if (existing === level) return false;
const insert = "#".repeat(level) + " ";
dispatch({
changes: existing
? { from: line.from, to: line.from + existing + 1, insert }
: { from: line.from, to: line.from, insert },
});
return true;
};
}
export function headingLevelAt(state: EditorState, pos: number): number {
const line = state.doc.lineAt(pos);
const match = line.text.match(headingRe);
return match ? match[1].length : 0;
}
/* ------------------------------------------------------------------ */
/* Line prefixes: bullet list, blockquote */
/* ------------------------------------------------------------------ */
function toggleLinePrefix(prefix: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const lines = slice(state, start, end).split("\n");
if (!lines.length) return false;
const allHave = lines.every((line) => line.startsWith(prefix));
const insert = lines.map((line) => (allHave ? line.slice(prefix.length) : prefix + line)).join("\n");
dispatch({ changes: { from: start, to: end, insert } });
return true;
};
}
export const toggleBulletList: FormatCommand = toggleLinePrefix("- ");
export const toggleBlockquote: FormatCommand = toggleLinePrefix("> ");
/* ------------------------------------------------------------------ */
/* Numbered list */
/* ------------------------------------------------------------------ */
const orderedRe = /^\d+\.\s+/;
export const toggleOrderedList: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const lines = slice(state, start, end).split("\n");
if (!lines.length) return false;
const allHave = lines.every((line) => orderedRe.test(line));
const insert = lines
.map((line, index) => {
const stripped = line.replace(orderedRe, "");
return allHave ? stripped : `${index + 1}. ${stripped}`;
})
.join("\n");
dispatch({ changes: { from: start, to: end, insert } });
return true;
};
/* ------------------------------------------------------------------ */
/* Horizontal rule */
/* ------------------------------------------------------------------ */
export const insertHorizontalRule: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from } = state.selection.main;
if (from === 0) {
dispatch({
changes: { from: 0, to: 0, insert: "---\n\n" },
selection: EditorSelection.cursor(5),
});
return true;
}
const line = state.doc.lineAt(from);
if (line.from === from && line.text.length === 0) {
const hasNextLine =
line.to < state.doc.length && slice(state, line.to, line.to + 1) === "\n";
const insertAt = hasNextLine ? line.to + 1 : line.from;
dispatch({
changes: { from: insertAt, to: insertAt, insert: "---\n\n" },
selection: EditorSelection.cursor(insertAt + 5),
});
return true;
}
dispatch({
changes: { from, to: from, insert: "\n\n---\n\n" },
selection: EditorSelection.cursor(from + 6),
});
return true;
};
/* ------------------------------------------------------------------ */
/* Clear formatting */
/* ------------------------------------------------------------------ */
function clearLine(line: string): string {
let out = line;
out = out.replace(/^#{1,6}[ \t]+/, "");
out = out.replace(/^>\s*/, "");
out = out.replace(/^[-*+]\s+/, "");
out = out.replace(/^\d+\.\s+/, "");
out = out.replace(/\[([^\]\n]+)\]\([^)\n]*\)/g, "$1");
out = out.replace(/\*\*|\*|~~|`/g, "");
return out;
}
export const clearFormatting: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const cleaned = region.split("\n").map(clearLine).join("\n");
if (cleaned === region) return false;
dispatch({ changes: { from: start, to: end, insert: cleaned } });
return true;
};