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

View file

@ -1,6 +1,7 @@
import type { BookMeta } from "../main/project.js";
import markdownIt from "markdown-it";
import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
import type { EditorView } from "@codemirror/view";
export type FolioAPI = {
newBook: () => Promise<any>;
@ -63,30 +64,24 @@ export function initBook(api: FolioAPI) {
const editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView");
const chapterTitleEl = $("chapterTitle");
const sourceArea = $("sourceArea") as HTMLTextAreaElement;
const editorPreview = $("editorPreview");
const editorHost = $("editorHost");
const wordCountEl = $("wordCount");
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 exportZipBtn = $("exportZipBtn") as HTMLButtonElement;
const addChapterBtn = $("addChapterBtn") as HTMLButtonElement;
const projectBar = $("projectBar");
const editorView: EditorView = createMdEditor(editorHost, {
onChange: (text) => handleEditorChange(text),
});
function setChapterButtons(enabled: boolean) {
addChapterBtn.disabled = !enabled;
newChapterBtn.disabled = !enabled;
}
const md = markdownIt({ html: false, linkify: true, typographer: true });
let dirty = false;
let loadedContent = "";
let renderTimer: ReturnType<typeof setTimeout> | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const bookTitleEl = $("bookTitle");
const bookPathEl = $("bookPath");
@ -192,8 +187,8 @@ export function initBook(api: FolioAPI) {
async function selectChapter(id: string) {
if (selectedId && dirty) {
const res = await api.saveChapter(selectedId, sourceArea.value);
if (!(res && "error" in res)) loadedContent = sourceArea.value;
const res = await api.saveChapter(selectedId, editorText(editorView));
if (!(res && "error" in res)) loadedContent = editorText(editorView);
dirty = false;
saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved");
@ -206,15 +201,14 @@ export function initBook(api: FolioAPI) {
const text = typeof content === "string" ? content : "";
const title = (meta && meta.chapters[id]?.title) || id;
chapterTitleEl.textContent = title;
sourceArea.value = text;
setEditorText(editorView, text);
loadedContent = text;
dirty = false;
saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved");
wordCountEl.textContent = `words: ${countWords(text)}`;
renderPreview(text);
sourceArea.focus();
sourceArea.setSelectionRange(text.length, text.length);
editorView.dispatch({ selection: { anchor: text.length } });
editorView.focus();
clearTimers();
chapterView.classList.remove("hidden");
editorPlaceholder.classList.add("hidden");
@ -224,27 +218,16 @@ export function initBook(api: FolioAPI) {
}
function clearTimers() {
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
}
function renderPreview(text: 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() {
function handleEditorChange(content: string) {
if (!selectedId) return;
const content = sourceArea.value;
dirty = content !== loadedContent;
saveStateEl.textContent = dirty ? "Unsaved" : "Saved";
saveStateEl.classList.toggle("unsaved", dirty);
wordCountEl.textContent = `words: ${countWords(content)}`;
clearTimers();
renderTimer = setTimeout(() => renderPreview(content), 150);
if (dirty) {
saveTimer = setTimeout(async () => {
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) {
saveStateEl.textContent = text;
saveStateEl.classList.add("unsaved");
@ -529,35 +474,6 @@ export function initBook(api: FolioAPI) {
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.onBookClosed(showWelcome);
api.onChaptersChanged(() => void refreshMeta());
@ -570,4 +486,6 @@ export function initBook(api: FolioAPI) {
setChapterButtons(false);
void renderRecents();
(window as any).__folioEditor = editorView;
}

View file

@ -150,10 +150,8 @@ html, body {
.editor {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
padding: var(--gap);
overflow: hidden;
}
.editor-placeholder {
@ -401,17 +399,17 @@ html, body {
flex: 1;
display: flex;
flex-direction: column;
padding: var(--gap);
overflow: auto;
padding: 0;
overflow: hidden;
}
.chapter-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: var(--gap);
padding: 14px 20px;
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--gap);
}
.chapter-title {
@ -427,38 +425,6 @@ html, body {
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 {
display: flex;
align-items: center;
@ -473,83 +439,56 @@ html, body {
}
.editor-panes {
flex: 1;
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
gap: 0;
}
.source-area {
.cm-editor {
flex: 1;
min-width: 0;
border: 0;
resize: none;
outline: none;
padding: 24px 20px;
font-family: var(--font-serif);
font-size: 1.05rem;
line-height: 1.7;
color: var(--color-text);
height: 100%;
}
.cm-editor .cm-scroller {
overflow: auto;
}
.cm-editor .cm-gutters {
background: var(--color-bg);
tab-size: 4;
color: var(--color-muted);
border-right: 1px solid var(--color-border);
}
.source-area:focus {
background: var(--color-panel);
}
.cm-h1 { font-size: 1.7rem; font-weight: 700; }
.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 {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 24px 20px;
font-family: var(--font-serif);
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 {
.cm-strong { font-weight: 700; }
.cm-em { font-style: italic; }
.cm-strike { text-decoration: line-through; }
.cm-link { color: var(--color-accent); }
.cm-url { color: var(--color-muted); text-decoration: underline; }
.cm-code {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
background: var(--color-panel);
padding: 2px 6px;
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 {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
.cm-panels {
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);
}
/* ---- modal dialog ---- */
.modal-backdrop {
position: fixed;
@ -624,3 +563,4 @@ html, body {
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -46,19 +46,9 @@
<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="exportZipBtn" title="Export bundle (ZIP)">📦</button>
<button class="icon-button" id="findBtn" title="Find (Ctrl+F)">⌕</button>
</div>
</header>
<div class="editor-panes">
<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 class="editor-panes" id="editorHost"></div>
</div>
<div class="editor-placeholder hidden" id="editorPlaceholder">
<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();
}