Fix block content sizing (#280)

Make the block content sizing update once when its node moves or becomes
magnified. By manually updating this inner sizing rather than letting
the block flow in the DOM, the animations of the block frames are much
smoother.

This also fixes an issue where two scrollbars were being rendered for
the Directory Preview widget.

This also sets zero padding on nodes when there's only a single node
being rendered.
This commit is contained in:
Evan Simkowitz 2024-08-27 13:41:36 -07:00 committed by GitHub
parent 86fc45fc13
commit c892c39a73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 109 additions and 25 deletions

View File

@ -6,7 +6,7 @@ import { PlotView } from "@/app/view/plotview/plotview";
import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview";
import { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems";
import { NodeModel } from "@/layout/index";
import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index";
import { counterInc, getViewModel, registerViewModel, unregisterViewModel } from "@/store/global";
import * as WOS from "@/store/wos";
import * as util from "@/util/util";
@ -122,7 +122,7 @@ const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
const [focusedChild, setFocusedChild] = React.useState(null);
const isFocused = jotai.useAtomValue(nodeModel.isFocused);
const disablePointerEvents = jotai.useAtomValue(nodeModel.disablePointerEvents);
const addlProps = jotai.useAtomValue(nodeModel.additionalProps);
const innerRect = useDebouncedNodeInnerRect(nodeModel);
React.useLayoutEffect(() => {
setBlockClicked(isFocused);
@ -152,6 +152,32 @@ const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
setBlockClicked(true);
}, []);
const [blockContentOffset, setBlockContentOffset] = React.useState<Dimensions>();
React.useEffect(() => {
if (blockRef.current && contentRef.current) {
const blockRect = blockRef.current.getBoundingClientRect();
const contentRect = contentRef.current.getBoundingClientRect();
setBlockContentOffset({
top: 0,
left: 0,
width: blockRect.width - contentRect.width,
height: blockRect.height - contentRect.height,
});
}
}, [blockRef, contentRef]);
const blockContentStyle = React.useMemo<React.CSSProperties>(() => {
const retVal: React.CSSProperties = {
pointerEvents: disablePointerEvents ? "none" : undefined,
};
if (innerRect?.width && innerRect.height && blockContentOffset) {
retVal.width = `calc(${innerRect?.width} - ${blockContentOffset.width}px)`;
retVal.height = `calc(${innerRect?.height} - ${blockContentOffset.height}px)`;
}
return retVal;
}, [innerRect, disablePointerEvents, blockContentOffset]);
const viewElem = React.useMemo(
() => getViewElem(nodeModel.blockId, blockData?.meta?.view, viewModel),
[nodeModel.blockId, blockData?.meta?.view, viewModel]
@ -195,16 +221,7 @@ const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
onChange={() => {}}
/>
</div>
<div
key="content"
className="block-content"
style={{
pointerEvents: disablePointerEvents ? "none" : undefined,
width: addlProps?.transform?.width,
height: addlProps?.transform?.height,
}}
ref={contentRef}
>
<div key="content" className="block-content" ref={contentRef} style={blockContentStyle}>
<ErrorBoundary>
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</React.Suspense>
</ErrorBoundary>

View File

@ -402,7 +402,7 @@ const BlockFrame = React.memo((props: BlockFrameProps) => {
if (!blockId || !blockData) {
return null;
}
let FrameElem = BlockFrame_Default;
const FrameElem = BlockFrame_Default;
const numBlocks = tabData?.blockids?.length ?? 0;
return <FrameElem {...props} numBlocksInTab={numBlocks} />;
});

View File

@ -11,7 +11,7 @@
justify-content: center;
overflow: hidden;
position: relative;
padding: 3px 3px 3px 0;
padding-right: 3px;
.block-container {
display: flex;

View File

@ -8,6 +8,7 @@ import {
getLayoutModelForActiveTab,
getLayoutModelForTab,
getLayoutModelForTabById,
useDebouncedNodeInnerRect,
useLayoutModel,
} from "./lib/layoutModelHooks";
import { newLayoutNode } from "./lib/layoutNode";
@ -44,6 +45,7 @@ export {
NavigateDirection,
newLayoutNode,
TileLayout,
useDebouncedNodeInnerRect,
useLayoutModel,
};
export type {

View File

@ -102,8 +102,12 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
}, []);
const tileStyle = useMemo(
() => ({ "--gap-size-px": `${layoutModel.gapSizePx}px` }) as CSSProperties,
[layoutModel.gapSizePx]
() =>
({
"--gap-size-px": `${layoutModel.gapSizePx}px`,
"--animation-time-s": `${layoutModel.animationTimeS}s`,
}) as CSSProperties,
[layoutModel.gapSizePx, layoutModel.animationTimeS]
);
return (

View File

@ -55,6 +55,7 @@ interface ResizeContext {
const DefaultGapSizePx = 5;
const MinNodeSizePx = 40;
const DefaultAnimationTimeS = 0.15;
export class LayoutModel {
/**
@ -94,6 +95,11 @@ export class LayoutModel {
*/
gapSizePx: number;
/**
* The time a transition animation takes, in seconds.
*/
animationTimeS: number;
/**
* List of nodes that are leafs and should be rendered as a DisplayNode.
*/
@ -102,6 +108,10 @@ export class LayoutModel {
* An ordered list of node ids starting from the top left corner to the bottom right corner.
*/
leafOrder: PrimitiveAtom<string[]>;
/**
* Atom representing the number of leaf nodes in a layout.
*/
numLeafs: Atom<number>;
/**
* A map of node models for currently-active leafs.
*/
@ -197,7 +207,8 @@ export class LayoutModel {
renderContent?: ContentRenderer,
renderPreview?: PreviewRenderer,
onNodeDelete?: (data: TabLayoutData) => Promise<void>,
gapSizePx?: number
gapSizePx?: number,
animationTimeS?: number
) {
this.treeStateAtom = treeStateAtom;
this.getter = getter;
@ -208,9 +219,11 @@ export class LayoutModel {
this.gapSizePx = gapSizePx ?? DefaultGapSizePx;
this.halfResizeHandleSizePx = this.gapSizePx > 5 ? this.gapSizePx : DefaultGapSizePx;
this.resizeHandleSizePx = 2 * this.halfResizeHandleSizePx;
this.animationTimeS = animationTimeS ?? DefaultAnimationTimeS;
this.leafs = atom([]);
this.leafOrder = atom([]);
this.numLeafs = atom((get) => get(this.leafOrder).length);
this.nodeModels = new Map();
this.additionalProps = atom({});
@ -639,12 +652,27 @@ export class LayoutModel {
getNodeModel(node: LayoutNode): NodeModel {
const nodeid = node.id;
const blockId = node.data.blockId;
const addlPropsAtom = this.getNodeAdditionalPropertiesAtom(nodeid);
if (!this.nodeModels.has(nodeid)) {
this.nodeModels.set(nodeid, {
additionalProps: this.getNodeAdditionalPropertiesAtom(nodeid),
additionalProps: addlPropsAtom,
animationTimeS: this.animationTimeS,
innerRect: atom((get) => {
const addlProps = get(addlPropsAtom);
const numLeafs = get(this.numLeafs);
if (numLeafs > 1 && addlProps?.rect) {
return {
width: `${addlProps.transform.width} - ${this.gapSizePx}px`,
height: `${addlProps.transform.height} - ${this.gapSizePx}px`,
} as CSSProperties;
} else {
return null;
}
}),
nodeId: nodeid,
blockId,
blockNum: atom((get) => get(this.leafOrder).indexOf(nodeid) + 1),
isResizing: this.isResizing,
isFocused: atom((get) => {
const treeState = get(this.treeStateAtom);
const isFocused = treeState.focusedNodeId === nodeid;

View File

@ -4,7 +4,7 @@
import { atoms, globalStore, WOS } from "@/app/store/global";
import useResizeObserver from "@react-hook/resize-observer";
import { Atom, useAtomValue } from "jotai";
import { useEffect } from "react";
import { CSSProperties, useEffect, useState } from "react";
import { withLayoutTreeStateAtomFromTab } from "./layoutAtom";
import { LayoutModel } from "./layoutModel";
import { LayoutNode, NodeModel, TileLayoutContents } from "./types";
@ -59,3 +59,30 @@ export function useTileLayout(tabAtom: Atom<Tab>, tileContent: TileLayoutContent
export function useNodeModel(layoutModel: LayoutModel, layoutNode: LayoutNode): NodeModel {
return layoutModel.getNodeModel(layoutNode);
}
export function useDebouncedNodeInnerRect(nodeModel: NodeModel): CSSProperties {
const nodeInnerRect = useAtomValue(nodeModel.innerRect);
const nodeIsResizing = useAtomValue(nodeModel.isResizing);
const [debounceTimeout, setDebounceTimeout] = useState<NodeJS.Timeout>();
const [innerRect, setInnerRect] = useState<CSSProperties>();
useEffect(() => {
if (!nodeIsResizing && nodeInnerRect) {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
setDebounceTimeout(
setTimeout(() => {
console.log("setting inner rect", nodeInnerRect);
setInnerRect(nodeInnerRect);
setDebounceTimeout(null);
}, nodeModel.animationTimeS * 1000)
);
} else {
setInnerRect(null);
}
}, [nodeInnerRect, nodeIsResizing]);
return innerRect;
}

View File

@ -99,12 +99,20 @@
border: 1px solid var(--accent-color);
backdrop-filter: blur(8px);
}
.tile-leaf {
overflow: hidden;
}
&:not(:only-child) .tile-leaf {
padding: calc(var(--gap-size-px) / 2);
}
}
&.animate {
.tile-node,
.placeholder {
transition-duration: 0.15s;
transition-duration: var(--animation-time-s);
transition-timing-function: ease-in;
transition-property: transform, width, height, background-color;
}
@ -116,11 +124,6 @@
width: 100%;
}
.tile-leaf {
overflow: hidden;
padding: calc(var(--gap-size-px) / 2);
}
.placeholder {
background-color: var(--accent-color);
opacity: 0.5;

View File

@ -316,9 +316,12 @@ export interface LayoutNodeAdditionalProps {
export interface NodeModel {
additionalProps: Atom<LayoutNodeAdditionalProps>;
animationTimeS: number;
innerRect: Atom<CSSProperties>;
blockNum: Atom<number>;
nodeId: string;
blockId: string;
isResizing: Atom<boolean>;
isFocused: Atom<boolean>;
isMagnified: Atom<boolean>;
ready: Atom<boolean>;