UNPKG

tldraw

Version:

A tiny little drawing editor.

219 lines (218 loc) • 6.74 kB
import { jsx } from "react/jsx-runtime"; import { Editor as TextEditor } from "@tiptap/react"; import { isEqual, preventDefault, useEditor, useEvent, useUniqueSafeId } from "@tldraw/editor"; import React, { useLayoutEffect, useRef } from "react"; const RichTextArea = React.forwardRef(function RichTextArea2({ 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); } }, [richText]); const rCreateInfo = useRef({ selectAll: false, caretPosition: null }); useLayoutEffect(() => { function selectAllIfEditing(event) { if (event.shapeId === editor.getEditingShapeId()) { rCreateInfo.current.selectAll = true; } } function placeCaret(event) { 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; const textEditorInstance = new TextEditor({ element: rTextEditorEl.current, autofocus: true, editable: isEditing, onUpdate: (props) => { const content = props.editor.state.doc.toJSON(); rInitialRichText.current = content; onChange({ richText: content }); }, onFocus, onBlur, // onCreate is called after a `setTimeout(0)` onCreate: (props) => { if (editor.getEditingShapeId() !== shapeId) return; const textEditor = props.editor; editor.setRichTextEditor(textEditor); const { selectAll, caretPosition } = rCreateInfo.current; if (selectAll) { textEditor.chain().focus().selectAll().run(); } else if (caretPosition) { const pos = textEditor.view.posAtCoords({ left: caretPosition.x, top: caretPosition.y })?.pos; if (pos) { textEditor.chain().focus().setTextSelection(pos).run(); } else { textEditor.chain().focus().selectAll().run(); } } }, editorProps: { handleKeyDown: (view, event) => { if (!hasCustomTabBehavior && event.key === "Tab") { handleTab(editor, view, event); } onKeyDown(event); }, handlePaste: (view, event) => { 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 }); 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 /* @__PURE__ */ jsx( "div", { id: tipTapId, ref, tabIndex: -1, "data-testid": "rich-text-area", className: "tl-rich-text tl-text tl-text-input", onContextMenu: isEditing ? (e) => e.stopPropagation() : void 0, onPointerDownCapture: (e) => e.stopPropagation(), onTouchEnd: (e) => e.stopPropagation(), onDragStart: preventDefault, children: /* @__PURE__ */ jsx("div", { className: "tl-rich-text", ref: rTextEditorEl }) } ); }); function handleTab(editor, view, event) { 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; let tr = state.tr; 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"); let isInList = false; state.doc.nodesBetween(lineStart, lineEnd, (node) => { if (node.type.name === "bulletList" || node.type.name === "orderedList") { isInList = true; return false; } return true; }); if (!isInList) { if (!isShift) { tr = tr.insertText(" ", lineStart + 1); } else { if (lineText.startsWith(" ")) { 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); } } export { RichTextArea }; //# sourceMappingURL=RichTextArea.mjs.map