import { MAX_PAGES, PageRecordType, TLPageId, releasePointerCapture, setPointerCapture, useEditor, useValue, } from '@tldraw/editor' import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { PORTRAIT_BREAKPOINT } from '../../constants' import { useBreakpoint } from '../../context/breakpoints' import { useMenuIsOpen } from '../../hooks/useMenuIsOpen' import { useReadonly } from '../../hooks/useReadonly' import { useTranslation } from '../../hooks/useTranslation/useTranslation' import { TldrawUiButton } from '../primitives/Button/TldrawUiButton' import { TldrawUiButtonCheck } from '../primitives/Button/TldrawUiButtonCheck' import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon' import { TldrawUiButtonLabel } from '../primitives/Button/TldrawUiButtonLabel' import { TldrawUiPopover, TldrawUiPopoverContent, TldrawUiPopoverTrigger, } from '../primitives/TldrawUiPopover' import { PageItemInput } from './PageItemInput' import { PageItemSubmenu } from './PageItemSubmenu' import { onMovePage } from './edit-pages-shared' /** @public */ export const DefaultPageMenu = memo(function DefaultPageMenu() { const editor = useEditor() 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]) // When in readonly mode, we don't allow a user to edit the pages const isReadonlyMode = useReadonly() // If the user has reached the max page count, we disable the "add page" button const maxPageCountReached = useValue( 'maxPageCountReached', () => editor.getPages().length >= MAX_PAGES, [editor] ) const isCoarsePointer = useValue( 'isCoarsePointer', () => editor.getInstanceState().isCoarsePointer, [editor] ) // The component has an "editing state" that may be toggled to expose additional controls const [isEditing, setIsEditing] = useState(false) const toggleEditing = useCallback(() => { if (isReadonlyMode) return setIsEditing((s) => !s) }, [isReadonlyMode]) const rMutables = useRef({ isPointing: false, status: 'idle' as 'idle' | 'pointing' | 'dragging', pointing: null as { id: string; index: number } | 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 }]) ) ) // Update the sortable position items when the pages change useLayoutEffect(() => { setSortablePositionItems( Object.fromEntries( pages.map((page, i) => [page.id, { y: i * ITEM_HEIGHT, offsetY: 0, isSelected: false }]) ) ) }, [ITEM_HEIGHT, pages]) // Scroll the current page into view when the menu opens / when current page changes useEffect(() => { if (!isOpen) return requestAnimationFrame(() => { const elm = document.querySelector( `[data-testid="page-menu-item-${currentPageId}"]` ) as HTMLDivElement if (elm) { const container = rSortableContainer.current if (!container) return // Scroll into view is slightly borked on iOS Safari // if top of less than top cuttoff, scroll into view at top const elmTopPosition = elm.offsetTop const containerScrollTopPosition = container.scrollTop if (elmTopPosition < containerScrollTopPosition) { container.scrollTo({ top: elmTopPosition }) } // if bottom position is greater than bottom cutoff, scroll into view at bottom const elmBottomPosition = elmTopPosition + ITEM_HEIGHT const containerScrollBottomPosition = container.scrollTop + container.offsetHeight if (elmBottomPosition > containerScrollBottomPosition) { container.scrollTo({ top: elmBottomPosition - container.offsetHeight }) } } }) }, [ITEM_HEIGHT, currentPageId, isOpen]) const handlePointerDown = useCallback( (e: React.PointerEvent) => { 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: React.PointerEvent) => { 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: React.PointerEvent) => { const mut = rMutables.current if (mut.status === 'dragging') { const { id, index } = mut.pointing! onMovePage(editor, id as TLPageId, index, mut.dragIndex) } releasePointerCapture(e.currentTarget, e) mut.status = 'idle' }, [editor] ) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { const mut = rMutables.current // bail on escape 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.mark('creating page') const newPageId = PageRecordType.createId() editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId }) editor.setCurrentPage(newPageId) setIsEditing(true) }, [editor, msg, isReadonlyMode]) return (
{currentPage.name}
{msg('page-menu.title')}
{!isReadonlyMode && (
)}
{pages.map((page, index) => { const position = sortablePositionItems[page.id] ?? { position: index * 40, offsetY: 0, } return isEditing ? (
{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! { const name = window.prompt('Rename page', page.name) if (name && name !== page.name) { editor.renamePage(page.id, name) } }} onDoubleClick={toggleEditing} > {page.name} ) : (
)} {!isReadonlyMode && (
)}
) : (
editor.setCurrentPage(page.id)} onDoubleClick={toggleEditing} title={msg('page-menu.go-to-page')} > {page.name} {!isReadonlyMode && (
{ if (editor.environment.isIos) { const name = window.prompt('Rename page', page.name) if (name && name !== page.name) { editor.renamePage(page.id, name) } } else { setIsEditing(true) editor.setCurrentPage(page.id) } }} />
)}
) })}
) })