Add Markdown formatting toolbar and editor shortcuts
This commit is contained in:
parent
1ae9744b2b
commit
c09f4adc30
8 changed files with 718 additions and 2 deletions
|
|
@ -13,7 +13,7 @@
|
|||
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
|
||||
"start": "npm run build && npm run electron",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs",
|
||||
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-format-test.mjs",
|
||||
"package": "electron-builder",
|
||||
"test:e2e": "sh tests/run-e2e.sh"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)">↶</button>
|
||||
<button class="toolbar-btn" data-cmd="redo" title="Redo (Ctrl+Shift+Z)">↷</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)">↗</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="hr" title="Horizontal rule">―</button>
|
||||
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">⌫</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-panes" id="editorHost"></div>
|
||||
</div>
|
||||
<div class="editor-placeholder hidden" id="editorPlaceholder">
|
||||
|
|
|
|||
|
|
@ -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
267
src/renderer/md-format.ts
Normal 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;
|
||||
};
|
||||
235
tests/run-format-test.mjs
Normal file
235
tests/run-format-test.mjs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// Standalone test for the Markdown formatting commands (md-format.ts).
|
||||
// Bundles with esbuild and drives the commands through a plain
|
||||
// { state, dispatch } target, so no DOM is required.
|
||||
// Run with: node tests/run-format-test.mjs
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
// Write the bundle inside the repo so node can resolve the external
|
||||
// @codemirror/state peer dependency (same instance as the one imported below).
|
||||
const out = path.resolve(`tests/.tmp-folio-format-${process.pid}.mjs`);
|
||||
await esbuild.build({
|
||||
entryPoints: [path.resolve("src/renderer/md-format.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
external: ["@codemirror/state"],
|
||||
outfile: out,
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const mod = await import(url.pathToFileURL(out).href);
|
||||
const {
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStrikethrough,
|
||||
toggleInlineCode,
|
||||
toggleLink,
|
||||
setHeading,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleBlockquote,
|
||||
insertHorizontalRule,
|
||||
clearFormatting,
|
||||
headingLevelAt,
|
||||
} = mod;
|
||||
|
||||
// Build a minimal { state, dispatch } target around an EditorState.
|
||||
import { EditorState } from "@codemirror/state";
|
||||
|
||||
function target(doc, anchor, head = anchor) {
|
||||
let state = EditorState.create({
|
||||
doc,
|
||||
selection: { anchor, head },
|
||||
});
|
||||
const t = {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
dispatch(spec) {
|
||||
state = state.update(spec).state;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return { t, text: () => state.doc.toString(), sel: () => state.selection.main };
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) console.log(" ok -", msg);
|
||||
else {
|
||||
failures++;
|
||||
console.error("FAIL -", msg);
|
||||
}
|
||||
}
|
||||
function eq(actual, expected, msg) {
|
||||
ok(actual === expected, `${msg} (got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)})`);
|
||||
}
|
||||
|
||||
// ---- bold ----
|
||||
{
|
||||
const { t, text, sel } = target("say hello world", 4, 9); // "hello"
|
||||
toggleBold(t);
|
||||
eq(text(), "say **hello** world", "bold wraps selection");
|
||||
eq(sel().from, 6, "bold keeps selection on inner text (from)");
|
||||
eq(sel().to, 11, "bold keeps selection on inner text (to)");
|
||||
toggleBold(t); // cursor/selection still over inner text -> unwrap
|
||||
eq(text(), "say hello world", "bold toggles back off");
|
||||
}
|
||||
|
||||
// ---- bold with empty cursor ----
|
||||
{
|
||||
const { t, text, sel } = target("abc", 1);
|
||||
toggleBold(t);
|
||||
eq(text(), "a****bc", "bold on empty cursor inserts marker pair");
|
||||
eq(sel().from, 3, "bold cursor placed between markers");
|
||||
toggleBold(t);
|
||||
eq(text(), "abc", "bold on cursor toggles back off");
|
||||
}
|
||||
|
||||
// ---- bold unwrap when whole span selected ----
|
||||
{
|
||||
const { t, text, sel } = target("x **bold** y", 2, 10);
|
||||
toggleBold(t);
|
||||
eq(text(), "x bold y", "bold unwraps when selection includes markers");
|
||||
eq(sel().from, 2, "unwrapped selection anchor");
|
||||
eq(sel().to, 6, "unwrapped selection head");
|
||||
}
|
||||
|
||||
// ---- italic must not unwrap bold markers ----
|
||||
{
|
||||
const { t, text } = target("**bold**", 2, 6); // inside bold
|
||||
toggleItalic(t);
|
||||
eq(text(), "***bold***", "italic inside bold produces bold+italic, not a broken span");
|
||||
const { t: t2, text: text2 } = target("*em*", 1, 3); // inside italic
|
||||
toggleItalic(t2);
|
||||
eq(text2(), "em", "italic toggles off when already inside italic markers");
|
||||
const { t: t3, text: text3 } = target("*em*", 0, 4); // whole italic span
|
||||
toggleItalic(t3);
|
||||
eq(text3(), "em", "italic unwraps whole span");
|
||||
}
|
||||
|
||||
// ---- strikethrough ----
|
||||
{
|
||||
const { t, text, sel } = target("strike this text", 0, 6);
|
||||
toggleStrikethrough(t);
|
||||
eq(text(), "~~strike~~ this text", "strikethrough wraps selection");
|
||||
eq(sel().from, 2, "strikethrough selection from");
|
||||
toggleStrikethrough(t);
|
||||
eq(text(), "strike this text", "strikethrough toggles back off");
|
||||
}
|
||||
|
||||
// ---- inline code ----
|
||||
{
|
||||
const { t, text } = target("run `npm` and code here", 5, 8);
|
||||
toggleInlineCode(t);
|
||||
eq(text(), "run npm and code here", "inline code toggles off when already in a code span");
|
||||
const { t: t2, text: text2 } = target("var x = 1;", 0, 9);
|
||||
toggleInlineCode(t2);
|
||||
eq(text2(), "`var x = 1`;", "inline code wraps normal text");
|
||||
}
|
||||
|
||||
// ---- links ----
|
||||
{
|
||||
const { t, text, sel } = target("visit example", 6, 13);
|
||||
toggleLink(t);
|
||||
eq(text(), "visit [example](url)", "link wraps selection with url placeholder");
|
||||
eq(sel().from, 16, "link selects url placeholder (from)");
|
||||
eq(sel().to, 19, "link selects url placeholder (to)");
|
||||
}
|
||||
{
|
||||
const { t, text, sel } = target("ab", 1);
|
||||
toggleLink(t);
|
||||
eq(text(), "a[](url)b", "link on empty cursor inserts empty label");
|
||||
eq(sel().from, 2, "link cursor inside label brackets");
|
||||
}
|
||||
|
||||
// ---- headings ----
|
||||
{
|
||||
const { t, text } = target("Chapter one", 0);
|
||||
setHeading(2)(t);
|
||||
eq(text(), "## Chapter one", "setHeading adds level 2 marker");
|
||||
setHeading(3)(t);
|
||||
eq(text(), "### Chapter one", "setHeading changes existing level");
|
||||
setHeading(3)(t);
|
||||
eq(text(), "### Chapter one", "setHeading same level is a no-op");
|
||||
setHeading(0)(t);
|
||||
eq(text(), "Chapter one", "setHeading paragraph clears heading");
|
||||
}
|
||||
{
|
||||
const { t } = target("# Title", 3);
|
||||
ok(headingLevelAt(t.state, 3) === 1, "headingLevelAt detects level 1");
|
||||
const { t: t2 } = target("plain", 2);
|
||||
ok(headingLevelAt(t2.state, 2) === 0, "headingLevelAt returns 0 for plain line");
|
||||
}
|
||||
|
||||
// ---- bullet list ----
|
||||
{
|
||||
const { t, text } = target("one\ntwo\nthree", 0, 13);
|
||||
toggleBulletList(t);
|
||||
eq(text(), "- one\n- two\n- three", "bullet list prefixes each line");
|
||||
toggleBulletList(t);
|
||||
eq(text(), "one\ntwo\nthree", "bullet list toggles back off");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("already listed", 0);
|
||||
toggleBulletList(t);
|
||||
eq(text(), "- already listed", "bullet list works with cursor only");
|
||||
}
|
||||
|
||||
// ---- numbered list ----
|
||||
{
|
||||
const { t, text } = target("one\ntwo", 0, 7);
|
||||
toggleOrderedList(t);
|
||||
eq(text(), "1. one\n2. two", "numbered list numbers each line");
|
||||
toggleOrderedList(t);
|
||||
eq(text(), "one\ntwo", "numbered list toggles back off");
|
||||
}
|
||||
|
||||
// ---- blockquote ----
|
||||
{
|
||||
const { t, text } = target("quoted words", 0);
|
||||
toggleBlockquote(t);
|
||||
eq(text(), "> quoted words", "blockquote prefixes line");
|
||||
toggleBlockquote(t);
|
||||
eq(text(), "quoted words", "blockquote toggles back off");
|
||||
}
|
||||
|
||||
// ---- horizontal rule ----
|
||||
{
|
||||
const { t, text, sel } = target("", 0);
|
||||
insertHorizontalRule(t);
|
||||
eq(text(), "---\n\n", "hr inserts at doc start");
|
||||
eq(sel().from, 5, "hr cursor after rule");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("para\n\nnext", 5); // start of the empty line
|
||||
insertHorizontalRule(t);
|
||||
eq(text(), "para\n\n---\n\nnext", "hr inserts on empty line");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("mid | line", 4);
|
||||
insertHorizontalRule(t);
|
||||
eq(text(), "mid \n\n---\n\n| line", "hr splits a line in the middle");
|
||||
}
|
||||
|
||||
// ---- clear formatting ----
|
||||
{
|
||||
const { t, text } = target("## **Bold** and ~~strike~~", 0, 26);
|
||||
clearFormatting(t);
|
||||
eq(text(), "Bold and strike", "clear formatting strips heading, bold, strike");
|
||||
}
|
||||
{
|
||||
const { t, text } = target("- item\n> quote\n1. thing\n`code` and [label](https://x.com)", 0, 57);
|
||||
clearFormatting(t);
|
||||
eq(text(), "item\nquote\nthing\ncode and label", "clear formatting handles lists, quote, code, links");
|
||||
}
|
||||
|
||||
fs.rmSync(out, { force: true });
|
||||
|
||||
console.log("\nFormat tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -109,6 +109,43 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
|
|||
typedContent.includes("[link](https://example.com)") &&
|
||||
typedContent.includes("`inline code`");
|
||||
|
||||
// --- formatting toolbar + keyboard shortcuts ---
|
||||
if (cm) {
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" }, selection: { anchor: 0 } });
|
||||
cm.focus();
|
||||
cm.dispatch({ changes: { from: 0, to: 0, insert: "Hello World" }, selection: { anchor: 0, head: 5 } });
|
||||
|
||||
document.querySelector('[data-cmd="bold"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.toolbarBoldOk = cm.state.doc.toString() === "**Hello** World";
|
||||
out.focusAfterBold = document.activeElement === cm.contentDOM;
|
||||
|
||||
cm.dispatch({ selection: { anchor: 10, head: 15 } }); // "World"
|
||||
cm.contentDOM.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "b", ctrlKey: true, bubbles: true, cancelable: true })
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.shortcutBoldOk = cm.state.doc.toString() === "**Hello** **World**";
|
||||
|
||||
const headingSel = document.getElementById("headingSelect");
|
||||
headingSel.value = "2";
|
||||
headingSel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.headingOk = cm.state.doc.toString().startsWith("## ");
|
||||
|
||||
document.querySelector('[data-cmd="undo"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.undoOk = cm.state.doc.toString() === "**Hello** **World**";
|
||||
|
||||
document.querySelector('[data-cmd="redo"]').click();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.redoOk = cm.state.doc.toString().startsWith("## ");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce
|
||||
out.formattingAutosaved =
|
||||
(await window.folio.getChapterContent(typedId)).includes("**Hello**");
|
||||
}
|
||||
|
||||
out.all = true;
|
||||
} catch (e) {
|
||||
out.error = String((e && e.message) || e);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue