Stage 5: live Markdown editor (source+preview), autosave, word count, find bar, save-state indicator

This commit is contained in:
avi 2026-08-16 14:51:06 -05:00
commit 25baae2950
9 changed files with 390 additions and 6 deletions

View file

@ -27,6 +27,13 @@ export function getChapterContent(bookPath: string, id: string): string {
}
}
export function setChapterContent(bookPath: string, id: string, content: string): void {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
fs.writeFileSync(path.join(bookPath, entry.file), content, "utf-8");
}
export function createChapter(bookPath: string, title: string): ChapterEntry {
if (!title.trim()) throw new Error("Chapter title cannot be empty");
const meta = loadBook(bookPath);

View file

@ -20,6 +20,7 @@ import {
renameChapter,
reorderChapters,
listChaptersInOrder,
setChapterContent,
} from "./chapters.js";
let mainWindow: BrowserWindow | null = null;
@ -249,3 +250,12 @@ ipcMain.handle("folio:getChapterList", () => {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
try {
setChapterContent(requireBook(), id, content);
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
});

View file

@ -23,6 +23,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.invoke("folio:duplicateChapter", id, title),
reorderChapters: (newOrder: string[]) =>
ipcRenderer.invoke("folio:reorderChapters", newOrder),
saveChapter: (id: string, content: string) =>
ipcRenderer.invoke("folio:saveChapter", id, content),
// events
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>

View file

@ -1,4 +1,5 @@
import type { BookMeta } from "../main/project.js";
import markdownIt from "markdown-it";
export type FolioAPI = {
newBook: () => Promise<any>;
@ -15,6 +16,7 @@ export type FolioAPI = {
deleteChapter: (id: string) => Promise<any>;
duplicateChapter: (id: string, title?: string) => Promise<any>;
reorderChapters: (newOrder: string[]) => Promise<any>;
saveChapter: (id: string, content: string) => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void;
@ -56,9 +58,23 @@ export function initBook(api: FolioAPI) {
const editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView");
const chapterTitleEl = $("chapterTitle");
const chapterContentEl = $("chapterContent");
const sourceArea = $("sourceArea") as HTMLTextAreaElement;
const editorPreview = $("editorPreview");
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 projectBar = $("projectBar");
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");
const welcome = $("welcome");
@ -159,6 +175,14 @@ 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;
dirty = false;
saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved");
}
clearTimers();
selectedId = id;
renderChapterList();
try {
@ -166,8 +190,16 @@ export function initBook(api: FolioAPI) {
const text = typeof content === "string" ? content : "";
const title = (meta && meta.chapters[id]?.title) || id;
chapterTitleEl.textContent = title;
chapterContentEl.textContent = text;
sourceArea.value = 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);
clearTimers();
chapterView.classList.remove("hidden");
editorPlaceholder.classList.add("hidden");
} catch {
@ -175,7 +207,80 @@ export function initBook(api: FolioAPI) {
}
}
function countWords(text: string): number {
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() {
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);
if (!(res && "error" in res)) {
loadedContent = content;
dirty = false;
saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved");
}
}, 600);
}
}
// ---- 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 countWords(text: string) {
const trimmed = text.trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).length;
@ -375,6 +480,35 @@ 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());

View file

@ -415,3 +415,112 @@ html, body {
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;
gap: 10px;
color: var(--color-muted);
font-size: 0.72rem;
}
.save-state.unsaved {
color: var(--color-accent);
font-weight: 600;
}
.editor-panes {
display: flex;
flex: 1;
overflow: hidden;
gap: 0;
}
.source-area {
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);
background: var(--color-bg);
tab-size: 4;
}
.source-area:focus {
background: var(--color-panel);
}
.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 {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
background: var(--color-panel);
padding: 2px 6px;
border-radius: 4px;
}
.find-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
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);
}

View file

@ -40,9 +40,22 @@
<div class="chapter-view hidden" id="chapterView">
<header class="chapter-header">
<h2 class="chapter-title" id="chapterTitle"></h2>
<span class="word-count" id="wordCount"></span>
<div class="editor-actions">
<span class="save-state" id="saveState">Saved</span>
<span class="word-count" id="wordCount">words: 0</span>
<button class="icon-button" id="findBtn" title="Find (Ctrl+F)">⌕</button>
</div>
</header>
<pre class="chapter-content" id="chapterContent"></pre>
<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>
<div class="editor-placeholder hidden" id="editorPlaceholder">
<p>Select a chapter to begin writing.</p>