import { getMarkRange, Range, EditorEvents as TextEditorEvents } from '@tiptap/core'
import { MarkType } from '@tiptap/pm/model'
import { Box, debounce, TiptapEditor, track, useEditor, useValue } from '@tldraw/editor'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
import { rectToBox, TldrawUiContextualToolbar } from '../primitives/TldrawUiContextualToolbar'
import { DefaultRichTextToolbarContent } from './DefaultRichTextToolbarContent'
import { LinkEditor } from './LinkEditor'
/** @public */
export interface TLUiRichTextToolbarProps {
children?: React.ReactNode
}
/**
* The default rich text toolbar.
*
* @public @react
*/
export const DefaultRichTextToolbar = track(function DefaultRichTextToolbar({
children,
}: TLUiRichTextToolbarProps) {
const editor = useEditor()
const textEditor = useValue('textEditor', () => editor.getRichTextEditor(), [editor])
if (editor.getInstanceState().isCoarsePointer || !textEditor) return null
return {children}
})
function ContextualToolbarInner({
textEditor,
children,
}: {
children?: React.ReactNode
textEditor: TiptapEditor
}) {
const editor = useEditor()
const { isEditingLink, onEditLinkStart, onEditLinkClose } = useEditingLinkBehavior(textEditor)
const [currentSelection, setCurrentSelection] = useState(null)
const previousSelectionBounds = useRef(undefined)
const isMousingDown = useIsMousingDownOnTextEditor(textEditor)
const msg = useTranslation()
const getSelectionBounds = useCallback(() => {
if (isEditingLink) {
// If we're editing a link we don't have selection bounds temporarily.
return previousSelectionBounds.current
}
// Get the text selection rects as a box. This will be undefined if there are no selections.
const selection = editor.getContainerWindow().getSelection()
// If there are no selections, don't return a box
if (!currentSelection || !selection || selection.rangeCount === 0 || selection.isCollapsed)
return
// Get a common box from all of the ranges' screen rects
const rangeBoxes: Box[] = []
for (let i = 0; i < selection.rangeCount; i++) {
const range = selection.getRangeAt(i)
rangeBoxes.push(rectToBox(range.getBoundingClientRect()))
}
const bounds = Box.Common(rangeBoxes)
previousSelectionBounds.current = bounds
return bounds
}, [editor, currentSelection, isEditingLink])
useEffect(() => {
const handleSelectionUpdate = ({ editor: textEditor }: TextEditorEvents['selectionUpdate']) =>
setCurrentSelection(textEditor.state.selection)
textEditor.on('selectionUpdate', handleSelectionUpdate)
// Need to kick off the selection update manually to get the initial selection, esp. if select-all.
handleSelectionUpdate({ editor: textEditor } as TextEditorEvents['selectionUpdate'])
return () => {
textEditor.off('selectionUpdate', handleSelectionUpdate)
}
}, [textEditor])
return (
{children ? (
children
) : isEditingLink ? (
) : (
)}
)
}
function useEditingLinkBehavior(textEditor?: TiptapEditor) {
const [isEditingLink, setIsEditingLink] = useState(false)
// Set up text editor event listeners.
useEffect(() => {
if (!textEditor) {
setIsEditingLink(false)
return
}
const handleClick = () => {
const isLinkActive = textEditor.isActive('link')
setIsEditingLink(isLinkActive)
}
textEditor.view.dom.addEventListener('click', handleClick)
return () => {
if (textEditor.isInitialized) {
textEditor.view.dom.removeEventListener('click', handleClick)
}
}
}, [textEditor, isEditingLink])
// If we're editing a link, select the entire link.
// This can happen via a click or via keyboarding over to the link and then
// clicking the toolbar button.
useEffect(() => {
if (!textEditor) {
return
}
// N.B. This specifically isn't checking the isEditingLink state but
// the current active state of the text editor. This is because there's
// a subtelty where when going edit-to-edit, that is text editor-to-text editor
// in different shapes, the isEditingLink state doesn't get reset quickly enough.
if (textEditor.isActive('link')) {
try {
const { from, to } = getMarkRange(
textEditor.state.doc.resolve(textEditor.state.selection.from),
textEditor.schema.marks.link as MarkType
) as Range
// Select the entire link if we just clicked on it while in edit mode, but not if there's
// a specific selection.
if (textEditor.state.selection.empty) {
textEditor.commands.setTextSelection({ from, to })
}
} catch {
// Sometimes getMarkRange throws an error when the selection is the entire document.
// This is somewhat mysterious but it's harmless. We just need to ignore it.
// Also, this seems to have recently broken with the React 19 preparation changes.
}
}
}, [textEditor, isEditingLink])
const onEditLinkStart = useCallback(() => {
setIsEditingLink(true)
}, [])
const onEditLinkCancel = useCallback(() => {
setIsEditingLink(false)
}, [])
const onEditLinkClose = useCallback(() => {
setIsEditingLink(false)
if (!textEditor) return
const from = textEditor.state.selection.from
textEditor.commands.setTextSelection({ from, to: from })
}, [textEditor])
return { isEditingLink, onEditLinkStart, onEditLinkClose, onEditLinkCancel }
}
function useIsMousingDownOnTextEditor(textEditor: TiptapEditor) {
const [isMousingDown, setIsMousingDown] = useState(false)
// Set up general event listeners for text selection.
useEffect(() => {
if (!textEditor) return
const handlePointingStateChange = debounce(({ isPointing }: { isPointing: boolean }) => {
setIsMousingDown(isPointing)
}, 16)
const handlePointingDown = () => handlePointingStateChange({ isPointing: true })
const handlePointingUp = () => handlePointingStateChange({ isPointing: false })
const touchDownEvents = ['touchstart', 'pointerdown', 'mousedown']
const touchUpEvents = ['touchend', 'pointerup', 'mouseup']
touchDownEvents.forEach((eventName: string) => {
textEditor.view.dom.addEventListener(eventName, handlePointingDown)
})
const doc = textEditor.view.dom.ownerDocument
touchUpEvents.forEach((eventName: string) => {
doc.body.addEventListener(eventName, handlePointingUp)
})
return () => {
touchDownEvents.forEach((eventName: string) => {
if (textEditor.isInitialized) {
textEditor.view.dom.removeEventListener(eventName, handlePointingDown)
}
})
touchUpEvents.forEach((eventName: string) => {
doc.body.removeEventListener(eventName, handlePointingUp)
})
}
}, [textEditor])
return isMousingDown
}