From 99b97d8887a2d39add29d8a78b152dfec8039894 Mon Sep 17 00:00:00 2001 From: Klaus Eckelt Date: Tue, 5 Mar 2024 13:54:13 +0100 Subject: [PATCH] Connect cells across states (#88) * Replace implementation with xarrow * don't let xarrow lines overlap header and footer --- package.json | 1 + src/Overview/Cells/CodeCell.tsx | 75 +++++++++++++------------ src/Overview/Cells/DeletedCell.tsx | 9 ++- src/Overview/Cells/MarkDownCell.tsx | 76 +++++++++++++------------ src/Overview/State.tsx | 83 ++++++++++++---------------- src/Overview/StateList.tsx | 86 ++++++++--------------------- yarn.lock | 21 +++++++ 7 files changed, 161 insertions(+), 190 deletions(-) diff --git a/package.json b/package.json index ad6af93..59827a1 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "d3-selection": "^3.0.0", "html-react-parser": "^4.0.0", "monaco-editor": "^0.41.0", + "react-xarrows": "^2.0.2", "tabletojson": "^4.0.1", "zustand": "^4.3.8" }, diff --git a/src/Overview/Cells/CodeCell.tsx b/src/Overview/Cells/CodeCell.tsx index f986fc0..ca490e7 100644 --- a/src/Overview/Cells/CodeCell.tsx +++ b/src/Overview/Cells/CodeCell.tsx @@ -123,45 +123,44 @@ export function CodeCell({ // create a cell with input and output return ( - <> -
0 }, - { ['changed']: !unchanged && !added } - )} - > - - {multiUser && fullWidth ? : <>} - - { - // Add CompareBadge if old, oldStateNo, and oldTimestamp are defined - previousCell && previousStateNo && previousStateTimestamp && ( - - ) - } +
0 }, + { ['changed']: !unchanged && !added } + )} + > + + {multiUser && fullWidth ? : <>} + + { + // Add CompareBadge if old, oldStateNo, and oldTimestamp are defined + previousCell && previousStateNo && previousStateTimestamp && ( + + ) + } - {input} - {split} - {fullWidth && (outputChanged || isActiveCell) ? ( - detailDiffOutput - ) : ( -
- )} -
- + {input} + {split} + {fullWidth && (outputChanged || isActiveCell) ? ( + detailDiffOutput + ) : ( +
+ )} +
); } diff --git a/src/Overview/Cells/DeletedCell.tsx b/src/Overview/Cells/DeletedCell.tsx index f7007ac..ea67f24 100644 --- a/src/Overview/Cells/DeletedCell.tsx +++ b/src/Overview/Cells/DeletedCell.tsx @@ -3,12 +3,17 @@ import React from 'react'; export interface IDeletedCellProps { cellId: string; isActiveCell: boolean; + stateNo: number; } -export function DeletedCell({ cellId, isActiveCell }: IDeletedCellProps): JSX.Element { +export function DeletedCell({ cellId, isActiveCell, stateNo }: IDeletedCellProps): JSX.Element { // console.log('render deleted cell'); return ( -
+
); diff --git a/src/Overview/Cells/MarkDownCell.tsx b/src/Overview/Cells/MarkDownCell.tsx index 9d42ce3..59d697b 100644 --- a/src/Overview/Cells/MarkDownCell.tsx +++ b/src/Overview/Cells/MarkDownCell.tsx @@ -83,44 +83,8 @@ export function MarkdownCell({ if (fullWidth) { return ( - <> -
0 }, - { ['changed']: changed } - )} - > - - {multiUser ? : <>} - - { - // Add CompareBadge if old, oldStateNo, and oldTimestamp are defined - previousCell && previousStateNo && previousStateTimestamp && ( - - ) - } - {detailDiffContent} -
- - ); - } - //else: compact - return ( - <>
+ {multiUser ? : <>} -
+ { + // Add CompareBadge if old, oldStateNo, and oldTimestamp are defined + previousCell && previousStateNo && previousStateTimestamp && ( + + ) + } + {detailDiffContent}
- + ); + } + //else: compact + return ( +
0 }, + { ['changed']: changed } + )} + > + + +
+
); } diff --git a/src/Overview/State.tsx b/src/Overview/State.tsx index a4ca602..eec24f1 100644 --- a/src/Overview/State.tsx +++ b/src/Overview/State.tsx @@ -12,10 +12,13 @@ import { getScrollParent, mergeArrays } from '../util'; import { CodeCell } from './Cells/CodeCell'; import { DeletedCell } from './Cells/DeletedCell'; import { MarkdownCell } from './Cells/MarkDownCell'; +import { useXarrow } from 'react-xarrows'; const useStyles = createStyles((theme, _params) => ({ header: { - borderBottom: 'var(--jp-border-width) solid var(--jp-toolbar-border-color)' + borderBottom: 'var(--jp-border-width) solid var(--jp-toolbar-border-color)', + zIndex: 1, + backgroundColor: 'white' }, stateWrapper: { label: 'wrapper', @@ -181,7 +184,9 @@ const useStyles = createStyles((theme, _params) => ({ label: 'version-split', borderTop: '1px solid var(--jp-toolbar-border-color)', marginTop: '1em', - textAlign: 'center' + textAlign: 'center', + zIndex: 1, // higher than the xarrow lines + backgroundColor: 'white' }, dashedBorder: { // borderLeft: 'var(--jp-border-width) dotted var(--jp-toolbar-border-color)', @@ -203,7 +208,6 @@ interface IStateProps { timestamp: Date; numStates: number; nbTracker: INotebookTracker; - handleScroll: (stateNo: number) => void; multiUser: boolean; } @@ -218,10 +222,10 @@ export function State({ timestamp, numStates, nbTracker, - handleScroll, multiUser }: IStateProps): JSX.Element { const { classes, cx } = useStyles(); + const updateXarrow = useXarrow(); const [fullWidth, setFullWidth] = useState(stateDoI === 1); // on first render, initialize with stateDoI useEffect(() => { @@ -292,50 +296,35 @@ export function State({ const activeCellTop = useLoopsStore(state => state.activeCellTop); const stateScrollerRef = useRef(null); - const scrollToElement = () => { - // provCellTop = distance of the provenance's corresponding cell to the top of the extension - // console.log(`state ${stateNo} scroll to active cell ID with top position`, activeCellId, activeCellTop); - const provCellTop = stateScrollerRef.current?.querySelector( - `[data-cell-id="${activeCellId}"]` - )?.offsetTop; - - const versionSplit = 35; - // activeCellTop and provCellTop are calculated relative to different elements, align them by adding the height of the top panel - const jpTopPanelHeight = document.querySelector('#jp-top-panel')?.offsetHeight || 0; - // the notebook cells have some padding at the top that needs to be considered in order to align the cells properly - const jpCellPadding = - parseInt(getComputedStyle(document.documentElement).getPropertyValue('--jp-cell-padding')) || 0; - - if (activeCellTop && provCellTop) { - // console.log('scroll to element', activeCellTop, provCellTop, jpTopPanelHeight, jpCellPadding); - const scrollPos = provCellTop - activeCellTop + jpTopPanelHeight - jpCellPadding + versionSplit; - // console.log('scrollpos', scrollPos); - stateScrollerRef.current?.scrollTo({ top: scrollPos, behavior: 'instant' }); - } - }; - useEffect( () => { + const scrollToElement = () => { + // provCellTop = distance of the provenance's corresponding cell to the top of the extension + // console.log(`state ${stateNo} scroll to active cell ID with top position`, activeCellId, activeCellTop); + const provCellTop = stateScrollerRef.current?.querySelector( + `[data-cell-id="${activeCellId}"]` + )?.offsetTop; + + const versionSplit = 35; + // activeCellTop and provCellTop are calculated relative to different elements, align them by adding the height of the top panel + const jpTopPanelHeight = document.querySelector('#jp-top-panel')?.offsetHeight || 0; + // the notebook cells have some padding at the top that needs to be considered in order to align the cells properly + const jpCellPadding = + parseInt(getComputedStyle(document.documentElement).getPropertyValue('--jp-cell-padding')) || 0; + + if (activeCellTop && provCellTop) { + // console.log('scroll to element', activeCellTop, provCellTop, jpTopPanelHeight, jpCellPadding); + const scrollPos = provCellTop - activeCellTop + jpTopPanelHeight - jpCellPadding + versionSplit; + // console.log('scrollpos', scrollPos); + stateScrollerRef.current?.scrollTo({ top: scrollPos, behavior: 'instant' }); + } + }; scrollToElement(); - } //, [activeCellTop] // commented out: dpeend on activeCellTop --> run if the value changes - //currently: no dependency --> run on every render (at the end of the render cycle) + }, + [activeCellId, activeCellTop] //depend on activeCellTop --> run if the value changes + ////currently: no dependency --> run on every render (at the end of the render cycle) ); - // useEffect(() => { - // const element = stateScrollerRef.current; - // const handleScrollWrapper = () => handleScroll(stateNo); - - // if (element !== null) { - // element.addEventListener('scroll', handleScrollWrapper); - // } - - // return () => { - // if (element !== null) { - // element.removeEventListener('scroll', handleScrollWrapper); - // } - // }; - // }, []); // Empty dependency array means this effect runs once on mount and cleanup on unmount - if (!state) { return
State {stateNo} not found
; } @@ -354,7 +343,7 @@ export function State({ if (cell === undefined && previousCell !== undefined) { // cell was deleted in current state - return ; + return ; } else if (cell === undefined && previousCell === undefined) { // cell is in none of the states // weird, but nothing to do @@ -478,7 +467,7 @@ export function State({ -
+
{cells} @@ -510,7 +499,3 @@ export function State({
); } - -interface IScrollableElement extends Element { - onscrollend: ((this: IScrollableElement, ev: Event) => any) | null; -} diff --git a/src/Overview/StateList.tsx b/src/Overview/StateList.tsx index 7ef911d..66ee803 100644 --- a/src/Overview/StateList.tsx +++ b/src/Overview/StateList.tsx @@ -10,6 +10,7 @@ import { NotebookProvenance } from '../Provenance/JupyterListener'; import { LoopsActiveCellMetaDataKey, LoopsStateMetaDataKey, LoopsUserMetaDataKey } from '../Provenance/NotebookTrrack'; import { State } from './State'; import { logTimes } from '../util'; +import Xarrow, { Xwrapper } from 'react-xarrows'; const useStyles = createStyles((theme, _params) => ({ stateList: { @@ -54,7 +55,7 @@ export function StateList({ nbTracker, labShell }: IStateListProps): JSX.Element const setActiveCell = useLoopsStore(state => state.setActiveCell); // Lines connecting the cells - const [lines, setLines] = useState([]); // Initialize state with empty array + const lines: JSX.Element[] = []; const trrack = notebook ? notebookModelCache.get(notebook)?.trrack : undefined; @@ -76,64 +77,6 @@ export function StateList({ nbTracker, labShell }: IStateListProps): JSX.Element // }; // }, [nbTracker, setActiveCell]); - const updateLines = stateNo => { - console.debug('update lines', stateNo); - const boundingClientRect = document.getElementById('Statelist')?.getBoundingClientRect(); - console.log('boundingClientRect', boundingClientRect); - if (!boundingClientRect) { - return; - } - - //remove all lines by initializing the array with an empty array - const newLines: JSX.Element[] = []; - - const cells = document.querySelectorAll('#DiffOverview .jp-Cell'); - - // Loop through the items - cells.forEach(item => { - const id = item.getAttribute('data-cell-id'); - const matchingCells = document.querySelectorAll(`#DiffOverview .jp-Cell[data-cell-id="${id}"]`); - - // Loop through matching items and create lines - matchingCells.forEach(matchingItem => { - if (matchingItem !== item) { - const itemRect = item.getBoundingClientRect(); - const matchingItemRect = matchingItem.getBoundingClientRect(); - - // Calculate the width based on horizontal distance - const width = matchingItemRect.left - itemRect.right; - - if (width > 0 && width < 30) { - // Calculate the height based on vertical offset - const height = matchingItemRect.top - itemRect.top; - - // Calculate the angle of the line - const angle = Math.atan2(height, width); - - // Calculate the length of the line - const length = Math.sqrt(width * width + height * height); - - // Calculate the top position for the line to start from the center - const top = itemRect.top - boundingClientRect.top + window.scrollY + itemRect.height / 2; - - newLines.push( -
- ); - } - } - }); - }); - setLines(newLines); - }; - // Scroll the container to the very right (most recent). // useCallback instead of useEffect because ref.current was null in useEffect // React will call that callback whenever the ref gets attached to a different node. @@ -272,6 +215,7 @@ export function StateList({ nbTracker, labShell }: IStateListProps): JSX.Element const states = statesFiltered.map((state, i, statesArray) => { const previousLastState = i - 1 >= 0 ? statesArray[i - 1].state : undefined; + const previouSCellExecutions = i - 1 >= 0 ? statesArray[i - 1].cellExecutions : undefined; const thisLastState = state; //create a map of cell Ids to execution counts @@ -288,6 +232,23 @@ export function StateList({ nbTracker, labShell }: IStateListProps): JSX.Element ) }); + Array.from(thisLastState.cellExecutions.keys()) + .filter(cellId => previouSCellExecutions?.has(cellId)) // did it exist, so we can connect? + .forEach(cellId => { + lines.push( + + ); + }); + return ( 1} /> ); @@ -313,8 +273,10 @@ export function StateList({ nbTracker, labShell }: IStateListProps): JSX.Element // console.log('stateTimes', stateTimes); return (
- {states} - {lines} + + {states} + {lines} +
); } diff --git a/yarn.lock b/yarn.lock index 67f48f3..3b62181 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4161,6 +4161,13 @@ __metadata: languageName: node linkType: hard +"@types/prop-types@npm:^15.7.3": + version: 15.7.11 + resolution: "@types/prop-types@npm:15.7.11" + checksum: 7519ff11d06fbf6b275029fe03fff9ec377b4cb6e864cac34d87d7146c7f5a7560fd164bdc1d2dbe00b60c43713631251af1fd3d34d46c69cd354602bc0c7c54 + languageName: node + linkType: hard + "@types/react@npm:^18.0.26": version: 18.2.6 resolution: "@types/react@npm:18.2.6" @@ -8287,6 +8294,7 @@ __metadata: monaco-editor: ^0.41.0 npm-run-all: ^4.1.5 prettier: ^2.8.7 + react-xarrows: ^2.0.2 rimraf: ^4.4.1 source-map-loader: ^1.0.2 style-loader: ^3.3.1 @@ -9624,6 +9632,19 @@ __metadata: languageName: node linkType: hard +"react-xarrows@npm:^2.0.2": + version: 2.0.2 + resolution: "react-xarrows@npm:2.0.2" + dependencies: + "@types/prop-types": ^15.7.3 + lodash: ^4.17.21 + prop-types: ^15.7.2 + peerDependencies: + react: ">=16.8.0" + checksum: e74a53405811f5031d9e31ed6d78f6b712e0c357dbd762abfa68b3ebf0c1311ca9a1396bece012881af54bc84f7ec09e191c0285db6763ce73dbd4cc7fdeffed + languageName: node + linkType: hard + "react@npm:^18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0"