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
13 changes: 11 additions & 2 deletions packages/core/src/extensions/atom-mark-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function createForwardDelete(marks: AtomMarks): Command {
}

const SELECTED_CLASS = 'md-atom-selected'
const SELECTED_TEST_ID = 'atom-selection'

// Decorate each selected atom's source range with `md-atom-selected`, so its
// mark view can ring the rendered preview/label.
Expand All @@ -182,7 +183,10 @@ function createSelectionPlugin(marks: AtomMarks): Plugin {
const range = getSelectedRange(state, markNames)
if (range) {
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, { class: SELECTED_CLASS }),
Decoration.inline(range.from, range.to, {
class: SELECTED_CLASS,
'data-testid': SELECTED_TEST_ID,
}),
])
}

Expand All @@ -192,7 +196,12 @@ function createSelectionPlugin(marks: AtomMarks): Plugin {
const decorations: Decoration[] = []
state.doc.nodesBetween(from, to, (node, pos) => {
if (node.marks.some((mark) => markNames.includes(mark.type.name as MarkName))) {
decorations.push(Decoration.inline(pos, pos + node.nodeSize, { class: SELECTED_CLASS }))
decorations.push(
Decoration.inline(pos, pos + node.nodeSize, {
class: SELECTED_CLASS,
'data-testid': SELECTED_TEST_ID,
}),
)
}
})
return DecorationSet.create(state.doc, decorations)
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/extensions/hidden-run-caret.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,10 @@ describe('hide mode unformat deletion', () => {
expect(docToMarkdown(fixture.doc)).toBe('**a**b\n')
})

it('a fully hidden unit deletes entirely', async () => {
it('a degenerate literal link deletes one character', async () => {
using fixture = setupMode('hide', 'x[](https://a.io)<a>y')
await userEvent.keyboard('{Backspace}')
expect(fixture.selectionSnapshot).toMatchInlineSnapshot(`"x┃y"`)
expect(fixture.selectionSnapshot).toMatchInlineSnapshot(`"x[](https://a.io┃y"`)
})

it('atom deletion stays with atom navigation at a shared boundary', async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/extensions/hidden-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,10 @@ describe('getUnitMarkerRuns', () => {
expect(runs.map((run) => runText(fixture, run))).toEqual(['***', '***'])
})

it('returns one run for a fully hidden unit', () => {
it('returns no runs for a degenerate literal link', () => {
using fixture = setup('x[](https://a.io)y')
const start = findText(fixture.doc, 'x') + 1
const runs = getUnitMarkerRuns(fixture.state, start)
expect(runs.map((run) => runText(fixture, run))).toEqual(['[](https://a.io)'])
expect(runs.map((run) => runText(fixture, run))).toEqual([])
})
})
14 changes: 2 additions & 12 deletions packages/core/src/extensions/hidden-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,7 @@ import { getMarkType } from '@prosekit/core'
import type { Mark } from '@prosekit/pm/model'
import type { EditorState } from '@prosekit/pm/state'

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

// The marks whose text hide mode renders at font-size 0. Mirrors the CSS rules
// in style.css and CLIPBOARD_STRIP_MARK_NAMES in mark-mode.ts. Atom sources
// (mdImage / mdWikilink / mdFile) are not here: defineAtomMarkNavigation owns
// them.
const HIDDEN_MARK_NAMES: ReadonlySet<MarkName> = new Set<MarkName>([
'mdMark',
'mdLinkUri',
'mdLinkTitle',
])
import { HIDDEN_SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts'

export interface HiddenRun {
from: number
Expand All @@ -32,7 +22,7 @@ function getCharMarks(state: EditorState, pos: number): readonly Mark[] | undefi
export function isHiddenChar(state: EditorState, pos: number): boolean {
const marks = getCharMarks(state, pos)
if (marks == null) return false
return marks.some((mark) => HIDDEN_MARK_NAMES.has(mark.type.name as MarkName))
return marks.some((mark) => HIDDEN_SYNTAX_MARK_NAMES.has(mark.type.name as MarkName))
}

function isInsideNonCodeTextblock(state: EditorState, pos: number): boolean {
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/extensions/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type { MarkMode } from './mark-mode.ts'

const pmRoot = page.locate('.ProseMirror')
const preview = pmRoot.getByTestId('image-preview')
const imageSource = pmRoot.getByTestId('image-source')
const atomSelection = pmRoot.getByTestId('atom-selection')

function getSVGImageURL(width: number, height: number): string {
const svg =
Expand Down Expand Up @@ -126,6 +128,60 @@ describe('image selection ring', () => {
})
})

describe('unresolvable image source', () => {
function setupUnresolvable(mode: MarkMode, text: string): Fixture {
const fixture = setupFixture({ extensionOptions: { markMode: mode } })
const { editor, n } = fixture
editor.use(defineImage({ resolveImageUrl: () => undefined }))
fixture.set(n.doc(n.paragraph(text)))
fixture.view.focus()
return fixture
}

it('shows the source in hide mode', async () => {
using fixture = setupUnresolvable('hide', '![alt](x.png)')
void fixture
await expect.element(imageSource).toBeVisible()
await expect.element(pmRoot.getByText('![alt](x.png)')).toBeVisible()
})

it('shows the source in focus mode', async () => {
using fixture = setupUnresolvable('focus', '![alt](x.png)')
void fixture
await expect.element(imageSource).toBeVisible()
await expect.element(pmRoot.getByText('![alt](x.png)')).toBeVisible()
})

it('shows the source in show mode', async () => {
using fixture = setupUnresolvable('show', '![alt](x.png)')
void fixture
await expect.element(imageSource).toBeVisible()
await expect.element(pmRoot.getByText('![alt](x.png)')).toBeVisible()
})

it('shows the selected source when arrowing onto it', async () => {
using fixture = setupUnresolvable('hide', 'ABC<a>![alt](x.png)DEF')
expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"ABC┃![alt](x.png)DEF"`)
await userEvent.keyboard('{ArrowRight}')
expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"ABC❰![alt](x.png)❱DEF"`)
await expect.element(atomSelection).toBeVisible()
})

it('edits the revealed source text', async () => {
using fixture = setupUnresolvable('hide', '![alt](x.png)')
await userEvent.click(imageSource)
await userEvent.keyboard('X')
expect(fixture.doc.textContent).toContain('X')
})

it('keeps the source hidden when a preview renders', async () => {
using fixture = setup('hide', '![alt](url)')
void fixture
await expect.element(preview).toBeVisible()
await expect.element(imageSource).not.toBeVisible()
})
})

describe('image click callback', () => {
// Render `markdown` with a click handler attached, showing http(s) images as-is.
function setupClickable(markdown: string, onImageClick: ImageClickHandler): Fixture {
Expand Down Expand Up @@ -335,6 +391,14 @@ describe('image resize', () => {
expect(resizable.element()).toHaveAttribute('data-loading', '')
await expect.element(resizable).not.toHaveAttribute('data-loading')
})

it('keeps a placeholder when the image fails to load', async () => {
using fixture = setupResize('![cat](u)', 'https://example.invalid/missing.png')
void fixture
pmRoot.getByAltText('cat').element().dispatchEvent(new Event('error'))
await expect.element(resizable).toHaveAttribute('data-error', '')
await expect.element(resizable).not.toHaveAttribute('data-loading')
})
})

describe('typing after an inline image', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/extensions/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ class ImageMarkView implements MarkView {

this.#contentDOM = document.createElement('span')
this.#contentDOM.className = 'md-image-view-content md-atom-view-content'
this.#contentDOM.dataset.testid = 'image-source'

const preview = this.#renderPreview()
if (preview) {
Expand Down Expand Up @@ -222,6 +223,7 @@ class ImageMarkView implements MarkView {
applyDisplaySize(root, image, this.#attrs.width, this.#attrs.height)
image.addEventListener('load', () => {
root.removeAttribute('data-loading')
root.removeAttribute('data-error')
const ratio = image.naturalWidth / image.naturalHeight
if (!Number.isFinite(ratio) || ratio <= 0) return
root.setAttribute('data-aspect-ratio', String(ratio))
Expand All @@ -230,6 +232,7 @@ class ImageMarkView implements MarkView {
})
image.addEventListener('error', () => {
root.removeAttribute('data-loading')
root.setAttribute('data-error', '')
})
root.appendChild(image)

Expand Down
138 changes: 138 additions & 0 deletions packages/core/src/extensions/inline-text-to-mark-chunks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,135 @@ describe('wikilink', () => {
})
})

describe('degenerate units', () => {
it('keeps an empty image literal', () => {
expect(parse('![]()')).toMatchInlineSnapshot(`
"
[0, 5]
"
`)
})

it('keeps an empty link literal', () => {
expect(parse('[]()')).toMatchInlineSnapshot(`
"
[0, 4]
"
`)
})

it('keeps a link with an empty label literal', () => {
expect(parse('[](https://example.com)')).toMatchInlineSnapshot(`
"
[0, 23]
"
`)
})

it('keeps a titled link with an empty label literal', () => {
expect(parse('[](https://example.com "title")')).toMatchInlineSnapshot(`
"
[0, 31]
"
`)
})

it('keeps a link with a whitespace label literal', () => {
expect(parse('[ ](https://example.com)')).toMatchInlineSnapshot(`
"
[0, 24]
"
`)
})

it('keeps a link with visible text as a link', () => {
expect(parse('[a]()')).toMatchInlineSnapshot(`
"
[0, 1] mdPack(key=link,data={"href":"","title":""}) + mdLinkText + mdMark
[1, 2] mdPack(key=link,data={"href":"","title":""}) + mdLinkText
[2, 5] mdPack(key=link,data={"href":"","title":""}) + mdMark
"
`)
})

it('keeps an image with visible alt text as a link-shaped source', () => {
expect(parse('![alt]()')).toMatchInlineSnapshot(`
"
[0, 2] mdPack(key=link,data={"href":"","title":""}) + mdMark
[2, 5] mdPack(key=link,data={"href":"","title":""})
[5, 8] mdPack(key=link,data={"href":"","title":""}) + mdMark
"
`)
})

it('keeps a nested image label visible', () => {
expect(parse('[![alt](https://img.test/b.svg)](https://x.test)')).toMatchInlineSnapshot(`
"
[0, 3] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdMark
[3, 6] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test)
[6, 8] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdMark
[8, 30] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdLinkText(href=https://img.test/b.svg)
[30, 31] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdMark
[31, 33] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdMark
[33, 47] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkUri
[47, 48] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdMark
"
`)
})

it('keeps a pipe-only wikilink literal', () => {
expect(parse('[[|]]')).toMatchInlineSnapshot(`
"
[0, 5]
"
`)
})

it('keeps a spaced pipe-only wikilink literal', () => {
expect(parse('[[ | ]]')).toMatchInlineSnapshot(`
"
[0, 7]
"
`)
})

it('keeps an empty-target wikilink with an alias literal', () => {
expect(parse('[[|alias]]')).toMatchInlineSnapshot(`
"
[0, 10]
"
`)
})

it('keeps an empty-alias wikilink with a target as a wikilink', () => {
expect(parse('[[x|]]')).toMatchInlineSnapshot(`
"
[0, 6] mdWikilink(target=x)
"
`)
})

it('strips angle brackets from image destinations', () => {
expect(parse('![a](<https://x.test/a b.png>)')).toMatchInlineSnapshot(`
"
[0, 30] mdImage(src=https://x.test/a b.png,alt=a)
"
`)
})

it('strips angle brackets from link destinations', () => {
expect(parse('[a](<https://x.test>)')).toMatchInlineSnapshot(`
"
[0, 1] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdMark
[1, 2] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test)
[2, 4] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdMark
[4, 20] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkUri
[20, 21] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdMark
"
`)
})
})

describe('file link', () => {
const claimAssets: FileLinkResolver = ({ href }) => href.startsWith('assets/')

Expand Down Expand Up @@ -856,6 +985,15 @@ describe('file link', () => {
`)
})

it('strips angle brackets before claiming a file link', () => {
expect(parse('[report.pdf](<assets/report.pdf>)', { resolveFileLink: claimAssets }))
.toMatchInlineSnapshot(`
"
[0, 33] mdFile(href=assets/report.pdf,name=report.pdf)
"
`)
})

it('claims only what the resolver claims in mixed content', () => {
expect(
parse('see [report.pdf](assets/report.pdf) and [docs](https://example.com)', {
Expand Down
Loading
Loading