Replace split textarea/preview editor with a single CodeMirror Markdown editor
This commit is contained in:
parent
2545dede0b
commit
1ae9744b2b
7 changed files with 451 additions and 200 deletions
239
package-lock.json
generated
239
package-lock.json
generated
|
|
@ -9,6 +9,13 @@
|
|||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"markdown-it": "^15.0.0"
|
||||
},
|
||||
|
|
@ -22,6 +29,147 @@
|
|||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
|
|
@ -523,6 +671,79 @@
|
|||
"@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": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||
|
|
@ -1144,6 +1365,12 @@
|
|||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -2502,6 +2729,12 @@
|
|||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
|
|
@ -2635,6 +2868,12 @@
|
|||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"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": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@
|
|||
"wait-on": "^8.0.0"
|
||||
},
|
||||
"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",
|
||||
"markdown-it": "^15.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
130
src/renderer/md-editor.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -82,6 +82,33 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
|
|||
out.uiCreated = metaAfterUi.chapterOrder.length === countBefore + 1 &&
|
||||
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;
|
||||
} catch (e) {
|
||||
out.error = String((e && e.message) || e);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue