diff --git a/packages/core/src/converters/roundtrip.test.ts b/packages/core/src/converters/roundtrip.test.ts index 288fe49e..0fab1708 100644 --- a/packages/core/src/converters/roundtrip.test.ts +++ b/packages/core/src/converters/roundtrip.test.ts @@ -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 now' + expect(roundtrip(md)).toBe(md + '\n') + }) +}) diff --git a/packages/core/src/extensions/clipboard/plain-text.test.ts b/packages/core/src/extensions/clipboard/plain-text.test.ts index c38864fa..a1c64cc2 100644 --- a/packages/core/src/extensions/clipboard/plain-text.test.ts +++ b/packages/core/src/extensions/clipboard/plain-text.test.ts @@ -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 end'), + ).toMatchInlineSnapshot(`"go www.example.com end"`) + }) + + it('keeps the noLink comment in focus mode', () => { + expect( + copyText('focus', 'go www.example.com end'), + ).toMatchInlineSnapshot(`"go www.example.com end"`) + }) + + it('strips the noLink comment in hide mode', () => { + expect( + copyText('hide', 'go www.example.com end'), + ).toMatchInlineSnapshot(`"go www.example.com end"`) + }) +}) diff --git a/packages/core/src/extensions/clipboard/semantic-inline.test.ts b/packages/core/src/extensions/clipboard/semantic-inline.test.ts index a7984f9b..d57503b5 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.test.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.test.ts @@ -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 end')) + .toMatchInlineSnapshot(` + " +
+ go www.example.com end +
+ " + `) + }) +}) diff --git a/packages/core/src/extensions/hidden-run-caret.test.ts b/packages/core/src/extensions/hidden-run-caret.test.ts index 7a23c9c0..ad27743d 100644 --- a/packages/core/src/extensions/hidden-run-caret.test.ts +++ b/packages/core/src/extensions/hidden-run-caret.test.ts @@ -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.com end') + const steps = await traceKeySelection(fixture, 'ArrowRight', 2) + expect(steps).toMatchInlineSnapshot(` + [ + "go www.example.co┃m end", + "go www.example.com⎦ end", + "go www.example.com⎣ end", + ] + `) + }) + + it('ArrowRight crosses the comment in one step in focus mode', async () => { + using fixture = setupMode('focus', 'go www.example.com end') + const steps = await traceKeySelection(fixture, 'ArrowRight', 2) + expect(steps).toMatchInlineSnapshot(` + [ + "go www.example.co┃m end", + "go www.example.com⎦ end", + "go www.example.com⎣ end", + ] + `) + }) + + it('Shift+ArrowLeft extends over the comment without cutting it in show mode', async () => { + using fixture = setupMode('show', 'go www.example.com end') + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}') + expect(fixture.selectionSnapshot).toMatchInlineSnapshot( + `"go www.example.com❰ ❱end"`, + ) + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}') + expect(fixture.selectionSnapshot).toMatchInlineSnapshot( + `"go www.example.com❰ ❱end"`, + ) + }) + + it('Enter beside the comment splits outside the unit in show mode', async () => { + using fixture = setupMode('show', 'go www.example.com end') + await userEvent.keyboard('{Enter}') + expect(fixture.selectionSnapshot).toMatchInlineSnapshot(` + " + go www.example.com + ┃ end + " + `) + expect(docToMarkdown(fixture.doc)).toMatchInlineSnapshot(` + """ + go www.example.com + + end + + """ + `) + }) + + it('Backspace after the comment dissolves it and relinks in show mode', async () => { + using fixture = setupMode('show', 'go www.example.com 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 end\n') + }) + + it('Delete before the comment dissolves it in focus mode', async () => { + using fixture = setupMode('focus', 'go www.example.com 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 end') + await userEvent.keyboard('{Backspace}') + expect(docToMarkdown(fixture.doc)).toBe('go www.example.com end\n') + }) +}) diff --git a/packages/core/src/extensions/hidden-run-caret.ts b/packages/core/src/extensions/hidden-run-caret.ts index 44837269..7f4bf4a7 100644 --- a/packages/core/src/extensions/hidden-run-caret.ts +++ b/packages/core/src/extensions/hidden-run-caret.ts @@ -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 @@ -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 @@ -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) diff --git a/packages/core/src/extensions/hidden-run.ts b/packages/core/src/extensions/hidden-run.ts index a9bd3711..8f917e72 100644 --- a/packages/core/src/extensions/hidden-run.ts +++ b/packages/core/src/extensions/hidden-run.ts @@ -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 { @@ -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) @@ -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 } } @@ -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 } @@ -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' } @@ -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)) { diff --git a/packages/core/src/extensions/inline-marks.ts b/packages/core/src/extensions/inline-marks.ts index 53313989..a563fb24 100644 --- a/packages/core/src/extensions/inline-marks.ts +++ b/packages/core/src/extensions/inline-marks.ts @@ -70,6 +70,20 @@ function defineMdMark() { }) } +/** + * Editor-managed metadata riding in the source as a magic comment, e.g. the + * `` behind an unlinked URL. No mark mode renders it; + * style.css keeps it a zero-width box unconditionally. + */ +function defineMdMagic() { + return defineMarkSpec({ + name: 'mdMagic' satisfies MarkName, + inclusive: false, + toDOM: () => ['span', { class: 'md-magic', ...NON_PROSE_ATTRS }, 0], + parseDOM: [{ tag: 'span.md-magic' }], + }) +} + function defineMdEm() { return defineMarkSpec({ name: 'mdEm' satisfies MarkName, @@ -286,12 +300,11 @@ export type MdPackAttrs = | { // A URL opted out of autolinking by a trailing `` // magic comment (written by `removeLink`): the address is plain visible - // text, and the comment is hidden syntax revealed in focus so it can be - // edited in place. + // text, and the comment is editor metadata no mark mode renders. key: 'noLink' data?: null slot?: 1 | null - revealInFocus: true + revealInFocus?: null revealInHide?: null } | { @@ -349,6 +362,7 @@ export function defineInlineMarks() { // all, so it wraps the whole unit (including a mark view). return union( defineMdMark(), + defineMdMagic(), defineMdEm(), defineMdStrong(), defineMdCode(), diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts index d5670222..7ed97551 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts @@ -1322,3 +1322,24 @@ describe('file link', () => { ).toContain('mdFile(href=assets/report.pdf,name=report,title=Quarterly)') }) }) + +describe('noLink', () => { + it('unlinks a URL followed by a noLink magic comment', () => { + expect(parse('www.example.com')).toMatchInlineSnapshot(` + " + [0, 15] mdPack(key=noLink) + [15, 39] mdPack(key=noLink) + mdMagic + " + `) + }) + + it('folds a stacked comment run behind the address', () => { + expect(parse('www.example.com')) + .toMatchInlineSnapshot(` + " + [0, 15] mdPack(key=noLink) + [15, 63] mdPack(key=noLink) + mdMagic + " + `) + }) +}) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.ts index 970ed761..55d8c2e5 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -388,9 +388,9 @@ function walkURL( /** * A URL followed directly by a `` magic comment - * stays plain text instead of autolinking. The comment rides behind the - * address as hidden syntax that reveals in focus, so it can be edited or - * deleted in place. + * stays plain text instead of autolinking. The comment is editor metadata: + * an `mdMagic` run no mark mode renders. Deleting into it dissolves the + * whole comment and the address autolinks again. */ function walkUnlinkedURL( node: InlineElement, @@ -401,10 +401,10 @@ function walkUnlinkedURL( ): void { const base = [ ...parentMarks, - createUnitPack(marks, out, parentMarks, node.from, { key: 'noLink', revealInFocus: true }), + createUnitPack(marks, out, parentMarks, node.from, { key: 'noLink' }), ] emit(out, node.from, node.to, base) - emit(out, node.to, trailing.to, [...base, marks.mdMark.create()]) + emit(out, node.to, trailing.to, [...base, marks.mdMagic.create()]) } function walkLink( diff --git a/packages/core/src/extensions/mark-mode.test.ts b/packages/core/src/extensions/mark-mode.test.ts index 089369d7..d60ee338 100644 --- a/packages/core/src/extensions/mark-mode.test.ts +++ b/packages/core/src/extensions/mark-mode.test.ts @@ -1506,3 +1506,42 @@ describe('mark mode lifecycle', () => { expect(fixture.dom.getAttribute('data-mark-mode')).toBe('hide') }) }) + +describe('magic comment visibility', () => { + it('keeps the noLink comment hidden in focus mode when the caret touches it', async () => { + using fixture = setupFixture({ extensionOptions: { markMode: 'focus' } }) + const { n } = fixture + fixture.set(n.doc(n.paragraph('go to www.example.com now'))) + const comment = pmRoot.getByText('noLink') + await expect.element(comment).toBeInTheDocument() + expect(getComputedStyle(comment.element()).fontSize).toBe('0px') + }) + + it('hides the noLink comment in show mode', async () => { + using fixture = setupFixture({ extensionOptions: { markMode: 'show' } }) + const { n } = fixture + fixture.set(n.doc(n.paragraph('go to www.example.com now'))) + const comment = pmRoot.getByText('noLink') + await expect.element(comment).toBeInTheDocument() + expect(getComputedStyle(comment.element()).fontSize).toBe('0px') + expect(getComputedStyle(comment.element()).opacity).toBe('0') + }) + + it('hides the noLink comment in hide mode', async () => { + using fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) + const { n } = fixture + fixture.set(n.doc(n.paragraph('go to www.example.com now'))) + const comment = pmRoot.getByText('noLink') + await expect.element(comment).toBeInTheDocument() + expect(getComputedStyle(comment.element()).fontSize).toBe('0px') + }) + + it('keeps the noLink comment hidden while an enclosing unit reveals', async () => { + using fixture = setupFixture({ extensionOptions: { markMode: 'focus' } }) + const { n } = fixture + fixture.set(n.doc(n.paragraph('*a www.example.com b*'))) + const comment = pmRoot.getByText('noLink') + await expect.element(comment).toBeInTheDocument() + expect(getComputedStyle(comment.element()).fontSize).toBe('0px') + }) +}) diff --git a/packages/core/src/extensions/mark-names.ts b/packages/core/src/extensions/mark-names.ts index 542c6b2a..be73a528 100644 --- a/packages/core/src/extensions/mark-names.ts +++ b/packages/core/src/extensions/mark-names.ts @@ -6,6 +6,7 @@ export const MARK_NAMES = [ 'mdFile', 'mdMath', 'mdMark', + 'mdMagic', 'mdEm', 'mdStrong', 'mdCode', @@ -31,7 +32,12 @@ export function isMarkOfTypes(mark: Mark, names: readonly MarkName[]): boolean { // Marks whose text is Markdown syntax rather than content. Hide mode renders // these runs at font-size 0 (mirroring the CSS rules in style.css), and text // projections drop them. -export const SYNTAX_MARK_NAMES: readonly MarkName[] = ['mdMark', 'mdLinkUri', 'mdLinkTitle'] +export const SYNTAX_MARK_NAMES: readonly MarkName[] = [ + 'mdMark', + 'mdMagic', + 'mdLinkUri', + 'mdLinkTitle', +] // Marks covering a whole source unit, emitted as one replacement per unit by // text projections. diff --git a/packages/core/src/extensions/virtual-caret.ts b/packages/core/src/extensions/virtual-caret.ts index 7dae54b4..459eb371 100644 --- a/packages/core/src/extensions/virtual-caret.ts +++ b/packages/core/src/extensions/virtual-caret.ts @@ -13,8 +13,7 @@ import { findNativeCaretRect, type CaretRect, } from './caret-rect.ts' -import { getCaretTail, type CaretTail } from './hidden-run.ts' -import { getMarkMode } from './mark-mode.ts' +import { getCaretTail, getHiddenPredicate, type CaretTail } from './hidden-run.ts' const key = new PluginKey('meowdown-virtual-caret') @@ -159,11 +158,11 @@ class VirtualCaretView implements PluginView { height: viewportRect.height, } } - // In hide mode the two doc positions at a hidden run boundary render at - // one x; the tail (typing affinity) tells them apart. + // The two doc positions at a hidden run boundary render at one x; the + // tail (typing affinity) tells them apart. const tail = - rect != null && getMarkMode(state) === 'hide' && !getIsTouchInput() - ? getCaretTail(state, selection.head) + rect != null && !getIsTouchInput() + ? getCaretTail(state, selection.head, getHiddenPredicate(state)) : undefined this.#renderTail(tail) this.#renderCaret(rect) diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 9651e851..9c280f45 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -804,6 +804,17 @@ } } + /* Magic comments are editor-managed metadata, not authored syntax: an + * `mdMagic` run keeps a zero-width inline box in every mark mode. It is + * not `.md-mark`, so no `.show` reveal (mode or find) ever applies to + * it. */ + .ProseMirror .md-magic { + display: inline; + font-size: 0; + letter-spacing: 0; + opacity: 0; + } + /* atom marks (image, wikilink) */ .ProseMirror { .md-atom-view { diff --git a/packages/core/src/testing/selection-snapshot.ts b/packages/core/src/testing/selection-snapshot.ts index 24922a89..78c54dad 100644 --- a/packages/core/src/testing/selection-snapshot.ts +++ b/packages/core/src/testing/selection-snapshot.ts @@ -7,8 +7,7 @@ import { RIGHT_SQUARE_BRACKET_LOWER_CORNER, } from 'unicode-by-name' -import { getCaretTail } from '../extensions/hidden-run.ts' -import { getMarkMode } from '../extensions/mark-mode.ts' +import { getCaretTail, getHiddenPredicate } from '../extensions/hidden-run.ts' const TEXT_CARET = BOX_DRAWINGS_HEAVY_VERTICAL const TEXT_CARET_TAIL_LEFT = RIGHT_SQUARE_BRACKET_LOWER_CORNER @@ -16,11 +15,10 @@ const TEXT_CARET_TAIL_RIGHT = LEFT_SQUARE_BRACKET_LOWER_CORNER const SELECTION_START = HEAVY_LEFT_POINTING_ANGLE_BRACKET_ORNAMENT const SELECTION_END = HEAVY_RIGHT_POINTING_ANGLE_BRACKET_ORNAMENT -// The caret glyph mirrors the rendered virtual caret: in hide mode a caret at -// a hidden run boundary carries a typing-affinity tail. +// The caret glyph mirrors the rendered virtual caret: a caret at the boundary +// of a run the current mode hides carries a typing-affinity tail. function getCaretGlyph(state: EditorState): string { - if (getMarkMode(state) !== 'hide') return TEXT_CARET - const tail = getCaretTail(state, state.selection.from) + const tail = getCaretTail(state, state.selection.from, getHiddenPredicate(state)) if (tail === 'left') return TEXT_CARET_TAIL_LEFT if (tail === 'right') return TEXT_CARET_TAIL_RIGHT return TEXT_CARET diff --git a/packages/react/src/components/markdown-view.test.tsx b/packages/react/src/components/markdown-view.test.tsx index 2afeb924..23cc870d 100644 --- a/packages/react/src/components/markdown-view.test.tsx +++ b/packages/react/src/components/markdown-view.test.tsx @@ -29,6 +29,13 @@ describe('MarkdownView', () => { await expect.element(view.locate('code').first()).toBeInTheDocument() }) + it('hides a noLink magic comment in show mode', async () => { + await renderView('go to www.example.com now', { markMode: 'show' }) + const comment = view.getByText('noLink') + await expect.element(comment).toBeInTheDocument() + expect(getComputedStyle(comment.element()).fontSize).toBe('0px') + }) + it('renders a wikilink as a chip showing the target', async () => { await renderView('[[Reflect Playground 4]]') await expect.element(wikilink).toHaveTextContent('Reflect Playground 4')