Add Obsidian-like full-featured editor toolbar

This commit is contained in:
avi 2026-08-17 14:34:36 -05:00
commit 11dd692ec4
10 changed files with 1211 additions and 28 deletions

View file

@ -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 };
});

View file

@ -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) =>

View file

@ -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<any>;
exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>;
importAttachment: () => Promise<any>;
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<HTMLButtonElement>("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", () => {

View file

@ -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;

View file

@ -51,9 +51,15 @@
<div class="editor-toolbar" id="editorToolbar">
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="undo" title="Undo (Ctrl+Z)">&#8630;</button>
<button class="toolbar-btn" data-cmd="redo" title="Redo (Ctrl+Shift+Z)">&#8631;</button>
<button class="toolbar-btn" data-cmd="redo" title="Redo (Ctrl+Shift+Z / Ctrl+Y)">&#8631;</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="painter" title="Format painter: copy formatting at the cursor, then select text and click again to apply">&#128396;</button>
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">&#9003;</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn h2" data-cmd="h2" title="Heading 2">H2</button>
<button class="toolbar-btn h3" data-cmd="h3" title="Heading 3">H3</button>
<select id="headingSelect" class="toolbar-select" title="Paragraph / heading">
<option value="0">Paragraph</option>
<option value="1">Heading 1</option>
@ -67,18 +73,90 @@
<div class="toolbar-group">
<button class="toolbar-btn b" data-cmd="bold" title="Bold (Ctrl+B)">B</button>
<button class="toolbar-btn i" data-cmd="italic" title="Italic (Ctrl+I)">I</button>
<button class="toolbar-btn s" data-cmd="strike" title="Strikethrough">S</button>
<button class="toolbar-btn code" data-cmd="code" title="Inline code / code block">code</button>
<button class="toolbar-btn" data-cmd="link" title="Link (Ctrl+K)">&#8599;</button>
<button class="toolbar-btn s" data-cmd="strike" title="Strikethrough (Ctrl+Shift+S)">S</button>
<button class="toolbar-btn u" data-cmd="underline" title="Underline (Ctrl+U)">U</button>
<button class="toolbar-btn hl" data-cmd="highlight" title="Highlight">&#8801;</button>
<button class="toolbar-btn code" data-cmd="code" title="Inline code">`</button>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="bullet" title="Bulleted list">&#8226;</button>
<button class="toolbar-btn" data-cmd="number" title="Numbered list">1.</button>
<button class="toolbar-btn" data-cmd="quote" title="Blockquote">&#10077;</button>
<button class="toolbar-btn" data-cmd="indent" title="Indent (Tab)">&#8594;</button>
<button class="toolbar-btn" data-cmd="outdent" title="Outdent (Shift+Tab)">&#8592;</button>
<button class="toolbar-btn" data-cmd="hr" title="Horizontal rule">&#8213;</button>
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">&#9003;</button>
<div class="toolbar-dropdown">
<button class="toolbar-btn dropdown-toggle" data-dropdown="insertMenu" title="Insert link, wikilink, image, attachment, horizontal rule">Insert &#9662;</button>
<div class="dropdown-menu hidden" id="insertMenu">
<button class="dropdown-item" data-cmd="link" title="Link (Ctrl+K)">Link</button>
<button class="dropdown-item" data-action="wikilink" title="Link to another chapter">Wikilink</button>
<button class="dropdown-item" data-action="image" title="Insert image">Image</button>
<button class="dropdown-item" data-action="attach" title="Attach a file from your computer">Attachment</button>
<div class="dropdown-sep"></div>
<button class="dropdown-item" data-cmd="hr" title="Horizontal rule">Horizontal rule</button>
</div>
</div>
</div>
<div class="toolbar-group">
<div class="toolbar-dropdown">
<button class="toolbar-btn dropdown-toggle" data-dropdown="listMenu" title="Lists, blocks, indentation">Lists &#9662;</button>
<div class="dropdown-menu hidden" id="listMenu">
<button class="dropdown-item" data-cmd="bullet" title="Bulleted list">Bulleted list</button>
<button class="dropdown-item" data-cmd="number" title="Numbered list">Numbered list</button>
<button class="dropdown-item" data-cmd="checklist" title="Task list">Task list</button>
<div class="dropdown-sep"></div>
<button class="dropdown-item" data-cmd="indent" title="Indent (Tab)">Indent</button>
<button class="dropdown-item" data-cmd="outdent" title="Outdent (Shift+Tab)">Outdent</button>
<div class="dropdown-sep"></div>
<button class="dropdown-item" data-cmd="quote" title="Blockquote">Blockquote</button>
<button class="dropdown-item" data-cmd="codeblock" title="Fenced code block">Code block</button>
<button class="dropdown-item" data-action="callout" title="Callout / admonition">Callout</button>
<div class="dropdown-sep"></div>
<div class="table-config">
<span>Table</span>
<input type="number" id="tableRows" min="2" max="12" value="3" title="Rows" />
<span>&times;</span>
<input type="number" id="tableCols" min="1" max="8" value="2" title="Columns" />
<button class="dropdown-item" data-action="table" title="Insert a Markdown table with the chosen size">Insert</button>
</div>
</div>
</div>
</div>
<div class="toolbar-group">
<div class="toolbar-dropdown">
<button class="toolbar-btn dropdown-toggle" data-dropdown="alignMenu" title="Text alignment">&#8801; &#9662;</button>
<div class="dropdown-menu hidden" id="alignMenu">
<button class="dropdown-item" data-cmd="align-left" title="Align left">Align left</button>
<button class="dropdown-item" data-cmd="align-center" title="Align center">Align center</button>
<button class="dropdown-item" data-cmd="align-right" title="Align right">Align right</button>
</div>
</div>
<div class="toolbar-dropdown">
<button class="toolbar-btn dropdown-toggle color-dot" data-dropdown="colorMenu" title="Text color">A &#9662;</button>
<div class="dropdown-menu hidden color-menu" id="colorMenu">
<button class="color-swatch" data-cmd="color:#e11d48" title="Red" style="background:#e11d48"></button>
<button class="color-swatch" data-cmd="color:#f97316" title="Orange" style="background:#f97316"></button>
<button class="color-swatch" data-cmd="color:#eab308" title="Yellow" style="background:#eab308"></button>
<button class="color-swatch" data-cmd="color:#22c55e" title="Green" style="background:#22c55e"></button>
<button class="color-swatch" data-cmd="color:#3b82f6" title="Blue" style="background:#3b82f6"></button>
<button class="color-swatch" data-cmd="color:#8b5cf6" title="Purple" style="background:#8b5cf6"></button>
<button class="color-swatch" data-cmd="color:#78716c" title="Gray" style="background:#78716c"></button>
<button class="color-swatch" data-cmd="color:#18181b" title="Black" style="background:#18181b"></button>
<div class="dropdown-sep"></div>
<button class="dropdown-item" data-cmd="removecolor" title="Remove text color">Remove color</button>
</div>
</div>
<div class="toolbar-dropdown">
<button class="toolbar-btn dropdown-toggle hl-dot" data-dropdown="hlMenu" title="Highlight color">&#9608; &#9662;</button>
<div class="dropdown-menu hidden color-menu" id="hlMenu">
<button class="color-swatch" data-cmd="hcolor:#fde047" title="Yellow" style="background:#fde047"></button>
<button class="color-swatch" data-cmd="hcolor:#fda4af" title="Pink" style="background:#fda4af"></button>
<button class="color-swatch" data-cmd="hcolor:#86efac" title="Green" style="background:#86efac"></button>
<button class="color-swatch" data-cmd="hcolor:#93c5fd" title="Blue" style="background:#93c5fd"></button>
<button class="color-swatch" data-cmd="hcolor:#d8b4fe" title="Purple" style="background:#d8b4fe"></button>
<button class="color-swatch" data-cmd="hcolor:#fed7aa" title="Orange" style="background:#fed7aa"></button>
<div class="dropdown-sep"></div>
<button class="dropdown-item" data-cmd="removehl" title="Remove highlight">Remove highlight</button>
</div>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="fullscreen" title="Fullscreen">&#8852;</button>
<button class="toolbar-btn" data-cmd="focus" title="Focus / distraction-free mode">&#9673;</button>
</div>
</div>
<div class="editor-panes" id="editorHost"></div>

View file

@ -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 },
];

View file

@ -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>|<\/u>|<mark[^>]*>|<\/mark>|<span[^>]*>|<\/span>|<div[^>]*>|<\/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;
};
};
/* ------------------------------------------------------------------ */
/* 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) === "<u>" &&
slice(state, to, to + 4) === "</u>"
) {
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("<u>") && text.endsWith("</u>")) {
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, "<u>", "</u>");
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: "<u>" },
{ from: to, to: to, insert: "</u>" },
],
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 = `<span style="${attr}:${color}">`;
const close = "</span>";
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(`^<span style="${attr}:[^"]*">([\\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, `<span style="${attr}:`, close);
if (wrap) {
const inner = slice(state, wrap.textFrom, wrap.textTo);
const openTag = state.doc.sliceString(wrap.from, wrap.textFrom);
const same = openTag.includes(`:${color}"`);
const insert = same ? inner : open + inner + close;
dispatch({
changes: { from: wrap.from, to: wrap.to, insert },
selection: EditorSelection.single(
wrap.from + (same ? 0 : open.length),
wrap.from + (same ? inner.length : open.length + inner.length)
),
});
return true;
}
if (from === to) {
const insert = open + "text" + close;
dispatch({
changes: { from, to: from, insert },
selection: EditorSelection.range(from + open.length, from + open.length + 4),
});
return true;
}
dispatch({
changes: { from, to, insert: open + text + close },
selection: EditorSelection.single(from + open.length, to + open.length),
});
return true;
};
}
export const toggleTextColor = (color: string): FormatCommand => wrapColored("color", color);
export const toggleHighlightColor = (color: string): FormatCommand => wrapColored("bg", color);
function stripSpan(region: string, attr: string): string {
return region.replace(
new RegExp(`<span style="${attr}:[^"]*">([\\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(/<span style="background-color:[^"]*">([\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 = /^(<div align="(left|center|right)">)([\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: `<div align="${align}">${m[3]}</div>` } });
}
return true;
}
if (align === "left") return false;
const open = `<div align="${align}">`;
dispatch({
changes: { from: start, to: end, insert: `${open}${region}</div>` },
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, "<div align=", "</div>");
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, "<u>", "</u>");
const mark = htmlSpanAt(state, pos, '<mark style="background-color:', "</mark>");
const span = htmlSpanAt(state, pos, '<span style="color:', "</span>");
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, "<u>", "</u>")) changed = ensureWrap(target, "<u>", "</u>") || 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 (!/^<span style="color:[^"]*">/.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 (!/^<span style="background-color:[^"]*">/.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;
}