Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/core/src/converters/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1031,3 +1031,10 @@ describe('idempotency', () => {
expect(roundtrip(once)).toBe(once)
})
})

describe('magic comments', () => {
it('keeps a noLink magic comment', () => {
const md = 'go to www.example.com<!-- {"noLink":true} --> now'
expect(roundtrip(md)).toBe(md + '\n')
})
})
20 changes: 20 additions & 0 deletions packages/core/src/extensions/clipboard/plain-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,23 @@ describe('native plain text copy', () => {
expect((await readClipboard()).text).toBe('value')
})
})

describe('magic comments in plain text copy', () => {
it('keeps the noLink comment in show mode', () => {
expect(
copyText('show', 'go www.example.com<!-- {"noLink":true} --> end'),
).toMatchInlineSnapshot(`"go www.example.com<!-- {"noLink":true} --> end"`)
})

it('keeps the noLink comment in focus mode', () => {
expect(
copyText('focus', 'go www.example.com<!-- {"noLink":true} --> end'),
).toMatchInlineSnapshot(`"go www.example.com<!-- {"noLink":true} --> end"`)
})

it('strips the noLink comment in hide mode', () => {
expect(
copyText('hide', 'go www.example.com<!-- {"noLink":true} --> end'),
).toMatchInlineSnapshot(`"go www.example.com end"`)
})
})
14 changes: 14 additions & 0 deletions packages/core/src/extensions/clipboard/semantic-inline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,17 @@ describe('headingClipboardDOM', () => {
`)
})
})

describe('magic comments', () => {
it('omits the noLink comment from the semantic children', () => {
using fixture = setupFixture()
expect(firstTextblockDOM(fixture, 'go www.example.com<!-- {"noLink":true} --> end'))
.toMatchInlineSnapshot(`
"
<p data-md="go www.example.com&lt;!-- {&quot;noLink&quot;:true} --&gt; end">
go www.example.com end
</p>
"
`)
})
})
79 changes: 79 additions & 0 deletions packages/core/src/extensions/hidden-run-caret.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,82 @@ describe('hide mode typing at coincident positions', () => {
expect(docToMarkdown(fixture.doc)).toBe('foo **bold**x bar\n')
})
})

describe('magic run caret in every mode', () => {
it('ArrowRight crosses the comment in one step in show mode', async () => {
using fixture = setupMode('show', 'go www.example.co<a>m<!-- {"noLink":true} --> end')
const steps = await traceKeySelection(fixture, 'ArrowRight', 2)
expect(steps).toMatchInlineSnapshot(`
[
"go www.example.co┃m<!-- {"noLink":true} --> end",
"go www.example.com⎦<!-- {"noLink":true} --> end",
"go www.example.com<!-- {"noLink":true} -->⎣ end",
]
`)
})

it('ArrowRight crosses the comment in one step in focus mode', async () => {
using fixture = setupMode('focus', 'go www.example.co<a>m<!-- {"noLink":true} --> end')
const steps = await traceKeySelection(fixture, 'ArrowRight', 2)
expect(steps).toMatchInlineSnapshot(`
[
"go www.example.co┃m<!-- {"noLink":true} --> end",
"go www.example.com⎦<!-- {"noLink":true} --> end",
"go www.example.com<!-- {"noLink":true} -->⎣ end",
]
`)
})

it('Shift+ArrowLeft extends over the comment without cutting it in show mode', async () => {
using fixture = setupMode('show', 'go www.example.com<!-- {"noLink":true} --> <a>end')
await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}')
expect(fixture.selectionSnapshot).toMatchInlineSnapshot(
`"go www.example.com<!-- {"noLink":true} -->❰ ❱end"`,
)
await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}')
expect(fixture.selectionSnapshot).toMatchInlineSnapshot(
`"go www.example.com❰<!-- {"noLink":true} --> ❱end"`,
)
})

it('Enter beside the comment splits outside the unit in show mode', async () => {
using fixture = setupMode('show', 'go www.example.com<a><!-- {"noLink":true} --> end')
await userEvent.keyboard('{Enter}')
expect(fixture.selectionSnapshot).toMatchInlineSnapshot(`
"
go www.example.com<!-- {"noLink":true} -->
┃ end
"
`)
expect(docToMarkdown(fixture.doc)).toMatchInlineSnapshot(`
"""
go www.example.com<!-- {"noLink":true} -->

end

"""
`)
})

it('Backspace after the comment dissolves it and relinks in show mode', async () => {
using fixture = setupMode('show', 'go www.example.com<!-- {"noLink":true} --><a> end')
await userEvent.keyboard('{Backspace}')
expect(fixture.selectionSnapshot).toMatchInlineSnapshot(`"go www.example.com┃ end"`)
expect(docToMarkdown(fixture.doc)).toBe('go www.example.com end\n')
await expect.element(pmRoot.getByRole('link', { name: 'www.example.com' })).toBeInTheDocument()
fixture.editor.commands.undo()
expect(docToMarkdown(fixture.doc)).toBe('go www.example.com<!-- {"noLink":true} --> end\n')
})

it('Delete before the comment dissolves it in focus mode', async () => {
using fixture = setupMode('focus', 'go www.example.com<a><!-- {"noLink":true} --> end')
await userEvent.keyboard('{Delete}')
expect(docToMarkdown(fixture.doc)).toBe('go www.example.com end\n')
})

it('Backspace after the comment dissolves it in hide mode too', async () => {
using fixture = setupMode('hide', 'go www.example.com<!-- {"noLink":true} --><a> end')
await userEvent.keyboard('{Backspace}')
expect(docToMarkdown(fixture.doc)).toBe('go www.example.com end\n')
})
})
44 changes: 28 additions & 16 deletions packages/core/src/extensions/hidden-run-caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,45 @@ import { executeCommand } from '../utils/execute-command.ts'
import { hasPointerSelectionTransaction } from '../utils/transaction.ts'

import {
getHiddenPredicate,
getHiddenRunAfter,
getHiddenRunAround,
getHiddenRunBefore,
getRestPosition,
getUnitMarkerRuns,
} from './hidden-run.ts'
import { getMarkMode } from './mark-mode.ts'

const snapKey = new PluginKey('meowdown-hidden-run-snap')
const beforeInputKey = new PluginKey('meowdown-hidden-run-beforeinput')

// Keeps the hide-mode caret on rest positions, whatever moved it: arrow keys,
// clicks, vertical motion, Home/End, shift-extension, or programmatic
// setSelection. Keyboard motion continues through a run interior in the travel
// direction; a pointer caret snaps to the unit's outer edge; a range selection
// expands outward so it never cuts a run in half.
// Keeps the caret on rest positions, whatever moved it: arrow keys, clicks,
// vertical motion, Home/End, shift-extension, or programmatic setSelection.
// Keyboard motion continues through a run interior in the travel direction; a
// pointer caret snaps to the unit's outer edge; a range selection expands
// outward so it never cuts a run in half. The runs are what the current mode
// hides: every syntax run in hide mode, only magic runs in focus and show.
function createSnapPlugin(): Plugin {
return new Plugin({
key: snapKey,
appendTransaction: (transactions, oldState, newState) => {
if (getIsComposing()) return null
if (getMarkMode(newState) !== 'hide') return null
const isHidden = getHiddenPredicate(newState)
const selection = newState.selection
if (!isTextSelection(selection)) return null
const isPointer = hasPointerSelectionTransaction(transactions)
if (selection.empty) {
const next = getRestPosition(newState, oldState.selection.head, selection.head, isPointer)
const next = getRestPosition(
newState,
oldState.selection.head,
selection.head,
isPointer,
isHidden,
)
if (next === selection.head) return null
return newState.tr.setSelection(TextSelection.create(newState.doc, next))
}
const from = getHiddenRunAround(newState, selection.from)?.from ?? selection.from
const to = getHiddenRunAround(newState, selection.to)?.to ?? selection.to
const from = getHiddenRunAround(newState, selection.from, isHidden)?.from ?? selection.from
const to = getHiddenRunAround(newState, selection.to, isHidden)?.to ?? selection.to
if (from === selection.from && to === selection.to) return null
const anchor = selection.anchor === selection.from ? from : to
const head = selection.head === selection.from ? from : to
Expand All @@ -59,10 +66,15 @@ function createSnapPlugin(): Plugin {
// Enter chain (flat-list, base keymap) performs the split there. A split can
// then never separate a unit from half of its markers.
const relocateEnterSplit: Command = (state, dispatch) => {
if (getMarkMode(state) !== 'hide') return false
const selection = state.selection
if (!isTextSelection(selection) || !selection.empty) return false
const outer = getRestPosition(state, selection.head, selection.head, true)
const outer = getRestPosition(
state,
selection.head,
selection.head,
true,
getHiddenPredicate(state),
)
if (outer === selection.head) return false
dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, outer)))
return false
Expand All @@ -74,18 +86,18 @@ const relocateEnterSplit: Command = (state, dispatch) => {
// trailing run first, so the deletions never need remapping.
function createUnformatCommand(direction: -1 | 1): Command {
return (state, dispatch) => {
if (getMarkMode(state) !== 'hide') return false
const isHidden = getHiddenPredicate(state)
const selection = state.selection
if (!isTextSelection(selection) || !selection.empty) return false
const $head = selection.$head
if (!$head.parent.isTextblock || $head.parent.type.spec.code) return false
const run =
direction === -1
? getHiddenRunBefore(state, selection.head)
: getHiddenRunAfter(state, selection.head)
? getHiddenRunBefore(state, selection.head, isHidden)
: getHiddenRunAfter(state, selection.head, isHidden)
if (run == null) return false
const markerChar = direction === -1 ? run.to - 1 : run.from
const markerRuns = getUnitMarkerRuns(state, markerChar)
const markerRuns = getUnitMarkerRuns(state, markerChar, isHidden)
const tr = state.tr
if (markerRuns.length === 0) {
tr.delete(run.from, run.to)
Expand Down
85 changes: 65 additions & 20 deletions packages/core/src/extensions/hidden-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { getMarkType } from '@prosekit/core'
import type { Mark } from '@prosekit/pm/model'
import type { EditorState } from '@prosekit/pm/state'

import { isMarkOfTypes, SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts'
import { getMarkMode } from './mark-mode.ts'
import { isMarkOfType, isMarkOfTypes, SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts'

export interface HiddenRun {
from: number
to: number
}

export type HiddenCharPredicate = (state: EditorState, pos: number) => boolean

// Marks of the character occupying [pos, pos + 1), or undefined when that slot
// is not a text character (block boundary, inline atom node, end of block).
function getCharMarks(state: EditorState, pos: number): readonly Mark[] | undefined {
Expand All @@ -25,6 +28,23 @@ export function isHiddenChar(state: EditorState, pos: number): boolean {
return marks.some((mark) => isMarkOfTypes(mark, SYNTAX_MARK_NAMES))
}

/**
* A character hidden in every mark mode: editor-managed magic-comment
* metadata (`mdMagic`), which style.css never renders.
*/
function isMagicChar(state: EditorState, pos: number): boolean {
const marks = getCharMarks(state, pos)
if (marks == null) return false
return marks.some((mark) => isMarkOfType(mark, 'mdMagic'))
}

// In hide mode every syntax run is invisible; in focus and show only the
// always-hidden magic runs are. The caret rules act on what the current mode
// actually hides.
export function getHiddenPredicate(state: EditorState): HiddenCharPredicate {
return getMarkMode(state) === 'hide' ? isHiddenChar : isMagicChar
}

function isInsideNonCodeTextblock(state: EditorState, pos: number): boolean {
if (pos < 0 || pos > state.doc.content.size) return false
const $pos = state.doc.resolve(pos)
Expand All @@ -34,39 +54,55 @@ function isInsideNonCodeTextblock(state: EditorState, pos: number): boolean {
/**
* The maximal contiguous hidden run ending exactly at `pos`, or undefined.
*/
export function getHiddenRunBefore(state: EditorState, pos: number): HiddenRun | undefined {
export function getHiddenRunBefore(
state: EditorState,
pos: number,
isHidden: HiddenCharPredicate = isHiddenChar,
): HiddenRun | undefined {
if (!isInsideNonCodeTextblock(state, pos)) return
const blockStart = state.doc.resolve(pos).start()
let from = pos
while (from > blockStart && isHiddenChar(state, from - 1)) from--
while (from > blockStart && isHidden(state, from - 1)) from--
return from < pos ? { from, to: pos } : undefined
}

/**
* The maximal contiguous hidden run starting exactly at `pos`, or undefined.
*/
export function getHiddenRunAfter(state: EditorState, pos: number): HiddenRun | undefined {
export function getHiddenRunAfter(
state: EditorState,
pos: number,
isHidden: HiddenCharPredicate = isHiddenChar,
): HiddenRun | undefined {
if (!isInsideNonCodeTextblock(state, pos)) return
const blockEnd = state.doc.resolve(pos).end()
let to = pos
while (to < blockEnd && isHiddenChar(state, to)) to++
while (to < blockEnd && isHidden(state, to)) to++
return to > pos ? { from: pos, to } : undefined
}

// A position is a hidden-run interior (never a caret rest position in hide
// mode) when the characters on both sides are hidden.
export function isHiddenRunInterior(state: EditorState, pos: number): boolean {
return isHiddenChar(state, pos - 1) && isHiddenChar(state, pos)
export function isHiddenRunInterior(
state: EditorState,
pos: number,
isHidden: HiddenCharPredicate = isHiddenChar,
): boolean {
return isHidden(state, pos - 1) && isHidden(state, pos)
}

/**
* The full run around an interior position, or undefined for rest positions.
*/
export function getHiddenRunAround(state: EditorState, pos: number): HiddenRun | undefined {
if (!isHiddenRunInterior(state, pos)) return
const before = getHiddenRunBefore(state, pos)
export function getHiddenRunAround(
state: EditorState,
pos: number,
isHidden: HiddenCharPredicate = isHiddenChar,
): HiddenRun | undefined {
if (!isHiddenRunInterior(state, pos, isHidden)) return
const before = getHiddenRunBefore(state, pos, isHidden)
if (!before) return
const after = getHiddenRunAfter(state, pos)
const after = getHiddenRunAfter(state, pos, isHidden)
if (!after) return
return { from: before.from, to: after.to }
}
Expand Down Expand Up @@ -131,17 +167,18 @@ export function getRestPosition(
oldPos: number,
newPos: number,
isPointer: boolean,
isHidden: HiddenCharPredicate = isHiddenChar,
): number {
if (!isInsideNonCodeTextblock(state, newPos)) return newPos
const run = getHiddenRunAround(state, newPos)
const run = getHiddenRunAround(state, newPos, isHidden)
if (run != null) {
if (!isPointer) return newPos >= oldPos ? run.to : run.from
return getPointerEdge(state, run, newPos)
}
if (!isPointer) return newPos
const runBefore = getHiddenRunBefore(state, newPos)
const runBefore = getHiddenRunBefore(state, newPos, isHidden)
if (runBefore != null && isPackOuterEdge(state, runBefore, 'from')) return runBefore.from
const runAfter = getHiddenRunAfter(state, newPos)
const runAfter = getHiddenRunAfter(state, newPos, isHidden)
if (runAfter != null && isPackOuterEdge(state, runAfter, 'to')) return runAfter.to
return newPos
}
Expand All @@ -150,10 +187,14 @@ export type CaretTail = 'left' | 'right'

// Typing affinity: the tail points to the side whose formatting a typed
// character would adopt, which is the opposite side of the hidden run.
export function getCaretTail(state: EditorState, pos: number): CaretTail | undefined {
export function getCaretTail(
state: EditorState,
pos: number,
isHidden: HiddenCharPredicate = isHiddenChar,
): CaretTail | undefined {
if (!isInsideNonCodeTextblock(state, pos)) return
const hiddenBefore = isHiddenChar(state, pos - 1)
const hiddenAfter = isHiddenChar(state, pos)
const hiddenBefore = isHidden(state, pos - 1)
const hiddenAfter = isHidden(state, pos)
if (hiddenBefore === hiddenAfter) return
return hiddenAfter ? 'left' : 'right'
}
Expand All @@ -163,11 +204,15 @@ export function getCaretTail(state: EditorState, pos: number): CaretTail | undef
* character sits at `charPos`, trailing first so callers can delete them in
* order without remapping. A fully hidden unit yields one run.
*/
export function getUnitMarkerRuns(state: EditorState, charPos: number): HiddenRun[] {
export function getUnitMarkerRuns(
state: EditorState,
charPos: number,
isHidden: HiddenCharPredicate = isHiddenChar,
): HiddenRun[] {
const pack = getInnermostPackRangeAt(state, charPos)
if (pack == null) return []
const leading = getHiddenRunAfter(state, pack.from)
const trailing = getHiddenRunBefore(state, pack.to)
const leading = getHiddenRunAfter(state, pack.from, isHidden)
const trailing = getHiddenRunBefore(state, pack.to, isHidden)
const runs: HiddenRun[] = []
if (trailing != null) runs.push(trailing)
if (leading != null && (trailing == null || leading.from !== trailing.from)) {
Expand Down
Loading
Loading