From 3ef0e14581298112a7d8bff72d75327c7618aae8 Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Wed, 24 Jun 2026 21:57:13 +0200 Subject: [PATCH] Add callouts (> [!type]) with fold, titles, type picker, and custom types --- src/app.css | 29 +++ src/lib/components/Editor.svelte | 375 +++++++++++++++++++++++++++++++ src/lib/editor/callouts.ts | 171 ++++++++++++++ 3 files changed, 575 insertions(+) create mode 100644 src/lib/editor/callouts.ts diff --git a/src/app.css b/src/app.css index c072077..9793a8c 100644 --- a/src/app.css +++ b/src/app.css @@ -173,6 +173,21 @@ --danger-hover: #d43d3d; --success: #40c057; --warning: #fab005; + /* Obsidian-style callout accent hues (RGB triplets; mixed with the background via opacity, so one set works on every theme) */ + --callout-note: 68, 138, 255; + --callout-abstract: 0, 191, 188; + --callout-info: 0, 184, 212; + --callout-todo: 0, 184, 212; + --callout-tip: 0, 191, 188; + --callout-success: 8, 185, 78; + --callout-question: 236, 191, 0; + --callout-warning: 236, 117, 0; + --callout-failure: 233, 49, 71; + --callout-danger: 233, 49, 71; + --callout-bug: 233, 49, 71; + --callout-example: 120, 82, 238; + --callout-quote: 158, 158, 158; + --callout-custom: 124, 134, 156; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); @@ -204,6 +219,20 @@ --danger-hover: #eba0ac; --success: #a6e3a1; --warning: #f9e2af; + --callout-note: 100, 160, 255; + --callout-abstract: 60, 209, 205; + --callout-info: 60, 200, 224; + --callout-todo: 60, 200, 224; + --callout-tip: 60, 209, 205; + --callout-success: 80, 205, 120; + --callout-question: 240, 205, 80; + --callout-warning: 245, 150, 60; + --callout-failure: 240, 95, 115; + --callout-danger: 240, 95, 115; + --callout-bug: 240, 95, 115; + --callout-example: 165, 135, 245; + --callout-quote: 150, 150, 162; + --callout-custom: 150, 160, 182; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 7d0ca51..bab4b49 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -46,6 +46,7 @@ import { debounce } from '$lib/utils/debounce'; import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets'; import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText'; + import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts'; import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs'; import GraphView from './GraphView.svelte'; import TagSuggestInput from './TagSuggestInput.svelte'; @@ -246,6 +247,7 @@ { label: 'Secret', aliases: ['secret', 'encrypt', 'password', 'private'], icon: '', action: () => openSecretInsert() }, { label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '', action: () => editor?.chain().focus().toggleBlockquote().run() }, { label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '', action: () => insertDetails() }, + { label: 'Callout', aliases: ['callout', 'admonition', 'note', 'info', 'tip', 'warning', 'caution', 'danger', 'success', 'question', 'quote', 'aside', 'box'], icon: '', action: () => insertCallout('note') }, { label: 'Table', aliases: ['table', 'grid'], icon: '', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } }, { label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '', action: () => editor?.chain().focus().setHorizontalRule().run() }, { label: 'Page Break', aliases: ['pagebreak', 'page', 'break', 'newpage', 'print'], icon: '', action: () => editor?.chain().focus().insertContent({ type: 'pageBreak' }).run() }, @@ -830,6 +832,143 @@ }, }); + const Callout = TiptapNode.create({ + name: 'callout', + group: 'block', + content: 'block+', + defining: true, + addAttributes() { + return { + type: { + default: 'note', + parseHTML: (el: HTMLElement) => (el.getAttribute('data-callout') || 'note').toLowerCase(), + renderHTML: (a: Record) => ({ 'data-callout': a.type }), + }, + title: { + default: '', + parseHTML: (el: HTMLElement) => el.getAttribute('data-callout-title') || '', + renderHTML: (a: Record) => (a.title ? { 'data-callout-title': a.title } : {}), + }, + foldable: { + default: false, + parseHTML: (el: HTMLElement) => el.getAttribute('data-callout-foldable') === 'true', + renderHTML: (a: Record) => ({ 'data-callout-foldable': a.foldable ? 'true' : 'false' }), + }, + folded: { + default: false, + parseHTML: (el: HTMLElement) => el.getAttribute('data-callout-folded') === 'true', + renderHTML: (a: Record) => ({ 'data-callout-folded': a.folded ? 'true' : 'false' }), + }, + }; + }, + parseHTML() { + return [{ tag: 'div[data-callout]' }]; + }, + renderHTML({ node, HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes, { class: 'callout', 'data-callout-group': calloutGroup(node.attrs.type) }), 0]; + }, + addNodeView() { + return ({ node, getPos, editor }) => { + const dom = document.createElement('div'); + const apply = (n: any) => { + dom.className = 'callout'; + dom.classList.toggle('is-foldable', !!n.attrs.foldable); + dom.classList.toggle('is-folded', !!n.attrs.folded); + dom.setAttribute('data-callout', n.attrs.type || 'note'); + dom.setAttribute('data-callout-group', calloutGroup(n.attrs.type)); + }; + apply(node); + + const header = document.createElement('div'); + header.className = 'callout-header'; + header.contentEditable = 'false'; + + const iconBtn = document.createElement('button'); + iconBtn.type = 'button'; + iconBtn.className = 'callout-icon'; + iconBtn.title = 'Change type'; + iconBtn.innerHTML = calloutIcon(node.attrs.type); + + const titleInput = document.createElement('input'); + titleInput.className = 'callout-title'; + titleInput.value = node.attrs.title || ''; + titleInput.placeholder = calloutLabel(node.attrs.type); + titleInput.spellcheck = false; + titleInput.readOnly = !editor.isEditable; + + const foldBtn = document.createElement('button'); + foldBtn.type = 'button'; + foldBtn.className = 'callout-fold'; + foldBtn.title = 'Fold / unfold'; + foldBtn.innerHTML = ''; + + const content = document.createElement('div'); + content.className = 'callout-content'; + + header.append(iconBtn, titleInput, foldBtn); + dom.append(header, content); + + const updateAttr = (attrs: Record) => { + if (typeof getPos !== 'function') return; + const pos = getPos(); + if (pos == null) return; + const cur = editor.state.doc.nodeAt(pos); + if (!cur) return; + editor.view.dispatch(editor.state.tr.setNodeMarkup(pos, undefined, { ...cur.attrs, ...attrs })); + }; + + let titleDirty = false; + const commitTitle = () => { if (titleDirty) { updateAttr({ title: titleInput.value }); titleDirty = false; } }; + titleInput.addEventListener('input', () => { titleDirty = true; }); + titleInput.addEventListener('change', commitTitle); + titleInput.addEventListener('blur', commitTitle); + titleInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commitTitle(); titleInput.blur(); editor.commands.focus(); } + }); + + foldBtn.addEventListener('mousedown', (e) => e.preventDefault()); + foldBtn.addEventListener('click', (e) => { + e.preventDefault(); + const nowFolded = !dom.classList.contains('is-folded'); + if (editor.isEditable) { + updateAttr({ folded: nowFolded, foldable: true }); + } else { + dom.classList.toggle('is-folded', nowFolded); + dom.classList.add('is-foldable'); + } + }); + + iconBtn.addEventListener('mousedown', (e) => e.preventDefault()); + iconBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (!editor.isEditable) return; + openCalloutTypeMenu(iconBtn, (newType) => updateAttr({ type: newType })); + }); + + return { + dom, + contentDOM: content, + update(updated: any) { + if (updated.type.name !== 'callout') return false; + apply(updated); + iconBtn.innerHTML = calloutIcon(updated.attrs.type); + titleInput.placeholder = calloutLabel(updated.attrs.type); + titleInput.readOnly = !editor.isEditable; + if (document.activeElement !== titleInput) titleInput.value = updated.attrs.title || ''; + return true; + }, + ignoreMutation(mutation: any) { + if (mutation.type === 'selection') return false; + return !content.contains(mutation.target as Node); + }, + stopEvent(event: any) { + return header.contains(event.target as Node); + }, + }; + }; + }, + }); + const HeadingShortcuts = Extension.create({ name: 'headingShortcuts', addKeyboardShortcuts() { @@ -2813,6 +2952,8 @@ }); return blocks.join('\n>\n') + '\n'; } + case 'callout': + return serializeCallout(node, serializeNode); case 'bulletList': { const items: string[] = []; node.forEach((child: any) => items.push('- ' + serializeListItem(child))); @@ -3565,6 +3706,14 @@ return `${alt}`; }); + // Post-process: turn Obsidian callout blockquotes (> [!type] ...) into callout blocks. + if (html.includes('[!')) { + const tmp = document.createElement('div'); + tmp.innerHTML = html; + transformCalloutBlockquotes(tmp); + html = tmp.innerHTML; + } + return html; } @@ -3687,6 +3836,7 @@ MathBlock, MathInline, PageBreak, + Callout, Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }), DetailsSummary, DetailsContent, @@ -4360,6 +4510,71 @@ } } + function insertCallout(type = 'note') { + if (!editor) return; + editor.chain().focus().wrapIn('callout', { type, title: '', foldable: false, folded: false }).run(); + } + + function openCalloutTypeMenu(anchor: HTMLElement, onPick: (type: string) => void) { + document.querySelectorAll('.callout-type-menu').forEach((el) => el.remove()); + const menu = document.createElement('div'); + menu.className = 'callout-type-menu'; + const close = () => { + menu.remove(); + document.removeEventListener('mousedown', onDoc, true); + document.removeEventListener('keydown', onKey, true); + window.removeEventListener('scroll', close, true); + }; + const onDoc = (e: MouseEvent) => { if (!menu.contains(e.target as Node)) close(); }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); }; + for (const item of CALLOUT_MENU) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'callout-type-option'; + btn.innerHTML = `${calloutIcon(item.type, 16)}${item.label}`; + btn.addEventListener('mousedown', (e) => e.preventDefault()); + btn.addEventListener('click', () => { onPick(item.type); close(); }); + menu.appendChild(btn); + } + // "Custom…" fills the last grid slot: name any callout type (Obsidian round-trips it). + const customBtn = document.createElement('button'); + customBtn.type = 'button'; + customBtn.className = 'callout-type-option'; + customBtn.innerHTML = 'Custom…'; + customBtn.addEventListener('mousedown', (e) => e.preventDefault()); + customBtn.addEventListener('click', () => { + menu.classList.add('is-custom'); + menu.innerHTML = ''; + const input = document.createElement('input'); + input.className = 'callout-type-custom-input'; + input.placeholder = 'Type name, then Enter'; + input.spellcheck = false; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const v = input.value.trim().toLowerCase().replace(/[^\w-]/g, ''); + if (v) onPick(v); + close(); + } else if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }); + menu.appendChild(input); + input.focus(); + }); + menu.appendChild(customBtn); + document.body.appendChild(menu); + const r = anchor.getBoundingClientRect(); + menu.style.top = `${Math.min(r.bottom + 4, window.innerHeight - menu.offsetHeight - 8)}px`; + menu.style.left = `${Math.min(r.left, window.innerWidth - menu.offsetWidth - 8)}px`; + setTimeout(() => { + document.addEventListener('mousedown', onDoc, true); + document.addEventListener('keydown', onKey, true); + window.addEventListener('scroll', close, true); + }, 0); + } + function insertDetails() { if (!editor) return; editor.chain().focus().setDetails().run(); @@ -4380,6 +4595,11 @@ closeTextContextMenu(); } + function ctxCallout() { + insertCallout('note'); + closeTextContextMenu(); + } + function ctxBulletList() { editor?.chain().focus().toggleBulletList().run(); closeTextContextMenu(); @@ -5575,6 +5795,10 @@ Collapsible Section + + + +
+