mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
Add callouts (> [!type]) with fold, titles, type picker, and custom types
This commit is contained in:
@@ -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: '<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: '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: '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() },
|
||||
@@ -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({
|
||||
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 `<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;
|
||||
}
|
||||
|
||||
@@ -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 = `<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() {
|
||||
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 @@
|
||||
<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
|
||||
</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(); }}>
|
||||
<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
|
||||
@@ -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>
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -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>
|
||||
Collapsible Section
|
||||
</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}>
|
||||
<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
|
||||
@@ -8343,6 +8576,148 @@
|
||||
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"]) {
|
||||
margin: 1em 0;
|
||||
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