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
|
|
@ -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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue