diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index a4f9605ba1..2610daac7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -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, '\\|'), }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index de0620ce9f..10bee44d72 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -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) }} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 1264882530..61d23c429b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx index ee8ae3f5c9..039cda318a 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx @@ -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()