improvement(rich-md-editor): stabilize bubble-menu plugin key + comment cleanup (#5158)

* improvement(rich-md-editor): stabilize the bubble-menu plugin key and move inline rationale into TSDoc

* docs(rich-md-editor): preserve bubble reveal + blur rationale via a helper + TSDoc
This commit is contained in:
Waleed
2026-06-20 15:53:13 -07:00
committed by GitHub
parent 4bf791716e
commit 2bbf70ec20
4 changed files with 26 additions and 27 deletions
@@ -30,7 +30,10 @@ const InlineCode = Code.extend({ excludes: '' })
* Table that escapes interior `|` characters when serializing cells. The upstream serializer
* joins cells with `|` without escaping, so a cell containing a literal pipe silently splits
* into phantom columns on round-trip (data loss). Escaping must happen on the `table` node —
* `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly.
* `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. Only
* `|` is escaped — `renderChildren` already escapes backslashes, so escaping them again would
* double-escape and break round-trip idempotency (CodeQL's "missing backslash escape" is a false
* positive here; covered by the table round-trip tests).
*
* The upstream serializer also wraps the table in its own leading/trailing blank lines; left in,
* the block joiner adds another, so an interior table churns its surrounding whitespace to
@@ -42,9 +45,6 @@ const PipeSafeTable = Table.extend({
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
renderTableToMarkdown(node, {
...h,
// `renderChildren` already markdown-escapes backslashes; here we only add the table-specific
// pipe escaping on top. (CodeQL flags the missing backslash escape, but escaping it again would
// double-escape and break round-trip idempotency — see the table round-trip tests.)
renderChildren: (nodes, separator) =>
h.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
})
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { posToDOMRect } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
@@ -32,12 +32,21 @@ function hasFormattableSelection(editor: Editor, from: number, to: number): bool
return editor.state.doc.textBetween(from, to, ' ').trim().length > 0
}
// Pin the toolbar to the viewport (fixed) and never attach a scroll listener, so once it's placed for
// a selection it stays put while the document scrolls instead of tracking the text — matching Linear.
/**
* Reveals the bubble menu for the current selection. Both calls are required and must stay in order:
* `show` alone leaves the bar visible but unpositioned (its internal `updatePosition` no-ops until the
* menu is shown), so the follow-up `updatePosition` anchors it. Both are step-free transactions, so
* neither marks the document dirty.
*/
function revealBubbleMenu(editor: Editor, key: PluginKey): void {
editor.commands.setMeta(key, 'show')
editor.commands.setMeta(key, 'updatePosition')
}
/** Pins the toolbar to the viewport so it stays put while the document scrolls instead of tracking the text. */
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
// Render into the body so a transformed/clipping ancestor (e.g. the mothership panels) can't reparent
// the fixed-positioned toolbar and shift it off the selection.
/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
const APPEND_TO_BODY = () => document.body
interface EditorBubbleMenuProps {
@@ -58,8 +67,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
const isEditingLink = linkValue !== null
// Explicit key so `setMeta` can target this menu to reveal it after a drag-select.
const bubbleMenuKey = useMemo(() => new PluginKey('markdownBubbleMenu'), [])
const [bubbleMenuKey] = useState(() => new PluginKey('markdownBubbleMenu'))
const isPointerDownRef = useRef(false)
const active = useEditorState({
@@ -94,8 +102,12 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
}
}, [editor])
// Reveal the toolbar only once a drag-select finishes (Linear-style); `shouldShow` keeps it hidden
// while the pointer is down. Keyboard selection has no pointer, so it still shows live.
/**
* Linear-style reveal: the toolbar stays hidden while the pointer is down (the drag gate in
* `shouldShow`) and surfaces on release. `mouseup`/`blur` listen on `window` so a release outside
* the editor — or off-screen, where no `mouseup` fires — still clears the drag flag; otherwise it
* could wedge `true` and suppress the toolbar for later keyboard selections.
*/
useEffect(() => {
const dom = editor.view.dom
const onPointerDown = () => {
@@ -105,14 +117,8 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
if (!isPointerDownRef.current || editor.isDestroyed) return
isPointerDownRef.current = false
const { from, to } = editor.state.selection
if (hasFormattableSelection(editor, from, to)) {
// `show` alone leaves the bar visible-but-unpositioned (its updatePosition no-ops until shown),
// so a second `updatePosition` anchors it. Both are step-free, so the doc isn't marked dirty.
editor.commands.setMeta(bubbleMenuKey, 'show')
editor.commands.setMeta(bubbleMenuKey, 'updatePosition')
}
if (hasFormattableSelection(editor, from, to)) revealBubbleMenu(editor, bubbleMenuKey)
}
// A release outside the window delivers no mouseup; clear the flag on blur so it can't stay wedged.
const onWindowBlur = () => {
isPointerDownRef.current = false
}
@@ -175,10 +181,6 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
setLinkValue(null)
}
// Freeze the anchor per selection: the rect is computed once (in viewport coordinates) and reused on
// every scroll/resize reposition, so the toolbar stays where it first appeared instead of tracking
// the moving text — matching Linear. A new selection recomputes it. A selection taller than the
// viewport (e.g. select-all) is clamped into the visible area so the bar isn't placed off-screen.
const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null)
const resolveAnchor = useCallback(() => {
const { view, state } = editor
@@ -218,7 +220,6 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
// can't be applied to a doc that must not mutate.
if (!e.isEditable) return false
if (isEditingLink) return true
// Suppressed mid-drag; the pointer-release handler forces it back open once the selection sticks.
if (isPointerDownRef.current) return false
return hasFormattableSelection(e, from, to)
}}
@@ -206,7 +206,6 @@ export function LoadedRichMarkdownEditor({
shouldRerenderOnTransaction: false,
content: initialContent,
editorProps: {
// Claim Mod+K so the global command registry yields it to the editor's link shortcut.
attributes: { class: 'rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K' },
handleKeyDown: (_view, event) => {
const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's'
@@ -151,7 +151,6 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
}
if (matchesShortcut(e, cmd.parsed)) {
// A focused rich editor that owns this shortcut (e.g. Mod+K for links) handles it itself.
if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue
e.preventDefault()
e.stopPropagation()