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,6 +11,16 @@
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
.block-content {
display: flex;
flex-grow: 1;
width: 100%;
overflow: hidden;
min-height: 0;
padding: 5px;
}
}
.block-header { .block-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -22,7 +32,8 @@
background-color: var(--panel-bg-color); background-color: var(--panel-bg-color);
.block-header-text { .block-header-text {
padding-left: 5px; padding: 0 5px;
flex-grow: 1;
} }
.close-button { .close-button {
@ -33,13 +44,3 @@
} }
} }
} }
.block-content {
display: flex;
flex-grow: 1;
width: 100%;
overflow: hidden;
min-height: 0;
padding: 5px;
}
}

View File

@ -14,9 +14,26 @@ import "./block.less";
interface BlockProps { interface BlockProps {
blockId: string; 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 Block = ({ blockId, onClose }: BlockProps) => {
const blockRef = React.useRef<HTMLDivElement>(null); const blockRef = React.useRef<HTMLDivElement>(null);
@ -36,15 +53,7 @@ const Block = ({ blockId, onClose }: BlockProps) => {
} }
return ( return (
<div className="block" ref={blockRef}> <div className="block" ref={blockRef}>
<div key="header" className="block-header"> <BlockHeader blockId={blockId} onClose={onClose} />
<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>
<div key="content" className="block-content"> <div key="content" className="block-content">
<ErrorBoundary> <ErrorBoundary>
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{blockElem}</React.Suspense> <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) // should provide getFn if it is available (e.g. inside of a jotai atom)
// otherwise it will use the globalStore.get function // otherwise it will use the globalStore.get function
function getObjectValue<T>(oref: string, getFn?: jotai.Getter): T { function getObjectValue<T>(oref: string, getFn?: jotai.Getter): T {
console.log("getObjectValue", oref);
let wov = waveObjectValueCache.get(oref); let wov = waveObjectValueCache.get(oref);
if (wov == null) { if (wov == null) {
return null; return null;
} }
if (getFn == null) { if (getFn == null) {
console.log("getObjectValue", "getFn is null, using globalStore.get");
getFn = globalStore.get; getFn = globalStore.get;
} }
const atomVal = getFn(wov.dataAtom); const atomVal = getFn(wov.dataAtom);
@ -303,14 +301,10 @@ function setObjectValue<T extends WaveObj>(value: T, setFn?: jotai.Setter, pushT
return; return;
} }
if (setFn == null) { if (setFn == null) {
console.log("setter null");
setFn = globalStore.set; setFn = globalStore.set;
} }
console.log("Setting", oref, "to", value);
setFn(wov.dataAtom, { value: value, loading: false }); setFn(wov.dataAtom, { value: value, loading: false });
console.log("Setting", oref, "to", value, "done");
if (pushToServer) { if (pushToServer) {
console.log("pushToServer", oref, value);
UpdateObject(value, false); 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[]> { export function UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise<WaveObjUpdate[]> {
console.log("UpdateObject", waveObj, returnUpdates);
return wrapObjectServiceCall("UpdateObject", waveObj, returnUpdates); return wrapObjectServiceCall("UpdateObject", waveObj, returnUpdates);
} }
export { export {

View File

@ -21,3 +21,13 @@
border-radius: 4px; 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. // Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // 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 * as WOS from "@/store/wos";
import { TileLayout } from "@/faraday/index"; import { TileLayout } from "@/faraday/index";
@ -27,6 +27,11 @@ const TabContent = ({ tabId }: { tabId: string }) => {
return <Block blockId={tabData.blockId} onClose={onClose} />; 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) => { const onNodeDelete = useCallback((data: TabLayoutData) => {
console.log("onNodeDelete", data); console.log("onNodeDelete", data);
return WOS.DeleteBlock(data.blockId); return WOS.DeleteBlock(data.blockId);
@ -49,6 +54,7 @@ const TabContent = ({ tabId }: { tabId: string }) => {
<TileLayout <TileLayout
key={tabId} key={tabId}
renderContent={renderBlock} renderContent={renderBlock}
renderPreview={renderPreview}
layoutTreeStateAtom={layoutStateAtom} layoutTreeStateAtom={layoutStateAtom}
onNodeDelete={onNodeDelete} onNodeDelete={onNodeDelete}
/> />

View File

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

View File

@ -6,6 +6,7 @@ import {
CSSProperties, CSSProperties,
ReactNode, ReactNode,
RefObject, RefObject,
Suspense,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@ -16,6 +17,7 @@ import {
import { useDrag, useDragLayer, useDrop } from "react-dnd"; import { useDrag, useDragLayer, useDrop } from "react-dnd";
import useResizeObserver from "@react-hook/resize-observer"; import useResizeObserver from "@react-hook/resize-observer";
import { toPng } from "html-to-image";
import { useLayoutTreeStateReducerAtom } from "./layoutAtom.js"; import { useLayoutTreeStateReducerAtom } from "./layoutAtom.js";
import { findNode } from "./layoutNode.js"; import { findNode } from "./layoutNode.js";
import { import {
@ -27,6 +29,7 @@ import {
LayoutTreeDeleteNodeAction, LayoutTreeDeleteNodeAction,
LayoutTreeMoveNodeAction, LayoutTreeMoveNodeAction,
LayoutTreeState, LayoutTreeState,
PreviewRenderer,
WritableLayoutTreeStateAtom, WritableLayoutTreeStateAtom,
} from "./model.js"; } from "./model.js";
import "./tilelayout.less"; import "./tilelayout.less";
@ -35,11 +38,18 @@ import { FlexDirection, setTransform as createTransform, debounce, determineDrop
export interface TileLayoutProps<T> { export interface TileLayoutProps<T> {
layoutTreeStateAtom: WritableLayoutTreeStateAtom<T>; layoutTreeStateAtom: WritableLayoutTreeStateAtom<T>;
renderContent: ContentRenderer<T>; renderContent: ContentRenderer<T>;
renderPreview?: PreviewRenderer<T>;
onNodeDelete?: (data: T) => Promise<void>; onNodeDelete?: (data: T) => Promise<void>;
className?: string; 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 overlayContainerRef = useRef<HTMLDivElement>(null);
const displayContainerRef = useRef<HTMLDivElement>(null); const displayContainerRef = useRef<HTMLDivElement>(null);
@ -54,6 +64,7 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
const setRef = useCallback( const setRef = useCallback(
(id: string, ref: RefObject<HTMLDivElement>) => { (id: string, ref: RefObject<HTMLDivElement>) => {
setNodeRefs((prev) => { setNodeRefs((prev) => {
// console.log("setRef", id, ref);
prev.set(id, ref); prev.set(id, ref);
return prev; return prev;
}); });
@ -64,6 +75,7 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
const deleteRef = useCallback( const deleteRef = useCallback(
(id: string) => { (id: string) => {
// console.log("deleteRef", id);
if (nodeRefs.has(id)) { if (nodeRefs.has(id)) {
setNodeRefs((prev) => { setNodeRefs((prev) => {
prev.delete(id); prev.delete(id);
@ -105,6 +117,7 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
for (const leaf of layoutTreeState.leafs) { for (const leaf of layoutTreeState.leafs) {
const leafRef = nodeRefs.get(leaf.id); const leafRef = nodeRefs.get(leaf.id);
// console.log("current leafRef", leafRef.current);
if (leafRef?.current) { if (leafRef?.current) {
const leafBounding = leafRef.current.getBoundingClientRect(); const leafBounding = leafRef.current.getBoundingClientRect();
const transform = createTransform({ 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. // 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. // `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 [animate, setAnimate] = useState(false);
const [overlayVisible, setOverlayVisible] = useState(false);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
setAnimate(true); setAnimate(true);
}, 50); }, 50);
setTimeout(() => {
setOverlayVisible(true);
}, 30);
}, []); }, []);
const onLeafClose = useCallback( const onLeafClose = useCallback(
@ -177,7 +185,8 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
); );
return ( return (
<div className={clsx("tile-layout", className, { animate, overlayVisible })}> <Suspense>
<div className={clsx("tile-layout", className, { animate })}>
<div key="display" ref={displayContainerRef} className="display-container"> <div key="display" ref={displayContainerRef} className="display-container">
{layoutLeafTransforms && {layoutLeafTransforms &&
layoutTreeState.leafs.map((leaf) => { layoutTreeState.leafs.map((leaf) => {
@ -186,6 +195,7 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
key={leaf.id} key={leaf.id}
layoutNode={leaf} layoutNode={leaf}
renderContent={renderContent} renderContent={renderContent}
renderPreview={renderPreview}
transform={layoutLeafTransforms[leaf.id]} transform={layoutLeafTransforms[leaf.id]}
onLeafClose={onLeafClose} onLeafClose={onLeafClose}
ready={animate} ready={animate}
@ -215,12 +225,14 @@ export const TileLayout = <T,>({ layoutTreeStateAtom, className, renderContent,
/> />
</div> </div>
</div> </div>
</Suspense>
); );
}; };
interface TileNodeProps<T> { interface TileNodeProps<T> {
layoutNode: LayoutNode<T>; layoutNode: LayoutNode<T>;
renderContent: ContentRenderer<T>; renderContent: ContentRenderer<T>;
renderPreview?: PreviewRenderer<T>;
onLeafClose: (node: LayoutNode<T>) => void; onLeafClose: (node: LayoutNode<T>) => void;
ready: boolean; ready: boolean;
transform: CSSProperties; transform: CSSProperties;
@ -228,9 +240,18 @@ interface TileNodeProps<T> {
const dragItemType = "TILE_ITEM"; 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 tileNodeRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
// Register the node as a draggable item.
const [{ isDragging, dragItem }, drag, dragPreview] = useDrag( const [{ isDragging, dragItem }, drag, dragPreview] = useDrag(
() => ({ () => ({
type: dragItemType, type: dragItemType,
@ -243,16 +264,47 @@ const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose, ready
[layoutNode] [layoutNode]
); );
// TODO: remove debug effect
useEffect(() => { useEffect(() => {
if (isDragging) { if (isDragging) {
console.log("drag start", layoutNode.id, layoutNode, dragItem); console.log("drag start", layoutNode.id, layoutNode, dragItem);
} }
}, [isDragging]); }, [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 // Register the tile item as a draggable component
useEffect(() => { useEffect(() => {
drag(tileNodeRef); drag(tileNodeRef);
dragPreview(tileNodeRef);
}, [tileNodeRef]); }, [tileNodeRef]);
const onClose = useCallback(() => { const onClose = useCallback(() => {
@ -267,11 +319,11 @@ const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose, ready
</div> </div>
) )
); );
}, [, layoutNode.data, ready, onClose]); }, [layoutNode.data, ready, onClose]);
return ( return (
<div <div
className="tile-node" className={clsx("tile-node", { dragging: isDragging })}
ref={tileNodeRef} ref={tileNodeRef}
id={layoutNode.id} id={layoutNode.id}
style={{ style={{
@ -281,6 +333,7 @@ const TileNode = <T,>({ layoutNode, renderContent, transform, onLeafClose, ready
}} }}
> >
{leafContent} {leafContent}
{preview}
</div> </div>
); );
}; };
@ -424,7 +477,7 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect(); const overlayBoundingRect = overlayContainerRef.current.getBoundingClientRect();
const targetBoundingRect = targetRef.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 = const placeholderHeight =
parentNode.flexDirection === FlexDirection.Column parentNode.flexDirection === FlexDirection.Column
? targetBoundingRect.height / 2 ? targetBoundingRect.height / 2
@ -433,27 +486,32 @@ const Placeholder = <T,>({ layoutTreeState, overlayContainerRef, nodeRefs, style
parentNode.flexDirection === FlexDirection.Row parentNode.flexDirection === FlexDirection.Row
? targetBoundingRect.width / 2 ? targetBoundingRect.width / 2
: targetBoundingRect.width; : 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) { if (action.index > targetIndex) {
placeholderTransform = createTransform({ if (action.index >= (parentNode.children?.length ?? 1)) {
top: // 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).
targetBoundingRect.top + placeholderTop +=
(parentNode.flexDirection === FlexDirection.Column && targetBoundingRect.height / 2) - parentNode.flexDirection === FlexDirection.Column && targetBoundingRect.height / 2;
overlayBoundingRect.top, placeholderLeft +=
left: parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2;
targetBoundingRect.left +
(parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2) -
overlayBoundingRect.left,
width: placeholderWidth,
height: placeholderHeight,
});
} else { } else {
placeholderTransform = createTransform({ // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node
top: targetBoundingRect.top - overlayBoundingRect.top, placeholderTop +=
left: targetBoundingRect.left - overlayBoundingRect.left, 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, width: placeholderWidth,
height: placeholderHeight, height: placeholderHeight,
}); });
}
newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />; newPlaceholderOverlay = <div className="placeholder" style={{ ...placeholderTransform }} />;
} }

View File

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

View File

@ -116,10 +116,27 @@ function computeMoveNode<T>(
let newOperation: MoveOperation<T>; let newOperation: MoveOperation<T>;
const parent = lazy(() => findParent(rootNode, node.id)); 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 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; const isRoot = rootNode.id === node.id;
switch (direction) { 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: case DropDirection.Top:
if (node.flexDirection === FlexDirection.Column) { if (node.flexDirection === FlexDirection.Column) {
newOperation = { parentId: node.id, index: 0, node: nodeToMove }; newOperation = { parentId: node.id, index: 0, node: nodeToMove };
@ -140,6 +157,21 @@ function computeMoveNode<T>(
}; };
} }
break; 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: case DropDirection.Bottom:
if (node.flexDirection === FlexDirection.Column) { if (node.flexDirection === FlexDirection.Column) {
newOperation = { parentId: node.id, index: 1, node: nodeToMove }; newOperation = { parentId: node.id, index: 1, node: nodeToMove };
@ -160,6 +192,21 @@ function computeMoveNode<T>(
}; };
} }
break; 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: case DropDirection.Left:
if (node.flexDirection === FlexDirection.Row) { if (node.flexDirection === FlexDirection.Row) {
newOperation = { parentId: node.id, index: 0, node: nodeToMove }; newOperation = { parentId: node.id, index: 0, node: nodeToMove };
@ -173,6 +220,21 @@ function computeMoveNode<T>(
}; };
} }
break; 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: case DropDirection.Right:
if (node.flexDirection === FlexDirection.Row) { if (node.flexDirection === FlexDirection.Row) {
newOperation = { parentId: node.id, index: 1, node: nodeToMove }; newOperation = { parentId: node.id, index: 1, node: nodeToMove };
@ -186,8 +248,12 @@ function computeMoveNode<T>(
}; };
} }
break; break;
case DropDirection.Center:
// TODO: handle center drop
console.log("center drop");
break;
default: default:
throw new Error("Invalid direction"); throw new Error(`Invalid direction: ${direction}`);
} }
if (newOperation) layoutTreeState.pendingAction = { type: LayoutTreeActionType.Move, ...newOperation }; if (newOperation) layoutTreeState.pendingAction = { type: LayoutTreeActionType.Move, ...newOperation };
@ -217,7 +283,9 @@ function moveNode<T>(layoutTreeState: LayoutTreeState<T>, action: LayoutTreeMove
} }
if (!parent && action.insertAtRoot) { if (!parent && action.insertAtRoot) {
if (!rootNode.children) {
addIntermediateNode(rootNode); addIntermediateNode(rootNode);
}
addChildAt(rootNode, action.index, node); addChildAt(rootNode, action.index, node);
} else if (parent) { } else if (parent) {
addChildAt(parent, action.index, node); 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 ContentRenderer<T> = (data: T, ready: boolean, onClose?: () => void) => React.ReactNode;
export type PreviewRenderer<T> = (data: T) => React.ReactElement;
export interface LayoutNodeWaveObj<T> extends WaveObj { export interface LayoutNodeWaveObj<T> extends WaveObj {
node: LayoutNode<T>; node: LayoutNode<T>;
} }

View File

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

View File

@ -16,6 +16,11 @@ export enum DropDirection {
Right = 1, Right = 1,
Bottom = 2, Bottom = 2,
Left = 3, Left = 3,
OuterTop = 4,
OuterRight = 5,
OuterBottom = 6,
OuterLeft = 7,
Center = 8,
} }
export enum FlexDirection { export enum FlexDirection {
@ -38,6 +43,15 @@ export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord
// Lies outside of the box // Lies outside of the box
if (y < 0 || y > height || x < 0 || x > width) return undefined; 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 diagonal1 = y * width - x * height;
const diagonal2 = y * width + x * height - height * width; const diagonal2 = y * width + x * height - height * width;
@ -55,6 +69,16 @@ export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord
code = 5 - code; 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; return code;
} }

View File

@ -14,42 +14,83 @@ test("determineDropDirection", () => {
const dimensions: Dimensions = { const dimensions: Dimensions = {
top: 0, top: 0,
left: 0, left: 0,
height: 3, height: 5,
width: 3, width: 5,
}; };
assert.equal( assert.equal(
determineDropDirection(dimensions, { determineDropDirection(dimensions, {
x: 1.5, x: 2.5,
y: 0.5, y: 1.5,
}), }),
DropDirection.Top 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( assert.equal(
determineDropDirection(dimensions, { determineDropDirection(dimensions, {
x: 1.5, x: 1.5,
y: 2.5, y: 2.5,
}), }),
DropDirection.Bottom DropDirection.Left
); );
assert.equal( assert.equal(
determineDropDirection(dimensions, { determineDropDirection(dimensions, {
x: 2.5, 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( assert.equal(
determineDropDirection(dimensions, { determineDropDirection(dimensions, {
x: 0.5, 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( assert.equal(
determineDropDirection(dimensions, { determineDropDirection(dimensions, {
x: 1.5, x: 1.5,

View File

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

View File

@ -6961,6 +6961,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "html-url-attributes@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "html-url-attributes@npm:3.0.0" resolution: "html-url-attributes@npm:3.0.0"
@ -11126,6 +11133,7 @@ __metadata:
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
eslint: "npm:^9.2.0" eslint: "npm:^9.2.0"
eslint-config-prettier: "npm:^9.1.0" eslint-config-prettier: "npm:^9.1.0"
html-to-image: "npm:^1.11.11"
immer: "npm:^10.1.1" immer: "npm:^10.1.1"
jotai: "npm:^2.8.0" jotai: "npm:^2.8.0"
less: "npm:^4.2.0" less: "npm:^4.2.0"