tldraw
Version:
A tiny little drawing editor.
219 lines (218 loc) • 6.74 kB
JavaScript
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