import { NodeSelection } from "prosemirror-state"; import { CellSelection, selectedRect } from "prosemirror-tables"; import * as React from "react"; import { Portal as ReactPortal } from "react-portal"; import styled, { css } from "styled-components"; import { isCode } from "@shared/editor/lib/isCode"; import { findParentNode } from "@shared/editor/queries/findParentNode"; import { depths, s } from "@shared/styles"; import { Portal } from "~/components/Portal"; import useComponentSize from "~/hooks/useComponentSize"; import useEventListener from "~/hooks/useEventListener"; import useMobile from "~/hooks/useMobile"; import useWindowSize from "~/hooks/useWindowSize"; import Logger from "~/utils/Logger"; import { useEditor } from "./EditorContext"; type Props = { active?: boolean; children: React.ReactNode; width?: number; forwardedRef?: React.RefObject | null; }; const defaultPosition = { left: -10000, top: 0, offset: 0, maxWidth: 1000, blockSelection: false, visible: false, }; function usePosition({ menuRef, active, }: { menuRef: React.RefObject; active?: boolean; }) { const { view } = useEditor(); const { selection } = view.state; const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef); if (!active || !menuWidth || !menuHeight || !menuRef.current) { return defaultPosition; } // based on the start and end of the selection calculate the position at // the center top let fromPos; let toPos; try { fromPos = view.coordsAtPos(selection.from); toPos = view.coordsAtPos(selection.to, -1); } catch (err) { Logger.warn("Unable to calculate selection position", err); return defaultPosition; } // ensure that start < end for the menu to be positioned correctly const selectionBounds = { top: Math.min(fromPos.top, toPos.top), bottom: Math.max(fromPos.bottom, toPos.bottom), left: Math.min(fromPos.left, toPos.left), right: Math.max(fromPos.right, toPos.right), }; const offsetParent = menuRef.current.offsetParent ? menuRef.current.offsetParent.getBoundingClientRect() : ({ width: window.innerWidth, height: window.innerHeight, top: 0, left: 0, } as DOMRect); // position at the top right of code blocks const codeBlock = findParentNode(isCode)(view.state.selection); if (codeBlock && view.state.selection.empty) { const element = view.nodeDOM(codeBlock.pos); const bounds = (element as HTMLElement).getBoundingClientRect(); selectionBounds.top = bounds.top; selectionBounds.left = bounds.right - menuWidth; selectionBounds.right = bounds.right; } // tables are an oddity, and need their own positioning logic const isColSelection = selection instanceof CellSelection && selection.isColSelection(); const isRowSelection = selection instanceof CellSelection && selection.isRowSelection(); if (isColSelection && isRowSelection) { const rect = selectedRect(view.state); const table = view.domAtPos(rect.tableStart); const bounds = (table.node as HTMLElement).getBoundingClientRect(); selectionBounds.top = bounds.top - 16; selectionBounds.left = bounds.left - 10; selectionBounds.right = bounds.left - 10; } else if (isColSelection) { const rect = selectedRect(view.state); const table = view.domAtPos(rect.tableStart); const element = (table.node as HTMLElement).querySelector( `tr > *:nth-child(${rect.left + 1})` ); if (element instanceof HTMLElement) { const bounds = element.getBoundingClientRect(); selectionBounds.top = bounds.top - 16; selectionBounds.left = bounds.left; selectionBounds.right = bounds.right; } } else if (isRowSelection) { const rect = selectedRect(view.state); const table = view.domAtPos(rect.tableStart); const element = (table.node as HTMLElement).querySelector( `tr:nth-child(${rect.top + 1}) > *` ); if (element instanceof HTMLElement) { const bounds = element.getBoundingClientRect(); selectionBounds.top = bounds.top; selectionBounds.left = bounds.left - 10; selectionBounds.right = bounds.left - 10; } } const isImageSelection = selection instanceof NodeSelection && selection.node?.type.name === "image"; // Images need their own positioning to get the toolbar in the center if (isImageSelection) { const element = view.nodeDOM(selection.from); // Images are wrapped which impacts positioning - need to traverse through // p > span > div.image const imageElement = (element as HTMLElement).getElementsByTagName( "img" )[0]; const { left, top, width } = imageElement.getBoundingClientRect(); return { left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left), top: Math.round(top - menuHeight - offsetParent.top), offset: 0, visible: true, }; } else { // calculate the horizontal center of the selection const halfSelection = Math.abs(selectionBounds.right - selectionBounds.left) / 2; const centerOfSelection = selectionBounds.left + halfSelection; // position the menu so that it is centered over the selection except in // the cases where it would extend off the edge of the screen. In these // instances leave a margin const margin = 12; const left = Math.min( Math.min( offsetParent.x + offsetParent.width - menuWidth - margin, window.innerWidth - margin ), Math.max( Math.max(offsetParent.x, margin), centerOfSelection - menuWidth / 2 ) ); const top = Math.min( window.innerHeight - menuHeight - margin, Math.max(margin, selectionBounds.top - menuHeight) ); // if the menu has been offset to not extend offscreen then we should adjust // the position of the triangle underneath to correctly point to the center // of the selection still const offset = left - (centerOfSelection - menuWidth / 2); return { left: Math.round(left - offsetParent.left), top: Math.round(top - offsetParent.top), offset: Math.round(offset), maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width), blockSelection: codeBlock || isColSelection || isRowSelection, visible: true, }; } } const FloatingToolbar = React.forwardRef(function FloatingToolbar_( props: Props, ref: React.RefObject ) { const menuRef = ref || React.createRef(); const [isSelectingText, setSelectingText] = React.useState(false); let position = usePosition({ menuRef, active: props.active, }); if (isSelectingText) { position = defaultPosition; } useEventListener("mouseup", () => { setSelectingText(false); }); useEventListener("mousedown", () => { if (!props.active) { setSelectingText(true); } }); const isMobile = useMobile(); const { height } = useWindowSize(); if (isMobile) { if (!props.children) { return null; } if (props.active) { const rect = document.body.getBoundingClientRect(); return ( {props.children} ); } return null; } return ( {props.children} ); }); type WrapperProps = { active?: boolean; arrow?: boolean; $offset: number; }; const arrow = (props: WrapperProps) => props.arrow ? css` &::before { content: ""; display: block; width: 24px; height: 24px; transform: translateX(-50%) rotate(45deg); background: ${s("menuBackground")}; border-radius: 3px; z-index: -1; position: absolute; bottom: -2px; left: calc(50% - ${props.$offset || 0}px); pointer-events: none; } ` : ""; const MobileWrapper = styled.div` position: absolute; left: 0; right: 0; width: 100vw; padding: 10px 6px; background-color: ${s("menuBackground")}; border-top: 1px solid ${s("divider")}; box-sizing: border-box; z-index: ${depths.editorToolbar}; &:after { content: ""; position: absolute; left: 0; right: 0; height: 100px; background-color: ${s("menuBackground")}; } `; const Wrapper = styled.div` will-change: opacity, transform; padding: 6px; position: absolute; z-index: ${depths.editorToolbar}; opacity: 0; background-color: ${s("menuBackground")}; box-shadow: ${s("menuShadow")}; border-radius: 4px; transform: scale(0.95); transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275); transition-delay: 150ms; line-height: 0; height: 36px; box-sizing: border-box; pointer-events: none; white-space: nowrap; ${arrow} * { box-sizing: border-box; } ${({ active }) => active && ` transform: translateY(-6px) scale(1); opacity: 1; `}; @media print { display: none; } `; export default FloatingToolbar;