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.
This commit is contained in:
Evan Simkowitz 2024-11-13 18:00:13 -08:00 committed by GitHub
parent 31d0aa114d
commit 3fcf209b52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 272 additions and 56 deletions

View File

@ -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;

View File

@ -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(<IconButton key="settings" decl={settingsDecl} className="block-frame-settings" />);
endIconsElem.push(
<OptMagnifyButton
key="unmagnify"
magnified={magnified}
toggleMagnify={nodeModel.toggleMagnify}
disabled={magnifyDisabled}
/>
);
if (ephemeral) {
const addToLayoutDecl: IconButtonDecl = {
elemtype: "iconbutton",
icon: "circle-plus",
title: "Add to Layout",
click: () => {
nodeModel.addEphemeralNodeToLayout();
},
};
endIconsElem.push(<IconButton key="add-to-layout" decl={addToLayoutDecl} />);
} else {
endIconsElem.push(
<OptMagnifyButton
key="unmagnify"
magnified={magnified}
toggleMagnify={nodeModel.toggleMagnify}
disabled={magnifyDisabled}
/>
);
}
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 (
<div className="block-frame-default-header" ref={dragHandleRef} onContextMenu={onContextMenu}>
<div
className="block-frame-default-header"
ref={dragHandleRef}
onContextMenu={onContextMenu}
draggable={!(preview || magnified || ephemeral)}
>
{preIconButtonElem}
<div className="block-frame-default-header-iconview">
{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<boolean>;
const connModalOpen = jotai.useAtomValue(changeConnModalAtom);
const isMagnified = jotai.useAtomValue(nodeModel.isMagnified);
const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral);
const connBtnRef = React.useRef<HTMLDivElement>();
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}

View File

@ -341,17 +341,21 @@ function getApi(): ElectronApi {
return (window as any).api;
}
async function createBlock(blockDef: BlockDef, magnified = false): Promise<string> {
async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = false): Promise<string> {
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;
}

View File

@ -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;

View File

@ -240,7 +240,7 @@ export class WaveAiModel implements ViewModel {
file: path,
},
};
await createBlock(blockDef, true);
await createBlock(blockDef, false, true);
});
},
});

View File

@ -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
<div key="display" ref={layoutModel.displayContainerRef} className="display-container">
<ResizeHandleWrapper layoutModel={layoutModel} />
<DisplayNodesWrapper layoutModel={layoutModel} />
<NodeBackdrops layoutModel={layoutModel} />
</div>
<Placeholder key="placeholder" layoutModel={layoutModel} style={{ top: 10000, ...overlayTransform }} />
<OverlayNodeWrapper layoutModel={layoutModel} />
@ -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 && (
<div
className="magnified-node-backdrop"
onClick={() => {
layoutModel.magnifyNodeToggle(magnifiedNodeId);
}}
/>
)}
{showEphemeralBackdrop && (
<div
className="ephemeral-node-backdrop"
onClick={() => {
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) => {
<div
className={clsx("tile-node", {
dragging: isDragging,
magnified: addlProps?.isMagnifiedNode,
"last-magnified": addlProps?.isLastMagnifiedNode,
})}
key={node.id}
ref={tileNodeRef}

View File

@ -171,6 +171,8 @@ export class LayoutModel {
* Atom pointing to the currently focused node.
*/
focusedNode: Atom<LayoutNode>;
// 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<LayoutNode>;
/**
* 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<string, LayoutNodeAdditionalProps>,
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<string, LayoutNodeAdditionalProps>,
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.
*/

View File

@ -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 {

View File

@ -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<number>;
nodeId: string;
blockId: string;
addEphemeralNodeToLayout: () => void;
animationTimeS: Atom<number>;
isResizing: Atom<boolean>;
isFocused: Atom<boolean>;
isMagnified: Atom<boolean>;
isEphemeral: Atom<boolean>;
ready: Atom<boolean>;
disablePointerEvents: Atom<boolean>;
toggleMagnify: () => void;

View File

@ -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,
};
}