346 lines
9.5 KiB
TypeScript
346 lines
9.5 KiB
TypeScript
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<HTMLDivElement> | null;
|
|
};
|
|
|
|
const defaultPosition = {
|
|
left: -10000,
|
|
top: 0,
|
|
offset: 0,
|
|
maxWidth: 1000,
|
|
blockSelection: false,
|
|
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);
|
|
|
|
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<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);
|
|
}
|
|
});
|
|
|
|
const isMobile = useMobile();
|
|
const { height } = useWindowSize();
|
|
|
|
if (isMobile) {
|
|
if (!props.children) {
|
|
return null;
|
|
}
|
|
|
|
if (props.active) {
|
|
const rect = document.body.getBoundingClientRect();
|
|
return (
|
|
<ReactPortal>
|
|
<MobileWrapper
|
|
ref={menuRef}
|
|
style={{
|
|
bottom: `calc(100% - ${height - rect.y}px)`,
|
|
}}
|
|
>
|
|
{props.children}
|
|
</MobileWrapper>
|
|
</ReactPortal>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Portal>
|
|
<Wrapper
|
|
active={props.active && position.visible}
|
|
arrow={!position.blockSelection}
|
|
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>
|
|
);
|
|
});
|
|
|
|
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<WrapperProps>`
|
|
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;
|