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
98
package-lock.json
generated
98
package-lock.json
generated
|
|
@ -8,6 +8,9 @@
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"markdown-it": "^15.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.5.0",
|
"@types/node": "^22.5.0",
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
|
|
@ -651,6 +654,22 @@
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
|
@ -1023,6 +1042,18 @@
|
||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
|
|
@ -1576,6 +1607,25 @@
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
|
@ -1593,6 +1643,33 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "15.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^3.0.0",
|
||||||
|
"entities": "^8.0.0",
|
||||||
|
"linkify-it": "^6.0.0",
|
||||||
|
"mdurl": "^2.1.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/matcher": {
|
"node_modules/matcher": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||||
|
|
@ -1617,6 +1694,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
|
@ -1749,6 +1832,15 @@
|
||||||
"once": "^1.3.1"
|
"once": "^1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/quick-lru": {
|
"node_modules/quick-lru": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||||
|
|
@ -1979,6 +2071,12 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -24,5 +24,8 @@
|
||||||
"esbuild": "^0.24.0",
|
"esbuild": "^0.24.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"wait-on": "^8.0.0"
|
"wait-on": "^8.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"markdown-it": "^15.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export function createChapter(bookPath: string, title: string): ChapterEntry {
|
||||||
if (!title.trim()) throw new Error("Chapter title cannot be empty");
|
if (!title.trim()) throw new Error("Chapter title cannot be empty");
|
||||||
const meta = loadBook(bookPath);
|
const meta = loadBook(bookPath);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
renameChapter,
|
renameChapter,
|
||||||
reorderChapters,
|
reorderChapters,
|
||||||
listChaptersInOrder,
|
listChaptersInOrder,
|
||||||
|
setChapterContent,
|
||||||
} from "./chapters.js";
|
} from "./chapters.js";
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
@ -249,3 +250,12 @@ ipcMain.handle("folio:getChapterList", () => {
|
||||||
return { error: (e as Error).message };
|
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),
|
ipcRenderer.invoke("folio:duplicateChapter", id, title),
|
||||||
reorderChapters: (newOrder: string[]) =>
|
reorderChapters: (newOrder: string[]) =>
|
||||||
ipcRenderer.invoke("folio:reorderChapters", newOrder),
|
ipcRenderer.invoke("folio:reorderChapters", newOrder),
|
||||||
|
saveChapter: (id: string, content: string) =>
|
||||||
|
ipcRenderer.invoke("folio:saveChapter", id, content),
|
||||||
|
|
||||||
// events
|
// events
|
||||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
|
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { BookMeta } from "../main/project.js";
|
import type { BookMeta } from "../main/project.js";
|
||||||
|
import markdownIt from "markdown-it";
|
||||||
|
|
||||||
export type FolioAPI = {
|
export type FolioAPI = {
|
||||||
newBook: () => Promise<any>;
|
newBook: () => Promise<any>;
|
||||||
|
|
@ -15,6 +16,7 @@ export type FolioAPI = {
|
||||||
deleteChapter: (id: string) => Promise<any>;
|
deleteChapter: (id: string) => Promise<any>;
|
||||||
duplicateChapter: (id: string, title?: string) => Promise<any>;
|
duplicateChapter: (id: string, title?: string) => Promise<any>;
|
||||||
reorderChapters: (newOrder: string[]) => Promise<any>;
|
reorderChapters: (newOrder: string[]) => Promise<any>;
|
||||||
|
saveChapter: (id: string, content: string) => Promise<any>;
|
||||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
||||||
onBookClosed: (cb: () => void) => void;
|
onBookClosed: (cb: () => void) => void;
|
||||||
onChaptersChanged: (cb: () => void) => void;
|
onChaptersChanged: (cb: () => void) => void;
|
||||||
|
|
@ -56,9 +58,23 @@ export function initBook(api: FolioAPI) {
|
||||||
const editorPlaceholder = $("editorPlaceholder");
|
const editorPlaceholder = $("editorPlaceholder");
|
||||||
const chapterView = $("chapterView");
|
const chapterView = $("chapterView");
|
||||||
const chapterTitleEl = $("chapterTitle");
|
const chapterTitleEl = $("chapterTitle");
|
||||||
const chapterContentEl = $("chapterContent");
|
const sourceArea = $("sourceArea") as HTMLTextAreaElement;
|
||||||
|
const editorPreview = $("editorPreview");
|
||||||
const wordCountEl = $("wordCount");
|
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 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 bookTitleEl = $("bookTitle");
|
||||||
const bookPathEl = $("bookPath");
|
const bookPathEl = $("bookPath");
|
||||||
const welcome = $("welcome");
|
const welcome = $("welcome");
|
||||||
|
|
@ -159,6 +175,14 @@ export function initBook(api: FolioAPI) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectChapter(id: string) {
|
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;
|
selectedId = id;
|
||||||
renderChapterList();
|
renderChapterList();
|
||||||
try {
|
try {
|
||||||
|
|
@ -166,8 +190,16 @@ 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;
|
||||||
chapterContentEl.textContent = text;
|
sourceArea.value = text;
|
||||||
|
loadedContent = text;
|
||||||
|
dirty = false;
|
||||||
|
saveStateEl.textContent = "Saved";
|
||||||
|
saveStateEl.classList.remove("unsaved");
|
||||||
wordCountEl.textContent = `words: ${countWords(text)}`;
|
wordCountEl.textContent = `words: ${countWords(text)}`;
|
||||||
|
renderPreview(text);
|
||||||
|
sourceArea.focus();
|
||||||
|
sourceArea.setSelectionRange(text.length, text.length);
|
||||||
|
clearTimers();
|
||||||
chapterView.classList.remove("hidden");
|
chapterView.classList.remove("hidden");
|
||||||
editorPlaceholder.classList.add("hidden");
|
editorPlaceholder.classList.add("hidden");
|
||||||
} catch {
|
} 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();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) return 0;
|
if (!trimmed) return 0;
|
||||||
return trimmed.split(/\s+/).length;
|
return trimmed.split(/\s+/).length;
|
||||||
|
|
@ -375,6 +480,35 @@ 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());
|
||||||
|
|
|
||||||
|
|
@ -415,3 +415,112 @@ html, body {
|
||||||
outline: none;
|
outline: none;
|
||||||
max-width: 720px;
|
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">
|
<div class="chapter-view hidden" id="chapterView">
|
||||||
<header class="chapter-header">
|
<header class="chapter-header">
|
||||||
<h2 class="chapter-title" id="chapterTitle"></h2>
|
<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>
|
</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>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const {
|
||||||
deleteChapter,
|
deleteChapter,
|
||||||
duplicateChapter,
|
duplicateChapter,
|
||||||
reorderChapters,
|
reorderChapters,
|
||||||
|
setChapterContent,
|
||||||
moveInOrder,
|
moveInOrder,
|
||||||
} = chapters;
|
} = chapters;
|
||||||
|
|
||||||
|
|
@ -83,6 +84,13 @@ ok(dup.id !== c1.id, "duplicate has new id");
|
||||||
ok(getChapterContent(dir, dup.id) === "Hello world this is text", "duplicate copies content");
|
ok(getChapterContent(dir, dup.id) === "Hello world this is text", "duplicate copies content");
|
||||||
ok(loadBook(dir).chapterOrder.slice(-1)[0] === dup.id, "duplicate appended to order");
|
ok(loadBook(dir).chapterOrder.slice(-1)[0] === dup.id, "duplicate appended to order");
|
||||||
|
|
||||||
|
// setChapterContent writes markdown to disk by chapter id
|
||||||
|
setChapterContent(dir, c1.id, "# Introduction\n\nWelcome.");
|
||||||
|
ok(getChapterContent(dir, c1.id) === "# Introduction\n\nWelcome.", "setChapterContent persists markdown");
|
||||||
|
let threw2 = false;
|
||||||
|
try { setChapterContent(dir, "nope", "x"); } catch { threw2 = true; }
|
||||||
|
ok(threw2, "setChapterContent throws on unknown id");
|
||||||
|
|
||||||
// delete
|
// delete
|
||||||
const c3File = path.join(dir, c3.file);
|
const c3File = path.join(dir, c3.file);
|
||||||
deleteChapter(dir, c3.id);
|
deleteChapter(dir, c3.id);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue