diff --git a/src/components/Editable.tsx b/src/components/Editable.tsx index d1191d1bd2..f7041feb5f 100644 --- a/src/components/Editable.tsx +++ b/src/components/Editable.tsx @@ -34,6 +34,7 @@ import { import preventAutoscroll, { preventAutoscrollEnd } from '../device/preventAutoscroll' import * as selection from '../device/selection' import globals from '../globals' +import useTreeNodeAnimation from '../hooks/useTreeNodeAnimation' import findDescendant from '../selectors/findDescendant' import { anyChild, getAllChildrenAsThoughts } from '../selectors/getChildren' import getContexts from '../selectors/getContexts' @@ -135,6 +136,7 @@ const Editable = ({ const oldValueRef = useRef(value) const nullRef = useRef(null) const contentRef = editableRef || nullRef + const { isAnimating } = useTreeNodeAnimation() /** Used to prevent edit mode from being incorrectly activated on long tap. The default browser behavior must be prevented if setCursorOnThought was just called. */ // https://github.com/cybersemics/em/issues/1793 @@ -593,7 +595,7 @@ const Editable = ({ className={cx( multiline ? multilineRecipe() : null, editableRecipe({ - preventAutoscroll: true, + preventAutoscroll: !isAnimating, }), className, )} diff --git a/src/components/LayoutTree.tsx b/src/components/LayoutTree.tsx index 98b71c9f86..e0fa1eb76c 100644 --- a/src/components/LayoutTree.tsx +++ b/src/components/LayoutTree.tsx @@ -1,5 +1,5 @@ import _ from 'lodash' -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { TransitionGroup } from 'react-transition-group' import { CSSTransitionProps } from 'react-transition-group/CSSTransition' @@ -17,6 +17,7 @@ import { isTouch } from '../browser' import { HOME_PATH } from '../constants' import testFlags from '../e2e/testFlags' import useSortedContext from '../hooks/useSortedContext' +import useTreeNodeAnimation from '../hooks/useTreeNodeAnimation' import attributeEquals from '../selectors/attributeEquals' import calculateAutofocus from '../selectors/calculateAutofocus' import findDescendant from '../selectors/findDescendant' @@ -45,6 +46,7 @@ import unroot from '../util/unroot' import DropEnd from './DropEnd' import FadeTransition from './FadeTransition' import HoverArrow from './HoverArrow' +import TreeNodeAnimationProvider from './TreeNodeAnimation' import VirtualThought, { OnResize } from './VirtualThought' /** 1st Pass: A thought with rendering information after the tree has been linearized. */ @@ -379,213 +381,211 @@ const linearizeTree = ( } /** Renders a thought component for mapped treeThoughtsPositioned. */ -const TreeNode = ({ - belowCursor, - cliff, - depth, - env, - height, - indexChild, - indexDescendant, - isCursor, - isEmpty, - isTableCol1, - isTableCol2, - thoughtKey, - leaf, - path, - prevChild, - showContexts, - isLastVisible, - simplePath, - singleLineHeightWithCliff, - style, - thoughtId, - width, - autofocus, - x, - y: _y, - index, - viewportBottom, - treeThoughtsPositioned, - bulletWidth, - cursorUncleId, - setSize, - cliffPaddingStyle, - dragInProgress, - autofocusDepth, - ...transitionGroupsProps -}: TreeThoughtPositioned & { - thoughtKey: string - index: number - viewportBottom: number - treeThoughtsPositioned: TreeThoughtPositioned[] - bulletWidth: number - cursorUncleId: string | null - setSize: OnResize - cliffPaddingStyle: { paddingBottom: number } - dragInProgress: boolean - autofocusDepth: number -} & Pick) => { - const [y, setY] = useState(_y) - const fadeThoughtRef = useRef(null) - const isLastActionNewThought = useSelector(state => { - const lastPatches = state.undoPatches[state.undoPatches.length - 1] - return lastPatches?.some(patch => patch.actions[0] === 'newThought') - }) - - // true if the last action is any of archive/delete/collapse - const isLastActionDelete = useSelector(state => { - const deleteActions: ActionType[] = [ - 'archiveThought', - 'collapseContext', - 'deleteThought', - 'deleteThoughtWithCursor', - ] - const lastPatches = state.undoPatches[state.undoPatches.length - 1] - return lastPatches?.some(patch => deleteActions.includes(patch.actions[0])) - }) +const TreeNode = forwardRef( + ( + { + belowCursor, + cliff, + depth, + env, + height, + indexChild, + indexDescendant, + isCursor, + isEmpty, + isTableCol1, + isTableCol2, + thoughtKey, + leaf, + path, + prevChild, + showContexts, + isLastVisible, + simplePath, + singleLineHeightWithCliff, + style, + thoughtId, + width, + autofocus, + x, + y: _y, + index, + viewportBottom, + treeThoughtsPositioned, + bulletWidth, + cursorUncleId, + setSize, + cliffPaddingStyle, + dragInProgress, + autofocusDepth, + ...transitionGroupsProps + }: TreeThoughtPositioned & { + thoughtKey: string + index: number + viewportBottom: number + treeThoughtsPositioned: TreeThoughtPositioned[] + bulletWidth: number + cursorUncleId: string | null + setSize: OnResize + cliffPaddingStyle: { paddingBottom: number } + dragInProgress: boolean + autofocusDepth: number + } & Pick, + ref: any, + ) => { + const { y } = useTreeNodeAnimation() + const fadeThoughtRef = useRef(null) + const isLastActionNewThought = useSelector(state => { + const lastPatches = state.undoPatches[state.undoPatches.length - 1] + return lastPatches?.some(patch => patch.actions[0] === 'newThought') + }) - useLayoutEffect(() => { - if (y !== _y) { - // When y changes React re-renders the component with the new value of y. It will result in a visual change in the DOM. - // Because this is a state-driven change, React applies the updated value to the DOM, which causes the browser to recognize that - // a CSS property has changed, thereby triggering the CSS transition. - // Without this additional render, updates get batched and subsequent CSS transitions may not work properly. For example, when moving a thought down, it would not animate. - setY(_y) - } - }, [y, _y]) + // true if the last action is any of archive/delete/collapse + const isLastActionDelete = useSelector(state => { + const deleteActions: ActionType[] = [ + 'archiveThought', + 'collapseContext', + 'deleteThought', + 'deleteThoughtWithCursor', + ] + const lastPatches = state.undoPatches[state.undoPatches.length - 1] + return lastPatches?.some(patch => deleteActions.includes(patch.actions[0])) + }) - // List Virtualization - // Do not render thoughts that are below the viewport. - // Exception: The cursor thought and its previous siblings may temporarily be out of the viewport, such as if when New Subthought is activated on a long context. In this case, the new thought will be created below the viewport and needs to be rendered in order for scrollCursorIntoView to be activated. - // Render virtualized thoughts with their estimated height so that document height is relatively stable. - // Perform this check here instead of in virtualThoughtsPositioned since it changes with the scroll position (though currently `sizes` will change as new thoughts are rendered, causing virtualThoughtsPositioned to re-render anyway). - if (belowCursor && !isCursor && y > viewportBottom + height) return null + // List Virtualization + // Do not render thoughts that are below the viewport. + // Exception: The cursor thought and its previous siblings may temporarily be out of the viewport, such as if when New Subthought is activated on a long context. In this case, the new thought will be created below the viewport and needs to be rendered in order for scrollCursorIntoView to be activated. + // Render virtualized thoughts with their estimated height so that document height is relatively stable. + // Perform this check here instead of in virtualThoughtsPositioned since it changes with the scroll position (though currently `sizes` will change as new thoughts are rendered, causing virtualThoughtsPositioned to re-render anyway). + if (belowCursor && !isCursor && y > viewportBottom + height) return null - const nextThought = isTableCol1 ? treeThoughtsPositioned[index + 1] : null - const previousThought = isTableCol1 ? treeThoughtsPositioned[index - 1] : null + const nextThought = isTableCol1 ? treeThoughtsPositioned[index + 1] : null + const previousThought = isTableCol1 ? treeThoughtsPositioned[index - 1] : null - // Adjust col1 width to remove dead zones between col1 and col2, increase the width by the difference between col1 and col2 minus bullet width - const xCol2 = isTableCol1 ? nextThought?.x || previousThought?.x || 0 : 0 - // Increasing margin-right of thought for filling gaps and moving the thought to the left by adding negative margin from right. - const marginRight = isTableCol1 ? xCol2 - (width || 0) - x - (bulletWidth || 0) : 0 + // Adjust col1 width to remove dead zones between col1 and col2, increase the width by the difference between col1 and col2 minus bullet width + const xCol2 = isTableCol1 ? nextThought?.x || previousThought?.x || 0 : 0 + // Increasing margin-right of thought for filling gaps and moving the thought to the left by adding negative margin from right. + const marginRight = isTableCol1 ? xCol2 - (width || 0) - x - (bulletWidth || 0) : 0 - // Speed up the tree-node's transition (normally layoutNodeAnimationDuration) by 50% on New (Sub)Thought only. - const transition = isLastActionNewThought - ? `left {durations.layoutNodeAnimationFast} ease-out,top {durations.layoutNodeAnimationFast} ease-out` - : `left {durations.layoutNodeAnimation} ease-out,top {durations.layoutNodeAnimation} ease-out` + // Speed up the tree-node's transition (normally layoutNodeAnimationDuration) by 50% on New (Sub)Thought only. + const transition = isLastActionNewThought + ? `left {durations.layoutNodeAnimationFast} ease-out,top {durations.layoutNodeAnimationFast} ease-out` + : `left {durations.layoutNodeAnimation} ease-out,top {durations.layoutNodeAnimation} ease-out` - return ( -
- -
- -
-
- - {/* DropEnd (cliff) */} - {dragInProgress && - cliff < 0 && - // do not render hidden cliffs - // rough autofocus estimate - autofocusDepth - depth < 2 && - Array(-cliff) - .fill(0) - .map((x, i) => { - const pathEnd = -(cliff + i) < path.length ? (path.slice(0, cliff + i) as Path) : HOME_PATH - const cliffDepth = unroot(pathEnd).length - - // After table col2, shift the DropEnd left by the width of col1. - // This correctly positions the drop target for dropping after the table view. - // Otherwise it would be too far to the right. - const dropEndMarginLeft = - isTableCol2 && cliffDepth - depth < 0 ? treeThoughtsPositioned[index - 1].width || 0 : 0 - - return ( -
- -
- ) - })} -
- ) -} + +
+ +
+
+ + {/* DropEnd (cliff) */} + {dragInProgress && + cliff < 0 && + // do not render hidden cliffs + // rough autofocus estimate + autofocusDepth - depth < 2 && + Array(-cliff) + .fill(0) + .map((x, i) => { + const pathEnd = -(cliff + i) < path.length ? (path.slice(0, cliff + i) as Path) : HOME_PATH + const cliffDepth = unroot(pathEnd).length + + // After table col2, shift the DropEnd left by the width of col1. + // This correctly positions the drop target for dropping after the table view. + // Otherwise it would be too far to the right. + const dropEndMarginLeft = + isTableCol2 && cliffDepth - depth < 0 ? treeThoughtsPositioned[index - 1].width || 0 : 0 + + return ( +
+ +
+ ) + })} + + ) + }, +) + +TreeNode.displayName = 'TreeNode' /** Lays out thoughts as DOM siblings with manual x,y positioning. */ const LayoutTree = () => { @@ -593,7 +593,7 @@ const LayoutTree = () => { const treeThoughts = useSelector(linearizeTree, _.isEqual) const fontSize = useSelector(state => state.fontSize) const dragInProgress = useSelector(state => state.dragInProgress) - const ref = useRef(null) + const ref = useRef(null) as React.MutableRefObject const indentDepth = useSelector(state => state.cursor && state.cursor.length > 2 ? // when the cursor is on a leaf, the indention level should not change @@ -964,24 +964,26 @@ const LayoutTree = () => { > {treeThoughtsPositioned.map((thought, index) => ( - + + + ))} diff --git a/src/components/TreeNodeAnimation.tsx b/src/components/TreeNodeAnimation.tsx new file mode 100644 index 0000000000..150478ac7d --- /dev/null +++ b/src/components/TreeNodeAnimation.tsx @@ -0,0 +1,68 @@ +import { ReactElement, cloneElement, useLayoutEffect, useRef, useState } from 'react' +import { TreeNodeAnimationContext } from './TreeNodeAnimationContext' + +interface TreeNodeAnimationProviderProps { + children: ReactElement + y: number + transitionGroupsProps?: object +} + +/** + * TreeNodeAnimationProvider component to provide animation context to its children. + * + * @param props - The props for the provider. + * @param props.children - The children components. + * @param props.y - The initial y value. + * @param [props.transitionGroupsProps] - Additional props to pass to the children. + * @returns The provider component. + */ +const TreeNodeAnimationProvider = ({ + children, + y: _y, + ...transitionGroupsProps +}: TreeNodeAnimationProviderProps): ReactElement => { + const isAnimatingRef = useRef(false) + const [y, setY] = useState(0) + const nodeRef = useRef(null) + + useLayoutEffect(() => { + const currentNode = nodeRef.current + /** + * Handle the transition end event. + */ + const handleTransitionEnd = () => { + // Mark as animation done after the transition ends + requestAnimationFrame(() => { + isAnimatingRef.current = false + }) + } + + if (currentNode) { + currentNode.addEventListener('transitionend', handleTransitionEnd) + } + + if (y !== _y) { + // Mark as animating + isAnimatingRef.current = true + // When y changes React re-renders the component with the new value of y. It will result in a visual change in the DOM. + // Because this is a state-driven change, React applies the updated value to the DOM, which causes the browser to recognize that + // a CSS property has changed, thereby triggering the CSS transition. + // Without this additional render, updates get batched and subsequent CSS transitions may not work properly. For example, when moving a thought down, it would not animate. + setY(_y) + } + + return () => { + if (currentNode) { + currentNode.removeEventListener('transitionend', handleTransitionEnd) + } + } + }, [y, _y]) + + return ( + + {cloneElement(children, { ...transitionGroupsProps, ref: nodeRef })} + + ) +} + +export default TreeNodeAnimationProvider diff --git a/src/components/TreeNodeAnimationContext.ts b/src/components/TreeNodeAnimationContext.ts new file mode 100644 index 0000000000..58b78dde21 --- /dev/null +++ b/src/components/TreeNodeAnimationContext.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react' + +export interface TreeNodeAnimationContextProps { + isAnimating: boolean + y: number +} + +export const TreeNodeAnimationContext = createContext(undefined) diff --git a/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/color-theme-superscript-on-light-theme-1.png b/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/color-theme-superscript-on-light-theme-1.png index 5c80b44de5..4d16a34622 100644 Binary files a/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/color-theme-superscript-on-light-theme-1.png and b/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/color-theme-superscript-on-light-theme-1.png differ diff --git a/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/font-size-18-default-superscript-1.png b/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/font-size-18-default-superscript-1.png index f30577c728..1ee564e785 100644 Binary files a/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/font-size-18-default-superscript-1.png and b/src/e2e/puppeteer/__tests__/__image_snapshots__/render-thoughts/font-size-18-default-superscript-1.png differ diff --git a/src/e2e/puppeteer/__tests__/__image_snapshots__/url/multiline-font-size-13-1.png b/src/e2e/puppeteer/__tests__/__image_snapshots__/url/multiline-font-size-13-1.png index 90a49c5540..11ee0d18bd 100644 Binary files a/src/e2e/puppeteer/__tests__/__image_snapshots__/url/multiline-font-size-13-1.png and b/src/e2e/puppeteer/__tests__/__image_snapshots__/url/multiline-font-size-13-1.png differ diff --git a/src/hooks/useTreeNodeAnimation.tsx b/src/hooks/useTreeNodeAnimation.tsx new file mode 100644 index 0000000000..99d893e395 --- /dev/null +++ b/src/hooks/useTreeNodeAnimation.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react' +import { TreeNodeAnimationContext, TreeNodeAnimationContextProps } from '../components/TreeNodeAnimationContext' + +/** Custom hook to use the TreeNodeAnimation context. Will throw an error if used outside of a TreeNodeAnimationProvider. */ +const useTreeNodeAnimation = (): TreeNodeAnimationContextProps => { + const context = useContext(TreeNodeAnimationContext) + if (!context) { + throw new Error('useTreeNodeAnimation must be used within a TreeNodeAnimationProvider') + } + return context +} + +export default useTreeNodeAnimation