tldraw
Version:
A tiny little drawing editor.
352 lines (351 loc) • 14.2 kB
JavaScript
;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var DefaultRichTextToolbar_exports = {};
__export(DefaultRichTextToolbar_exports, {
DefaultRichTextToolbar: () => DefaultRichTextToolbar
});
module.exports = __toCommonJS(DefaultRichTextToolbar_exports);
var import_jsx_runtime = require("react/jsx-runtime");
var import_core = require("@tiptap/core");
var import_editor = require("@tldraw/editor");
var import_react = require("react");
var import_useTranslation = require("../../hooks/useTranslation/useTranslation");
var import_TldrawUiContextualToolbar = require("../primitives/TldrawUiContextualToolbar");
var import_DefaultRichTextToolbarContent = require("./DefaultRichTextToolbarContent");
var import_LinkEditor = require("./LinkEditor");
const MOVE_TIMEOUT = 150;
const HIDE_VISIBILITY_TIMEOUT = 16;
const SHOW_VISIBILITY_TIMEOUT = 16;
const TOOLBAR_GAP = 8;
const SCREEN_MARGIN = 16;
const MIN_DISTANCE_TO_REPOSITION_SQUARED = 16 ** 2;
const HIDE_TOOLBAR_WHEN_CAMERA_IS_MOVING = true;
const CHANGE_ONLY_WHEN_Y_CHANGES = true;
const LEFT_ALIGN_TOOLBAR = false;
const DefaultRichTextToolbar = (0, import_editor.track)(function DefaultRichTextToolbar2({
children
}) {
const editor = (0, import_editor.useEditor)();
const textEditor = (0, import_editor.useValue)("textEditor", () => editor.getRichTextEditor(), [editor]);
if (editor.getInstanceState().isCoarsePointer || !textEditor) return null;
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ContextualToolbarInner, { textEditor, children });
});
function ContextualToolbarInner({
textEditor,
children
}) {
const editor = (0, import_editor.useEditor)();
const msg = (0, import_useTranslation.useTranslation)();
const rToolbar = (0, import_react.useRef)(null);
const { isVisible, isInteractive, hide, show, position, move } = useToolbarVisibilityStateMachine();
const { isEditingLink, onEditLinkStart, onEditLinkComplete } = useEditingLinkBehavior(textEditor);
const forcePositionUpdateAtom = (0, import_editor.useAtom)("force toolbar position update", 0);
(0, import_react.useEffect)(
function forceUpdateWhenSelectionUpdates() {
function handleSelectionUpdate() {
forcePositionUpdateAtom.update((t) => t + 1);
}
import_editor.tltime.requestAnimationFrame("first forced update", handleSelectionUpdate);
textEditor.on("selectionUpdate", handleSelectionUpdate);
return () => {
textEditor.off("selectionUpdate", handleSelectionUpdate);
};
},
[textEditor, forcePositionUpdateAtom]
);
(0, import_editor.useReactor)(
"shape change",
function forceUpdateOnNextFrameWhenShapeChanges() {
editor.getEditingShape();
forcePositionUpdateAtom.update((t) => t + 1);
},
[editor]
);
const rCouldShowToolbar = (0, import_react.useRef)(false);
const [hasValidToolbarPosition, setHasValidToolbarPosition] = (0, import_react.useState)(false);
(0, import_editor.useQuickReactor)(
"toolbar position",
function updateToolbarPositionAndDisplay() {
const toolbarElm = rToolbar.current;
if (!toolbarElm) return;
editor.getCamera();
forcePositionUpdateAtom.get();
const position2 = getToolbarScreenPosition(editor, toolbarElm);
if (!position2) {
if (rCouldShowToolbar.current) {
rCouldShowToolbar.current = false;
setHasValidToolbarPosition(false);
}
return;
}
const cameraState2 = editor.getCameraState();
if (cameraState2 === "moving") {
const elm = rToolbar.current;
elm.style.setProperty("transform", `translate(${position2.x}px, ${position2.y}px)`);
} else {
move(position2.x, position2.y);
}
if (!rCouldShowToolbar.current) {
rCouldShowToolbar.current = true;
setHasValidToolbarPosition(true);
}
},
[editor, textEditor, forcePositionUpdateAtom]
);
const cameraState = (0, import_editor.useValue)("camera state", () => editor.getCameraState(), [editor]);
const isMousingDown = useIsMousingDownOnTextEditor(textEditor);
(0, import_react.useEffect)(() => {
if (cameraState === "moving" && HIDE_TOOLBAR_WHEN_CAMERA_IS_MOVING) {
hide(true);
return;
}
if (isMousingDown || !hasValidToolbarPosition) {
hide();
return;
}
show();
}, [hasValidToolbarPosition, cameraState, isMousingDown, show, hide]);
(0, import_react.useLayoutEffect)(() => {
const elm = rToolbar.current;
if (!elm) return;
elm.dataset.visible = `${isVisible}`;
}, [isVisible, position]);
(0, import_react.useLayoutEffect)(() => {
const elm = rToolbar.current;
if (!elm) return;
elm.style.setProperty("transform", `translate(${position.x}px, ${position.y}px)`);
}, [position]);
(0, import_react.useLayoutEffect)(() => {
const elm = rToolbar.current;
if (!elm) return;
elm.dataset.interactive = `${isInteractive}`;
}, [isInteractive]);
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
import_TldrawUiContextualToolbar.TldrawUiContextualToolbar,
{
ref: rToolbar,
className: "tlui-rich-text__toolbar",
"data-interactive": false,
"data-visible": false,
label: msg("tool.rich-text-toolbar-title"),
children: children ? children : isEditingLink ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
import_LinkEditor.LinkEditor,
{
textEditor,
value: textEditor.isActive("link") ? textEditor.getAttributes("link").href : "",
onComplete: onEditLinkComplete
}
) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_DefaultRichTextToolbarContent.DefaultRichTextToolbarContent, { textEditor, onEditLinkStart })
}
);
}
function rectToBox(rect) {
return new import_editor.Box(rect.x, rect.y, rect.width, rect.height);
}
function getToolbarScreenPosition(editor, toolbarElm) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
const rangeBoxes = [];
for (let i = 0; i < selection.rangeCount; i++) {
const range = selection.getRangeAt(i);
rangeBoxes.push(rectToBox(range.getBoundingClientRect()));
}
const selectionBounds = import_editor.Box.Common(rangeBoxes);
const vsb = editor.getViewportScreenBounds();
selectionBounds.x -= vsb.x;
selectionBounds.y -= vsb.y;
if (selectionBounds.midY < SCREEN_MARGIN || selectionBounds.midY > vsb.h - SCREEN_MARGIN || selectionBounds.midX < SCREEN_MARGIN || selectionBounds.midX > vsb.w - SCREEN_MARGIN) {
return;
}
const toolbarBounds = rectToBox(toolbarElm.getBoundingClientRect());
if (!toolbarBounds.width || !toolbarBounds.height) return;
const { scrollLeft, scrollTop } = editor.getContainer();
let x = LEFT_ALIGN_TOOLBAR ? selectionBounds.x : selectionBounds.midX - toolbarBounds.w / 2;
let y = selectionBounds.y - toolbarBounds.h - TOOLBAR_GAP;
x = (0, import_editor.clamp)(x, SCREEN_MARGIN, vsb.w - toolbarBounds.w - SCREEN_MARGIN);
y = (0, import_editor.clamp)(y, SCREEN_MARGIN, vsb.h - toolbarBounds.h - SCREEN_MARGIN);
x += scrollLeft;
y += scrollTop;
x = Math.round(x);
y = Math.round(y);
return { x, y };
}
function useEditingLinkBehavior(textEditor) {
const [isEditingLink, setIsEditingLink] = (0, import_react.useState)(false);
(0, import_react.useEffect)(() => {
if (!textEditor) {
setIsEditingLink(false);
return;
}
const handleClick = () => {
const isLinkActive = textEditor.isActive("link");
setIsEditingLink(isLinkActive);
};
textEditor.view.dom.addEventListener("click", handleClick);
return () => {
textEditor.view.dom.removeEventListener("click", handleClick);
};
}, [textEditor, isEditingLink]);
(0, import_react.useEffect)(() => {
if (!textEditor) {
return;
}
if (textEditor.isActive("link")) {
try {
const { from, to } = (0, import_core.getMarkRange)(
textEditor.state.doc.resolve(textEditor.state.selection.from),
textEditor.schema.marks.link
);
if (textEditor.state.selection.empty) {
textEditor.commands.setTextSelection({ from, to });
}
} catch {
}
}
}, [textEditor, isEditingLink]);
const onEditLinkStart = (0, import_react.useCallback)(() => {
setIsEditingLink(true);
}, []);
const onEditLinkCancel = (0, import_react.useCallback)(() => {
setIsEditingLink(false);
}, []);
const onEditLinkComplete = (0, import_react.useCallback)(() => {
setIsEditingLink(false);
if (!textEditor) return;
const from = textEditor.state.selection.from;
textEditor.commands.setTextSelection({ from, to: from });
}, [textEditor]);
return { isEditingLink, onEditLinkStart, onEditLinkComplete, onEditLinkCancel };
}
function sufficientlyDistant(curr, next) {
if (CHANGE_ONLY_WHEN_Y_CHANGES) {
return import_editor.Vec.Sub(next, curr).y ** 2 >= MIN_DISTANCE_TO_REPOSITION_SQUARED;
}
return import_editor.Vec.Len2(import_editor.Vec.Sub(next, curr)) >= MIN_DISTANCE_TO_REPOSITION_SQUARED;
}
function useToolbarVisibilityStateMachine() {
const editor = (0, import_editor.useEditor)();
const rState = (0, import_react.useRef)({ name: "hidden" });
const [isInteractive, setIsInteractive] = (0, import_react.useState)(false);
const [isVisible, setIsVisible] = (0, import_react.useState)(false);
const [position, setPosition] = (0, import_react.useState)({ x: -1e3, y: -1e3 });
const rCurrPosition = (0, import_react.useRef)(new import_editor.Vec(-1e3, -1e3));
const rNextPosition = (0, import_react.useRef)(new import_editor.Vec(-1e3, -1e3));
const rStableVisibilityTimeout = (0, import_react.useRef)(-1);
const rStablePositionTimeout = (0, import_react.useRef)(-1);
const move = (0, import_react.useCallback)(
(x, y) => {
rNextPosition.current.x = x;
rNextPosition.current.y = y;
if (rState.current.name === "hidden" || rState.current.name === "showing") return;
clearTimeout(rStablePositionTimeout.current);
rStablePositionTimeout.current = editor.timers.setTimeout(() => {
if (rState.current.name === "shown" && sufficientlyDistant(rNextPosition.current, rCurrPosition.current)) {
const { x: x2, y: y2 } = rNextPosition.current;
rCurrPosition.current = new import_editor.Vec(x2, y2);
setPosition({ x: x2, y: y2 });
}
}, MOVE_TIMEOUT);
},
[editor]
);
const hide = (0, import_react.useCallback)(
(immediate = false) => {
switch (rState.current.name) {
case "showing": {
clearTimeout(rStableVisibilityTimeout.current);
rState.current = { name: "hidden" };
break;
}
case "shown": {
rState.current = { name: "hiding" };
setIsInteractive(false);
if (immediate) {
rState.current = { name: "hidden" };
setIsVisible(false);
} else {
rStableVisibilityTimeout.current = editor.timers.setTimeout(() => {
rState.current = { name: "hidden" };
setIsVisible(false);
}, HIDE_VISIBILITY_TIMEOUT);
}
break;
}
default: {
}
}
},
[editor]
);
const show = (0, import_react.useCallback)(() => {
switch (rState.current.name) {
case "hidden": {
rState.current = { name: "showing" };
rStableVisibilityTimeout.current = editor.timers.setTimeout(() => {
const { x, y } = rNextPosition.current;
rCurrPosition.current = new import_editor.Vec(x, y);
setPosition({ x, y });
rState.current = { name: "shown" };
setIsVisible(true);
setIsInteractive(true);
}, SHOW_VISIBILITY_TIMEOUT);
break;
}
case "hiding": {
clearTimeout(rStableVisibilityTimeout.current);
rState.current = { name: "shown" };
setIsInteractive(true);
move(rNextPosition.current.x, rNextPosition.current.y);
break;
}
default: {
}
}
}, [editor, move]);
return { isVisible, isInteractive, show, hide, move, position };
}
function useIsMousingDownOnTextEditor(textEditor) {
const [isMousingDown, setIsMousingDown] = (0, import_react.useState)(false);
(0, import_react.useEffect)(() => {
if (!textEditor) return;
const handlePointingStateChange = (0, import_editor.debounce)(({ isPointing }) => {
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) => {
textEditor.view.dom.addEventListener(eventName, handlePointingDown);
});
touchUpEvents.forEach((eventName) => {
document.body.addEventListener(eventName, handlePointingUp);
});
return () => {
touchDownEvents.forEach((eventName) => {
textEditor.view.dom.removeEventListener(eventName, handlePointingDown);
});
touchUpEvents.forEach((eventName) => {
document.body.removeEventListener(eventName, handlePointingUp);
});
};
}, [textEditor]);
return isMousingDown;
}
//# sourceMappingURL=DefaultRichTextToolbar.js.map