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

Table view 543 (Milestone 1) #2764

Open
wants to merge 43 commits into
base: main
Choose a base branch
from

Conversation

ohrytskov
Copy link
Collaborator

@ohrytskov ohrytskov commented Jan 9, 2025

#543
Built on #2763. Ignore commit 99c19d9 to review current changes only.

@ohrytskov ohrytskov changed the title Table view 543 Table view 543 (Milestone 1) Jan 9, 2025
@raineorshine
Copy link
Contributor

Note: Built on #2763. Ignore commit 99c19d9 to review current changes only.

@ohrytskov ohrytskov marked this pull request as ready for review January 10, 2025 17:33
@ohrytskov
Copy link
Collaborator Author

This PR is ready for review.

…ider same-level siblings when it's the only child, handling Table View and non-Table View scenarios.
@ohrytskov
Copy link
Collaborator Author

Both of the two latest commits successfully resolve the current issue. In the most recent commit, I've implemented state-based logic using selectors wherever possible instead of relying solely on DOM queries. However, since we still need to use DOM queries to measure text widths, does it make sense to combine state-based logic with selectors and DOM queries in this way? Or would it be better to use only DOM queries, as in the previous commit?

@ohrytskov
Copy link
Collaborator Author

Just to clarify, the two commits I'm referring to are:

Commit 919dd24: Implemented using only DOM queries.
Commit ad33c61: Switched to state-based logic using selectors wherever possible.

@trevinhofmann trevinhofmann self-requested a review January 14, 2025 06:39
@trevinhofmann trevinhofmann self-assigned this Jan 14, 2025
Copy link
Collaborator

@trevinhofmann trevinhofmann left a comment

Choose a reason for hiding this comment

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

Thank you for the PR, @ohrytskov!

This seems to work when pasting the given examples from the issue:

Case 1

Screenshot 2025-01-15 at 4 34 42 AM

Case 2

Screenshot 2025-01-15 at 4 34 52 AM

1. Divider widths do not update

The divider widths do not update while editing the thoughts.

Screen.Recording.2025-01-15.at.4.39.21.AM.mov

src/components/Divider.tsx Outdated Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
@ohrytskov
Copy link
Collaborator Author

Including children and thoughtsAtSameDepth in the useLayoutEffect dependency array caused an infinite re-render loop because these arrays were recreated with new references on each render. Since the hook's dependency array relies on referential equality, and the arrays were derived from selectors that created new instances of the Redux state values, the effect ran repeatedly. This triggered state updates and re-renders in a loop. To resolve this, the arrays were memoized with useMemo, ensuring their references only changed when their contents did.

@ohrytskov ohrytskov removed their assignment Jan 15, 2025
@trevinhofmann trevinhofmann self-assigned this Jan 16, 2025
@ohrytskov
Copy link
Collaborator Author

@trevinhofmann, I've made the following updates:

  • Refactored the Divider component to use useSelectorEffect for more efficient state updates.
  • Memoized updateDividerWidth with useCallback to prevent redundant re-renders.

Copy link
Collaborator

@trevinhofmann trevinhofmann left a comment

Choose a reason for hiding this comment

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

Thank you, @ohrytskov! The latest changes resolved the issues 4. Divider widths are longer than thoughts and 5. Divider widths affected by selected thought.

I am seeing one other issue now.


6. Divider continues to grow after text wraps

When a thought's text wraps to a new line, the divider continues to grow wider. The divider should reach a maximum width.

Screen.Recording.2025-01-28.at.7.03.39.PM.mov

src/components/Divider.tsx Outdated Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
@ohrytskov
Copy link
Collaborator Author

@trevinhofmann, the PR is ready for the next review. Let me know if any further changes are needed.

@ohrytskov ohrytskov removed their assignment Jan 29, 2025
@trevinhofmann trevinhofmann self-assigned this Jan 29, 2025
@ohrytskov
Copy link
Collaborator Author

The new solution now uses DOM elements to measure the maximum width of thoughts instead of relying on canvas text measurement. Since accounting for text wrapping requires accessing the actual element, we no longer need canvas for width calculations.This also simplifies the logic by returning to DOM-based measurements, making it more reliable for real-time UI updates.

Copy link
Collaborator

@trevinhofmann trevinhofmann left a comment

Choose a reason for hiding this comment

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

Thank you, @ohrytskov!

The previous issues are all fixed, and the functionality on desktop and mobile appears to be working as expected.

Copy link
Contributor

@raineorshine raineorshine left a comment

Choose a reason for hiding this comment

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

Thank! I really like the new approach. It is lightweight since it avoids ResizeObserver and Canvas, and almost all of the logic is contained within the Divider component. Nice work.

There are a couple more dependencies that need to trigger updateDividerWidth for it to be updated when the font size changes and the window is resized:

  const fontSize = useSelector(state => state.fontSize)

  viewportStore.useSelectorEffect(updateDividerWidth, state => state.innerWidth)

Overall I like the separation between useDividerData, getThoughtWidths, and updateDividerWidth. useDividerData selects from the Redux state, getThoughtWidths interacts with the DOM, and updateDividerWidth calculates the divider width.

However, notice that updateDividerWidth is really just an extension of useDividerData, determining which thoughts' widths are relevant based on the Redux state. By splitting up useDividerData and updateDividerWidth into separate hooks, it exposes more variables than necessary to the component.

Combining useDividerData and updateDividerWidth into a single custom hook that returns ThoughtId[] would

  • reduce the { isOnlyChild, isTableView, children, thoughtsAtSameDepth } return object down to a single ThoughtId[]
  • eliminate the custom equality function
  • reduce the number of re-renders

Let me know if you see any issues with this suggested change!

*/
const editingValueUntrimmedStore = reactMinistore<string | null>(null)

export default editingValueUntrimmedStore
Copy link
Contributor

@raineorshine raineorshine Jan 31, 2025

Choose a reason for hiding this comment

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

I agree that they can be combined, since they have the same update rates. That's fine to refactor in a separate PR. Thanks!

src/components/Editable.tsx Outdated Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
src/components/Divider.tsx Outdated Show resolved Hide resolved
}
useEffect(() => {
/** Calculates and updates the Divider's width based on sibling thought widths. */
const updateDividerWidth = () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

The multiple return statements and calls to setDivideWidth make this function look more complicated than it actually is. Try refactoring it so that there is a single setDividerWidth at the end and no early returns. Math.max(...widths, DIVIDER_MIN_WIDTH)) naturally returns DIVIDER_MIN_WIDTH when widths = [].

useEffect(() => {
/** Calculates and updates the Divider's width based on sibling thought widths. */
const updateDividerWidth = () => {
if (!dividerRef.current) return
Copy link
Contributor

@raineorshine raineorshine Jan 31, 2025

Choose a reason for hiding this comment

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

This is an exception to my above comment. The initial short circuit to make sure the component is still mounted is fine.

/**
* Calculates the width of multiple thoughts by measuring their rendered widths in the DOM.
*/
const getThoughtWidths = (thoughts: { id: ThoughtId }[]): number[] => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this function only needs ids, it would be better to reduce the surface area of the parameters and just take ids: ThoughtId[].

const [dividerWidth, setDividerWidth] = useState<number>(DIVIDER_MIN_WIDTH)
const { isOnlyChild, isTableView, children, thoughtsAtSameDepth } = useDividerData(path)
const editingThoughtId = useSelector((state: State) => state.cursor && head(state.cursor))
const editingValueUntrimmed = editingValueUntrimmedStore.useSelector(editingValue => editingValue)
Copy link
Contributor

Choose a reason for hiding this comment

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

Shorthand for selecting the whole state: editingValueUntrimmedStore.useState()

const getThoughtWidths = (thoughts: { id: ThoughtId }[]): number[] => {
return thoughts.map(thought => {
const innerThoughtElement = document.querySelector(
`[aria-label="tree-node"][data-id="${thought.id}"] [aria-label="thought"]`,
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it work to use the existing editable element for measurement?

document.querySelector(`[aria-label="editable-${id}"]`)

Comment on lines 484 to 486
data-id={thoughtId}
data-parent-id={parentId}
data-depth={depth}
Copy link
Contributor

@raineorshine raineorshine Jan 31, 2025

Choose a reason for hiding this comment

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

It looks like data-depth and data-parent-id are not used.

data-id may also be able to be removed if the sibling elements are selected by editable aria-label as mentioned above.

@ohrytskov
Copy link
Collaborator Author

Thank you for your feedback, @raineorshine! I’ve carefully reviewed your suggestions and have made all the necessary changes accordingly. The PR has been updated with the requested modifications, and it is now ready for your next review. Please let me know if there are any further adjustments needed.

Copy link
Contributor

@raineorshine raineorshine left a comment

Choose a reason for hiding this comment

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

Looks great! Just a few touchups, then we should be good to merge.

Comment on lines 19 to 20
/** Custom hook to fetch relevant thought IDs for calculating divider width. */
const useRelevantThoughtIds = (path: Path): ThoughtId[] => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's call this useWidthDependentThoughtIds. I try to avoid words like "relevant" or "data" in variable names since they don't explain anything.


return relevantThoughtIds
},
(prev, next) => prev.length === next.length && prev.every((id, index) => id === next[index]),
Copy link
Contributor

Choose a reason for hiding this comment

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

You can use drop in shallowEqual from react-redux or isEqual from lodash rather than providing a custom equality function.

Comment on lines 30 to 32
const isTableView =
(parentId && attributeEquals(state, parentId, '=view', 'Table')) ||
(grandParentId && attributeEquals(state, grandParentId, '=view', 'Table'))
Copy link
Contributor

Choose a reason for hiding this comment

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

You can make this a little more terse by omitting parentId && since attributeEquals handles null.

(state: State) => {
const parentPath = rootedParentOf(state, path)
const parentId = head(parentPath)
const grandParentPath = parentId ? rootedParentOf(state, parentPath) : []
Copy link
Contributor

Choose a reason for hiding this comment

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

It won't affect the behavior here, but [] is not a valid Path. It should probably be set to null if there is no parentId.

(parentId && attributeEquals(state, parentId, '=view', 'Table')) ||
(grandParentId && attributeEquals(state, grandParentId, '=view', 'Table'))

const relevantThoughtIds = isOnlyChild
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks, this is much better.

Let's call this dependentThoughtIds.

Could you also add a couple inline comments that explain the two branches?

@ohrytskov
Copy link
Collaborator Author

This appears to be ready for the next review—awaiting your feedback, @raineorshine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants