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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, '\\|'),
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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({
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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
}
Comment thread
waleedlatif1 marked this conversation as resolved.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading