Skip to content

Commit 2bbf70e

Browse files
authored
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
1 parent 4bf7917 commit 2bbf70e

4 files changed

Lines changed: 26 additions & 27 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ const InlineCode = Code.extend({ excludes: '' })
3030
* Table that escapes interior `|` characters when serializing cells. The upstream serializer
3131
* joins cells with `|` without escaping, so a cell containing a literal pipe silently splits
3232
* into phantom columns on round-trip (data loss). Escaping must happen on the `table` node —
33-
* `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly.
33+
* `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. Only
34+
* `|` is escaped — `renderChildren` already escapes backslashes, so escaping them again would
35+
* double-escape and break round-trip idempotency (CodeQL's "missing backslash escape" is a false
36+
* positive here; covered by the table round-trip tests).
3437
*
3538
* The upstream serializer also wraps the table in its own leading/trailing blank lines; left in,
3639
* the block joiner adds another, so an interior table churns its surrounding whitespace to
@@ -42,9 +45,6 @@ const PipeSafeTable = Table.extend({
4245
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
4346
renderTableToMarkdown(node, {
4447
...h,
45-
// `renderChildren` already markdown-escapes backslashes; here we only add the table-specific
46-
// pipe escaping on top. (CodeQL flags the missing backslash escape, but escaping it again would
47-
// double-escape and break round-trip idempotency — see the table round-trip tests.)
4848
renderChildren: (nodes, separator) =>
4949
h.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
5050
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { posToDOMRect } from '@tiptap/core'
33
import { PluginKey } from '@tiptap/pm/state'
44
import type { Editor } from '@tiptap/react'
@@ -32,12 +32,21 @@ function hasFormattableSelection(editor: Editor, from: number, to: number): bool
3232
return editor.state.doc.textBetween(from, to, ' ').trim().length > 0
3333
}
3434

35-
// Pin the toolbar to the viewport (fixed) and never attach a scroll listener, so once it's placed for
36-
// a selection it stays put while the document scrolls instead of tracking the text — matching Linear.
35+
/**
36+
* Reveals the bubble menu for the current selection. Both calls are required and must stay in order:
37+
* `show` alone leaves the bar visible but unpositioned (its internal `updatePosition` no-ops until the
38+
* menu is shown), so the follow-up `updatePosition` anchors it. Both are step-free transactions, so
39+
* neither marks the document dirty.
40+
*/
41+
function revealBubbleMenu(editor: Editor, key: PluginKey): void {
42+
editor.commands.setMeta(key, 'show')
43+
editor.commands.setMeta(key, 'updatePosition')
44+
}
45+
46+
/** Pins the toolbar to the viewport so it stays put while the document scrolls instead of tracking the text. */
3747
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
3848

39-
// Render into the body so a transformed/clipping ancestor (e.g. the mothership panels) can't reparent
40-
// the fixed-positioned toolbar and shift it off the selection.
49+
/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
4150
const APPEND_TO_BODY = () => document.body
4251

4352
interface EditorBubbleMenuProps {
@@ -58,8 +67,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
5867
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
5968
const isEditingLink = linkValue !== null
6069

61-
// Explicit key so `setMeta` can target this menu to reveal it after a drag-select.
62-
const bubbleMenuKey = useMemo(() => new PluginKey('markdownBubbleMenu'), [])
70+
const [bubbleMenuKey] = useState(() => new PluginKey('markdownBubbleMenu'))
6371
const isPointerDownRef = useRef(false)
6472

6573
const active = useEditorState({
@@ -94,8 +102,12 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
94102
}
95103
}, [editor])
96104

97-
// Reveal the toolbar only once a drag-select finishes (Linear-style); `shouldShow` keeps it hidden
98-
// while the pointer is down. Keyboard selection has no pointer, so it still shows live.
105+
/**
106+
* Linear-style reveal: the toolbar stays hidden while the pointer is down (the drag gate in
107+
* `shouldShow`) and surfaces on release. `mouseup`/`blur` listen on `window` so a release outside
108+
* the editor — or off-screen, where no `mouseup` fires — still clears the drag flag; otherwise it
109+
* could wedge `true` and suppress the toolbar for later keyboard selections.
110+
*/
99111
useEffect(() => {
100112
const dom = editor.view.dom
101113
const onPointerDown = () => {
@@ -105,14 +117,8 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
105117
if (!isPointerDownRef.current || editor.isDestroyed) return
106118
isPointerDownRef.current = false
107119
const { from, to } = editor.state.selection
108-
if (hasFormattableSelection(editor, from, to)) {
109-
// `show` alone leaves the bar visible-but-unpositioned (its updatePosition no-ops until shown),
110-
// so a second `updatePosition` anchors it. Both are step-free, so the doc isn't marked dirty.
111-
editor.commands.setMeta(bubbleMenuKey, 'show')
112-
editor.commands.setMeta(bubbleMenuKey, 'updatePosition')
113-
}
120+
if (hasFormattableSelection(editor, from, to)) revealBubbleMenu(editor, bubbleMenuKey)
114121
}
115-
// A release outside the window delivers no mouseup; clear the flag on blur so it can't stay wedged.
116122
const onWindowBlur = () => {
117123
isPointerDownRef.current = false
118124
}
@@ -175,10 +181,6 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
175181
setLinkValue(null)
176182
}
177183

178-
// Freeze the anchor per selection: the rect is computed once (in viewport coordinates) and reused on
179-
// every scroll/resize reposition, so the toolbar stays where it first appeared instead of tracking
180-
// the moving text — matching Linear. A new selection recomputes it. A selection taller than the
181-
// viewport (e.g. select-all) is clamped into the visible area so the bar isn't placed off-screen.
182184
const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null)
183185
const resolveAnchor = useCallback(() => {
184186
const { view, state } = editor
@@ -218,7 +220,6 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
218220
// can't be applied to a doc that must not mutate.
219221
if (!e.isEditable) return false
220222
if (isEditingLink) return true
221-
// Suppressed mid-drag; the pointer-release handler forces it back open once the selection sticks.
222223
if (isPointerDownRef.current) return false
223224
return hasFormattableSelection(e, from, to)
224225
}}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ export function LoadedRichMarkdownEditor({
206206
shouldRerenderOnTransaction: false,
207207
content: initialContent,
208208
editorProps: {
209-
// Claim Mod+K so the global command registry yields it to the editor's link shortcut.
210209
attributes: { class: 'rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K' },
211210
handleKeyDown: (_view, event) => {
212211
const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's'

apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,6 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
151151
}
152152

153153
if (matchesShortcut(e, cmd.parsed)) {
154-
// A focused rich editor that owns this shortcut (e.g. Mod+K for links) handles it itself.
155154
if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue
156155
e.preventDefault()
157156
e.stopPropagation()

0 commit comments

Comments
 (0)