Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add indicator arrow on top/bottom of viewport for drop positions outs… #2369

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fac864b
Add indicator arrow on top/bottom of viewport for drop positions outs…
Sep 5, 2024
67b555c
Indicator arrow implementaion in LayoutTree
Sep 16, 2024
44939c6
Fix edge case on showing arrow on hover sorted context parent drop end
Sep 19, 2024
9974579
Refactor code from LayoutTree
Sep 21, 2024
2e0b0f2
remove compareRanks function
Sep 25, 2024
fcc2a23
add arrow zindex to panda.config.css
Sep 27, 2024
8b14939
fix bug with arrow rendering
Sep 29, 2024
214a3d4
fixed state.hoveringPath on toolbar/navbar and on invalid drop targets
Oct 15, 2024
25c6ba9
updated arrow logic based on ranks of first and last visible thoughts
Oct 17, 2024
0ae9219
fix hover highlight and arrow render when canDrop is false
Oct 25, 2024
18de5b1
Rename zIndex arrow -> hoverArrow.
raineorshine Oct 26, 2024
a3d251f
HoverArrow: Convert to PandaCSS.
raineorshine Oct 26, 2024
a47d585
HoverArrow: Rename top and bottom props.
raineorshine Oct 26, 2024
25a03e3
Rename arrowBobbleAnimation -> verySlowPulseDuration.
raineorshine Oct 26, 2024
853a23d
useSortedContext: Combine into single useSelector.
raineorshine Oct 26, 2024
40a456f
useSortedContext: Combine isSortedContext and hoveringOnDropEnd.
raineorshine Oct 26, 2024
c8a1be5
LayoutTree: Add rank to TreeThought.
raineorshine Oct 26, 2024
8192dfc
LayoutTree: Add isSortedContext to TreeThought.
raineorshine Oct 26, 2024
8f4aada
LayoutTree: Cancel out scrollTop.
raineorshine Oct 26, 2024
a828e1a
LayoutTree: Rename distanceFromTop -> layoutTop.
raineorshine Oct 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions panda.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -315,6 +326,7 @@ export default defineConfig({
bullet: { value: 2 },
stack: { value: 1 },
hide: { value: -1 },
arrow: { value: 1400 },
},
},
recipes: {
Expand Down
1 change: 1 addition & 0 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
18 changes: 18 additions & 0 deletions src/actions/updateHoveringPath.ts
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 36 additions & 0 deletions src/components/HoverArrow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { token } from '../../styled-system/tokens/index.mjs'

interface HoverArrowType {
hoverArrowVisibility: 'above' | 'below' | null
scrollTop: number
arrowBottom: number
}

/** Renders upward/downward arrow when hovering over a sorted context. */
const HoverArrow = ({ hoverArrowVisibility, scrollTop, arrowBottom }: HoverArrowType) => {
raineorshine marked this conversation as resolved.
Show resolved Hide resolved
return (
hoverArrowVisibility && (
<div
style={{
width: '0',
height: '0',
borderLeft: '10px solid transparent',
borderRight: '10px solid transparent',
position: 'absolute',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why position: absolute instead of position: fixed? It seems like position: absolute would involve a lot more manual calculation to get it in the viewport, while position: fixed does that for you.

top: hoverArrowVisibility === 'above' ? scrollTop : undefined,
left: '50%',
zIndex: 'arrow',
transform: 'translateX(-50%)',
borderBottom: '20px solid rgb(155, 170, 220)',
...(hoverArrowVisibility === 'below' && {
bottom: `${arrowBottom}px`,
rotate: '180deg',
}),
animation: `bobble ${token('durations.arrowBobbleAnimation')} infinite`,
}}
></div>
)
)
}

export default HoverArrow
107 changes: 95 additions & 12 deletions src/components/LayoutTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +26,7 @@ import nextSibling from '../selectors/nextSibling'
import rootedParentOf from '../selectors/rootedParentOf'
import simplifyPath from '../selectors/simplifyPath'
import thoughtToPath from '../selectors/thoughtToPath'
import store from '../stores/app'
import reactMinistore from '../stores/react-ministore'
import scrollTopStore from '../stores/scrollTop'
import viewportStore from '../stores/viewport'
Expand All @@ -38,6 +40,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. */
Expand Down Expand Up @@ -186,24 +189,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. */
Expand Down Expand Up @@ -364,16 +370,27 @@ const LayoutTree = () => {
state.cursor.length - (hasChildren(state, head(state.cursor)) ? 2 : 3)
: 0,
)
const scrollTop = scrollTopStore.useState()

const [bulletWidth, setBulletWidth] = useState<number | undefined>(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 [distanceFromTop, setDistanceFromTop] = 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]')
trevinhofmann marked this conversation as resolved.
Show resolved Hide resolved
setBulletWidth(bullet?.getBoundingClientRect().width)
if (bullet) setBulletWidth(bullet?.getBoundingClientRect().width)

const toolbar = document.querySelector('#toolbar')
if (toolbar) setToolbarHeight(toolbar.getBoundingClientRect().height)

setDistanceFromTop((ref.current?.getBoundingClientRect().top ?? 0) + scrollTop)
Copy link
Contributor

@raineorshine raineorshine Oct 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You add scrollTop here only to subtract it below. i.e. It cancels out when you calculate maxVisibleY. Therefore, you can remove scrollTop from these calculations and avoid having to trigger useLayoutEffect on scroll.

Copy link
Contributor

@raineorshine raineorshine Oct 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}, [dragInProgress])
}, [dragInProgress, scrollTop])

// singleLineHeight is the measured height of a single line thought.
// If no sizes have been measured yet, use the estimated height.
Expand Down Expand Up @@ -454,6 +471,13 @@ const LayoutTree = () => {
),
)

const { footerHeight, navbarHeight } = useNavAndFooterHeight()
const navAndFooterHeight = navbarHeight + footerHeight

const maxVisibleY = viewportHeight + scrollTop - (distanceFromTop + navbarHeight)

const { isSortedContext, newRank, hoveringOnDropEnd } = useSortedContext()

raineorshine marked this conversation as resolved.
Show resolved Hide resolved
// 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)

Expand All @@ -470,17 +494,29 @@ 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
// (it is especially hard to determine how much x is decreased on cliffs when there are any number of tables in between)
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.

Expand All @@ -504,6 +540,7 @@ const LayoutTree = () => {
const tableCol1Widths = new Map<ThoughtId, number>()
const treeThoughtsPositioned = treeThoughts.map((node, i) => {
const next: TreeThought | undefined = treeThoughts[i + 1]
const state = store.getState()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to avoid importing the store directly. Calling getState() outside of a selector can result in a stale state.

The first render phase, linearizeTree, has access to a fresh State, we we can add rank and isSortedContext to TreeThought there and pass them down to the positioning phase.

Copy link
Contributor

@raineorshine raineorshine Oct 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// cliff is the number of levels that drop off after the last thought at a given depth. Increase in depth is ignored.
// This is used to determine how many DropEnd to insert before the next thought (one for each level dropped).
Expand Down Expand Up @@ -587,6 +624,27 @@ const LayoutTree = () => {
(node.autofocus === 'dim' || node.autofocus === 'show') &&
!(next?.autofocus === 'dim' || next?.autofocus === 'show')

// Check if current thought is inside a sorted context.
const isInSortedContext = attributeEquals(state, head(parentOf(node.path)), '=sort', 'Alphabetical')

if (isInSortedContext) {
const currentThought = getThoughtById(state, head(node.path))
// Get first and last thought ranks in sorted context
if (!firstThoughtRank) {
firstThoughtRank = currentThought.rank
}
lastThoughtRank = currentThought.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 = currentThought.rank
}
lastVisibleThoughtRank = currentThought.rank
}
}

return {
...node,
cliff,
Expand All @@ -599,8 +657,30 @@ const LayoutTree = () => {
}
})

return { indentCursorAncestorTables, treeThoughtsPositioned }
}, [fontSize, sizes, singleLineHeight, treeThoughts])
// Determine hoverArrowVisibility based on newRank and the visible thoughts
if (newRank > 0 && (isSortedContext || hoveringOnDropEnd)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rank is an arbitrary integer that can be negative, so the comparison with 0 is not a good idea. For example, you could have a sorted list with ranks [-3, -2, -1].

In the current codebase, when a context is sorted it may be the case that thoughts get re-ranked starting from 0, but this is tenuous and implementation-specific so we should avoid relying on that.

I can't quite figure out what purpose newRank > 0 is serving, so maybe it can be removed. Otherwise let me know what the intention is and we can find a different way to accomplish it.

if (newRank > lastVisibleThoughtRank && lastVisibleThoughtRank !== lastThoughtRank) {
hoverArrowVisibility = 'below'
} else if (newRank < firstVisibleThoughtRank && firstVisibleThoughtRank !== firstThoughtRank) {
hoverArrowVisibility = 'above'
} else {
hoverArrowVisibility = null
}
}

return { indentCursorAncestorTables, treeThoughtsPositioned, hoverArrowVisibility }
}, [
treeThoughts,
isSortedContext,
hoveringOnDropEnd,
singleLineHeight,
fontSize,
sizes,
maxVisibleY,
scrollTop,
toolbarHeight,
newRank,
])

const spaceAboveLast = useRef(spaceAboveExtended)

Expand All @@ -624,11 +704,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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be necessary, but it feels convoluted to have so many variables going into the calculation of arrow positioning. Feel free to disregard this if there isn't a simpler approach, but I suspect there is one.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do feel that there are a lot of variables involved but I can't see a proper positioning of the arrow without using them.


return (
<div
className={css({
Expand All @@ -640,6 +722,7 @@ const LayoutTree = () => {
}}
ref={ref}
>
<HoverArrow arrowBottom={arrowBottom} hoverArrowVisibility={hoverArrowVisibility} scrollTop={scrollTop} />
<div
className={css({ transition: `transform {durations.layoutSlowShiftDuration} ease-out` })}
style={{
Expand Down
16 changes: 10 additions & 6 deletions src/components/Thought.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import testFlags from '../e2e/testFlags'
import globals from '../globals'
import useDragAndDropThought from '../hooks/useDragAndDropThought'
import useDragHold from '../hooks/useDragHold'
import useDragLeave from '../hooks/useDragLeave'
import useHideBullet from '../hooks/useHideBullet'
import useHoveringPath from '../hooks/useHoveringPath'
import useThoughtStyle from '../hooks/useThoughtStyle'
Expand Down Expand Up @@ -161,14 +162,17 @@ const ThoughtContainer = ({
equalPath(cursorParent, path)
})

const { isDragging, dragSource, isHovering, isBeingHoveredOver, dropTarget } = useDragAndDropThought({
path,
simplePath,
isVisible,
isCursorParent,
})
const { isDragging, dragSource, isHovering, isBeingHoveredOver, dropTarget, canDropThought, isDeepHovering } =
useDragAndDropThought({
path,
simplePath,
isVisible,
isCursorParent,
})

useHoveringPath(path, isBeingHoveredOver, DropThoughtZone.ThoughtDrop)
useDragLeave({ isDeepHovering, canDropThought })

// check if the cursor is editing a thought directly
const isEditing = useSelector(state => equalPath(state.cursor, path))

Expand Down
1 change: 1 addition & 0 deletions src/durations.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const durationsMillis = {
layoutNodeAnimationDuration: 150,
/** The time it takes the trace TraceGesture to fade in/out. */
traceOpacityDuration: 150,
arrowBobbleAnimation: 1000,
} as const

export default durationsMillis
14 changes: 12 additions & 2 deletions src/hooks/useDragAndDropThought.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -221,14 +222,23 @@ const useDragAndDropThought = (props: Partial<ThoughtContainerProps>) => {
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
Loading