import { EditorView } from '@tiptap/pm/view' import { EditorEvents, JSONContent, Editor as TextEditor, type Editor as TTEditor, } from '@tiptap/react' import { Editor, TLRichText, TLShapeId, isEqual, preventDefault, useEditor, useEvent, useUniqueSafeId, } from '@tldraw/editor' import React, { useLayoutEffect, useRef } from 'react' /** @public */ export interface TextAreaProps { isEditing: boolean text?: string shapeId: TLShapeId richText?: TLRichText handleFocus(): void handleBlur(): void handleKeyDown(e: KeyboardEvent): void handleChange(changeInfo: { plaintext?: string; richText?: TLRichText }): void handleInputPointerDown(e: React.PointerEvent): void handleDoubleClick(e: any): any handlePaste(e: ClipboardEvent | React.ClipboardEvent): void hasCustomTabBehavior?: boolean } /** * N.B. In Development mode you need to ensure you're testing this without StrictMode on. * Otherwise it's not gonna work as expected on iOS. * Specifically, it means that the virtual keyboard won't pop open sometimes * (iOS starts flipping out when you render multiple times when trying to focus something) . */ /** * A rich text area that can be used for editing text with rich text formatting. * This component uses the TipTap editor under the hood. * * @public @react */ export const RichTextArea = React.forwardRef(function RichTextArea( { shapeId, isEditing, richText, handleFocus, handleChange, handleBlur, handleKeyDown, handleDoubleClick, hasCustomTabBehavior, handlePaste, }, ref ) { const editor = useEditor() const tipTapId = useUniqueSafeId('tip-tap-editor') const tipTapConfig = editor.getTextOptions().tipTapConfig const rInitialRichText = useRef(richText) const rTextEditor = useRef(null) const rTextEditorEl = useRef(null) useLayoutEffect(() => { if (!rTextEditor.current) { rInitialRichText.current = richText } else if (!isEqual(rInitialRichText.current, richText)) { rTextEditor.current.commands.setContent(richText as JSONContent) } }, [richText]) // The order of events is: // - editor begins editing any shape // - we set listeners for select all / place caret events // - if the user is editing this shape, this component is rendered // - editor emits the select all event / place caret event // - the text editor is onCreate callback is called const rCreateInfo = useRef({ selectAll: false, caretPosition: null as { x: number; y: number } | null, }) useLayoutEffect(() => { function selectAllIfEditing(event: { shapeId: TLShapeId }) { if (event.shapeId === editor.getEditingShapeId()) { rCreateInfo.current.selectAll = true } } function placeCaret(event: { shapeId: TLShapeId; point: { x: number; y: number } }) { if (event.shapeId === editor.getEditingShapeId()) { rCreateInfo.current.caretPosition = event.point } } editor.on('select-all-text', selectAllIfEditing) editor.on('place-caret', placeCaret) return () => { editor.off('select-all-text', selectAllIfEditing) editor.off('place-caret', placeCaret) } }, [editor, isEditing]) const onChange = useEvent(handleChange) const onKeyDown = useEvent(handleKeyDown) const onFocus = useEvent(handleFocus) const onBlur = useEvent(handleBlur) const onDoubleClick = useEvent(handleDoubleClick) const onPaste = useEvent(handlePaste) useLayoutEffect(() => { if (!isEditing || !tipTapConfig || !rTextEditorEl.current) return const { editorProps, ...restOfTipTapConfig } = tipTapConfig // Because React can double-render in Strict Mode, we need to make sure we're not setting // the text editor twice. This became more much more prevalent in React 19, but also it // started manifesting in some cases in Next 14.2 (which maybe patches React 18.3 in weird // ways). So we used to use EditorProvider but we into weird rendering issues. const textEditorInstance = new TextEditor({ element: rTextEditorEl.current, autofocus: true, editable: isEditing, onUpdate: (props: EditorEvents['update']) => { const content: TLRichText = props.editor.state.doc.toJSON() rInitialRichText.current = content onChange({ richText: content }) }, onFocus, onBlur, // onCreate is called after a `setTimeout(0)` onCreate: (props) => { // If we're not still editing the original shape, then don't do anything. if (editor.getEditingShapeId() !== shapeId) return const textEditor = props.editor editor.setRichTextEditor(textEditor) const { selectAll, caretPosition } = rCreateInfo.current if (selectAll) { // Select all of the text textEditor.chain().focus().selectAll().run() } else if (caretPosition) { // Set the initial caret screen position const pos = textEditor.view.posAtCoords({ left: caretPosition.x, top: caretPosition.y, })?.pos if (pos) { // Focus to that position. textEditor.chain().focus().setTextSelection(pos).run() } else { // If no position, default to select all. textEditor.chain().focus().selectAll().run() } } }, editorProps: { handleKeyDown: (view: EditorView, event: KeyboardEvent) => { if (!hasCustomTabBehavior && event.key === 'Tab') { handleTab(editor, view, event) } onKeyDown(event) }, handlePaste: (view: EditorView, event: ClipboardEvent) => { onPaste(event) if (event.defaultPrevented) return true return false }, handleDoubleClick: (_view, _pos, event) => onDoubleClick(event), ...editorProps, }, coreExtensionOptions: { clipboardTextSerializer: { blockSeparator: '\n', }, }, // N.B. We disable the text direction in the core list here, // but we add it back in again in our own extensions list so that // people can omit/override it if they want to. enableCoreExtensions: { textDirection: false }, textDirection: 'auto', ...restOfTipTapConfig, content: rInitialRichText.current as JSONContent, }) // XXX: When creating a brand new shape and double-clicking into it quickly to edit it, // there's some kind of race condition happening where the editor doesn't focus properly. const timeout = editor.timers.setTimeout(() => { if (rCreateInfo.current.caretPosition || rCreateInfo.current.selectAll) { textEditorInstance.commands.focus() } else { textEditorInstance.commands.focus('end') } rCreateInfo.current.selectAll = false rCreateInfo.current.caretPosition = null }, 100) rTextEditor.current = textEditorInstance return () => { rTextEditor.current = null clearTimeout(timeout) textEditorInstance.destroy() } }, [ isEditing, tipTapConfig, onFocus, onBlur, onDoubleClick, onChange, onPaste, onKeyDown, editor, shapeId, hasCustomTabBehavior, ]) if (!isEditing || !tipTapConfig) { return null } return (
e.stopPropagation() : undefined} // N.B. When PointerStateExtension was introduced, this was moved there. // However, that caused selecting over list items to break. // The handleDOMEvents in TipTap don't seem to support the pointerDownCapture event. onPointerDownCapture={(e) => e.stopPropagation()} // This onTouchEnd is important for Android to be able to change selection on text. onTouchEnd={(e) => e.stopPropagation()} // On FF, there's a behavior where dragging a selection will grab that selection into // the drag event. However, once the drag is over, and you select away from the textarea, // starting a drag over the textarea will restart a selection drag instead of a shape drag. // This prevents that default behavior in FF. onDragStart={preventDefault} >
) }) // Prevent exiting the editor when hitting Tab. // Also, insert a tab character at the front of the line if the shift key isn't pressed, // otherwise if shift is pressed, remove a tab character from the front of the line. function handleTab(editor: Editor, view: EditorView, event: KeyboardEvent) { // Don't exit the editor. event.preventDefault() const textEditor = editor.getRichTextEditor() if (textEditor?.isActive('bulletList') || textEditor?.isActive('orderedList')) return const { state, dispatch } = view const { $from, $to } = state.selection const isShift = event.shiftKey // Create a new transaction let tr = state.tr // Iterate over each line in the selection in reverse so that the positions // are stable as we modify the document. let pos = $to.end() while (pos >= $from.start()) { const line = state.doc.resolve(pos).blockRange() if (!line) break const lineStart = line.start const lineEnd = line.end const lineText = state.doc.textBetween(lineStart, lineEnd, '\n') // Check if the current line or any of its parent nodes are part of a list let isInList = false state.doc.nodesBetween(lineStart, lineEnd, (node) => { if (node.type.name === 'bulletList' || node.type.name === 'orderedList') { isInList = true return false // Stop iteration } return true }) // TODO: for now skip over lists. Later, we might consider handling them using // sinkListItem and liftListItem from @tiptap/pm/schema-list if (!isInList) { if (!isShift) { // Insert a tab character at the start of the line tr = tr.insertText('\t', lineStart + 1) } else { // Remove a tab character from the start of the line if (lineText.startsWith('\t')) { tr = tr.delete(lineStart + 1, lineStart + 2) } } } pos = lineStart - 1 } const mappedSelection = state.selection.map(tr.doc, tr.mapping) tr.setSelection(mappedSelection) if (tr.docChanged) { dispatch(tr) } }