Skip to content

Commit

Permalink
Use floating-ui to position text toolbar (#467)
Browse files Browse the repository at this point in the history
Ref #107

Text toolbar is badly positioned when flipped up. Keeping it too close
to cursor makes text selection harder.

Here I replaced custom logic with computed rect from toolbar parent and
state from canvas. A few middlewares polished offsets. Flip no longer
overlap text.

Also fixed toolbar background.

Hide toolbar while selecting text with mouse.
  • Loading branch information
TrySound committed Nov 19, 2022
1 parent 1caa943 commit b163fdf
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 57 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import {
type RangeSelection,
type TextNode,
Expand Down Expand Up @@ -82,6 +82,7 @@ const $isSelectedLink = (selection: RangeSelection) => {

export const ToolbarConnectorPlugin = () => {
const [editor] = useLexicalComposerContext();
const isMouseDownRef = useRef(false);

// control toolbar state on data or selection updates
const updateToolbar = useCallback(() => {
Expand All @@ -90,7 +91,8 @@ export const ToolbarConnectorPlugin = () => {
if (
$isRangeSelection(selection) &&
selection.getTextContent().length !== 0 &&
nativeSelection != null
nativeSelection != null &&
isMouseDownRef.current === false
) {
const domRange = nativeSelection.getRangeAt(0);
const selectionRect = domRange.getBoundingClientRect();
Expand All @@ -117,6 +119,26 @@ export const ToolbarConnectorPlugin = () => {
}
}, []);

// prevent showing toolbar when select with mouse
useEffect(() => {
const onMouseDown = () => {
isMouseDownRef.current = true;
};
const onMouseUp = () => {
isMouseDownRef.current = false;
const editorState = editor.getEditorState();
editorState.read(() => {
updateToolbar();
});
};
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("mouseup", onMouseUp);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mouseup", onMouseUp);
};
}, [editor, updateToolbar]);

useEffect(() => {
// hide toolbar when editor is unmounted
return () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { useRef, useEffect, type MouseEventHandler } from "react";
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
import { type Publish } from "~/shared/pubsub";
import { useMemo, useState, type MouseEventHandler } from "react";
import {
useSelectedInstanceData,
type TextToolbarState,
useTextToolbarState,
} from "~/designer/shared/nano-states";
import {
type CSS,
ToggleGroupRoot,
ToggleGroupItem,
} from "@webstudio-is/design-system";
import { ToggleGroupRoot, ToggleGroupItem } from "@webstudio-is/design-system";
import {
FontBoldIcon,
FontItalicIcon,
Expand Down Expand Up @@ -44,51 +41,52 @@ export const useSubscribeTextToolbar = () => {
useSubscribe("hideTextToolbar", () => setTextToolbar(undefined));
};

const getPlacement = ({
toolbarRect,
selectionRect,
}: {
toolbarRect?: DOMRect;
selectionRect: DOMRect;
}) => {
let align = "top";
let left = selectionRect.x + selectionRect.width / 2;
// We measure the size in a hidden state after we render the menu,
// then show it
let visibility = "hidden";
if (toolbarRect !== undefined) {
visibility = "visible";
// Prevent going further than left 0
left = Math.max(left, toolbarRect.width / 2);
// Prevent going further than window width
left = Math.min(left, window.innerWidth - toolbarRect.width / 2);
align = selectionRect.y > toolbarRect.height ? "top" : "bottom";
}

const marginBottom = align === "bottom" ? "-$5" : 0;
const marginTop = align === "bottom" ? 0 : "-$5";
const transform = "translateX(-50%)";
const top =
align === "top"
? Math.max(selectionRect.y - selectionRect.height, 0)
: Math.max(selectionRect.y + selectionRect.height);

return { top, left, marginBottom, marginTop, transform, visibility };
};

const onClickPreventDefault: MouseEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
event.stopPropagation();
};

const getRectForRelativeRect = (parent: DOMRect, rel: DOMRect) => {
return {
x: parent.x + rel.x,
y: parent.y + rel.y,
width: rel.width,
height: rel.height,
top: parent.top + rel.top,
left: parent.left + rel.left,
bottom: parent.top + rel.bottom,
right: parent.left + rel.right,
};
};

type ToolbarProps = {
css?: CSS;
rootRef: React.Ref<HTMLDivElement>;
state: TextToolbarState;
onToggle: (value: Format) => void;
};

const Toolbar = ({ css, rootRef, state, onToggle }: ToolbarProps) => {
const Toolbar = ({ state, onToggle }: ToolbarProps) => {
const rootRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (rootRef.current?.parentElement) {
const floating = rootRef.current;
const parent = rootRef.current.parentElement;
const newRect = getRectForRelativeRect(
parent.getBoundingClientRect(),
state.selectionRect
);
const reference = {
getBoundingClientRect: () => newRect,
};
computePosition(reference, floating, {
placement: "top",
middleware: [flip(), shift({ padding: 4 }), offset(12)],
}).then(({ x, y }) => {
floating.style.transform = `translate(${x}px, ${y}px)`;
});
}
}, [state.selectionRect]);

const value: Format[] = [];
if (state.isBold) {
value.push("bold");
Expand All @@ -108,6 +106,7 @@ const Toolbar = ({ css, rootRef, state, onToggle }: ToolbarProps) => {
if (state.isSpan) {
value.push("span");
}

return (
<ToggleGroupRoot
ref={rootRef}
Expand Down Expand Up @@ -140,10 +139,10 @@ const Toolbar = ({ css, rootRef, state, onToggle }: ToolbarProps) => {
onClick={onClickPreventDefault}
css={{
position: "absolute",
borderRadius: "$borderRadius$4",
padding: "$spacing$3 $spacing$5",
top: 0,
left: 0,
pointerEvents: "auto",
...css,
background: "white",
}}
>
<ToggleGroupItem value="bold">
Expand Down Expand Up @@ -178,24 +177,13 @@ type TextToolbarProps = {
export const TextToolbar = ({ publish }: TextToolbarProps) => {
const [textToolbar] = useTextToolbarState();
const [selectedIntsanceData] = useSelectedInstanceData();
const [element, setElement] = useState<HTMLElement | null>(null);
const placement = useMemo(() => {
if (textToolbar == null || element === null) return;
const toolbarRect = element.getBoundingClientRect();
return getPlacement({
toolbarRect,
selectionRect: textToolbar.selectionRect,
});
}, [textToolbar, element]);

if (textToolbar == null || selectedIntsanceData === undefined) {
return null;
}

return (
<Toolbar
rootRef={setElement}
css={placement}
state={textToolbar}
onToggle={(value) =>
publish({
Expand Down
1 change: 1 addition & 0 deletions apps/designer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ci:migrate": "migrations migrate --force"
},
"dependencies": {
"@floating-ui/dom": "^1.0.6",
"@lexical/react": "^0.6.0",
"@remix-run/node": "^1.6.4",
"@remix-run/react": "^1.6.4",
Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2220,13 +2220,25 @@
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-0.7.3.tgz#d274116678ffae87f6b60e90f88cc4083eefab86"
integrity sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg==

"@floating-ui/core@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.2.tgz#d06a66d3ad8214186eda2432ac8b8d81868a571f"
integrity sha512-Skfy0YS3NJ5nV9us0uuPN0HDk1Q4edljaOhRBJGDWs9EBa7ZVMYBHRFlhLvvmwEoaIM9BlH6QJFn9/uZg0bACg==

"@floating-ui/dom@^0.5.3":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-0.5.4.tgz#4eae73f78bcd4bd553ae2ade30e6f1f9c73fe3f1"
integrity sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==
dependencies:
"@floating-ui/core" "^0.7.3"

"@floating-ui/dom@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.6.tgz#e42393ec381a4fe96673fbcee137a95e86c93ebc"
integrity sha512-kt/tg1oip9OAH1xjCTcx1OpcUpu9rjDw3GKJ/rEhUqhO7QyJWfrHU0DpLTNsH67+JyFL5Kv9X1utsXwKFVtyEQ==
dependencies:
"@floating-ui/core" "^1.0.2"

"@floating-ui/react-dom@0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-0.7.2.tgz#0bf4ceccb777a140fc535c87eb5d6241c8e89864"
Expand Down

0 comments on commit b163fdf

Please sign in to comment.