diff --git a/panda.config.ts b/panda.config.ts index 052abb023b..3f5a5c2d0e 100644 --- a/panda.config.ts +++ b/panda.config.ts @@ -119,6 +119,17 @@ const keyframes = defineKeyframes({ fill: 'white', }, }, + bobble: { + '0%': { + transform: 'translateX(-50%) translateY(0)', + }, + '50%': { + transform: 'translateX(-50%) translateY(10px)', + }, + '100%': { + transform: 'translateX(-50%) translateY(0)', + }, + }, }) const globalCss = defineGlobalStyles({ @@ -294,6 +305,7 @@ export default defineConfig({ }, zIndex: { popup: { value: 1500 }, + hoverArrow: { value: 1400 }, gestureTrace: { value: 50 }, commandPalette: { value: 45 }, modal: { value: 40 }, diff --git a/src/actions/index.ts b/src/actions/index.ts index 4a2f597518..99f3a7ffc3 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -107,3 +107,4 @@ export { default as undoArchive } from './undoArchive' export { default as unknownAction } from './unknownAction' export { default as updateSplitPosition } from './updateSplitPosition' export { default as updateThoughts } from './updateThoughts' +export { default as updateHoveringPath } from './updateHoveringPath' diff --git a/src/actions/updateHoveringPath.ts b/src/actions/updateHoveringPath.ts new file mode 100644 index 0000000000..63e31add16 --- /dev/null +++ b/src/actions/updateHoveringPath.ts @@ -0,0 +1,18 @@ +import _ from 'lodash' +import Path from '../@types/Path' +import State from '../@types/State' +import Thunk from '../@types/Thunk' + +/** Reducer for setting the hoveringPath when drag leave is firing. */ +const updateHoveringPath = (state: State, { path }: { path: Path | undefined }) => ({ + ...state, + hoveringPath: path, +}) + +/** Action-creator for state.hoveringPath. */ +export const updateHoveringPathActionCreator = + ({ path }: { path: Path | undefined }): Thunk => + dispatch => + dispatch([{ type: 'updateHoveringPath', path }]) + +export default _.curryRight(updateHoveringPath) diff --git a/src/components/HoverArrow.tsx b/src/components/HoverArrow.tsx new file mode 100644 index 0000000000..5dd4efe280 --- /dev/null +++ b/src/components/HoverArrow.tsx @@ -0,0 +1,40 @@ +import { css } from '../../styled-system/css' + +/** Renders upward/downward arrow when hovering over a sorted context. */ +const HoverArrow = ({ + hoverArrowVisibility, + top, + bottom, +}: { + hoverArrowVisibility: 'above' | 'below' | null + /** The distance the arrow is rendered from the top of the document (px) if hoverArrowVisibility is 'above'. */ + top: number + /** The distance the arrow is rendered from the bottom of the document (px) if hoverArrowVisibility is 'below'. */ + bottom: number +}) => { + return ( + hoverArrowVisibility && ( +
+ ) + ) +} + +export default HoverArrow diff --git a/src/components/LayoutTree.tsx b/src/components/LayoutTree.tsx index 7ac78ade57..683d966bde 100644 --- a/src/components/LayoutTree.tsx +++ b/src/components/LayoutTree.tsx @@ -13,6 +13,7 @@ import ThoughtId from '../@types/ThoughtId' import { isTouch } from '../browser' import { HOME_PATH } from '../constants' import testFlags from '../e2e/testFlags' +import useSortedContext from '../hooks/useSortedContext' import attributeEquals from '../selectors/attributeEquals' import calculateAutofocus from '../selectors/calculateAutofocus' import findDescendant from '../selectors/findDescendant' @@ -38,6 +39,7 @@ import parseLet from '../util/parseLet' import safeRefMerge from '../util/safeRefMerge' import unroot from '../util/unroot' import DropEnd from './DropEnd' +import HoverArrow from './HoverArrow' import VirtualThought from './VirtualThought' /** 1st Pass: A thought with rendering information after the tree has been linearized. */ @@ -51,6 +53,7 @@ type TreeThought = { // index among all visible thoughts in the tree indexDescendant: number isCursor: boolean + isInSortedContext: boolean isTableCol1: boolean isTableCol2: boolean isTableCol2Child: boolean @@ -58,6 +61,7 @@ type TreeThought = { leaf: boolean path: Path prevChild: Thought + rank: number showContexts?: boolean simplePath: SimplePath // style inherited from parents with =children/=style and grandparents with =grandchildren/=style @@ -186,24 +190,27 @@ const useNavAndFooterHeight = () => { // Get the nav and footer heights for the spaceBelow calculation. // Nav hight changes when the breadcrumbs wrap onto multiple lines. // Footer height changes on font size change. - const [navAndFooterHeight, setNavAndFooterHeight] = useState(0) + const [navbarHeight, setNavbarHeight] = useState(0) + const [footerHeight, setFooterHeight] = useState(0) // Read the footer and nav heights on render and set the refs so that the spaceBelow calculation is updated on the next render. // This works because there is always a second render due to useSizeTracking. // No risk of infinite render since the effect cannot change the height of the nav or footer. - // nav/footer height -> effect -> setNavAndFooterHeight -> render -> effect -> setNavAndFooterHeight (same values) + // nav/footer height -> effect -> setNavbarHeight/setFooterHeight -> render -> effect -> setNavbarHeight/setFooterHeight (same values) // eslint-disable-next-line react-hooks/exhaustive-deps useEffect( _.throttle(() => { const navEl = document.querySelector('[aria-label="nav"]') const footerEl = document.querySelector('[aria-label="footer"]') - setNavAndFooterHeight( - (navEl?.getBoundingClientRect().height || 0) + (footerEl?.getBoundingClientRect().height || 0), - ) + setNavbarHeight(navEl?.getBoundingClientRect().height || 0) + setFooterHeight(footerEl?.getBoundingClientRect().height || 0) }, 16.666), ) - return navAndFooterHeight + return { + navbarHeight, + footerHeight, + } } /** Recursiveley calculates the tree of visible thoughts, in order, represented as a flat list of thoughts with tree layout information. */ @@ -290,6 +297,7 @@ const linearizeTree = ( const isTable = attributeEquals(state, child.id, '=view', 'Table') const isTableCol1 = attributeEquals(state, head(simplePath), '=view', 'Table') + const isInSortedContext = attributeEquals(state, head(simplePath), '=sort', 'Alphabetical') const isTableCol2 = attributeEquals(state, head(rootedParentOf(state, simplePath)), '=view', 'Table') const isTableCol2Child = attributeEquals(state, head(rootedParentOf(state, parentOf(simplePath))), '=view', 'Table') const autofocus = calculateAutofocus(state, childPath) @@ -301,6 +309,7 @@ const linearizeTree = ( indexChild: i, indexDescendant: virtualIndexNew, isCursor, + isInSortedContext, isTableCol1, isTableCol2, isTableCol2Child, @@ -313,6 +322,7 @@ const linearizeTree = ( leaf: !hasChildren(state, filteredChild.id), path: childPath, prevChild: filteredChildren[i - 1], + rank: child.rank, showContexts: contextViewActive, simplePath: contextViewActive ? thoughtToPath(state, child.id) : appendToPathMemo(simplePath, child.id), style, @@ -364,14 +374,25 @@ const LayoutTree = () => { state.cursor.length - (hasChildren(state, head(state.cursor)) ? 2 : 3) : 0, ) + const scrollTop = scrollTopStore.useState() - const [bulletWidth, setBulletWidth] = useState(0) + // Width of thought bullet + const [bulletWidth, setBulletWidth] = useState(0) + // Height of toolbar element + const [toolbarHeight, setToolbarHeight] = useState(0) + // Distance from toolbar to the first visible thought + const [layoutTop, setLayoutTop] = useState(0) // set the bullet width only during drag or when simulateDrop is true useLayoutEffect(() => { if (dragInProgress || testFlags.simulateDrop) { const bullet = ref.current?.querySelector('[aria-label=bullet]') - setBulletWidth(bullet?.getBoundingClientRect().width) + if (bullet) setBulletWidth(bullet?.getBoundingClientRect().width) + + const toolbar = document.querySelector('#toolbar') + if (toolbar) setToolbarHeight(toolbar.getBoundingClientRect().height) + + setLayoutTop(ref.current?.getBoundingClientRect().top ?? 0) } }, [dragInProgress]) @@ -454,6 +475,13 @@ const LayoutTree = () => { ), ) + const { footerHeight, navbarHeight } = useNavAndFooterHeight() + const navAndFooterHeight = navbarHeight + footerHeight + + const maxVisibleY = viewportHeight - (layoutTop + navbarHeight) + + const { isHoveringSorted, newRank } = useSortedContext() + // extend spaceAbove to be at least the height of the viewport so that there is room to scroll up const spaceAboveExtended = Math.max(spaceAbove, viewportHeight) @@ -470,10 +498,12 @@ const LayoutTree = () => { const { indentCursorAncestorTables, treeThoughtsPositioned, + hoverArrowVisibility, }: { // the global indent based on the depth of the cursor and how many ancestors are tables indentCursorAncestorTables: number treeThoughtsPositioned: TreeThoughtPositioned[] + hoverArrowVisibility: 'above' | 'below' | null } = useMemo(() => { // y increases monotically, so it is more efficent to accumulate than to calculate each time // x varies, so we calculate it each time @@ -481,6 +511,16 @@ const LayoutTree = () => { let yaccum = 0 let indentCursorAncestorTables = 0 + // Arrow visibility based on the rank of drop target in sorted context. + let hoverArrowVisibility: 'above' | 'below' | null = null + + // The rank of the first and last thoughts in sorted context. + let firstThoughtRank = 0 + let lastThoughtRank = 0 + // The rank of the first and last visible thoughts in sorted context. + let firstVisibleThoughtRank = 0 + let lastVisibleThoughtRank = 0 + /** A stack of { depth, y } that stores the bottom y value of each col1 ancestor. */ /* By default, yaccum is not advanced by the height of col1. This is what positions col2 at the same y value as col1. However, if the height of col1 exceeds the height of col2, then the next node needs to be positioned below col1, otherwise it will overlap. This stack stores the minimum y value of the next node (i.e. y + height). Depth is used to detect the next node after all of col1's descendants. @@ -587,6 +627,23 @@ const LayoutTree = () => { (node.autofocus === 'dim' || node.autofocus === 'show') && !(next?.autofocus === 'dim' || next?.autofocus === 'show') + if (node.isInSortedContext) { + // Get first and last thought ranks in sorted context + if (!firstThoughtRank) { + firstThoughtRank = node.rank + } + lastThoughtRank = node.rank + + // Check if the current thought is visible + if (y < maxVisibleY && y > scrollTop - toolbarHeight) { + // Get first and last visible thought ranks in sorted context + if (!firstVisibleThoughtRank) { + firstVisibleThoughtRank = node.rank + } + lastVisibleThoughtRank = node.rank + } + } + return { ...node, cliff, @@ -599,8 +656,29 @@ const LayoutTree = () => { } }) - return { indentCursorAncestorTables, treeThoughtsPositioned } - }, [fontSize, sizes, singleLineHeight, treeThoughts]) + // Determine hoverArrowVisibility based on newRank and the visible thoughts + if (newRank > 0 && isHoveringSorted) { + if (newRank > lastVisibleThoughtRank && lastVisibleThoughtRank !== lastThoughtRank) { + hoverArrowVisibility = 'below' + } else if (newRank < firstVisibleThoughtRank && firstVisibleThoughtRank !== firstThoughtRank) { + hoverArrowVisibility = 'above' + } else { + hoverArrowVisibility = null + } + } + + return { indentCursorAncestorTables, treeThoughtsPositioned, hoverArrowVisibility } + }, [ + fontSize, + isHoveringSorted, + maxVisibleY, + newRank, + scrollTop, + singleLineHeight, + sizes, + toolbarHeight, + treeThoughts, + ]) const spaceAboveLast = useRef(spaceAboveExtended) @@ -624,11 +702,13 @@ const LayoutTree = () => { [spaceAboveExtended], ) - const navAndFooterHeight = useNavAndFooterHeight() /** The space added below the last rendered thought and the breadcrumbs/footer. This is calculated such that there is a total of one viewport of height between the last rendered thought and the bottom of the document. This ensures that when the keyboard is closed, the scroll position will not change. If the caret is on a thought at the top edge of the screen when the keyboard is closed, then the document will shrink by the height of the virtual keyboard. The scroll position will only be forced to change if the document height is less than window.scrollY + window.innerHeight. */ // Subtract singleLineHeight since we can assume that the last rendered thought is within the viewport. (It would be more accurate to use its exact rendered height, but it just means that there may be slightly more space at the bottom, which is not a problem. The scroll position is only forced to change when there is not enough space.) const spaceBelow = viewportHeight - navAndFooterHeight - CONTENT_PADDING_BOTTOM - singleLineHeight + // Calculate the position of the arrow relative to the bottom of the container. + const arrowBottom = totalHeight + spaceBelow - scrollTop - viewportHeight + navAndFooterHeight + return (
{ }} ref={ref} > +
equalPath(state.cursor, path)) diff --git a/src/durations.config.ts b/src/durations.config.ts index 3908e3eb1a..f34d1941e7 100644 --- a/src/durations.config.ts +++ b/src/durations.config.ts @@ -10,6 +10,8 @@ const durationsMillis = { veryFastDuration: 80, /* PULSE ANIMATIONS */ + /** A very slow pulse animation. */ + verySlowPulseDuration: 1000, /** A slow pulse animation. */ slowPulseDuration: 500, /** A medium pulse animation. */ diff --git a/src/hooks/useDragAndDropThought.tsx b/src/hooks/useDragAndDropThought.tsx index 9bd01cc8e1..baaa8231d2 100644 --- a/src/hooks/useDragAndDropThought.tsx +++ b/src/hooks/useDragAndDropThought.tsx @@ -207,6 +207,7 @@ const dropCollect = (monitor: DropTargetMonitor) => ({ // is being hovered over current thought irrespective of whether the given item is droppable isBeingHoveredOver: monitor.isOver({ shallow: true }), isDeepHovering: monitor.isOver(), + canDropThought: monitor.canDrop(), }) /** A draggable and droppable Thought hook. */ @@ -221,14 +222,23 @@ const useDragAndDropThought = (props: Partial) => { collect: dragCollect, }) - const [{ isHovering, isBeingHoveredOver, isDeepHovering }, dropTarget] = useDrop({ + const [{ isHovering, isBeingHoveredOver, isDeepHovering, canDropThought }, dropTarget] = useDrop({ accept: [DragAndDropType.Thought, NativeTypes.FILE], canDrop: (item, monitor) => canDrop(propsTypes, monitor), drop: (item, monitor) => drop(propsTypes, monitor), collect: dropCollect, }) - return { isDragging, dragSource, dragPreview, isHovering, isBeingHoveredOver, isDeepHovering, dropTarget } + return { + isDragging, + dragSource, + dragPreview, + isHovering, + isBeingHoveredOver, + isDeepHovering, + canDropThought, + dropTarget, + } } export default useDragAndDropThought diff --git a/src/hooks/useDragLeave.ts b/src/hooks/useDragLeave.ts new file mode 100644 index 0000000000..0a175f7fb8 --- /dev/null +++ b/src/hooks/useDragLeave.ts @@ -0,0 +1,71 @@ +import { debounce } from 'lodash' +import { useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { updateHoveringPathActionCreator } from '../actions/updateHoveringPath' + +const DEBOUNCE_DELAY = 50 +let hoverCount = 0 +let debouncedSetHoveringPath: ReturnType | null = null + +/** + * Hook to capture the dragleave event and dispatch the dragInProgress action. + */ +const useDragLeave = ({ isDeepHovering, canDropThought }: { isDeepHovering: boolean; canDropThought: boolean }) => { + const dispatch = useDispatch() + const hoverZone = useSelector(state => state.hoverZone) + const prevIsDeepHoveringRef = useRef(isDeepHovering) + const prevHoverZone = useRef(hoverZone) + + // Initialize the debounced function if it hasn't been already + if (!debouncedSetHoveringPath) { + debouncedSetHoveringPath = debounce(() => { + // Only set hoveringPath to undefined if hoverCount is still zero + dispatch(updateHoveringPathActionCreator({ path: undefined })) + }, DEBOUNCE_DELAY) + } + + useEffect(() => { + if (prevHoverZone.current !== hoverZone) { + // Cancel any debounce function if hovering on subthought + prevHoverZone.current = hoverZone + debouncedSetHoveringPath?.cancel() + return + } + + // If canDrop is false return + if (!canDropThought) { + dispatch(updateHoveringPathActionCreator({ path: undefined })) + return + } + + if (isDeepHovering && !prevIsDeepHoveringRef.current) { + // Cursor has entered a drop target, increase hover count + hoverCount += 1 + + // Cancel any pending debounce since we're over a drop target + debouncedSetHoveringPath?.cancel() + } else { + // Cursor has left a drop target, decrease hover count + hoverCount = Math.max(hoverCount - 1, 0) + if (hoverCount === 0) { + // No drop targets are being hovered over; start debounce + debouncedSetHoveringPath?.() + } + } + + prevIsDeepHoveringRef.current = isDeepHovering + prevHoverZone.current = hoverZone + + return () => { + // Cleanup on unmount + if (isDeepHovering) { + if (hoverCount === 0) { + // Start debounce when unmounting and no more drop targets are hovered + debouncedSetHoveringPath?.() + } + } + } + }, [isDeepHovering, dispatch, hoverZone, canDropThought]) +} + +export default useDragLeave diff --git a/src/hooks/useSortedContext.ts b/src/hooks/useSortedContext.ts new file mode 100644 index 0000000000..2f90f1e1f2 --- /dev/null +++ b/src/hooks/useSortedContext.ts @@ -0,0 +1,51 @@ +import { useDragDropManager } from 'react-dnd' +import { shallowEqual, useSelector } from 'react-redux' +import DragThoughtItem from '../@types/DragThoughtItem' +import DropThoughtZone from '../@types/DropThoughtZone' +import attributeEquals from '../selectors/attributeEquals' +import getSortedRank from '../selectors/getSortedRank' +import getThoughtById from '../selectors/getThoughtById' +import head from '../util/head' +import parentOf from '../util/parentOf' + +/** A hook that checks if a dragging thought is hovering over a sorted context, and returns new rank where that thought will be dropped. */ +const useSortedContext = () => { + const dragDropManager = useDragDropManager() + + return useSelector(state => { + if (!state.hoveringPath) { + return { isSortedContext: false, hoveringOnDropEnd: false, newRank: -1 } + } + + const contextParentPath = parentOf(state.hoveringPath) + + // Check if the drop target is on sorted context children or on its parent. + const isContextChildren = attributeEquals(state, head(contextParentPath), '=sort', 'Alphabetical') + const thoughtDrop = state.hoverZone === DropThoughtZone.ThoughtDrop + const isSortedContext = isContextChildren && thoughtDrop + + // check if the hovering path is on a drop end of parent sorted context + const dropTargetId = head(state.hoveringPath || []) + const isContextParent = attributeEquals(state, dropTargetId, '=sort', 'Alphabetical') + const hoveringOnDropEnd = state.hoverZone === 'SubthoughtsDrop' && isContextParent + + const monitor = dragDropManager.getMonitor() + const item = monitor.getItem() as DragThoughtItem + + // Check if the dragged item is a thought and the drop zone is not a subthought + const isThought = item?.zone === 'Thoughts' + const sourceThoughtId = head(item?.path || []) + + // get the source thought and its new rank + const sourceThought = isThought ? getThoughtById(state, sourceThoughtId) : null + const contextpath = hoveringOnDropEnd ? state.hoveringPath : contextParentPath + const newRank = getSortedRank(state, head(contextpath), sourceThought?.value || '') + + return { + isHoveringSorted: isSortedContext || hoveringOnDropEnd, + newRank, + } + }, shallowEqual) +} + +export default useSortedContext diff --git a/src/redux-enhancers/undoRedoEnhancer.ts b/src/redux-enhancers/undoRedoEnhancer.ts index f9b3a3a4be..944525ac3a 100644 --- a/src/redux-enhancers/undoRedoEnhancer.ts +++ b/src/redux-enhancers/undoRedoEnhancer.ts @@ -145,6 +145,7 @@ const UNDOABLE_ACTIONS: ActionFlags = { unknownAction: false, updateSplitPosition: false, updateThoughts: false, + updateHoveringPath: false, } /** Returns if an action is undoable. */