diff --git a/packages/components/src/components/SquiggleChart.tsx b/packages/components/src/components/SquiggleChart.tsx index 9367e63e..10e4e22c 100644 --- a/packages/components/src/components/SquiggleChart.tsx +++ b/packages/components/src/components/SquiggleChart.tsx @@ -51,6 +51,7 @@ export interface SquiggleChartProps { maxX?: number; /** Whether to show vega actions to the user, so they can copy the chart spec */ distributionChartActions?: boolean; + enableLocalSettings?: boolean; } const defaultOnChange = () => {}; @@ -76,6 +77,7 @@ export const SquiggleChart: React.FC = React.memo( color, title, distributionChartActions, + enableLocalSettings = false, }) => { const result = useSquiggle({ code, @@ -111,6 +113,7 @@ export const SquiggleChart: React.FC = React.memo( distributionPlotSettings={distributionPlotSettings} chartSettings={chartSettings} environment={environment ?? defaultEnvironment} + enableLocalSettings={enableLocalSettings} /> ); } diff --git a/packages/components/src/components/SquiggleContainer.tsx b/packages/components/src/components/SquiggleContainer.tsx index 63dbb54a..bb3f1db4 100644 --- a/packages/components/src/components/SquiggleContainer.tsx +++ b/packages/components/src/components/SquiggleContainer.tsx @@ -13,6 +13,7 @@ const SquiggleContext = React.createContext({ export const SquiggleContainer: React.FC = ({ children }) => { const context = useContext(SquiggleContext); + if (context.containerized) { return <>{children}; } else { diff --git a/packages/components/src/components/SquigglePlayground.tsx b/packages/components/src/components/SquigglePlayground.tsx index 18e6a4c7..d333985a 100644 --- a/packages/components/src/components/SquigglePlayground.tsx +++ b/packages/components/src/components/SquigglePlayground.tsx @@ -1,4 +1,11 @@ -import React, { FC, useState, useEffect, useMemo } from "react"; +import React, { + FC, + useState, + useEffect, + useMemo, + useRef, + useCallback, +} from "react"; import { useForm, UseFormRegister, useWatch } from "react-hook-form"; import * as yup from "yup"; import { useMaybeControlledValue } from "../lib/hooks"; @@ -229,6 +236,13 @@ const useRunnerState = (code: string) => { }; }; +type PlaygroundContextShape = { + getLeftPanelElement: () => HTMLDivElement | undefined; +}; +export const PlaygroundContext = React.createContext({ + getLeftPanelElement: () => undefined, +}); + export const SquigglePlayground: FC = ({ defaultCode = "", height = 500, @@ -301,6 +315,7 @@ export const SquigglePlayground: FC = ({ {...vars} bindings={defaultBindings} jsImports={imports} + enableLocalSettings={true} /> ); @@ -345,40 +360,54 @@ export const SquigglePlayground: FC = ({ ); + const leftPanelRef = useRef(null); + const withEditor = (
-
{tabs}
+
+ {tabs} +
{squiggleChart}
); const withoutEditor =
{tabs}
; + const getLeftPanelElement = useCallback(() => { + return leftPanelRef.current ?? undefined; + }, []); + return ( - -
-
- - + +
+
+ + + + + + + - - - - - +
+ {vars.showEditor ? withEditor : withoutEditor}
- {vars.showEditor ? withEditor : withoutEditor} -
- + + ); }; diff --git a/packages/components/src/components/SquiggleViewer/ItemSettingsMenu.tsx b/packages/components/src/components/SquiggleViewer/ItemSettingsMenu.tsx index 39f14988..c4c226d8 100644 --- a/packages/components/src/components/SquiggleViewer/ItemSettingsMenu.tsx +++ b/packages/components/src/components/SquiggleViewer/ItemSettingsMenu.tsx @@ -1,5 +1,5 @@ import { CogIcon } from "@heroicons/react/solid"; -import React, { useContext, useState } from "react"; +import React, { useContext, useRef, useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; import { Modal } from "../ui/Modal"; @@ -10,6 +10,7 @@ import { defaultColor, defaultTickFormat, } from "../../lib/distributionSpecBuilder"; +import { PlaygroundContext } from "../SquigglePlayground"; type Props = { path: Path; @@ -18,12 +19,15 @@ type Props = { withFunctionSettings: boolean; }; -const ItemSettingsModal: React.FC void }> = ({ +const ItemSettingsModal: React.FC< + Props & { close: () => void; resetScroll: () => void } +> = ({ path, onChange, disableLogX, withFunctionSettings, close, + resetScroll, }) => { const { setSettings, getSettings, getMergedSettings } = useContext(ViewerContext); @@ -51,7 +55,7 @@ const ItemSettingsModal: React.FC void }> = ({ diagramCount: mergedSettings.chartSettings.count, }, }); - React.useEffect(() => { + useEffect(() => { const subscription = watch((vars) => { const settings = getSettings(path); // get the latest version setSettings(path, { @@ -78,10 +82,26 @@ const ItemSettingsModal: React.FC void }> = ({ return () => subscription.unsubscribe(); }, [getSettings, setSettings, onChange, path, watch]); + const { getLeftPanelElement } = useContext(PlaygroundContext); + return ( - - - Chart settings{path.length ? " for " + pathAsString(path) : ""} + + + Chart settings + {path.length ? ( + <> + {" for "} + + {pathAsString(path)} + {" "} + + ) : ( + "" + )} void }> = ({ export const ItemSettingsMenu: React.FC = (props) => { const [isOpen, setIsOpen] = useState(false); - const { setSettings, getSettings } = useContext(ViewerContext); + const { enableLocalSettings, setSettings, getSettings } = + useContext(ViewerContext); + + const ref = useRef(null); + + if (!enableLocalSettings) { + return null; + } const settings = getSettings(props.path); + const resetScroll = () => { + if (!ref.current) return; + window.scroll({ + top: ref.current.getBoundingClientRect().y + window.scrollY, + }); + }; + return ( -
+
setIsOpen(!isOpen)} @@ -122,7 +156,11 @@ export const ItemSettingsMenu: React.FC = (props) => { ) : null} {isOpen ? ( - setIsOpen(false)} /> + setIsOpen(false)} + resetScroll={resetScroll} + /> ) : null}
); diff --git a/packages/components/src/components/SquiggleViewer/ViewerContext.ts b/packages/components/src/components/SquiggleViewer/ViewerContext.ts index 58270a90..0769f3b1 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerContext.ts +++ b/packages/components/src/components/SquiggleViewer/ViewerContext.ts @@ -9,6 +9,7 @@ type ViewerContextShape = { getSettings(path: Path): LocalItemSettings; getMergedSettings(path: Path): MergedItemSettings; setSettings(path: Path, value: LocalItemSettings): void; + enableLocalSettings: boolean; // show local settings icon in the UI }; export const ViewerContext = React.createContext({ @@ -30,4 +31,5 @@ export const ViewerContext = React.createContext({ height: 150, }), setSettings() {}, + enableLocalSettings: false, }); diff --git a/packages/components/src/components/SquiggleViewer/index.tsx b/packages/components/src/components/SquiggleViewer/index.tsx index 0c6a20dd..6feb3cad 100644 --- a/packages/components/src/components/SquiggleViewer/index.tsx +++ b/packages/components/src/components/SquiggleViewer/index.tsx @@ -23,6 +23,7 @@ type Props = { chartSettings: FunctionChartSettings; /** Environment for further function executions */ environment: environment; + enableLocalSettings?: boolean; }; type Settings = { @@ -38,6 +39,7 @@ export const SquiggleViewer: React.FC = ({ distributionPlotSettings, chartSettings, environment, + enableLocalSettings = false, }) => { // can't store settings in the state because we don't want to rerender the entire tree on every change const settingsRef = useRef({}); @@ -85,6 +87,7 @@ export const SquiggleViewer: React.FC = ({ getSettings, setSettings, getMergedSettings, + enableLocalSettings, }} > {result.tag === "Ok" ? ( diff --git a/packages/components/src/components/ui/Modal.tsx b/packages/components/src/components/ui/Modal.tsx index d0565ddc..31f3e914 100644 --- a/packages/components/src/components/ui/Modal.tsx +++ b/packages/components/src/components/ui/Modal.tsx @@ -1,20 +1,35 @@ import { motion } from "framer-motion"; -import * as React from "react"; +import React, { useContext } from "react"; import * as ReactDOM from "react-dom"; import { XIcon } from "@heroicons/react/solid"; +import { SquiggleContainer } from "../SquiggleContainer"; +import clsx from "clsx"; +import { useWindowScroll, useWindowSize } from "react-use"; +import { rectToClientRect } from "@floating-ui/core"; -const Overlay: React.FC = () => ( - -); +type ModalContextShape = { + close: () => void; +}; +const ModalContext = React.createContext({ + close: () => undefined, +}); + +const Overlay: React.FC = () => { + const { close } = useContext(ModalContext); + return ( + + ); +}; const ModalHeader: React.FC<{ - close: () => void; children: React.ReactNode; -}> = ({ children, close }) => { +}> = ({ children }) => { + const { close } = useContext(ModalContext); return (
{children}
@@ -41,22 +56,92 @@ const ModalFooter: React.FC<{ children: React.ReactNode }> = ({ children }) => (
{children}
); -const ModalWindow: React.FC<{ children: React.ReactNode }> = ({ children }) => ( -
- {children} -
-); +const ModalWindow: React.FC<{ + children: React.ReactNode; + container?: HTMLElement; +}> = ({ children, container }) => { + // This component works in two possible modes: + // 1. container mode - the modal is rendered inside a container element + // 2. centered mode - the modal is rendered in the middle of the screen + // The mode is determined by the presence of the `container` prop and by whether the available space is large enough to fit the modal. -type ModalType = React.FC<{ children: React.ReactNode }> & { + // Necessary for container mode - need to reposition the modal on scroll and resize events. + useWindowSize(); + useWindowScroll(); + + let position: + | { + left: number; + top: number; + maxWidth: number; + maxHeight: number; + transform: string; + } + | undefined; + + const { clientWidth: screenWidth, clientHeight: screenHeight } = + document.documentElement; + if (container) { + const rect = container?.getBoundingClientRect(); + + // If available space in `visibleRect` is smaller than these, fallback to positioning in the middle of the screen. + const minWidth = 384; // matches the w-96 below + const minHeight = 300; + const offset = 8; + + const visibleRect = { + left: Math.max(rect.left, 0), + right: Math.min(rect.right, screenWidth), + top: Math.max(rect.top, 0), + bottom: Math.min(rect.bottom, screenHeight), + }; + const maxWidth = visibleRect.right - visibleRect.left - 2 * offset; + const maxHeight = visibleRect.bottom - visibleRect.top - 2 * offset; + + const center = { + left: visibleRect.left + (visibleRect.right - visibleRect.left) / 2, + top: visibleRect.top + (visibleRect.bottom - visibleRect.top) / 2, + }; + position = { + left: center.left, + top: center.top, + transform: "translate(-50%, -50%)", + maxWidth, + maxHeight, + }; + if (maxWidth < minWidth || maxHeight < minHeight) { + position = undefined; // modal is hard to fit in the container, fallback to positioning it in the middle of the screen + } + } + return ( +
+ {children} +
+ ); +}; + +type ModalType = React.FC<{ + children: React.ReactNode; + container?: HTMLElement; // if specified, modal will be positioned over the visible part of the container, if it's not too small + close: () => void; +}> & { Body: typeof ModalBody; Footer: typeof ModalFooter; Header: typeof ModalHeader; }; -export const Modal: ModalType = ({ children }) => { +export const Modal: ModalType = ({ children, container, close }) => { const [el] = React.useState(() => document.createElement("div")); React.useEffect(() => { @@ -68,15 +153,19 @@ export const Modal: ModalType = ({ children }) => { }, [el]); const modal = ( -
-
- - {children} -
-
+ + +
+
+ + {children} +
+
+
+
); - return ReactDOM.createPortal(modal, el); + return ReactDOM.createPortal(modal, container || el); }; Modal.Body = ModalBody; diff --git a/yarn.lock b/yarn.lock index cb466cc6..1ede77ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4825,7 +4825,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^18.0.9": +"@types/react@*", "@types/react@^18.0.1", "@types/react@^18.0.9": version "18.0.15" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe" integrity sha512-iz3BtLuIYH1uWdsv6wXYdhozhqj20oD4/Hk2DNXIn1kFsmp9x8d9QB6FnPhfkbhd2PgEONt9Q1x/ebkwjfFLow== @@ -15261,7 +15261,7 @@ react-vega@^7.6.0: prop-types "^15.8.1" vega-embed "^6.5.1" -react@^18.1.0: +react@^18.0.0, react@^18.1.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==