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

Lazy rendering #150

Merged
merged 7 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
2 changes: 1 addition & 1 deletion src/management-system-v2/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
}

.reduceLogo .bjs-powered-by svg {
width: 30px;
width: 20px;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added after meeting

}

.small-tabs .ant-tabs-nav-wrap {
Expand Down
2 changes: 1 addition & 1 deletion src/management-system-v2/components/content.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
.Content {
overflow-x: initial;
overflow-y: auto;
padding: 40px 20px;
padding: 10px 10px;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added after meeting

max-width: 100%;
background-color: white;
height: calc(100vh - 150px);
Expand Down
17 changes: 12 additions & 5 deletions src/management-system-v2/components/process-icon-list.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
'use client';

import React, { Dispatch, FC, Key, SetStateAction } from 'react';
import React, {
Dispatch,
FC,
Key,
SetStateAction,
use,
useCallback,
useEffect,
useState,
} from 'react';

import TabCard from './tabcard-model-metadata';

import { Preferences, getPreferences } from '@/lib/utils';
import { ApiData } from '@/lib/fetch-data';
import ScrollBar from './scrollbar';
import { clear } from 'console';

type Processes = ApiData<'/process', 'get'>;

Expand All @@ -18,11 +27,9 @@ type IconViewProps = {
};

const IconView: FC<IconViewProps> = ({ data, selection, setSelection, search }) => {
const prefs: Preferences = getPreferences();

return (
<>
<ScrollBar width="10px">
<ScrollBar width="12px">
<div
style={{
width: '100%',
Expand Down
8 changes: 8 additions & 0 deletions src/management-system-v2/components/scrollbar.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.Scrollbar:hover {
background-color: #ddd;
cursor: ns-resize;
}

.Dragging {
background-color: #ddd;
}
139 changes: 128 additions & 11 deletions src/management-system-v2/components/scrollbar.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,77 @@
import React, { FC, useEffect, useState, useRef, useCallback } from 'react';
import { useIntervalLock } from '@/lib/useIntervalLock';
import { clear, time } from 'console';
import React, { FC, useEffect, useState, useRef, useCallback, use, MouseEventHandler } from 'react';
import styles from './scrollbar.module.scss';
import classNames from 'classnames';

type ScrollBarType = {
children: React.ReactNode;
width: string;
threshold?: number;
reachedEndCallBack?: () => void;
maxCallInterval?: number;
};

const ScrollBar: FC<ScrollBarType> = ({ children, width }) => {
const [scrollHeight, setScrollHeight] = useState(0);
const [maxThumbHeight, minThumbHeight] = [15, 5]; /* In % */

const ScrollBar: FC<ScrollBarType> = ({
children,
width,
threshold,
reachedEndCallBack,
maxCallInterval,
}) => {
const [thumbHeight, setThumbHeight] = useState(0);
const [scrollPosition, setScrollPosition] = useState(0);
const [isDragging, setIsDragging] = useState(false);

const containerRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);

const maxInterval = maxCallInterval || 500;
const scrolledToTH = useIntervalLock(reachedEndCallBack, maxInterval);

const handleScroll = useCallback(() => {
if (containerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const newScrollHeight = (clientHeight / scrollHeight) * 100;
const newThumbHeight = Math.min(
Math.max((clientHeight / scrollHeight) * 100, minThumbHeight),
maxThumbHeight,
);
const newScrollPosition =
(scrollTop / (scrollHeight - clientHeight)) * (100 - newScrollHeight);
(scrollTop / (scrollHeight - clientHeight)) * (100 - newThumbHeight);

if (reachedEndCallBack) {
const th = threshold || 0.8;

if (scrollTop / (scrollHeight - clientHeight) > th) {
scrolledToTH();
}
}

setScrollHeight(newScrollHeight);
setThumbHeight(newThumbHeight);
setScrollPosition(newScrollPosition);
}
}, [reachedEndCallBack, scrolledToTH, threshhold]);

const handleScrollbarClick = useCallback((e: MouseEvent) => {
if (containerRef.current && thumbRef.current) {
const { clientY } = e;
const { top, height } = containerRef.current.getBoundingClientRect();
const thumbHeight = thumbRef.current.clientHeight;

let newScrollTop =
((clientY - top - thumbHeight / 2) / (height - thumbHeight)) *
containerRef.current.scrollHeight;

newScrollTop = Math.max(newScrollTop, 0);
newScrollTop = Math.min(
newScrollTop,
containerRef.current.scrollHeight - containerRef.current.clientHeight,
);

containerRef.current.scrollTop = newScrollTop;
}
}, []);

const handleMouseDown = useCallback(() => {
Expand All @@ -41,7 +90,7 @@ const ScrollBar: FC<ScrollBarType> = ({ children, width }) => {
const thumbHeight = thumbRef.current.clientHeight;

let newScrollTop =
((clientY - top - thumbHeight / 2) / (height - thumbHeight)) *
((clientY - top - thumbHeight) / (height - thumbHeight)) *
containerRef.current.scrollHeight;

newScrollTop = Math.max(newScrollTop, 0);
Expand Down Expand Up @@ -69,11 +118,14 @@ const ScrollBar: FC<ScrollBarType> = ({ children, width }) => {
}, [handleMouseMove, handleMouseUp, isDragging]);

useEffect(() => {
handleScroll();
/* Timeout for DOM to load content first */
setTimeout(() => {
handleScroll();
}, 100);
}, [handleScroll]);

return (
<div style={{ display: 'flex', height: '95%', width: '100%' }}>
<div style={{ position: 'relative', display: 'flex', height: '95%', width: '100%' }}>
<div
className="Hide-Scroll-Bar"
ref={containerRef}
Expand All @@ -82,16 +134,20 @@ const ScrollBar: FC<ScrollBarType> = ({ children, width }) => {
overflowY: 'scroll',
width: `calc(100% - ${width})`,
}}
data-proceed-scroll="Scroll-Bar-Viewport"
>
{children}
</div>
<div
className={classNames(styles.Scrollbar, { [styles.Dragging]: isDragging })}
style={{
width,
width: `${width}`,
height: '100%',
position: 'relative',
marginLeft: '4px',
borderRadius: '8px',
}}
onDoubleClick={handleScrollbarClick}
>
<div
ref={thumbRef}
Expand All @@ -100,7 +156,8 @@ const ScrollBar: FC<ScrollBarType> = ({ children, width }) => {
position: 'absolute',
top: `${scrollPosition}%`,
width: '80%',
height: `${scrollHeight}%`,
marginLeft: '10%',
height: `${thumbHeight}%`,
backgroundColor: '#888',
borderRadius: '8px',
cursor: 'grab',
Expand All @@ -111,4 +168,64 @@ const ScrollBar: FC<ScrollBarType> = ({ children, width }) => {
);
};

// Helper function to find first ancestor with custom attribute
// Traverses up the DOM tree
// Here: data-scroll
const findParentWithAttribute = (
element: HTMLElement | null,
attribute: string,
): HTMLElement | null => {
if (!element || element.tagName === 'BODY') {
return null;
}
return element.getAttribute(attribute)
? element
: findParentWithAttribute(element.parentElement, attribute);
};

/**
* The next Scrollbar ancestor will be used as the viewport or, if there is none, the viewport of the client
* @param watchElement ref to element to watch
* @param margin top and bottom margin of viewport in px or %, defaults to 50%
* @param once whether the returned value should remain true once it has been set to true, defaults to true
* @returns boolean: whether the element is visible or not
*/
export const useLazyLoading = (
watchElement: React.MutableRefObject<HTMLElement | null>,
margin: string = '50%',
once: boolean = true,
) => {
const [isVisible, setIsVisible] = useState(false);
const viewport = useRef<HTMLElement | null>(null);
/* viewport = null -> defaults to the browser viewport */

useEffect(() => {
viewport.current = findParentWithAttribute(watchElement!.current, 'data-proceed-scroll');

const thisElement = watchElement!.current;

const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(entry.isIntersecting || entry.intersectionRatio > 0);
if (once && (entry.isIntersecting || entry.intersectionRatio > 0)) {
observer.unobserve(thisElement);
}
},
{
root: viewport.current,
rootMargin: `${margin} 0%`,
// threshold: 0.5,
},
);

observer.observe(thisElement);

return () => {
observer.unobserve(thisElement);
};
}, [watchElement, margin, once]);

return isVisible;
};

export default ScrollBar;
22 changes: 18 additions & 4 deletions src/management-system-v2/components/tabcard-model-metadata.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { Button, Card, Descriptions, DescriptionsProps } from 'antd';
import React, { Dispatch, FC, Key, ReactNode, SetStateAction, useCallback, useState } from 'react';
import React, {
Dispatch,
FC,
Key,
ReactNode,
SetStateAction,
useCallback,
useRef,
useState,
} from 'react';

import { MoreOutlined } from '@ant-design/icons';
import Viewer from './bpmn-viewer';
Expand All @@ -9,6 +18,7 @@ import classNames from 'classnames';
import { generateDateString } from '@/lib/utils';
import useLastClickedStore from '@/lib/use-last-clicked-process-store';
import { ApiData } from '@/lib/fetch-data';
import { useLazyLoading } from './scrollbar';

type Processes = ApiData<'/process', 'get'>;
type Process = Processes[number];
Expand Down Expand Up @@ -67,7 +77,7 @@ const generateDescription = (data: Process) => {
return desc;
};

const generateContentList = (data: Process) => {
const generateContentList = (data: Process, showViewer: boolean = true) => {
return {
viewer: (
<div
Expand All @@ -78,7 +88,7 @@ const generateContentList = (data: Process) => {
borderRadius: '8px',
}}
>
<Viewer selectedElement={data} reduceLogo={true} />
{showViewer && <Viewer selectedElement={data} reduceLogo={true} />}
</div>
),
meta: (
Expand All @@ -104,6 +114,9 @@ const TabCard: FC<TabCardProps> = ({
const router = useRouter();
const [activeTabKey, setActiveTabKey] = useState<Tab>('viewer');

const cardRef = useRef<HTMLDivElement | null>(null);
const isVisible = useLazyLoading(cardRef);

const lastProcessId = useLastClickedStore((state) => state.processId);
const setLastProcessId = useLastClickedStore((state) => state.setProcessId);

Expand Down Expand Up @@ -150,6 +163,7 @@ const TabCard: FC<TabCardProps> = ({

return (
<Card
ref={cardRef}
hoverable
title={
<div style={{ display: 'inline-flex', alignItems: 'center', width: '100%' }}>
Expand Down Expand Up @@ -222,7 +236,7 @@ const TabCard: FC<TabCardProps> = ({
router.push(`/processes/${item.definitionId}`);
}}
>
{generateContentList(item)[activeTabKey]}
{generateContentList(item, isVisible)[activeTabKey]}
</Card>
);
};
Expand Down
19 changes: 19 additions & 0 deletions src/management-system-v2/lib/useIntervalLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useCallback, useRef } from 'react';

/**
* A hook that returns a function that can be invoked at most once per interval.
*/
export const useIntervalLock = (func: (() => any) | undefined = () => {}, interval: number) => {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const invoke = useCallback(() => {
if (timeoutRef.current === null) {
func();
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
}, interval);
}
}, [func, interval]);

return invoke;
};
32 changes: 0 additions & 32 deletions src/management-system-v2/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,3 @@ export function generateDateString(date?: Date | string, includeTime: boolean =
type JSONValue = string | number | boolean | null | JSONObject | JSONArray;
type JSONObject = { [key: string]: JSONValue };
type JSONArray = JSONValue[];

export type Preferences = JSONObject;

export const getPreferences = (): Preferences => {
const res =
typeof document !== 'undefined'
? document?.cookie.split('; ').find((cookie) => cookie.startsWith('userpreferences='))
: undefined;
if (!res) return {};
return JSON.parse(res?.split('=')[1]);
};

/**
* Adds a preference to the user's preferences.
* If the preference already exists, it will be overwritten.
* @param prefs - Object of preferences to add to the user's preferences
* @returns void
**/
export const addUserPreference = (prefs: Preferences) => {
const oldPrefs = getPreferences();
document.cookie = `userpreferences=${JSON.stringify({
...oldPrefs,
...prefs,
})}; SameSite=None; Secure; Max-Age=31536000;`;
};

/* Values and defaults:
{
'show-process-meta-data': true
'icon-view-in-process-list': false
}
*/