Add Obsidian-like full-featured editor toolbar
This commit is contained in:
parent
45990bbd62
commit
11dd692ec4
10 changed files with 1211 additions and 28 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ dist/
|
|||
*.asar
|
||||
releases/
|
||||
.DS_Store
|
||||
tests/.tmp-folio-format-*.mjs
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)">↶</button>
|
||||
<button class="toolbar-btn" data-cmd="redo" title="Redo (Ctrl+Shift+Z)">↷</button>
|
||||
<button class="toolbar-btn" data-cmd="redo" title="Redo (Ctrl+Shift+Z / Ctrl+Y)">↷</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">🖌</button>
|
||||
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">⌫</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)">↗</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">≡</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">•</button>
|
||||
<button class="toolbar-btn" data-cmd="number" title="Numbered list">1.</button>
|
||||
<button class="toolbar-btn" data-cmd="quote" title="Blockquote">❝</button>
|
||||
<button class="toolbar-btn" data-cmd="indent" title="Indent (Tab)">→</button>
|
||||
<button class="toolbar-btn" data-cmd="outdent" title="Outdent (Shift+Tab)">←</button>
|
||||
<button class="toolbar-btn" data-cmd="hr" title="Horizontal rule">―</button>
|
||||
<button class="toolbar-btn" data-cmd="clear" title="Clear formatting">⌫</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button class="toolbar-btn dropdown-toggle" data-dropdown="insertMenu" title="Insert link, wikilink, image, attachment, horizontal rule">Insert ▾</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 ▾</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>×</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">≡ ▾</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 ▾</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">█ ▾</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">⊔</button>
|
||||
<button class="toolbar-btn" data-cmd="focus" title="Focus / distraction-free mode">◉</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-panes" id="editorHost"></div>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -315,3 +319,500 @@ export const clearFormatting: FormatCommand = (target) => {
|
|||
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 = ``;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 <u>under</u> and <span style=\"color:#e11d48\">red</span>", 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 <u>text</u>", "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 <u>mid</u> b", 6); // cursor inside <u>…</u>
|
||||
toggleUnderline(t);
|
||||
eq(text(), "a mid b", "underline unwraps when cursor is inside");
|
||||
}
|
||||
{
|
||||
const { t, text, sel } = target("ab", 1);
|
||||
toggleUnderline(t);
|
||||
eq(text(), "a<u></u>b", "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(), '<span style="color:#e11d48">red</span> 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(), '<span style="color:#3b82f6">change</span> color', "text color replaces existing color");
|
||||
}
|
||||
|
||||
// ---- highlight color ----
|
||||
{
|
||||
const { t, text } = target("mark this", 0, 4);
|
||||
toggleHighlightColor("#fde047")(t);
|
||||
eq(text(), '<span style="background-color:#fde047">mark</span> 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", "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(), '<div align="center">centered line</div>', "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(), '<div align="right">centered line</div>', "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 <u>under</u> 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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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("<u>Under</u>");
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue