Skip to content

Commit

Permalink
Add resizing of sidebar (#2240)
Browse files Browse the repository at this point in the history
* Add useDimensions hook to dynamically get HTML element dimensions

* Fix resizing editor divider snapping calculations using window width

* Update SideBar styling to take up empty space

* Set max width of sidebar

* Fix sidebar resize component not being positioned correctly

* Fix sidebar resize component not being positioned correctly without hacks

* Disable resizing of sidebar when there are no sidebar tabs

* Set min width of sidebar

* Rework SideBar state management to remember last selected tab

* Add snapping when sidebar is resized below a width of 100px

* Hook resizing logic of sidebar to its expansion state

* Fix sidebar min width on pages where the sidebar is not rendered

* Remove temporary sidebar tab for testing
  • Loading branch information
ianyong authored Oct 16, 2022
1 parent edd9728 commit e618d6a
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 24 deletions.
26 changes: 17 additions & 9 deletions src/commons/sideBar/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,48 @@ export type SideBarTab = {

export type SideBarProps = {
tabs: SideBarTab[];
isExpanded: boolean;
expandSideBar: () => void;
collapseSideBar: () => void;
};

const SideBar: React.FC<SideBarProps> = (props: SideBarProps) => {
const [selectedTabIndex, setSelectedTabIndex] = React.useState<number | null>(null);
const { tabs, isExpanded, expandSideBar, collapseSideBar } = props;

const [selectedTabIndex, setSelectedTabIndex] = React.useState<number>(0);

const handleTabSelection = (tabIndex: number) => {
if (selectedTabIndex === tabIndex) {
setSelectedTabIndex(null);
if (isExpanded) {
collapseSideBar();
} else {
expandSideBar();
}
return;
}
setSelectedTabIndex(tabIndex);
expandSideBar();
};

// Do not render the sidebar if there are no tabs.
if (props.tabs.length === 0) {
return <></>;
if (tabs.length === 0) {
return <div className="sidebar-container" />;
}

return (
<div className="sidebar-container">
<div className="tab-container">
{props.tabs.map((tab, index) => (
{tabs.map((tab, index) => (
<Card
key={index}
className={classNames('tab', { selected: selectedTabIndex === index })}
className={classNames('tab', { selected: isExpanded && selectedTabIndex === index })}
onClick={() => handleTabSelection(index)}
>
{tab.label}
</Card>
))}
</div>
{selectedTabIndex !== null && (
<Card className="panel">{props.tabs[selectedTabIndex].body}</Card>
)}
{selectedTabIndex !== null && <Card className="panel">{tabs[selectedTabIndex].body}</Card>}
</div>
);
};
Expand Down
39 changes: 38 additions & 1 deletion src/commons/utils/Hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { RefObject } from 'react';

import { readLocalStorage, setLocalStorage } from './LocalStorageHelper';

Expand Down Expand Up @@ -99,3 +99,40 @@ export function useLocalStorageState<T>(

return [value, setValue];
}

/**
* Dynamically returns the dimensions (width & height) of an HTML element, updating whenever the
* element is loaded or resized.
*
* @param ref A reference to the underlying HTML element.
*/
export const useDimensions = (ref: RefObject<HTMLElement>): [width: number, height: number] => {
const [width, setWidth] = React.useState<number>(0);
const [height, setHeight] = React.useState<number>(0);

const resizeObserver = React.useMemo(
() =>
new ResizeObserver((entries: ResizeObserverEntry[], observer: ResizeObserver) => {
if (entries.length !== 1) {
throw new Error(
'Expected only a single HTML element to be observed by the ResizeObserver.'
);
}
const contentRect = entries[0].contentRect;
setWidth(contentRect.width);
setHeight(contentRect.height);
}),
[]
);

React.useEffect(() => {
const htmlElement = ref.current;
if (htmlElement === null) {
return;
}
resizeObserver.observe(htmlElement);
return () => resizeObserver.disconnect();
}, [ref, resizeObserver]);

return [width, height];
};
111 changes: 99 additions & 12 deletions src/commons/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { FocusStyleManager } from '@blueprintjs/core';
import { Resizable, ResizableProps, ResizeCallback } from 're-resizable';
import { NumberSize, Resizable, ResizableProps, ResizeCallback } from 're-resizable';
import { Direction } from 're-resizable/lib/resizer';
import * as React from 'react';
import { Prompt } from 'react-router';

import ControlBar, { ControlBarProps } from '../controlBar/ControlBar';
import Editor, { EditorProps } from '../editor/Editor';
import McqChooser, { McqChooserProps } from '../mcqChooser/McqChooser';
import Repl, { ReplProps } from '../repl/Repl';
import SideBar, { SideBarProps } from '../sideBar/SideBar';
import SideBar, { SideBarTab } from '../sideBar/SideBar';
import SideContent, { SideContentProps } from '../sideContent/SideContent';
import { useDimensions } from '../utils/Hooks';

export type WorkspaceProps = DispatchProps & StateProps;

Expand All @@ -24,17 +26,44 @@ type StateProps = {
hasUnsavedChanges?: boolean;
mcqProps?: McqChooserProps;
replProps: ReplProps;
sideBarProps: SideBarProps;
sideBarProps: {
tabs: SideBarTab[];
};
sideContentHeight?: number;
sideContentProps: SideContentProps;
sideContentIsResizeable?: boolean;
};

const Workspace: React.FC<WorkspaceProps> = props => {
const sideBarResizable = React.useRef<Resizable | null>(null);
const contentContainerDiv = React.useRef<HTMLDivElement | null>(null);
const editorDividerDiv = React.useRef<HTMLDivElement | null>(null);
const leftParentResizable = React.useRef<Resizable | null>(null);
const maxDividerHeight = React.useRef<number | null>(null);
const sideDividerDiv = React.useRef<HTMLDivElement | null>(null);
const [contentContainerWidth] = useDimensions(contentContainerDiv);
const [lastExpandedSideBarWidth, setLastExpandedSideBarWidth] = React.useState<number>(200);
const [isSideBarExpanded, setIsSideBarExpanded] = React.useState<boolean>(false);

const sideBarCollapsedWidth = 40;

const expandSideBar = () => {
setIsSideBarExpanded(true);
const sideBar = sideBarResizable.current;
if (sideBar === null) {
throw Error('Reference to SideBar not found when expanding.');
}
sideBar.updateSize({ width: lastExpandedSideBarWidth, height: '100%' });
};

const collapseSideBar = () => {
setIsSideBarExpanded(false);
const sideBar = sideBarResizable.current;
if (sideBar === null) {
throw Error('Reference to SideBar not found when collapsing.');
}
sideBar.updateSize({ width: sideBarCollapsedWidth, height: '100%' });
};

FocusStyleManager.onlyShowFocusOnTabs();

Expand All @@ -48,6 +77,31 @@ const Workspace: React.FC<WorkspaceProps> = props => {
return { ...props.controlBarProps };
};

const sideBarResizableProps = () => {
const onResizeStop: ResizeCallback = (
event: MouseEvent | TouchEvent,
direction: Direction,
elementRef: HTMLElement,
delta: NumberSize
) => {
const sideBarWidth = elementRef.clientWidth;
if (sideBarWidth !== sideBarCollapsedWidth) {
setLastExpandedSideBarWidth(sideBarWidth);
}
};
const isSideBarRendered = props.sideBarProps.tabs.length !== 0;
const minWidth = isSideBarRendered ? sideBarCollapsedWidth : 'auto';
return {
enable: isSideBarRendered ? rightResizeOnly : noResize,
minWidth,
maxWidth: '50%',
onResize: toggleSideBarDividerDisplay,
onResizeStop,
ref: sideBarResizable,
defaultSize: { width: minWidth, height: '100%' }
} as ResizableProps;
};

const editorResizableProps = () => {
return {
className: 'resize-editor left-parent',
Expand All @@ -72,6 +126,26 @@ const Workspace: React.FC<WorkspaceProps> = props => {
} as ResizableProps;
};

const toggleSideBarDividerDisplay: ResizeCallback = (
event: MouseEvent | TouchEvent,
direction: Direction,
elementRef: HTMLElement,
delta: NumberSize
) => {
const minWidthThreshold = 100;
const sideBarWidth = elementRef.clientWidth;
if (sideBarWidth < minWidthThreshold) {
const sideBar = sideBarResizable.current;
if (sideBar === null) {
throw Error('Reference to SideBar not found when resizing.');
}
sideBar.updateSize({ width: 40, height: '100%' });
setIsSideBarExpanded(false);
} else {
setIsSideBarExpanded(true);
}
};

/**
* Snaps the left-parent resizable to 100% or 0% when percentage width goes
* above 95% or below 5% respectively. Also changes the editor divider width
Expand All @@ -80,19 +154,14 @@ const Workspace: React.FC<WorkspaceProps> = props => {
const toggleEditorDividerDisplay: ResizeCallback = (_a, _b, ref) => {
const leftThreshold = 5;
const rightThreshold = 95;
const editorWidthPercentage = ((ref as HTMLDivElement).clientWidth / window.innerWidth) * 100;
const editorWidthPercentage =
((ref as HTMLDivElement).clientWidth / contentContainerWidth) * 100;
// update resizable size
if (editorWidthPercentage > rightThreshold) {
leftParentResizable.current!.updateSize({ width: '100%', height: '100%' });
} else if (editorWidthPercentage < leftThreshold) {
leftParentResizable.current!.updateSize({ width: '0%', height: '100%' });
}
// Update divider margin
if (editorWidthPercentage < leftThreshold) {
editorDividerDiv.current!.style.marginRight = '0.5rem';
} else {
editorDividerDiv.current!.style.marginRight = '0';
}
};

/**
Expand Down Expand Up @@ -144,8 +213,15 @@ const Workspace: React.FC<WorkspaceProps> = props => {
) : null}
<ControlBar {...controlBarProps()} />
<div className="workspace-parent">
<SideBar {...props.sideBarProps} />
<div className="row content-parent">
<Resizable {...sideBarResizableProps()}>
<SideBar
{...props.sideBarProps}
isExpanded={isSideBarExpanded}
expandSideBar={expandSideBar}
collapseSideBar={collapseSideBar}
/>
</Resizable>
<div className="row content-parent" ref={contentContainerDiv}>
<div className="editor-divider" ref={editorDividerDiv} />
<Resizable {...editorResizableProps()}>{createWorkspaceInput(props)}</Resizable>
<div className="right-parent">
Expand All @@ -160,6 +236,17 @@ const Workspace: React.FC<WorkspaceProps> = props => {
);
};

const noResize = {
top: false,
right: false,
bottom: false,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false
};

const rightResizeOnly = {
top: false,
right: true,
Expand Down
6 changes: 5 additions & 1 deletion src/styles/_sideBar.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
.sidebar-container {
height: 100%;
padding-bottom: 0.6rem;
padding-right: 0.5rem;
display: flex;
flex-direction: row;
column-gap: 8px;
Expand Down Expand Up @@ -44,6 +46,8 @@
.panel {
// !important is necessary to override the default Card background-color property.
background-color: $cadet-color-2 !important;
width: 200px;
width: 100%;
height: 100%;
min-width: 0;
padding: 0;
}
2 changes: 1 addition & 1 deletion src/styles/_workspace.scss
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@ $code-color-error: #ff4444;

.Editor {
flex: 1 1 auto;
margin: 0 0.5rem 0 0.5rem;
margin: 0 0.5rem 0 0;
padding: 0;
}

Expand Down

0 comments on commit e618d6a

Please sign in to comment.