From 0a45311f3071791b6ceb264bc1c419b57a4c08af Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Tue, 11 Jun 2024 13:03:41 -0700 Subject: [PATCH] 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. --- frontend/app/block/block.less | 47 +++---- frontend/app/block/block.tsx | 31 +++-- frontend/app/store/wos.ts | 7 - frontend/app/tab/tab.less | 10 ++ frontend/app/tab/tab.tsx | 8 +- frontend/app/workspace/workspace.tsx | 9 +- frontend/faraday/lib/TileLayout.tsx | 188 ++++++++++++++++++--------- frontend/faraday/lib/layoutAtom.ts | 39 +++--- frontend/faraday/lib/layoutState.ts | 72 +++++++++- frontend/faraday/lib/model.ts | 2 + frontend/faraday/lib/tilelayout.less | 15 ++- frontend/faraday/lib/utils.ts | 24 ++++ frontend/faraday/tests/utils.test.ts | 59 +++++++-- package.json | 1 + yarn.lock | 8 ++ 15 files changed, 374 insertions(+), 146 deletions(-) diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less index 9f4bdecd1..e48e1a2ce 100644 --- a/frontend/app/block/block.less +++ b/frontend/app/block/block.less @@ -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; + } + } +} diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 912118142..d7df888f8 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -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(WOS.makeORef("block", blockId)); + + return ( +
+
+ Block [{blockId.substring(0, 8)}] {blockData.view} +
+ {onClose && ( +
+ +
+ )} +
+ ); +}; + const Block = ({ blockId, onClose }: BlockProps) => { const blockRef = React.useRef(null); @@ -36,15 +53,7 @@ const Block = ({ blockId, onClose }: BlockProps) => { } return (
-
-
- Block [{blockId.substring(0, 8)}] {blockData.view} -
-
-
- -
-
+
Loading...}>{blockElem} @@ -54,4 +63,4 @@ const Block = ({ blockId, onClose }: BlockProps) => { ); }; -export { Block }; +export { Block, BlockHeader }; diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index da3f05304..bffeb8a23 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -280,13 +280,11 @@ function wrapObjectServiceCall(fnName: string, ...args: any[]): Promise { // should provide getFn if it is available (e.g. inside of a jotai atom) // otherwise it will use the globalStore.get function function getObjectValue(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(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 { - console.log("UpdateObject", waveObj, returnUpdates); return wrapObjectServiceCall("UpdateObject", waveObj, returnUpdates); } export { diff --git a/frontend/app/tab/tab.less b/frontend/app/tab/tab.less index 621cf2510..6efdb348a 100644 --- a/frontend/app/tab/tab.less +++ b/frontend/app/tab/tab.less @@ -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; +} diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 6b3e25cb9..f358764ea 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -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 ; }, []); + const renderPreview = useCallback((tabData: TabLayoutData) => { + console.log("renderPreview", tabData); + return ; + }, []); + const onNodeDelete = useCallback((data: TabLayoutData) => { console.log("onNodeDelete", data); return WOS.DeleteBlock(data.blockId); @@ -49,6 +54,7 @@ const TabContent = ({ tabId }: { tabId: string }) => { diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 816cfe555..e8b172bcd 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -63,11 +63,12 @@ function TabBar({ workspace }: { workspace: Workspace }) { function Widgets() { const windowData = jotai.useAtomValue(atoms.waveWindow); const activeTabAtom = useMemo(() => { - return WOS.getWaveObjectAtom(WOS.makeORef("tab", windowData.activetabid)); + return getLayoutStateAtomForTab( + windowData.activetabid, + WOS.getWaveObjectAtom(WOS.makeORef("tab", windowData.activetabid)) + ); }, [windowData.activetabid]); - const [, dispatchLayoutStateAction] = useLayoutTreeStateReducerAtom( - getLayoutStateAtomForTab(windowData.activetabid, activeTabAtom) - ); + const [, dispatchLayoutStateAction] = useLayoutTreeStateReducerAtom(activeTabAtom); const addBlockToTab = useCallback( (blockId: string) => { diff --git a/frontend/faraday/lib/TileLayout.tsx b/frontend/faraday/lib/TileLayout.tsx index a24aa5f97..4c8cce22a 100644 --- a/frontend/faraday/lib/TileLayout.tsx +++ b/frontend/faraday/lib/TileLayout.tsx @@ -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 { layoutTreeStateAtom: WritableLayoutTreeStateAtom; renderContent: ContentRenderer; + renderPreview?: PreviewRenderer; onNodeDelete?: (data: T) => Promise; className?: string; } -export const TileLayout = ({ layoutTreeStateAtom, className, renderContent, onNodeDelete }: TileLayoutProps) => { +export const TileLayout = ({ + layoutTreeStateAtom, + className, + renderContent, + renderPreview, + onNodeDelete, +}: TileLayoutProps) => { const overlayContainerRef = useRef(null); const displayContainerRef = useRef(null); @@ -54,6 +64,7 @@ export const TileLayout = ({ layoutTreeStateAtom, className, renderContent, const setRef = useCallback( (id: string, ref: RefObject) => { setNodeRefs((prev) => { + // console.log("setRef", id, ref); prev.set(id, ref); return prev; }); @@ -64,6 +75,7 @@ export const TileLayout = ({ 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 = ({ 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 = ({ 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 = ({ layoutTreeStateAtom, className, renderContent, ); return ( -
-
- {layoutLeafTransforms && - layoutTreeState.leafs.map((leaf) => { - return ( - - ); - })} -
- -
- +
+
+ {layoutLeafTransforms && + layoutTreeState.leafs.map((leaf) => { + return ( + + ); + })} +
+ +
+ +
-
+ ); }; interface TileNodeProps { layoutNode: LayoutNode; renderContent: ContentRenderer; + renderPreview?: PreviewRenderer; onLeafClose: (node: LayoutNode) => void; ready: boolean; transform: CSSProperties; @@ -228,9 +240,18 @@ interface TileNodeProps { const dragItemType = "TILE_ITEM"; -const TileNode = ({ layoutNode, renderContent, transform, onLeafClose, ready }: TileNodeProps) => { +const TileNode = ({ + layoutNode, + renderContent, + renderPreview, + transform, + onLeafClose, + ready, +}: TileNodeProps) => { const tileNodeRef = useRef(null); + const previewRef = useRef(null); + // Register the node as a draggable item. const [{ isDragging, dragItem }, drag, dragPreview] = useDrag( () => ({ type: dragItemType, @@ -243,16 +264,47 @@ const TileNode = ({ 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 ( +
+
+ {previewElement} +
+
+ ); + }, []); + + // 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 = ({ layoutNode, renderContent, transform, onLeafClose, ready
) ); - }, [, layoutNode.data, ready, onClose]); + }, [layoutNode.data, ready, onClose]); return (
({ layoutNode, renderContent, transform, onLeafClose, ready }} > {leafContent} + {preview}
); }; @@ -424,7 +477,7 @@ const Placeholder = ({ 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 = ({ 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 =
; } diff --git a/frontend/faraday/lib/layoutAtom.ts b/frontend/faraday/lib/layoutAtom.ts index 110edfe6e..a1e20021f 100644 --- a/frontend/faraday/lib/layoutAtom.ts +++ b/frontend/faraday/lib/layoutAtom.ts @@ -73,29 +73,34 @@ function getLayoutNodeWaveObjAtomFromTab( get: Getter ): WritableAtom, [value: LayoutNodeWaveObj], 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>(layoutNodeOref); } -export function withLayoutNodeAtomFromTab(tabAtom: Atom): WritableLayoutNodeAtom { +export function withLayoutStateAtomFromTab(tabAtom: Atom): WritableLayoutTreeStateAtom { + const pendingActionAtom = atom(null) as PrimitiveAtom; + const generationAtom = atom(0) as PrimitiveAtom; return atom( (get) => { - console.log("get withLayoutNodeAtomFromTab", tabAtom); - const atom = getLayoutNodeWaveObjAtomFromTab(tabAtom, get); - if (!atom) return null; - const retVal = get(atom)?.node; - console.log("get withLayoutNodeAtomFromTab end", retVal); - return get(atom)?.node; + const waveObjAtom = getLayoutNodeWaveObjAtomFromTab(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(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(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 { 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(tabAtom)); + // console.log("Creating new atom for tab", tabId); + atom = withLayoutStateAtomFromTab(tabAtom); tabLayoutAtomCache.set(tabId, atom); return atom; } diff --git a/frontend/faraday/lib/layoutState.ts b/frontend/faraday/lib/layoutState.ts index 95606c779..91ef5f194 100644 --- a/frontend/faraday/lib/layoutState.ts +++ b/frontend/faraday/lib/layoutState.ts @@ -116,10 +116,27 @@ function computeMoveNode( let newOperation: MoveOperation; 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( }; } 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( }; } 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( }; } 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( }; } 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(layoutTreeState: LayoutTreeState, 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); diff --git a/frontend/faraday/lib/model.ts b/frontend/faraday/lib/model.ts index e56b152e7..f804888af 100644 --- a/frontend/faraday/lib/model.ts +++ b/frontend/faraday/lib/model.ts @@ -133,6 +133,8 @@ export type WritableLayoutTreeStateAtom = WritableAtom, [v export type ContentRenderer = (data: T, ready: boolean, onClose?: () => void) => React.ReactNode; +export type PreviewRenderer = (data: T) => React.ReactElement; + export interface LayoutNodeWaveObj extends WaveObj { node: LayoutNode; } diff --git a/frontend/faraday/lib/tilelayout.less b/frontend/faraday/lib/tilelayout.less index ab471efb5..431f1dabf 100644 --- a/frontend/faraday/lib/tilelayout.less +++ b/frontend/faraday/lib/tilelayout.less @@ -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; diff --git a/frontend/faraday/lib/utils.ts b/frontend/faraday/lib/utils.ts index 0f6043965..191edef7c 100644 --- a/frontend/faraday/lib/utils.ts +++ b/frontend/faraday/lib/utils.ts @@ -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; } diff --git a/frontend/faraday/tests/utils.test.ts b/frontend/faraday/tests/utils.test.ts index 7c95ca642..56c60b440 100644 --- a/frontend/faraday/tests/utils.test.ts +++ b/frontend/faraday/tests/utils.test.ts @@ -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, diff --git a/package.json b/package.json index dd8348dbb..d8019d359 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 4e37748bd..d0df0e836 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"