Disable block pointer events during layout drag (#183)

This will ensure that the webview cannot capture the pointer events and
disrupt the drag functionality.

This also fixes an issue where greedy and imprecise bounds calculations
could result in thrashing of the layoutState.pendingAction field, which
could cause the placeholder to flicker.

This also fixes issues where some React Effects that were supposed to be
debounced or throttled were being invoked too much. This is because
useEffect regenerates the callback when it is run, resulting in the
debounce or throttle never taking effect. Moving the throttled or
debounced logic to a separate callback solves this.
This commit is contained in:
Evan Simkowitz 2024-07-31 12:49:38 -07:00 committed by GitHub
parent 3ff03f7b34
commit 2157df85de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 152 additions and 108 deletions

View File

@ -22,6 +22,7 @@ import * as React from "react";
import "./block.less"; import "./block.less";
export interface LayoutComponentModel { export interface LayoutComponentModel {
disablePointerEvents: boolean;
onClose?: () => void; onClose?: () => void;
onMagnifyToggle?: () => void; onMagnifyToggle?: () => void;
dragHandleRef?: React.RefObject<HTMLDivElement>; dragHandleRef?: React.RefObject<HTMLDivElement>;
@ -268,7 +269,7 @@ const BlockFrame_Default_Component = ({
if (preview) { if (preview) {
isFocused = true; isFocused = true;
} }
let style: React.CSSProperties = {}; const style: React.CSSProperties = {};
if (!isFocused && blockData?.meta?.["frame:bordercolor"]) { if (!isFocused && blockData?.meta?.["frame:bordercolor"]) {
style.borderColor = blockData.meta["frame:bordercolor"]; style.borderColor = blockData.meta["frame:bordercolor"];
} }
@ -286,12 +287,13 @@ const BlockFrame_Default_Component = ({
if (preIconButton) { if (preIconButton) {
preIconButtonElem = <IconButton decl={preIconButton} className="block-frame-preicon-button" />; preIconButtonElem = <IconButton decl={preIconButton} className="block-frame-preicon-button" />;
} }
let endIconsElem: JSX.Element[] = []; const endIconsElem: JSX.Element[] = [];
if (endIconButtons && endIconButtons.length > 0) { if (endIconButtons && endIconButtons.length > 0) {
for (let idx = 0; idx < endIconButtons.length; idx++) { endIconsElem.push(
const button = endIconButtons[idx]; ...endIconButtons.map((button, idx) => (
endIconsElem.push(<IconButton key={idx} decl={button} className="block-frame-endicon-button" />); <IconButton key={idx} decl={button} className="block-frame-endicon-button" />
} ))
);
} }
const settingsDecl: HeaderIconButton = { const settingsDecl: HeaderIconButton = {
elemtype: "iconbutton", elemtype: "iconbutton",
@ -549,7 +551,7 @@ function makeDefaultViewModel(blockId: string): ViewModel {
} }
const BlockPreview = React.memo(({ blockId, layoutModel }: BlockProps) => { const BlockPreview = React.memo(({ blockId, layoutModel }: BlockProps) => {
const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
if (!blockData) { if (!blockData) {
return null; return null;
} }
@ -578,7 +580,7 @@ const BlockFull = React.memo(({ blockId, layoutModel }: BlockProps) => {
return winData.activeblockid === blockId; return winData.activeblockid === blockId;
}); });
}); });
let isFocused = jotai.useAtomValue(isFocusedAtom); const isFocused = jotai.useAtomValue(isFocusedAtom);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
setBlockClicked(isFocused); setBlockClicked(isFocused);
@ -651,7 +653,11 @@ const BlockFull = React.memo(({ blockId, layoutModel }: BlockProps) => {
<div key="focuselem" className="block-focuselem"> <div key="focuselem" className="block-focuselem">
<input type="text" value="" ref={focusElemRef} id={`${blockId}-dummy-focus`} onChange={() => {}} /> <input type="text" value="" ref={focusElemRef} id={`${blockId}-dummy-focus`} onChange={() => {}} />
</div> </div>
<div key="content" className="block-content"> <div
key="content"
className="block-content"
style={{ pointerEvents: layoutModel?.disablePointerEvents ? "none" : undefined }}
>
<ErrorBoundary> <ErrorBoundary>
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</React.Suspense> <React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</React.Suspense>
</ErrorBoundary> </ErrorBoundary>

View File

@ -8,6 +8,7 @@ import * as WOS from "@/store/wos";
import * as React from "react"; import * as React from "react";
import { CenteredDiv } from "@/element/quickelems"; import { CenteredDiv } from "@/element/quickelems";
import { ContentRenderer } from "@/layout/lib/model";
import { TileLayout } from "frontend/layout/index"; import { TileLayout } from "frontend/layout/index";
import { getLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom"; import { getLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
@ -23,23 +24,25 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
const tabData = useAtomValue(tabAtom); const tabData = useAtomValue(tabAtom);
const tileLayoutContents = useMemo(() => { const tileLayoutContents = useMemo(() => {
function renderBlock( const renderBlock: ContentRenderer<TabLayoutData> = (
tabData: TabLayoutData, tabData: TabLayoutData,
ready: boolean, ready: boolean,
disablePointerEvents: boolean,
onMagnifyToggle: () => void, onMagnifyToggle: () => void,
onClose: () => void, onClose: () => void,
dragHandleRef: React.RefObject<HTMLDivElement> dragHandleRef: React.RefObject<HTMLDivElement>
) { ) => {
if (!tabData.blockId || !ready) { if (!tabData.blockId || !ready) {
return null; return null;
} }
const layoutModel: LayoutComponentModel = { const layoutModel: LayoutComponentModel = {
onClose: onClose, disablePointerEvents,
onMagnifyToggle: onMagnifyToggle, onClose,
dragHandleRef: dragHandleRef, onMagnifyToggle,
dragHandleRef,
}; };
return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={layoutModel} preview={false} />; return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={layoutModel} preview={false} />;
} };
function renderPreview(tabData: TabLayoutData) { function renderPreview(tabData: TabLayoutData) {
return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={null} preview={true} />; return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={null} preview={true} />;

View File

@ -17,7 +17,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { DropTargetMonitor, useDrag, useDragLayer, useDrop } from "react-dnd"; import { DropTargetMonitor, XYCoord, useDrag, useDragLayer, useDrop } from "react-dnd";
import { debounce, throttle } from "throttle-debounce"; import { debounce, throttle } from "throttle-debounce";
import { useDevicePixelRatio } from "use-device-pixel-ratio"; import { useDevicePixelRatio } from "use-device-pixel-ratio";
import { globalLayoutTransformsMap } from "./layoutAtom"; import { globalLayoutTransformsMap } from "./layoutAtom";
@ -132,11 +132,9 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
dragClientOffset: monitor.getClientOffset(), dragClientOffset: monitor.getClientOffset(),
})); }));
// Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture const checkForCursorBounds = useCallback(
// because that conflicts with the DnD layer. debounce(100, (dragClientOffset: XYCoord) => {
useEffect( const cursorPoint = dragClientOffset ?? getCursorPoint?.();
debounce(100, () => {
const cursorPoint = getCursorPoint?.() ?? dragClientOffset;
if (cursorPoint && displayContainerRef.current) { if (cursorPoint && displayContainerRef.current) {
const displayContainerRect = displayContainerRef.current.getBoundingClientRect(); const displayContainerRect = displayContainerRef.current.getBoundingClientRect();
const normalizedX = cursorPoint.x - displayContainerRect.x; const normalizedX = cursorPoint.x - displayContainerRect.x;
@ -151,9 +149,13 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
} }
} }
}), }),
[dragClientOffset] [getCursorPoint, displayContainerRef, dispatch]
); );
// Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture
// because that conflicts with the DnD layer.
useEffect(() => checkForCursorBounds(dragClientOffset), [dragClientOffset]);
/** /**
* Callback to update the transforms on the displayed leafs and move the overlay over the display layer when dragging. * Callback to update the transforms on the displayed leafs and move the overlay over the display layer when dragging.
*/ */
@ -279,6 +281,7 @@ function TileLayoutComponent<T>({ layoutTreeStateAtom, contents, getCursorPoint
<DisplayNodesWrapper <DisplayNodesWrapper
contents={contents} contents={contents}
ready={animate} ready={animate}
activeDrag={activeDrag}
onLeafMagnifyToggle={onLeafMagnifyToggle} onLeafMagnifyToggle={onLeafMagnifyToggle}
onLeafClose={onLeafClose} onLeafClose={onLeafClose}
layoutTreeState={layoutTreeState} layoutTreeState={layoutTreeState}
@ -345,6 +348,11 @@ interface DisplayNodesWrapperProps<T> {
* Determines whether the leaf nodes are ready to be displayed to the user. * Determines whether the leaf nodes are ready to be displayed to the user.
*/ */
ready: boolean; ready: boolean;
/**
* Determines if a drag operation is in progress.
*/
activeDrag: boolean;
} }
const DisplayNodesWrapper = memo( const DisplayNodesWrapper = memo(
@ -355,6 +363,7 @@ const DisplayNodesWrapper = memo(
onLeafMagnifyToggle, onLeafMagnifyToggle,
layoutLeafTransforms, layoutLeafTransforms,
ready, ready,
activeDrag,
}: DisplayNodesWrapperProps<T>) => { }: DisplayNodesWrapperProps<T>) => {
if (!layoutLeafTransforms) { if (!layoutLeafTransforms) {
return null; return null;
@ -366,6 +375,7 @@ const DisplayNodesWrapper = memo(
key={leaf.id} key={leaf.id}
layoutNode={leaf} layoutNode={leaf}
contents={contents} contents={contents}
activeDrag={activeDrag}
transform={layoutLeafTransforms[leaf.id]} transform={layoutLeafTransforms[leaf.id]}
onLeafClose={onLeafClose} onLeafClose={onLeafClose}
onLeafMagnifyToggle={onLeafMagnifyToggle} onLeafMagnifyToggle={onLeafMagnifyToggle}
@ -398,11 +408,17 @@ interface DisplayNodeProps<T> {
* @param node The node that is closed. * @param node The node that is closed.
*/ */
onLeafClose: (node: LayoutNode<T>) => void; onLeafClose: (node: LayoutNode<T>) => void;
/** /**
* Determines whether a leaf's contents should be displayed to the user. * Determines whether a leaf's contents should be displayed to the user.
*/ */
ready: boolean; ready: boolean;
/**
* Determines if a drag operation is in progress.
*/
activeDrag: boolean;
/** /**
* Any class names to add to the component. * Any class names to add to the component.
*/ */
@ -427,6 +443,7 @@ const DisplayNode = memo(
onLeafMagnifyToggle, onLeafMagnifyToggle,
onLeafClose, onLeafClose,
ready, ready,
activeDrag,
className, className,
}: DisplayNodeProps<T>) => { }: DisplayNodeProps<T>) => {
const tileNodeRef = useRef<HTMLDivElement>(null); const tileNodeRef = useRef<HTMLDivElement>(null);
@ -508,11 +525,18 @@ const DisplayNode = memo(
return ( return (
layoutNode.data && ( layoutNode.data && (
<div key="leaf" className="tile-leaf"> <div key="leaf" className="tile-leaf">
{contents.renderContent(layoutNode.data, ready, onMagnifyToggle, onClose, dragHandleRef)} {contents.renderContent(
layoutNode.data,
ready,
activeDrag,
onMagnifyToggle,
onClose,
dragHandleRef
)}
</div> </div>
) )
); );
}, [layoutNode.data, ready, onClose]); }, [layoutNode.data, ready, activeDrag, onClose]);
return ( return (
<div <div
@ -885,113 +909,123 @@ interface PlaceholderProps<T> {
/** /**
* An overlay to preview pending actions on the layout tree. * An overlay to preview pending actions on the layout tree.
*/ */
const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefsAtom, style }: PlaceholderProps<T>) => { const Placeholder = memo(<T,>({ layoutTreeState, overlayContainerRef, nodeRefsAtom, style }: PlaceholderProps<T>) => {
const [placeholderOverlay, setPlaceholderOverlay] = useState<ReactNode>(null); const [placeholderOverlay, setPlaceholderOverlay] = useState<ReactNode>(null);
const nodeRefs = useAtomValue(nodeRefsAtom); const nodeRefs = useAtomValue(nodeRefsAtom);
useEffect(() => { const updatePlaceholder = useCallback(
let newPlaceholderOverlay: ReactNode; throttle(10, (pendingAction: LayoutTreeAction) => {
if (overlayContainerRef?.current) { let newPlaceholderOverlay: ReactNode;
switch (layoutTreeState?.pendingAction?.type) { if (overlayContainerRef?.current) {
case LayoutTreeActionType.Move: { switch (pendingAction?.type) {
const action = layoutTreeState.pendingAction as LayoutTreeMoveNodeAction<T>; case LayoutTreeActionType.Move: {
let parentId: string; const action = pendingAction as LayoutTreeMoveNodeAction<T>;
if (action.insertAtRoot) { let parentId: string;
parentId = layoutTreeState.rootNode.id; if (action.insertAtRoot) {
} else { parentId = layoutTreeState.rootNode.id;
parentId = action.parentId;
}
const parentNode = findNode(layoutTreeState.rootNode, parentId);
if (action.index !== undefined && parentNode) {
const targetIndex = Math.min(
parentNode.children ? parentNode.children.length - 1 : 0,
Math.max(0, action.index - 1)
);
let targetNode = parentNode?.children?.at(targetIndex);
let targetRef: React.RefObject<HTMLElement>;
if (targetNode) {
targetRef = nodeRefs.get(targetNode.id);
} else { } else {
targetRef = nodeRefs.get(parentNode.id); parentId = action.parentId;
targetNode = parentNode;
} }
const parentNode = findNode(layoutTreeState.rootNode, parentId);
if (action.index !== undefined && parentNode) {
const targetIndex = Math.min(
parentNode.children ? parentNode.children.length - 1 : 0,
Math.max(0, action.index - 1)
);
let targetNode = parentNode?.children?.at(targetIndex);
let targetRef: React.RefObject<HTMLElement>;
if (targetNode) {
targetRef = nodeRefs.get(targetNode.id);
} else {
targetRef = nodeRefs.get(parentNode.id);
targetNode = parentNode;
}
if (targetRef?.current) {
const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
const targetBoundingRect = targetRef.current.getBoundingClientRect();
// Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent.
// Default to placing the placeholder in the first half of the target node.
const placeholderDimensions: Dimensions = {
height:
parentNode.flexDirection === FlexDirection.Column
? targetBoundingRect.height / 2
: targetBoundingRect.height,
width:
parentNode.flexDirection === FlexDirection.Row
? targetBoundingRect.width / 2
: targetBoundingRect.width,
top: targetBoundingRect.top - overlayBoundingRect.top,
left: targetBoundingRect.left - overlayBoundingRect.left,
};
if (action.index > targetIndex) {
if (action.index >= (parentNode.children?.length ?? 1)) {
// If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom).
placeholderDimensions.top +=
parentNode.flexDirection === FlexDirection.Column &&
targetBoundingRect.height / 2;
placeholderDimensions.left +=
parentNode.flexDirection === FlexDirection.Row &&
targetBoundingRect.width / 2;
} else {
// Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node
placeholderDimensions.top +=
parentNode.flexDirection === FlexDirection.Column &&
(3 * targetBoundingRect.height) / 4;
placeholderDimensions.left +=
parentNode.flexDirection === FlexDirection.Row &&
(3 * targetBoundingRect.width) / 4;
}
}
const placeholderTransform = setTransform(placeholderDimensions);
newPlaceholderOverlay = (
<div className="placeholder" style={{ ...placeholderTransform }} />
);
}
}
break;
}
case LayoutTreeActionType.Swap: {
const action = pendingAction as LayoutTreeSwapNodeAction;
// console.log("placeholder for swap", action);
const targetNodeId = action.node1Id;
const targetRef = nodeRefs.get(targetNodeId);
if (targetRef?.current) { if (targetRef?.current) {
const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
const targetBoundingRect = targetRef.current.getBoundingClientRect(); const targetBoundingRect = targetRef.current.getBoundingClientRect();
// Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent.
// Default to placing the placeholder in the first half of the target node.
const placeholderDimensions: Dimensions = { const placeholderDimensions: Dimensions = {
height:
parentNode.flexDirection === FlexDirection.Column
? targetBoundingRect.height / 2
: targetBoundingRect.height,
width:
parentNode.flexDirection === FlexDirection.Row
? targetBoundingRect.width / 2
: targetBoundingRect.width,
top: targetBoundingRect.top - overlayBoundingRect.top, top: targetBoundingRect.top - overlayBoundingRect.top,
left: targetBoundingRect.left - overlayBoundingRect.left, left: targetBoundingRect.left - overlayBoundingRect.left,
height: targetBoundingRect.height,
width: targetBoundingRect.width,
}; };
if (action.index > targetIndex) {
if (action.index >= (parentNode.children?.length ?? 1)) {
// If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom).
placeholderDimensions.top +=
parentNode.flexDirection === FlexDirection.Column &&
targetBoundingRect.height / 2;
placeholderDimensions.left +=
parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2;
} else {
// Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node
placeholderDimensions.top +=
parentNode.flexDirection === FlexDirection.Column &&
(3 * targetBoundingRect.height) / 4;
placeholderDimensions.left +=
parentNode.flexDirection === FlexDirection.Row &&
(3 * targetBoundingRect.width) / 4;
}
}
const placeholderTransform = setTransform(placeholderDimensions); const placeholderTransform = setTransform(placeholderDimensions);
newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />; newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />;
} }
break;
} }
break; default:
// No-op
break;
} }
case LayoutTreeActionType.Swap: {
const action = layoutTreeState.pendingAction as LayoutTreeSwapNodeAction;
// console.log("placeholder for swap", action);
const targetNodeId = action.node1Id;
const targetRef = nodeRefs.get(targetNodeId);
if (targetRef?.current) {
const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
const targetBoundingRect = targetRef.current.getBoundingClientRect();
const placeholderDimensions: Dimensions = {
top: targetBoundingRect.top - overlayBoundingRect.top,
left: targetBoundingRect.left - overlayBoundingRect.left,
height: targetBoundingRect.height,
width: targetBoundingRect.width,
};
const placeholderTransform = setTransform(placeholderDimensions);
newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />;
}
break;
}
default:
// No-op
break;
} }
} setPlaceholderOverlay(newPlaceholderOverlay);
setPlaceholderOverlay(newPlaceholderOverlay); }),
}, [layoutTreeState.pendingAction, nodeRefs, overlayContainerRef]); [nodeRefs, overlayContainerRef, layoutTreeState.rootNode]
);
useEffect(() => {
updatePlaceholder(layoutTreeState.pendingAction);
}, [layoutTreeState.pendingAction, updatePlaceholder]);
return ( return (
<div className="placeholder-container" style={style}> <div className="placeholder-container" style={style}>
{placeholderOverlay} {placeholderOverlay}
</div> </div>
); );
}; });

View File

@ -214,6 +214,7 @@ export type WritableLayoutTreeStateAtom<T> = WritableAtom<LayoutTreeState<T>, [v
export type ContentRenderer<T> = ( export type ContentRenderer<T> = (
data: T, data: T,
ready: boolean, ready: boolean,
disablePointerEvents: boolean,
onMagnifyToggle: () => void, onMagnifyToggle: () => void,
onClose: () => void, onClose: () => void,
dragHandleRef: React.RefObject<HTMLDivElement> dragHandleRef: React.RefObject<HTMLDivElement>