Tldraw/packages/tldraw/src/lib/ui/components/Toolbar/OverflowingToolbar.tsx

202 wiersze
5.8 KiB
TypeScript

import { preventDefault, useEditor, useEvent, useSafeId } from '@tldraw/editor'
import classNames from 'classnames'
import hotkeys from 'hotkeys-js'
import { createContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { tldrawConstants } from '../../../tldraw-constants'
import { useBreakpoint } from '../../context/breakpoints'
import { areShortcutsDisabled } from '../../hooks/useKeyboardShortcuts'
import { TLUiToolItem } from '../../hooks/useTools'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
import { TldrawUiButton } from '../primitives/Button/TldrawUiButton'
import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon'
import {
TldrawUiDropdownMenuContent,
TldrawUiDropdownMenuRoot,
TldrawUiDropdownMenuTrigger,
} from '../primitives/TldrawUiDropdownMenu'
import { TldrawUiMenuContextProvider } from '../primitives/menus/TldrawUiMenuContext'
const { PORTRAIT_BREAKPOINT } = tldrawConstants
export const IsInOverflowContext = createContext(false)
export function OverflowingToolbar({ children }: { children: React.ReactNode }) {
const editor = useEditor()
const id = useSafeId()
const breakpoint = useBreakpoint()
const msg = useTranslation()
const overflowIndex = Math.min(8, 5 + breakpoint)
const [totalItems, setTotalItems] = useState(0)
const mainToolsRef = useRef<HTMLDivElement>(null)
const [lastActiveOverflowItem, setLastActiveOverflowItem] = useState<string | null>(null)
const css = useMemo(() => {
const showInMainSelectors = []
const hideFromOverflowSelectors = []
if (lastActiveOverflowItem) {
showInMainSelectors.push(`[data-value="${lastActiveOverflowItem}"]`)
} else {
showInMainSelectors.push(`:nth-child(${overflowIndex + 1})`)
}
for (let i = 0; i < overflowIndex; i++) {
showInMainSelectors.push(`:nth-child(${i + 1})`)
hideFromOverflowSelectors.push(`:nth-child(${i + 1})`)
}
return `
#${id}_main > *:not(${showInMainSelectors.join(', ')}) {
display: none;
}
${hideFromOverflowSelectors.map((s) => `#${id}_more > *${s}`).join(', ')} {
display: none;
}
`
}, [lastActiveOverflowItem, id, overflowIndex])
const onDomUpdate = useEvent(() => {
if (!mainToolsRef.current) return
const children = Array.from(mainToolsRef.current.children)
setTotalItems(children.length)
// If the last active overflow item is no longer in the overflow, clear it
const lastActiveElementIdx = children.findIndex(
(el) => el.getAttribute('data-value') === lastActiveOverflowItem
)
if (lastActiveElementIdx <= overflowIndex) {
setLastActiveOverflowItem(null)
}
// But if there's a new active item...
const activeElementIdx = Array.from(mainToolsRef.current.children).findIndex(
(el) => el.getAttribute('aria-checked') === 'true'
)
if (activeElementIdx === -1) return
// ...and it's in the overflow, set it as the last active overflow item
if (activeElementIdx >= overflowIndex) {
setLastActiveOverflowItem(children[activeElementIdx].getAttribute('data-value'))
}
})
useLayoutEffect(() => {
onDomUpdate()
})
useLayoutEffect(() => {
if (!mainToolsRef.current) return
const mutationObserver = new MutationObserver(onDomUpdate)
mutationObserver.observe(mainToolsRef.current, {
childList: true,
subtree: true,
attributeFilter: ['data-value', 'aria-checked'],
})
return () => {
mutationObserver.disconnect()
}
}, [onDomUpdate])
useEffect(() => {
const keys = [
['1', 0],
['2', 1],
['3', 2],
['4', 3],
['5', 4],
['6', 5],
['7', 6],
['8', 7],
['9', 8],
['0', 9],
] as const
for (const [key, index] of keys) {
hotkeys(key, (event) => {
if (areShortcutsDisabled(editor)) return
preventDefault(event)
const relevantEls = Array.from(mainToolsRef.current?.children ?? []).filter(
(el): el is HTMLElement => {
// only count html elements...
if (!(el instanceof HTMLElement)) return false
// ...that are buttons...
if (el.tagName.toLowerCase() !== 'button') return false
// ...that are actually visible
return !!(el.offsetWidth || el.offsetHeight)
}
)
const el = relevantEls[index]
if (el) el.click()
})
}
return () => {
hotkeys.unbind('1,2,3,4,5,6,7,8,9,0')
}
}, [editor])
return (
<>
<style>{css}</style>
<div
className={classNames('tlui-toolbar__tools', {
'tlui-toolbar__tools__mobile': breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM,
})}
role="radiogroup"
>
<div id={`${id}_main`} ref={mainToolsRef} className="tlui-toolbar__tools__list">
<TldrawUiMenuContextProvider type="toolbar" sourceId="toolbar">
{children}
</TldrawUiMenuContextProvider>
</div>
{totalItems > overflowIndex && (
<IsInOverflowContext.Provider value={true}>
<TldrawUiDropdownMenuRoot id="toolbar overflow" modal={false}>
<TldrawUiDropdownMenuTrigger>
<TldrawUiButton
title={msg('tool-panel.more')}
type="tool"
className="tlui-toolbar__overflow"
data-testid="tools.more-button"
>
<TldrawUiButtonIcon icon="chevron-up" />
</TldrawUiButton>
</TldrawUiDropdownMenuTrigger>
<TldrawUiDropdownMenuContent side="top" align="center">
<div
className="tlui-buttons__grid"
data-testid="tools.more-content"
id={`${id}_more`}
>
<TldrawUiMenuContextProvider type="toolbar-overflow" sourceId="toolbar">
{children}
</TldrawUiMenuContextProvider>
</div>
</TldrawUiDropdownMenuContent>
</TldrawUiDropdownMenuRoot>
</IsInOverflowContext.Provider>
)}
</div>
</>
)
}
export const isActiveTLUiToolItem = (
item: TLUiToolItem,
activeToolId: string | undefined,
geoState: string | null | undefined
) => {
return item.meta?.geo
? activeToolId === 'geo' && geoState === item.meta?.geo
: activeToolId === item.id
}