From c892c39a735e4debf582322bfc5c1bd9935e637a Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Tue, 27 Aug 2024 13:41:36 -0700 Subject: [PATCH] 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. --- frontend/app/block/block.tsx | 41 +++++++++++++++++-------- frontend/app/block/blockframe.tsx | 2 +- frontend/app/tab/tabcontent.less | 2 +- frontend/layout/index.ts | 2 ++ frontend/layout/lib/TileLayout.tsx | 8 +++-- frontend/layout/lib/layoutModel.ts | 32 +++++++++++++++++-- frontend/layout/lib/layoutModelHooks.ts | 29 ++++++++++++++++- frontend/layout/lib/tilelayout.less | 15 +++++---- frontend/layout/lib/types.ts | 3 ++ 9 files changed, 109 insertions(+), 25 deletions(-) diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 846cd0e6..e1d46992 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -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(); + + 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(() => { + 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={() => {}} /> -
+
Loading...}>{viewElem} diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index a6041ef2..8c1c87ad 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -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 ; }); diff --git a/frontend/app/tab/tabcontent.less b/frontend/app/tab/tabcontent.less index e7ada760..6ab83862 100644 --- a/frontend/app/tab/tabcontent.less +++ b/frontend/app/tab/tabcontent.less @@ -11,7 +11,7 @@ justify-content: center; overflow: hidden; position: relative; - padding: 3px 3px 3px 0; + padding-right: 3px; .block-container { display: flex; diff --git a/frontend/layout/index.ts b/frontend/layout/index.ts index 09035f1b..57ce2631 100644 --- a/frontend/layout/index.ts +++ b/frontend/layout/index.ts @@ -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 { diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index 36d23e31..3d9a2906 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -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 ( diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index 7d82272f..447ab0e3 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -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; + /** + * Atom representing the number of leaf nodes in a layout. + */ + numLeafs: Atom; /** * A map of node models for currently-active leafs. */ @@ -197,7 +207,8 @@ export class LayoutModel { renderContent?: ContentRenderer, renderPreview?: PreviewRenderer, onNodeDelete?: (data: TabLayoutData) => Promise, - 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; diff --git a/frontend/layout/lib/layoutModelHooks.ts b/frontend/layout/lib/layoutModelHooks.ts index 2190ab2f..27927de1 100644 --- a/frontend/layout/lib/layoutModelHooks.ts +++ b/frontend/layout/lib/layoutModelHooks.ts @@ -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, 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(); + const [innerRect, setInnerRect] = useState(); + + 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; +} diff --git a/frontend/layout/lib/tilelayout.less b/frontend/layout/lib/tilelayout.less index 05c20fad..570ad371 100644 --- a/frontend/layout/lib/tilelayout.less +++ b/frontend/layout/lib/tilelayout.less @@ -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; diff --git a/frontend/layout/lib/types.ts b/frontend/layout/lib/types.ts index b649ddbe..64307f5c 100644 --- a/frontend/layout/lib/types.ts +++ b/frontend/layout/lib/types.ts @@ -316,9 +316,12 @@ export interface LayoutNodeAdditionalProps { export interface NodeModel { additionalProps: Atom; + animationTimeS: number; + innerRect: Atom; blockNum: Atom; nodeId: string; blockId: string; + isResizing: Atom; isFocused: Atom; isMagnified: Atom; ready: Atom;