Merge pull request #243 from gamosoft/features/callouts
Features/callouts
This commit is contained in:
commit
40797ac457
|
|
@ -528,6 +528,34 @@ def generate_export_html(
|
||||||
color: var(--text-secondary, #6a737d);
|
color: var(--text-secondary, #6a737d);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* Callouts — mirror the in-app preview. */
|
||||||
|
.markdown-preview .callout {{
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-left: 4px solid var(--callout-color, var(--accent-primary, #0366d6));
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--callout-bg, var(--bg-secondary, #f6f8fa));
|
||||||
|
}}
|
||||||
|
.markdown-preview .callout-title {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--callout-color, var(--accent-primary, #0366d6));
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}}
|
||||||
|
.markdown-preview .callout-icon {{
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1;
|
||||||
|
}}
|
||||||
|
.markdown-preview .callout-body > :first-child {{ margin-top: 0; }}
|
||||||
|
.markdown-preview .callout-body > :last-child {{ margin-bottom: 0; }}
|
||||||
|
.markdown-preview .callout-note {{ --callout-color: #0969da; --callout-bg: rgba(9, 105, 218, 0.08); }}
|
||||||
|
.markdown-preview .callout-tip {{ --callout-color: #1a7f37; --callout-bg: rgba(26, 127, 55, 0.08); }}
|
||||||
|
.markdown-preview .callout-important {{ --callout-color: #8250df; --callout-bg: rgba(130, 80, 223, 0.08); }}
|
||||||
|
.markdown-preview .callout-warning {{ --callout-color: #9a6700; --callout-bg: rgba(154, 103, 0, 0.08); }}
|
||||||
|
.markdown-preview .callout-caution {{ --callout-color: #d1242f; --callout-bg: rgba(209, 36, 47, 0.08); }}
|
||||||
|
|
||||||
.markdown-preview ul,
|
.markdown-preview ul,
|
||||||
.markdown-preview ol {{
|
.markdown-preview ol {{
|
||||||
padding-left: 2em;
|
padding-left: 2em;
|
||||||
|
|
@ -767,9 +795,52 @@ def generate_export_html(
|
||||||
// Raw markdown content
|
// Raw markdown content
|
||||||
const markdown = `{escaped_content}`;
|
const markdown = `{escaped_content}`;
|
||||||
|
|
||||||
|
// GitHub-style callouts — mirrors the in-app preview preprocessor.
|
||||||
|
let processed;
|
||||||
|
{{
|
||||||
|
const CALLOUT_RE = /^>\\s*\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\]\\s*(.*)$/i;
|
||||||
|
const CALLOUT_ICONS = {{ note: 'ℹ️', tip: '💡', important: '❗', warning: '⚠️', caution: '🛑' }};
|
||||||
|
const CALLOUT_TITLES = {{ note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' }};
|
||||||
|
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const codeBlocks = [];
|
||||||
|
processed = markdown
|
||||||
|
.replace(/```[\\s\\S]*?```/g, function(m) {{ codeBlocks.push(m); return '\\x00CODEBLOCK' + (codeBlocks.length - 1) + '\\x00'; }})
|
||||||
|
.replace(/`[^`]+`/g, function(m) {{ codeBlocks.push(m); return '\\x00CODEBLOCK' + (codeBlocks.length - 1) + '\\x00'; }});
|
||||||
|
const srcLines = processed.split('\\n');
|
||||||
|
const outLines = [];
|
||||||
|
let li = 0;
|
||||||
|
while (li < srcLines.length) {{
|
||||||
|
const m = srcLines[li].match(CALLOUT_RE);
|
||||||
|
if (!m) {{ outLines.push(srcLines[li]); li++; continue; }}
|
||||||
|
const type = m[1].toLowerCase();
|
||||||
|
const title = escapeAttr((m[2] || '').trim() || CALLOUT_TITLES[type]);
|
||||||
|
const icon = CALLOUT_ICONS[type];
|
||||||
|
const bodyLines = [];
|
||||||
|
li++;
|
||||||
|
while (li < srcLines.length && srcLines[li].startsWith('>')) {{
|
||||||
|
bodyLines.push(srcLines[li].replace(/^>\\s?/, ''));
|
||||||
|
li++;
|
||||||
|
}}
|
||||||
|
outLines.push(
|
||||||
|
'',
|
||||||
|
'<div class="callout callout-' + type + '">',
|
||||||
|
'<div class="callout-title"><span class="callout-icon" aria-hidden="true">' + icon + '</span><span class="callout-title-text">' + title + '</span></div>',
|
||||||
|
'<div class="callout-body">',
|
||||||
|
'',
|
||||||
|
bodyLines.join('\\n'),
|
||||||
|
'',
|
||||||
|
'</div>',
|
||||||
|
'</div>',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
processed = outLines.join('\\n')
|
||||||
|
.replace(/\\x00CODEBLOCK(\\d+)\\x00/g, function(_, idx) {{ return codeBlocks[parseInt(idx)]; }});
|
||||||
|
}}
|
||||||
|
|
||||||
// Render markdown with XSS sanitization
|
// Render markdown with XSS sanitization
|
||||||
// DOMPurify strips scripts, iframes, and event handlers while allowing safe HTML/SVG
|
// DOMPurify strips scripts, iframes, and event handlers while allowing safe HTML/SVG
|
||||||
const rawHtml = marked.parse(markdown);
|
const rawHtml = marked.parse(processed);
|
||||||
const safeHtml = DOMPurify.sanitize(rawHtml);
|
const safeHtml = DOMPurify.sanitize(rawHtml);
|
||||||
document.getElementById('content').innerHTML = safeHtml;
|
document.getElementById('content').innerHTML = safeHtml;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,34 @@ graph TD
|
||||||
|
|
||||||
📄 **See the [MERMAID](MERMAID.md) note for diagram examples and syntax reference.**
|
📄 **See the [MERMAID](MERMAID.md) note for diagram examples and syntax reference.**
|
||||||
|
|
||||||
|
## 💬 Callouts / Admonitions
|
||||||
|
|
||||||
|
GitHub-style callouts highlight important information in your notes. They render as colored, bordered blocks in the preview pane and are fully compatible with notes imported from GitHub or Obsidian.
|
||||||
|
|
||||||
|
### Syntax
|
||||||
|
|
||||||
|
Start a blockquote with `> [!TYPE]` on its own line, then continue with the body:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
> [!NOTE]
|
||||||
|
> Useful information that users should know, even when skimming.
|
||||||
|
|
||||||
|
> [!TIP] Custom title
|
||||||
|
> Helpful advice for doing things better or more easily.
|
||||||
|
```
|
||||||
|
|
||||||
|
The first line can include an optional custom title after the type. Inner markdown (bold, links, lists, code) is fully parsed.
|
||||||
|
|
||||||
|
### Supported Types
|
||||||
|
|
||||||
|
- `[!NOTE]` - General information (blue)
|
||||||
|
- `[!TIP]` - Helpful advice (green)
|
||||||
|
- `[!IMPORTANT]` - Crucial information (purple)
|
||||||
|
- `[!WARNING]` - Urgent caution required (amber)
|
||||||
|
- `[!CAUTION]` - Negative consequences if ignored (red)
|
||||||
|
|
||||||
|
Unknown types fall through to a normal blockquote, so existing notes are never broken.
|
||||||
|
|
||||||
## 📄 Note Templates
|
## 📄 Note Templates
|
||||||
|
|
||||||
Create notes from reusable templates with dynamic placeholder replacement.
|
Create notes from reusable templates with dynamic placeholder replacement.
|
||||||
|
|
|
||||||
|
|
@ -5190,6 +5190,21 @@ function noteApp() {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Markdown formatting helpers
|
// Markdown formatting helpers
|
||||||
|
// Trim trailing whitespace from double-click word selections.
|
||||||
|
// No-op on browsers that already exclude it (Firefox, Safari).
|
||||||
|
trimSelectionTrailingSpace(event) {
|
||||||
|
const editor = event && event.target;
|
||||||
|
if (!editor) return;
|
||||||
|
const start = editor.selectionStart;
|
||||||
|
let end = editor.selectionEnd;
|
||||||
|
if (start >= end) return;
|
||||||
|
const text = editor.value;
|
||||||
|
while (end > start && /\s/.test(text.charAt(end - 1))) end--;
|
||||||
|
if (end !== editor.selectionEnd) {
|
||||||
|
editor.setSelectionRange(start, end);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
wrapSelection(before, after, placeholder) {
|
wrapSelection(before, after, placeholder) {
|
||||||
const editor = document.getElementById('note-editor');
|
const editor = document.getElementById('note-editor');
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
|
@ -5197,23 +5212,29 @@ function noteApp() {
|
||||||
const start = editor.selectionStart;
|
const start = editor.selectionStart;
|
||||||
const end = editor.selectionEnd;
|
const end = editor.selectionEnd;
|
||||||
const selectedText = this.noteContent.substring(start, end);
|
const selectedText = this.noteContent.substring(start, end);
|
||||||
const textToWrap = selectedText || placeholder;
|
|
||||||
|
// Push edge whitespace OUTSIDE inline wrappers — "**house **" is
|
||||||
|
// invalid per CommonMark. Block wrappers (with \n) keep it intact.
|
||||||
|
const isInline = !before.includes('\n') && !after.includes('\n');
|
||||||
|
let leading = '', trailing = '', core = selectedText;
|
||||||
|
if (isInline && selectedText) {
|
||||||
|
const m = selectedText.match(/^(\s*)([\s\S]*?)(\s*)$/);
|
||||||
|
leading = m[1];
|
||||||
|
core = m[2];
|
||||||
|
trailing = m[3];
|
||||||
|
}
|
||||||
|
const textToWrap = core || placeholder;
|
||||||
|
|
||||||
// Build the new text
|
// Build the new text
|
||||||
const newText = before + textToWrap + after;
|
const newText = leading + before + textToWrap + after + trailing;
|
||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
|
this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
|
||||||
|
|
||||||
// Set cursor position (select the wrapped text or placeholder)
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (selectedText) {
|
const coreStart = start + leading.length + before.length;
|
||||||
// If text was selected, keep it selected (inside the wrapper)
|
const coreEnd = coreStart + textToWrap.length;
|
||||||
editor.setSelectionRange(start + before.length, start + before.length + selectedText.length);
|
editor.setSelectionRange(coreStart, coreEnd);
|
||||||
} else {
|
|
||||||
// If no text selected, select the placeholder
|
|
||||||
editor.setSelectionRange(start + before.length, start + before.length + placeholder.length);
|
|
||||||
}
|
|
||||||
editor.focus();
|
editor.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -5811,6 +5832,49 @@ function noteApp() {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Step 2c: GitHub-style callouts. Runs inside the code-protection
|
||||||
|
// window. Unknown types fall through to plain blockquotes.
|
||||||
|
{
|
||||||
|
const CALLOUT_RE = /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i;
|
||||||
|
const CALLOUT_ICONS = { note: 'ℹ️', tip: '💡', important: '❗', warning: '⚠️', caution: '🛑' };
|
||||||
|
const CALLOUT_TITLES = { note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' };
|
||||||
|
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const srcLines = contentToRender.split('\n');
|
||||||
|
const outLines = [];
|
||||||
|
let li = 0;
|
||||||
|
while (li < srcLines.length) {
|
||||||
|
const m = srcLines[li].match(CALLOUT_RE);
|
||||||
|
if (!m) {
|
||||||
|
outLines.push(srcLines[li]);
|
||||||
|
li++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = m[1].toLowerCase();
|
||||||
|
const title = escapeAttr((m[2] || '').trim() || CALLOUT_TITLES[type]);
|
||||||
|
const icon = CALLOUT_ICONS[type];
|
||||||
|
const bodyLines = [];
|
||||||
|
li++;
|
||||||
|
while (li < srcLines.length && srcLines[li].startsWith('>')) {
|
||||||
|
bodyLines.push(srcLines[li].replace(/^>\s?/, ''));
|
||||||
|
li++;
|
||||||
|
}
|
||||||
|
outLines.push(
|
||||||
|
'',
|
||||||
|
`<div class="callout callout-${type}">`,
|
||||||
|
`<div class="callout-title"><span class="callout-icon" aria-hidden="true">${icon}</span><span class="callout-title-text">${title}</span></div>`,
|
||||||
|
`<div class="callout-body">`,
|
||||||
|
'',
|
||||||
|
bodyLines.join('\n'),
|
||||||
|
'',
|
||||||
|
`</div>`,
|
||||||
|
`</div>`,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
contentToRender = outLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: Restore code blocks
|
// Step 3: Restore code blocks
|
||||||
contentToRender = contentToRender.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
|
contentToRender = contentToRender.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
|
||||||
return codeBlocks[parseInt(index)];
|
return codeBlocks[parseInt(index)];
|
||||||
|
|
|
||||||
|
|
@ -545,6 +545,34 @@
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Callouts — GitHub-style admonitions (> [!NOTE], etc.) */
|
||||||
|
.markdown-preview .callout {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-left: 4px solid var(--callout-color, var(--accent-primary));
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--callout-bg, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
.markdown-preview .callout-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--callout-color, var(--accent-primary));
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.markdown-preview .callout-icon {
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.markdown-preview .callout-body > :first-child { margin-top: 0; }
|
||||||
|
.markdown-preview .callout-body > :last-child { margin-bottom: 0; }
|
||||||
|
.markdown-preview .callout-note { --callout-color: #0969da; --callout-bg: rgba(9, 105, 218, 0.08); }
|
||||||
|
.markdown-preview .callout-tip { --callout-color: #1a7f37; --callout-bg: rgba(26, 127, 55, 0.08); }
|
||||||
|
.markdown-preview .callout-important { --callout-color: #8250df; --callout-bg: rgba(130, 80, 223, 0.08); }
|
||||||
|
.markdown-preview .callout-warning { --callout-color: #9a6700; --callout-bg: rgba(154, 103, 0, 0.08); }
|
||||||
|
.markdown-preview .callout-caution { --callout-color: #d1242f; --callout-bg: rgba(209, 36, 47, 0.08); }
|
||||||
|
|
||||||
/* Task lists */
|
/* Task lists */
|
||||||
.markdown-preview input[type="checkbox"] {
|
.markdown-preview input[type="checkbox"] {
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
|
@ -3031,6 +3059,7 @@
|
||||||
@scroll="syncOverlayScroll()"
|
@scroll="syncOverlayScroll()"
|
||||||
@keydown.enter="handleEditorEnterKey($event)"
|
@keydown.enter="handleEditorEnterKey($event)"
|
||||||
@keydown.tab="handleTabKey($event)"
|
@keydown.tab="handleTabKey($event)"
|
||||||
|
@dblclick="trimSelectionTrailingSpace($event)"
|
||||||
@drop="onEditorDrop($event)"
|
@drop="onEditorDrop($event)"
|
||||||
@dragover.prevent="onEditorDragOver($event)"
|
@dragover.prevent="onEditorDragOver($event)"
|
||||||
@dragenter="onEditorDragEnter($event)"
|
@dragenter="onEditorDragEnter($event)"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue