Skip to content

Commit

Permalink
Indicator arrow implementaion in LayoutTree
Browse files Browse the repository at this point in the history
  • Loading branch information
Zubair286 committed Sep 16, 2024
1 parent e935d4d commit 8e5967a
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 96 deletions.
90 changes: 3 additions & 87 deletions src/components/Content.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import classNames from 'classnames'
import React, { FC, useCallback, useMemo, useRef, useState } from 'react'
import { useDragDropManager } from 'react-dnd'
import React, { FC, useMemo, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { token } from '../../styled-system/tokens'
import Dispatch from '../@types/Dispatch'
import SimplePath from '../@types/SimplePath'
import { Thunk } from '../@types/Thunk'
Expand All @@ -12,22 +10,16 @@ import { toggleColorPickerActionCreator as toggleColorPicker } from '../actions/
import { isTouch } from '../browser'
import { ABSOLUTE_PATH, HOME_PATH, TUTORIAL2_STEP_SUCCESS } from '../constants'
import * as selection from '../device/selection'
import attributeEquals from '../selectors/attributeEquals'
import { childrenFilterPredicate, filterAllChildren } from '../selectors/getChildren'
import getSetting from '../selectors/getSetting'
import getSortedRank from '../selectors/getSortedRank'
import getThoughtById from '../selectors/getThoughtById'
import isTutorial from '../selectors/isTutorial'
import store from '../stores/app'
import viewportStore from '../stores/viewport'
import fastClick from '../util/fastClick'
import head from '../util/head'
import isAbsolute from '../util/isAbsolute'
import parentOf from '../util/parentOf'
import publishMode from '../util/publishMode'
import Editable from './Editable'
import EmptyThoughtspace from './EmptyThoughtspace'
import LayoutTree, { TreeThoughtPositioned } from './LayoutTree'
import LayoutTree from './LayoutTree'
import Search from './Search'

const transientChildPath = ['TRANSIENT_THOUGHT_ID'] as SimplePath
Expand Down Expand Up @@ -55,66 +47,6 @@ const Content: FC = () => {
return children.length
})
const isAbsoluteContext = useSelector(state => isAbsolute(state.rootContext))
const visibilityRef = useRef<'above' | 'below' | null>(null)
const hoveringPath = useSelector(state => state.hoveringPath)
const contextParentPath = parentOf(hoveringPath || [])

const isSortedContext = useSelector(state => {
return attributeEquals(state, head(contextParentPath), '=sort', 'Alphabetical')
})

const dragDropManager = useDragDropManager()
const monitor = dragDropManager.getMonitor()

const item = monitor.getItem()

const sourceThought = useSelector(state => {
const sourceThoughtId = head(item?.path || [])
const sourceThought = getThoughtById(state, sourceThoughtId)
return sourceThought
})

const newRank = useSelector(state => getSortedRank(state, head(contextParentPath), sourceThought?.value || ''))
const scrollY = window.scrollY
const viewportHeight = viewportStore.useSelector(state => state.innerHeight)
// The arbitrary space above the first thought
const spaceAbove = 100

/** Calculate the visibiltiy of thought being dragged in a sorted context. */
const calculateHoverArrowPosition = useCallback(
(thoughts: TreeThoughtPositioned[]) => {
const state = store.getState()
visibilityRef.current = null

let lastSortedThought: TreeThoughtPositioned | null = null

thoughts.some((thought, index) => {
const path = thought.path
const currentThought = getThoughtById(state, head(path))
lastSortedThought = isSortedContext ? thought : null

if (isSortedContext && Math.floor(newRank) === currentThought.rank) {
const y = thought.y

if (y > viewportHeight + scrollY) {
visibilityRef.current = 'below'
} else if (y < scrollY - spaceAbove) {
visibilityRef.current = 'above'
}

return true
}

if (lastSortedThought && lastSortedThought.y > viewportHeight + scrollY) {
visibilityRef.current = 'below'
return true
}

return false
})
},
[isSortedContext, newRank, scrollY, viewportHeight],
)

/** Removes the cursor if the click goes all the way through to the content. Extends cursorBack with logic for closing modals. */
const clickOnEmptySpace: Thunk = (dispatch: Dispatch, getState) => {
Expand Down Expand Up @@ -159,22 +91,6 @@ const Content: FC = () => {
{...fastClick(() => dispatch(clickOnEmptySpace))}
onMouseDown={() => setIsPressed(true)}
>
{isSortedContext && visibilityRef.current && (
<div
style={{
width: '0',
height: '0',
borderLeft: '10px solid transparent',
borderRight: '10px solid transparent',
position: 'fixed',
left: '50%',
transform: 'translateX(-50%)',
borderBottom: '20px solid rgb(155, 170, 220)',
...(visibilityRef.current === 'below' && { bottom: '80px', rotate: '180deg' }),
animation: `bobble ${token('durations.arrowBobbleAnimation')} infinite`,
}}
></div>
)}
{search != null ? (
<Search />
) : (
Expand All @@ -185,7 +101,7 @@ const Content: FC = () => {
TransientEditable
) : (
/* <Subthoughts simplePath={isAbsoluteContext ? ABSOLUTE_PATH : HOME_PATH} expandable={true} /> */
<LayoutTree calculateThoughtPosition={calculateHoverArrowPosition} />
<LayoutTree />
)}
</>
)}
Expand Down
132 changes: 123 additions & 9 deletions src/components/LayoutTree.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import classNames from 'classnames'
import _ from 'lodash'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDragDropManager } from 'react-dnd'
import { useSelector } from 'react-redux'
import { token } from '../../styled-system/tokens'
import DragThoughtItem from '../@types/DragThoughtItem'
import DropThoughtZone from '../@types/DropThoughtZone'
import Index from '../@types/IndexType'
import LazyEnv from '../@types/LazyEnv'
import Path from '../@types/Path'
Expand All @@ -17,13 +20,15 @@ import attributeEquals from '../selectors/attributeEquals'
import findDescendant from '../selectors/findDescendant'
import getChildren, { childrenFilterPredicate, getChildrenRanked, hasChildren } from '../selectors/getChildren'
import getContextsSortedAndRanked from '../selectors/getContextsSortedAndRanked'
import getSortedRank from '../selectors/getSortedRank'
import getStyle from '../selectors/getStyle'
import getThoughtById from '../selectors/getThoughtById'
import isContextViewActive from '../selectors/isContextViewActive'
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 Down Expand Up @@ -68,7 +73,7 @@ type TreeThought = {
}

/** 2nd Pass: A thought with position information after its height has been measured. */
export type TreeThoughtPositioned = TreeThought & {
type TreeThoughtPositioned = TreeThought & {
cliff: number
height: number
singleLineHeightWithCliff: number
Expand Down Expand Up @@ -347,7 +352,7 @@ const linearizeTree = (
}

/** Lays out thoughts as DOM siblings with manual x,y positioning. */
const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: any }) => {
const LayoutTree = () => {
const { sizes, setSize } = useSizeTracking()
const treeThoughts = useSelector(linearizeTree, _.isEqual)
const fontSize = useSelector(state => state.fontSize)
Expand All @@ -358,6 +363,7 @@ const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: an
state.cursor.length - (hasChildren(state, head(state.cursor)) ? 2 : 3)
: 0,
)
const scrollTop = scrollTopStore.useState()

// 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 @@ -438,6 +444,29 @@ const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: an
),
)

const hoveringPath = useSelector(state => state.hoveringPath)
const contextParentPath = parentOf(hoveringPath || [])

const isSortedContext = useSelector(state => {
return attributeEquals(state, head(contextParentPath), '=sort', 'Alphabetical')
})

const dragDropManager = useDragDropManager()

const sourceThought = useSelector(state => {
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' && state.hoverZone === DropThoughtZone.ThoughtDrop
const sourceThoughtId = head(item?.path || [])

const sourceThought = isThought ? getThoughtById(state, sourceThoughtId) : null
return sourceThought
})

const newRank = useSelector(state => getSortedRank(state, head(contextParentPath), sourceThought?.value || ''))

// 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 @@ -449,22 +478,51 @@ const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: an
[fontSize],
)

/** Compare ranks between two thoughts. */
const compareRanks = (rankA: number, rankB: number) => {
const partsA = String(rankA).split('.').map(Number)
const partsB = String(rankB).split('.').map(Number)
const len = Math.max(partsA.length, partsB.length)

let result = 0
const indexArray = Array.from({ length: len }, (_, i) => i)

indexArray.some(i => {
const valA = partsA[i] || 0
const valB = partsB[i] || 0
if (valA !== valB) {
result = valA - valB
return true
}
return false
})

return result
}

// Accumulate the y position as we iterate the visible thoughts since the sizes may vary.
// We need to do this in a second pass since we do not know the height of a thought until it is rendered, and since we need to linearize the tree to get the depth of the next node for calculating the cliff.
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

// Flag to indicate if we've found the insertion point in sorted context
let insertionY: number | null = null
let hoverArrowVisibility: 'above' | 'below' | null = null
let insertionFound = false

/** 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 Down Expand Up @@ -567,6 +625,17 @@ const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: an
ycol1Ancestors.push({ y: yaccum + height, depth: node.depth })
}

// Get the insertion point of the thought in sorted context
if (isSortedContext && !insertionFound && sourceThought) {
const currentThought = getThoughtById(store.getState(), head(node.path))
const currentRank = currentThought.rank

if (compareRanks(newRank, currentRank) < 0) {
insertionY = y
insertionFound = true
}
}

return {
...node,
cliff,
Expand All @@ -578,8 +647,35 @@ const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: an
}
})

return { indentCursorAncestorTables, treeThoughtsPositioned }
}, [fontSize, sizes, singleLineHeight, treeThoughts])
// If insertionY is still null, newRank would be inserted at the end
if (isSortedContext && insertionY === null && treeThoughtsPositioned.length > 0 && sourceThought) {
const lastThought = treeThoughtsPositioned[treeThoughtsPositioned.length - 1]
insertionY = lastThought.y + lastThought.height
}

// Determine hoverArrowVisibility based on insertionY
if (insertionY !== null) {
if (insertionY > viewportHeight + scrollTop) {
hoverArrowVisibility = 'below'
} else if (insertionY < scrollTop) {
hoverArrowVisibility = 'above'
} else {
hoverArrowVisibility = null
}
}

return { indentCursorAncestorTables, treeThoughtsPositioned, hoverArrowVisibility }
}, [
treeThoughts,
singleLineHeight,
fontSize,
sizes,
isSortedContext,
sourceThought,
newRank,
viewportHeight,
scrollTop,
])

const spaceAboveLast = useRef(spaceAboveExtended)

Expand All @@ -591,11 +687,6 @@ const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: an
// get the scroll position before the render so it can be preserved
const scrollY = window.scrollY

// When a thought is hovered over a sorted context, determine the position of arrow.
useEffect(() => {
calculateThoughtPosition(treeThoughtsPositioned)
}, [calculateThoughtPosition, treeThoughtsPositioned])

// when spaceAbove changes, scroll by the same amount so that the thoughts appear to stay in the same place
useEffect(
() => {
Expand All @@ -614,6 +705,9 @@ const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: an
// 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 - window.innerHeight + navAndFooterHeight

return (
<div
style={{
Expand All @@ -622,6 +716,26 @@ const LayoutTree = ({ calculateThoughtPosition }: { calculateThoughtPosition: an
marginTop: '0.501em',
}}
>
{hoverArrowVisibility && (
<div
style={{
width: '0',
height: '0',
borderLeft: '10px solid transparent',
borderRight: '10px solid transparent',
position: 'absolute',
top: hoverArrowVisibility === 'above' ? scrollTop : undefined,
left: '50%',
transform: 'translateX(-50%)',
borderBottom: '20px solid rgb(155, 170, 220)',
...(hoverArrowVisibility === 'below' && {
bottom: `${arrowBottom}px`,
rotate: '180deg',
}),
animation: `bobble ${token('durations.arrowBobbleAnimation')} infinite`,
}}
></div>
)}
<div
style={{
// Set a container height that fits all thoughts.
Expand Down

0 comments on commit 8e5967a

Please sign in to comment.