{ "name": "selection-toolbar", "type": "registry:ui", "files": [ { "path": "ui/selection-toolbar.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport {\n autoUpdate,\n flip,\n hide,\n limitShift,\n type Middleware,\n offset,\n type Placement,\n shift,\n size,\n useFloating,\n} from \"@floating-ui/react-dom\";\nimport * as React from \"react\";\nimport * as ReactDOM from \"react-dom\";\nimport { useComposedRefs } from \"@/lib/compose-refs\";\nimport { cn } from \"@/lib/utils\";\nimport { useAsRef } from \"@/registry/bases/base/hooks/use-as-ref\";\nimport { useIsomorphicLayoutEffect } from \"@/registry/bases/base/hooks/use-isomorphic-layout-effect\";\nimport { useLazyRef } from \"@/registry/bases/base/hooks/use-lazy-ref\";\nimport { Button } from \"@/registry/bases/base/ui/button\";\n\nconst ROOT_NAME = \"SelectionToolbar\";\nconst ITEM_NAME = \"SelectionToolbarItem\";\n\nconst SIDE_OPTIONS = [\"top\", \"right\", \"bottom\", \"left\"] as const;\nconst ALIGN_OPTIONS = [\"start\", \"center\", \"end\"] as const;\n\ntype Side = (typeof SIDE_OPTIONS)[number];\ntype Align = (typeof ALIGN_OPTIONS)[number];\ntype Boundary = Element | null;\n\ninterface DivProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction getSideAndAlignFromPlacement(placement: Placement) {\n const [side, align = \"center\"] = placement.split(\"-\");\n return [side as Side, align as Align] as const;\n}\n\nfunction isNotNull(value: T | null): value is T {\n return value !== null;\n}\n\ninterface SelectionRect {\n top: number;\n left: number;\n width: number;\n height: number;\n}\n\ninterface StoreState {\n open: boolean;\n selectedText: string;\n selectionRect: SelectionRect | null;\n}\n\ninterface Store {\n subscribe: (callback: () => void) => () => void;\n getState: () => StoreState;\n setState: (key: K, value: StoreState[K]) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nconst StoreContext = React.createContext(null);\n\nfunction useStoreContext(consumerName: string) {\n const context = React.useContext(StoreContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`${ROOT_NAME}\\``);\n }\n return context;\n}\n\nfunction useStore(\n selector: (state: StoreState) => T,\n ogStore?: Store | null,\n): T {\n const contextStore = React.useContext(StoreContext);\n\n const store = ogStore ?? contextStore;\n\n if (!store) {\n throw new Error(`\\`useStore\\` must be used within \\`${ROOT_NAME}\\``);\n }\n\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface SelectionToolbarProps\n extends Omit,\n useRender.ComponentProps<\"div\"> {\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n onSelectionChange?: (text: string) => void;\n container?: HTMLElement | React.RefObject | null;\n portalContainer?: Element | DocumentFragment | null;\n side?: Side;\n sideOffset?: number;\n align?: Align;\n alignOffset?: number;\n avoidCollisions?: boolean;\n collisionBoundary?: Boundary | Boundary[];\n collisionPadding?: number | Partial>;\n sticky?: \"partial\" | \"always\";\n hideWhenDetached?: boolean;\n updatePositionStrategy?: \"optimized\" | \"always\";\n}\n\nfunction SelectionToolbar(props: SelectionToolbarProps) {\n const {\n open: openProp,\n onOpenChange,\n onSelectionChange,\n container: containerProp,\n portalContainer: portalContainerProp,\n side = \"top\",\n sideOffset = 8,\n align = \"center\",\n alignOffset = 0,\n avoidCollisions = true,\n collisionBoundary = [],\n collisionPadding: collisionPaddingProp = 0,\n sticky = \"partial\",\n hideWhenDetached = false,\n updatePositionStrategy = \"optimized\",\n className,\n style,\n render,\n ...rootProps\n } = props;\n\n const listenersRef = useLazyRef(() => new Set<() => void>());\n const stateRef = useLazyRef(() => ({\n open: openProp ?? false,\n selectedText: \"\",\n selectionRect: null,\n }));\n\n const propsRef = useAsRef({\n onOpenChange,\n onSelectionChange,\n });\n\n const getContainer = React.useCallback((): HTMLElement | null => {\n if (containerProp === undefined || containerProp === null) return null;\n if (typeof containerProp === \"object\" && \"current\" in containerProp) {\n return containerProp.current;\n }\n return containerProp;\n }, [containerProp]);\n\n const store = React.useMemo(() => {\n let isBatching = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n\n if (key === \"open\" && typeof value === \"boolean\") {\n stateRef.current.open = value;\n propsRef.current.onOpenChange?.(value);\n } else if (key === \"selectedText\" && typeof value === \"string\") {\n stateRef.current.selectedText = value;\n propsRef.current.onSelectionChange?.(value);\n } else {\n stateRef.current[key] = value;\n }\n\n if (!isBatching) {\n store.notify();\n }\n },\n notify: () => {\n for (const cb of listenersRef.current) {\n cb();\n }\n },\n batch: (fn: () => void) => {\n if (isBatching) {\n fn();\n return;\n }\n isBatching = true;\n try {\n fn();\n } finally {\n isBatching = false;\n store.notify();\n }\n },\n };\n }, [listenersRef, stateRef, propsRef]);\n\n useIsomorphicLayoutEffect(() => {\n if (openProp !== undefined) {\n store.setState(\"open\", openProp);\n }\n }, [openProp]);\n\n const open = useStore((state) => state.open, store);\n const selectionRect = useStore((state) => state.selectionRect, store);\n\n const rafRef = React.useRef(null);\n\n const mounted = React.useSyncExternalStore(\n () => () => {},\n () => true,\n () => false,\n );\n\n const virtualElement = React.useMemo(() => {\n if (!selectionRect) return null;\n\n return {\n getBoundingClientRect: () => ({\n x: selectionRect.left,\n y: selectionRect.top,\n width: selectionRect.width,\n height: selectionRect.height,\n top: selectionRect.top,\n left: selectionRect.left,\n right: selectionRect.left + selectionRect.width,\n bottom: selectionRect.top + selectionRect.height,\n }),\n };\n }, [selectionRect]);\n\n const transformOrigin = React.useMemo(\n () => ({\n name: \"transformOrigin\",\n fn(data) {\n const { placement, rects } = data;\n const [placedSide, placedAlign] =\n getSideAndAlignFromPlacement(placement);\n const noArrowAlign = { start: \"0%\", center: \"50%\", end: \"100%\" }[\n placedAlign\n ];\n\n let x = \"\";\n let y = \"\";\n\n if (placedSide === \"bottom\") {\n x = noArrowAlign;\n y = \"0px\";\n } else if (placedSide === \"top\") {\n x = noArrowAlign;\n y = `${rects.floating.height}px`;\n } else if (placedSide === \"right\") {\n x = \"0px\";\n y = noArrowAlign;\n } else if (placedSide === \"left\") {\n x = `${rects.floating.width}px`;\n y = noArrowAlign;\n }\n return { data: { x, y } };\n },\n }),\n [],\n );\n\n const desiredPlacement = React.useMemo(\n () => (side + (align !== \"center\" ? `-${align}` : \"\")) as Placement,\n [side, align],\n );\n\n const collisionPadding = React.useMemo(\n () =>\n typeof collisionPaddingProp === \"number\"\n ? collisionPaddingProp\n : { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp },\n [collisionPaddingProp],\n );\n\n const boundary = React.useMemo(\n () =>\n Array.isArray(collisionBoundary)\n ? collisionBoundary\n : [collisionBoundary],\n [collisionBoundary],\n );\n\n const hasExplicitBoundaries = boundary.length > 0;\n\n const detectOverflowOptions = React.useMemo(\n () => ({\n padding: collisionPadding,\n boundary: boundary.filter(isNotNull),\n altBoundary: hasExplicitBoundaries,\n }),\n [collisionPadding, boundary, hasExplicitBoundaries],\n );\n\n const sizeMiddleware = React.useMemo(\n () =>\n size({\n ...detectOverflowOptions,\n apply: ({ elements, rects, availableWidth, availableHeight }) => {\n const { width: anchorWidth, height: anchorHeight } = rects.reference;\n const contentStyle = elements.floating.style;\n contentStyle.setProperty(\n \"--selection-toolbar-available-width\",\n `${availableWidth}px`,\n );\n contentStyle.setProperty(\n \"--selection-toolbar-available-height\",\n `${availableHeight}px`,\n );\n contentStyle.setProperty(\n \"--selection-toolbar-anchor-width\",\n `${anchorWidth}px`,\n );\n contentStyle.setProperty(\n \"--selection-toolbar-anchor-height\",\n `${anchorHeight}px`,\n );\n },\n }),\n [detectOverflowOptions],\n );\n\n const middleware = React.useMemo>(\n () => [\n offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }),\n avoidCollisions &&\n shift({\n mainAxis: true,\n crossAxis: false,\n limiter: sticky === \"partial\" ? limitShift() : undefined,\n ...detectOverflowOptions,\n }),\n avoidCollisions && flip({ ...detectOverflowOptions }),\n sizeMiddleware,\n transformOrigin,\n hideWhenDetached &&\n hide({ strategy: \"referenceHidden\", ...detectOverflowOptions }),\n ],\n [\n sideOffset,\n alignOffset,\n avoidCollisions,\n sticky,\n detectOverflowOptions,\n sizeMiddleware,\n transformOrigin,\n hideWhenDetached,\n ],\n );\n\n const { refs, floatingStyles, isPositioned, middlewareData } = useFloating({\n open: open && !!virtualElement,\n placement: desiredPlacement,\n strategy: \"fixed\",\n middleware,\n whileElementsMounted: (reference, floating, update) => {\n return autoUpdate(reference, floating, update, {\n animationFrame: updatePositionStrategy === \"always\",\n });\n },\n elements: {\n reference: virtualElement,\n },\n });\n\n const closeToolbar = React.useCallback(() => {\n const state = store.getState();\n if (state.open || state.selectedText || state.selectionRect) {\n store.batch(() => {\n store.setState(\"open\", false);\n store.setState(\"selectedText\", \"\");\n store.setState(\"selectionRect\", null);\n });\n }\n }, [store]);\n\n const updateSelection = React.useCallback(() => {\n const selection = window.getSelection();\n if (!selection || selection.rangeCount === 0) {\n closeToolbar();\n return;\n }\n\n const text = selection.toString().trim();\n if (!text) {\n closeToolbar();\n return;\n }\n\n if (containerProp !== undefined) {\n const resolvedContainer = getContainer();\n if (!resolvedContainer) return;\n\n const range = selection.getRangeAt(0);\n const commonAncestor = range.commonAncestorContainer;\n const element =\n commonAncestor.nodeType === Node.ELEMENT_NODE\n ? (commonAncestor as Element)\n : commonAncestor.parentElement;\n\n if (!element || !resolvedContainer.contains(element)) {\n closeToolbar();\n return;\n }\n }\n\n const range = selection.getRangeAt(0);\n const rect = range.getBoundingClientRect();\n\n const state = store.getState();\n const hasChanges =\n state.selectedText !== text ||\n !state.selectionRect ||\n state.selectionRect.top !== rect.top ||\n state.selectionRect.left !== rect.left ||\n state.selectionRect.width !== rect.width ||\n state.selectionRect.height !== rect.height ||\n !state.open;\n\n if (hasChanges) {\n store.batch(() => {\n store.setState(\"selectedText\", text);\n store.setState(\"selectionRect\", {\n top: rect.top,\n left: rect.left,\n width: rect.width,\n height: rect.height,\n });\n store.setState(\"open\", true);\n });\n }\n }, [containerProp, getContainer, store, closeToolbar]);\n\n const scheduleUpdate = React.useCallback(() => {\n if (rafRef.current !== null) return;\n rafRef.current = requestAnimationFrame(() => {\n if (store.getState().open) {\n updateSelection();\n }\n rafRef.current = null;\n });\n }, [store, updateSelection]);\n\n React.useEffect(() => {\n const container = getContainer() ?? document;\n\n function onMouseUp() {\n requestAnimationFrame(() => {\n updateSelection();\n });\n }\n\n function onSelectionChange() {\n const selection = window.getSelection();\n if (!selection?.toString().trim()) {\n closeToolbar();\n }\n }\n\n container.addEventListener(\"mouseup\", onMouseUp);\n document.addEventListener(\"selectionchange\", onSelectionChange);\n window.addEventListener(\"scroll\", scheduleUpdate, { passive: true });\n window.addEventListener(\"resize\", scheduleUpdate, { passive: true });\n\n return () => {\n container.removeEventListener(\"mouseup\", onMouseUp);\n document.removeEventListener(\"selectionchange\", onSelectionChange);\n window.removeEventListener(\"scroll\", scheduleUpdate);\n window.removeEventListener(\"resize\", scheduleUpdate);\n if (rafRef.current !== null) {\n cancelAnimationFrame(rafRef.current);\n rafRef.current = null;\n }\n };\n }, [getContainer, updateSelection, closeToolbar, scheduleUpdate]);\n\n const clearSelection = React.useCallback(() => {\n const selection = window.getSelection();\n if (selection) {\n selection.removeAllRanges();\n }\n closeToolbar();\n }, [closeToolbar]);\n\n React.useEffect(() => {\n if (!open) return;\n\n function onMouseDown(event: MouseEvent) {\n const target = event.target as Node;\n if (refs.floating.current && !refs.floating.current.contains(target)) {\n clearSelection();\n }\n }\n\n function onKeyDown(event: KeyboardEvent) {\n if (event.key === \"Escape\") {\n clearSelection();\n }\n }\n\n document.addEventListener(\"mousedown\", onMouseDown);\n document.addEventListener(\"keydown\", onKeyDown);\n\n return () => {\n document.removeEventListener(\"mousedown\", onMouseDown);\n document.removeEventListener(\"keydown\", onKeyDown);\n };\n }, [open, refs.floating, clearSelection]);\n\n const element = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"toolbar\",\n \"aria-label\": \"Text formatting toolbar\",\n className: cn(\n \"flex items-center gap-1 rounded-lg border bg-card px-1.5 py-1.5 shadow-lg outline-none\",\n isPositioned &&\n \"fade-in-0 zoom-in-95 animate-in duration-200 [animation-timing-function:cubic-bezier(0.16,1,0.3,1)]\",\n \"motion-reduce:animate-none motion-reduce:transition-none\",\n className,\n ),\n style: {\n transformOrigin: middlewareData.transformOrigin\n ? `${middlewareData.transformOrigin.x} ${middlewareData.transformOrigin.y}`\n : undefined,\n animation: !isPositioned ? \"none\" : undefined,\n ...style,\n },\n },\n rootProps,\n ),\n render,\n state: {\n slot: \"selection-toolbar\",\n state: open ? \"open\" : \"closed\",\n },\n });\n\n const portalContainer =\n portalContainerProp ?? (mounted ? globalThis.document?.body : null);\n\n if (!portalContainer || !open) return null;\n\n return (\n \n {ReactDOM.createPortal(\n \n {element}\n ,\n portalContainer,\n )}\n \n );\n}\n\ninterface SelectionToolbarItemProps\n extends Omit<\n React.ComponentProps,\n \"onSelect\" | \"onClick\" | \"onPointerDown\" | \"onPointerUp\"\n > {\n onSelect?: (text: string, event: Event) => void;\n onClick?: (event: React.MouseEvent) => void;\n onPointerDown?: (event: React.PointerEvent) => void;\n onPointerUp?: (event: React.PointerEvent) => void;\n}\n\nfunction SelectionToolbarItem(props: SelectionToolbarItemProps) {\n const {\n onSelect: onSelectProp,\n onClick: onClickProp,\n onPointerDown: onPointerDownProp,\n onPointerUp: onPointerUpProp,\n className,\n ref,\n ...itemProps\n } = props;\n\n const store = useStoreContext(ITEM_NAME);\n\n const propsRef = useAsRef({\n onSelect: onSelectProp,\n onClick: onClickProp,\n onPointerDown: onPointerDownProp,\n onPointerUp: onPointerUpProp,\n });\n\n const itemRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, itemRef);\n const pointerTypeRef =\n React.useRef(\"touch\");\n\n const onSelect = React.useCallback(() => {\n const item = itemRef.current;\n if (!item) return;\n\n const text = store.getState().selectedText;\n\n const selectEvent = new CustomEvent(\"selectiontoolbar.select\", {\n bubbles: true,\n cancelable: true,\n detail: { text },\n });\n\n item.addEventListener(\n \"selectiontoolbar.select\",\n (event) => propsRef.current.onSelect?.(text, event),\n {\n once: true,\n },\n );\n\n item.dispatchEvent(selectEvent);\n }, [propsRef, store]);\n\n const onPointerDown = React.useCallback(\n (event: React.PointerEvent) => {\n pointerTypeRef.current = event.pointerType;\n propsRef.current.onPointerDown?.(event);\n\n if (event.pointerType === \"mouse\") {\n event.preventDefault();\n }\n },\n [propsRef],\n );\n\n const onClick = React.useCallback(\n (event: React.MouseEvent) => {\n propsRef.current.onClick?.(event);\n if (event.defaultPrevented) return;\n\n if (pointerTypeRef.current !== \"mouse\") {\n onSelect();\n }\n },\n [propsRef, onSelect],\n );\n\n const onPointerUp = React.useCallback(\n (event: React.PointerEvent) => {\n propsRef.current.onPointerUp?.(event);\n if (event.defaultPrevented) return;\n\n if (pointerTypeRef.current === \"mouse\") {\n onSelect();\n }\n },\n [propsRef, onSelect],\n );\n\n return (\n \n );\n}\n\ntype ItemElement = React.ComponentRef;\n\nfunction SelectionToolbarSeparator(props: DivProps) {\n const { className, render, ...separatorProps } = props;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"separator\",\n \"aria-orientation\": \"vertical\",\n \"aria-hidden\": \"true\",\n className: cn(\"mx-0.5 h-6 w-px bg-border\", className),\n },\n separatorProps,\n ),\n render,\n state: {\n slot: \"selection-toolbar-separator\",\n },\n });\n}\n\nexport {\n SelectionToolbar,\n SelectionToolbarItem,\n type SelectionToolbarProps,\n SelectionToolbarSeparator,\n useStore as useSelectionToolbar,\n};\n", "target": "" }, { "path": "lib/compose-refs.ts", "type": "registry:lib", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\n\nimport * as React from \"react\";\n\ntype PossibleRef = React.Ref | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef(ref: PossibleRef, value: T) {\n if (typeof ref === \"function\") {\n return ref(value);\n }\n\n if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs(...refs: PossibleRef[]): React.RefCallback {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup === \"function\") {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup === \"function\") {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs(...refs: PossibleRef[]): React.RefCallback {\n // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n", "target": "" } ], "registryDependencies": [ "button", "@diceui/use-as-ref", "@diceui/use-isomorphic-layout-effect", "@diceui/use-lazy-ref" ], "dependencies": [ "@base-ui/react", "@floating-ui/react-dom" ] }