feat: improve task note markdown editor
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
import { applyMarkdownFormat, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
@@ -86,14 +86,25 @@ describe('task utilities', () => {
|
||||
expect(buildTaskRecurrencePayload('none', { afterCompletionDays: '1' })).toEqual({})
|
||||
})
|
||||
|
||||
it('renders safe basic markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
it('renders safe practical markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n> Note\n1. first\n- [x] done\n\n```js\nconst x = 1\n```\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
expect(html).toContain('<h1>Plan</h1>')
|
||||
expect(html).toContain('<blockquote>Note</blockquote>')
|
||||
expect(html).toContain('<ol><li>first</li></ol>')
|
||||
expect(html).toContain('type="checkbox" disabled checked')
|
||||
expect(html).toContain('<pre><code class="language-js">const x = 1</code></pre>')
|
||||
expect(html).toContain('<strong>bold</strong>')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('inserts markdown around selections and prefixes selected lines', () => {
|
||||
expect(applyMarkdownFormat('hello', 0, 5, 'bold')).toEqual({ value: '**hello**', start: 2, end: 7 })
|
||||
expect(applyMarkdownFormat('one\ntwo', 0, 7, 'bullet')).toEqual({ value: '- one\n- two', start: 2, end: 11 })
|
||||
expect(applyMarkdownFormat('', 0, 0, 'link')).toEqual({ value: '[链接文字](https://)', start: 1, end: 5 })
|
||||
expect(applyMarkdownFormat('work', 0, 4, 'task')).toEqual({ value: '- [ ] work', start: 6, end: 10 })
|
||||
})
|
||||
|
||||
it('filters titles, descriptions, and list names', () => {
|
||||
const searchable: SearchTask[] = [
|
||||
...tasks,
|
||||
|
||||
@@ -17,6 +17,38 @@ const escapeHtml = (value: string) =>
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
export type MarkdownFormat = 'heading' | 'bold' | 'italic' | 'bullet' | 'ordered' | 'task' | 'link' | 'code' | 'codeblock' | 'quote'
|
||||
|
||||
export function applyMarkdownFormat(value: string, start: number, end: number, format: MarkdownFormat) {
|
||||
const selected = value.slice(start, end)
|
||||
const replace = (replacement: string, selectionStart: number, selectionEnd: number) => ({
|
||||
value: value.slice(0, start) + replacement + value.slice(end),
|
||||
start: start + selectionStart,
|
||||
end: start + selectionEnd,
|
||||
})
|
||||
const wrap = (before: string, after = before, fallback = '文字') => {
|
||||
const content = selected || fallback
|
||||
return replace(`${before}${content}${after}`, before.length, before.length + content.length)
|
||||
}
|
||||
const prefixLines = (prefix: string, fallback = '列表项') => {
|
||||
const content = selected || fallback
|
||||
const replacement = content.split('\n').map((line) => `${prefix}${line}`).join('\n')
|
||||
return replace(replacement, prefix.length, replacement.length)
|
||||
}
|
||||
if (format === 'heading') return prefixLines('## ', '标题')
|
||||
if (format === 'bold') return wrap('**')
|
||||
if (format === 'italic') return wrap('*')
|
||||
if (format === 'bullet') return prefixLines('- ')
|
||||
if (format === 'ordered') return prefixLines('1. ')
|
||||
if (format === 'task') return prefixLines('- [ ] ')
|
||||
if (format === 'link') return selected
|
||||
? replace(`[${selected}](https://)`, 1, 1 + selected.length)
|
||||
: replace('[链接文字](https://)', 1, 5)
|
||||
if (format === 'code') return wrap('`', '`', '代码')
|
||||
if (format === 'codeblock') return wrap('```\n', '\n```', '代码')
|
||||
return prefixLines('> ', '引用')
|
||||
}
|
||||
|
||||
const renderInline = (value: string) => {
|
||||
let out = escapeHtml(value)
|
||||
out = out.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
@@ -32,17 +64,45 @@ const renderInline = (value: string) => {
|
||||
export function renderMarkdown(markdown = '') {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||
const html: string[] = []
|
||||
let inList = false
|
||||
let listType: 'ul' | 'ol' | null = null
|
||||
let inCode = false
|
||||
let codeLanguage = ''
|
||||
const codeLines: string[] = []
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push('</ul>')
|
||||
inList = false
|
||||
if (listType) {
|
||||
html.push(`</${listType}>`)
|
||||
listType = null
|
||||
}
|
||||
}
|
||||
const openList = (type: 'ul' | 'ol') => {
|
||||
if (listType === type) return
|
||||
closeList()
|
||||
html.push(`<${type}>`)
|
||||
listType = type
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd()
|
||||
const fence = line.match(/^```([\w-]*)\s*$/)
|
||||
if (fence) {
|
||||
closeList()
|
||||
if (inCode) {
|
||||
const languageClass = codeLanguage ? ` class="language-${escapeHtml(codeLanguage)}"` : ''
|
||||
html.push(`<pre><code${languageClass}>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||
codeLines.length = 0
|
||||
codeLanguage = ''
|
||||
inCode = false
|
||||
} else {
|
||||
inCode = true
|
||||
codeLanguage = fence[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (inCode) {
|
||||
codeLines.push(raw)
|
||||
continue
|
||||
}
|
||||
if (!line.trim()) {
|
||||
closeList()
|
||||
continue
|
||||
@@ -53,18 +113,29 @@ export function renderMarkdown(markdown = '') {
|
||||
} else if (line.startsWith('## ')) {
|
||||
closeList()
|
||||
html.push(`<h2>${renderInline(line.slice(3))}</h2>`)
|
||||
} else if (line.startsWith('> ')) {
|
||||
closeList()
|
||||
html.push(`<blockquote>${renderInline(line.slice(2))}</blockquote>`)
|
||||
} else if (/^\d+\. /.test(line)) {
|
||||
openList('ol')
|
||||
html.push(`<li>${renderInline(line.replace(/^\d+\. /, ''))}</li>`)
|
||||
} else if (/^[-*] \[[ xX]\] /.test(line)) {
|
||||
openList('ul')
|
||||
const checked = /^[-*] \[[xX]\] /.test(line)
|
||||
html.push(`<li class="task-list-item"><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderInline(line.replace(/^[-*] \[[ xX]\] /, ''))}</li>`)
|
||||
} else if (/^[-*] /.test(line)) {
|
||||
if (!inList) {
|
||||
html.push('<ul>')
|
||||
inList = true
|
||||
}
|
||||
openList('ul')
|
||||
html.push(`<li>${renderInline(line.slice(2))}</li>`)
|
||||
} else if (/^(---|\*\*\*)$/.test(line.trim())) {
|
||||
closeList()
|
||||
html.push('<hr>')
|
||||
} else {
|
||||
closeList()
|
||||
html.push(`<p>${renderInline(line)}</p>`)
|
||||
}
|
||||
}
|
||||
closeList()
|
||||
if (inCode) html.push(`<pre><code>${escapeHtml(['```' + codeLanguage, ...codeLines].join('\n'))}</code></pre>`)
|
||||
return html.join('')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user