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