feat(editor): add themed callouts and desktop command boundary
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calloutTypes, parseCallout } from './callouts'
|
||||
import { renderMarkdown } from './markdown'
|
||||
|
||||
describe('callouts', () => {
|
||||
for (const [type, aliases] of Object.entries(calloutTypes)) {
|
||||
for (const alias of aliases) it(`renders ${alias}`, async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown(`> [!${alias.toUpperCase()}] 标题\n> **正文** 与 \`code\`\n>\n> - 条目`)
|
||||
expect(root.querySelector('.markdown-callout')?.getAttribute('data-callout')).toBe(type)
|
||||
expect(root.querySelector('.callout-title')?.textContent).toBe('标题')
|
||||
expect(root.querySelector('strong')?.textContent).toBe('正文')
|
||||
expect(root.querySelector('li')?.textContent).toBe('条目')
|
||||
})
|
||||
}
|
||||
it('supports folding, nesting, unknown types, empty bodies and safe titles', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown('> [!WARNING]- 外层\n>\n> > [!tip]+ 内层\n> > 内容\n\n> [!custom] <img src=x onerror=alert(1)>\n\n> [!NOTE]')
|
||||
expect(root.querySelector('details')?.hasAttribute('open')).toBe(false)
|
||||
expect(root.querySelector('details details')?.hasAttribute('open')).toBe(true)
|
||||
expect(root.querySelectorAll('.markdown-callout')).toHaveLength(4)
|
||||
expect(root.querySelector('img')).toBeNull()
|
||||
expect(root.textContent).toContain('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
it('renders the editor serialization of nested folded callouts', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown('> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容\n')
|
||||
expect(root.querySelectorAll('.markdown-callout')).toHaveLength(2)
|
||||
expect(root.querySelector('strong')?.textContent).toBe('正文')
|
||||
})
|
||||
it('does not convert ordinary quotes, inline code, escaped markers or fenced examples', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown('> ordinary\n\n> \\[!NOTE]\n\n`[!TIP]`\n\n```text\n> [!WARNING]\n```')
|
||||
expect(root.querySelector('.markdown-callout')).toBeNull()
|
||||
expect(root.querySelectorAll('blockquote')).toHaveLength(2)
|
||||
expect(parseCallout('prefix [!NOTE]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
/** GitHub alerts and Obsidian callouts share the same portable Markdown syntax. */
|
||||
export const calloutTypes = {
|
||||
note: ['note'], abstract: ['abstract', 'summary', 'tldr'], info: ['info'],
|
||||
todo: ['todo'], tip: ['tip', 'hint'], important: ['important'], success: ['success', 'check', 'done'],
|
||||
question: ['question', 'help', 'faq'], warning: ['warning', 'caution', 'attention'],
|
||||
failure: ['failure', 'fail', 'missing'], danger: ['danger', 'error'], bug: ['bug'],
|
||||
example: ['example'], quote: ['quote', 'cite'],
|
||||
} as const
|
||||
|
||||
export function parseCallout(text: string) {
|
||||
const match = /^\[!([\w-]+)\]([+-]?)[ \t]*([^\n]*)(?:\n|$)/.exec(text)
|
||||
if (!match) return null
|
||||
const name = match[1]!.toLowerCase()
|
||||
const type = Object.entries(calloutTypes).find(([, aliases]) => (aliases as readonly string[]).includes(name))?.[0] ?? 'note'
|
||||
return { name, type, title: match[3]!.trim() || name.charAt(0).toUpperCase() + name.slice(1),
|
||||
fold: match[2] || null, markerLength: match[0].replace(/\n$/, '').length, body: text.slice(match[0].length) }
|
||||
}
|
||||
|
||||
export function escapeCalloutTitle(text: string) {
|
||||
return text.replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
|
||||
}
|
||||
@@ -9,6 +9,19 @@ import { renderMermaid } from '@/services/mermaidService'
|
||||
import { appendDiagramControls } from './diagramControls'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { parseCallout, escapeCalloutTitle } from './callouts'
|
||||
import '@/styles/callouts.css'
|
||||
|
||||
marked.use({ renderer: { blockquote(token) {
|
||||
const callout = parseCallout(token.text)
|
||||
if (!callout) return false
|
||||
const title = escapeCalloutTitle(callout.title)
|
||||
const body = marked.parse(callout.body, { async: false }) as string
|
||||
const attributes = `class="markdown-callout" data-callout="${callout.type}"`
|
||||
return callout.fold
|
||||
? `<details ${attributes}${callout.fold === '+' ? ' open' : ''}><summary class="callout-title">${title}</summary><div class="callout-body">${body}</div></details>`
|
||||
: `<aside ${attributes}><div class="callout-title">${title}</div><div class="callout-body">${body}</div></aside>`
|
||||
} } })
|
||||
|
||||
function mathHtml(source: string, displayMode: boolean) {
|
||||
const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'})
|
||||
|
||||
Reference in New Issue
Block a user