mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-23 23:35:57 +02:00
Add callouts (> [!type]) with fold, titles, type picker, and custom types
This commit is contained in:
+29
@@ -173,6 +173,21 @@
|
|||||||
--danger-hover: #d43d3d;
|
--danger-hover: #d43d3d;
|
||||||
--success: #40c057;
|
--success: #40c057;
|
||||||
--warning: #fab005;
|
--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-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
@@ -204,6 +219,20 @@
|
|||||||
--danger-hover: #eba0ac;
|
--danger-hover: #eba0ac;
|
||||||
--success: #a6e3a1;
|
--success: #a6e3a1;
|
||||||
--warning: #f9e2af;
|
--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-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
|
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
|
||||||
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
|
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 { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
||||||
import GraphView from './GraphView.svelte';
|
import GraphView from './GraphView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
@@ -246,6 +247,7 @@
|
|||||||
{ label: 'Secret', aliases: ['secret', 'encrypt', 'password', 'private'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>', action: () => openSecretInsert() },
|
{ label: 'Secret', aliases: ['secret', 'encrypt', 'password', 'private'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>', action: () => openSecretInsert() },
|
||||||
{ label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() },
|
{ label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() },
|
||||||
{ label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => insertDetails() },
|
{ label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => insertDetails() },
|
||||||
|
{ label: 'Callout', aliases: ['callout', 'admonition', 'note', 'info', 'tip', 'warning', 'caution', 'danger', 'success', 'question', 'quote', 'aside', 'box'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="7" y1="5" x2="7" y2="19"/></svg>', action: () => insertCallout('note') },
|
||||||
{ label: 'Table', aliases: ['table', 'grid'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } },
|
{ label: 'Table', aliases: ['table', 'grid'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } },
|
||||||
{ label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="22" y2="12"/></svg>', action: () => editor?.chain().focus().setHorizontalRule().run() },
|
{ label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="22" y2="12"/></svg>', action: () => editor?.chain().focus().setHorizontalRule().run() },
|
||||||
{ label: 'Page Break', aliases: ['pagebreak', 'page', 'break', 'newpage', 'print'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="9" x2="22" y2="9" stroke-dasharray="4 2"/><line x1="2" y1="15" x2="22" y2="15" stroke-dasharray="4 2"/><path d="M6 5v4M18 5v4M6 15v4M18 15v4"/></svg>', action: () => editor?.chain().focus().insertContent({ type: 'pageBreak' }).run() },
|
{ label: 'Page Break', aliases: ['pagebreak', 'page', 'break', 'newpage', 'print'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="9" x2="22" y2="9" stroke-dasharray="4 2"/><line x1="2" y1="15" x2="22" y2="15" stroke-dasharray="4 2"/><path d="M6 5v4M18 5v4M6 15v4M18 15v4"/></svg>', 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<string, any>) => ({ 'data-callout': a.type }),
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute('data-callout-title') || '',
|
||||||
|
renderHTML: (a: Record<string, any>) => (a.title ? { 'data-callout-title': a.title } : {}),
|
||||||
|
},
|
||||||
|
foldable: {
|
||||||
|
default: false,
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute('data-callout-foldable') === 'true',
|
||||||
|
renderHTML: (a: Record<string, any>) => ({ 'data-callout-foldable': a.foldable ? 'true' : 'false' }),
|
||||||
|
},
|
||||||
|
folded: {
|
||||||
|
default: false,
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute('data-callout-folded') === 'true',
|
||||||
|
renderHTML: (a: Record<string, any>) => ({ '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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'callout-content';
|
||||||
|
|
||||||
|
header.append(iconBtn, titleInput, foldBtn);
|
||||||
|
dom.append(header, content);
|
||||||
|
|
||||||
|
const updateAttr = (attrs: Record<string, any>) => {
|
||||||
|
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({
|
const HeadingShortcuts = Extension.create({
|
||||||
name: 'headingShortcuts',
|
name: 'headingShortcuts',
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
@@ -2813,6 +2952,8 @@
|
|||||||
});
|
});
|
||||||
return blocks.join('\n>\n') + '\n';
|
return blocks.join('\n>\n') + '\n';
|
||||||
}
|
}
|
||||||
|
case 'callout':
|
||||||
|
return serializeCallout(node, serializeNode);
|
||||||
case 'bulletList': {
|
case 'bulletList': {
|
||||||
const items: string[] = [];
|
const items: string[] = [];
|
||||||
node.forEach((child: any) => items.push('- ' + serializeListItem(child)));
|
node.forEach((child: any) => items.push('- ' + serializeListItem(child)));
|
||||||
@@ -3565,6 +3706,14 @@
|
|||||||
return `<img src="${resolveImageSrc(imgSrc)}" alt="${alt}" data-size="${size}">`;
|
return `<img src="${resolveImageSrc(imgSrc)}" alt="${alt}" data-size="${size}">`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3687,6 +3836,7 @@
|
|||||||
MathBlock,
|
MathBlock,
|
||||||
MathInline,
|
MathInline,
|
||||||
PageBreak,
|
PageBreak,
|
||||||
|
Callout,
|
||||||
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
|
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
|
||||||
DetailsSummary,
|
DetailsSummary,
|
||||||
DetailsContent,
|
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 = `<span class="callout-type-icon" style="color: rgb(var(--callout-${item.type}))">${calloutIcon(item.type, 16)}</span><span>${item.label}</span>`;
|
||||||
|
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 = '<span class="callout-type-icon" style="color: var(--text-secondary)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg></span><span>Custom…</span>';
|
||||||
|
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() {
|
function insertDetails() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
editor.chain().focus().setDetails().run();
|
editor.chain().focus().setDetails().run();
|
||||||
@@ -4380,6 +4595,11 @@
|
|||||||
closeTextContextMenu();
|
closeTextContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ctxCallout() {
|
||||||
|
insertCallout('note');
|
||||||
|
closeTextContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
function ctxBulletList() {
|
function ctxBulletList() {
|
||||||
editor?.chain().focus().toggleBulletList().run();
|
editor?.chain().focus().toggleBulletList().run();
|
||||||
closeTextContextMenu();
|
closeTextContextMenu();
|
||||||
@@ -5575,6 +5795,10 @@
|
|||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
||||||
Collapsible Section
|
Collapsible Section
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick={() => { insertDropdown = false; insertCallout('note'); }}>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="7" y1="5" x2="7" y2="19"/></svg>
|
||||||
|
Callout
|
||||||
|
</button>
|
||||||
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
|
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
||||||
Horizontal Rule
|
Horizontal Rule
|
||||||
@@ -5861,6 +6085,11 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Callout -->
|
||||||
|
<button class="fmt-btn" class:active={(editorState, editor.isActive('callout'))} onclick={() => insertCallout('note')} title="Callout">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="7" y1="5" x2="7" y2="19"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="fmt-dropdown-wrap">
|
<div class="fmt-dropdown-wrap">
|
||||||
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); tablePickerOpen = !tablePickerOpen; headingDropdown = false; colorDropdown = false; highlightDropdown = false; alignDropdown = false; insertDropdown = false; }} title="Insert Table">
|
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); tablePickerOpen = !tablePickerOpen; headingDropdown = false; colorDropdown = false; highlightDropdown = false; alignDropdown = false; insertDropdown = false; }} title="Insert Table">
|
||||||
@@ -6178,6 +6407,10 @@
|
|||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>
|
||||||
Collapsible Section
|
Collapsible Section
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick={ctxCallout}>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><line x1="7" y1="5" x2="7" y2="19"/></svg>
|
||||||
|
Callout
|
||||||
|
</button>
|
||||||
<button onclick={ctxTimestamp}>
|
<button onclick={ctxTimestamp}>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg>
|
||||||
Date & Time
|
Date & Time
|
||||||
@@ -8343,6 +8576,148 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Callouts (Obsidian-style) */
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout) {
|
||||||
|
--cc: var(--callout-note);
|
||||||
|
margin: 1em 0;
|
||||||
|
border: 1px solid rgba(var(--cc), 0.3);
|
||||||
|
border-left: 3px solid rgb(var(--cc));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--cc), 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="note"]) { --cc: var(--callout-note); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="abstract"]) { --cc: var(--callout-abstract); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="info"]) { --cc: var(--callout-info); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="todo"]) { --cc: var(--callout-todo); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="tip"]) { --cc: var(--callout-tip); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="success"]) { --cc: var(--callout-success); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="question"]) { --cc: var(--callout-question); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="warning"]) { --cc: var(--callout-warning); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="failure"]) { --cc: var(--callout-failure); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="danger"]) { --cc: var(--callout-danger); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="bug"]) { --cc: var(--callout-bug); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="example"]) { --cc: var(--callout-example); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="quote"]) { --cc: var(--callout-quote); }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout[data-callout-group="custom"]) { --cc: var(--callout-custom); }
|
||||||
|
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-header) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--cc));
|
||||||
|
background: rgba(var(--cc), 0.1);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-icon),
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-fold) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgb(var(--cc));
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s, transform 0.2s;
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-icon:hover),
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-fold:hover) {
|
||||||
|
background: rgba(var(--cc), 0.18);
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout.is-folded .callout-fold) {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-title) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--cc));
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-title::placeholder) {
|
||||||
|
color: rgb(var(--cc));
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-content) {
|
||||||
|
padding: 8px 14px 10px;
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout.is-folded .callout-content) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-content > p:first-child) { margin-top: 0; }
|
||||||
|
:global(.tiptap-wrapper .tiptap .callout-content > p:last-child) { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Callout type picker (rendered into <body>) */
|
||||||
|
:global(.callout-type-menu) {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 4px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
:global(.callout-type-option) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
:global(.callout-type-option:hover) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
:global(.callout-type-icon) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
:global(.callout-type-menu.is-custom) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
:global(.callout-type-custom-input) {
|
||||||
|
width: 200px;
|
||||||
|
max-width: 60vw;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 7px 9px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
:global(.callout-type-custom-input:focus) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.tiptap-wrapper .tiptap [data-type="details"]) {
|
:global(.tiptap-wrapper .tiptap [data-type="details"]) {
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Obsidian-compatible callouts.
|
||||||
|
//
|
||||||
|
// Callouts are written as blockquotes whose first line is `[!type]`:
|
||||||
|
//
|
||||||
|
// > [!warning] Optional title
|
||||||
|
// > Body markdown...
|
||||||
|
//
|
||||||
|
// A trailing `+` / `-` on the type makes the callout foldable
|
||||||
|
// (`+` = expanded, `-` = collapsed). Types are case-insensitive and have
|
||||||
|
// aliases. Callouts round-trip to this exact syntax so notes stay portable
|
||||||
|
// (no lock-in) and stay compatible with Obsidian.
|
||||||
|
|
||||||
|
// alias (lowercase) -> canonical group used for icon + colour
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
note: 'note',
|
||||||
|
abstract: 'abstract', summary: 'abstract', tldr: 'abstract',
|
||||||
|
info: 'info',
|
||||||
|
todo: 'todo',
|
||||||
|
tip: 'tip', hint: 'tip', important: 'tip',
|
||||||
|
success: 'success', check: 'success', done: 'success',
|
||||||
|
question: 'question', help: 'question', faq: 'question',
|
||||||
|
warning: 'warning', caution: 'warning', attention: 'warning',
|
||||||
|
failure: 'failure', fail: 'failure', missing: 'failure',
|
||||||
|
danger: 'danger', error: 'danger',
|
||||||
|
bug: 'bug',
|
||||||
|
example: 'example',
|
||||||
|
quote: 'quote', cite: 'quote',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a (possibly aliased / cased) type to its canonical styling group.
|
||||||
|
* Unknown / user-defined types resolve to 'custom' (neutral accent + tag icon)
|
||||||
|
* so a `[!decision]` reads as intentional rather than masquerading as a note.
|
||||||
|
*/
|
||||||
|
export function calloutGroup(type: string): string {
|
||||||
|
return ALIASES[(type || '').trim().toLowerCase()] || 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default header label shown when no custom title is set (matches Obsidian). */
|
||||||
|
export function calloutLabel(type: string): string {
|
||||||
|
const t = (type || 'note').trim();
|
||||||
|
return t ? t.charAt(0).toUpperCase() + t.slice(1) : 'Note';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner SVG markup per canonical group (24x24, stroke = currentColor).
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
note: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||||
|
abstract: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 12h6"/><path d="M9 16h6"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="11" x2="12" y2="16"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
||||||
|
todo: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
||||||
|
tip: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5Z"/>',
|
||||||
|
success: '<polyline points="20 6 9 17 4 12"/>',
|
||||||
|
question: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
failure: '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
|
||||||
|
danger: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||||
|
bug: '<rect x="8" y="6" width="8" height="14" rx="4"/><path d="m19 8-3 2"/><path d="m5 8 3 2"/><path d="m19 16-3-2"/><path d="m5 16 3-2"/><path d="M21 13h-5"/><path d="M3 13h5"/><path d="m15 4-1.5 2.5"/><path d="m9 4 1.5 2.5"/>',
|
||||||
|
example: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||||
|
quote: '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 0-1 1.03V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>',
|
||||||
|
custom: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full SVG icon string for a callout type, at the given pixel size. */
|
||||||
|
export function calloutIcon(type: string, size = 18): string {
|
||||||
|
const inner = ICONS[calloutGroup(type)] || ICONS.note;
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered list of types offered in the type picker. */
|
||||||
|
export const CALLOUT_MENU: { type: string; label: string }[] = [
|
||||||
|
{ type: 'note', label: 'Note' },
|
||||||
|
{ type: 'abstract', label: 'Abstract' },
|
||||||
|
{ type: 'info', label: 'Info' },
|
||||||
|
{ type: 'todo', label: 'Todo' },
|
||||||
|
{ type: 'tip', label: 'Tip' },
|
||||||
|
{ type: 'success', label: 'Success' },
|
||||||
|
{ type: 'question', label: 'Question' },
|
||||||
|
{ type: 'warning', label: 'Warning' },
|
||||||
|
{ type: 'failure', label: 'Failure' },
|
||||||
|
{ type: 'danger', label: 'Danger' },
|
||||||
|
{ type: 'bug', label: 'Bug' },
|
||||||
|
{ type: 'example', label: 'Example' },
|
||||||
|
{ type: 'quote', label: 'Quote' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// First line of a callout: [!type], optional +/- fold marker, optional title.
|
||||||
|
const CALLOUT_RE = /^\s*\[!([\w-]+)\]([-+]?)[ \t]*(.*)$/;
|
||||||
|
|
||||||
|
const BLOCK_SELECTOR =
|
||||||
|
'p,ul,ol,blockquote,pre,h1,h2,h3,h4,h5,h6,table,hr,div[data-callout],div[data-secret-block],div[data-math-block],div[data-pdf-src],div[data-page-break]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert callout blockquotes produced by markdown-it into
|
||||||
|
* `<div data-callout=...>` blocks that the Callout node parses. Operates in
|
||||||
|
* place on a detached container element. Processes depth-first so nested
|
||||||
|
* callouts (`> > [!todo]`) are converted before their parents.
|
||||||
|
*/
|
||||||
|
export function transformCalloutBlockquotes(root: Element): void {
|
||||||
|
const walk = (el: Element) => {
|
||||||
|
for (const child of Array.from(el.children)) walk(child);
|
||||||
|
if (el.tagName === 'BLOCKQUOTE') convertBlockquote(el);
|
||||||
|
};
|
||||||
|
walk(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertBlockquote(bq: Element): void {
|
||||||
|
const first = bq.firstElementChild;
|
||||||
|
if (!first || first.tagName !== 'P') return;
|
||||||
|
|
||||||
|
// markdown-it merges the title line and any immediately-following body lines
|
||||||
|
// into one paragraph (separated by "\n"). Split off the first line.
|
||||||
|
const innerHtml = (first as HTMLElement).innerHTML;
|
||||||
|
const nlIdx = innerHtml.indexOf('\n');
|
||||||
|
const firstLineHtml = nlIdx === -1 ? innerHtml : innerHtml.slice(0, nlIdx);
|
||||||
|
const restHtml = nlIdx === -1 ? '' : innerHtml.slice(nlIdx + 1);
|
||||||
|
|
||||||
|
const probe = document.createElement('div');
|
||||||
|
probe.innerHTML = firstLineHtml;
|
||||||
|
const m = (probe.textContent || '').match(CALLOUT_RE);
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
const type = m[1].toLowerCase();
|
||||||
|
const foldChar = m[2];
|
||||||
|
const title = (m[3] || '').trim();
|
||||||
|
const foldable = foldChar === '+' || foldChar === '-';
|
||||||
|
const folded = foldChar === '-';
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('data-callout', type);
|
||||||
|
div.setAttribute('data-callout-foldable', foldable ? 'true' : 'false');
|
||||||
|
div.setAttribute('data-callout-folded', folded ? 'true' : 'false');
|
||||||
|
if (title) div.setAttribute('data-callout-title', title);
|
||||||
|
|
||||||
|
if (restHtml.trim()) {
|
||||||
|
(first as HTMLElement).innerHTML = restHtml;
|
||||||
|
div.appendChild(first);
|
||||||
|
} else {
|
||||||
|
first.remove();
|
||||||
|
}
|
||||||
|
while (bq.firstChild) div.appendChild(bq.firstChild);
|
||||||
|
|
||||||
|
// content is `block+`; guarantee at least one block.
|
||||||
|
if (!div.querySelector(BLOCK_SELECTOR)) div.appendChild(document.createElement('p'));
|
||||||
|
|
||||||
|
bq.replaceWith(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a Callout ProseMirror node back to `> [!type] ...` markdown.
|
||||||
|
* `serializeChild` serializes a child block (the editor's own serializer),
|
||||||
|
* which makes nesting fall out automatically (each `>` line gains another `>`).
|
||||||
|
*/
|
||||||
|
export function serializeCallout(node: any, serializeChild: (child: any) => string): string {
|
||||||
|
const type = node.attrs.type || 'note';
|
||||||
|
const foldable = !!node.attrs.foldable;
|
||||||
|
const folded = !!node.attrs.folded;
|
||||||
|
const title = (node.attrs.title || '').trim();
|
||||||
|
const suffix = foldable ? (folded ? '-' : '+') : '';
|
||||||
|
const header = `[!${type}]${suffix}${title ? ' ' + title : ''}`;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
node.forEach((child: any) => parts.push(serializeChild(child).replace(/\n+$/, '')));
|
||||||
|
while (parts.length && parts[parts.length - 1] === '') parts.pop();
|
||||||
|
|
||||||
|
const out = [`> ${header}`];
|
||||||
|
const inner = parts.join('\n');
|
||||||
|
if (inner.length) {
|
||||||
|
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
|
||||||
|
}
|
||||||
|
return out.join('\n') + '\n';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user