diff --git a/.gitignore b/.gitignore index 0004e52..1b39c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ *.asar releases/ .DS_Store +tests/.tmp-folio-format-*.mjs diff --git a/src/main/index.ts b/src/main/index.ts index 3c6ff14..72b02fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -322,3 +322,27 @@ ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { return { error: (e as Error).message }; } }); + +ipcMain.handle("folio:importAttachment", async () => { + const bp = requireBook(); + const { canceled, filePaths } = await showOpenDialog({ + title: "Attach a file", + properties: ["openFile"], + }); + if (canceled || !filePaths.length) return { canceled: true } as const; + const src = filePaths[0]; + const dir = path.join(bp, "attachments"); + fs.mkdirSync(dir, { recursive: true }); + const base = path.basename(src); + const ext = path.extname(base); + const stem = path.basename(base, ext); + let name = base; + let counter = 2; + while (fs.existsSync(path.join(dir, name))) { + name = `${stem}-${counter}${ext}`; + counter += 1; + } + fs.copyFileSync(src, path.join(dir, name)); + const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/"); + return { ok: true, name, relPath }; +}); diff --git a/src/main/preload.ts b/src/main/preload.ts index 601f4d0..9952776 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -27,6 +27,7 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.invoke("folio:saveChapter", id, content), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportZip: () => ipcRenderer.invoke("folio:exportZip"), + importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), // events onBookOpened: (cb: (data: { path: string; title: string }) => void) => diff --git a/src/renderer/book.ts b/src/renderer/book.ts index e119aa8..d6639c9 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -4,18 +4,35 @@ import { createMdEditor, editorText, setEditorText } from "./md-editor.js"; import type { EditorView } from "@codemirror/view"; import { redo, undo, indentLess, indentMore } from "@codemirror/commands"; import { + applyFormatPainter, + captureFormat, clearFormatting, - headingLevelAt, + detectFormatting, + insertAttachment, + insertCallout, insertHorizontalRule, + insertImage, + insertTable, + insertWikilink, + removeColor, + removeHighlight, setHeading, + toggleAlign, toggleBlockquote, toggleBold, toggleBulletList, - toggleCode, + toggleChecklist, + toggleFencedCode, + toggleHighlight, + toggleHighlightColor, + toggleInlineCode, toggleItalic, toggleLink, toggleOrderedList, toggleStrikethrough, + toggleTextColor, + toggleUnderline, + type FormatState, } from "./md-format.js"; export type FolioAPI = { @@ -36,6 +53,7 @@ export type FolioAPI = { saveChapter: (id: string, content: string) => Promise; exportMarkdown: () => Promise; exportZip: () => Promise; + importAttachment: () => Promise; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; onBookClosed: (cb: () => void) => void; onChaptersChanged: (cb: () => void) => void; @@ -92,7 +110,16 @@ export function initBook(api: FolioAPI) { const editorView: EditorView = createMdEditor(editorHost, { onChange: (text) => handleEditorChange(text), onSelection: (view) => { - headingSelect.value = String(headingLevelAt(view.state, view.state.selection.main.head)); + const fmt = detectFormatting(view.state, view.state.selection.main.head); + headingSelect.value = String(fmt.heading); + setActive("bold", fmt.bold); + setActive("italic", fmt.italic); + setActive("strike", fmt.strike); + setActive("underline", fmt.underline); + setActive("highlight", fmt.highlight); + setActive("code", fmt.inlineCode); + setActive("h2", fmt.heading === 2); + setActive("h3", fmt.heading === 3); }, }); @@ -104,29 +131,190 @@ export function initBook(api: FolioAPI) { bold: toggleBold, italic: toggleItalic, strike: toggleStrikethrough, - code: toggleCode, + underline: toggleUnderline, + highlight: toggleHighlight, + code: toggleInlineCode, link: toggleLink, + hr: insertHorizontalRule, bullet: toggleBulletList, number: toggleOrderedList, - quote: toggleBlockquote, + checklist: toggleChecklist, indent: indentMore, outdent: indentLess, - hr: insertHorizontalRule, + quote: toggleBlockquote, + codeblock: toggleFencedCode, + h2: setHeading(2), + h3: setHeading(3), + removecolor: removeColor, + removehl: removeHighlight, + "align-left": toggleAlign("left"), + "align-center": toggleAlign("center"), + "align-right": toggleAlign("right"), clear: clearFormatting, }; + let painterFmt: FormatState | null = null; + let painterActive = false; + + function closeDropdowns() { + toolbar.querySelectorAll(".dropdown-menu").forEach((m) => m.classList.add("hidden")); + toolbar.querySelectorAll(".dropdown-toggle").forEach((t) => t.classList.remove("open")); + } + + function setActive(cmd: string, on: boolean) { + document.querySelectorAll(`[data-cmd="${cmd}"]`).forEach((el) => el.classList.toggle("active", on)); + } + + function updatePainterBtn() { + setActive("painter", painterActive); + } + + function togglePainter() { + if (painterActive && painterFmt) { + if (editorView.state.selection.main.from !== editorView.state.selection.main.to) { + applyFormatPainter(editorView, painterFmt); + } + painterFmt = null; + painterActive = false; + } else { + painterFmt = captureFormat(editorView.state, editorView.state.selection.main.head); + painterActive = true; + } + updatePainterBtn(); + editorView.focus(); + } + + function toggleFullscreen() { + if (document.fullscreenElement) void document.exitFullscreen(); + else void document.documentElement.requestFullscreen().catch(() => undefined); + } + + document.addEventListener("fullscreenchange", () => { + setActive("fullscreen", !!document.fullscreenElement); + }); + + function toggleFocus() { + const on = document.body.classList.toggle("focus-mode"); + setActive("focus", on); + } + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && painterActive) { + painterActive = false; + painterFmt = null; + updatePainterBtn(); + } + }); + + async function attachFile() { + const res = await api.importAttachment(); + if (!res) return; + if ("canceled" in res) return; + if ("error" in res) { + await alertMessage("Could not attach file", (res as { error: string }).error); + return; + } + if ("name" in res && "relPath" in res) { + insertAttachment((res as { name: string }).name, (res as { relPath: string }).relPath)(editorView); + editorView.focus(); + } + } + + function selectedText(): string { + const { from, to } = editorView.state.selection.main; + return editorView.state.doc.sliceString(from, to); + } + + async function handleSpecial(item: HTMLElement) { + const action = item.dataset.action; + switch (action) { + case "wikilink": { + const sel = selectedText(); + const name = await promptInput("Wikilink to chapter", sel); + if (name === null) return; + insertWikilink(name, sel && sel !== name ? sel : undefined)(editorView); + break; + } + case "image": { + const sel = selectedText(); + const alt = await promptInput("Image alt text", sel || "alt"); + if (alt === null) return; + const url = await promptInput("Image URL", "https://"); + if (url === null) return; + insertImage(alt || "alt", url || "https://")(editorView); + break; + } + case "attach": { + await attachFile(); + return; + } + case "callout": { + const title = await promptInput("Callout title", ""); + if (title === null) return; + insertCallout("note", title || undefined)(editorView); + break; + } + case "table": { + const rowsEl = $("tableRows") as HTMLInputElement; + const colsEl = $("tableCols") as HTMLInputElement; + const rows = Math.max(1, Math.min(12, parseInt(rowsEl.value, 10) || 3)); + const cols = Math.max(1, Math.min(8, parseInt(colsEl.value, 10) || 2)); + insertTable(rows, cols)(editorView); + break; + } + default: + return; + } + editorView.focus(); + } + toolbar.addEventListener("pointerdown", (e) => { if ((e.target as HTMLElement).closest("button, select")) e.preventDefault(); }); + document.addEventListener("click", (e) => { + if (!(e.target as HTMLElement).closest(".toolbar-dropdown")) closeDropdowns(); + }); + toolbar.addEventListener("click", (e) => { - const btn = (e.target as HTMLElement).closest("button[data-cmd]") as HTMLButtonElement | null; - if (!btn) return; - const cmd = formatCommands[btn.dataset.cmd ?? ""]; - if (cmd) { - cmd(editorView); + const item = (e.target as HTMLElement).closest("[data-cmd], [data-action]") as HTMLElement | null; + if (!item) return; + closeDropdowns(); + const cmd = item.dataset.cmd; + if (cmd && formatCommands[cmd]) { + formatCommands[cmd](editorView); editorView.focus(); + return; } + if (cmd === "painter") { togglePainter(); return; } + if (cmd === "fullscreen") { toggleFullscreen(); return; } + if (cmd === "focus") { toggleFocus(); return; } + if (cmd?.startsWith("color:")) { + toggleTextColor(cmd.slice(6))(editorView); + editorView.focus(); + return; + } + if (cmd?.startsWith("hcolor:")) { + toggleHighlightColor(cmd.slice(7))(editorView); + editorView.focus(); + return; + } + void handleSpecial(item); + }); + + // Bind toolbar dropdown toggles. + toolbar.querySelectorAll("button[data-dropdown]").forEach((toggle) => { + const menu = document.getElementById(toggle.dataset.dropdown ?? ""); + if (!menu) return; + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + const wasOpen = !menu.classList.contains("hidden"); + closeDropdowns(); + if (!wasOpen) { + menu.classList.remove("hidden"); + toggle.classList.add("open"); + } + }); }); headingSelect.addEventListener("change", () => { diff --git a/src/renderer/index.css b/src/renderer/index.css index c1356f0..c8b2d94 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -449,13 +449,15 @@ html, body { .editor-toolbar { display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow-x: auto; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--color-border); background: var(--color-panel); user-select: none; -webkit-user-select: none; + scrollbar-width: thin; } .toolbar-group { @@ -465,6 +467,7 @@ html, body { padding-right: 8px; margin-right: 8px; border-right: 1px solid var(--color-border); + flex: 0 0 auto; } .toolbar-group:last-child { @@ -499,13 +502,23 @@ html, body { border-color: var(--color-accent); } +.toolbar-btn.active { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); +} + .toolbar-btn.b { font-weight: 700; } .toolbar-btn.i { font-style: italic; } .toolbar-btn.s { text-decoration: line-through; } +.toolbar-btn.u { text-decoration: underline; } .toolbar-btn.code { font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; - font-size: 0.75rem; + font-size: 0.85rem; } +.toolbar-btn.hl { font-weight: 700; letter-spacing: -1px; } +.toolbar-btn.h2 { font-weight: 700; font-size: 0.95rem; } +.toolbar-btn.h3 { font-weight: 600; font-size: 0.9rem; } .toolbar-select { height: 28px; @@ -525,6 +538,134 @@ html, body { border-color: var(--color-accent); } +/* ---- toolbar dropdowns ---- */ +.toolbar-dropdown { + position: relative; + display: inline-flex; +} + +.dropdown-toggle.open { + background: var(--color-bg); + border-color: var(--color-border); +} + +.dropdown-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 60; + min-width: 190px; + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + padding: 4px; + display: flex; + flex-direction: column; + gap: 1px; +} + +.dropdown-item { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + color: var(--color-text); + font-family: var(--font-sans); + font-size: 0.82rem; + padding: 7px 10px; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; +} + +.dropdown-item:hover { + background: var(--color-accent); + color: #fff; +} + +.dropdown-sep { + height: 1px; + background: var(--color-border); + margin: 4px 6px; +} + +.color-menu { + flex-direction: row; + flex-wrap: wrap; + width: 152px; + gap: 4px; +} + +.color-swatch { + width: 24px; + height: 24px; + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + padding: 0; +} + +.color-swatch:hover { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.color-menu .dropdown-sep { + flex-basis: 100%; +} + +.color-menu .dropdown-item { + flex-basis: 100%; +} + +.color-dot { + font-weight: 700; +} + +.hl-dot { + font-weight: 700; +} + +.table-config { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + font-size: 0.82rem; + color: var(--color-text); +} + +.table-config input { + width: 42px; + height: 24px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-text); + font-size: 0.82rem; + padding: 0 4px; +} + +.table-config .dropdown-item { + width: auto; + margin-left: auto; +} + +/* ---- focus / distraction-free mode ---- */ +body.focus-mode .sidebar { + display: none; +} + +body.focus-mode .chapter-header { + display: none; +} + +body.focus-mode .editor { + background: var(--color-bg); +} + .cm-editor { flex: 1; min-width: 0; diff --git a/src/renderer/index.html b/src/renderer/index.html index 0b98fd9..37b2638 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -51,9 +51,15 @@
- +
+ + +
+
+ + + × + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
diff --git a/src/renderer/md-editor.ts b/src/renderer/md-editor.ts index 138aa4a..da6b6b6 100644 --- a/src/renderer/md-editor.ts +++ b/src/renderer/md-editor.ts @@ -17,7 +17,7 @@ import { import { markdown } from "@codemirror/lang-markdown"; import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search"; import { tags } from "@lezer/highlight"; -import { toggleBold, toggleItalic, toggleLink, toggleStrikethrough } from "./md-format.js"; +import { toggleBold, toggleItalic, toggleLink, toggleStrikethrough, toggleUnderline } from "./md-format.js"; export interface MdEditorCallbacks { onChange: (text: string) => void; @@ -27,6 +27,7 @@ export interface MdEditorCallbacks { const formatBindings: KeyBinding[] = [ { key: "Mod-b", run: toggleBold, preventDefault: true }, { key: "Mod-i", run: toggleItalic, preventDefault: true }, + { key: "Mod-u", run: toggleUnderline, preventDefault: true }, { key: "Mod-Shift-s", run: toggleStrikethrough, preventDefault: true }, { key: "Mod-k", run: toggleLink, preventDefault: true }, ]; diff --git a/src/renderer/md-format.ts b/src/renderer/md-format.ts index 3e9c54c..6ef2f74 100644 --- a/src/renderer/md-format.ts +++ b/src/renderer/md-format.ts @@ -301,7 +301,11 @@ function clearLine(line: string): string { out = out.replace(/^[-*+]\s+/, ""); out = out.replace(/^\d+\.\s+/, ""); out = out.replace(/\[([^\]\n]+)\]\([^)\n]*\)/g, "$1"); - out = out.replace(/\*\*|\*|~~|`/g, ""); + out = out.replace(/\*\*|\*|~~|`|==/g, ""); + out = out.replace( + /|<\/u>|]*>|<\/mark>|]*>|<\/span>|]*>|<\/div>/g, + "" + ); return out; } @@ -314,4 +318,501 @@ export const clearFormatting: FormatCommand = (target) => { if (cleaned === region) return false; dispatch({ changes: { from: start, to: end, insert: cleaned } }); return true; -}; \ No newline at end of file +}; + +/* ------------------------------------------------------------------ */ +/* HTML inline wrappers: underline, colors (readable outside Folio) */ +/* ------------------------------------------------------------------ */ + +interface HtmlSpan { + from: number; + textFrom: number; + textTo: number; + to: number; +} + +function htmlSpanAt( + state: EditorState, + pos: number, + openPrefix: string, + closeTag: string +): HtmlSpan | null { + const before = state.doc.sliceString(0, pos); + const openIdx = before.lastIndexOf(openPrefix); + if (openIdx < 0) return null; + const gt = state.doc.sliceString(openIdx).indexOf(">"); + if (gt < 0) return null; + const textFrom = openIdx + gt + 1; + const after = state.doc.sliceString(textFrom); + const closeIdx = after.indexOf(closeTag); + if (closeIdx < 0) return null; + const textTo = textFrom + closeIdx; + if (pos >= openIdx && pos <= textTo + closeTag.length) { + return { from: openIdx, textFrom, textTo, to: textTo + closeTag.length }; + } + return null; +} + +function hexFrom(tag: string): string | null { + const m = /color:\s*(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)/.exec(tag); + return m ? m[1].toLowerCase() : null; +} + +export const toggleUnderline: FormatCommand = (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + + // Selection bounded by the tags: unwrap. + if ( + from - 3 >= 0 && + slice(state, from - 3, from) === "" && + slice(state, to, to + 4) === "" + ) { + dispatch({ + changes: [ + { from: from - 3, to: from, insert: "" }, + { from: to, to: to + 4, insert: "" }, + ], + selection: EditorSelection.single(from - 3, to - 3), + }); + return true; + } + + // Selection includes the tags: unwrap. + const text = slice(state, from, to); + if (text.startsWith("") && text.endsWith("")) { + dispatch({ + changes: [ + { from, to: from + 3, insert: "" }, + { from: to - 4, to, insert: "" }, + ], + selection: EditorSelection.single(from, to - 7), + }); + return true; + } + + // Cursor inside a wrapper: unwrap it. + const wrapper = htmlSpanAt(state, from, "", ""); + if (wrapper && from === to) { + dispatch({ + changes: [ + { from: wrapper.from, to: wrapper.textFrom, insert: "" }, + { from: wrapper.textTo, to: wrapper.to, insert: "" }, + ], + selection: EditorSelection.cursor(wrapper.textFrom), + }); + return true; + } + + dispatch({ + changes: [ + { from, to: from, insert: "" }, + { from: to, to: to, insert: "" }, + ], + selection: EditorSelection.single(from + 3, to + 3), + }); + return true; +}; + +// Obsidian-compatible highlight markers. +export const toggleHighlight: FormatCommand = toggleInline("=="); + +function wrapColored(kind: "color" | "bg", color: string): FormatCommand { + const attr = kind === "color" ? "color" : "background-color"; + const open = ``; + const close = ""; + return (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const text = slice(state, from, to); + + // Whole colored span selected: remove the color. + const re = new RegExp(`^([\\s\\S]*)<\\/span>$`); + const full = re.exec(text); + if (full) { + const inner = full[1]; + dispatch({ + changes: { from, to, insert: inner }, + selection: EditorSelection.single(from, from + inner.length), + }); + return true; + } + + // Selection/cursor inside an existing wrapper: remove if same color, else recolor. + const wrap = htmlSpanAt(state, from, ` wrapColored("color", color); +export const toggleHighlightColor = (color: string): FormatCommand => wrapColored("bg", color); + +function stripSpan(region: string, attr: string): string { + return region.replace( + new RegExp(`([\\s\\S]*?)<\\/span>`, "g"), + "$1" + ); +} + +export const removeColor: FormatCommand = (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const { start, end } = lineRange(state, from, to); + const region = slice(state, start, end); + const cleaned = stripSpan(region, "color"); + if (cleaned === region) return false; + dispatch({ changes: { from: start, to: end, insert: cleaned } }); + return true; +}; + +export const removeHighlight: FormatCommand = (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const { start, end } = lineRange(state, from, to); + const region = slice(state, start, end); + const cleaned = region + .replace(/([\s\S]*?)<\/span>/g, "$1") + .replace(/==([\s\S]*?)==/g, "$1"); + if (cleaned === region) return false; + dispatch({ changes: { from: start, to: end, insert: cleaned } }); + return true; +}; + +/* ------------------------------------------------------------------ */ +/* Task list */ +/* ------------------------------------------------------------------ */ + +const checklistRe = /^([-*+]\s+)\[([ xX])\]\s+/; + +export const toggleChecklist: FormatCommand = (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const { start, end } = lineRange(state, from, to); + const lines = slice(state, start, end).split("\n"); + if (!lines.length) return false; + const allHave = lines.every((l) => checklistRe.test(l)); + const insert = lines + .map((l) => { + if (allHave) { + const m = l.match(checklistRe)!; + const checked = m[2].toLowerCase() === "x"; + return `${m[1]}[${checked ? " " : "x"}] ${l.slice(m[0].length)}`; + } + const stripped = l.replace(/^([-*+]\s+|\d+\.\s+)/, ""); + return `- [ ] ${stripped}`; + }) + .join("\n"); + dispatch({ changes: { from: start, to: end, insert } }); + return true; +}; + +/* ------------------------------------------------------------------ */ +/* Insertions: wikilink, image, table, callout, attachment */ +/* ------------------------------------------------------------------ */ + +export function insertWikilink(name: string, label?: string): FormatCommand { + return (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const text = label && label !== name ? `[[${name}|${label}]]` : `[[${name}]]`; + dispatch({ + changes: { from, to, insert: text }, + selection: EditorSelection.cursor(from + text.length), + }); + return true; + }; +} + +export function insertImage(alt: string, url: string): FormatCommand { + return (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const text = `![${alt}](${url})`; + dispatch({ + changes: { from, to, insert: text }, + selection: EditorSelection.range(from + alt.length + 4, from + alt.length + 4 + url.length), + }); + return true; + }; +} + +export function insertTable(rows: number, cols: number): FormatCommand { + return (target) => { + const { state, dispatch } = target; + const { from } = state.selection.main; + const header = `| ${Array.from({ length: cols }, (_, i) => `Col ${i + 1}`).join(" | ")} |`; + const sep = `| ${Array(cols).fill("---").join(" | ")} |`; + const bodyRows = Math.max(1, rows - 1); + const body = Array.from( + { length: bodyRows }, + () => `| ${Array(cols).fill(" ").join(" | ")} |` + ).join("\n"); + const table = `${header}\n${sep}\n${body}`; + const line = state.doc.lineAt(from); + const prefix = from > 0 && line.from !== from ? "\n" : ""; + const insert = `${prefix}${table}\n`; + const cellFrom = from + prefix.length + header.length + 1 + sep.length + 1 + 2; + dispatch({ + changes: { from, to: from, insert }, + selection: EditorSelection.cursor(cellFrom), + }); + return true; + }; +} + +export function insertCallout(type = "note", title?: string): FormatCommand { + return (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const head = title ? `> [!${type}] ${title}\n` : `> [!${type}]\n`; + const insert = head + "> "; + dispatch({ + changes: { from, to, insert }, + selection: EditorSelection.cursor(from + insert.length), + }); + return true; + }; +} + +export function insertAttachment(name: string, relPath: string): FormatCommand { + return (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const text = `[${name}](${relPath})`; + dispatch({ + changes: { from, to, insert: text }, + selection: EditorSelection.cursor(from + text.length), + }); + return true; + }; +} + +/* ------------------------------------------------------------------ */ +/* Alignment (HTML, compatible with Obsidian) */ +/* ------------------------------------------------------------------ */ + +const alignDivRe = /^(
)([\s\S]*)(<\/div>)$/; + +export function toggleAlign(align: "left" | "center" | "right"): FormatCommand { + return (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const { start, end } = lineRange(state, from, to); + const region = slice(state, start, end); + const m = alignDivRe.exec(region); + if (m) { + if (align === "left" || m[2] === align) { + dispatch({ changes: { from: start, to: end, insert: m[3] } }); + } else { + dispatch({ changes: { from: start, to: end, insert: `
${m[3]}
` } }); + } + return true; + } + if (align === "left") return false; + const open = `
`; + dispatch({ + changes: { from: start, to: end, insert: `${open}${region}
` }, + selection: EditorSelection.single(start + open.length, end + open.length), + }); + return true; + }; +} + +/* ------------------------------------------------------------------ */ +/* Explicit code block */ +/* ------------------------------------------------------------------ */ + +function fencedBlockAt(state: EditorState, pos: number): { from: number; to: number } | null { + const doc = state.doc; + const line = doc.lineAt(pos); + let open = fenceLineRe.test(line.text) ? line.number : -1; + if (open < 0) { + for (let n = line.number - 1; n >= 1; n--) { + if (fenceLineRe.test(doc.line(n).text)) { + open = n; + break; + } + } + } + if (open < 1) return null; + for (let n = open + 1; n <= doc.lines; n++) { + if (fenceLineRe.test(doc.line(n).text)) { + return { from: doc.line(open).from, to: doc.line(n).to }; + } + } + return null; +} + +export const toggleFencedCode: FormatCommand = (target) => { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + const { start, end } = lineRange(state, from, to); + + const block = fencedBlockAt(state, start); + if (block) { + const body = slice(state, block.from, block.to).split("\n").slice(1, -1).join("\n"); + dispatch({ changes: { from: block.from, to: block.to, insert: body } }); + return true; + } + + const region = slice(state, start, end); + dispatch({ + changes: { from: start, to: end, insert: "```\n" + region + "\n```" }, + selection: EditorSelection.single(start + 4, end + 4), + }); + return true; +}; + +/* ------------------------------------------------------------------ */ +/* Active-state detection */ +/* ------------------------------------------------------------------ */ + +export interface FormatState { + bold: boolean; + italic: boolean; + strike: boolean; + underline: boolean; + highlight: boolean; + inlineCode: boolean; + color: string | null; + hColor: string | null; + heading: number; + list: "bullet" | "ordered" | "checklist" | null; + align: "left" | "center" | "right" | null; +} + +function countOccurrences(text: string, sub: string): number { + let count = 0; + let i = 0; + while ((i = text.indexOf(sub, i)) !== -1) { + count += 1; + i += sub.length; + } + return count; +} + +function oddBefore(line: string, pos: number, marker: string): boolean { + return countOccurrences(line.slice(0, pos), marker) % 2 === 1; +} + +export function detectFormatting(state: EditorState, pos: number): FormatState { + const line = state.doc.lineAt(pos); + const inLine = pos - line.from; + const before = line.text.slice(0, inLine); + const italic = (countOccurrences(before, "*") - 2 * countOccurrences(before, "**")) % 2 === 1; + + let list: FormatState["list"] = null; + if (checklistRe.test(line.text)) list = "checklist"; + else if (/^[-*+]\s+/.test(line.text)) list = "bullet"; + else if (orderedRe.test(line.text)) list = "ordered"; + + let align: FormatState["align"] = null; + const div = htmlSpanAt(state, pos, "
"); + if (div) { + const tag = state.doc.sliceString(div.from, div.textFrom).toLowerCase(); + if (tag.includes('align="center"')) align = "center"; + else if (tag.includes('align="right"')) align = "right"; + else align = "left"; + } + + const u = htmlSpanAt(state, pos, "", ""); + const mark = htmlSpanAt(state, pos, '"); + const span = htmlSpanAt(state, pos, '"); + + return { + bold: oddBefore(line.text, inLine, "**"), + italic, + strike: oddBefore(line.text, inLine, "~~"), + underline: u !== null, + highlight: oddBefore(line.text, inLine, "==") || mark !== null, + inlineCode: oddBefore(line.text, inLine, "`"), + color: span ? hexFrom(state.doc.sliceString(span.from, span.textFrom)) : null, + hColor: mark ? hexFrom(state.doc.sliceString(mark.from, mark.textFrom)) : null, + heading: headingLevelAt(state, pos), + list, + align, + }; +} + +export const captureFormat: (state: EditorState, pos: number) => FormatState = detectFormatting; + +/* ------------------------------------------------------------------ */ +/* Format painter */ +/* ------------------------------------------------------------------ */ + +function selectionWrapped(state: EditorState, open: string, close: string): boolean { + const { from, to } = state.selection.main; + const t = slice(state, from, to); + return t.startsWith(open) && t.endsWith(close); +} + +function ensureWrap(target: FormatTarget, open: string, close: string): boolean { + const { state, dispatch } = target; + const { from, to } = state.selection.main; + if (from === to) return false; + dispatch({ + changes: [ + { from, to: from, insert: open }, + { from: to, to: to, insert: close }, + ], + selection: EditorSelection.single(from + open.length, to + open.length), + }); + return true; +} + +export function applyFormatPainter(target: FormatTarget, fmt: FormatState): boolean { + let changed = false; + if (fmt.bold && !selectionWrapped(target.state, "**", "**")) changed = ensureWrap(target, "**", "**") || changed; + if (fmt.italic && !selectionWrapped(target.state, "*", "*")) changed = ensureWrap(target, "*", "*") || changed; + if (fmt.strike && !selectionWrapped(target.state, "~~", "~~")) changed = ensureWrap(target, "~~", "~~") || changed; + if (fmt.underline && !selectionWrapped(target.state, "", "")) changed = ensureWrap(target, "", "") || changed; + if (fmt.highlight && !selectionWrapped(target.state, "==", "==")) changed = ensureWrap(target, "==", "==") || changed; + + const hasSelection = + target.state.selection.main.from !== target.state.selection.main.to; + if (fmt.color && hasSelection) { + const t = slice(target.state, target.state.selection.main.from, target.state.selection.main.to); + if (!/^/.test(t)) changed = toggleTextColor(fmt.color)(target) || changed; + } + if (fmt.hColor && hasSelection) { + const t = slice(target.state, target.state.selection.main.from, target.state.selection.main.to); + if (!/^/.test(t)) changed = toggleHighlightColor(fmt.hColor)(target) || changed; + } + + const cur = detectFormatting(target.state, target.state.selection.main.from); + if (fmt.heading && cur.heading !== fmt.heading) changed = setHeading(fmt.heading)(target) || changed; + if (fmt.list && cur.list !== fmt.list) { + if (fmt.list === "bullet") changed = toggleBulletList(target) || changed; + else if (fmt.list === "ordered") changed = toggleOrderedList(target) || changed; + else if (fmt.list === "checklist") changed = toggleChecklist(target) || changed; + } + return changed; +} \ No newline at end of file diff --git a/tests/run-format-test.mjs b/tests/run-format-test.mjs index 37bfd7f..9b5678c 100644 --- a/tests/run-format-test.mjs +++ b/tests/run-format-test.mjs @@ -37,6 +37,21 @@ const { insertHorizontalRule, clearFormatting, headingLevelAt, + toggleUnderline, + toggleHighlight, + toggleTextColor, + toggleHighlightColor, + removeColor, + removeHighlight, + toggleChecklist, + insertWikilink, + insertImage, + insertTable, + insertCallout, + toggleAlign, + toggleFencedCode, + detectFormatting, + applyFormatPainter, } = mod; // Build a minimal { state, dispatch } target around an EditorState. @@ -266,6 +281,189 @@ function eq(actual, expected, msg) { clearFormatting(t); eq(text(), "item\nquote\nthing\ncode and label", "clear formatting handles lists, quote, code, links"); } +{ + const { t, text } = target("==hi== and under and red", 0, 64); + clearFormatting(t); + eq(text(), "hi and under and red", "clear formatting strips highlight, underline, color"); +} + +// ---- underline ---- +{ + const { t, text, sel } = target("some text", 5, 9); + toggleUnderline(t); + eq(text(), "some text", "underline wraps selection"); + eq(sel().from, 8, "underline selection from"); + toggleUnderline(t); + eq(text(), "some text", "underline toggles back off"); +} +{ + const { t, text } = target("a mid b", 6); // cursor inside … + toggleUnderline(t); + eq(text(), "a mid b", "underline unwraps when cursor is inside"); +} +{ + const { t, text, sel } = target("ab", 1); + toggleUnderline(t); + eq(text(), "ab", "underline on empty cursor inserts tag pair"); + eq(sel().from, 4, "underline cursor between tags"); +} + +// ---- highlight (Obsidian ==) ---- +{ + const { t, text } = target("mark this", 0, 4); + toggleHighlight(t); + eq(text(), "==mark== this", "highlight wraps selection"); + toggleHighlight(t); + eq(text(), "mark this", "highlight toggles back off"); +} + +// ---- text color ---- +{ + const { t, text, sel } = target("red text", 0, 3); + toggleTextColor("#e11d48")(t); + eq(text(), 'red text', "text color wraps selection"); + eq(sel().from, 28, "text color keeps selection on text"); + toggleTextColor("#e11d48")(t); + eq(text(), "red text", "text color toggles off with same color"); +} +{ + const { t, text } = target("change color", 0, 6); + toggleTextColor("#e11d48")(t); + toggleTextColor("#3b82f6")(t); + eq(text(), 'change color', "text color replaces existing color"); +} + +// ---- highlight color ---- +{ + const { t, text } = target("mark this", 0, 4); + toggleHighlightColor("#fde047")(t); + eq(text(), 'mark this', "highlight color wraps selection"); + removeHighlight(t); + eq(text(), "mark this", "removeHighlight strips background color"); +} +{ + const { t, text } = target("==plain==", 0, 8); + removeHighlight(t); + eq(text(), "plain", "removeHighlight strips Obsidian markers"); +} +{ + const { t, text } = target("colored", 0, 7); + toggleTextColor("#e11d48")(t); + removeColor(t); + eq(text(), "colored", "removeColor strips text color"); +} + +// ---- checklist ---- +{ + const { t, text } = target("buy milk\nwrite note", 0, 19); + toggleChecklist(t); + eq(text(), "- [ ] buy milk\n- [ ] write note", "checklist prefixes lines"); + toggleChecklist(t); + eq(text(), "- [x] buy milk\n- [x] write note", "checklist toggles to checked"); + toggleChecklist(t); + eq(text(), "- [ ] buy milk\n- [ ] write note", "checklist toggles back unchecked"); +} +{ + const { t, text } = target("1. numbered item", 0, 16); + toggleChecklist(t); + eq(text(), "- [ ] numbered item", "checklist converts numbered item"); +} + +// ---- wikilink ---- +{ + const { t, text, sel } = target("see intro", 4, 9); + insertWikilink("Introduction")(t); + eq(text(), "see [[Introduction]]", "wikilink inserts target"); + eq(sel().from, 20, "wikilink cursor after insertion"); +} +{ + const { t, text } = target("see intro", 4, 9); + insertWikilink("Introduction", "intro")(t); + eq(text(), "see [[Introduction|intro]]", "wikilink with alias label"); +} + +// ---- image ---- +{ + const { t, text, sel } = target("before", 6); + insertImage("alt", "https://x.com/a.png")(t); + eq(text(), "before![alt](https://x.com/a.png)", "image inserts markdown"); + eq(sel().from, 13, "image selects url start"); + eq(sel().to, 32, "image selects url end"); +} + +// ---- table ---- +{ + const { t, text } = target("para", 4); + insertTable(3, 2)(t); + eq( + text(), + "para\n| Col 1 | Col 2 |\n| --- | --- |\n| | |\n| | |\n", + "table inserts header, separator, body rows" + ); +} + +// ---- callout ---- +{ + const { t, text } = target("body here", 0); + insertCallout("note", "Tip")(t); + eq(text(), "> [!note] Tip\n> body here", "callout inserts Obsidian blockquote syntax"); +} + +// ---- alignment ---- +{ + const { t, text } = target("centered line", 0); + toggleAlign("center")(t); + eq(text(), '
centered line
', "align wraps paragraph in HTML div"); + toggleAlign("center")(t); + eq(text(), "centered line", "align toggles back off"); + toggleAlign("center")(t); + toggleAlign("right")(t); + eq(text(), '
centered line
', "align replaces alignment"); + toggleAlign("left")(t); + eq(text(), "centered line", "align left clears alignment"); +} + +// ---- fenced code block ---- +{ + const { t, text } = target("single line", 0); + toggleFencedCode(t); + eq(text(), "```\nsingle line\n```", "code block wraps single line"); + toggleFencedCode(t); + eq(text(), "single line", "code block toggles off"); +} + +// ---- detectFormatting ---- +{ + const { t } = target("**bold** and *italic* and under and ==hl==", 6); + const f = detectFormatting(t.state, 6); + ok(f.bold === true, "detectFormatting finds bold"); + ok(f.italic === false, "detectFormatting bold not italic"); +} +{ + const { t } = target("# Heading\nplain", 3); + ok(detectFormatting(t.state, 3).heading === 1, "detectFormatting finds heading"); +} +{ + const { t } = target("- [ ] task", 3); + ok(detectFormatting(t.state, 3).list === "checklist", "detectFormatting finds checklist"); +} +{ + const { t } = target("- item", 3); + ok(detectFormatting(t.state, 3).list === "bullet", "detectFormatting finds bullet list"); +} +{ + const { t } = target("1. item", 3); + ok(detectFormatting(t.state, 3).list === "ordered", "detectFormatting finds ordered list"); +} + +// ---- format painter ---- +{ + const { t, text } = target("**bold source** plain target", 0, 16); + const fmt = { ...detectFormatting(t.state, 8), bold: true }; + const { t: t2, text: text2 } = target("**bold source** plain target", 16, 28); + applyFormatPainter(t2, fmt); + eq(text2(), "**bold source** **plain target**", "format painter applies bold"); +} fs.rmSync(out, { force: true }); diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index f97618c..269220a 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -158,10 +158,55 @@ const BOOK_DIR = __BOOKDIR_VALUE__; changes: { from: 0, to: 0, insert: "line one\nline two\n" }, selection: { anchor: 0, head: 16 }, }); - document.querySelector('[data-cmd="code"]').click(); + document.querySelector('[data-cmd="codeblock"]').click(); await new Promise((r) => setTimeout(r, 50)); out.fencedCodeOk = cm.state.doc.toString().startsWith("```\nline one\nline two\n```"); + // underline shortcut (Ctrl+U) + cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } }); + cm.dispatch({ changes: { from: 0, to: 0, insert: "Under me" }, selection: { anchor: 0, head: 5 } }); + cm.contentDOM.dispatchEvent( + new KeyboardEvent("keydown", { key: "u", ctrlKey: true, bubbles: true, cancelable: true }) + ); + await new Promise((r) => setTimeout(r, 50)); + out.underlineOk = cm.state.doc.toString().startsWith("Under"); + + // task list via toolbar + cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } }); + cm.dispatch({ changes: { from: 0, to: 0, insert: "task one\ntask two" }, selection: { anchor: 0, head: 8 } }); + document.querySelector('[data-cmd="checklist"]').click(); + await new Promise((r) => setTimeout(r, 50)); + out.checklistOk = cm.state.doc.toString().startsWith("- [ ] task one"); + + // highlight via toolbar + cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } }); + cm.dispatch({ changes: { from: 0, to: 0, insert: "cell text" }, selection: { anchor: 0, head: 4 } }); + document.querySelector('[data-cmd="highlight"]').click(); + await new Promise((r) => setTimeout(r, 50)); + out.highlightOk = cm.state.doc.toString().startsWith("==cell=="); + + // table via toolbar dropdown config + cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } }); + document.getElementById("tableRows").value = "3"; + document.getElementById("tableCols").value = "2"; + document.querySelector('[data-action="table"]').click(); + await new Promise((r) => setTimeout(r, 50)); + out.tableOk = cm.state.doc.toString().startsWith("| Col 1 | Col 2 |\n| --- | --- |"); + + // format painter + cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } }); + cm.dispatch({ changes: { from: 0, to: 0, insert: "**src** dst" }, selection: { anchor: 3, head: 3 } }); + document.querySelector('[data-cmd="painter"]').click(); + cm.dispatch({ selection: { anchor: 8, head: 11 } }); + document.querySelector('[data-cmd="painter"]').click(); + await new Promise((r) => setTimeout(r, 50)); + out.painterOk = cm.state.doc.toString() === "**src** **dst**"; + + // leave some formatted content on disk for the autosave check + cm.dispatch({ + changes: { from: 0, to: cm.state.doc.length, insert: "**Hello** World" }, + selection: { anchor: 0 }, + }); await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce out.formattingAutosaved = (await window.folio.getChapterContent(typedId)).includes("**Hello**"); @@ -182,6 +227,11 @@ const BOOK_DIR = __BOOKDIR_VALUE__; "redoOk", "strikeShortcutOk", "fencedCodeOk", + "underlineOk", + "checklistOk", + "highlightOk", + "tableOk", + "painterOk", "formattingAutosaved", ]; out.all = okFlags.every((f) => out[f] === true);