Files
outline/app/editor/components/FloatingToolbar.tsx
2023-05-24 19:24:05 -07:00

291 lines
8.2 KiB
TypeScript

import { NodeSelection } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMediaQuery from "~/hooks/useMediaQuery";
import useViewportHeight from "~/hooks/useViewportHeight";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
type Props = {
active?: boolean;
children: React.ReactNode;
width?: number;
forwardedRef?: React.RefObject<HTMLDivElement> | null;
};
const defaultPosition = {
left: -10000,
top: 0,
offset: 0,
maxWidth: 1000,
visible: false,
};
function usePosition({
menuRef,
active,
}: {
menuRef: React.RefObject<HTMLDivElement>;
active?: boolean;
}) {
const { view } = useEditor();
const { selection } = view.state;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
const viewportHeight = useViewportHeight();
const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)");
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
}
// If we're on a mobile device then stick the floating toolbar to the bottom
// of the screen above the virtual keyboard.
if (isTouchDevice && viewportHeight) {
return {
left: 0,
right: 0,
top: viewportHeight - menuHeight,
offset: 0,
maxWidth: 1000,
visible: true,
};
}
// 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);
// 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})`
);
const bounds = (element as HTMLElement).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}) > *`
);
const bounds = (element as HTMLElement).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 {
// calcluate 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: offsetParent.width,
visible: true,
};
}
}
const FloatingToolbar = React.forwardRef(
(props: Props, ref: React.RefObject<HTMLDivElement>) => {
const menuRef = ref || React.createRef<HTMLDivElement>();
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);
}
});
return (
<Portal>
<Wrapper
active={props.active && position.visible}
ref={menuRef}
$offset={position.offset}
style={{
width: props.width,
maxWidth: `${position.maxWidth}px`,
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{props.children}
</Wrapper>
</Portal>
);
}
);
const Wrapper = styled.div<{
active?: boolean;
$offset: number;
}>`
will-change: opacity, transform;
padding: 8px 16px;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
background-color: ${s("toolbarBackground")};
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: 40px;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
&::before {
content: "";
display: block;
width: 24px;
height: 24px;
transform: translateX(-50%) rotate(45deg);
background: ${s("toolbarBackground")};
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -2px;
left: calc(50% - ${(props) => props.$offset || 0}px);
pointer-events: none;
}
* {
box-sizing: border-box;
}
${({ active }) =>
active &&
`
transform: translateY(-6px) scale(1);
opacity: 1;
`};
@media print {
display: none;
}
@media (hover: none) and (pointer: coarse) {
&:before {
display: none;
}
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: scale(1);
border-radius: 0;
width: 100vw;
position: fixed;
}
`;
export default FloatingToolbar;