Files
Compass/web/components/editor/floating-format-menu.tsx
MartinBraquet b3351707e2 Optimize editor performance by reducing excessive re-renders
- Introduced `useEditorState` hook for targeted subscriptions in components (e.g., text length, active marks, editor state).
- Disabled global re-rendering on every transaction within the editor.
- Improved typing responsiveness across components by limiting reflows and re-renders caused by keystrokes.
- Updated components (`LLMExtractSection`, `comment-input`, `editable-bio`, etc.) to leverage `useEditorState` for localized updates.
- Refined floating toolbar behavior with `requestAnimationFrame` for smoother scrolling and reduced layout thrashing.
2026-07-23 18:14:30 +02:00

127 lines
3.8 KiB
TypeScript

import {CheckIcon, LinkIcon, TrashIcon} from '@heroicons/react/24/solid'
import {Editor} from '@tiptap/core'
import {BubbleMenu, useEditorState} from '@tiptap/react'
import clsx from 'clsx'
import {getUrl} from 'common/util/parse'
import {Bold, Italic, Type} from 'lucide-react'
import {useState} from 'react'
// see https://tiptap.dev/guide/menus
export function FloatingFormatMenu(props: {
editor: Editor | null
/** show more formatting options */
advanced?: boolean
}) {
const {editor, advanced} = props
const [url, setUrl] = useState<string | null>(null)
// The editor no longer re-renders this component on every transaction (see editor.tsx), so subscribe
// to just the active marks we highlight. This re-renders only the bubble menu when they change.
const active = useEditorState({
editor,
selector: ({editor}) =>
editor
? {
h1: editor.isActive('heading', {level: 1}),
h2: editor.isActive('heading', {level: 2}),
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
link: editor.isActive('link'),
}
: null,
})
if (!editor || !active) return null
const setLink = () => {
const href = url && getUrl(url)
if (href) {
editor.chain().focus().extendMarkRange('link').setLink({href}).run()
}
}
const unsetLink = () => editor.chain().focus().unsetLink().run()
return (
<BubbleMenu
editor={editor}
shouldShow={({state}) => {
// CellSelection has $anchorCell, regular selections don't
if ('$anchorCell' in state.selection) return false
return !state.selection.empty
}}
className="text-ink-0 bg-ink-700 flex gap-2 rounded-sm p-1"
>
{url === null ? (
<>
{advanced && (
<>
<IconButton
icon={Type}
onClick={() => editor.chain().focus().toggleHeading({level: 1}).run()}
isActive={active.h1}
/>
<IconButton
icon={Type}
onClick={() => editor.chain().focus().toggleHeading({level: 2}).run()}
isActive={active.h2}
className="!h-4"
/>
<Divider />
</>
)}
<IconButton
icon={Bold}
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={active.bold}
/>
<IconButton
icon={Italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={active.italic}
/>
<IconButton
icon={LinkIcon}
onClick={() => (active.link ? unsetLink() : setUrl(''))}
isActive={active.link}
/>
</>
) : (
<>
<input
type="text"
inputMode="url"
className="h-5 border-0 bg-inherit text-sm !shadow-none !ring-0"
placeholder="Type or paste a link"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
/>
<button onClick={() => (setLink(), setUrl(null))}>
<CheckIcon className="h-5 w-5" />
</button>
<button onClick={() => (unsetLink(), setUrl(null))}>
<TrashIcon className="h-5 w-5" />
</button>
</>
)}
</BubbleMenu>
)
}
const IconButton = (props: {
icon: React.FC<React.SVGProps<SVGSVGElement>>
onClick: () => any
isActive?: boolean
className?: string
}) => {
const {icon: Icon, onClick, isActive, className} = props
return (
<button onClick={onClick} type="button">
<Icon className={clsx('h-5', isActive && 'text-primary-200', className)} />
</button>
)
}
const Divider = () => <div className="bg-ink-400 mx-0.5 w-[1px]" />