Implement outer drop direction, add rudimentary drag preview image rendering (#29)

This PR adds support for Outer variants of each DropDirection.

When calculating the drop direction, the cursor position is calculated
relevant to the box over which it is hovering. The following diagram
shows how drop directions are calculated. The colored in center is
currently not supported, it is assigned to the top, bottom, left, right
direction for now, though it will ultimately be its own distinct
direction.

![IMG_3505](https://github.com/wavetermdev/thenextwave/assets/16651283/a7ea7387-b95d-4831-9e29-d3225b824c97)

When an outer drop direction is provided for a move operation, if the
reference node flexes in the same axis as the drop direction, the new
node will be inserted at the same level as the parent of the reference
node. If the reference node flexes in a different direction or the
reference node does not have a grandparent, the operation will fall back
to its non-Outer variant.

This also removes some chatty debug statements, adds a blur to the
currently-dragging node to indicate that it cannot be dropped onto, and
simplifies the deriving of the layout state atom from the tab atom so
there's no longer another intermediate derived atom for the layout node.

This also adds rudimentary support for rendering custom preview images
for any tile being dragged. Right now, this is a simple block containing
the block ID, but this can be anything. This resolves an issue where
letting React-DnD generate its own previews could take up to a half
second, and would block dragging until complete. For Monaco, this was
outright failing.

It also fixes an issue where the tile layout could animate on first
paint. Now, I use React Suspense to prevent the layout from displaying
until all the children have loaded.
This commit is contained in:
Evan Simkowitz 2024-06-11 13:03:41 -07:00 committed by GitHub
parent 45a9a95e38
commit 0a45311f30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 374 additions and 146 deletions

View File

@ -11,29 +11,6 @@
overflow: hidden;
min-height: 0;
.block-header {
display: flex;
flex-direction: row;
flex-shrink: 0;
height: 30px;
width: 100%;
align-items: center;
justify-content: center;
background-color: var(--panel-bg-color);
.block-header-text {
padding-left: 5px;
}
.close-button {
font-size: 12px;
padding-right: 5px;
&:hover {
cursor: pointer;
}
}
}
.block-content {
display: flex;
flex-grow: 1;
@ -43,3 +20,27 @@
padding: 5px;
}
}
.block-header {
display: flex;
flex-direction: row;
flex-shrink: 0;
height: 30px;
width: 100%;
align-items: center;
justify-content: center;
background-color: var(--panel-bg-color);
.block-header-text {
padding: 0 5px;
flex-grow: 1;
}
.close-button {
font-size: 12px;
padding-right: 5px;
&:hover {
cursor: pointer;
}
}
}

View File

@ -14,9 +14,26 @@ import "./block.less";
interface BlockProps {
blockId: string;
onClose: () => void;
onClose?: () => void;
}
const BlockHeader = ({ blockId, onClose }: BlockProps) => {
const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
return (
<div key="header" className="block-header">
<div className="block-header-text text-fixed">
Block [{blockId.substring(0, 8)}] {blockData.view}
</div>
{onClose && (
<div className="close-button" onClick={onClose}>
<i className="fa fa-solid fa-xmark-large" />
</div>
)}
</div>
);
};
const Block = ({ blockId, onClose }: BlockProps) => {
const blockRef = React.useRef<HTMLDivElement>(null);
@ -36,15 +53,7 @@ const Block = ({ blockId, onClose }: BlockProps) => {
}
return (
<div className="block" ref={blockRef}>
<div key="header" className="block-header">
<div className="block-header-text text-fixed">
Block [{blockId.substring(0, 8)}] {blockData.view}
</div>
<div className="flex-spacer" />
<div className="close-button" onClick={onClose}>
<i className="fa fa-solid fa-xmark-large" />
</div>
</div>
<BlockHeader blockId={blockId} onClose={onClose} />
<div key="content" className="block-content">
<ErrorBoundary>
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{blockElem}</React.Suspense>
@ -54,4 +63,4 @@ const Block = ({ blockId, onClose }: BlockProps) => {
);
};
export { Block };
export { Block, BlockHeader };

View File

@ -280,13 +280,11 @@ function wrapObjectServiceCall<T>(fnName: string, ...args: any[]): Promise<T> {
// should provide getFn if it is available (e.g. inside of a jotai atom)
// otherwise it will use the globalStore.get function
function getObjectValue<T>(oref: string, getFn?: jotai.Getter): T {
console.log("getObjectValue", oref);
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
return null;
}
if (getFn == null) {
console.log("getObjectValue", "getFn is null, using globalStore.get");
getFn = globalStore.get;
}
const atomVal = getFn(wov.dataAtom);
@ -303,14 +301,10 @@ function setObjectValue<T extends WaveObj>(value: T, setFn?: jotai.Setter, pushT
return;
}
if (setFn == null) {
console.log("setter null");
setFn = globalStore.set;
}
console.log("Setting", oref, "to", value);
setFn(wov.dataAtom, { value: value, loading: false });
console.log("Setting", oref, "to", value, "done");
if (pushToServer) {
console.log("pushToServer", oref, value);
UpdateObject(value, false);
}
}
@ -340,7 +334,6 @@ export function UpdateObjectMeta(blockId: string, meta: MetadataType): Promise<v
}
export function UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise<WaveObjUpdate[]> {
console.log("UpdateObject", waveObj, returnUpdates);
return wrapObjectServiceCall("UpdateObject", waveObj, returnUpdates);
}
export {

View File

@ -21,3 +21,13 @@
border-radius: 4px;
}
}
.drag-preview {
display: block;
width: 100px;
height: 20px;
border-radius: 2px;
background-color: aquamarine;
color: black;
text-align: center;
}

View File

@ -1,7 +1,7 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Block } from "@/app/block/block";
import { Block, BlockHeader } from "@/app/block/block";
import * as WOS from "@/store/wos";
import { TileLayout } from "@/faraday/index";
@ -27,6 +27,11 @@ const TabContent = ({ tabId }: { tabId: string }) => {
return <Block blockId={tabData.blockId} onClose={onClose} />;
}, []);
const renderPreview = useCallback((tabData: TabLayoutData) => {
console.log("renderPreview", tabData);
return <BlockHeader blockId={tabData.blockId} />;
}, []);
const onNodeDelete = useCallback((data: TabLayoutData) => {
console.log("onNodeDelete", data);
return WOS.DeleteBlock(data.blockId);
@ -49,6 +54,7 @@ const TabContent = ({ tabId }: { tabId: string }) => {
<TileLayout
key={tabId}
renderContent={renderBlock}
renderPreview={renderPreview}
layoutTreeStateAtom={layoutStateAtom}
onNodeDelete={onNodeDelete}
/>

View File

@ -63,11 +63,12 @@ function TabBar({ workspace }: { workspace: Workspace }) {
function Widgets() {
const windowData = jotai.useAtomValue(atoms.waveWindow);
const activeTabAtom = useMemo(() => {
return WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", windowData.activetabid));
return getLayoutStateAtomForTab(
windowData.activetabid,
WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", windowData.activetabid))
);
}, [windowData.activetabid]);
const [, dispatchLayoutStateAction] = useLayoutTreeStateReducerAtom(
getLayoutStateAtomForTab(windowData.activetabid, activeTabAtom)
);
const [, dispatchLayoutStateAction] = useLayoutTreeStateReducerAtom(activeTabAtom);
const addBlockToTab = useCallback(
(blockId: string) => {

View File

@ -6,6 +6,7 @@ import {
CSSProperties,
ReactNode,
RefObject,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
@ -16,6 +17,7 @@ import {
import { useDrag, useDragLayer, useDrop } from "react-dnd";
import useResizeObserver from "@react-hook/resize-observer";
import { toPng } from "html-to-image";
import { useLayoutTreeStateReducerAtom } from "./layoutAtom.js";
import { findNode } from "./layoutNode.js";
import {
@ -27,6 +29,7 @@ import {
LayoutTreeDeleteNodeAction,
LayoutTreeMoveNodeAction,
LayoutTreeState,
PreviewRenderer,
WritableLayoutTreeStateAtom,
} from "./model.js";
import "./tilelayout.less";
@ -35,11 +38,18 @@ import { FlexDirection, setTransform as createTransform, debounce, determineDrop
export interface TileLayoutProps<T> {
layoutTreeStateAtom: WritableLayoutTreeStateAtom<T>;
renderContent: ContentRenderer<T>;
renderPreview?: PreviewRenderer<T>;
onNodeDelete?: (data: T) => Promise<void>;
className?: string;
}
export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent, onNodeDelete }: TileLayoutProps<T>) => {
export const TileLayout = <T,>({
layoutTreeStateAtom,
className,
renderContent,
renderPreview,
onNodeDelete,
}: TileLayoutProps<T>) => {
const overlayContainerRef = useRef<HTMLDivElement>(null);
const displayContainerRef = useRef<HTMLDivElement>(null);
@ -54,6 +64,7 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
const setRef = useCallback(
(id: string, ref: RefObject<HTMLDivElement>) => {
setNodeRefs((prev) => {
// console.log("setRef", id, ref);
prev.set(id, ref);
return prev;
});
@ -64,6 +75,7 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
const deleteRef = useCallback(
(id: string) => {
// console.log("deleteRef", id);
if (nodeRefs.has(id)) {
setNodeRefs((prev) => {
prev.delete(id);
@ -105,6 +117,7 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
for (const leaf of layoutTreeState.leafs) {
const leafRef = nodeRefs.get(leaf.id);
// console.log("current leafRef", leafRef.current);
if (leafRef?.current) {
const leafBounding = leafRef.current.getBoundingClientRect();
const transform = createTransform({
@ -148,16 +161,11 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
// Ensure that we don't see any jostling in the layout when we're rendering it the first time.
// `animate` will be disabled until after the transforms have all applied the first time.
// `overlayVisible` will be disabled until after the overlay has been pushed out of view.
const [animate, setAnimate] = useState(false);
const [overlayVisible, setOverlayVisible] = useState(false);
useEffect(() => {
setTimeout(() => {
setAnimate(true);
}, 50);
setTimeout(() => {
setOverlayVisible(true);
}, 30);
}, []);
const onLeafClose = useCallback(
@ -177,50 +185,54 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
);
return (
<div className={clsx("tile-layout", className, { animate, overlayVisible })}>
<div key="display" ref={displayContainerRef} className="display-container">
{layoutLeafTransforms &&
layoutTreeState.leafs.map((leaf) => {
return (
<TileNode
key={leaf.id}
layoutNode={leaf}
renderContent={renderContent}
transform={layoutLeafTransforms[leaf.id]}
onLeafClose={onLeafClose}
ready={animate}
/>
);
})}
</div>
<Placeholder
key="placeholder"
layoutTreeState={layoutTreeState}
overlayContainerRef={overlayContainerRef}
nodeRefs={nodeRefs}
style={{ top: 10000, ...overlayTransform }}
/>
<div
key="overlay"
ref={overlayContainerRef}
className="overlay-container"
style={{ top: 10000, ...overlayTransform }}
>
<OverlayNode
layoutNode={layoutTreeState.rootNode}
<Suspense>
<div className={clsx("tile-layout", className, { animate })}>
<div key="display" ref={displayContainerRef} className="display-container">
{layoutLeafTransforms &&
layoutTreeState.leafs.map((leaf) => {
return (
<TileNode
key={leaf.id}
layoutNode={leaf}
renderContent={renderContent}
renderPreview={renderPreview}
transform={layoutLeafTransforms[leaf.id]}
onLeafClose={onLeafClose}
ready={animate}
/>
);
})}
</div>
<Placeholder
key="placeholder"
layoutTreeState={layoutTreeState}
dispatch={dispatch}
setRef={setRef}
deleteRef={deleteRef}
overlayContainerRef={overlayContainerRef}
nodeRefs={nodeRefs}
style={{ top: 10000, ...overlayTransform }}
/>
<div
key="overlay"
ref={overlayContainerRef}
className="overlay-container"
style={{ top: 10000, ...overlayTransform }}
>
<OverlayNode
layoutNode={layoutTreeState.rootNode}
layoutTreeState={layoutTreeState}
dispatch={dispatch}
setRef={setRef}
deleteRef={deleteRef}
/>
</div>
</div>
</div>
</Suspense>
);
};
interface TileNodeProps<T> {
layoutNode: LayoutNode<T>;
renderContent: ContentRenderer<T>;
renderPreview?: PreviewRenderer<T>;
onLeafClose: (node: LayoutNode<T>) => void;
ready: boolean;
transform: CSSProperties;
@ -228,9 +240,18 @@ interface TileNodeProps<T> {
const dragItemType = "TILE_ITEM";
const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose, ready }: TileNodeProps<T>) => {
const TileNode = <T,>({
layoutNode,
renderContent,
renderPreview,
transform,
onLeafClose,
ready,
}: TileNodeProps<T>) => {
const tileNodeRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
// Register the node as a draggable item.
const [{ isDragging, dragItem }, drag, dragPreview] = useDrag(
() => ({
type: dragItemType,
@ -243,16 +264,47 @@ const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose, ready
[layoutNode]
);
// TODO: remove debug effect
useEffect(() => {
if (isDragging) {
console.log("drag start", layoutNode.id, layoutNode, dragItem);
}
}, [isDragging]);
// Generate a preview div using the provided renderPreview function. This will be placed in the DOM so we can render an image from it, but it is pushed out of view so the user will not see it.
// No-op if not provided, meaning React-DnD will attempt to generate a preview from the DOM, which is very slow.
const preview = useMemo(() => {
const previewElement = renderPreview?.(layoutNode.data);
console.log("preview", previewElement);
return (
<div className="tile-preview-container">
<div className="tile-preview" ref={previewRef}>
{previewElement}
</div>
</div>
);
}, []);
// Once the preview div is mounted, grab it and render a PNG, then register it with the DnD system. I noticed that if I call this immediately, it occasionally captures an empty HTMLElement.
// I found a hacky workaround of just adding a timeout so the capture doesn't happen until after the first paint.
useEffect(
debounce(() => {
console.log("dragPreview effect");
if (previewRef.current) {
toPng(previewRef.current).then((url) => {
console.log("got preview url", url);
const img = new Image();
img.src = url;
dragPreview(img);
});
}
}, 50),
[previewRef]
);
// Register the tile item as a draggable component
useEffect(() => {
drag(tileNodeRef);
dragPreview(tileNodeRef);
}, [tileNodeRef]);
const onClose = useCallback(() => {
@ -267,11 +319,11 @@ const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose, ready
</div>
)
);
}, [, layoutNode.data, ready, onClose]);
}, [layoutNode.data, ready, onClose]);
return (
<div
className="tile-node"
className={clsx("tile-node", { dragging: isDragging })}
ref={tileNodeRef}
id={layoutNode.id}
style={{
@ -281,6 +333,7 @@ const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose, ready
}}
>
{leafContent}
{preview}
</div>
);
};
@ -424,7 +477,7 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
const targetBoundingRect = targetRef.current.getBoundingClientRect();
let placeholderTransform: CSSProperties;
// Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent.
const placeholderHeight =
parentNode.flexDirection === FlexDirection.Column
? targetBoundingRect.height / 2
@ -433,27 +486,32 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
parentNode.flexDirection === FlexDirection.Row
? targetBoundingRect.width / 2
: targetBoundingRect.width;
// Default to placing the placeholder in the first half of the target node.
let placeholderTop = targetBoundingRect.top - overlayBoundingRect.top;
let placeholderLeft = targetBoundingRect.left - overlayBoundingRect.left;
if (action.index > targetIndex) {
placeholderTransform = createTransform({
top:
targetBoundingRect.top +
(parentNode.flexDirection === FlexDirection.Column && targetBoundingRect.height / 2) -
overlayBoundingRect.top,
left:
targetBoundingRect.left +
(parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2) -
overlayBoundingRect.left,
width: placeholderWidth,
height: placeholderHeight,
});
} else {
placeholderTransform = createTransform({
top: targetBoundingRect.top - overlayBoundingRect.top,
left: targetBoundingRect.left - overlayBoundingRect.left,
width: placeholderWidth,
height: placeholderHeight,
});
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).
placeholderTop +=
parentNode.flexDirection === FlexDirection.Column && targetBoundingRect.height / 2;
placeholderLeft +=
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
placeholderTop +=
parentNode.flexDirection === FlexDirection.Column &&
(3 * targetBoundingRect.height) / 4;
placeholderLeft +=
parentNode.flexDirection === FlexDirection.Row && (3 * targetBoundingRect.width) / 4;
}
}
const placeholderTransform = createTransform({
top: placeholderTop,
left: placeholderLeft,
width: placeholderWidth,
height: placeholderHeight,
});
newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />;
}

View File

@ -73,29 +73,34 @@ function getLayoutNodeWaveObjAtomFromTab<T>(
get: Getter
): WritableAtom<LayoutNodeWaveObj<T>, [value: LayoutNodeWaveObj<T>], void> {
const tabValue = get(tabAtom);
console.log("getLayoutNodeWaveObjAtomFromTab tabValue", tabValue);
// console.log("getLayoutNodeWaveObjAtomFromTab tabValue", tabValue);
if (!tabValue) return;
const layoutNodeOref = WOS.makeORef("layout", tabValue.layoutNode);
console.log("getLayoutNodeWaveObjAtomFromTab oref", layoutNodeOref);
// console.log("getLayoutNodeWaveObjAtomFromTab oref", layoutNodeOref);
return WOS.getWaveObjectAtom<LayoutNodeWaveObj<T>>(layoutNodeOref);
}
export function withLayoutNodeAtomFromTab<T>(tabAtom: Atom<Tab>): WritableLayoutNodeAtom<T> {
export function withLayoutStateAtomFromTab<T>(tabAtom: Atom<Tab>): WritableLayoutTreeStateAtom<T> {
const pendingActionAtom = atom<LayoutTreeAction>(null) as PrimitiveAtom<LayoutTreeAction>;
const generationAtom = atom(0) as PrimitiveAtom<number>;
return atom(
(get) => {
console.log("get withLayoutNodeAtomFromTab", tabAtom);
const atom = getLayoutNodeWaveObjAtomFromTab<T>(tabAtom, get);
if (!atom) return null;
const retVal = get(atom)?.node;
console.log("get withLayoutNodeAtomFromTab end", retVal);
return get(atom)?.node;
const waveObjAtom = getLayoutNodeWaveObjAtomFromTab<T>(tabAtom, get);
if (!waveObjAtom) return null;
const layoutState = newLayoutTreeState(get(waveObjAtom)?.node);
layoutState.pendingAction = get(pendingActionAtom);
layoutState.generation = get(generationAtom);
return layoutState;
},
(get, set, value) => {
console.log("set withLayoutNodeAtomFromTab", value);
const waveObjAtom = getLayoutNodeWaveObjAtomFromTab<T>(tabAtom, get);
if (!waveObjAtom) return;
const newWaveObjAtom = { ...get(waveObjAtom), node: value };
set(waveObjAtom, newWaveObjAtom);
set(pendingActionAtom, value.pendingAction);
if (get(generationAtom) !== value.generation) {
const waveObjAtom = getLayoutNodeWaveObjAtomFromTab<T>(tabAtom, get);
if (!waveObjAtom) return;
const newWaveObj = { ...get(waveObjAtom), node: value.rootNode };
set(generationAtom, value.generation);
set(waveObjAtom, newWaveObj);
}
}
);
}
@ -106,11 +111,11 @@ export function getLayoutStateAtomForTab(
): WritableLayoutTreeStateAtom<TabLayoutData> {
let atom = tabLayoutAtomCache.get(tabId);
if (atom) {
console.log("Reusing atom for tab", tabId);
// console.log("Reusing atom for tab", tabId);
return atom;
}
console.log("Creating new atom for tab", tabId);
atom = withLayoutTreeState(withLayoutNodeAtomFromTab<TabLayoutData>(tabAtom));
// console.log("Creating new atom for tab", tabId);
atom = withLayoutStateAtomFromTab<TabLayoutData>(tabAtom);
tabLayoutAtomCache.set(tabId, atom);
return atom;
}

View File

@ -116,10 +116,27 @@ function computeMoveNode<T>(
let newOperation: MoveOperation<T>;
const parent = lazy(() => findParent(rootNode, node.id));
const grandparent = lazy(() => findParent(rootNode, parent().id));
const indexInParent = lazy(() => parent()?.children.findIndex((child) => node.id === child.id));
const indexInGrandparent = lazy(() => grandparent()?.children.findIndex((child) => parent().id === child.id));
const isRoot = rootNode.id === node.id;
switch (direction) {
case DropDirection.OuterTop:
if (node.flexDirection === FlexDirection.Column) {
console.log("outer top column");
const grandparentNode = grandparent();
if (grandparentNode) {
console.log("has grandparent", grandparentNode);
const index = indexInGrandparent();
newOperation = {
parentId: grandparentNode.id,
node: nodeToMove,
index,
};
break;
}
}
case DropDirection.Top:
if (node.flexDirection === FlexDirection.Column) {
newOperation = { parentId: node.id, index: 0, node: nodeToMove };
@ -140,6 +157,21 @@ function computeMoveNode<T>(
};
}
break;
case DropDirection.OuterBottom:
if (node.flexDirection === FlexDirection.Column) {
console.log("outer bottom column");
const grandparentNode = grandparent();
if (grandparentNode) {
console.log("has grandparent", grandparentNode);
const index = indexInGrandparent() + 1;
newOperation = {
parentId: grandparentNode.id,
node: nodeToMove,
index,
};
break;
}
}
case DropDirection.Bottom:
if (node.flexDirection === FlexDirection.Column) {
newOperation = { parentId: node.id, index: 1, node: nodeToMove };
@ -160,6 +192,21 @@ function computeMoveNode<T>(
};
}
break;
case DropDirection.OuterLeft:
if (node.flexDirection === FlexDirection.Row) {
console.log("outer left row");
const grandparentNode = grandparent();
if (grandparentNode) {
console.log("has grandparent", grandparentNode);
const index = indexInGrandparent();
newOperation = {
parentId: grandparentNode.id,
node: nodeToMove,
index,
};
break;
}
}
case DropDirection.Left:
if (node.flexDirection === FlexDirection.Row) {
newOperation = { parentId: node.id, index: 0, node: nodeToMove };
@ -173,6 +220,21 @@ function computeMoveNode<T>(
};
}
break;
case DropDirection.OuterRight:
if (node.flexDirection === FlexDirection.Row) {
console.log("outer right row");
const grandparentNode = grandparent();
if (grandparentNode) {
console.log("has grandparent", grandparentNode);
const index = indexInGrandparent() + 1;
newOperation = {
parentId: grandparentNode.id,
node: nodeToMove,
index,
};
break;
}
}
case DropDirection.Right:
if (node.flexDirection === FlexDirection.Row) {
newOperation = { parentId: node.id, index: 1, node: nodeToMove };
@ -186,8 +248,12 @@ function computeMoveNode<T>(
};
}
break;
case DropDirection.Center:
// TODO: handle center drop
console.log("center drop");
break;
default:
throw new Error("Invalid direction");
throw new Error(`Invalid direction: ${direction}`);
}
if (newOperation) layoutTreeState.pendingAction = { type: LayoutTreeActionType.Move, ...newOperation };
@ -217,7 +283,9 @@ function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMove
}
if (!parent && action.insertAtRoot) {
addIntermediateNode(rootNode);
if (!rootNode.children) {
addIntermediateNode(rootNode);
}
addChildAt(rootNode, action.index, node);
} else if (parent) {
addChildAt(parent, action.index, node);

View File

@ -133,6 +133,8 @@ export type WritableLayoutTreeStateAtom<T> = WritableAtom<LayoutTreeState<T>, [v
export type ContentRenderer<T> = (data: T, ready: boolean, onClose?: () => void) => React.ReactNode;
export type PreviewRenderer<T> = (data: T) => React.ReactElement;
export interface LayoutNodeWaveObj<T> extends WaveObj {
node: LayoutNode<T>;
}

View File

@ -41,7 +41,14 @@
}
.tile-node {
visibility: hidden;
&.dragging {
filter: blur(8px);
}
.tile-preview-container {
position: absolute;
top: 10000px;
}
}
&.animate {
@ -53,12 +60,6 @@
}
}
&.overlayVisible {
.tile-node {
visibility: unset;
}
}
.tile-leaf,
.overlay-leaf {
display: flex;

View File

@ -16,6 +16,11 @@ export enum DropDirection {
Right = 1,
Bottom = 2,
Left = 3,
OuterTop = 4,
OuterRight = 5,
OuterBottom = 6,
OuterLeft = 7,
Center = 8,
}
export enum FlexDirection {
@ -38,6 +43,15 @@ export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord
// Lies outside of the box
if (y < 0 || y > height || x < 0 || x > width) return undefined;
// TODO: uncomment once center drop is supported
// // Determines if a drop point falls within the center fifth of the box, meaning we should return Center.
// const centerX1 = (2 * width) / 5;
// const centerX2 = (3 * width) / 5;
// const centerY1 = (2 * height) / 5;
// const centerY2 = (3 * width) / 5;
// if (x > centerX1 && x < centerX2 && y > centerY1 && y < centerY2) return DropDirection.Center;
const diagonal1 = y * width - x * height;
const diagonal2 = y * width + x * height - height * width;
@ -55,6 +69,16 @@ export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord
code = 5 - code;
}
// Determines whether a drop is close to an edge of the box, meaning drop direction should be OuterX, instead of X
const xOuter1 = width / 5;
const xOuter2 = width - width / 5;
const yOuter1 = height / 5;
const yOuter2 = height - height / 5;
if (y < yOuter1 || y > yOuter2 || x < xOuter1 || x > xOuter2) {
code += 4;
}
return code;
}

View File

@ -14,42 +14,83 @@ test("determineDropDirection", () => {
const dimensions: Dimensions = {
top: 0,
left: 0,
height: 3,
width: 3,
height: 5,
width: 5,
};
assert.equal(
determineDropDirection(dimensions, {
x: 1.5,
y: 0.5,
x: 2.5,
y: 1.5,
}),
DropDirection.Top
);
assert.equal(
determineDropDirection(dimensions, {
x: 2.5,
y: 3.5,
}),
DropDirection.Bottom
);
assert.equal(
determineDropDirection(dimensions, {
x: 3.5,
y: 2.5,
}),
DropDirection.Right
);
assert.equal(
determineDropDirection(dimensions, {
x: 1.5,
y: 2.5,
}),
DropDirection.Bottom
DropDirection.Left
);
assert.equal(
determineDropDirection(dimensions, {
x: 2.5,
y: 1.5,
y: 0.5,
}),
DropDirection.Right
DropDirection.OuterTop
);
assert.equal(
determineDropDirection(dimensions, {
x: 4.5,
y: 2.5,
}),
DropDirection.OuterRight
);
assert.equal(
determineDropDirection(dimensions, {
x: 2.5,
y: 4.5,
}),
DropDirection.OuterBottom
);
assert.equal(
determineDropDirection(dimensions, {
x: 0.5,
y: 1.5,
y: 2.5,
}),
DropDirection.Left
DropDirection.OuterLeft
);
// TODO: uncomment once center direction is supported
// assert.equal(
// determineDropDirection(dimensions, {
// x: 2.5,
// y: 2.5,
// }),
// DropDirection.Center
// );
assert.equal(
determineDropDirection(dimensions, {
x: 1.5,

View File

@ -57,6 +57,7 @@
"@xterm/xterm": "^5.5.0",
"base64-js": "^1.5.1",
"clsx": "^2.1.1",
"html-to-image": "^1.11.11",
"immer": "^10.1.1",
"jotai": "^2.8.0",
"monaco-editor": "^0.49.0",

View File

@ -6961,6 +6961,13 @@ __metadata:
languageName: node
linkType: hard
"html-to-image@npm:^1.11.11":
version: 1.11.11
resolution: "html-to-image@npm:1.11.11"
checksum: 10c0/0b6349221ad253dfca01d165c589d44341e942faf0273aab28c8b7d86ff2922d3e8e6390f57bf5ddaf6bac9a3b590a8cdaa77d52a363354796dd0e0e05eb35d2
languageName: node
linkType: hard
"html-url-attributes@npm:^3.0.0":
version: 3.0.0
resolution: "html-url-attributes@npm:3.0.0"
@ -11126,6 +11133,7 @@ __metadata:
clsx: "npm:^2.1.1"
eslint: "npm:^9.2.0"
eslint-config-prettier: "npm:^9.1.0"
html-to-image: "npm:^1.11.11"
immer: "npm:^10.1.1"
jotai: "npm:^2.8.0"
less: "npm:^4.2.0"