Skip to content

Commit 7a20d8a

Browse files
committed
improvement(rich-md-editor): reveal bubble after drag-select, keep it on-screen for tall selections, restyle task-list checkbox
1 parent 0e46052 commit 7a20d8a

3 files changed

Lines changed: 108 additions & 7 deletions

File tree

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

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+
import { posToDOMRect } from '@tiptap/core'
3+
import { PluginKey } from '@tiptap/pm/state'
24
import type { Editor } from '@tiptap/react'
35
import { useEditorState } from '@tiptap/react'
46
import { BubbleMenu } from '@tiptap/react/menus'
@@ -61,8 +63,20 @@ function ToolbarDivider() {
6163
return <div className='mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
6264
}
6365

66+
/**
67+
* Whether the formatting toolbar may show for the given range: the editor is editable, the range
68+
* isn't inside a code block, and it covers some non-whitespace text. Single source of truth shared by
69+
* `shouldShow` and the pointer-release reveal so the two can't drift apart.
70+
*/
71+
function hasFormattableSelection(editor: Editor, from: number, to: number): boolean {
72+
if (!editor.isEditable || editor.isActive('codeBlock')) return false
73+
return editor.state.doc.textBetween(from, to, ' ').trim().length > 0
74+
}
75+
6476
interface EditorBubbleMenuProps {
6577
editor: Editor
78+
/** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
79+
scrollContainerRef: React.RefObject<HTMLDivElement | null>
6680
}
6781

6882
/**
@@ -71,12 +85,16 @@ interface EditorBubbleMenuProps {
7185
* live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar
7286
* stays correct without re-rendering the editor on every transaction.
7387
*/
74-
export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
88+
export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) {
7589
const [linkValue, setLinkValue] = useState<string | null>(null)
7690
const linkInputRef = useRef<HTMLInputElement>(null)
7791
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
7892
const isEditingLink = linkValue !== null
7993

94+
// Explicit key so `setMeta` can target this menu to reveal it after a drag-select.
95+
const bubbleMenuKey = useMemo(() => new PluginKey('markdownBubbleMenu'), [])
96+
const isPointerDownRef = useRef(false)
97+
8098
const active = useEditorState({
8199
editor,
82100
selector: ({ editor: e }) => ({
@@ -109,6 +127,38 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
109127
}
110128
}, [editor])
111129

130+
// Reveal the toolbar only once a drag-select finishes (Linear-style); `shouldShow` keeps it hidden
131+
// while the pointer is down. Keyboard selection has no pointer, so it still shows live.
132+
useEffect(() => {
133+
const dom = editor.view.dom
134+
const onPointerDown = () => {
135+
isPointerDownRef.current = true
136+
}
137+
const onPointerUp = () => {
138+
if (!isPointerDownRef.current || editor.isDestroyed) return
139+
isPointerDownRef.current = false
140+
const { from, to } = editor.state.selection
141+
if (hasFormattableSelection(editor, from, to)) {
142+
// `show` alone leaves the bar visible-but-unpositioned (its updatePosition no-ops until shown),
143+
// so a second `updatePosition` anchors it. Both are step-free, so the doc isn't marked dirty.
144+
editor.commands.setMeta(bubbleMenuKey, 'show')
145+
editor.commands.setMeta(bubbleMenuKey, 'updatePosition')
146+
}
147+
}
148+
// A release outside the window delivers no mouseup; clear the flag on blur so it can't stay wedged.
149+
const onWindowBlur = () => {
150+
isPointerDownRef.current = false
151+
}
152+
dom.addEventListener('mousedown', onPointerDown)
153+
window.addEventListener('mouseup', onPointerUp)
154+
window.addEventListener('blur', onWindowBlur)
155+
return () => {
156+
dom.removeEventListener('mousedown', onPointerDown)
157+
window.removeEventListener('mouseup', onPointerUp)
158+
window.removeEventListener('blur', onWindowBlur)
159+
}
160+
}, [editor, bubbleMenuKey])
161+
112162
const openLinkEditor = () => {
113163
if (editor.isActive('codeBlock') || editor.isActive('code')) return
114164
const { from, to } = editor.state.selection
@@ -158,9 +208,26 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
158208
setLinkValue(null)
159209
}
160210

211+
// The default whole-selection anchor pushes the toolbar off-screen when the selection is taller than
212+
// the viewport (e.g. select-all in a long doc). There, anchor to the selection's top edge clamped
213+
// into the viewport so the bar settles at the top of the view; `null` keeps the default otherwise.
214+
const resolveAnchor = useCallback(() => {
215+
const { view, state } = editor
216+
if (!view.dom.isConnected) return null
217+
const viewport = scrollContainerRef.current?.getBoundingClientRect()
218+
if (!viewport) return null
219+
const selection = posToDOMRect(view, state.selection.from, state.selection.to)
220+
if (selection.height <= viewport.height) return null
221+
const top = Math.min(Math.max(selection.top, viewport.top), viewport.bottom)
222+
const rect = new DOMRect(selection.left, top, selection.width, 0)
223+
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
224+
}, [editor, scrollContainerRef])
225+
161226
return (
162227
<BubbleMenu
163228
editor={editor}
229+
pluginKey={bubbleMenuKey}
230+
getReferencedVirtualElement={resolveAnchor}
164231
role='toolbar'
165232
aria-label='Text formatting'
166233
updateDelay={0}
@@ -169,8 +236,9 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
169236
// can't be applied to a doc that must not mutate.
170237
if (!e.isEditable) return false
171238
if (isEditingLink) return true
172-
if (e.isActive('codeBlock')) return false
173-
return e.state.doc.textBetween(from, to, ' ').trim().length > 0
239+
// Suppressed mid-drag; the pointer-release handler forces it back open once the selection sticks.
240+
if (isPointerDownRef.current) return false
241+
return hasFormattableSelection(e, from, to)
174242
}}
175243
className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-100 motion-reduce:animate-none'
176244
>

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,11 @@
153153
gap: 0.5em;
154154
}
155155

156+
/* One line tall with the box centered, so it aligns with the item's first line. */
156157
.rich-markdown-prose ul[data-type="taskList"] li > label {
157-
margin-top: 0.28em;
158+
display: flex;
159+
align-items: center;
160+
height: 1.6667em; /* = the prose 25px line-height at 15px font */
158161
flex-shrink: 0;
159162
user-select: none;
160163
}
@@ -164,11 +167,39 @@
164167
min-width: 0;
165168
}
166169

170+
/* TaskItem nests content as li > div > p, which the `li > p` reset misses, leaving UA margins. */
171+
.rich-markdown-prose ul[data-type="taskList"] li > div > p {
172+
margin: 0;
173+
}
174+
175+
/* Match the design-system Checkbox (emcn) rather than the platform-native control. */
167176
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"] {
168-
accent-color: var(--text-primary);
177+
appearance: none;
178+
-webkit-appearance: none;
179+
display: inline-grid;
180+
place-content: center;
181+
width: 16px;
182+
height: 16px;
183+
margin: 0;
184+
border: 1px solid var(--border-1);
185+
border-radius: 3px;
186+
background: transparent;
169187
cursor: pointer;
170188
}
171189

190+
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked {
191+
background-color: var(--text-primary);
192+
border-color: var(--text-primary);
193+
}
194+
195+
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked::after {
196+
content: "";
197+
width: 10px;
198+
height: 10px;
199+
background-color: var(--surface-2);
200+
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
201+
}
202+
172203
.rich-markdown-prose blockquote {
173204
border-left: 2px solid var(--divider);
174205
padding-left: 1rem;

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,9 @@ export function LoadedRichMarkdownEditor({
354354
ref={containerRef}
355355
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
356356
>
357-
{showBubbleMenu && editor && <EditorBubbleMenu editor={editor} />}
357+
{showBubbleMenu && editor && (
358+
<EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />
359+
)}
358360
<EditorContent
359361
editor={editor}
360362
className='mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'

0 commit comments

Comments
 (0)