Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions e2e/src/plugin-listener/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ setup(() => {
console.log(before, after)
})
}
if (type === 'debounce') {
let callCount = 0
listener.markdownUpdated((_ctx, _markdown, _prevMarkdown) => {
callCount++
// oxlint-disable-next-line no-console
console.log(`[debounce-test] callCount=${callCount}`)
})
}
})
.config(nord)
.use(commonmark)
Expand Down
10 changes: 5 additions & 5 deletions e2e/tests/plugin/automd.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,12 @@ test('with plugin listener', async ({ page }) => {
const [msg2] = msg.args()
expect(await msg2?.jsonValue()).toBe('\\*A\n')

// Typing '*' triggers both a keystroke transaction and an automd italic
// conversion transaction. With debouncing, both are coalesced into a
// single markdownUpdated callback with the final markdown.
msgPromise = page.waitForEvent('console')
await page.keyboard.type('*')
await msgPromise

msgPromise = page.waitForEvent('console')
msg = await msgPromise
const [msg4] = msg.args()
expect(await msg4?.jsonValue()).toBe('*A*\n')
const [msg3] = msg.args()
expect(await msg3?.jsonValue()).toBe('*A*\n')
})
28 changes: 28 additions & 0 deletions e2e/tests/plugin/listener.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,31 @@ test('on selection updated', async ({ page }) => {
expect(afterText).toBe('5-5')
expect(beforeText).toBe('0-6')
})

test('markdownUpdated is properly debounced during rapid typing', async ({
page,
}) => {
await page.goto('/plugin-listener/?type=debounce')
await focusEditor(page)
await waitNextFrame(page)

// Collect all debounce-test console messages
const debounceLogs: string[] = []
page.on('console', (msg) => {
const text = msg.text()
if (text.includes('[debounce-test]')) {
debounceLogs.push(text)
}
})

// Type 10 characters rapidly (30ms between keys, well within 200ms debounce)
await page.keyboard.type('abcdefghij', { delay: 30 })

// Wait for debounce to fire (200ms after last keystroke + buffer)
await page.waitForTimeout(500)

// With proper debouncing, we expect at most 3 callbacks
// (timing variance may cause 2-3 debounce windows, but never 10)
expect(debounceLogs.length).toBeGreaterThanOrEqual(1)
expect(debounceLogs.length).toBeLessThanOrEqual(3)
})
51 changes: 26 additions & 25 deletions packages/plugins/plugin-listener/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Ctx, MilkdownPlugin } from '@milkdown/ctx'
import type { Node as ProseNode } from '@milkdown/prose/model'
import type { Selection } from '@milkdown/prose/state'
import type { Selection, Transaction } from '@milkdown/prose/state'

import {
EditorViewReady,
Expand Down Expand Up @@ -160,6 +160,29 @@ export const listener: MilkdownPlugin = (ctx) => {
let prevDoc: ProseNode | null = null
let prevMarkdown: string | null = null
let prevSelection: Selection | null = null
let latestTr: Transaction | null = null

const debouncedHandler = debounce(() => {
if (!latestTr) return
const { doc } = latestTr

if (listeners.updated.length > 0 && prevDoc && !prevDoc.eq(doc)) {
listeners.updated.forEach((fn) => {
fn(ctx, doc, prevDoc!)
})
}

if (listeners.markdownUpdated.length > 0 && prevDoc && !prevDoc.eq(doc)) {
const markdown = serializer(doc)
listeners.markdownUpdated.forEach((fn) => {
fn(ctx, markdown, prevMarkdown!)
})
prevMarkdown = markdown
}

prevDoc = doc
latestTr = null
}, 200)

const plugin = new Plugin({
key,
Expand Down Expand Up @@ -205,30 +228,8 @@ export const listener: MilkdownPlugin = (ctx) => {
)
return

const handler = debounce(() => {
const { doc } = tr
if (listeners.updated.length > 0 && prevDoc && !prevDoc.eq(doc)) {
listeners.updated.forEach((fn) => {
fn(ctx, doc, prevDoc!)
})
}

if (
listeners.markdownUpdated.length > 0 &&
prevDoc &&
!prevDoc.eq(doc)
) {
const markdown = serializer(doc)
listeners.markdownUpdated.forEach((fn) => {
fn(ctx, markdown, prevMarkdown!)
})
prevMarkdown = markdown
}

prevDoc = doc
}, 200)

return handler()
latestTr = tr
debouncedHandler()
},
},
})
Expand Down
Loading