Skip to content
Draft
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
173 changes: 173 additions & 0 deletions packages/core/src/extensions/caret-marker-snap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { TextSelection } from '@prosekit/pm/state'
import { describe, expect, it } from 'vitest'
import { userEvent } from 'vitest/browser'

import { docToMarkdown } from '../converters/pm-to-md.ts'
import { findText } from '../testing/find-text.ts'
import { setupFixture, type Fixture } from '../testing/index.ts'

import { defineMarkMode, type MarkMode } from './mark-mode.ts'

function setupMode(mode: MarkMode, doc: (fixture: Fixture) => void): Fixture {
const fixture = setupFixture()
fixture.editor.use(defineMarkMode(mode))
doc(fixture)
fixture.view.focus()
return fixture
}

function setCaret(fixture: Fixture, pos: number): void {
const { view } = fixture
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)))
}

/** Dispatch a real (synthetic) left click at viewport coords, then settle. */
async function clickAtCoords(fixture: Fixture, clientX: number, clientY: number): Promise<void> {
const target = document.elementFromPoint(clientX, clientY) ?? fixture.view.dom
const init = { clientX, clientY, bubbles: true, cancelable: true, view: window, button: 0 }
target.dispatchEvent(new MouseEvent('mousedown', init))
target.dispatchEvent(new MouseEvent('mouseup', init))
target.dispatchEvent(new MouseEvent('click', init))
await new Promise((resolve) => setTimeout(resolve, 15))
}

/** Click at the left edge of the first occurrence of `query`. */
async function clickLeftEdgeOf(fixture: Fixture, query: string): Promise<void> {
const coords = fixture.view.coordsAtPos(findText(fixture.doc, query))
await clickAtCoords(fixture, coords.left, (coords.top + coords.bottom) / 2)
}

async function traverse(
fixture: Fixture,
start: number,
key: string,
times: number,
): Promise<string> {
setCaret(fixture, start)
const steps = [fixture.selectionSnapshot]
for (let index = 0; index < times; index++) {
await userEvent.keyboard(`{${key}}`)
steps.push(fixture.selectionSnapshot)
}
return steps.join(' -> ')
}

describe('caret-marker-snap (hide mode)', () => {
it('a click left of "foo" then Enter keeps the bold intact', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**foo**'))))
await clickLeftEdgeOf(fixture, 'foo')
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('\n**foo**\n')
})

// KNOWN FAILING (display:none DOM-caret ambiguity): the click snaps the model
// selection to pos 1 (verified), but Space/typing go through the browser's DOM
// insertion, which resolves the zero-width hidden `**` to pos 3 anyway. Enter
// works because it uses the model selection (the Enter keymap); text input does not.
it('a click left of "foo" then Space inserts outside the bold', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**foo**'))))
await clickLeftEdgeOf(fixture, 'foo')
await userEvent.keyboard(' ')
expect(docToMarkdown(fixture.doc)).toBe(' **foo**\n')

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:44

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:44

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:44

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:44

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39

Check failure on line 71 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then Space inserts outside the bold

AssertionError: expected '** foo**\n' to be ' **foo**\n' // Object.is equality - Expected + Received - **foo** + ** foo** ❯ src/extensions/caret-marker-snap.test.ts:71:39
})

// KNOWN FAILING (same display:none DOM-caret ambiguity as the Space case above).
it('a click left of "foo" then typing inserts outside the bold', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**foo**'))))
await clickLeftEdgeOf(fixture, 'foo')
await userEvent.keyboard('x')
expect(docToMarkdown(fixture.doc)).toBe('x**foo**\n')

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:44

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:44

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:44

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:44

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39

Check failure on line 79 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of "foo" then typing inserts outside the bold

AssertionError: expected '**xfoo**\n' to be 'x**foo**\n' // Object.is equality - Expected + Received - x**foo** + **xfoo** ❯ src/extensions/caret-marker-snap.test.ts:79:39
})

it('ArrowLeft into the opening run then Enter keeps the bold intact', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**bold**'))))
setCaret(fixture, findText(fixture.doc, 'bold') + 1) // after "b"
await userEvent.keyboard('{ArrowLeft}')
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('\n**bold**\n')
})

// DOCUMENTS CURRENT BEHAVIOR (a known limitation): in hide mode the browser
// already skips the display:none markers natively, but it gets STUCK at the
// content edge - ArrowRight stops at pos 6 (before the hidden closing `**`) and
// never reaches pos 8. No selection change fires there, so appendTransaction
// cannot nudge it across. Ideal would reach the unit's outer edge.
it('hide-mode arrow nav stops at the content edge (known limitation)', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**foo**'))))
expect(await traverse(fixture, 1, 'ArrowRight', 6)).toMatchInlineSnapshot(

Check failure on line 97 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > hide-mode arrow nav stops at the content edge (known limitation)

Error: Snapshot `caret-marker-snap (hide mode) > hide-mode arrow nav stops at the content edge (known limitation) 1` mismatched Expected: ""┃**foo** -> **f┃oo** -> **fo┃o** -> **foo┃** -> **foo┃** -> **foo┃** -> **foo┃**"" Received: ""┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo**"" ❯ src/extensions/caret-marker-snap.test.ts:97:56

Check failure on line 97 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > hide-mode arrow nav stops at the content edge (known limitation)

Error: Snapshot `caret-marker-snap (hide mode) > hide-mode arrow nav stops at the content edge (known limitation) 1` mismatched Expected: ""┃**foo** -> **f┃oo** -> **fo┃o** -> **foo┃** -> **foo┃** -> **foo┃** -> **foo┃**"" Received: ""┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo**"" ❯ src/extensions/caret-marker-snap.test.ts:97:56

Check failure on line 97 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > hide-mode arrow nav stops at the content edge (known limitation)

Error: Snapshot `caret-marker-snap (hide mode) > hide-mode arrow nav stops at the content edge (known limitation) 1` mismatched Expected: ""┃**foo** -> **f┃oo** -> **fo┃o** -> **foo┃** -> **foo┃** -> **foo┃** -> **foo┃**"" Received: ""┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo**"" ❯ src/extensions/caret-marker-snap.test.ts:97:56

Check failure on line 97 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > hide-mode arrow nav stops at the content edge (known limitation)

Error: Snapshot `caret-marker-snap (hide mode) > hide-mode arrow nav stops at the content edge (known limitation) 1` mismatched Expected: ""┃**foo** -> **f┃oo** -> **fo┃o** -> **foo┃** -> **foo┃** -> **foo┃** -> **foo┃**"" Received: ""┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo** -> ┃**foo**"" ❯ src/extensions/caret-marker-snap.test.ts:97:56
`"┃**foo** -> **f┃oo** -> **fo┃o** -> **foo┃** -> **foo┃** -> **foo┃** -> **foo┃**"`,
)
expect(await traverse(fixture, 8, 'ArrowLeft', 6)).toMatchInlineSnapshot(
`"**foo**┃ -> **fo┃o** -> **f┃oo** -> **┃foo** -> **┃foo** -> **┃foo** -> **┃foo**"`,
)
})

// DOCUMENTS CURRENT BEHAVIOR: the caret reaches the single character (after "a")
// and the unit's left edge, so `**a**` is no longer skipped as an atom.
it('a single-character bold **a** is reachable by the caret', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**a**'))))
expect(await traverse(fixture, 1, 'ArrowRight', 4)).toMatchInlineSnapshot(

Check failure on line 109 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a single-character bold **a** is reachable by the caret

Error: Snapshot `caret-marker-snap (hide mode) > a single-character bold **a** is reachable by the caret 1` mismatched Expected: ""┃**a** -> **a┃** -> **a┃** -> **a┃** -> **a┃**"" Received: ""┃**a** -> ┃**a** -> ┃**a** -> ┃**a** -> ┃**a**"" ❯ src/extensions/caret-marker-snap.test.ts:109:56

Check failure on line 109 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a single-character bold **a** is reachable by the caret

Error: Snapshot `caret-marker-snap (hide mode) > a single-character bold **a** is reachable by the caret 1` mismatched Expected: ""┃**a** -> **a┃** -> **a┃** -> **a┃** -> **a┃**"" Received: ""┃**a** -> ┃**a** -> ┃**a** -> ┃**a** -> ┃**a**"" ❯ src/extensions/caret-marker-snap.test.ts:109:56

Check failure on line 109 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a single-character bold **a** is reachable by the caret

Error: Snapshot `caret-marker-snap (hide mode) > a single-character bold **a** is reachable by the caret 1` mismatched Expected: ""┃**a** -> **a┃** -> **a┃** -> **a┃** -> **a┃**"" Received: ""┃**a** -> ┃**a** -> ┃**a** -> ┃**a** -> ┃**a**"" ❯ src/extensions/caret-marker-snap.test.ts:109:56

Check failure on line 109 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a single-character bold **a** is reachable by the caret

Error: Snapshot `caret-marker-snap (hide mode) > a single-character bold **a** is reachable by the caret 1` mismatched Expected: ""┃**a** -> **a┃** -> **a┃** -> **a┃** -> **a┃**"" Received: ""┃**a** -> ┃**a** -> ┃**a** -> ┃**a** -> ┃**a**"" ❯ src/extensions/caret-marker-snap.test.ts:109:56
`"┃**a** -> **a┃** -> **a┃** -> **a┃** -> **a┃**"`,
)
})

it('Enter before "a" in **a** keeps the bold and adds an empty paragraph', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**a**'))))
setCaret(fixture, findText(fixture.doc, 'a')) // content edge before "a"
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('\n**a**\n')
})

it('Enter after "a" in **a** keeps the bold and adds an empty paragraph', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**a**'))))
setCaret(fixture, findText(fixture.doc, 'a') + 1) // content edge after "a"
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('**a**\n')
})

it('a click left of "foo" inside a bullet then Enter keeps the bold intact', async () => {
using fixture = setupMode('hide', (f) =>
f.set(f.n.doc(f.n.list({ kind: 'bullet' }, f.n.paragraph('**foo**')))),
)
await clickLeftEdgeOf(fixture, 'foo')
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('-\n- **foo**\n')
})

it('Enter before "foo" in nested ***foo*** keeps the whole unit intact', async () => {
using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('***foo***'))))
setCaret(fixture, findText(fixture.doc, 'foo'))
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('\n***foo***\n')
})

// KNOWN FAILING (hide-mode click hit-testing on a link is unreliable): the
// synthetic click on the link text lands at the paragraph start, not the link
// edge, so the empty line is inserted before "x" instead of before the link.
// The zone/snap logic itself handles a link's `](url)` tail (see unitInfoAt's
// hidden-mark walk); it is the click landing that is off here.
it('a click left of a link text then Enter keeps the link intact', async () => {
using fixture = setupMode('hide', (f) =>
f.set(f.n.doc(f.n.paragraph('x [docs](http://example.test) y'))),
)
await clickLeftEdgeOf(fixture, 'docs')
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('x \n[docs](http://example.test) y\n')

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:39

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:39

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:39

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:39

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:44

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:44

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:39

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-linux-firefox

[@meowdown/core (firefox)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:39

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:39

Check failure on line 155 in packages/core/src/extensions/caret-marker-snap.test.ts

View workflow job for this annotation

GitHub Actions / test-windows-chromium

[@meowdown/core (chromium)] src/extensions/caret-marker-snap.test.ts > caret-marker-snap (hide mode) > a click left of a link text then Enter keeps the link intact

AssertionError: expected '\nx [docs](http://example.test) y\n' to be 'x \n[docs](http://example.test) y\n' // Object.is equality - Expected + Received - x - [docs](http://example.test) y + + x [docs](http://example.test) y ❯ src/extensions/caret-marker-snap.test.ts:155:39
})
})

describe('caret-marker-snap (inert outside hide mode)', () => {
it('focus mode is unchanged: Enter at a revealed marker splits as before', async () => {
using fixture = setupMode('focus', (f) => f.set(f.n.doc(f.n.paragraph('**bold**'))))
setCaret(fixture, findText(fixture.doc, 'bold')) // between the revealed ** and "bold"
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('**\n\nbold**\n')
})

it('show mode is unchanged: Enter at the marker boundary splits as before', async () => {
using fixture = setupMode('show', (f) => f.set(f.n.doc(f.n.paragraph('**bold**'))))
setCaret(fixture, findText(fixture.doc, 'bold'))
await userEvent.keyboard('{Enter}')
expect(docToMarkdown(fixture.doc)).toBe('**\n\nbold**\n')
})
})
148 changes: 148 additions & 0 deletions packages/core/src/extensions/caret-marker-snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import {
defineKeymap,
definePlugin,
getMarkRange,
getMarkType,
Priority,
union,
withPriority,
type PlainExtension,
} from '@prosekit/core'
import {
Plugin,
PluginKey,
TextSelection,
type Command,
type EditorState,
} from '@prosekit/pm/state'

import { getMarkMode } from './mark-mode.ts'
import type { MarkName } from './mark-names.ts'

/**
* In `hide` mode the Markdown syntax of an inline unit (`**`, `[`, `](url)`, ...)
* is real text that is never rendered, so the caret can rest in an invisible
* "dead zone" between a hidden marker run and the visible content. Acting there
* (Enter, Space, typing) silently corrupts the unit. These two hooks keep the
* caret out of that dead zone while leaving the content - down to a single
* character - reachable. The feature is inert outside `hide` mode, where the
* markers are visible (`show`) or revealed near the caret (`focus`).
*/

// Marks whose characters `mark-mode` hides in `hide` mode (mirrors its CSS
// `display:none` rules and its clipboard strip set). A contiguous run of these
// is invisible, so the caret must not act inside it.
const HIDDEN_MARKS: ReadonlySet<MarkName> = new Set<MarkName>([
'mdMark',
'mdLinkUri',
'mdLinkTitle',
])

function charIsHidden(state: EditorState, charStart: number): boolean {
let hidden = false
state.doc.nodesBetween(charStart, charStart + 1, (node) => {
if (node.isText)
hidden = node.marks.some((mark) => HIDDEN_MARKS.has(mark.type.name as MarkName))
})
return hidden
}

interface UnitInfo {
from: number
to: number
/** End of the leading hidden run, i.e. the content's opening edge (after `**` / `[`). */
openTo: number
/** Start of the trailing hidden run, i.e. the content's closing edge (before `**` / `](url)`). */
closeFrom: number
}

/** The `mdPack` unit around `pos`, with its hidden marker runs measured by walking. */
function unitInfoAt(state: EditorState, pos: number): UnitInfo | undefined {
const $pos = state.doc.resolve(pos)
if (!$pos.parent.isTextblock || $pos.parent.type.spec.code) return undefined
const unit = getMarkRange($pos, getMarkType(state.schema, 'mdPack'))
if (!unit) return undefined
let openTo = unit.from
while (openTo < unit.to && charIsHidden(state, openTo)) openTo++
let closeFrom = unit.to
while (closeFrom > unit.from && charIsHidden(state, closeFrom - 1)) closeFrom--
return { from: unit.from, to: unit.to, openTo, closeFrom }
}

/** `pos` is inside the leading hidden run or at its content edge. */
function inOpening(info: UnitInfo, pos: number): boolean {
return pos > info.from && pos <= info.openTo && info.openTo < info.to
}
/** `pos` is inside the trailing hidden run or at its content edge. */
function inClosing(info: UnitInfo, pos: number): boolean {
return pos >= info.closeFrom && pos < info.to && info.closeFrom > info.from
}

/** The unit's outer edge a pointer click or Enter relocates to, or `undefined`. */
function outerEdge(info: UnitInfo, pos: number): number | undefined {
if (inOpening(info, pos)) return info.from
if (inClosing(info, pos)) return info.to
return undefined
}

const snapPluginKey = new PluginKey('meowdown-caret-marker-snap')

function createSnapPlugin(): Plugin {
return new Plugin({
key: snapPluginKey,
appendTransaction: (transactions, oldState, newState) => {
if (getMarkMode(newState) !== 'hide') return null
const { selection } = newState
if (!selection.empty) return null
const pos = selection.from
const info = unitInfoAt(newState, pos)
if (!info) return null

// prosemirror-view tags pointer-originated selections with this meta in
// `updateSelection`:
// https://code.haverbeke.berlin/prosemirror/prosemirror-view/src/tag/1.41.9/src/input.ts#L191
const isPointer = transactions.some((tr) => !!tr.getMeta('pointer'))

let target: number | undefined
if (isPointer) {
// A click aims at the visible content edge, i.e. outside the unit.
target = outerEdge(info, pos)
} else {
// Keyboard: skip only the invisible marker interior, in the travel
// direction. Content edges stay reachable so the content is navigable.
const dir = pos >= oldState.selection.from ? 1 : -1
if (pos > info.from && pos < info.openTo) {
target = dir > 0 ? info.openTo : info.from
} else if (pos > info.closeFrom && pos < info.to) {
target = dir > 0 ? info.to : info.closeFrom
}
}
if (target == null || target === pos) return null
return newState.tr.setSelection(TextSelection.create(newState.doc, target))
},
})
}

/**
* Enter at a content edge would split the unit's hidden syntax from its content
* (`**foo**` -> `**` / `foo**`). Relocate the caret to the unit's outer edge,
* then return `false` so the normal Enter chain (flat-list / base) splits there.
*/
const enterOutsideMarkers: Command = (state, dispatch) => {
if (getMarkMode(state) !== 'hide') return false
const { selection } = state
if (!selection.empty) return false
const info = unitInfoAt(state, selection.from)
if (!info) return false
const target = outerEdge(info, selection.from)
if (target == null || target === selection.from) return false
dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, target)))
return false
}

export function defineCaretMarkerSnap(): PlainExtension {
return union(
definePlugin(createSnapPlugin()),
withPriority(defineKeymap({ Enter: enterOutsideMarkers }), Priority.high),
)
}
2 changes: 2 additions & 0 deletions packages/core/src/extensions/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { defineText } from '@prosekit/extensions/text'
import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection'

import { defineAtomMarkNavigation } from './atom-mark-navigation.ts'
import { defineCaretMarkerSnap } from './caret-marker-snap.ts'
import { defineCodeBlockSyntaxHighlight } from './code-block-highlight.ts'
import { defineEditorCommands } from './commands.ts'
import { defineDocFrontmatterAttr } from './frontmatter.ts'
Expand Down Expand Up @@ -59,6 +60,7 @@ function defineEditorExtensionImpl() {
{ name: 'mdWikilink', modes: ['hide', 'focus', 'show'] },
],
}),
defineCaretMarkerSnap(),

// others
defineBaseKeymap(),
Expand Down
Loading