Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tidy-pillows-shout.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 4 additions & 7 deletions packages/app/src/editor/extensions/slash-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -139,12 +139,9 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
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 `^[<allowedPrefixes>\0]?$` against the char immediately before
Expand Down
41 changes: 41 additions & 0 deletions packages/app/src/editor/extensions/suggestion-allow.ts
Original file line number Diff line number Diff line change
@@ -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 `<Icon />` 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);
}
9 changes: 4 additions & 5 deletions packages/app/src/editor/extensions/tag-suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 4 additions & 5 deletions packages/app/src/editor/extensions/wiki-link-suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 3 additions & 8 deletions packages/app/src/editor/gfm-autolink-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down
71 changes: 71 additions & 0 deletions packages/app/src/editor/literal-text-context.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading