From f9cfb138ea716b5b0137288db42372bf9cbdd879 Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 20 Oct 2026 18:59:54 +0800 Subject: [PATCH] Resync initial event connections and render nested task blocks --- web/src/app/events.test.ts | 31 +++++++++++++++++++++++++++++++ web/src/app/events.ts | 8 +++----- web/src/markdown/html.test.ts | 8 ++++++++ web/src/markdown/html.ts | 20 ++++++++++---------- 4 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 web/src/app/events.test.ts diff --git a/web/src/app/events.test.ts b/web/src/app/events.test.ts new file mode 100644 index 0000000..32b1e25 --- /dev/null +++ b/web/src/app/events.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { connectEvents } from './events' + +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() }) + +it('首次连接和重连均补同步,停止后不再连接', () => { + vi.useFakeTimers() + class Socket { + static CLOSED = 3 + static instances: Socket[] = [] + readyState = 0 + onopen?: () => void + onclose?: () => void + close() { this.readyState = Socket.CLOSED } + constructor() { Socket.instances.push(this) } + } + vi.stubGlobal('WebSocket', Socket) + let synchronized = 0 + const stop = connectEvents({ onEvents: () => {}, onResync: () => { synchronized++ } }) + Socket.instances[0].onopen!() + expect(synchronized).toBe(1) + Socket.instances[0].onclose!() + vi.advanceTimersByTime(1000) + Socket.instances[1].onopen!() + expect(synchronized).toBe(2) + stop() + Socket.instances[1].onclose!() + vi.advanceTimersByTime(30000) + expect(Socket.instances).toHaveLength(2) +}) diff --git a/web/src/app/events.ts b/web/src/app/events.ts index 8f687d2..05b8f17 100644 --- a/web/src/app/events.ts +++ b/web/src/app/events.ts @@ -1,4 +1,4 @@ -// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步 +// 订阅服务端的仓库变更事件;断线后指数退避重连,每次连接成功时触发全量同步 export interface VaultEvent { type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' @@ -12,16 +12,14 @@ export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void; let delay = 1000 let timer: ReturnType | undefined let stopped = false - let connectedBefore = false const connect = () => { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' ws = new WebSocket(`${proto}//${location.host}/api/v1/events`) ws.onopen = () => { delay = 1000 - // 首次连接前的状态由初始化加载,之后每次重连都可能错过事件 - if (connectedBefore) handlers.onResync() - connectedBefore = true + // 初始化读取到首次订阅之间也存在空窗,每次建立连接都补同步。 + handlers.onResync() } ws.onmessage = (e) => handlers.onEvents(JSON.parse(e.data)) ws.onclose = () => { diff --git a/web/src/markdown/html.test.ts b/web/src/markdown/html.test.ts index 98bca6c..911e0b6 100644 --- a/web/src/markdown/html.test.ts +++ b/web/src/markdown/html.test.ts @@ -65,6 +65,14 @@ describe('安全模式', () => { }) describe('Typora 扩展', () => { + it('任务列表保留嵌套列表和后续段落,正确处理松散与紧凑列表', () => { + expect(normalize(renderMarkdown('- [ ] 父任务\n - 子任务\n\n 后续说明\n'))).toBe(normalize( + '', + )) + expect(normalize(renderMarkdown('- [x] 已完成\n - [ ] 子任务\n- [ ] 下一项\n'))).toBe(normalize( + '', + )) + }) it('高亮、上下标、脚注引用', () => { expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('

a x2 H2O1

\n') }) diff --git a/web/src/markdown/html.ts b/web/src/markdown/html.ts index 265bae1..ed2e316 100644 --- a/web/src/markdown/html.ts +++ b/web/src/markdown/html.ts @@ -362,7 +362,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe = function blockChildren(node: SyntaxNode, withDefinitions = false): SyntaxNode[] { const out: SyntaxNode[] = [] for (let ch = node.firstChild; ch; ch = ch.nextSibling) { - if (/Mark$/.test(ch.name) || ch.name === 'Task') continue + if (/Mark$/.test(ch.name)) continue if (ch.name !== 'LinkReference' || withDefinitions) out.push(ch) } return out @@ -403,6 +403,12 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe = const body = inline(node, node.from, node.to).trim() return tight ? body + '\n' : `

${body}

\n` } + case 'Task': { + const marker = node.getChild('TaskMarker')! + const checked = text(marker.from + 1, marker.from + 2) !== ' ' ? ' checked=""' : '' + const body = ` ${inline(node, marker.to, node.to).trim()}` + return tight ? body + '\n' : `

${body}

\n` + } case 'Blockquote': return `
\n${blocks(node)}
\n` case 'BulletList': @@ -467,18 +473,12 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe = function listItem(item: SyntaxNode, tight: boolean): string { const kids = blockChildren(item) - const task = item.getChild('Task') - if (task) { - const marker = task.getChild('TaskMarker')! - const checked = text(marker.from + 1, marker.from + 2) !== ' ' ? ' checked=""' : '' - const body = inline(task, marker.to, task.to).trim() - return `
  • ${body}
  • \n` - } if (!kids.length) return '
  • \n' let body = '' for (const k of kids) body += block(k, tight) - const startsInline = tight && kids[0].name === 'Paragraph' - const endsInline = tight && kids[kids.length - 1].name === 'Paragraph' + const startsInline = tight && (kids[0].name === 'Paragraph' || kids[0].name === 'Task') + const last = kids[kids.length - 1].name + const endsInline = tight && (last === 'Paragraph' || last === 'Task') if (!startsInline) body = '\n' + body if (endsInline) body = body.replace(/\n$/, '') return `
  • ${body}
  • \n`