From 3fcf209b5263eff16cce8835c7c03f843d858cf5 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Wed, 13 Nov 2024 18:00:13 -0800 Subject: [PATCH] Add ephemeral block support (#1275) Ephemeral blocks can now be added to the LayoutModel for a tab. Only one ephemeral block can exist at a time. It is placed above all other blocks, including the magnified blocks. Updates how magnified and ephemeral blocks overlay the other blocks. Now, there's a blurred backdrop behind them that will obscure the other blocks. As a result of this, the overlayed blocks are now translucent. --- frontend/app/block/block.less | 12 ++- frontend/app/block/blockframe.tsx | 43 ++++++-- frontend/app/store/global.ts | 10 +- frontend/app/theme.less | 12 ++- frontend/app/view/waveai/waveai.tsx | 2 +- frontend/layout/lib/TileLayout.tsx | 54 +++++++++- frontend/layout/lib/layoutModel.ts | 158 ++++++++++++++++++++++++---- frontend/layout/lib/tilelayout.less | 28 +++-- frontend/layout/lib/types.ts | 5 +- frontend/layout/lib/utils.ts | 4 +- 10 files changed, 272 insertions(+), 56 deletions(-) diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less index 637f4816f..8fa0af30e 100644 --- a/frontend/app/block/block.less +++ b/frontend/app/block/block.less @@ -61,7 +61,7 @@ } &.block-preview.block-frame-default .block-frame-default-inner .block-frame-default-header { - background-color: rgba(0, 0, 0, 0.7); + background-color: rgb(from var(--block-bg-color) r g b / 70%); } &.block-frame-default { @@ -254,7 +254,7 @@ } .block-frame-preview { - background-color: rgba(0, 0, 0, 0.7); + background-color: rgb(from var(--block-bg-color) r g b / 70%); width: 100%; flex-grow: 1; border-bottom-left-radius: var(--block-border-radius); @@ -271,6 +271,12 @@ } } + &.magnified, + &.ephemeral { + background-color: rgb(from var(--block-bg-color) r g b / 60%); + backdrop-filter: blur(10px); + } + .connstatus-overlay { position: absolute; top: calc(var(--header-height) + 6px); @@ -385,7 +391,7 @@ &.show-block-mask .block-mask-inner { margin-top: var(--header-height); // TODO fix this magic - background-color: rgba(0, 0, 0, 0.5); + background-color: rgb(from var(--block-bg-color) r g b / 50%); height: calc(100% - var(--header-height)); width: 100%; display: flex; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 0baf835fc..408997747 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -120,6 +120,7 @@ function computeEndIcons( const endIconsElem: JSX.Element[] = []; const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons); const magnified = jotai.useAtomValue(nodeModel.isMagnified); + const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral); const numLeafs = jotai.useAtomValue(nodeModel.numLeafs); const magnifyDisabled = numLeafs <= 1; @@ -133,14 +134,27 @@ function computeEndIcons( click: onContextMenu, }; endIconsElem.push(); - endIconsElem.push( - - ); + if (ephemeral) { + const addToLayoutDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "circle-plus", + title: "Add to Layout", + click: () => { + nodeModel.addEphemeralNodeToLayout(); + }, + }; + endIconsElem.push(); + } else { + endIconsElem.push( + + ); + } + const closeDecl: IconButtonDecl = { elemtype: "iconbutton", icon: "xmark-large", @@ -166,6 +180,7 @@ const BlockFrame_Header = ({ const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText); const magnified = jotai.useAtomValue(nodeModel.isMagnified); + const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); const dragHandleRef = preview ? null : nodeModel.dragHandleRef; @@ -221,7 +236,12 @@ const BlockFrame_Header = ({ } return ( -
+
{preIconButtonElem}
{viewIconElem} @@ -309,7 +329,6 @@ const ConnStatusOverlay = React.memo( const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); const width = domRect?.width; const [showError, setShowError] = React.useState(false); - const blockNum = jotai.useAtomValue(nodeModel.blockNum); React.useEffect(() => { if (width) { @@ -421,6 +440,8 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { return jotai.atom(false); }) as jotai.PrimitiveAtom; const connModalOpen = jotai.useAtomValue(changeConnModalAtom); + const isMagnified = jotai.useAtomValue(nodeModel.isMagnified); + const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); const connBtnRef = React.useRef(); React.useEffect(() => { @@ -476,6 +497,8 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { "block-focused": isFocused || preview, "block-preview": preview, "block-no-highlight": numBlocksInTab === 1, + ephemeral: isEphemeral, + magnified: isMagnified, })} data-blockid={nodeModel.blockId} onClick={blockModel?.onClick} diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index f71d925aa..e068273c4 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -341,17 +341,21 @@ function getApi(): ElectronApi { return (window as any).api; } -async function createBlock(blockDef: BlockDef, magnified = false): Promise { +async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = false): Promise { + const tabId = globalStore.get(atoms.staticTabId); + const layoutModel = getLayoutModelForTabById(tabId); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; const blockId = await ObjectService.CreateBlock(blockDef, rtOpts); + if (ephemeral) { + layoutModel.newEphemeralNode(blockId); + return blockId; + } const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, node: newLayoutNode(undefined, undefined, undefined, { blockId }), magnified, focused: true, }; - const tabId = globalStore.get(atoms.staticTabId); - const layoutModel = getLayoutModelForTabById(tabId); layoutModel.treeReducer(insertNodeAction); return blockId; } diff --git a/frontend/app/theme.less b/frontend/app/theme.less index 1904523a7..724cde6d8 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -56,10 +56,14 @@ --zindex-tab-name: 3; --zindex-layout-display-container: 0; --zindex-layout-last-magnified-node: 1; - --zindex-layout-resize-handle: 2; - --zindex-layout-placeholder-container: 3; - --zindex-layout-overlay-container: 4; - --zindex-layout-magnified-node: 5; + --zindex-layout-last-ephemeral-node: 2; + --zindex-layout-resize-handle: 3; + --zindex-layout-placeholder-container: 4; + --zindex-layout-overlay-container: 5; + --zindex-layout-magnified-node-backdrop: 6; + --zindex-layout-magnified-node: 7; + --zindex-layout-ephemeral-node-backdrop: 8; + --zindex-layout-ephemeral-node: 9; --zindex-block-mask-inner: 10; --zindex-flash-error-container: 550; --zindex-app-background: -1; diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index 92e1cc9b6..a3bc3b2a4 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -240,7 +240,7 @@ export class WaveAiModel implements ViewModel { file: path, }, }; - await createBlock(blockDef, true); + await createBlock(blockDef, false, true); }); }, }); diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index e8cfb6104..e2e7d8b6f 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -57,6 +57,7 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr const setActiveDrag = useSetAtom(layoutModel.activeDrag); const setReady = useSetAtom(layoutModel.ready); const isResizing = useAtomValue(layoutModel.isResizing); + const ephemeralNode = useAtomValue(layoutModel.ephemeralNode); const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({ activeDrag: monitor.isDragging(), @@ -121,6 +122,7 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
+
@@ -130,6 +132,55 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr } export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; +function NodeBackdrops({ layoutModel }: { layoutModel: LayoutModel }) { + const ephemeralNode = useAtomValue(layoutModel.ephemeralNode); + const magnifiedNodeId = useAtomValue(layoutModel.treeStateAtom).magnifiedNodeId; + + const [showMagnifiedBackdrop, setShowMagnifiedBackdrop] = useState(!!ephemeralNode); + const [showEphemeralBackdrop, setShowEphemeralBackdrop] = useState(!!magnifiedNodeId); + + const debouncedCallback = useCallback( + debounce(100, (callback: () => void) => callback()), + [] + ); + + useEffect(() => { + if (magnifiedNodeId && !showMagnifiedBackdrop) { + debouncedCallback(() => setShowMagnifiedBackdrop(true)); + } + if (!magnifiedNodeId) { + setShowMagnifiedBackdrop(false); + } + if (ephemeralNode && !showEphemeralBackdrop) { + debouncedCallback(() => setShowEphemeralBackdrop(true)); + } + if (!ephemeralNode) { + setShowEphemeralBackdrop(false); + } + }, [ephemeralNode, magnifiedNodeId]); + + return ( + <> + {showMagnifiedBackdrop && ( +
{ + layoutModel.magnifyNodeToggle(magnifiedNodeId); + }} + /> + )} + {showEphemeralBackdrop && ( +
{ + layoutModel.closeNode(ephemeralNode?.id); + }} + /> + )} + + ); +} + interface DisplayNodesWrapperProps { /** * The layout tree state. @@ -173,7 +224,6 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => { () => ({ type: dragItemType, item: () => node, - canDrag: () => !addlProps?.isMagnifiedNode, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), @@ -243,8 +293,6 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => {
; + + // TODO: Nodes that need to be placed at higher z-indices should probably be handled by an ordered list, rather than individual properties. /** * The currently magnified node. */ @@ -179,6 +181,14 @@ export class LayoutModel { * The last node to be magnified, other than the current magnified node, if set. This node should sit at a higher z-index than the others so that it floats above the other nodes as it returns to its original position. */ lastMagnifiedNodeId: string; + /** + * Atom holding an ephemeral node that is not part of the layout tree. This node displays above all other nodes. + */ + ephemeralNode: PrimitiveAtom; + /** + * The last node to be an ephemeral node. This node should sit at a higher z-index than the others so that it floats above the other nodes as it returns to its original position. + */ + lastEphemeralNodeId: string; /** * The size of the resize handles, in CSS pixels. @@ -267,6 +277,8 @@ export class LayoutModel { } }); + this.ephemeralNode = atom(); + this.focusedNode = atom((get) => { const treeState = get(this.treeStateAtom); if (treeState.focusedNodeId == null) { @@ -366,6 +378,7 @@ export class LayoutModel { if (this.lastTreeStateGeneration < this.treeState.generation) { if (this.magnifiedNodeId !== this.treeState.magnifiedNodeId) { this.lastMagnifiedNodeId = this.magnifiedNodeId; + this.lastEphemeralNodeId = undefined; this.magnifiedNodeId = this.treeState.magnifiedNodeId; } this.updateTree(); @@ -486,11 +499,28 @@ export class LayoutModel { ? (pendingAction as LayoutTreeResizeNodeAction) : null; const resizeHandleSizePx = this.getter(this.resizeHandleSizePx); + + const boundingRect = this.getBoundingRect(); + const callback = (node: LayoutNode) => - this.updateTreeHelper(node, newAdditionalProps, newLeafs, resizeHandleSizePx, resizeAction); + this.updateTreeHelper( + node, + newAdditionalProps, + newLeafs, + resizeHandleSizePx, + boundingRect, + resizeAction + ); if (balanceTree) this.treeState.rootNode = balanceNode(this.treeState.rootNode, callback); else walkNodes(this.treeState.rootNode, callback); + // Process ephemeral node, if present. + const ephemeralNode = this.getter(this.ephemeralNode); + if (ephemeralNode) { + console.log("updateTree ephemeralNode", ephemeralNode); + this.updateEphemeralNodeProps(ephemeralNode, newAdditionalProps, newLeafs, boundingRect); + } + this.treeState.leafOrder = getLeafOrder(newLeafs, newAdditionalProps); this.validateFocusedNode(this.treeState.leafOrder); this.validateMagnifiedNode(this.treeState.leafOrder, newAdditionalProps); @@ -516,23 +546,14 @@ export class LayoutModel { additionalPropsMap: Record, leafs: LayoutNode[], resizeHandleSizePx: number, + boundingRect: Dimensions, resizeAction?: LayoutTreeResizeNodeAction ) { - /** - * Gets normalized dimensions for the TileLayout container. - * @returns The normalized dimensions for the TileLayout container. - */ - const getBoundingRect: () => Dimensions = () => { - const boundingRect = this.displayContainerRef.current.getBoundingClientRect(); - return { top: 0, left: 0, width: boundingRect.width, height: boundingRect.height }; - }; - if (!node.children?.length) { leafs.push(node); const addlProps = additionalPropsMap[node.id]; if (addlProps) { if (this.magnifiedNodeId === node.id) { - const boundingRect = getBoundingRect(); const transform = setTransform( { top: boundingRect.height * 0.05, @@ -540,12 +561,17 @@ export class LayoutModel { width: boundingRect.width * 0.9, height: boundingRect.height * 0.9, }, - true + true, + true, + "var(--zindex-layout-magnified-node)" ); addlProps.transform = transform; - addlProps.isMagnifiedNode = true; } - addlProps.isLastMagnifiedNode = this.lastMagnifiedNodeId === node.id; + if (this.lastMagnifiedNodeId === node.id) { + addlProps.transform.zIndex = "var(--zindex-layout-last-magnified-node)"; + } else if (this.lastEphemeralNodeId === node.id) { + addlProps.transform.zIndex = "var(--zindex-layout-last-ephemeral-node)"; + } } return; } @@ -558,7 +584,7 @@ export class LayoutModel { ? additionalPropsMap[node.id] : { treeKey: "0" }; - const nodeRect: Dimensions = node.id === this.treeState.rootNode.id ? getBoundingRect() : additionalProps.rect; + const nodeRect: Dimensions = node.id === this.treeState.rootNode.id ? boundingRect : additionalProps.rect; const nodeIsRow = node.flexDirection === FlexDirection.Row; const nodePixels = nodeIsRow ? nodeRect.width : nodeRect.height; const totalChildrenSize = node.children.reduce((acc, child) => acc + getNodeSize(child), 0); @@ -615,6 +641,15 @@ export class LayoutModel { }; } + /** + * Gets normalized dimensions for the TileLayout container. + * @returns The normalized dimensions for the TileLayout container. + */ + getBoundingRect: () => Dimensions = () => { + const boundingRect = this.displayContainerRef.current.getBoundingClientRect(); + return { top: 0, left: 0, width: boundingRect.width, height: boundingRect.height }; + }; + /** * The id of the focused node in the layout. */ @@ -627,6 +662,7 @@ export class LayoutModel { * @param leafOrder The new leaf order array to use when searching for stale nodes in the stack. */ private validateFocusedNode(leafOrder: LeafOrderEntry[]) { + console.log("validateFocusedNode", this.treeState.focusedNodeId, this.focusedNodeId, this.focusedNodeIdStack); if (this.treeState.focusedNodeId !== this.focusedNodeId) { // Remove duplicates and stale entries from focus stack. const newFocusedNodeIdStack: string[] = []; @@ -794,6 +830,11 @@ export class LayoutModel { const treeState = get(this.treeStateAtom); return treeState.magnifiedNodeId === nodeid; }), + isEphemeral: atom((get) => { + const ephemeralNode = get(this.ephemeralNode); + return ephemeralNode?.id === nodeid; + }), + addEphemeralNodeToLayout: () => this.addEphemeralNodeToLayout(), animationTimeS: this.animationTimeS, ready: this.ready, disablePointerEvents: this.activeDrag, @@ -907,10 +948,15 @@ export class LayoutModel { */ focusNode(nodeId: string) { if (this.focusedNodeId === nodeId) return; - const layoutNode = findNode(this.treeState?.rootNode, nodeId); + let layoutNode = findNode(this.treeState?.rootNode, nodeId); if (!layoutNode) { - console.error("unable to focus node, cannot find it in tree", nodeId); - return; + const ephemeralNode = this.getter(this.ephemeralNode); + if (ephemeralNode?.id === nodeId) { + layoutNode = ephemeralNode; + } else { + console.error("unable to focus node, cannot find it in tree", nodeId); + return; + } } const action: LayoutTreeFocusNodeAction = { type: LayoutTreeActionType.FocusNode, @@ -931,13 +977,16 @@ export class LayoutModel { * Toggle magnification of a given node. * @param nodeId The id of the node that is being magnified. */ - magnifyNodeToggle(nodeId: string) { + magnifyNodeToggle(nodeId: string, setState = true) { const action: LayoutTreeMagnifyNodeToggleAction = { type: LayoutTreeActionType.MagnifyNodeToggle, nodeId: nodeId, }; - this.treeReducer(action); + // Unset the last ephemeral node id to ensure the magnify animation sits on top of the layout. + this.lastEphemeralNodeId = undefined; + + this.treeReducer(action, setState); } /** @@ -947,9 +996,23 @@ export class LayoutModel { async closeNode(nodeId: string) { const nodeToDelete = findNode(this.treeState.rootNode, nodeId); if (!nodeToDelete) { + // TODO: clean up the ephemeral node handling + // The ephemeral node is not in the tree, so we need to handle it separately. + const ephemeralNode = this.getter(this.ephemeralNode); + if (ephemeralNode?.id === nodeId) { + this.setter(this.ephemeralNode, undefined); + this.treeState.focusedNodeId = undefined; + this.updateTree(false); + this.setTreeStateAtom(true); + await this.onNodeDelete?.(ephemeralNode.data); + return; + } console.error("unable to close node, cannot find it in tree", nodeId); return; } + if (nodeId === this.magnifiedNodeId) { + this.magnifyNodeToggle(nodeId); + } const deleteAction: LayoutTreeDeleteNodeAction = { type: LayoutTreeActionType.DeleteNode, nodeId: nodeId, @@ -965,6 +1028,61 @@ export class LayoutModel { await this.closeNode(this.focusedNodeId); } + newEphemeralNode(blockId: string) { + if (this.getter(this.ephemeralNode)) { + this.closeNode(this.getter(this.ephemeralNode).id); + } + + const ephemeralNode = newLayoutNode(undefined, undefined, undefined, { blockId }); + this.setter(this.ephemeralNode, ephemeralNode); + + const addlProps = this.getter(this.additionalProps); + const leafs = this.getter(this.leafs); + const boundingRect = this.getBoundingRect(); + this.updateEphemeralNodeProps(ephemeralNode, addlProps, leafs, boundingRect); + this.setter(this.additionalProps, addlProps); + this.focusNode(ephemeralNode.id); + } + + addEphemeralNodeToLayout() { + const ephemeralNode = this.getter(this.ephemeralNode); + this.setter(this.ephemeralNode, undefined); + if (this.magnifiedNodeId) { + this.magnifyNodeToggle(this.magnifiedNodeId, false); + } + this.lastEphemeralNodeId = ephemeralNode.id; + if (ephemeralNode) { + const action: LayoutTreeInsertNodeAction = { + type: LayoutTreeActionType.InsertNode, + node: ephemeralNode, + magnified: false, + focused: false, + }; + this.treeReducer(action); + } + } + + updateEphemeralNodeProps( + node: LayoutNode, + addlPropsMap: Record, + leafs: LayoutNode[], + boundingRect: Dimensions + ) { + const transform = setTransform( + { + top: boundingRect.height * 0.075, + left: boundingRect.width * 0.075, + width: boundingRect.width * 0.85, + height: boundingRect.height * 0.85, + }, + true, + true, + "var(--zindex-layout-ephemeral-node)" + ); + addlPropsMap[node.id] = { treeKey: "-1", transform }; + leafs.push(node); + } + /** * Callback that is invoked when a drag operation completes and the pending action should be committed. */ diff --git a/frontend/layout/lib/tilelayout.less b/frontend/layout/lib/tilelayout.less index 5b928855e..86d7988c2 100644 --- a/frontend/layout/lib/tilelayout.less +++ b/frontend/layout/lib/tilelayout.less @@ -84,14 +84,6 @@ backdrop-filter: blur(8px); } - &.magnified { - background-color: var(--block-bg-solid-color); - z-index: var(--zindex-layout-magnified-node); - } - &.last-magnified { - z-index: var(--zindex-layout-last-magnified-node); - } - .tile-leaf { overflow: hidden; } @@ -109,11 +101,29 @@ } } - &:not(:only-child, .magnified) .tile-leaf { + &:not(:only-child) .tile-leaf { padding: calc(var(--gap-size-px) / 2); } } + .magnified-node-backdrop, + .ephemeral-node-backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + backdrop-filter: blur(2px); + } + + .magnified-node-backdrop { + z-index: var(--zindex-layout-magnified-node-backdrop); + } + + .ephemeral-node-backdrop { + z-index: var(--zindex-layout-ephemeral-node-backdrop); + } + &.animate { .tile-node, .placeholder { diff --git a/frontend/layout/lib/types.ts b/frontend/layout/lib/types.ts index deac3d4aa..531bb8a52 100644 --- a/frontend/layout/lib/types.ts +++ b/frontend/layout/lib/types.ts @@ -332,8 +332,7 @@ export interface LayoutNodeAdditionalProps { rect?: Dimensions; pixelToSizeRatio?: number; resizeHandles?: ResizeHandleProps[]; - isMagnifiedNode?: boolean; - isLastMagnifiedNode?: boolean; + isLastEphemeralNode?: boolean; } export interface NodeModel { @@ -343,10 +342,12 @@ export interface NodeModel { numLeafs: Atom; nodeId: string; blockId: string; + addEphemeralNodeToLayout: () => void; animationTimeS: Atom; isResizing: Atom; isFocused: Atom; isMagnified: Atom; + isEphemeral: Atom; ready: Atom; disablePointerEvents: Atom; toggleMagnify: () => void; diff --git a/frontend/layout/lib/utils.ts b/frontend/layout/lib/utils.ts index aae7b6668..2ade53a69 100644 --- a/frontend/layout/lib/utils.ts +++ b/frontend/layout/lib/utils.ts @@ -61,7 +61,8 @@ export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord export function setTransform( { top, left, width, height }: Dimensions, setSize = true, - roundVals = true + roundVals = true, + zIndex?: number | string ): CSSProperties { // Replace unitless items with px const topRounded = roundVals ? Math.floor(top) : top; @@ -80,6 +81,7 @@ export function setTransform( width: setSize ? `${widthRounded}px` : undefined, height: setSize ? `${heightRounded}px` : undefined, position: "absolute", + zIndex: zIndex, }; }