tldraw
Version:
A tiny little drawing editor.
410 lines (409 loc) • 16.7 kB
JavaScript
import { jsx, jsxs } from "react/jsx-runtime";
import {
PageRecordType,
releasePointerCapture,
setPointerCapture,
tlenv,
useEditor,
useValue
} from "@tldraw/editor";
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { PORTRAIT_BREAKPOINT } from "../../constants.mjs";
import { useBreakpoint } from "../../context/breakpoints.mjs";
import { useUiEvents } from "../../context/events.mjs";
import { useMenuIsOpen } from "../../hooks/useMenuIsOpen.mjs";
import { useReadonly } from "../../hooks/useReadonly.mjs";
import { useTranslation } from "../../hooks/useTranslation/useTranslation.mjs";
import { TldrawUiButton } from "../primitives/Button/TldrawUiButton.mjs";
import { TldrawUiButtonCheck } from "../primitives/Button/TldrawUiButtonCheck.mjs";
import { TldrawUiButtonIcon } from "../primitives/Button/TldrawUiButtonIcon.mjs";
import { TldrawUiButtonLabel } from "../primitives/Button/TldrawUiButtonLabel.mjs";
import {
TldrawUiPopover,
TldrawUiPopoverContent,
TldrawUiPopoverTrigger
} from "../primitives/TldrawUiPopover.mjs";
import { PageItemInput } from "./PageItemInput.mjs";
import { PageItemSubmenu } from "./PageItemSubmenu.mjs";
import { onMovePage } from "./edit-pages-shared.mjs";
const DefaultPageMenu = memo(function DefaultPageMenu2() {
const editor = useEditor();
const trackEvent = useUiEvents();
const msg = useTranslation();
const breakpoint = useBreakpoint();
const handleOpenChange = useCallback(() => setIsEditing(false), []);
const [isOpen, onOpenChange] = useMenuIsOpen("page-menu", handleOpenChange);
const ITEM_HEIGHT = 36;
const rSortableContainer = useRef(null);
const pages = useValue("pages", () => editor.getPages(), [editor]);
const currentPage = useValue("currentPage", () => editor.getCurrentPage(), [editor]);
const currentPageId = useValue("currentPageId", () => editor.getCurrentPageId(), [editor]);
const isReadonlyMode = useReadonly();
const maxPageCountReached = useValue(
"maxPageCountReached",
() => editor.getPages().length >= editor.options.maxPages,
[editor]
);
const isCoarsePointer = useValue(
"isCoarsePointer",
() => editor.getInstanceState().isCoarsePointer,
[editor]
);
const [isEditing, setIsEditing] = useState(false);
const toggleEditing = useCallback(() => {
if (isReadonlyMode) return;
setIsEditing((s) => !s);
}, [isReadonlyMode]);
const rMutables = useRef({
isPointing: false,
status: "idle",
pointing: null,
startY: 0,
startIndex: 0,
dragIndex: 0
});
const [sortablePositionItems, setSortablePositionItems] = useState(
Object.fromEntries(
pages.map((page, i) => [page.id, { y: i * ITEM_HEIGHT, offsetY: 0, isSelected: false }])
)
);
useLayoutEffect(() => {
setSortablePositionItems(
Object.fromEntries(
pages.map((page, i) => [page.id, { y: i * ITEM_HEIGHT, offsetY: 0, isSelected: false }])
)
);
}, [ITEM_HEIGHT, pages]);
useEffect(() => {
if (!isOpen) return;
editor.timers.requestAnimationFrame(() => {
const elm = document.querySelector(
`[data-testid="page-menu-item-${currentPageId}"]`
);
if (elm) {
const container = rSortableContainer.current;
if (!container) return;
const elmTopPosition = elm.offsetTop;
const containerScrollTopPosition = container.scrollTop;
if (elmTopPosition < containerScrollTopPosition) {
container.scrollTo({ top: elmTopPosition });
}
const elmBottomPosition = elmTopPosition + ITEM_HEIGHT;
const containerScrollBottomPosition = container.scrollTop + container.offsetHeight;
if (elmBottomPosition > containerScrollBottomPosition) {
container.scrollTo({ top: elmBottomPosition - container.offsetHeight });
}
}
});
}, [ITEM_HEIGHT, currentPageId, isOpen, editor]);
const handlePointerDown = useCallback(
(e) => {
const { clientY, currentTarget } = e;
const {
dataset: { id, index }
} = currentTarget;
if (!id || !index) return;
const mut = rMutables.current;
setPointerCapture(e.currentTarget, e);
mut.status = "pointing";
mut.pointing = { id, index: +index };
const current = sortablePositionItems[id];
const dragY = current.y;
mut.startY = clientY;
mut.startIndex = Math.max(0, Math.min(Math.round(dragY / ITEM_HEIGHT), pages.length - 1));
},
[ITEM_HEIGHT, pages.length, sortablePositionItems]
);
const handlePointerMove = useCallback(
(e) => {
const mut = rMutables.current;
if (mut.status === "pointing") {
const { clientY } = e;
const offset = clientY - mut.startY;
if (Math.abs(offset) > 5) {
mut.status = "dragging";
}
}
if (mut.status === "dragging") {
const { clientY } = e;
const offsetY = clientY - mut.startY;
const current = sortablePositionItems[mut.pointing.id];
const { startIndex, pointing } = mut;
const dragY = current.y + offsetY;
const dragIndex = Math.max(0, Math.min(Math.round(dragY / ITEM_HEIGHT), pages.length - 1));
const next = { ...sortablePositionItems };
next[pointing.id] = {
y: current.y,
offsetY,
isSelected: true
};
if (dragIndex !== mut.dragIndex) {
mut.dragIndex = dragIndex;
for (let i = 0; i < pages.length; i++) {
const item = pages[i];
if (item.id === mut.pointing.id) {
continue;
}
let { y } = next[item.id];
if (dragIndex === startIndex) {
y = i * ITEM_HEIGHT;
} else if (dragIndex < startIndex) {
if (dragIndex <= i && i < startIndex) {
y = (i + 1) * ITEM_HEIGHT;
} else {
y = i * ITEM_HEIGHT;
}
} else if (dragIndex > startIndex) {
if (dragIndex >= i && i > startIndex) {
y = (i - 1) * ITEM_HEIGHT;
} else {
y = i * ITEM_HEIGHT;
}
}
if (y !== next[item.id].y) {
next[item.id] = { y, offsetY: 0, isSelected: true };
}
}
}
setSortablePositionItems(next);
}
},
[ITEM_HEIGHT, pages, sortablePositionItems]
);
const handlePointerUp = useCallback(
(e) => {
const mut = rMutables.current;
if (mut.status === "dragging") {
const { id, index } = mut.pointing;
onMovePage(editor, id, index, mut.dragIndex, trackEvent);
}
releasePointerCapture(e.currentTarget, e);
mut.status = "idle";
},
[editor, trackEvent]
);
const handleKeyDown = useCallback(
(e) => {
const mut = rMutables.current;
if (e.key === "Escape") {
if (mut.status === "dragging") {
setSortablePositionItems(
Object.fromEntries(
pages.map((page, i) => [
page.id,
{ y: i * ITEM_HEIGHT, offsetY: 0, isSelected: false }
])
)
);
}
mut.status = "idle";
}
},
[ITEM_HEIGHT, pages]
);
const handleCreatePageClick = useCallback(() => {
if (isReadonlyMode) return;
editor.run(() => {
editor.markHistoryStoppingPoint("creating page");
const newPageId = PageRecordType.createId();
editor.createPage({ name: msg("page-menu.new-page-initial-name"), id: newPageId });
editor.setCurrentPage(newPageId);
setIsEditing(true);
});
trackEvent("new-page", { source: "page-menu" });
}, [editor, msg, isReadonlyMode, trackEvent]);
const changePage = useCallback(
(id) => {
editor.setCurrentPage(id);
trackEvent("change-page", { source: "page-menu" });
},
[editor, trackEvent]
);
const renamePage = useCallback(
(id, name) => {
editor.renamePage(id, name);
trackEvent("rename-page", { source: "page-menu" });
},
[editor, trackEvent]
);
return (
/* @__PURE__ */ jsxs(TldrawUiPopover, { id: "pages", onOpenChange, open: isOpen, children: [
/* @__PURE__ */ jsx(TldrawUiPopoverTrigger, { "data-testid": "main.page-menu", children: /* @__PURE__ */ jsxs(
TldrawUiButton,
{
type: "menu",
title: currentPage.name,
"data-testid": "page-menu.button",
className: "tlui-page-menu__trigger",
children: [
/* @__PURE__ */ jsx("div", { className: "tlui-page-menu__name", children: currentPage.name }),
/* @__PURE__ */ jsx(TldrawUiButtonIcon, { icon: "chevron-down", small: true })
]
}
) }),
/* @__PURE__ */ jsx(
TldrawUiPopoverContent,
{
side: "bottom",
align: "start",
sideOffset: 6,
disableEscapeKeyDown: isEditing,
children: /* @__PURE__ */ jsxs("div", { className: "tlui-page-menu__wrapper", children: [
/* @__PURE__ */ jsxs("div", { className: "tlui-page-menu__header", children: [
/* @__PURE__ */ jsx("div", { className: "tlui-page-menu__header__title", children: msg("page-menu.title") }),
!isReadonlyMode && /* @__PURE__ */ jsxs("div", { className: "tlui-buttons__horizontal", children: [
/* @__PURE__ */ jsx(
TldrawUiButton,
{
type: "icon",
"data-testid": "page-menu.edit",
title: msg(isEditing ? "page-menu.edit-done" : "page-menu.edit-start"),
onClick: toggleEditing,
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { icon: isEditing ? "check" : "edit" })
}
),
/* @__PURE__ */ jsx(
TldrawUiButton,
{
type: "icon",
"data-testid": "page-menu.create",
title: msg(
maxPageCountReached ? "page-menu.max-page-count-reached" : "page-menu.create-new-page"
),
disabled: maxPageCountReached,
onClick: handleCreatePageClick,
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { icon: "plus" })
}
)
] })
] }),
/* @__PURE__ */ jsx(
"div",
{
"data-testid": "page-menu.list",
className: "tlui-page-menu__list tlui-menu__group",
style: { height: ITEM_HEIGHT * pages.length + 4 },
ref: rSortableContainer,
children: pages.map((page, index) => {
const position = sortablePositionItems[page.id] ?? {
position: index * 40,
offsetY: 0
};
return isEditing ? /* @__PURE__ */ jsxs(
"div",
{
"data-testid": "page-menu.item",
className: "tlui-page_menu__item__sortable",
style: {
zIndex: page.id === currentPage.id ? 888 : index,
transform: `translate(0px, ${position.y + position.offsetY}px)`
},
children: [
/* @__PURE__ */ jsx(
TldrawUiButton,
{
type: "icon",
tabIndex: -1,
className: "tlui-page_menu__item__sortable__handle",
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
onPointerMove: handlePointerMove,
onKeyDown: handleKeyDown,
"data-id": page.id,
"data-index": index,
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { icon: "drag-handle-dots" })
}
),
breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM && isCoarsePointer ? (
// sigh, this is a workaround for iOS Safari
// because the device and the radix popover seem
// to be fighting over scroll position. Nothing
// else seems to work!
/* @__PURE__ */ (jsxs(TldrawUiButton, {
type: "normal",
className: "tlui-page-menu__item__button",
onClick: () => {
const name = window.prompt("Rename page", page.name);
if (name && name !== page.name) {
renamePage(page.id, name);
}
},
onDoubleClick: toggleEditing,
children: [
/* @__PURE__ */ jsx(TldrawUiButtonCheck, { checked: page.id === currentPage.id }),
/* @__PURE__ */ jsx(TldrawUiButtonLabel, { children: page.name })
]
}))
) : /* @__PURE__ */ jsx(
"div",
{
className: "tlui-page_menu__item__sortable__title",
style: { height: ITEM_HEIGHT },
children: /* @__PURE__ */ jsx(
PageItemInput,
{
id: page.id,
name: page.name,
isCurrentPage: page.id === currentPage.id,
onCancel: () => {
setIsEditing(false);
editor.menus.clearOpenMenus();
}
}
)
}
),
!isReadonlyMode && /* @__PURE__ */ jsx("div", { className: "tlui-page_menu__item__submenu", "data-isediting": isEditing, children: /* @__PURE__ */ jsx(PageItemSubmenu, { index, item: page, listSize: pages.length }) })
]
},
page.id + "_editing"
) : /* @__PURE__ */ jsxs("div", { "data-testid": "page-menu.item", className: "tlui-page-menu__item", children: [
/* @__PURE__ */ jsxs(
TldrawUiButton,
{
type: "normal",
className: "tlui-page-menu__item__button",
onClick: () => changePage(page.id),
onDoubleClick: toggleEditing,
title: msg("page-menu.go-to-page"),
children: [
/* @__PURE__ */ jsx(TldrawUiButtonCheck, { checked: page.id === currentPage.id }),
/* @__PURE__ */ jsx(TldrawUiButtonLabel, { children: page.name })
]
}
),
!isReadonlyMode && /* @__PURE__ */ jsx("div", { className: "tlui-page_menu__item__submenu", children: /* @__PURE__ */ jsx(
PageItemSubmenu,
{
index,
item: page,
listSize: pages.length,
onRename: () => {
if (tlenv.isIos) {
const name = window.prompt("Rename page", page.name);
if (name && name !== page.name) {
renamePage(page.id, name);
}
} else {
setIsEditing(true);
if (currentPageId !== page.id) {
changePage(page.id);
}
}
}
}
) })
] }, page.id);
})
}
)
] })
}
)
] })
);
});
export {
DefaultPageMenu
};
//# sourceMappingURL=DefaultPageMenu.mjs.map