From 0eb3beb766ca633be23dc0ffca04d8bb6e105911 Mon Sep 17 00:00:00 2001 From: Nick Gomez <122398915+nick-inkeep@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:47:11 -0700 Subject: [PATCH] fix(ok): refuse suggestion pickers inside code contexts (#3077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ok): refuse suggestion pickers inside code contexts The slash, wiki-link and tag pickers gated only on source mode, so all three activated inside a fenced code block and inside an inline code span. Committing an item there destroyed the bytes the user had typed: the picker deletes its trigger range, so the query characters vanished out of the fence and the chip landed after it, and a slash command replaced the whole code block with the item's node, losing the fence delimiter and its info string. Every input-rule surface already refuses in code contexts, via the @tiptap/core input-rule runner. These three are bare ProseMirror plugins that bypass that runner. Give them the same predicate, from one shared module the GFM autolinker now reads too, so the surfaces cannot drift apart again. * test(ok): pin the code-context refusal in the typed-view corpus and ledger Four corpus scripts and four tier-1 stability obligations covering both admission paths, the code node and the code mark. Verified non-vacuous: with the fix stashed, every fence script destroys bytes through the executor's own key route. Also adds the changeset and refreshes the generated typed-view catalogs and reachability record. * test(ok): address review — fix stale comment, share the plugin-key helper The test header said gfm-autolink 're-implements' the code predicate, which this PR made untrue by pointing that file at the shared module. getSuggestionState was independently implemented in both suggestion gating suites. Plugin-key discovery is the part worth sharing: if the synthesized PluginKey suffix format changes, one file should have to learn about it. Moved to a .test-helper.ts alongside the enumeration the closure guard uses. * fix(ok): extend the picker refusal to raw MDX source, not just code Review surfaced jsxInline as the other literal-text region the predicate missed, and its slash case needs no typing at all: already contains a space-then-slash, so parking the caret before the > arms the menu, and the commit deletes that / out of the source, leaving . Wiki-link and tag destroy bytes there by typing. Verified on the mounted editor before and after. The predicate now covers both kinds of region, and is renamed to say so: literal-text-context.ts / isInLiteralTextContext. A schema-enumerating test asserts the raw-source list still covers every non-code content:'text*' node, so a new one cannot ship unclassified. Corrects the corpus framing from the previous commit. Those cells are region coverage, not regression pins: measureRung compares a doc against its own re-serialization, so a destroyed-but-self-consistent doc still measures stable. Verified by disabling the gate — all four stayed green. The fixedBy receipts are dropped accordingly; the dom tier is the pin. The upstream input-rule runner tests spec.code only, so it stays blind to these nodes. That is a different seam and is left alone. * docs(ok): retire the last code-context wording from the picker comments The rename broadened the predicate; five comments still pointed readers at a module name that no longer exists. * docs(ok): describe the two raw-source nodes separately The shared parenthetical was true of jsxInline only. rawMdxFallback carries attrs and a NodeView, and that NodeView's contenteditable:false is the reason no picker path reaches it today - load-bearing context the collective description was hiding. GitOrigin-RevId: bc3fbe6f77d9a6fdba9b8abcb96a573ef29e1e6a --- .changeset/tidy-pillows-shout.md | 5 + .../src/editor/extensions/slash-command.ts | 11 +- .../src/editor/extensions/suggestion-allow.ts | 41 +++ .../src/editor/extensions/tag-suggestion.ts | 9 +- .../editor/extensions/wiki-link-suggestion.ts | 9 +- .../app/src/editor/gfm-autolink-plugin.ts | 11 +- .../app/src/editor/literal-text-context.ts | 71 ++++ ...iteral-text-suggestion-gating.dom.test.tsx | 326 ++++++++++++++++++ ...source-mode-suggestion-gating.dom.test.tsx | 24 +- .../suggestion-plugin-state.test-helper.ts | 47 +++ 10 files changed, 506 insertions(+), 48 deletions(-) create mode 100644 .changeset/tidy-pillows-shout.md create mode 100644 packages/app/src/editor/extensions/suggestion-allow.ts create mode 100644 packages/app/src/editor/literal-text-context.ts create mode 100644 packages/app/tests/dom/literal-text-suggestion-gating.dom.test.tsx create mode 100644 packages/app/tests/dom/suggestion-plugin-state.test-helper.ts diff --git a/.changeset/tidy-pillows-shout.md b/.changeset/tidy-pillows-shout.md new file mode 100644 index 000000000..06c2b2497 --- /dev/null +++ b/.changeset/tidy-pillows-shout.md @@ -0,0 +1,5 @@ +--- +'@inkeep/open-knowledge': patch +--- + +Fix the slash, wiki-link and tag pickers destroying typed text inside code blocks and inline code spans. All three menus used to open in a code context, and choosing an item deleted the characters you had typed there: the query vanished out of the fence and a chip landed after it, while a slash command replaced the whole code block with the chosen node and lost the fence's language tag. The menus now stay closed inside code, matching every other typing shortcut in the editor. diff --git a/packages/app/src/editor/extensions/slash-command.ts b/packages/app/src/editor/extensions/slash-command.ts index 757bd07e5..201dfa1e8 100644 --- a/packages/app/src/editor/extensions/slash-command.ts +++ b/packages/app/src/editor/extensions/slash-command.ts @@ -6,7 +6,7 @@ import { applySlashCommandItem } from '../slash-command/apply-item'; import { filterItems, getSlashCommandItems, type SlashCommandItem } from '../slash-command/items'; import { SlashCommandMenu } from '../slash-command/SlashCommandMenu'; import { isSelectionInTableCell } from '../table-cell-context'; -import { getEditorSourceMode } from './editor-mode-context'; +import { suggestionAllow } from './suggestion-allow'; import { createSuggestionPopup, destroySuggestionPopup, @@ -139,12 +139,9 @@ export const SlashCommand = Extension.create({ pluginKey: slashCommandKey, char: '/', startOfLine: false, - // Gate set inside @tiptap/suggestion's apply() reducer — when source - // mode is active, the bridge still propagates the `/` keystroke into - // the (CSS-hidden) WYSIWYG editor, but the plugin's `state.active` - // stays false so onStart never fires and no popup is appended to - // document.body. See `editor-mode-context.ts` for the signal. - allow: ({ editor }) => !getEditorSourceMode(editor), + // Source-mode and literal-text refusals, shared with the wiki-link and + // tag pickers. See `suggestion-allow.ts`. + allow: suggestionAllow, // allowedPrefixes: [' '] is the default — accept it. Verified against // @tiptap/suggestion source (findSuggestionMatch): the prefix check uses // regex `^[\0]?$` against the char immediately before diff --git a/packages/app/src/editor/extensions/suggestion-allow.ts b/packages/app/src/editor/extensions/suggestion-allow.ts new file mode 100644 index 000000000..28d6b5f8b --- /dev/null +++ b/packages/app/src/editor/extensions/suggestion-allow.ts @@ -0,0 +1,41 @@ +import type { Editor, Range } from '@tiptap/core'; +import type { EditorState } from '@tiptap/pm/state'; +import { isInLiteralTextContext } from '../literal-text-context'; +import { getEditorSourceMode } from './editor-mode-context'; + +/** + * The one `allow` predicate every `@tiptap/suggestion` picker in the document + * editor uses (slash `/`, wiki-link `[[`, tag `#`). + * + * Two refusals, both evaluated inside `@tiptap/suggestion`'s `apply()` reducer + * so `state.active` never flips and `onStart` never mounts a popup: + * + * - **Source mode.** The bridge still propagates the trigger keystroke into the + * CSS-hidden WYSIWYG editor; without this the picker would mount into + * `document.body`, outside the `.ok-mode-hidden` gate every other floating + * surface honors. Signal lives in `editor-mode-context.ts`. + * - **Literal-text context.** A fence, an inline code span, or a raw-MDX-source + * node is text the editor must not convert. Committing an item there is + * byte-destroying, not merely re-shaping: the picker deletes the trigger + * range, so the typed query disappears out of the fence and the chip lands + * after it, and a slash command replaces the entire code block (fence, info + * string and all). Inside `jsxInline` it does not even need typing — the `/` + * already in `` is itself a valid trigger, so parking the caret + * before the `>` arms a menu whose commit deletes that `/` out of the source. + * + * The predicate is shared rather than copied because a per-plugin copy is how + * the literal-text clause came to be missing from all three at once. A new + * picker gets both gates by construction; a new gate axis lands in one place. + */ +export function suggestionAllow({ + editor, + state, + range, +}: { + editor: Editor; + state: EditorState; + range: Range; +}): boolean { + if (getEditorSourceMode(editor)) return false; + return !isInLiteralTextContext(state, range.from, range.to); +} diff --git a/packages/app/src/editor/extensions/tag-suggestion.ts b/packages/app/src/editor/extensions/tag-suggestion.ts index 779c1bcc9..270ba0604 100644 --- a/packages/app/src/editor/extensions/tag-suggestion.ts +++ b/packages/app/src/editor/extensions/tag-suggestion.ts @@ -34,7 +34,7 @@ import { PluginKey } from '@tiptap/pm/state'; import { ReactRenderer } from '@tiptap/react'; import Suggestion, { type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'; import { TagSuggestionMenu } from '../tag-suggestion/TagSuggestionMenu'; -import { getEditorSourceMode } from './editor-mode-context'; +import { suggestionAllow } from './suggestion-allow'; import { createSuggestionPopup, destroySuggestionPopup, @@ -210,10 +210,9 @@ export function configureTagSuggestion(editor: Editor) { // matcher handles that case explicitly. allowedPrefixes: null, findSuggestionMatch: tagMatcher, - // Gate inside @tiptap/suggestion's apply() reducer keeps `state.active` - // false in source mode — bridge-propagated `#` from CodeMirror cannot - // mount the tag picker popup. Signal lives in `editor-mode-context.ts`. - allow: ({ editor }) => !getEditorSourceMode(editor), + // Source-mode and literal-text refusals, shared with the wiki-link and + // slash pickers. See `suggestion-allow.ts`. + allow: suggestionAllow, items: async ({ query }) => { if (!tagsLoaded) { diff --git a/packages/app/src/editor/extensions/wiki-link-suggestion.ts b/packages/app/src/editor/extensions/wiki-link-suggestion.ts index 72619f74f..d12ec0c81 100644 --- a/packages/app/src/editor/extensions/wiki-link-suggestion.ts +++ b/packages/app/src/editor/extensions/wiki-link-suggestion.ts @@ -22,7 +22,7 @@ import { fetchDocumentListShared } from '@/lib/documents-fetch'; import { HttpResponseParseError } from '../http-client'; import { WikiLinkSuggestionMenu } from '../wiki-link-suggestion/WikiLinkSuggestionMenu'; import { getEditorDocName } from './doc-context'; -import { getEditorSourceMode } from './editor-mode-context'; +import { suggestionAllow } from './suggestion-allow'; import { createSuggestionPopup, destroySuggestionPopup, @@ -552,10 +552,9 @@ export function configureWikiLinkSuggestion(editor: Editor) { // null allows mid-word triggers — safe because [[ is an unambiguous delimiter (unlike single-char /) allowedPrefixes: null, findSuggestionMatch: wikiLinkMatcher, - // Gate inside @tiptap/suggestion's apply() reducer keeps `state.active` - // false in source mode — bridge-propagated `[[` from CodeMirror cannot - // mount the page picker popup. Signal lives in `editor-mode-context.ts`. - allow: ({ editor }) => !getEditorSourceMode(editor), + // Source-mode and literal-text refusals, shared with the tag and slash + // pickers. See `suggestion-allow.ts`. + allow: suggestionAllow, items: async ({ query }) => { const { mode, pageTarget, anchorQuery } = parseQuery(query); diff --git a/packages/app/src/editor/gfm-autolink-plugin.ts b/packages/app/src/editor/gfm-autolink-plugin.ts index 49eeb596e..89b02a773 100644 --- a/packages/app/src/editor/gfm-autolink-plugin.ts +++ b/packages/app/src/editor/gfm-autolink-plugin.ts @@ -49,6 +49,7 @@ import { type EditorState, Plugin, PluginKey, type Transaction } from '@tiptap/p import type { EditorView } from '@tiptap/pm/view'; import { ySyncPluginKey } from '@tiptap/y-tiptap'; import { detectGfmLinkToken } from './gfm-link-detector'; +import { isCodeTextblock, rangeHasCodeMark } from './literal-text-context'; import { dispatchAsOwnUndoStep } from './undo-isolation'; // TipTap's UNICODE_WHITESPACE_PATTERN (@tiptap/extension-link), the class @@ -91,12 +92,6 @@ interface GfmAutolinkPluginOptions { isActiveEditor?: (view: EditorView) => boolean; } -function rangeHasCodeMark(view: EditorView, from: number, to: number): boolean { - const codeMark = view.state.schema.marks.code; - if (!codeMark) return false; - return view.state.doc.rangeHasMark(from, to, codeMark); -} - /** * Scan the batch's changed ranges for a GFM-shape token that a just-typed word * boundary completed. Pure with respect to the document — it only reads state @@ -142,7 +137,7 @@ function detectCandidates( if (!textBlock || !textBeforeWhitespace) continue; // Code blocks are plain-text-only; never linkify inside one. - if (textBlock.node.type.spec.code) continue; + if (isCodeTextblock(textBlock.node)) continue; const words = textBeforeWhitespace.split(WHITESPACE_SPLIT).filter(Boolean); const lastWord = words[words.length - 1]; @@ -199,7 +194,7 @@ function gfmAutolinkPlugin(options: GfmAutolinkPluginOptions = {}): Plugin { if (view.state.doc.textBetween(from, to) !== text) continue; // Skip if the span is already linked or lives inside inline code. if (getMarksBetween(from, to, view.state.doc).some((m) => m.mark.type === markType)) continue; - if (rangeHasCodeMark(view, from, to)) continue; + if (rangeHasCodeMark(view.state, from, to)) continue; tr = tr.addMark(from, to, markType.create({ href, linkStyle: GFM_AUTOLINK_STYLE })); changed = true; diff --git a/packages/app/src/editor/literal-text-context.ts b/packages/app/src/editor/literal-text-context.ts new file mode 100644 index 000000000..721a8245b --- /dev/null +++ b/packages/app/src/editor/literal-text-context.ts @@ -0,0 +1,71 @@ +import type { Node as PMNode } from '@tiptap/pm/model'; +import type { EditorState } from '@tiptap/pm/state'; + +/** + * Single source of truth for "is this position/range a literal-text region" — + * somewhere the characters on screen ARE the source, so the editor must never + * convert what the user typed there. + * + * Two kinds of region qualify. Code is the familiar one: a `codeBlock` node or + * an inline `code` mark. The other is raw MDX source: `jsxInline` (zero attrs, + * no NodeView) and `rawMdxFallback` (carries `reason` / `originalSpan` attrs, + * and a NodeView that sets `contenteditable: false`) both declare + * `content: 'text*'` and hold their markdown source as plain text, so deleting + * a character out of one edits the document's bytes directly. + * + * That `contenteditable: false` is why `rawMdxFallback` has no known path to a + * picker today — keystrokes never reach it. It is covered here anyway, because + * the predicate should follow the region's nature rather than the current + * reachability of one route into it. + * + * `@tiptap/core`'s input-rule runner refuses inside code before any rule sees + * the text, so every `addInputRules` surface inherits the code half for free. + * Bare ProseMirror plugins (the GFM autolinker, the three `@tiptap/suggestion` + * pickers) bypass that runner and must ask here instead. Keeping the clauses in + * one place is what stops the surfaces from drifting apart — the pickers + * shipped without any of it, and converted inside fences and inside inline JSX + * by deleting the typed bytes out of them. + * + * Known gap, deliberately not papered over here: the upstream input-rule runner + * tests `spec.code` only, so it is blind to the raw-source nodes. Closing that + * belongs at the rule-runner seam, not in another consumer-side guard. + */ + +/** + * Nodes whose text content is its own markdown source. + * + * Structurally these are the nodes declaring `content: 'text*'` without + * `spec.code`. They are listed by name rather than sniffed from that shape at + * runtime, because the shape alone is too broad to be a safe predicate — a + * future `content: 'text*'` node that is NOT raw source would be silently + * opted in. A test enumerates the schema and fails if this list drifts. + */ +export const RAW_SOURCE_NODE_TYPES: readonly string[] = ['jsxInline', 'rawMdxFallback']; + +/** A textblock whose content is plain text — `codeBlock` and friends. */ +export function isCodeTextblock(node: PMNode): boolean { + return node.type.spec.code === true; +} + +/** Whether any part of `[from, to)` carries the inline `code` mark. */ +export function rangeHasCodeMark(state: EditorState, from: number, to: number): boolean { + const codeMark = state.schema.marks.code; + if (!codeMark) return false; + return state.doc.rangeHasMark(from, to, codeMark); +} + +/** A node holding raw markdown source as its text content. */ +function isRawSourceNode(node: PMNode): boolean { + return RAW_SOURCE_NODE_TYPES.includes(node.type.name); +} + +/** + * Whether `[from, to)` sits in a literal-text region, by any clause: the + * range's parent is a code textblock or a raw-source node, or the range + * carries the inline `code` mark. + */ +export function isInLiteralTextContext(state: EditorState, from: number, to: number): boolean { + const parent = state.doc.resolve(from).parent; + if (isCodeTextblock(parent) || isRawSourceNode(parent)) return true; + return rangeHasCodeMark(state, from, to); +} diff --git a/packages/app/tests/dom/literal-text-suggestion-gating.dom.test.tsx b/packages/app/tests/dom/literal-text-suggestion-gating.dom.test.tsx new file mode 100644 index 000000000..ed4f195ea --- /dev/null +++ b/packages/app/tests/dom/literal-text-suggestion-gating.dom.test.tsx @@ -0,0 +1,326 @@ +/** + * Pin the invariant that the three `@tiptap/suggestion`-based extensions + * (slash `/`, wiki-link `[[`, tag `#`) must NOT activate inside a + * literal-text region — a `codeBlock` node, an inline `code` mark, or a + * raw-MDX-source node — and therefore cannot destroy the characters that + * ARE the document's source there. + * + * Literal-text regions are the universal "leave my text alone" surface. + * `@tiptap/core`'s input-rule runner short-circuits on + * `$from.parent.type.spec.code` or an adjacent code-marked node, so every + * `addInputRules` surface inherits the code half for free, and + * `gfm-autolink-plugin.ts` reads the same predicate out of + * `literal-text-context.ts` for its bare-plugin path. The suggestion + * plugins are bare ProseMirror plugins too, but their `allow` predicate + * tested source mode only — so a menu could activate inside a fence, and + * committing an item deleted the typed query out of the fence (wiki-link / + * tag) or replaced the whole code block (slash). + * + * `jsxInline` is the sharpest case and needs no typing at all: the `/` in + * `` is already a valid slash trigger, so parking the caret before + * the `>` arms a menu whose commit deletes that `/` out of the source and + * leaves ``. + * + * Byte destruction is the point of this suite: the activation assertions + * are the gate, the post-commit assertions are the consequence. Positive + * controls in a plain paragraph keep an over-broad predicate (e.g. + * `allow: () => false`) failing loudly here instead of silently breaking + * the pickers in normal prose. + * + * Commit is driven by a real `KeyboardEvent` through + * `view.someProp('handleKeyDown')` rather than + * `editor.commands.keyboardShortcut`, which double-dispatches a stale + * transaction and is not a faithful key route. The editor also takes real + * DOM focus, which several editor plugins gate on. + * + * Tier: `.dom.test.tsx` (jsdom preload) — TipTap's `new Editor({ ... })` + * needs `document` and `window`. + */ + +// `cleanup` is imported to satisfy the dom-test-filename-stop-rule contract +// (every `*.dom.test.tsx` file must value-import from +// `@testing-library/react`). The suite constructs the Editor directly. +import { cleanup } from '@testing-library/react'; +import { Editor } from '@tiptap/core'; +import type { EditorView } from '@tiptap/pm/view'; +import { afterEach, describe, expect, test } from 'vitest'; +import { sharedExtensions } from '../../src/editor/extensions/shared'; +import { RAW_SOURCE_NODE_TYPES } from '../../src/editor/literal-text-context'; +import { getSuggestionState, suggestionPluginKeys } from './suggestion-plugin-state.test-helper'; + +type TextInputHandler = (view: EditorView, from: number, to: number, text: string) => boolean; +type KeyDownHandler = (view: EditorView, event: KeyboardEvent) => boolean; + +/** Literal-text region under test. `paragraph` is the positive control. */ +type Context = 'codeBlock' | 'inlineCode' | 'jsxInline' | 'paragraph'; + +/** A paragraph holding inline JSX, which parses to a `jsxInline` node. */ +const JSX_DOC = '

hello <Icon /> world

'; + +/** Position `offset` characters into the first node of type `name`. */ +function positionInside(editor: Editor, name: string, offset: number): number | null { + let found: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (found === null && node.type.name === name) found = pos + 1 + offset; + return found === null; + }); + return found; +} + +interface Trigger { + /** Plugin-key prefix, matched against the synthesized `$` key. */ + keyPrefix: string; + /** Characters typed into the editor, including the leading boundary. */ + typed: string; + /** Substring that must survive verbatim inside the literal-text region. */ + literal: string; +} + +const TRIGGERS: Trigger[] = [ + { keyPrefix: 'wikiLinkSuggestion', typed: 'x [[note', literal: '[[note' }, + { keyPrefix: 'tagSuggestion', typed: 'x #roadmap', literal: '#roadmap' }, + { keyPrefix: 'slashCommand', typed: 'x /head', literal: '/head' }, +]; + +const ENTER: KeyboardEventInit & { key: string } = { key: 'Enter', code: 'Enter', keyCode: 13 }; + +function mountEditor(context: Context): { editor: Editor; container: HTMLDivElement } { + const container = document.createElement('div'); + document.body.appendChild(container); + const editor = new Editor({ + element: container, + content: context === 'jsxInline' ? JSX_DOC : '

', + extensions: sharedExtensions, + editable: true, + }); + // Real DOM focus: `commands.focus()` defers `view.focus()` into a rAF that + // never runs under jsdom, and plugins that gate on focus would stay inert. + editor.view.dom.focus(); + if (context === 'jsxInline') { + // Land inside `` between the `n` and the space, so a typed + // trigger sits in the middle of the raw source. + const pos = positionInside(editor, 'jsxInline', 5); + if (pos === null) throw new Error('jsxInline node not found in the seeded document'); + editor.commands.setTextSelection(pos); + return { editor, container }; + } + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + if (context === 'codeBlock') editor.commands.setCodeBlock(); + if (context === 'inlineCode') editor.commands.toggleMark('code'); + return { editor, container }; +} + +function teardown(editor: Editor, container: HTMLDivElement): void { + editor.destroy(); + container.remove(); + // Suggestion popups mount into `document.body`, not the editor container. + for (const node of Array.from(document.body.children)) { + if (node !== container) node.remove(); + } +} + +/** Feed characters through the real `handleTextInput` walk, one at a time. */ +function typeChars(editor: Editor, text: string): void { + for (const ch of text) { + const { from, to } = editor.state.selection; + const handled = + editor.view.someProp('handleTextInput', (h) => + (h as TextInputHandler)(editor.view, from, to, ch), + ) ?? false; + if (!handled) editor.view.dispatch(editor.state.tr.insertText(ch)); + } +} + +function pressEnter(editor: Editor): boolean { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...ENTER }); + return ( + editor.view.someProp('handleKeyDown', (h) => (h as KeyDownHandler)(editor.view, event)) ?? false + ); +} + +/** React 19's concurrent render needs two microtask yields to commit. */ +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function docJson(editor: Editor): string { + return JSON.stringify(editor.state.doc.toJSON()); +} + +describe('Suggestion plugins refuse inside literal text (byte-preservation contract)', () => { + afterEach(() => { + cleanup(); + }); + + /** + * Closure guard. The per-trigger cases below cover a hardcoded list; this + * asserts that list is the whole population, so adding a fourth picker + * fails here until it is added to `TRIGGERS` and proven to refuse. + * + */ + test('the three covered pickers are the only suggestion plugins in the editor', () => { + const { editor, container } = mountEditor('paragraph'); + try { + expect(suggestionPluginKeys(editor)).toEqual(TRIGGERS.map((t) => `${t.keyPrefix}$`).sort()); + } finally { + teardown(editor, container); + } + }); + + /** + * Closure guard for the other half of the predicate. `RAW_SOURCE_NODE_TYPES` + * is a name list, so it can silently fall behind the schema; the structural + * shape it stands for is "declares `content: 'text*'` and is not `spec.code`". + * A new node of that shape fails here until someone decides whether it holds + * raw source (add it to the list) or merely plain text (widen this guard's + * documented exceptions). + * + */ + test('the raw-source list covers every non-code `content: text*` node in the schema', () => { + const { editor, container } = mountEditor('paragraph'); + try { + const shaped = Object.entries(editor.state.schema.nodes) + .filter(([, type]) => { + const spec = type.spec as { content?: string; code?: boolean }; + return spec.content === 'text*' && spec.code !== true; + }) + .map(([name]) => name) + .sort(); + expect(shaped).toEqual([...RAW_SOURCE_NODE_TYPES].sort()); + } finally { + teardown(editor, container); + } + }); + + describe.each(TRIGGERS)('$keyPrefix', ({ keyPrefix, typed, literal }) => { + test('stays inactive inside a code block and leaves the typed bytes intact', async () => { + const { editor, container } = mountEditor('codeBlock'); + try { + typeChars(editor, typed); + await flush(); + + expect(getSuggestionState(editor, keyPrefix)?.active).toBe(false); + + // The commit key must fall through to normal code-block behavior + // rather than being consumed by a menu. + pressEnter(editor); + await flush(); + + const json = docJson(editor); + expect(json).toContain('codeBlock'); + expect(editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n')).toContain( + literal, + ); + // No chip / heading was minted out of the fence. + expect(json).not.toContain('wikiLink'); + expect(json).not.toContain('"type":"tag"'); + expect(json).not.toContain('heading'); + } finally { + teardown(editor, container); + } + }); + + test('stays inactive inside an inline code mark and leaves the typed bytes intact', async () => { + const { editor, container } = mountEditor('inlineCode'); + try { + typeChars(editor, typed); + await flush(); + + expect(getSuggestionState(editor, keyPrefix)?.active).toBe(false); + + pressEnter(editor); + await flush(); + + const json = docJson(editor); + expect(editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n')).toContain( + literal, + ); + expect(json).not.toContain('wikiLink'); + expect(json).not.toContain('"type":"tag"'); + } finally { + teardown(editor, container); + } + }); + + /** + * The raw-source clause. `jsxInline` holds its own markdown source as + * plain text, so a deleted trigger range is a direct edit to the + * document's bytes. + * + */ + test('stays inactive inside inline JSX source and leaves the source intact', async () => { + const { editor, container } = mountEditor('jsxInline'); + try { + typeChars(editor, typed); + await flush(); + + expect(getSuggestionState(editor, keyPrefix)?.active).toBe(false); + + pressEnter(editor); + await flush(); + + const json = docJson(editor); + const text = editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n'); + expect(text).toContain(literal); + // The JSX source itself survives, angle brackets and all. + expect(text).toContain(''); + expect(json).toContain('jsxInline'); + expect(json).not.toContain('wikiLink'); + expect(json).not.toContain('"type":"tag"'); + } finally { + teardown(editor, container); + } + }); + + /** + * Positive control: the same keystrokes in plain prose must still open + * the menu. Without this, `allow: () => false` would pass the refusal + * tests above while breaking every picker in the product. + * + */ + test('still activates in a plain paragraph', async () => { + const { editor, container } = mountEditor('paragraph'); + try { + typeChars(editor, typed); + await flush(); + expect(getSuggestionState(editor, keyPrefix)?.active).toBe(true); + } finally { + teardown(editor, container); + } + }); + }); + + /** + * The sharpest form of the raw-source case: no typing at all. `` + * already contains a space-then-slash, so parking the caret between the + * `/` and the `>` is enough for the default matcher to arm the slash menu + * with an empty query — and `applySlashCommandItem` deletes the trigger + * range unconditionally, taking that `/` out of the source and leaving + * ``. + * + */ + test('the slash menu does not arm on the `/` already inside ``', async () => { + const { editor, container } = mountEditor('paragraph'); + try { + editor.commands.setContent(JSX_DOC); + const pos = positionInside(editor, 'jsxInline', 7); + expect(pos).not.toBeNull(); + editor.commands.setTextSelection(pos as number); + await flush(); + + expect(getSuggestionState(editor, 'slashCommand')?.active).toBe(false); + + pressEnter(editor); + await flush(); + + expect(editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n')).toContain( + '', + ); + expect(docJson(editor)).not.toContain('heading'); + } finally { + teardown(editor, container); + } + }); +}); diff --git a/packages/app/tests/dom/source-mode-suggestion-gating.dom.test.tsx b/packages/app/tests/dom/source-mode-suggestion-gating.dom.test.tsx index 4302497fd..298f6fce0 100644 --- a/packages/app/tests/dom/source-mode-suggestion-gating.dom.test.tsx +++ b/packages/app/tests/dom/source-mode-suggestion-gating.dom.test.tsx @@ -32,29 +32,7 @@ import { Editor } from '@tiptap/core'; import { afterEach, describe, expect, test } from 'vitest'; import { setEditorSourceMode } from '../../src/editor/extensions/editor-mode-context'; import { sharedExtensions } from '../../src/editor/extensions/shared'; - -interface SuggestionPluginState { - active: boolean; -} - -/** - * Find a Suggestion-plugin instance on the editor by its plugin-key name - * prefix. The three keys (`slashCommand`, `wikiLinkSuggestion`, - * `tagSuggestion`) are constructed via `new PluginKey('')` which - * synthesizes a unique suffix (`$` or `$`). We match the - * prefix because `slashCommandKey` is not exported from - * `slash-command.ts` and we don't want to widen the production surface - * just for tests. - */ -function getSuggestionState(editor: Editor, keyPrefix: string): SuggestionPluginState | null { - const plugin = editor.state.plugins.find((p) => { - const keyName = (p as { spec?: { key?: { key?: string } } }).spec?.key?.key; - return typeof keyName === 'string' && keyName.startsWith(keyPrefix); - }); - if (!plugin) return null; - const state = plugin.getState(editor.state) as SuggestionPluginState | undefined; - return state ?? null; -} +import { getSuggestionState } from './suggestion-plugin-state.test-helper'; function mountEditor(): { editor: Editor; container: HTMLDivElement } { const container = document.createElement('div'); diff --git a/packages/app/tests/dom/suggestion-plugin-state.test-helper.ts b/packages/app/tests/dom/suggestion-plugin-state.test-helper.ts new file mode 100644 index 000000000..3d737537a --- /dev/null +++ b/packages/app/tests/dom/suggestion-plugin-state.test-helper.ts @@ -0,0 +1,47 @@ +/** + * Shared plugin-key discovery for the `@tiptap/suggestion` gating suites. + * + * The three pickers build their keys with `new PluginKey('')`, which + * synthesizes a unique suffix (`$` or `$`), and none of the + * three exports its key — so a test matches on the prefix rather than widening + * the production surface. That discovery mechanism is the part worth sharing: + * if the suffix format ever changes, one file has to learn about it, not each + * suite independently. + */ + +import type { Editor } from '@tiptap/core'; + +/** The subset of `@tiptap/suggestion`'s reducer state the gating suites read. */ +export interface SuggestionPluginState { + active: boolean; +} + +/** The suggestion plugin whose key starts with `keyPrefix`, or null. */ +export function getSuggestionState( + editor: Editor, + keyPrefix: string, +): SuggestionPluginState | null { + const plugin = editor.state.plugins.find((p) => { + const keyName = (p as { spec?: { key?: { key?: string } } }).spec?.key?.key; + return typeof keyName === 'string' && keyName.startsWith(keyPrefix); + }); + if (!plugin) return null; + return (plugin.getState(editor.state) as SuggestionPluginState | undefined) ?? null; +} + +/** + * Every mounted plugin carrying the `@tiptap/suggestion` reducer state shape, + * by key name. Read off the live editor rather than off source text, so a + * picker registered through any code path is seen. + */ +export function suggestionPluginKeys(editor: Editor): string[] { + const keys: string[] = []; + for (const plugin of editor.state.plugins) { + const state = plugin.getState(editor.state) as Record | undefined; + if (!state || typeof state !== 'object') continue; + if (!('active' in state && 'range' in state && 'query' in state)) continue; + const keyName = (plugin as { spec?: { key?: { key?: string } } }).spec?.key?.key; + if (typeof keyName === 'string') keys.push(keyName); + } + return keys.sort(); +}