diff --git a/teachertool/src/components/MainPanel.tsx b/teachertool/src/components/MainPanel.tsx index 2438a71ae78d..aef72e4d91d4 100644 --- a/teachertool/src/components/MainPanel.tsx +++ b/teachertool/src/components/MainPanel.tsx @@ -3,18 +3,30 @@ import css from "./styling/MainPanel.module.scss"; import { SplitPane } from "./SplitPane"; import { RubricWorkspace } from "./RubricWorkspace"; import { ProjectWorkspace } from "./ProjectWorkspace"; +import { getLastSplitPosition, setLastSplitPosition } from "../services/storageService"; interface IProps {} export const MainPanel: React.FC = () => { + const defaultSize = "50%"; + + function handleResizeEnd(size: number | string) { + setLastSplitPosition(size.toString()); + } + + const lastSavedSplitPosition = getLastSplitPosition(); return (
} right={} + leftMinSize="5rem" + rightMinSize="5rem" + onResizeEnd={handleResizeEnd} />
); diff --git a/teachertool/src/components/SplitPane.tsx b/teachertool/src/components/SplitPane.tsx index 034e707b1d36..3ee7b3ae932f 100644 --- a/teachertool/src/components/SplitPane.tsx +++ b/teachertool/src/components/SplitPane.tsx @@ -5,20 +5,114 @@ import { classList } from "react-common/components/util"; interface IProps { className?: string; split: "horizontal" | "vertical"; - defaultSize: number | string; + defaultSize: number | string; // The size to reset to when double clicking the splitter. + startingSize?: number | string; // The size to use initially when creating the splitter. Defaults to `defaultSize`. primary: "left" | "right"; left: React.ReactNode; right: React.ReactNode; + leftMinSize: number | string; + rightMinSize: number | string; + onResizeEnd?: (size: number | string) => void; } -export const SplitPane: React.FC = ({ className, split, left, right }) => { +export const SplitPane: React.FC = ({ + className, + split, + defaultSize, + startingSize, + left, + right, + leftMinSize, + rightMinSize, + onResizeEnd, +}) => { + const [size, setSize] = React.useState(startingSize ?? defaultSize); + const [isResizing, setIsResizing] = React.useState(false); + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (!isResizing) { + onResizeEnd?.(size); + } + }, [isResizing]); + + function handleResizeMouse(event: MouseEvent) { + handleResize(event.clientX, event.clientY); + } + + function handleResizeTouch(event: TouchEvent) { + if (event.touches.length >= 1) { + handleResize(event.touches[0].clientX, event.touches[0].clientY); + } + } + + function handleResize(clientX: number, clientY: number) { + const containerRect = containerRef.current?.getBoundingClientRect(); + if (containerRect) { + setIsResizing(true); // Do this here rather than inside startResizing to prevent interference with double click detection. + const newSize = + split === "vertical" + ? `${((clientX - containerRect.left) / containerRect.width) * 100}%` + : `${((clientY - containerRect.top) / containerRect.height) * 100}%`; + setSize(newSize); + } + } + + function startResizing(event: React.MouseEvent | React.TouchEvent) { + event.preventDefault(); + document.addEventListener("mousemove", handleResizeMouse); + document.addEventListener("mouseup", endResizing); + document.addEventListener("touchmove", handleResizeTouch); + document.addEventListener("touchend", endResizing); + } + + function endResizing() { + document.removeEventListener("mousemove", handleResizeMouse); + document.removeEventListener("mouseup", endResizing); + document.removeEventListener("touchmove", handleResizeTouch); + document.removeEventListener("touchend", endResizing); + + setIsResizing(false); + } + + function setToDefaultSize() { + setSize(defaultSize); + onResizeEnd?.(defaultSize); + } + + const leftStyle: React.CSSProperties = { flexBasis: size }; + if (split === "vertical") { + leftStyle.minWidth = leftMinSize; + + // Setting right's minWidth doesn't work because left is still allowed + // to expand beyond it. Instead, set left's maxWidth. + leftStyle.maxWidth = `calc(100% - ${rightMinSize})`; + } else { + leftStyle.minHeight = leftMinSize; + leftStyle.maxHeight = `calc(100% - ${rightMinSize})`; + } + return ( -
-
{left}
-
-
+
+
+ {left} +
+
+
-
{right}
+
{right}
+ + {/* + This overlay is necessary to prevent any other parts of the page (particularly iframes) + from intercepting the mouse events while resizing. We simply add a transparent div over the + left and right sections. + */} +
); }; diff --git a/teachertool/src/components/styling/SplitPane.module.scss b/teachertool/src/components/styling/SplitPane.module.scss index 6c0629eda7cc..7fcc62457a5f 100644 --- a/teachertool/src/components/styling/SplitPane.module.scss +++ b/teachertool/src/components/styling/SplitPane.module.scss @@ -1,67 +1,62 @@ - -// TODO make this scssy - -.split-pane-vertical { - display: flex; - flex-direction: row; - height: 100%; - width: 100%; -} - -.left-vertical { - flex: 1; - overflow: auto; -} - -.right-vertical { - flex: 1; - overflow: auto; -} - -.splitter-vertical { - background-color: var(--pxt-content-accent); - width: 1px; -} - -.splitter-vertical-inner { - position: relative; - background-color: transparent; - transition: background-color 0.2s ease; - left: -2.5px; - width: 5px; - height: 100%; - cursor: ew-resize; - z-index: 1; -} - -.splitter-vertical-inner:hover { - background-color: var(--pxt-content-foreground); - transition: background-color 0.2s ease; - transition-delay: 0.2s; -} - -/* Horizontal split pane */ - -.split-pane-horizontal { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; -} - -.left-horizontal { - flex: 1; - overflow: auto; -} - -.right-horizontal { - flex: 1; - overflow: auto; -} - -.splitter-horizontal { - flex: 0 0 1px; - background-color: var(--pxt-headerbar-background); - height: 5px; - cursor: ns-resize; +.split-pane { + display: flex; + height: 100%; + width: 100%; + + .left { + flex-grow: 0; + flex-shrink: 0; + overflow: auto; + } + + .right { + flex-grow: 1; + flex-shrink: 1; + overflow: auto; + } + + .resizing-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } +} + +.split-vertical { + flex-direction: row; + + .splitter { + background-color: var(--pxt-content-accent); + width: 1px; + + .splitter-inner { + position: relative; + background-color: transparent; + transition: background-color 0.2s ease; + left: -3px; + width: 6px; + height: 100%; + cursor: ew-resize; + z-index: 1; + + &:hover { + background-color: var(--pxt-content-foreground); + transition: background-color 0.2s ease; + transition-delay: 0.2s; + } + } + } +} + +.split-horizontal { + flex-direction: column; + + .splitter { + flex: 0 0 1px; + background-color: var(--pxt-headerbar-background); + height: 5px; + cursor: ns-resize; + } } diff --git a/teachertool/src/services/storageService.ts b/teachertool/src/services/storageService.ts index 459d6e02c9f6..33162c2fbf10 100644 --- a/teachertool/src/services/storageService.ts +++ b/teachertool/src/services/storageService.ts @@ -10,6 +10,7 @@ import { Rubric } from "../types/rubric"; const KEY_PREFIX = "teachertool"; const AUTORUN_KEY = [KEY_PREFIX, "autorun"].join("/"); const LAST_ACTIVE_RUBRIC_KEY = [KEY_PREFIX, "lastActiveRubric"].join("/"); +const SPLIT_POSITION_KEY = [KEY_PREFIX, "splitPosition"].join("/"); function getValue(key: string, defaultValue?: string): string | undefined { return localStorage.getItem(key) || defaultValue; @@ -148,6 +149,23 @@ export function setLastActiveRubricName(name: string) { } } +export function getLastSplitPosition(): string { + try { + return getValue(SPLIT_POSITION_KEY) ?? ""; + } catch (e) { + logError(ErrorCode.localStorageReadError, e); + return ""; + } +} + +export function setLastSplitPosition(position: string) { + try { + setValue(SPLIT_POSITION_KEY, position); + } catch (e) { + logError(ErrorCode.localStorageWriteError, e); + } +} + export async function getRubric(name: string): Promise { const db = await getDb; diff --git a/teachertool/src/state/helpers.ts b/teachertool/src/state/helpers.ts index 93b9d5aead91..c836e6b2d2ce 100644 --- a/teachertool/src/state/helpers.ts +++ b/teachertool/src/state/helpers.ts @@ -71,7 +71,7 @@ export function getSelectableCatalogCriteria(state: AppState): CatalogCriteria[] state.catalog?.filter( catalogCriteria => ((catalogCriteria.parameters && catalogCriteria.parameters.length > 0) || - !usedCatalogCriteria.includes(catalogCriteria.id)) && + !usedCatalogCriteria.includes(catalogCriteria.id)) && !catalogCriteria.hideInCatalog ) ?? [] );