Stage 5: live Markdown editor (source+preview), autosave, word count, find bar, save-state indicator
This commit is contained in:
parent
4b8d78458c
commit
25baae2950
9 changed files with 390 additions and 6 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue