Replace split textarea/preview editor with a single CodeMirror Markdown editor

This commit is contained in:
avi 2026-08-17 11:15:23 -05:00
commit 1ae9744b2b
7 changed files with 451 additions and 200 deletions

239
package-lock.json generated
View file

@ -9,6 +9,13 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.0",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.9",
"@lezer/highlight": "^1.2.3",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"markdown-it": "^15.0.0" "markdown-it": "^15.0.0"
}, },
@ -22,6 +29,147 @@
"wait-on": "^8.0.0" "wait-on": "^8.0.0"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz",
"integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.9",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz",
"integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@electron/get": { "node_modules/@electron/get": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
@ -523,6 +671,79 @@
"@hapi/hoek": "^11.0.2" "@hapi/hoek": "^11.0.2"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.6.tgz",
"integrity": "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
"integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@sindresorhus/is": { "node_modules/@sindresorhus/is": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@ -1144,6 +1365,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2502,6 +2729,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sumchecker": { "node_modules/sumchecker": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@ -2635,6 +2868,12 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wait-on": { "node_modules/wait-on": {
"version": "8.0.5", "version": "8.0.5",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz",

View file

@ -27,6 +27,13 @@
"wait-on": "^8.0.0" "wait-on": "^8.0.0"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.0",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.9",
"@lezer/highlight": "^1.2.3",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"markdown-it": "^15.0.0" "markdown-it": "^15.0.0"
} }

View file

@ -1,6 +1,7 @@
import type { BookMeta } from "../main/project.js"; import type { BookMeta } from "../main/project.js";
import markdownIt from "markdown-it";
import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
import type { EditorView } from "@codemirror/view";
export type FolioAPI = { export type FolioAPI = {
newBook: () => Promise<any>; newBook: () => Promise<any>;
@ -63,30 +64,24 @@ export function initBook(api: FolioAPI) {
const editorPlaceholder = $("editorPlaceholder"); const editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView"); const chapterView = $("chapterView");
const chapterTitleEl = $("chapterTitle"); const chapterTitleEl = $("chapterTitle");
const sourceArea = $("sourceArea") as HTMLTextAreaElement; const editorHost = $("editorHost");
const editorPreview = $("editorPreview");
const wordCountEl = $("wordCount"); const wordCountEl = $("wordCount");
const saveStateEl = $("saveState"); const saveStateEl = $("saveState");
const findBtn = $("findBtn") as HTMLButtonElement;
const findBar = $("findBar");
const findIn = $("findIn") as HTMLInputElement;
const findPrevBtn = $("findPrev") as HTMLButtonElement;
const findNextBtn = $("findNext") as HTMLButtonElement;
const findCloseBtn = $("findClose") as HTMLButtonElement;
const exportBtn = $("exportBtn") as HTMLButtonElement; const exportBtn = $("exportBtn") as HTMLButtonElement;
const exportZipBtn = $("exportZipBtn") as HTMLButtonElement; const exportZipBtn = $("exportZipBtn") as HTMLButtonElement;
const addChapterBtn = $("addChapterBtn") as HTMLButtonElement; const addChapterBtn = $("addChapterBtn") as HTMLButtonElement;
const projectBar = $("projectBar"); const projectBar = $("projectBar");
const editorView: EditorView = createMdEditor(editorHost, {
onChange: (text) => handleEditorChange(text),
});
function setChapterButtons(enabled: boolean) { function setChapterButtons(enabled: boolean) {
addChapterBtn.disabled = !enabled; addChapterBtn.disabled = !enabled;
newChapterBtn.disabled = !enabled; newChapterBtn.disabled = !enabled;
} }
const md = markdownIt({ html: false, linkify: true, typographer: true });
let dirty = false; let dirty = false;
let loadedContent = ""; let loadedContent = "";
let renderTimer: ReturnType<typeof setTimeout> | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
const bookTitleEl = $("bookTitle"); const bookTitleEl = $("bookTitle");
const bookPathEl = $("bookPath"); const bookPathEl = $("bookPath");
@ -192,8 +187,8 @@ export function initBook(api: FolioAPI) {
async function selectChapter(id: string) { async function selectChapter(id: string) {
if (selectedId && dirty) { if (selectedId && dirty) {
const res = await api.saveChapter(selectedId, sourceArea.value); const res = await api.saveChapter(selectedId, editorText(editorView));
if (!(res && "error" in res)) loadedContent = sourceArea.value; if (!(res && "error" in res)) loadedContent = editorText(editorView);
dirty = false; dirty = false;
saveStateEl.textContent = "Saved"; saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved"); saveStateEl.classList.remove("unsaved");
@ -206,15 +201,14 @@ export function initBook(api: FolioAPI) {
const text = typeof content === "string" ? content : ""; const text = typeof content === "string" ? content : "";
const title = (meta && meta.chapters[id]?.title) || id; const title = (meta && meta.chapters[id]?.title) || id;
chapterTitleEl.textContent = title; chapterTitleEl.textContent = title;
sourceArea.value = text; setEditorText(editorView, text);
loadedContent = text; loadedContent = text;
dirty = false; dirty = false;
saveStateEl.textContent = "Saved"; saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved"); saveStateEl.classList.remove("unsaved");
wordCountEl.textContent = `words: ${countWords(text)}`; wordCountEl.textContent = `words: ${countWords(text)}`;
renderPreview(text); editorView.dispatch({ selection: { anchor: text.length } });
sourceArea.focus(); editorView.focus();
sourceArea.setSelectionRange(text.length, text.length);
clearTimers(); clearTimers();
chapterView.classList.remove("hidden"); chapterView.classList.remove("hidden");
editorPlaceholder.classList.add("hidden"); editorPlaceholder.classList.add("hidden");
@ -224,27 +218,16 @@ export function initBook(api: FolioAPI) {
} }
function clearTimers() { function clearTimers() {
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
} }
function renderPreview(text: string) { function handleEditorChange(content: string) {
editorPreview.innerHTML = md.render(text);
// smooth-scroll anchor links open in same preview pane
editorPreview.querySelectorAll("a").forEach((a) =>
a.setAttribute("target", "_blank")
);
}
async function scheduleSave() {
if (!selectedId) return; if (!selectedId) return;
const content = sourceArea.value;
dirty = content !== loadedContent; dirty = content !== loadedContent;
saveStateEl.textContent = dirty ? "Unsaved" : "Saved"; saveStateEl.textContent = dirty ? "Unsaved" : "Saved";
saveStateEl.classList.toggle("unsaved", dirty); saveStateEl.classList.toggle("unsaved", dirty);
wordCountEl.textContent = `words: ${countWords(content)}`; wordCountEl.textContent = `words: ${countWords(content)}`;
clearTimers(); clearTimers();
renderTimer = setTimeout(() => renderPreview(content), 150);
if (dirty) { if (dirty) {
saveTimer = setTimeout(async () => { saveTimer = setTimeout(async () => {
const res = await api.saveChapter(selectedId!, content); const res = await api.saveChapter(selectedId!, content);
@ -258,44 +241,6 @@ export function initBook(api: FolioAPI) {
} }
} }
// ---- find in editor ----
function findNext(fromInput = false): boolean {
const needle = findIn.value;
if (!needle) return false;
const ta = sourceArea;
const cur = ta.selectionStart;
const idx = ta.value.toLowerCase().indexOf(needle.toLowerCase(), fromInput ? 0 : cur + 1);
if (idx === -1) return false;
ta.focus();
ta.setSelectionRange(idx, idx + needle.length);
return true;
}
function findPrev(): boolean {
const needle = findIn.value;
if (!needle) return false;
const ta = sourceArea;
const cur = ta.selectionStart;
const slice = ta.value.slice(0, cur).toLowerCase();
const idx = slice.lastIndexOf(needle.toLowerCase());
if (idx === -1) return false;
ta.focus();
ta.setSelectionRange(idx, idx + needle.length);
return true;
}
function openFindBar() {
findBar.classList.remove("hidden");
findIn.value = "";
findIn.focus();
sourceArea.dispatchEvent(new Event("blur"));
}
function closeFindBar() {
findBar.classList.add("hidden");
sourceArea.focus();
}
function status(text: string, ms = 3000) { function status(text: string, ms = 3000) {
saveStateEl.textContent = text; saveStateEl.textContent = text;
saveStateEl.classList.add("unsaved"); saveStateEl.classList.add("unsaved");
@ -529,35 +474,6 @@ export function initBook(api: FolioAPI) {
if (currentBook) void api.revealInFolder(currentBook.path); if (currentBook) void api.revealInFolder(currentBook.path);
}); });
// ---- editor wiring ----
sourceArea.addEventListener("input", () => void scheduleSave());
findBtn.addEventListener("click", () => openFindBar());
findNextBtn.addEventListener("click", () => { if (!findNext(false)) findIn.value = ""; });
findPrevBtn.addEventListener("click", () => findPrev());
findCloseBtn.addEventListener("click", () => closeFindBar());
findIn.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) findPrev(); else findNext(false);
} else if (e.key === "Escape") {
closeFindBar();
}
});
// Ctrl/Cmd+F to open find; Esc to close when open; keep editor focused otherwise
document.addEventListener("keydown", (e) => {
if (e.key === "f" && (e.ctrlKey || e.metaKey) && !findBar.classList.contains("hidden")) {
e.preventDefault();
}
if (e.key === "F" && (e.ctrlKey || e.metaKey) && findBar.classList.contains("hidden") && selectedId) {
e.preventDefault();
openFindBar();
} else if (e.key === "Escape" && !findBar.classList.contains("hidden")) {
closeFindBar();
}
});
api.onBookOpened((data) => setCurrentBook(data)); api.onBookOpened((data) => setCurrentBook(data));
api.onBookClosed(showWelcome); api.onBookClosed(showWelcome);
api.onChaptersChanged(() => void refreshMeta()); api.onChaptersChanged(() => void refreshMeta());
@ -570,4 +486,6 @@ export function initBook(api: FolioAPI) {
setChapterButtons(false); setChapterButtons(false);
void renderRecents(); void renderRecents();
(window as any).__folioEditor = editorView;
} }

View file

@ -150,10 +150,8 @@ html, body {
.editor { .editor {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg); background: var(--color-bg);
padding: var(--gap); overflow: hidden;
} }
.editor-placeholder { .editor-placeholder {
@ -401,17 +399,17 @@ html, body {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--gap); padding: 0;
overflow: auto; overflow: hidden;
} }
.chapter-header { .chapter-header {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: var(--gap); padding: 14px 20px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
padding-bottom: var(--gap);
} }
.chapter-title { .chapter-title {
@ -427,38 +425,6 @@ html, body {
color: var(--color-muted); color: var(--color-muted);
} }
.chapter-content {
flex: 1;
font-family: var(--font-serif);
font-size: 1.05rem;
line-height: 1.7;
color: var(--color-text);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0;
outline: none;
max-width: 720px;
}
/* ---- editor ---- */
.chapter-view {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.chapter-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid var(--color-border);
}
.editor-actions { .editor-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -473,83 +439,56 @@ html, body {
} }
.editor-panes { .editor-panes {
flex: 1;
display: flex; display: flex;
flex: 1; min-height: 0;
overflow: hidden; overflow: hidden;
gap: 0;
} }
.source-area { .cm-editor {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
border: 0; height: 100%;
resize: none; }
outline: none;
padding: 24px 20px; .cm-editor .cm-scroller {
font-family: var(--font-serif); overflow: auto;
font-size: 1.05rem; }
line-height: 1.7;
color: var(--color-text); .cm-editor .cm-gutters {
background: var(--color-bg); background: var(--color-bg);
tab-size: 4; color: var(--color-muted);
border-right: 1px solid var(--color-border);
} }
.source-area:focus { .cm-h1 { font-size: 1.7rem; font-weight: 700; }
background: var(--color-panel); .cm-h2 { font-size: 1.45rem; font-weight: 700; }
} .cm-h3 { font-size: 1.25rem; font-weight: 600; }
.cm-h4 { font-size: 1.1rem; font-weight: 600; }
.cm-h5 { font-size: 1.05rem; font-weight: 600; }
.cm-h6 { font-size: 1rem; font-weight: 600; color: var(--color-muted); }
.editor-preview { .cm-strong { font-weight: 700; }
flex: 1; .cm-em { font-style: italic; }
min-width: 0; .cm-strike { text-decoration: line-through; }
overflow-y: auto; .cm-link { color: var(--color-accent); }
padding: 24px 20px; .cm-url { color: var(--color-muted); text-decoration: underline; }
font-family: var(--font-serif); .cm-code {
font-size: 1.05rem;
line-height: 1.7;
color: var(--color-text);
background: var(--color-bg);
border-left: 1px solid var(--color-border);
}
.editor-preview img {
max-width: 100%;
height: auto;
}
.editor-preview pre {
background: var(--color-panel);
padding: 12px;
border-radius: var(--radius);
overflow-x: auto;
}
.editor-preview code {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
background: var(--color-panel); background: var(--color-panel);
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 0.9em;
} }
.cm-quote { color: var(--color-muted); }
.cm-hr { color: var(--color-muted); text-decoration: line-through; }
.cm-comment { color: var(--color-muted); }
.find-bar { .cm-panels {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: var(--color-panel); background: var(--color-panel);
border-bottom: 1px solid var(--color-border);
}
.find-bar input {
flex: 1;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 4px 8px;
font-size: 0.85rem;
background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
} }
/* ---- modal dialog ---- */ /* ---- modal dialog ---- */
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
@ -624,3 +563,4 @@ html, body {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }

View file

@ -46,19 +46,9 @@
<span class="word-count" id="wordCount">words: 0</span> <span class="word-count" id="wordCount">words: 0</span>
<button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button> <button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button>
<button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button> <button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button>
<button class="icon-button" id="findBtn" title="Find (Ctrl+F)">⌕</button>
</div> </div>
</header> </header>
<div class="editor-panes"> <div class="editor-panes" id="editorHost"></div>
<textarea class="source-area" id="sourceArea" spellcheck="true" autocapitalize="sentences"></textarea>
<div class="editor-preview" id="editorPreview"></div>
</div>
<div class="find-bar hidden" id="findBar">
<input type="text" id="findIn" placeholder="Find…" />
<button class="icon-btn" id="findPrev" title="Previous (↑)">↑</button>
<button class="icon-btn" id="findNext" title="Next (↓)">↓</button>
<button class="icon-btn" id="findClose" title="Close (Esc)">×</button>
</div>
</div> </div>
<div class="editor-placeholder hidden" id="editorPlaceholder"> <div class="editor-placeholder hidden" id="editorPlaceholder">
<p>Select a chapter to begin writing.</p> <p>Select a chapter to begin writing.</p>

130
src/renderer/md-editor.ts Normal file
View file

@ -0,0 +1,130 @@
import { EditorState } from "@codemirror/state";
import {
EditorView,
drawSelection,
dropCursor,
highlightActiveLine,
keymap,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import {
bracketMatching,
HighlightStyle,
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { markdown } from "@codemirror/lang-markdown";
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
import { tags } from "@lezer/highlight";
export interface MdEditorCallbacks {
onChange: (text: string) => void;
}
const mdHighlight = HighlightStyle.define([
{ tag: tags.heading1, class: "cm-h1" },
{ tag: tags.heading2, class: "cm-h2" },
{ tag: tags.heading3, class: "cm-h3" },
{ tag: tags.heading4, class: "cm-h4" },
{ tag: tags.heading5, class: "cm-h5" },
{ tag: tags.heading6, class: "cm-h6" },
{ tag: tags.strong, class: "cm-strong" },
{ tag: tags.emphasis, class: "cm-em" },
{ tag: tags.strikethrough, class: "cm-strike" },
{ tag: tags.link, class: "cm-link" },
{ tag: tags.url, class: "cm-url" },
{ tag: tags.monospace, class: "cm-code" },
{ tag: tags.quote, class: "cm-quote" },
{ tag: tags.contentSeparator, class: "cm-hr" },
{ tag: tags.comment, class: "cm-comment" },
]);
const folioTheme = EditorView.theme({
"&": {
height: "100%",
fontSize: "1.05rem",
backgroundColor: "var(--color-bg)",
color: "var(--color-text)",
},
"&.cm-focused": {
outline: "none",
},
".cm-scroller": {
fontFamily: "var(--font-serif)",
lineHeight: "1.7",
padding: "24px 20px",
},
".cm-content": {
caretColor: "var(--color-accent)",
maxWidth: "820px",
paddingBottom: "48px",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--color-accent)",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "var(--color-accent)",
opacity: "0.25",
},
".cm-activeLine": {
backgroundColor: "var(--color-panel)",
},
"&.cm-focused .cm-matchingBracket": {
outline: "1px solid var(--color-accent)",
},
".cm-searchMatch": {
backgroundColor: "rgba(37, 99, 235, 0.25)",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "rgba(37, 99, 235, 0.5)",
},
".cm-panels": {
backgroundColor: "var(--color-panel)",
color: "var(--color-text)",
fontFamily: "var(--font-sans)",
fontSize: "0.85rem",
},
".cm-panels input, .cm-panels button": {
fontFamily: "var(--font-sans)",
},
});
export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks): EditorView {
const view = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
EditorView.lineWrapping,
drawSelection(),
dropCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
history(),
indentOnInput(),
bracketMatching(),
markdown(),
search({ top: true }),
syntaxHighlighting(mdHighlight),
folioTheme,
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]),
EditorView.updateListener.of((update) => {
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
}),
],
}),
parent: host,
});
return view;
}
export function setEditorText(view: EditorView, text: string): void {
const current = view.state.doc.toString();
if (current === text) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
});
}
export function editorText(view: EditorView): string {
return view.state.doc.toString();
}

View file

@ -82,6 +82,33 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
out.uiCreated = metaAfterUi.chapterOrder.length === countBefore + 1 && out.uiCreated = metaAfterUi.chapterOrder.length === countBefore + 1 &&
Object.values(metaAfterUi.chapters).some((c) => c.title === "Via Modal"); Object.values(metaAfterUi.chapters).some((c) => c.title === "Via Modal");
// CodeMirror editor: typing, cursor/selection, autosave persistence
const cm = window.__folioEditor;
out.hasEditor = !!cm;
out.editorLoaded = cm ? cm.state.doc.toString() : null;
if (cm) {
cm.dispatch({
changes: {
from: 0,
to: cm.state.doc.length,
insert: "# Heading One\n\n**bold** and *italic* text\n\n- bullet one\n- bullet two\n\n> quoted line\n\n[link](https://example.com) and `inline code`\n\n---",
},
});
cm.dispatch({ selection: { anchor: cm.state.doc.length } });
}
await new Promise((r) => setTimeout(r, 1200));
out.editorDocAfterType = cm ? cm.state.doc.toString() : null;
const typedId = metaAfterUi.chapterOrder[metaAfterUi.chapterOrder.length - 1];
const typedContent = await window.folio.getChapterContent(typedId);
out.editorAutosaved =
!!cm &&
cm.state.doc.toString().includes("**bold** and *italic*") &&
typedContent.includes("# Heading One") &&
typedContent.includes("- bullet two") &&
typedContent.includes("> quoted line") &&
typedContent.includes("[link](https://example.com)") &&
typedContent.includes("`inline code`");
out.all = true; out.all = true;
} catch (e) { } catch (e) {
out.error = String((e && e.message) || e); out.error = String((e && e.message) || e);