diff --git a/bun.lockb b/bun.lockb index 4dab9c4..6938ce0 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/client/package.json b/client/package.json index 759ea33..6a5a9cd 100644 --- a/client/package.json +++ b/client/package.json @@ -5,17 +5,17 @@ "dependencies": { "@dagrejs/dagre": "^1.1.4", "@devbookhq/splitter": "^1.4.2", - "@emotion/react": "^11.13.3", - "@emotion/styled": "^11.13.0", - "@hello-pangea/dnd": "^16.6.0", + "@emotion/react": "^11.13.5", + "@emotion/styled": "^11.13.5", + "@hello-pangea/dnd": "^17.0.0", "@monaco-editor/react": "^4.6.0", - "@mui/icons-material": "^5.16.7", - "@mui/lab": "^5.0.0-alpha.173", - "@mui/material": "^5.16.7", + "@mui/icons-material": "^6.1.8", + "@mui/lab": "^6.0.0-beta.16", + "@mui/material": "^6.1.8", "@react-sigma/core": "^4.0.3", "@sigma/edge-curve": "^3.0.0-beta.16", "capture-console-logs": "^2.0.1-rc.1", - "caught-object-report-json": "^7.2.0", + "caught-object-report-json": "^8.0.0", "color-interpolate": "^1.0.5", "colortranslator": "^4.1.0", "css-element-queries": "^1.2.3", @@ -25,7 +25,7 @@ "graphology": "^0.25.4", "hotscript": "^1.0.13", "internal-renderers": "workspace:*", - "jimp": "^0.22.12", + "jimp": "^1.6.0", "js-yaml": "^4.1.0", "json-beautify": "^1.1.1", "json-rpc-2.0": "^1.7.0", @@ -36,7 +36,7 @@ "memoizee": "^0.4.17", "mobile-device-detect": "^0.4.3", "moderndash": "^3.12.0", - "monaco-editor": "^0.43.0", + "monaco-editor": "^0.52.0", "nanoid": "^5.0.8", "nearest-pantone": "^1.0.1", "object-sizeof": "^2.6.5", @@ -46,12 +46,12 @@ "pluralize": "^8.0.0", "promise-tools": "^2.1.0", "protocol": "workspace:*", - "react": "^19.0.0-rc-fb9a90fa48-20240614", + "react": "^19.0.0-rc.1", "react-async-hook": "^4.0.0", "react-colorful": "^5.6.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-dom": "^19.0.0-rc-fb9a90fa48-20240614", + "react-dom": "^19.0.0-rc.1", "react-error-boundary": "^4.1.2", "react-file-drop": "^3.1.6", "react-transition-group": "^4.4.5", @@ -59,7 +59,7 @@ "react-virtualized-auto-sizer": "^1.0.24", "react-virtuoso": "^4.12.0", "renderer": "workspace:*", - "sigma": "^3.0.0-beta.37", + "sigma": "^3.0.0-beta.38", "socket.io-client": "^4.8.1", "string-template-parser": "^1.2.6", "sysend": "^1.17.5", @@ -88,30 +88,30 @@ }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^14.3.1", + "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", "@types/downloadjs": "^1.4.6", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.13", "@types/md5": "^2.3.5", "@types/memoizee": "^0.4.11", - "@types/pluralize": "^0.0.31", + "@types/pluralize": "^0.0.33", "@types/react": "^18.3.12", "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.3.1", "@types/react-virtualized-auto-sizer": "^1.0.4", "@types/url-parse": "^1.4.11", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", "@vitejs/plugin-react": "^4.3.3", "babel-plugin-react-compiler": "^19.0.0-beta-0dec889-20241115", - "electron": "^26.6.10", + "electron": "^33.2.0", "electron-packager": "^17.1.2", - "eslint": "^8.57.1", + "eslint": "^9.15.0", "eslint-plugin-only-warn": "^1.1.0", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-compiler": "^19.0.0-beta-0dec889-20241115", - "jsdom": "^22.1.0", + "jsdom": "^25.0.1", "vite": "^5.4.11", "vite-tsconfig-paths": "^5.1.3", "vitest": "^2.1.5", diff --git a/client/src/App.tsx b/client/src/App.tsx index 1fd327c..7b2979c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,119 +1,119 @@ -import { - CircularProgress, - CssBaseline, - Fade, - Stack, - ThemeProvider, - useTheme, -} from "@mui/material"; -import { Flex } from "components/generic/Flex"; -import { SnackbarProvider } from "components/generic/Snackbar"; -import { Inspector } from "components/inspector"; -import { Placeholder } from "components/inspector/Placeholder"; -import { TitleBar, TitleBarPlaceholder } from "components/title-bar/TitleBar"; -import { useTitleBar } from "hooks/useTitleBar"; -import { Image } from "pages/Image"; -import logo from "public/logo192.png"; -import { useMemo } from "react"; -import { BootstrapService } from "services/BootstrapService"; -import { ConnectionsService } from "services/ConnectionsService"; -import { FeaturesService } from "services/FeaturesService"; -import { LayerService } from "services/LayerService"; -import { LogCaptureService } from "services/LogCaptureService"; -import { RendererService } from "services/RendererService"; -import { SettingsService } from "services/SettingsService"; -import { minimal } from "services/SyncParticipant"; -import { SyncService, useSyncStatus } from "services/SyncService"; -import { SliceProvider as EnvironmentProvider } from "slices/SliceProvider"; -import { useSettings } from "slices/settings"; -import { makeTheme } from "theme"; - -const services = [ - SyncService, - ConnectionsService, - FeaturesService, - RendererService, - LayerService, - LogCaptureService, - SettingsService, - BootstrapService, -]; - -function App() { - const { palette } = useTheme(); - const color = palette.background.default; - const { loading } = useSyncStatus(); - - return ( - - {!loading ? ( - <> - - - - - - ) : minimal ? ( - - t.palette.background.paper, - width: "100vw", - height: "100dvh", - }} - > - - } /> - - - ) : ( - - - - - - - )} - - ); -} - -function ThemedApp() { - const [ - { - "appearance/theme": mode = "dark", - "appearance/accentColor": accent = "teal", - }, - ] = useSettings(); - const theme = useMemo(() => makeTheme(mode, accent), [mode, accent]); - return ( - - - - - - - - - - ); -} - -export default ThemedApp; +import { + CircularProgress, + CssBaseline, + Fade, + Stack, + ThemeProvider, + useTheme, +} from "@mui/material"; +import { Flex } from "components/generic/Flex"; +import { SnackbarProvider } from "components/generic/Snackbar"; +import { Inspector } from "components/inspector"; +import { Placeholder } from "components/inspector/Placeholder"; +import { TitleBar, TitleBarPlaceholder } from "components/title-bar/TitleBar"; +import { useTitleBar } from "hooks/useTitleBar"; +import { Image } from "pages/Image"; +import logo from "public/logo192.png"; +import { useMemo } from "react"; +import { BootstrapService } from "services/BootstrapService"; +import { ConnectionsService } from "services/ConnectionsService"; +import { FeaturesService } from "services/FeaturesService"; +import { LayerService } from "services/LayerService"; +import { LogCaptureService } from "services/LogCaptureService"; +import { RendererService } from "services/RendererService"; +import { SettingsService } from "services/SettingsService"; +import { minimal } from "services/SyncParticipant"; +import { SyncService, useSyncStatus } from "services/SyncService"; +import { SliceProvider as EnvironmentProvider } from "slices/SliceProvider"; +import { useSettings } from "slices/settings"; +import { makeTheme } from "theme"; + +const services = [ + SyncService, + ConnectionsService, + FeaturesService, + RendererService, + LayerService, + LogCaptureService, + SettingsService, + BootstrapService, +]; + +function App() { + const { palette } = useTheme(); + const color = palette.background.default; + const { loading } = useSyncStatus(); + + return ( + + {!loading ? ( + <> + + + + + + ) : minimal ? ( + + t.palette.background.paper, + width: "100vw", + height: "100dvh", + }} + > + + } /> + + + ) : ( + + + + + + + )} + + ); +} + +function ThemedApp() { + const [ + { + "appearance/theme": mode = "dark", + "appearance/accentColor": accent = "teal", + }, + ] = useSettings(); + const theme = useMemo(() => makeTheme(mode, accent), [mode, accent]); + return ( + + + + + + + + + + ); +} + +export default ThemedApp; diff --git a/client/src/client/SocketIOTransport.ts b/client/src/client/SocketIOTransport.ts index 0b1c1e9..b7a01ed 100644 --- a/client/src/client/SocketIOTransport.ts +++ b/client/src/client/SocketIOTransport.ts @@ -1,49 +1,49 @@ -import { JSONRPCClient, JSONRPCResponse as Response } from "json-rpc-2.0"; -import { NameMethodMap } from "protocol"; -import { Request, RequestOf, ResponseOf } from "protocol/Message"; -import { Socket, io } from "socket.io-client"; -import { EventEmitter } from "./EventEmitter"; -import { Transport, TransportEvents, TransportOptions } from "./Transport"; - -export class SocketIOTransport - extends EventEmitter - implements Transport -{ - client: JSONRPCClient; - socket: Socket; - - constructor(readonly options: TransportOptions) { - super(); - this.socket = io(options.url); - // Initialise client - this.client = new JSONRPCClient(async (request: Request) => { - const listener = (response: Response) => { - if (response.id === request.id) { - this.socket.off("response", listener); - this.client.receive(response); - } - }; - this.socket.emit("request", request); - this.socket.on("response", listener); - }); - // Initialise server - this.socket.on("request", ({ method, params }: Request) => { - this.emit(method, params); - }); - } - - async connect() { - this.socket.connect(); - } - - async disconnect() { - this.socket.disconnect(); - } - - async call( - name: T, - params?: RequestOf["params"] - ): Promise["result"]> { - return await this.client.request(name, params); - } -} +import { JSONRPCClient, JSONRPCResponse as Response } from "json-rpc-2.0"; +import { NameMethodMap } from "protocol"; +import { Request, RequestOf, ResponseOf } from "protocol/Message"; +import { Socket, io } from "socket.io-client"; +import { EventEmitter } from "./EventEmitter"; +import { Transport, TransportEvents, TransportOptions } from "./Transport"; + +export class SocketIOTransport + extends EventEmitter + implements Transport +{ + client: JSONRPCClient; + socket: Socket; + + constructor(readonly options: TransportOptions) { + super(); + this.socket = io(options.url); + // Initialise client + this.client = new JSONRPCClient(async (request: Request) => { + const listener = (response: Response) => { + if (response.id === request.id) { + this.socket.off("response", listener); + this.client.receive(response); + } + }; + this.socket.emit("request", request); + this.socket.on("response", listener); + }); + // Initialise server + this.socket.on("request", ({ method, params }: Request) => { + this.emit(method, params); + }); + } + + async connect() { + this.socket.connect(); + } + + async disconnect() { + this.socket.disconnect(); + } + + async call( + name: T, + params?: RequestOf["params"] + ): Promise["result"]> { + return await this.client.request(name, params); + } +} diff --git a/client/src/components/app-bar/FeaturePicker.tsx b/client/src/components/app-bar/FeaturePicker.tsx index fdf8bad..349d3d0 100644 --- a/client/src/components/app-bar/FeaturePicker.tsx +++ b/client/src/components/app-bar/FeaturePicker.tsx @@ -1,90 +1,90 @@ -import { ButtonProps, Typography as Type, useTheme } from "@mui/material"; -import { Flex } from "components/generic/Flex"; -import { Select } from "components/generic/Select"; -import { Space } from "components/generic/Space"; -import { filter, find, map, startCase, truncate } from "lodash"; -import { FeatureDescriptor } from "protocol/FeatureQuery"; -import { ReactElement, ReactNode, cloneElement } from "react"; -import { AccentColor, getShade, usePaper } from "theme"; -import { FeaturePickerButton } from "./FeaturePickerButton"; - -export type Props = { - showTooltip?: boolean; - label?: string; - value?: string; - onChange?: (key: string) => void; - items?: (FeatureDescriptor & { icon?: ReactNode; color?: AccentColor })[]; - icon?: ReactNode; - arrow?: boolean; - disabled?: boolean; - ButtonProps?: ButtonProps; - itemOrientation?: "vertical" | "horizontal"; - ellipsis?: number; - paper?: boolean; -}; - -export function FeaturePicker({ - label, - value, - onChange, - items, - icon, - arrow, - disabled, - ButtonProps, - showTooltip, - itemOrientation = "horizontal", - ellipsis = Infinity, - paper: _paper, -}: Props) { - const paper = usePaper(); - const { palette } = useTheme(); - - const getIcon = (icon: ReactNode, color?: AccentColor) => - icon && - cloneElement(icon as ReactElement, { - sx: { - color: color ? getShade(color, palette.mode) : "primary.main", - }, - }); - - const selected = find(items, { id: value }); - return ( - ( + !item.hidden)?.length || disabled} + icon={selected?.icon ? getIcon(selected.icon, selected.color) : icon} + arrow={arrow} + > + {truncate(selected?.name ?? label, { + length: ellipsis, + })} + + )} + items={map(items, ({ id, name, description, hidden, icon, color }) => ({ + value: id, + label: ( + + + {name} + + + + {description} + + + ), + icon: getIcon(icon, color), + disabled: hidden, + }))} + value={selected?.id} + onChange={onChange} + /> + ); +} diff --git a/client/src/components/app-bar/FeaturePickerMulti.tsx b/client/src/components/app-bar/FeaturePickerMulti.tsx index f8b795c..5e005ca 100644 --- a/client/src/components/app-bar/FeaturePickerMulti.tsx +++ b/client/src/components/app-bar/FeaturePickerMulti.tsx @@ -1,69 +1,69 @@ -import { Typography as Type } from "@mui/material"; -import { SelectMulti } from "components/generic/SelectMulti"; -import { Space } from "components/generic/Space"; -import { filter, head, map, startCase, truncate } from "lodash"; -import { FeatureDescriptor } from "protocol/FeatureQuery"; -import { ReactNode } from "react"; -import { FeaturePickerButton } from "./FeaturePickerButton"; - -type Props = { - label?: string; - value?: Record; - onChange?: (key: Record) => void; - items?: FeatureDescriptor[]; - icon?: ReactNode; - showArrow?: boolean; - defaultChecked?: boolean; - ellipsis?: number; -}; - -export function FeaturePickerMulti({ - label, - value, - onChange, - items, - icon, - showArrow, - defaultChecked, - ellipsis = Infinity, -}: Props) { - const selected = filter(items, ({ id }) => !!(value?.[id] ?? defaultChecked)); - - const buttonLabel = selected.length - ? selected.length === 1 - ? head(selected)?.name - : `${selected.length} Selected` - : label; - - return ( - ( - - {truncate(buttonLabel, { length: ellipsis })} - - )} - items={map(items, ({ id, name, description, hidden }) => ({ - value: id, - label: ( - <> - {name} - - - {description} - - - ), - disabled: hidden, - }))} - value={value} - onChange={onChange} - /> - ); -} +import { Typography as Type } from "@mui/material"; +import { SelectMulti } from "components/generic/SelectMulti"; +import { Space } from "components/generic/Space"; +import { filter, head, map, startCase, truncate } from "lodash"; +import { FeatureDescriptor } from "protocol/FeatureQuery"; +import { ReactNode } from "react"; +import { FeaturePickerButton } from "./FeaturePickerButton"; + +type Props = { + label?: string; + value?: Record; + onChange?: (key: Record) => void; + items?: FeatureDescriptor[]; + icon?: ReactNode; + showArrow?: boolean; + defaultChecked?: boolean; + ellipsis?: number; +}; + +export function FeaturePickerMulti({ + label, + value, + onChange, + items, + icon, + showArrow, + defaultChecked, + ellipsis = Infinity, +}: Props) { + const selected = filter(items, ({ id }) => !!(value?.[id] ?? defaultChecked)); + + const buttonLabel = selected.length + ? selected.length === 1 + ? head(selected)?.name + : `${selected.length} Selected` + : label; + + return ( + ( + + {truncate(buttonLabel, { length: ellipsis })} + + )} + items={map(items, ({ id, name, description, hidden }) => ({ + value: id, + label: ( + <> + {name} + + + {description} + + + ), + disabled: hidden, + }))} + value={value} + onChange={onChange} + /> + ); +} diff --git a/client/src/components/app-bar/Input.tsx b/client/src/components/app-bar/Input.tsx index e9a7f7f..647a319 100644 --- a/client/src/components/app-bar/Input.tsx +++ b/client/src/components/app-bar/Input.tsx @@ -1,153 +1,153 @@ -import { FileOpenOutlined } from "@mui/icons-material"; -import { useSnackbar } from "components/generic/Snackbar"; -import { find, get, startCase } from "lodash"; -import { Map, UploadedTrace } from "slices/UIState"; -import { LARGE_FILE_B, formatByte, useBusyState } from "slices/busy"; -import { useConnections } from "slices/connections"; -import { useFeatures } from "slices/features"; -import { useLoading, useLoadingState } from "slices/loading"; -import { EditorProps } from "../Editor"; -import { FeaturePicker } from "./FeaturePicker"; -import { custom, uploadMap, uploadTrace } from "./upload"; - -function name(s: string) { - return s.split(".").shift(); -} - -export const mapDefaults = { start: undefined, end: undefined }; - -export function MapPicker({ onChange, value }: EditorProps) { - const notify = useSnackbar(); - const usingLoadingState = useLoadingState("map"); - const [{ features: featuresLoading, connections: connectionsLoading }] = - useLoading(); - const usingBusyState = useBusyState("map"); - const [connections] = useConnections(); - const [{ maps, formats }] = useFeatures(); - return ( - } - label="Choose Map" - value={value?.id} - items={[ - custom(value, "map"), - ...maps.map((c) => ({ - ...c, - description: find(connections, { url: c.source })?.name, - })), - ]} - onChange={async (v) => { - switch (v) { - case custom().id: - try { - const f = await uploadMap(formats); - if (f) { - usingLoadingState(async () => { - notify("Opening map..."); - const output = - f.file.size > LARGE_FILE_B - ? await usingBusyState( - f.read, - `Opening map (${formatByte(f.file.size)})` - ) - : await f.read(); - if (output) { - onChange?.(output); - } - }); - } - } catch (e) { - notify(`${e}`); - } - break; - default: - onChange?.(find(maps, { id: v })!); - break; - } - }} - /> - ); -} - -export function TracePicker({ - onChange, - value, -}: EditorProps) { - const notify = useSnackbar(); - const usingLoadingState = useLoadingState("specimen"); - const usingBusyState = useBusyState("specimen"); - const [connections] = useConnections(); - const [{ features: featuresLoading, connections: connectionsLoading }] = - useLoading(); - const [{ traces }] = useFeatures(); - return ( - } - label="Choose Trace" - value={value?.id} - items={[ - custom(value, "trace"), - ...traces.map((c) => ({ - ...c, - description: find(connections, { url: c.source })?.name, - })), - ]} - onChange={async (v) => { - switch (v) { - case custom().id: - { - try { - const f = await uploadTrace(); - if (f) - usingLoadingState(async () => { - notify("Opening trace..."); - try { - const output = - f.file.size > LARGE_FILE_B - ? await usingBusyState( - f.read, - `Opening trace (${formatByte(f.file.size)})` - ) - : await f.read(); - if (output) { - onChange?.(output); - } - } catch (e) { - console.error(e); - notify(`Error opening, ${get(e, "message")}`); - onChange?.({ - id: custom().id, - error: get(e, "message"), - name: startCase(name(f.file.name)), - }); - } - }); - } catch (e) { - console.error(e); - notify(`Error opening, ${get(e, "message")}`); - onChange?.({ - id: custom().id, - error: get(e, "message"), - name: "File", - }); - } - } - break; - default: - onChange?.(find(traces, { id: v })!); - break; - } - }} - /> - ); -} +import { FileOpenOutlined } from "@mui/icons-material"; +import { useSnackbar } from "components/generic/Snackbar"; +import { find, get, startCase } from "lodash"; +import { Map, UploadedTrace } from "slices/UIState"; +import { LARGE_FILE_B, formatByte, useBusyState } from "slices/busy"; +import { useConnections } from "slices/connections"; +import { useFeatures } from "slices/features"; +import { useLoading, useLoadingState } from "slices/loading"; +import { EditorProps } from "../Editor"; +import { FeaturePicker } from "./FeaturePicker"; +import { custom, uploadMap, uploadTrace } from "./upload"; + +function name(s: string) { + return s.split(".").shift(); +} + +export const mapDefaults = { start: undefined, end: undefined }; + +export function MapPicker({ onChange, value }: EditorProps) { + const notify = useSnackbar(); + const usingLoadingState = useLoadingState("map"); + const [{ features: featuresLoading, connections: connectionsLoading }] = + useLoading(); + const usingBusyState = useBusyState("map"); + const [connections] = useConnections(); + const [{ maps, formats }] = useFeatures(); + return ( + } + label="Choose Map" + value={value?.id} + items={[ + custom(value, "map"), + ...maps.map((c) => ({ + ...c, + description: find(connections, { url: c.source })?.name, + })), + ]} + onChange={async (v) => { + switch (v) { + case custom().id: + try { + const f = await uploadMap(formats); + if (f) { + usingLoadingState(async () => { + notify("Opening map..."); + const output = + f.file.size > LARGE_FILE_B + ? await usingBusyState( + f.read, + `Opening map (${formatByte(f.file.size)})` + ) + : await f.read(); + if (output) { + onChange?.(output); + } + }); + } + } catch (e) { + notify(`${e}`); + } + break; + default: + onChange?.(find(maps, { id: v })!); + break; + } + }} + /> + ); +} + +export function TracePicker({ + onChange, + value, +}: EditorProps) { + const notify = useSnackbar(); + const usingLoadingState = useLoadingState("specimen"); + const usingBusyState = useBusyState("specimen"); + const [connections] = useConnections(); + const [{ features: featuresLoading, connections: connectionsLoading }] = + useLoading(); + const [{ traces }] = useFeatures(); + return ( + } + label="Choose Trace" + value={value?.id} + items={[ + custom(value, "trace"), + ...traces.map((c) => ({ + ...c, + description: find(connections, { url: c.source })?.name, + })), + ]} + onChange={async (v) => { + switch (v) { + case custom().id: + { + try { + const f = await uploadTrace(); + if (f) + usingLoadingState(async () => { + notify("Opening trace..."); + try { + const output = + f.file.size > LARGE_FILE_B + ? await usingBusyState( + f.read, + `Opening trace (${formatByte(f.file.size)})` + ) + : await f.read(); + if (output) { + onChange?.(output); + } + } catch (e) { + console.error(e); + notify(`Error opening, ${get(e, "message")}`); + onChange?.({ + id: custom().id, + error: get(e, "message"), + name: startCase(name(f.file.name)), + }); + } + }); + } catch (e) { + console.error(e); + notify(`Error opening, ${get(e, "message")}`); + onChange?.({ + id: custom().id, + error: get(e, "message"), + name: "File", + }); + } + } + break; + default: + onChange?.(find(traces, { id: v })!); + break; + } + }} + /> + ); +} diff --git a/client/src/components/app-bar/Playback.tsx b/client/src/components/app-bar/Playback.tsx index e36db7e..85b3fe4 100644 --- a/client/src/components/app-bar/Playback.tsx +++ b/client/src/components/app-bar/Playback.tsx @@ -1,242 +1,242 @@ -import { - ArrowForwardOutlined, - NavigateNextOutlined, - ChevronRightOutlined as NextIcon, - PauseOutlined as PauseIcon, - PlayArrowOutlined as PlayIcon, - ChevronLeftOutlined as PreviousIcon, - SkipNextOutlined as SkipIcon, - SkipPreviousOutlined as StopIcon, -} from "@mui/icons-material"; -import { - Button, - Collapse, - Divider, - InputAdornment, - Popover, - Stack, - TextField, - Typography, -} from "@mui/material"; -import { EditorSetterProps } from "components/Editor"; -import { IconButtonWithTooltip as IconButton } from "components/generic/IconButtonWithTooltip"; -import { usePlaybackState } from "hooks/usePlaybackState"; -import { ceil, noop } from "lodash"; -import PopupState, { bindPopover, bindTrigger } from "material-ui-popup-state"; -import { useEffect, useState } from "react"; -import { Layer } from "slices/layers"; -import { useSettings } from "slices/settings"; -import { usePaper } from "theme"; - -const divider = ; - -export type PlaybackLayerData = { - step?: number; - playback?: "playing" | "paused"; - playbackTo?: number; -}; - -const FRAME_TIME_MS = 1000 / 60; - -export function PlaybackService({ - children, - value, -}: EditorSetterProps>) { - const { step, end, playing, pause, stepWithBreakpointCheck } = - usePlaybackState(value?.key); - - const [{ "playback/playbackRate": playbackRate = 1 }] = useSettings(); - - useEffect(() => { - if (playing) { - let cancelled = false; - let cancel = noop; - let prev = Date.now(); - const f = () => { - if (!cancelled) { - const now = Date.now(); - const elapsed = ceil((playbackRate * (now - prev)) / FRAME_TIME_MS); - if (step < end) { - cancel = stepWithBreakpointCheck(elapsed); - prev = now; - } else { - cancelled = true; - pause(); - } - requestAnimationFrame(f); - } - }; - requestAnimationFrame(f); - return () => { - cancel(); - cancelled = true; - }; - } - }, [stepWithBreakpointCheck, playing, end, step, pause, playbackRate]); - - return <>{children}; -} - -const centered = { horizontal: "center", vertical: "center" } as const; -export function Playback({ layer }: { layer?: Layer }) { - const paper = usePaper(); - const { - playing, - canPause, - canPlay, - canStepBackward, - canStepForward, - canStop, - pause, - play, - stepBackward, - stepForward, - findBreakpoint, - step, - stepTo, - } = usePlaybackState(layer?.key); - const [stepInput, setStepInput] = useState(""); - const parsedStepInput = parseInt(stepInput); - const parsedStepInputValid = !isNaN(parsedStepInput); - return ( - <> - } - onClick={() => { - stepTo(findBreakpoint(-1)); - }} - disabled={!canStop || !canStepBackward} - /> - } - onClick={stepBackward} - disabled={!canStepBackward} - /> - , - onClick: () => pause(), - disabled: !canPause, - } - : { - label: "play", - icon: , - onClick: () => play(), - disabled: !canPlay, - color: "primary", - })} - /> - } - onClick={stepForward} - disabled={!canStepForward} - /> - } - onClick={() => { - stepTo(findBreakpoint()); - }} - disabled={!canStepForward} - /> - {divider} - - {(state) => ( - <> - - - setStepInput(e.target.value)} - defaultValue={step} - placeholder="0" - InputProps={{ - sx: { fontSize: "0.875rem" }, - startAdornment: ( - Step - ), - endAdornment: ( - - } - label="Go" - size="small" - color="inherit" - disabled={ - !parsedStepInputValid || parsedStepInput === step - } - onClick={() => { - stepTo(parsedStepInput); - state.close(); - }} - /> - - ), - }} - sx={{ width: 180, border: "none" }} - /> - - - )} - - - ); -} -export function MinimisedPlaybackControls({ - layer, -}: { - layer?: Layer; -}) { - return ( - - {(state) => ( - <> - - - - {divider} - - - t.palette.text.secondary, - transform: state.isOpen ? "rotate(180deg)" : undefined, - transition: (t) => t.transitions.create("transform"), - }} - icon={} - /> - - )} - - ); -} +import { + ArrowForwardOutlined, + NavigateNextOutlined, + ChevronRightOutlined as NextIcon, + PauseOutlined as PauseIcon, + PlayArrowOutlined as PlayIcon, + ChevronLeftOutlined as PreviousIcon, + SkipNextOutlined as SkipIcon, + SkipPreviousOutlined as StopIcon, +} from "@mui/icons-material"; +import { + Button, + Collapse, + Divider, + InputAdornment, + Popover, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { EditorSetterProps } from "components/Editor"; +import { IconButtonWithTooltip as IconButton } from "components/generic/IconButtonWithTooltip"; +import { usePlaybackState } from "hooks/usePlaybackState"; +import { ceil, noop } from "lodash"; +import PopupState, { bindPopover, bindTrigger } from "material-ui-popup-state"; +import { useEffect, useState } from "react"; +import { Layer } from "slices/layers"; +import { useSettings } from "slices/settings"; +import { usePaper } from "theme"; + +const divider = ; + +export type PlaybackLayerData = { + step?: number; + playback?: "playing" | "paused"; + playbackTo?: number; +}; + +const FRAME_TIME_MS = 1000 / 60; + +export function PlaybackService({ + children, + value, +}: EditorSetterProps>) { + const { step, end, playing, pause, stepWithBreakpointCheck } = + usePlaybackState(value?.key); + + const [{ "playback/playbackRate": playbackRate = 1 }] = useSettings(); + + useEffect(() => { + if (playing) { + let cancelled = false; + let cancel = noop; + let prev = Date.now(); + const f = () => { + if (!cancelled) { + const now = Date.now(); + const elapsed = ceil((playbackRate * (now - prev)) / FRAME_TIME_MS); + if (step < end) { + cancel = stepWithBreakpointCheck(elapsed); + prev = now; + } else { + cancelled = true; + pause(); + } + requestAnimationFrame(f); + } + }; + requestAnimationFrame(f); + return () => { + cancel(); + cancelled = true; + }; + } + }, [stepWithBreakpointCheck, playing, end, step, pause, playbackRate]); + + return <>{children}; +} + +const centered = { horizontal: "center", vertical: "center" } as const; +export function Playback({ layer }: { layer?: Layer }) { + const paper = usePaper(); + const { + playing, + canPause, + canPlay, + canStepBackward, + canStepForward, + canStop, + pause, + play, + stepBackward, + stepForward, + findBreakpoint, + step, + stepTo, + } = usePlaybackState(layer?.key); + const [stepInput, setStepInput] = useState(""); + const parsedStepInput = parseInt(stepInput); + const parsedStepInputValid = !isNaN(parsedStepInput); + return (<> + } + onClick={() => { + stepTo(findBreakpoint(-1)); + }} + disabled={!canStop || !canStepBackward} + /> + } + onClick={stepBackward} + disabled={!canStepBackward} + /> + , + onClick: () => pause(), + disabled: !canPause, + } + : { + label: "play", + icon: , + onClick: () => play(), + disabled: !canPlay, + color: "primary", + })} + /> + } + onClick={stepForward} + disabled={!canStepForward} + /> + } + onClick={() => { + stepTo(findBreakpoint()); + }} + disabled={!canStepForward} + /> + {divider} + + {(state) => ( + <> + + + setStepInput(e.target.value)} + defaultValue={step} + placeholder="0" + sx={{ width: 180, border: "none" }} + slotProps={{ + input: { + sx: { fontSize: "0.875rem" }, + startAdornment: ( + Step + ), + endAdornment: ( + + } + label="Go" + size="small" + color="inherit" + disabled={ + !parsedStepInputValid || parsedStepInput === step + } + onClick={() => { + stepTo(parsedStepInput); + state.close(); + }} + /> + + ), + } + }} + /> + + + )} + + ); +} +export function MinimisedPlaybackControls({ + layer, +}: { + layer?: Layer; +}) { + return ( + + {(state) => ( + <> + + + + {divider} + + + t.palette.text.secondary, + transform: state.isOpen ? "rotate(180deg)" : undefined, + transition: (t) => t.transitions.create("transform"), + }} + icon={} + /> + + )} + + ); +} diff --git a/client/src/components/breakpoint-editor/BreakpointEditor.tsx b/client/src/components/breakpoint-editor/BreakpointEditor.tsx index 1c68281..8f1c43a 100644 --- a/client/src/components/breakpoint-editor/BreakpointEditor.tsx +++ b/client/src/components/breakpoint-editor/BreakpointEditor.tsx @@ -1,91 +1,92 @@ -import { Divider, TextField, Typography as Type } from "@mui/material"; -import { find, last, map, startCase } from "lodash"; -import { comparators } from "./comparators"; -import { eventTypes } from "./eventTypes"; -import { Flex } from "components/generic/Flex"; -import { SelectField as Select } from "components/generic/Select"; -import { Space } from "components/generic/Space"; -import { Switch } from "components/generic/Switch"; -import { Breakpoint } from "hooks/useBreakpoints"; - -type BreakpointEditorProps = { - value: Breakpoint; - onValueChange?: (v: Breakpoint) => void; - properties?: string[]; -}; - -export function BreakpointEditor({ - value, - onValueChange: onChange, - properties, -}: BreakpointEditorProps) { - function handleChange(next: Partial) { - onChange?.({ ...value, ...next }); - } - return ( - - ({ - value: c, - label: ( - <> - {last(c.split("."))} - - {`$.${c}`} - - ), - }))} - onChange={(v) => handleChange({ property: v })} - value={value.property} - /> - - ({ value: c, label: startCase(c) }))} + onChange={(v) => handleChange({ type: v === "any" ? undefined : v })} + value={value.type ?? "any"} + /> + + ({ + value: c.key, + label: startCase(c.key), + }))} + value={value.condition?.key ?? comparators?.[0]?.key} + onChange={(v) => + handleChange({ condition: find(comparators, { key: v }) }) + } + /> + + handleChange({ reference: +v.target.value })} + type="number" + disabled={!value.condition?.needsReference} + slotProps={{ + htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } + }} + /> + + handleChange({ active: v })} + sx={{ mr: -4 }} + /> + ) + ); +} diff --git a/client/src/components/breakpoint-editor/BreakpointListEditor.tsx b/client/src/components/breakpoint-editor/BreakpointListEditor.tsx index 54ce746..b5013b4 100644 --- a/client/src/components/breakpoint-editor/BreakpointListEditor.tsx +++ b/client/src/components/breakpoint-editor/BreakpointListEditor.tsx @@ -1,75 +1,75 @@ -import { Box } from "@mui/material"; -import { ListEditor } from "components/generic/ListEditor"; -import { Breakpoint, DebugLayerData } from "hooks/useBreakpoints"; -import { chain as _, keys, set } from "lodash"; -import { produce } from "produce"; -import { useMemo } from "react"; -import { useLayer } from "slices/layers"; -import { BreakpointEditor } from "./BreakpointEditor"; -import { comparators } from "./comparators"; -import { Scroll } from "components/generic/Scrollbars"; - -type BreakpointListEditorProps = { - breakpoints?: Breakpoint[]; - onValueChange?: (v: Breakpoint[]) => void; - layer?: string; -}; - -export function BreakpointListEditor({ - layer: key, -}: BreakpointListEditorProps) { - const { layer, setLayer } = useLayer(key); - const { breakpoints } = layer?.source ?? {}; - - function handleBreakpointsChange(updatedBreakpoints: Breakpoint[]) { - if (layer) { - setLayer( - produce(layer, (layer) => - set(layer, "source.breakpoints", updatedBreakpoints) - ) - ); - } - } - - const properties = useMemo( - () => - _(layer?.source?.trace?.content?.events) - .flatMap(keys) - .uniq() - .filter((p) => p !== "type") - .value(), - [layer?.source?.trace?.content?.events] - ); - - return ( - - - - - sortable - button={false} - icon={null} - value={breakpoints} - deletable - editable={false} - editor={(v) => ( - - )} //v = a breakpoint - create={() => ({ - active: true, - property: properties?.[0], - condition: comparators?.[0], - type: undefined, - reference: 0, - })} - onChange={(updatedBreakpoints) => - handleBreakpointsChange(updatedBreakpoints) - } - addItemLabel="Breakpoint" - placeholder="Get started by adding a breakpoint." - /> - - - - ); -} +import { Box } from "@mui/material"; +import { ListEditor } from "components/generic/ListEditor"; +import { Breakpoint, DebugLayerData } from "hooks/useBreakpoints"; +import { chain as _, keys, set } from "lodash"; +import { produce } from "produce"; +import { useMemo } from "react"; +import { useLayer } from "slices/layers"; +import { BreakpointEditor } from "./BreakpointEditor"; +import { comparators } from "./comparators"; +import { Scroll } from "components/generic/Scrollbars"; + +type BreakpointListEditorProps = { + breakpoints?: Breakpoint[]; + onValueChange?: (v: Breakpoint[]) => void; + layer?: string; +}; + +export function BreakpointListEditor({ + layer: key, +}: BreakpointListEditorProps) { + const { layer, setLayer } = useLayer(key); + const { breakpoints } = layer?.source ?? {}; + + function handleBreakpointsChange(updatedBreakpoints: Breakpoint[]) { + if (layer) { + setLayer( + produce(layer, (layer) => + set(layer, "source.breakpoints", updatedBreakpoints) + ) + ); + } + } + + const properties = useMemo( + () => + _(layer?.source?.trace?.content?.events) + .flatMap(keys) + .uniq() + .filter((p) => p !== "type") + .value(), + [layer?.source?.trace?.content?.events] + ); + + return ( + + + + + sortable + button={false} + icon={null} + value={breakpoints} + deletable + editable={false} + editor={(v) => ( + + )} //v = a breakpoint + create={() => ({ + active: true, + property: properties?.[0], + condition: comparators?.[0], + type: undefined, + reference: 0, + })} + onChange={(updatedBreakpoints) => + handleBreakpointsChange(updatedBreakpoints) + } + addItemLabel="Breakpoint" + placeholder="Get started by adding a breakpoint." + /> + + + + ); +} diff --git a/client/src/components/breakpoint-editor/comparators.tsx b/client/src/components/breakpoint-editor/comparators.tsx index 66040d9..8cd87b0 100644 --- a/client/src/components/breakpoint-editor/comparators.tsx +++ b/client/src/components/breakpoint-editor/comparators.tsx @@ -1,33 +1,33 @@ -import { Comparator } from "hooks/useBreakpoints"; -import { findLast, get } from "lodash"; - -export const comparators: Comparator[] = [ - { - key: "equal", - apply: ({ value, reference }) => value === reference, - needsReference: true, - }, - { - key: "less-than", - apply: ({ value, reference }) => value < reference, - needsReference: true, - }, - { - key: "greater-than", - apply: ({ value, reference }) => value > reference, - needsReference: true, - }, - { - //find a unique next value (typically for f or g value) - key: "changed", - apply: ({ value, property, step, node }) => { - if (node.parent) { - const previous = findLast(node.parent.events, (e) => e.step < step); - if (previous) { - return get(previous.data, property) !== value; - } - } - return false; - }, - }, -]; +import { Comparator } from "hooks/useBreakpoints"; +import { findLast, get } from "lodash"; + +export const comparators: Comparator[] = [ + { + key: "equal", + apply: ({ value, reference }) => value === reference, + needsReference: true, + }, + { + key: "less-than", + apply: ({ value, reference }) => value < reference, + needsReference: true, + }, + { + key: "greater-than", + apply: ({ value, reference }) => value > reference, + needsReference: true, + }, + { + //find a unique next value (typically for f or g value) + key: "changed", + apply: ({ value, property, step, node }) => { + if (node.parent) { + const previous = findLast(node.parent.events, (e) => e.step < step); + if (previous) { + return get(previous.data, property) !== value; + } + } + return false; + }, + }, +]; diff --git a/client/src/components/breakpoint-editor/eventTypes.tsx b/client/src/components/breakpoint-editor/eventTypes.tsx index 08be79d..3bbc60c 100644 --- a/client/src/components/breakpoint-editor/eventTypes.tsx +++ b/client/src/components/breakpoint-editor/eventTypes.tsx @@ -1,10 +1,10 @@ import { TraceEventType } from "protocol/Trace"; -export const eventTypes: (TraceEventType | "any")[] = [ - "any", - "source", - "destination", - "expanding", - "generating", - "closing", +export const eventTypes: (TraceEventType | "any")[] = [ + "any", + "source", + "destination", + "expanding", + "generating", + "closing", ]; \ No newline at end of file diff --git a/client/src/components/breakpoint-editor/intrinsicProperties.tsx b/client/src/components/breakpoint-editor/intrinsicProperties.tsx index b772f1f..3f6a1a0 100644 --- a/client/src/components/breakpoint-editor/intrinsicProperties.tsx +++ b/client/src/components/breakpoint-editor/intrinsicProperties.tsx @@ -1 +1 @@ -export const intrinsicProperties = ["f", "g"]; +export const intrinsicProperties = ["f", "g"]; diff --git a/client/src/components/breakpoint-editor/propertyPaths.tsx b/client/src/components/breakpoint-editor/propertyPaths.tsx index 00f14c6..fa65c39 100644 --- a/client/src/components/breakpoint-editor/propertyPaths.tsx +++ b/client/src/components/breakpoint-editor/propertyPaths.tsx @@ -1 +1 @@ -export const propertyPaths = ["variables"]; +export const propertyPaths = ["variables"]; diff --git a/client/src/components/generic/Flex.tsx b/client/src/components/generic/Flex.tsx index 260bf28..87b1396 100644 --- a/client/src/components/generic/Flex.tsx +++ b/client/src/components/generic/Flex.tsx @@ -1,18 +1,18 @@ -import { Box, BoxProps } from "@mui/material"; -import { forwardRef } from "react"; - -export type FlexProps = { - vertical?: boolean; -} & BoxProps; - -export const Flex = forwardRef(({ vertical, ...props }: FlexProps, ref) => ( - -)); +import { Box, BoxProps } from "@mui/material"; +import { forwardRef } from "react"; + +export type FlexProps = { + vertical?: boolean; +} & BoxProps; + +export const Flex = forwardRef(({ vertical, ...props }: FlexProps, ref) => ( + +)); diff --git a/client/src/components/generic/IconButtonWithTooltip.tsx b/client/src/components/generic/IconButtonWithTooltip.tsx index afae694..db1b0b7 100644 --- a/client/src/components/generic/IconButtonWithTooltip.tsx +++ b/client/src/components/generic/IconButtonWithTooltip.tsx @@ -1,31 +1,31 @@ -import { - IconButton, - IconButtonProps, - Tooltip, - TooltipProps, -} from "@mui/material"; -import { startCase } from "lodash"; -import { ReactNode } from "react"; - -type IconButtonWithTooltipProps = { - label: string; - icon: ReactNode; - slotProps?: { - tooltip?: Partial; - }; -} & IconButtonProps; - -export function IconButtonWithTooltip({ - label, - icon, - slotProps, - ...rest -}: IconButtonWithTooltipProps) { - return ( - - - {icon} - - - ); -} +import { + IconButton, + IconButtonProps, + Tooltip, + TooltipProps, +} from "@mui/material"; +import { startCase } from "lodash"; +import { ReactNode } from "react"; + +type IconButtonWithTooltipProps = { + label: string; + icon: ReactNode; + slotProps?: { + tooltip?: Partial; + }; +} & IconButtonProps; + +export function IconButtonWithTooltip({ + label, + icon, + slotProps, + ...rest +}: IconButtonWithTooltipProps) { + return ( + + + {icon} + + + ); +} diff --git a/client/src/components/generic/LazyList.tsx b/client/src/components/generic/LazyList.tsx index 1bc3746..dc61e1b 100644 --- a/client/src/components/generic/LazyList.tsx +++ b/client/src/components/generic/LazyList.tsx @@ -1,139 +1,139 @@ -import { Box, BoxProps, useTheme } from "@mui/material"; -import { - ComponentProps, - ReactElement, - ReactNode, - Ref, - forwardRef, - useCallback, - useEffect, - useRef, -} from "react"; -import { useCss } from "react-use"; - -import { useOverlayScrollbars } from "overlayscrollbars-react"; -import { - VirtuosoHandle as Handle, - Virtuoso as List, - VirtuosoProps as ListProps, - VirtuosoHandle, -} from "react-virtuoso"; - -// const Scroller = forwardRef( -// ({ style, ...props }, ref) => { -// const { spacing } = useTheme(); -// const cls = useCss({ -// "> .os-scrollbar-vertical > .os-scrollbar-track > .os-scrollbar-handle": { -// "min-height": spacing(12), -// }, -// }); -// return ( -// -// ); -// } -// ); - -const Scroller = forwardRef>( - ({ style, children, ...rest }, ref) => { - const containerRef = useRef(null); - const { palette, spacing } = useTheme(); - const cls = useCss({ - "--os-padding-perpendicular": "2px", - ".os-scrollbar": { visibility: "visible", opacity: 1 }, - ".os-scrollbar-vertical > .os-scrollbar-track > .os-scrollbar-handle": { - "min-height": spacing(12), - }, - "div.os-scrollbar-vertical > div.os-scrollbar-track": { - height: `calc(100% - ${spacing(6)})`, - marginTop: spacing(6), - }, - "div > div.os-scrollbar-track": { - "--os-handle-perpendicular-size": "2px", - "--os-handle-perpendicular-size-hover": "6px", - "--os-handle-perpendicular-size-active": "6px", - "> div.os-scrollbar-handle": { - borderRadius: 0, - opacity: 0.5, - "&:hover": { opacity: 0.8 }, - }, - }, - }); - const [initialize] = useOverlayScrollbars({ - options: { - overflow: { x: "hidden", y: "scroll" }, - scrollbars: { - autoHide: "move", - theme: palette.mode === "dark" ? "os-theme-light" : "os-theme-dark", - }, - }, - }); - - useEffect(() => { - if (typeof ref !== "function" && ref?.current && containerRef?.current) { - initialize({ - target: containerRef.current, - elements: { - viewport: ref.current, - }, - }); - } - }, [initialize]); - - const refSetter = useCallback( - (node: HTMLDivElement | null) => { - if (node && ref) { - if (typeof ref === "function") { - ref(node); - } else { - ref.current = node; - } - } - }, - [ref] - ); - - return ( -
-
- {children} -
-
- ); - } -); - -export type LazyListHandle = Handle; - -export type LazyListProps = { - items?: T[]; - renderItem?: (item: T, index: number) => ReactElement; - listOptions?: Partial>> & { - ref?: Ref; - }; - placeholder?: ReactNode; -} & Omit; - -export function LazyList({ - items = [], - renderItem, - listOptions: options, - placeholder, - ...props -}: LazyListProps) { - return ( - - renderItem?.(items[i], i)} - {...options} - /> - - ); -} +import { Box, BoxProps, useTheme } from "@mui/material"; +import { + ComponentProps, + ReactElement, + ReactNode, + Ref, + forwardRef, + useCallback, + useEffect, + useRef, +} from "react"; +import { useCss } from "react-use"; + +import { useOverlayScrollbars } from "overlayscrollbars-react"; +import { + VirtuosoHandle as Handle, + Virtuoso as List, + VirtuosoProps as ListProps, + VirtuosoHandle, +} from "react-virtuoso"; + +// const Scroller = forwardRef( +// ({ style, ...props }, ref) => { +// const { spacing } = useTheme(); +// const cls = useCss({ +// "> .os-scrollbar-vertical > .os-scrollbar-track > .os-scrollbar-handle": { +// "min-height": spacing(12), +// }, +// }); +// return ( +// +// ); +// } +// ); + +const Scroller = forwardRef>( + ({ style, children, ...rest }, ref) => { + const containerRef = useRef(null); + const { palette, spacing } = useTheme(); + const cls = useCss({ + "--os-padding-perpendicular": "2px", + ".os-scrollbar": { visibility: "visible", opacity: 1 }, + ".os-scrollbar-vertical > .os-scrollbar-track > .os-scrollbar-handle": { + "min-height": spacing(12), + }, + "div.os-scrollbar-vertical > div.os-scrollbar-track": { + height: `calc(100% - ${spacing(6)})`, + marginTop: spacing(6), + }, + "div > div.os-scrollbar-track": { + "--os-handle-perpendicular-size": "2px", + "--os-handle-perpendicular-size-hover": "6px", + "--os-handle-perpendicular-size-active": "6px", + "> div.os-scrollbar-handle": { + borderRadius: 0, + opacity: 0.5, + "&:hover": { opacity: 0.8 }, + }, + }, + }); + const [initialize] = useOverlayScrollbars({ + options: { + overflow: { x: "hidden", y: "scroll" }, + scrollbars: { + autoHide: "move", + theme: palette.mode === "dark" ? "os-theme-light" : "os-theme-dark", + }, + }, + }); + + useEffect(() => { + if (typeof ref !== "function" && ref?.current && containerRef?.current) { + initialize({ + target: containerRef.current, + elements: { + viewport: ref.current, + }, + }); + } + }, [initialize]); + + const refSetter = useCallback( + (node: HTMLDivElement | null) => { + if (node && ref) { + if (typeof ref === "function") { + ref(node); + } else { + ref.current = node; + } + } + }, + [ref] + ); + + return ( +
+
+ {children} +
+
+ ); + } +); + +export type LazyListHandle = Handle; + +export type LazyListProps = { + items?: T[]; + renderItem?: (item: T, index: number) => ReactElement; + listOptions?: Partial>> & { + ref?: Ref; + }; + placeholder?: ReactNode; +} & Omit; + +export function LazyList({ + items = [], + renderItem, + listOptions: options, + placeholder, + ...props +}: LazyListProps) { + return ( + + renderItem?.(items[i], i)} + {...options} + /> + + ); +} diff --git a/client/src/components/generic/ListEditor.tsx b/client/src/components/generic/ListEditor.tsx index 6ba3525..e44ca3f 100644 --- a/client/src/components/generic/ListEditor.tsx +++ b/client/src/components/generic/ListEditor.tsx @@ -1,524 +1,524 @@ -import { - Add, - ClearOutlined as DeleteIcon, - DragHandleOutlined, - EditOutlined as EditIcon, -} from "@mui/icons-material"; -import { - Box, - Button, - ButtonBase, - Collapse, - IconButton, - InputBase, - List, - ListSubheader, - Stack, - Switch, - SxProps, - Theme, - Typography, - useTheme, -} from "@mui/material"; -import { defer, filter, map, sortBy, uniqBy } from "lodash"; -import { nanoid as id } from "nanoid"; -import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd"; - -import { - CSSProperties, - ComponentProps, - ReactElement, - ReactNode, - cloneElement, - forwardRef, - useEffect, - useRef, - useState, -} from "react"; -import { useAcrylic, usePaper } from "theme"; -import { Flex } from "./Flex"; - -export const DefaultListEditorInput = forwardRef(function StyledInputBase( - { - onValueChange, - ...props - }: ComponentProps & { onValueChange?: (v: string) => void }, - ref -) { - return ( - - ); -}); - -type Key = string | number; - -type Item = { - editor?: ReactElement; - enabled?: boolean; - value?: T; - id: Key; -}; - -type Props = { - button?: boolean; - UNSAFE_label?: ReactNode; - UNSAFE_text?: ReactNode; - UNSAFE_extrasPlacement?: "flex-start" | "center" | "flex-end"; - onChange?: (value: Item[]) => void; - onChangeItem?: (key: Key, value: T, enabled: boolean) => void; - onAddItem?: () => void; - onDeleteItem?: (key: Key) => void; - category?: (value?: T) => string; - order?: (value?: T) => string | number; - extras?: (value?: T) => ReactNode; - items?: Item[]; - addItemLabel?: ReactNode; - addItemExtras?: ReactNode; - sortable?: boolean; - toggleable?: boolean; - deletable?: boolean; - icon?: ReactElement | null; - orderable?: boolean; - editable?: boolean; - addable?: boolean; - variant?: "outlined" | "default"; - placeholder?: ReactNode; - cardStyle?: CSSProperties; - autoFocus?: boolean; - renderEditor?: (parts: { - value: T; - onValueChange: (v: T) => void; - handle: ReactNode; - content: ReactNode; - extras: ReactNode; - }) => ReactNode; -}; - -type ListEditorFieldProps = { - isPlaceholder?: boolean; - i?: number; -}; - -function useInitialRender() { - const ref = useRef(false); - const current = ref.current; - ref.current = true; - return !current; -} - -const defaultEditorRenderer: Props["renderEditor"] = ({ - handle, - content, - extras, -}) => ( - <> - {handle} - {content} - {extras} - -); - -export function ListEditorField({ - toggleable, - deletable, - editable = true, - onChangeItem = () => {}, - onDeleteItem = () => {}, - extras: getExtras, - enabled = false, - editor = , - value, - id, - i = 0, - autoFocus, - sortable, - button = true, - renderEditor = defaultEditorRenderer, -}: Props & ListEditorFieldProps & Item) { - const acrylic = useAcrylic(); - const paper = usePaper(); - const [field, setField] = useState(null); - const ListElement = (button ? ButtonBase : Box) as typeof Box; - return ( - - {(provided, snapshot) => ( -
- t.transitions.create("background"), - "&:hover": { - background: (t) => t.palette.action.hover, - }, - } - : undefined), - ...(snapshot.isDragging - ? ({ - ...paper(1), - ...acrylic, - } as SxProps) - : undefined), - }} - > - {renderEditor?.({ - value, - onValueChange: (e: any) => onChangeItem(id ?? i, e, enabled), - handle: sortable && ( - - - - ), - content: ( - - {cloneElement(editor, { - onDelete: () => onDeleteItem(id ?? i), - autoFocus, - value, - key: id ?? i, - onValueChange: (e: any) => - onChangeItem(id ?? i, e, enabled), - onChange: (e: any) => - onChangeItem(id ?? i, e.target.value, enabled), - ref: (e: HTMLElement | null) => setField(e), - })} - - ), - extras: ( - - {toggleable && ( - onChangeItem(id ?? i, value, v)} - checked={enabled} - /> - )} - {editable && ( - { - if (field?.focus) { - field.focus(); - } - }} - > - - - )} - {deletable && ( - onDeleteItem(id ?? i)} - sx={{ color: (t) => t.palette.text.secondary }} - > - - - )} - {getExtras && getExtras(value)} - - ), - })} - -
- )} -
- ); -} - -// a little function to help us with reordering the result -function reorder(list: T[], startIndex: number, endIndex: number) { - const result = Array.from(list); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - - return result; -} - -export default function Editor(props: Props) { - const { - addItemLabel = "Add Item", - UNSAFE_label: label, - UNSAFE_text: text, - onAddItem = () => {}, - onDeleteItem = () => {}, - items = [], - placeholder: placeholderText, - autoFocus, - category: getCategory, - order: getOrder, - onChange, - addItemExtras: extras, - addable = true, - } = props; - const paper = usePaper(); - const isInitialRender = useInitialRender(); - const theme = useTheme(); - const [intermediateItems, setIntermediateItems] = useState(items); - const [newIndex, setNewIndex] = useState(-1); - useEffect(() => { - const timeout = setTimeout(() => { - setIntermediateItems(items); - }, theme.transitions.duration.standard); - return () => { - clearTimeout(timeout); - }; - }, [items, setIntermediateItems, theme.transitions.duration.standard]); - const children: { - key: Key; - in: boolean; - value?: T; - render: (p?: ComponentProps) => ReactNode; - }[] = uniqBy([...intermediateItems, ...items], (c) => c.id) - .map((c) => items.find((c2) => c.id === c2.id) ?? c) - .map((x, i) => { - const { enabled, editor, value, id } = x ?? {}; - return { - value, - render: (p?: ComponentProps) => ( - p.id === x.id)} - unmountOnExit - appear={!isInitialRender} - mountOnEnter - > - { - onDeleteItem(e); - setNewIndex(-1); - }} - enabled={enabled} - editor={editor} - value={value} - id={id} - i={i} - autoFocus={autoFocus || i === newIndex} - {...p} - /> - - ), - key: id, - in: !!items.find((p) => p.id === x.id), - }; - }); - const sorted = sortBy( - children, - (c) => getCategory?.(c.value), - (c) => getOrder?.(c.value) - ).map((c) => ({ - ...c, - render: (p?: ComponentProps) => ( - {c.render(p)} - ), - })); - return ( - { - // dropped outside the list - if (!result.destination) { - return; - } - - const reordered = reorder( - items, - result.source.index, - result.destination.index - ); - - onChange?.(reordered); - setIntermediateItems(reordered); - }} - > - - - {label && ( - - {label} - - )} - {text && ( - - {text} - - )} - - - ) : undefined - } - > - - - {(provided) => ( -
- {(() => { - const out: ReactNode[] = []; - sorted.forEach((c, i) => { - if (getCategory && isNewCategory(sorted, i, c)) { - out.push( - - getCategory(c2.value) === getCategory(c.value) - )} - appear - key={getCategory(c.value)} - > - - - {getCategory(c.value)} - - - - ); - } - out.push(c.render()); - }); - return out; - })()} - {provided.placeholder} -
- )} -
-
- - - - {placeholderText ?? "No items"} - - - - - {addable && ( - - )} - {extras} - -
-
- ); - - function isNewCategory(arr: any, i: any, c: any) { - return !!( - getCategory && - (arr[i - 1] === undefined || - getCategory(arr[i - 1].value) !== getCategory(c.value)) - ); - } -} - -export function ListEditor({ - onChange, - value, - editor, - create, - onFocus, - ...props -}: Omit, "items" | "onChange"> & { - items?: T[]; - onChange?: (value: T[]) => void; - value?: T[]; - editor?: (item: T) => ReactElement; - create?: () => Omit; - onFocus?: (key: string) => void; -}) { - const [state, setState] = useState(value ?? []); - function handleChange(next: T[]) { - setState(next); - onChange?.(next); - } - useEffect(() => { - setState(value ?? []); - }, [value]); - return ( - - ({ - id: c.key, - value: c, - editor: editor?.(c), - }))} - onAddItem={() => { - const _id = id(); - handleChange?.([...state, { key: _id, ...create?.() } as T]); - defer(() => onFocus?.(_id)); - }} - onDeleteItem={(k) => { - return handleChange?.(filter(state, (b) => b.key !== k)); - }} - onChangeItem={(k, v) => - handleChange?.(map(state, (b) => (b.key === k ? v : b))) - } - onChange={(k) => handleChange?.(map(k, (a) => a.value!))} - /> - - ); -} +import { + Add, + ClearOutlined as DeleteIcon, + DragHandleOutlined, + EditOutlined as EditIcon, +} from "@mui/icons-material"; +import { + Box, + Button, + ButtonBase, + Collapse, + IconButton, + InputBase, + List, + ListSubheader, + Stack, + Switch, + SxProps, + Theme, + Typography, + useTheme, +} from "@mui/material"; +import { defer, filter, map, sortBy, uniqBy } from "lodash"; +import { nanoid as id } from "nanoid"; +import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd"; + +import { + CSSProperties, + ComponentProps, + ReactElement, + ReactNode, + cloneElement, + forwardRef, + useEffect, + useRef, + useState, +} from "react"; +import { useAcrylic, usePaper } from "theme"; +import { Flex } from "./Flex"; + +export const DefaultListEditorInput = forwardRef(function StyledInputBase( + { + onValueChange, + ...props + }: ComponentProps & { onValueChange?: (v: string) => void }, + ref +) { + return ( + + ); +}); + +type Key = string | number; + +type Item = { + editor?: ReactElement; + enabled?: boolean; + value?: T; + id: Key; +}; + +type Props = { + button?: boolean; + UNSAFE_label?: ReactNode; + UNSAFE_text?: ReactNode; + UNSAFE_extrasPlacement?: "flex-start" | "center" | "flex-end"; + onChange?: (value: Item[]) => void; + onChangeItem?: (key: Key, value: T, enabled: boolean) => void; + onAddItem?: () => void; + onDeleteItem?: (key: Key) => void; + category?: (value?: T) => string; + order?: (value?: T) => string | number; + extras?: (value?: T) => ReactNode; + items?: Item[]; + addItemLabel?: ReactNode; + addItemExtras?: ReactNode; + sortable?: boolean; + toggleable?: boolean; + deletable?: boolean; + icon?: ReactElement | null; + orderable?: boolean; + editable?: boolean; + addable?: boolean; + variant?: "outlined" | "default"; + placeholder?: ReactNode; + cardStyle?: CSSProperties; + autoFocus?: boolean; + renderEditor?: (parts: { + value: T; + onValueChange: (v: T) => void; + handle: ReactNode; + content: ReactNode; + extras: ReactNode; + }) => ReactNode; +}; + +type ListEditorFieldProps = { + isPlaceholder?: boolean; + i?: number; +}; + +function useInitialRender() { + const ref = useRef(false); + const current = ref.current; + ref.current = true; + return !current; +} + +const defaultEditorRenderer: Props["renderEditor"] = ({ + handle, + content, + extras, +}) => ( + <> + {handle} + {content} + {extras} + +); + +export function ListEditorField({ + toggleable, + deletable, + editable = true, + onChangeItem = () => {}, + onDeleteItem = () => {}, + extras: getExtras, + enabled = false, + editor = , + value, + id, + i = 0, + autoFocus, + sortable, + button = true, + renderEditor = defaultEditorRenderer, +}: Props & ListEditorFieldProps & Item) { + const acrylic = useAcrylic(); + const paper = usePaper(); + const [field, setField] = useState(null); + const ListElement = (button ? ButtonBase : Box) as typeof Box; + return ( + + {(provided, snapshot) => ( +
+ t.transitions.create("background"), + "&:hover": { + background: (t) => t.palette.action.hover, + }, + } + : undefined), + ...(snapshot.isDragging + ? ({ + ...paper(1), + ...acrylic, + } as SxProps) + : undefined), + }} + > + {renderEditor?.({ + value, + onValueChange: (e: any) => onChangeItem(id ?? i, e, enabled), + handle: sortable && ( + + + + ), + content: ( + + {cloneElement(editor, { + onDelete: () => onDeleteItem(id ?? i), + autoFocus, + value, + key: id ?? i, + onValueChange: (e: any) => + onChangeItem(id ?? i, e, enabled), + onChange: (e: any) => + onChangeItem(id ?? i, e.target.value, enabled), + ref: (e: HTMLElement | null) => setField(e), + })} + + ), + extras: ( + + {toggleable && ( + onChangeItem(id ?? i, value, v)} + checked={enabled} + /> + )} + {editable && ( + { + if (field?.focus) { + field.focus(); + } + }} + > + + + )} + {deletable && ( + onDeleteItem(id ?? i)} + sx={{ color: (t) => t.palette.text.secondary }} + > + + + )} + {getExtras && getExtras(value)} + + ), + })} + +
+ )} +
+ ); +} + +// a little function to help us with reordering the result +function reorder(list: T[], startIndex: number, endIndex: number) { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +} + +export default function Editor(props: Props) { + const { + addItemLabel = "Add Item", + UNSAFE_label: label, + UNSAFE_text: text, + onAddItem = () => {}, + onDeleteItem = () => {}, + items = [], + placeholder: placeholderText, + autoFocus, + category: getCategory, + order: getOrder, + onChange, + addItemExtras: extras, + addable = true, + } = props; + const paper = usePaper(); + const isInitialRender = useInitialRender(); + const theme = useTheme(); + const [intermediateItems, setIntermediateItems] = useState(items); + const [newIndex, setNewIndex] = useState(-1); + useEffect(() => { + const timeout = setTimeout(() => { + setIntermediateItems(items); + }, theme.transitions.duration.standard); + return () => { + clearTimeout(timeout); + }; + }, [items, setIntermediateItems, theme.transitions.duration.standard]); + const children: { + key: Key; + in: boolean; + value?: T; + render: (p?: ComponentProps) => ReactNode; + }[] = uniqBy([...intermediateItems, ...items], (c) => c.id) + .map((c) => items.find((c2) => c.id === c2.id) ?? c) + .map((x, i) => { + const { enabled, editor, value, id } = x ?? {}; + return { + value, + render: (p?: ComponentProps) => ( + p.id === x.id)} + unmountOnExit + appear={!isInitialRender} + mountOnEnter + > + { + onDeleteItem(e); + setNewIndex(-1); + }} + enabled={enabled} + editor={editor} + value={value} + id={id} + i={i} + autoFocus={autoFocus || i === newIndex} + {...p} + /> + + ), + key: id, + in: !!items.find((p) => p.id === x.id), + }; + }); + const sorted = sortBy( + children, + (c) => getCategory?.(c.value), + (c) => getOrder?.(c.value) + ).map((c) => ({ + ...c, + render: (p?: ComponentProps) => ( + {c.render(p)} + ), + })); + return ( + { + // dropped outside the list + if (!result.destination) { + return; + } + + const reordered = reorder( + items, + result.source.index, + result.destination.index + ); + + onChange?.(reordered); + setIntermediateItems(reordered); + }} + > + + + {label && ( + + {label} + + )} + {text && ( + + {text} + + )} + + + ) : undefined + } + > + + + {(provided) => ( +
+ {(() => { + const out: ReactNode[] = []; + sorted.forEach((c, i) => { + if (getCategory && isNewCategory(sorted, i, c)) { + out.push( + + getCategory(c2.value) === getCategory(c.value) + )} + appear + key={getCategory(c.value)} + > + + + {getCategory(c.value)} + + + + ); + } + out.push(c.render()); + }); + return out; + })()} + {provided.placeholder} +
+ )} +
+
+ + + + {placeholderText ?? "No items"} + + + + + {addable && ( + + )} + {extras} + +
+
+ ); + + function isNewCategory(arr: any, i: any, c: any) { + return !!( + getCategory && + (arr[i - 1] === undefined || + getCategory(arr[i - 1].value) !== getCategory(c.value)) + ); + } +} + +export function ListEditor({ + onChange, + value, + editor, + create, + onFocus, + ...props +}: Omit, "items" | "onChange"> & { + items?: T[]; + onChange?: (value: T[]) => void; + value?: T[]; + editor?: (item: T) => ReactElement; + create?: () => Omit; + onFocus?: (key: string) => void; +}) { + const [state, setState] = useState(value ?? []); + function handleChange(next: T[]) { + setState(next); + onChange?.(next); + } + useEffect(() => { + setState(value ?? []); + }, [value]); + return ( + + ({ + id: c.key, + value: c, + editor: editor?.(c), + }))} + onAddItem={() => { + const _id = id(); + handleChange?.([...state, { key: _id, ...create?.() } as T]); + defer(() => onFocus?.(_id)); + }} + onDeleteItem={(k) => { + return handleChange?.(filter(state, (b) => b.key !== k)); + }} + onChangeItem={(k, v) => + handleChange?.(map(state, (b) => (b.key === k ? v : b))) + } + onChange={(k) => handleChange?.(map(k, (a) => a.value!))} + /> + + ); +} diff --git a/client/src/components/generic/Modal.tsx b/client/src/components/generic/Modal.tsx index ab0302b..a879219 100644 --- a/client/src/components/generic/Modal.tsx +++ b/client/src/components/generic/Modal.tsx @@ -1,384 +1,384 @@ -import { ArrowBack } from "@mui/icons-material"; -import { - AppBar, - Box, - BoxProps, - Dialog, - Fade, - IconButton, - ModalProps, - Popover, - PopoverProps, - Toolbar, - Typography, - useTheme, -} from "@mui/material"; -import { ResizeSensor } from "css-element-queries"; -import { useScrollState } from "hooks/useScrollState"; -import { useSmallDisplay } from "hooks/useSmallDisplay"; -import PopupState, { bindPopover } from "material-ui-popup-state"; -import { usePanel } from "./ScrollPanel"; - -import { merge } from "lodash"; -import { PopupState as State } from "material-ui-popup-state/hooks"; -import { - cloneElement, - ComponentProps, - CSSProperties, - ReactElement, - ReactNode, - SyntheticEvent, - useEffect, - useState, -} from "react"; -import { useUIState } from "slices/UIState"; -import { useAcrylic, usePaper } from "theme"; -import { Scroll } from "./Scrollbars"; -import Swipe from "./Swipe"; - -export function AppBarTitle({ children }: { children?: ReactNode }) { - return ( - - {children} - - ); -} - -export type Props = { - children?: ReactNode; - actions?: ReactNode; - width?: string | number; - height?: string | number; - variant?: "default" | "submodal"; - scrollable?: boolean; -}; - -type ModalAppBarProps = { - onClose?: () => void; - style?: CSSProperties; - elevatedStyle?: CSSProperties; - transitionProperties?: string[]; - children?: ReactNode; - elevatedChildren?: ReactNode; - simple?: boolean; - position?: "fixed" | "absolute" | "sticky" | "static"; -}; - -export function ModalAppBar({ - onClose = () => {}, - style, - elevatedStyle, - children, - transitionProperties = ["box-shadow", "background", "border-bottom"], - elevatedChildren, - simple, - position = "sticky", -}: ModalAppBarProps) { - const sm = useSmallDisplay(); - const panel = usePanel(); - const theme = useTheme(); - const [, , isAbsoluteTop, , setTarget] = useScrollState(); - useEffect(() => { - setTarget(panel); - }, [panel, setTarget]); - - const styles = isAbsoluteTop - ? { - background: sm - ? theme.palette.background.paper - : theme.palette.background.paper, - ...(!simple && { - boxShadow: theme.shadows[0], - }), - ...style, - } - : { - background: sm - ? theme.palette.background.paper - : theme.palette.background.paper, - ...(!simple && { - boxShadow: theme.shadows[4], - }), - ...elevatedStyle, - }; - - function renderTitle(label: ReactNode) { - return typeof label === "string" ? ( - {label} - ) : ( - label - ); - } - - return ( - - - onClose()} - > - - - - {children && ( -
- - {renderTitle(children)} - -
- )} - {elevatedChildren && ( -
- - - {renderTitle(elevatedChildren)} - - -
- )} -
-
- ); -} - -export default function Modal({ - children, - actions, - width = 480, - height, - variant = "default", - scrollable = true, - ...props -}: Props & ComponentProps) { - const [uiState, setUIState] = useUIState(); - const [content, setContent] = useState(undefined); - useEffect(() => { - if (children) setContent(children); - }, [children]); - const theme = useTheme(); - const sm = useSmallDisplay(); - - const [target, setTarget] = useState(null); - const [contentRef, setContentRef] = useState(null); - const [hasOverflowingChildren, setHasOverflowingChildren] = useState(false); - const [childHeight, setChildHeight] = useState(0); - const [depth, setDepth] = useState(0); - useEffect(() => { - if (props.open) { - let depth = 0; - setUIState((prev) => { - //TODO: Fix side effect - depth = prev.depth!; - return { depth: prev.depth! + 1 }; - }); - setDepth(depth + 1); - return () => { - setUIState((prev) => ({ depth: prev.depth! - 1 })); - }; - } - }, [setUIState, setDepth, props.open]); - - const mt = 95 - 5 * depth; - - useEffect(() => { - if (target && contentRef && !sm && !height) { - const callback = () => { - const doesOverflow = window.innerHeight - 64 < contentRef.offsetHeight; - setHasOverflowingChildren(doesOverflow); - setChildHeight( - contentRef.offsetHeight <= 1 ? 0 : Math.ceil(contentRef.offsetHeight) - ); - }; - window.addEventListener("resize", callback); - const ob = new ResizeSensor(contentRef, callback); - callback(); - return () => { - window.removeEventListener("resize", callback); - ob.detach(); - }; - } - }, [target, contentRef, sm, height]); - - const useVariant = variant === "submodal" && sm; - - return ( - setTarget(e), - style: { - ...(sm && { - borderRadius: `${theme.shape.borderRadius * 2}px ${ - theme.shape.borderRadius * 2 - }px 0 0`, - }), - background: theme.palette.background.paper, - overflow: "hidden", - height: - height && !sm - ? height - : sm - ? `${mt}dvh` - : hasOverflowingChildren - ? "100%" - : childHeight || "fit-content", - position: "relative", - maxWidth: "none", - marginTop: sm ? `${100 - mt}dvh` : 0, - ...props.PaperProps?.style, - }, - ...props.PaperProps, - }} - > - -
setContentRef(e)} - style={{ width: "100%", height: sm ? "100%" : undefined }} - > - {content} -
-
- {actions} -
- ); -} - -export function ManagedModal({ - appBar: ModalAppBarProps, - trigger = () => <>, - children, - popover, - slotProps, -}: { - options?: ComponentProps; - trigger?: ( - onClick: (e: SyntheticEvent) => void, - isOpen: boolean - ) => ReactElement; - appBar?: ModalAppBarProps; - children?: ((state: State) => ReactNode) | ReactNode; - popover?: boolean; - slotProps?: { - popover?: Partial; - paper?: Partial; - modal?: Partial; - }; -}) { - const paper = usePaper(); - const acrylic = useAcrylic(); - const sm = useSmallDisplay(); - const shouldDisplayPopover = popover && !sm; - const chi = children ?? slotProps?.modal?.children; - return ( - - {(state) => { - const { open, close, isOpen } = state; - const chi2 = typeof chi === "function" ? chi(state) : chi; - return ( - <> - {cloneElement(trigger(open, isOpen))} - {shouldDisplayPopover ? ( - { - e.stopPropagation(); - }} - onTouchStart={(e) => { - e.stopPropagation(); - }} - {...merge( - bindPopover(state), - { - slotProps: { - paper: { - sx: { - ...acrylic, - }, - }, - }, - }, - slotProps?.popover - )} - > - - {chi2} - - - ) : ( - void} - {...slotProps?.modal} - > - - {chi2} - - )} - - ); - }} - - ); -} - -export type ManagedModalProps = ComponentProps; +import { ArrowBack } from "@mui/icons-material"; +import { + AppBar, + Box, + BoxProps, + Dialog, + Fade, + IconButton, + ModalProps, + Popover, + PopoverProps, + Toolbar, + Typography, + useTheme, +} from "@mui/material"; +import { ResizeSensor } from "css-element-queries"; +import { useScrollState } from "hooks/useScrollState"; +import { useSmallDisplay } from "hooks/useSmallDisplay"; +import PopupState, { bindPopover } from "material-ui-popup-state"; +import { usePanel } from "./ScrollPanel"; + +import { merge } from "lodash"; +import { PopupState as State } from "material-ui-popup-state/hooks"; +import { + cloneElement, + ComponentProps, + CSSProperties, + ReactElement, + ReactNode, + SyntheticEvent, + useEffect, + useState, +} from "react"; +import { useUIState } from "slices/UIState"; +import { useAcrylic, usePaper } from "theme"; +import { Scroll } from "./Scrollbars"; +import Swipe from "./Swipe"; + +export function AppBarTitle({ children }: { children?: ReactNode }) { + return ( + + {children} + + ); +} + +export type Props = { + children?: ReactNode; + actions?: ReactNode; + width?: string | number; + height?: string | number; + variant?: "default" | "submodal"; + scrollable?: boolean; +}; + +type ModalAppBarProps = { + onClose?: () => void; + style?: CSSProperties; + elevatedStyle?: CSSProperties; + transitionProperties?: string[]; + children?: ReactNode; + elevatedChildren?: ReactNode; + simple?: boolean; + position?: "fixed" | "absolute" | "sticky" | "static"; +}; + +export function ModalAppBar({ + onClose = () => {}, + style, + elevatedStyle, + children, + transitionProperties = ["box-shadow", "background", "border-bottom"], + elevatedChildren, + simple, + position = "sticky", +}: ModalAppBarProps) { + const sm = useSmallDisplay(); + const panel = usePanel(); + const theme = useTheme(); + const [, , isAbsoluteTop, , setTarget] = useScrollState(); + useEffect(() => { + setTarget(panel); + }, [panel, setTarget]); + + const styles = isAbsoluteTop + ? { + background: sm + ? theme.palette.background.paper + : theme.palette.background.paper, + ...(!simple && { + boxShadow: theme.shadows[0], + }), + ...style, + } + : { + background: sm + ? theme.palette.background.paper + : theme.palette.background.paper, + ...(!simple && { + boxShadow: theme.shadows[4], + }), + ...elevatedStyle, + }; + + function renderTitle(label: ReactNode) { + return typeof label === "string" ? ( + {label} + ) : ( + label + ); + } + + return ( + + + onClose()} + > + + + + {children && ( +
+ + {renderTitle(children)} + +
+ )} + {elevatedChildren && ( +
+ + + {renderTitle(elevatedChildren)} + + +
+ )} +
+
+ ); +} + +export default function Modal({ + children, + actions, + width = 480, + height, + variant = "default", + scrollable = true, + ...props +}: Props & ComponentProps) { + const [uiState, setUIState] = useUIState(); + const [content, setContent] = useState(undefined); + useEffect(() => { + if (children) setContent(children); + }, [children]); + const theme = useTheme(); + const sm = useSmallDisplay(); + + const [target, setTarget] = useState(null); + const [contentRef, setContentRef] = useState(null); + const [hasOverflowingChildren, setHasOverflowingChildren] = useState(false); + const [childHeight, setChildHeight] = useState(0); + const [depth, setDepth] = useState(0); + useEffect(() => { + if (props.open) { + let depth = 0; + setUIState((prev) => { + //TODO: Fix side effect + depth = prev.depth!; + return { depth: prev.depth! + 1 }; + }); + setDepth(depth + 1); + return () => { + setUIState((prev) => ({ depth: prev.depth! - 1 })); + }; + } + }, [setUIState, setDepth, props.open]); + + const mt = 95 - 5 * depth; + + useEffect(() => { + if (target && contentRef && !sm && !height) { + const callback = () => { + const doesOverflow = window.innerHeight - 64 < contentRef.offsetHeight; + setHasOverflowingChildren(doesOverflow); + setChildHeight( + contentRef.offsetHeight <= 1 ? 0 : Math.ceil(contentRef.offsetHeight) + ); + }; + window.addEventListener("resize", callback); + const ob = new ResizeSensor(contentRef, callback); + callback(); + return () => { + window.removeEventListener("resize", callback); + ob.detach(); + }; + } + }, [target, contentRef, sm, height]); + + const useVariant = variant === "submodal" && sm; + + return ( + setTarget(e), + style: { + ...(sm && { + borderRadius: `${theme.shape.borderRadius * 2}px ${ + theme.shape.borderRadius * 2 + }px 0 0`, + }), + background: theme.palette.background.paper, + overflow: "hidden", + height: + height && !sm + ? height + : sm + ? `${mt}dvh` + : hasOverflowingChildren + ? "100%" + : childHeight || "fit-content", + position: "relative", + maxWidth: "none", + marginTop: sm ? `${100 - mt}dvh` : 0, + ...props.PaperProps?.style, + }, + ...props.PaperProps, + }} + > + +
setContentRef(e)} + style={{ width: "100%", height: sm ? "100%" : undefined }} + > + {content} +
+
+ {actions} +
+ ); +} + +export function ManagedModal({ + appBar: ModalAppBarProps, + trigger = () => <>, + children, + popover, + slotProps, +}: { + options?: ComponentProps; + trigger?: ( + onClick: (e: SyntheticEvent) => void, + isOpen: boolean + ) => ReactElement; + appBar?: ModalAppBarProps; + children?: ((state: State) => ReactNode) | ReactNode; + popover?: boolean; + slotProps?: { + popover?: Partial; + paper?: Partial; + modal?: Partial; + }; +}) { + const paper = usePaper(); + const acrylic = useAcrylic(); + const sm = useSmallDisplay(); + const shouldDisplayPopover = popover && !sm; + const chi = children ?? slotProps?.modal?.children; + return ( + + {(state) => { + const { open, close, isOpen } = state; + const chi2 = typeof chi === "function" ? chi(state) : chi; + return ( + <> + {cloneElement(trigger(open, isOpen))} + {shouldDisplayPopover ? ( + { + e.stopPropagation(); + }} + onTouchStart={(e) => { + e.stopPropagation(); + }} + {...merge( + bindPopover(state), + { + slotProps: { + paper: { + sx: { + ...acrylic, + }, + }, + }, + }, + slotProps?.popover + )} + > + + {chi2} + + + ) : ( + void} + {...slotProps?.modal} + > + + {chi2} + + )} + + ); + }} + + ); +} + +export type ManagedModalProps = ComponentProps; diff --git a/client/src/components/generic/Overline.tsx b/client/src/components/generic/Overline.tsx index 7d8a7e8..976212d 100644 --- a/client/src/components/generic/Overline.tsx +++ b/client/src/components/generic/Overline.tsx @@ -1,32 +1,32 @@ -import { FiberManualRecord as Dot } from "@mui/icons-material"; -import { Typography as Type, TypographyProps } from "@mui/material"; -import { ComponentProps, ReactNode } from "react"; - -export function OverlineDot(props: ComponentProps) { - return ( - - ); -} - -type Props = { - children?: ReactNode; -} & TypographyProps; - -export function Overline({ children, ...props }: Props) { - return ( - - {children} - - ); -} +import { FiberManualRecord as Dot } from "@mui/icons-material"; +import { Typography as Type, TypographyProps } from "@mui/material"; +import { ComponentProps, ReactNode } from "react"; + +export function OverlineDot(props: ComponentProps) { + return ( + + ); +} + +type Props = { + children?: ReactNode; +} & TypographyProps; + +export function Overline({ children, ...props }: Props) { + return ( + + {children} + + ); +} diff --git a/client/src/components/generic/Property.tsx b/client/src/components/generic/Property.tsx index b61bd8d..55bb689 100644 --- a/client/src/components/generic/Property.tsx +++ b/client/src/components/generic/Property.tsx @@ -1,85 +1,85 @@ -import { - Typography as Type, - TypographyProps as TypeProps, -} from "@mui/material"; -import beautify from "json-beautify"; -import { get, isNull, round, truncate } from "lodash"; -import { CSSProperties, ReactNode } from "react"; -import { Flex } from "./Flex"; -import { Space } from "./Space"; - -type Props = { - label?: ReactNode; - value?: any; - type?: TypeProps<"div">; - simple?: boolean; -}; - -const supProps: CSSProperties = { - verticalAlign: "top", - position: "relative", - top: 0, -}; - -export function renderProperty(obj: any, simple: boolean = false) { - switch (typeof obj) { - case "number": { - if (simple) { - const [coefficient, exp] = obj - .toExponential(2) - .split("e") - .map((item) => +item); - return exp < -2 || exp > 4 ? ( - - {coefficient}x10{exp} - - ) : ( - round(obj, 2) - ); - } else { - return obj; - } - } - case "string": - return `${obj}`; - case "undefined": - return "null"; - default: - return simple ? ( - - {isNull(obj) ? "null" : get(obj, "constructor.name") ?? typeof obj} - - ) : ( - - {truncate(beautify(obj, undefined as any, 2), { - length: 100, - })} - - ); - } -} - -export function Property({ label, value, type, simple }: Props) { - return ( - - - {label} - - - - {renderProperty(value, simple) ?? "none"} - - - ); -} +import { + Typography as Type, + TypographyProps as TypeProps, +} from "@mui/material"; +import beautify from "json-beautify"; +import { get, isNull, round, truncate } from "lodash"; +import { CSSProperties, ReactNode } from "react"; +import { Flex } from "./Flex"; +import { Space } from "./Space"; + +type Props = { + label?: ReactNode; + value?: any; + type?: TypeProps<"div">; + simple?: boolean; +}; + +const supProps: CSSProperties = { + verticalAlign: "top", + position: "relative", + top: 0, +}; + +export function renderProperty(obj: any, simple: boolean = false) { + switch (typeof obj) { + case "number": { + if (simple) { + const [coefficient, exp] = obj + .toExponential(2) + .split("e") + .map((item) => +item); + return exp < -2 || exp > 4 ? ( + + {coefficient}x10{exp} + + ) : ( + round(obj, 2) + ); + } else { + return obj; + } + } + case "string": + return `${obj}`; + case "undefined": + return "null"; + default: + return simple ? ( + + {isNull(obj) ? "null" : get(obj, "constructor.name") ?? typeof obj} + + ) : ( + + {truncate(beautify(obj, undefined as any, 2), { + length: 100, + })} + + ); + } +} + +export function Property({ label, value, type, simple }: Props) { + return ( + + + {label} + + + + {renderProperty(value, simple) ?? "none"} + + + ); +} diff --git a/client/src/components/generic/ScrollPanel.tsx b/client/src/components/generic/ScrollPanel.tsx index 6d99e6d..8700794 100644 --- a/client/src/components/generic/ScrollPanel.tsx +++ b/client/src/components/generic/ScrollPanel.tsx @@ -1,57 +1,57 @@ -import { - ComponentProps, - createContext, - useContext, - useEffect, - useState, -} from "react"; - -type ScrollPanelProps = { - onTarget?: (e: HTMLDivElement | null) => void; -} & ComponentProps<"div">; - -export function ScrollPanel({ - onTarget, - onScroll, - ...props -}: ScrollPanelProps) { - const [target, setTarget] = useState(null); - - useEffect(() => { - if (target && onScroll) { - target.addEventListener("scroll", onScroll as any, { passive: true }); - return () => target.removeEventListener("scroll", onScroll as any); - } - }, [target, onScroll]); - - return ( -
{ - setTarget(e); - onTarget?.(e); - }} - > - -
- {props.children} -
-
-
- ); -} -const PanelContext = createContext(null); - -export function usePanel() { - return useContext(PanelContext); -} +import { + ComponentProps, + createContext, + useContext, + useEffect, + useState, +} from "react"; + +type ScrollPanelProps = { + onTarget?: (e: HTMLDivElement | null) => void; +} & ComponentProps<"div">; + +export function ScrollPanel({ + onTarget, + onScroll, + ...props +}: ScrollPanelProps) { + const [target, setTarget] = useState(null); + + useEffect(() => { + if (target && onScroll) { + target.addEventListener("scroll", onScroll as any, { passive: true }); + return () => target.removeEventListener("scroll", onScroll as any); + } + }, [target, onScroll]); + + return ( +
{ + setTarget(e); + onTarget?.(e); + }} + > + +
+ {props.children} +
+
+
+ ); +} +const PanelContext = createContext(null); + +export function usePanel() { + return useContext(PanelContext); +} diff --git a/client/src/components/generic/Select.tsx b/client/src/components/generic/Select.tsx index f4e6d2b..44ac2c5 100644 --- a/client/src/components/generic/Select.tsx +++ b/client/src/components/generic/Select.tsx @@ -1,117 +1,117 @@ -import { - ListItemIcon, - Menu, - MenuItem, - SxProps, - TextField, - TextFieldProps, - Theme, - Tooltip, -} from "@mui/material"; -import { useSmallDisplay } from "hooks/useSmallDisplay"; -import { findIndex, map, max } from "lodash"; -import State, { bindMenu, bindTrigger } from "material-ui-popup-state"; -import { ReactElement, ReactNode } from "react"; -import { useAcrylic, usePaper } from "theme"; - -type Key = string | number; - -export type SelectProps = { - trigger?: (props: ReturnType) => ReactElement; - items?: { - value: T; - label?: ReactNode; - disabled?: boolean; - icon?: ReactNode; - }[]; - value?: T; - onChange?: (value: T) => void; - placeholder?: string; - showTooltip?: boolean; -}; - -const itemHeight = (sm: boolean) => (sm ? 48 : 36); -const padding = 8; - -export function Select({ - trigger, - items, - value, - onChange, - showTooltip, - placeholder = "Select Option", -}: SelectProps) { - const sm = useSmallDisplay(); - const index = max([findIndex(items, { value: value as any }), 0]) ?? 0; - return ( - - {(state) => ( - <> - - {trigger?.(bindTrigger(state))} - - - {map(items, ({ value: v, label, disabled, icon }) => ( - - { - state.close(); - onChange?.(v); - }} - > - {icon && ( - - {icon} - - )} - {label} - - - ))} - - - )} - - ); -} - -export type SelectFieldProps = Pick< - SelectProps, - "items" | "onChange" -> & - Omit; - -export function SelectField(props: SelectFieldProps) { - const { placeholder, value, items = [], onChange } = props; - return ( - onChange?.(e.target.value as T)} - > - {map(items, (item) => ( - - {item.label} - - ))} - - ); -} +import { + ListItemIcon, + Menu, + MenuItem, + SxProps, + TextField, + TextFieldProps, + Theme, + Tooltip, +} from "@mui/material"; +import { useSmallDisplay } from "hooks/useSmallDisplay"; +import { findIndex, map, max } from "lodash"; +import State, { bindMenu, bindTrigger } from "material-ui-popup-state"; +import { ReactElement, ReactNode } from "react"; +import { useAcrylic, usePaper } from "theme"; + +type Key = string | number; + +export type SelectProps = { + trigger?: (props: ReturnType) => ReactElement; + items?: { + value: T; + label?: ReactNode; + disabled?: boolean; + icon?: ReactNode; + }[]; + value?: T; + onChange?: (value: T) => void; + placeholder?: string; + showTooltip?: boolean; +}; + +const itemHeight = (sm: boolean) => (sm ? 48 : 36); +const padding = 8; + +export function Select({ + trigger, + items, + value, + onChange, + showTooltip, + placeholder = "Select Option", +}: SelectProps) { + const sm = useSmallDisplay(); + const index = max([findIndex(items, { value: value as any }), 0]) ?? 0; + return ( + + {(state) => ( + <> + + {trigger?.(bindTrigger(state))} + + + {map(items, ({ value: v, label, disabled, icon }) => ( + + { + state.close(); + onChange?.(v); + }} + > + {icon && ( + + {icon} + + )} + {label} + + + ))} + + + )} + + ); +} + +export type SelectFieldProps = Pick< + SelectProps, + "items" | "onChange" +> & + Omit; + +export function SelectField(props: SelectFieldProps) { + const { placeholder, value, items = [], onChange } = props; + return ( + onChange?.(e.target.value as T)} + > + {map(items, (item) => ( + + {item.label} + + ))} + + ); +} diff --git a/client/src/components/generic/SelectMulti.tsx b/client/src/components/generic/SelectMulti.tsx index 01b26fb..565218f 100644 --- a/client/src/components/generic/SelectMulti.tsx +++ b/client/src/components/generic/SelectMulti.tsx @@ -1,101 +1,101 @@ -import { Checkbox, ListItemIcon, Menu, MenuItem, Tooltip } from "@mui/material"; -import { useSmallDisplay } from "hooks/useSmallDisplay"; -import { findIndex, map, max } from "lodash"; -import State, { bindMenu, bindTrigger } from "material-ui-popup-state"; -import { ReactElement, ReactNode } from "react"; - -type Key = string | number; - -export type SelectProps = { - trigger?: (props: ReturnType) => ReactElement; - items?: { value: T; label?: ReactNode; disabled?: boolean }[]; - value?: Record; - onChange?: (value: Record) => void; - placeholder?: string; - defaultChecked?: boolean; -}; - -const itemHeight = (sm: boolean) => (sm ? 48 : 36); -const padding = 8; - -export function SelectMulti({ - trigger, - items, - value, - onChange, - placeholder = "Select Options", - defaultChecked, -}: SelectProps) { - const sm = useSmallDisplay(); - const index = max([findIndex(items, ({ value: v }) => !!value?.[v]), 0]) ?? 0; - return ( - - {(state) => ( - <> - - {trigger?.(bindTrigger(state))} - - - {map(items, ({ value: v, label, disabled }) => ( - { - onChange?.({ - ...value, - [v]: !(value?.[v] ?? defaultChecked), - } as any); - }} - > - - - - {label} - - ))} - - - )} - - ); -} - -// export type SelectFieldProps = Pick< -// SelectProps, -// "items" | "onChange" -// > & -// Omit; - -// export function SelectField(props: SelectFieldProps) { -// const { placeholder, value, items = [], onChange } = props; -// return ( -// onChange?.(e.target.value as T)} -// > -// {map(items, (item) => ( -// -// {item.label} -// -// ))} -// -// ); -// } +import { Checkbox, ListItemIcon, Menu, MenuItem, Tooltip } from "@mui/material"; +import { useSmallDisplay } from "hooks/useSmallDisplay"; +import { findIndex, map, max } from "lodash"; +import State, { bindMenu, bindTrigger } from "material-ui-popup-state"; +import { ReactElement, ReactNode } from "react"; + +type Key = string | number; + +export type SelectProps = { + trigger?: (props: ReturnType) => ReactElement; + items?: { value: T; label?: ReactNode; disabled?: boolean }[]; + value?: Record; + onChange?: (value: Record) => void; + placeholder?: string; + defaultChecked?: boolean; +}; + +const itemHeight = (sm: boolean) => (sm ? 48 : 36); +const padding = 8; + +export function SelectMulti({ + trigger, + items, + value, + onChange, + placeholder = "Select Options", + defaultChecked, +}: SelectProps) { + const sm = useSmallDisplay(); + const index = max([findIndex(items, ({ value: v }) => !!value?.[v]), 0]) ?? 0; + return ( + + {(state) => ( + <> + + {trigger?.(bindTrigger(state))} + + + {map(items, ({ value: v, label, disabled }) => ( + { + onChange?.({ + ...value, + [v]: !(value?.[v] ?? defaultChecked), + } as any); + }} + > + + + + {label} + + ))} + + + )} + + ); +} + +// export type SelectFieldProps = Pick< +// SelectProps, +// "items" | "onChange" +// > & +// Omit; + +// export function SelectField(props: SelectFieldProps) { +// const { placeholder, value, items = [], onChange } = props; +// return ( +// onChange?.(e.target.value as T)} +// > +// {map(items, (item) => ( +// +// {item.label} +// +// ))} +// +// ); +// } diff --git a/client/src/components/generic/Snackbar.tsx b/client/src/components/generic/Snackbar.tsx index 1e6ac28..02f9030 100644 --- a/client/src/components/generic/Snackbar.tsx +++ b/client/src/components/generic/Snackbar.tsx @@ -1,141 +1,141 @@ -import { CloseOutlined as CloseIcon } from "@mui/icons-material"; -import { Button, IconButton, Snackbar } from "@mui/material"; -import { filter, noop } from "lodash"; -import { Label } from "./Label"; -import { useLog } from "slices/log"; -import { - ReactNode, - createContext, - useCallback, - useContext, - useEffect, - useState, -} from "react"; - -type A = ( - message?: string, - secondary?: string, - options?: { - error?: boolean; - action?: () => void; - actionLabel?: string; - } -) => () => void; - -const SnackbarContext = createContext(() => noop); - -export interface SnackbarMessage { - message?: ReactNode; - action?: () => void; - actionLabel?: ReactNode; - key: number; -} - -export interface State { - open: boolean; - snackPack: readonly SnackbarMessage[]; - messageInfo?: SnackbarMessage; -} - -export function useSnackbar() { - return useContext(SnackbarContext); -} - -export function SnackbarProvider({ children }: { children?: ReactNode }) { - const [snackPack, setSnackPack] = useState([]); - const [open, setOpen] = useState(false); - const [current, setCurrent] = useState( - undefined - ); - - const [, appendLog] = useLog(); - - useEffect(() => { - if (snackPack.length && !current) { - setCurrent({ ...snackPack[0] }); - setSnackPack((prev) => prev.slice(1)); - setOpen(true); - } else if (snackPack.length && current && open) { - setOpen(false); - } - }, [snackPack, current, open]); - - const handleMessage = useCallback( - ((message?: string, secondary?: string, options = {}) => { - setSnackPack((prev) => [ - ...prev, - { - message: (() => noop); + +export interface SnackbarMessage { + message?: ReactNode; + action?: () => void; + actionLabel?: ReactNode; + key: number; +} + +export interface State { + open: boolean; + snackPack: readonly SnackbarMessage[]; + messageInfo?: SnackbarMessage; +} + +export function useSnackbar() { + return useContext(SnackbarContext); +} + +export function SnackbarProvider({ children }: { children?: ReactNode }) { + const [snackPack, setSnackPack] = useState([]); + const [open, setOpen] = useState(false); + const [current, setCurrent] = useState( + undefined + ); + + const [, appendLog] = useLog(); + + useEffect(() => { + if (snackPack.length && !current) { + setCurrent({ ...snackPack[0] }); + setSnackPack((prev) => prev.slice(1)); + setOpen(true); + } else if (snackPack.length && current && open) { + setOpen(false); + } + }, [snackPack, current, open]); + + const handleMessage = useCallback( + ((message?: string, secondary?: string, options = {}) => { + setSnackPack((prev) => [ + ...prev, + { + message: {(b) => children?.(merge(a, b))}} - - ); - }, identity) - .value(), - [layers] - ); -} +import { + Divider, + ListItem, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + MenuList, + Typography, +} from "@mui/material"; +import { SelectionInfoProvider } from "layers/LayerController"; +import { getController } from "layers/layerControllers"; +import { SelectEvent as RendererSelectEvent } from "components/renderer/Renderer"; +import { chain, Dictionary, entries, merge } from "lodash"; +import { useCache } from "pages/TreePage"; +import { ComponentProps, ReactNode, useMemo } from "react"; +import { useLayers } from "slices/layers"; + +type Props = { + selection?: RendererSelectEvent; + onClose?: () => void; +}; + +export type SelectionMenuEntry = { + index?: number; + action?: () => void; + primary?: ReactNode; + secondary?: ReactNode; + icon?: ReactNode; + extras?: ReactNode; +}; + +type SelectionMenuSection = { + index?: number; + primary?: ReactNode; + items?: Dictionary; +}; + +export type SelectionMenuContent = Dictionary; + +export function SelectionMenu({ selection, onClose }: Props) { + const MenuContent = useSelectionMenu(); + const cache = useCache(selection); + + const { client } = selection ?? {}; + + return ( + + + { + + {(menu) => { + const entries2 = entries(menu); + return entries2.length ? ( + chain(entries2) + .sortBy(([, v]) => v.index) + .map(([, { items, primary }], i) => ( + <> + {!!i && } + {primary && ( + + + {primary} + + + )} + {chain(items) + .entries() + .sortBy(([, v]) => v.index) + .map( + ([ + k, + { action, icon, primary, secondary, extras }, + ]) => ( + <> + {!!(action || primary || secondary) && + (action ? ( + { + action?.(); + onClose?.(); + }} + > + {icon && ( + {icon} + )} + + + {secondary} + + + ) : ( + + {icon && ( + {icon} + )} + + + {secondary} + + + ))} + {!!extras && extras} + + ) + ) + .value()} + + )) + .value() + ) : ( + <> + + No info to show. + + + ); + }} + + } + + + ); +} + +type SelectionInfoProviderProps = ComponentProps; + +const identity = ({ children }: SelectionInfoProviderProps) => ( + <>{children?.({})} +); + +function useSelectionMenu() { + const [{ layers: layers }] = useLayers(); + return useMemo( + () => + chain(layers) + .reduce((A, l) => { + const B = getController(l)?.provideSelectionInfo ?? identity; + return ({ children, event }: SelectionInfoProviderProps) => ( + + {(a) => {(b) => children?.(merge(a, b))}} + + ); + }, identity) + .value(), + [layers] + ); +} diff --git a/client/src/components/inspector/index.tsx b/client/src/components/inspector/index.tsx index 78f06e8..91acb8e 100644 --- a/client/src/components/inspector/index.tsx +++ b/client/src/components/inspector/index.tsx @@ -1,61 +1,61 @@ -import { Box, Fade, LinearProgress } from "@mui/material"; -import { Sidebar } from "Sidebar"; -import { Flex, FlexProps } from "components/generic/Flex"; -import { openWindow } from "components/title-bar/window"; -import { pages } from "pages"; -import { Page } from "pages/Page"; -import { PlaceholderPage } from "pages/PlaceholderPage"; -import { useUIState } from "slices/UIState"; -import { useAnyLoading } from "slices/loading"; -import { PanelState, useView } from "slices/view"; -import { FileDropZone } from "./FileDropZone"; -import { FullscreenModalHost } from "./FullscreenModalHost"; -import { FullscreenProgress } from "./FullscreenProgress"; -import { ViewTree } from "./ViewTree"; - -type SpecimenInspectorProps = Record & FlexProps; - -export function Inspector(props: SpecimenInspectorProps) { - const loading = useAnyLoading(); - const [{ view }, setView] = useView(); - const [, setUIState] = useUIState(); - return ( - <> - - - - onPopOut={(leaf) => { - openWindow({ - page: leaf.content?.type, - }); - }} - onMaximise={(leaf) => { - setUIState(() => ({ fullscreenModal: leaf.content?.type })); - }} - canPopOut={(leaf) => !!pages[leaf.content!.type!]?.allowFullscreen} - root={view} - onChange={(v) => setView(() => ({ view: v }))} - renderLeaf={({ content }) => { - const Content = - pages[content?.type ?? ""]?.content ?? PlaceholderPage; - return ( - - - - ); - }} - /> - - - - - - - - - - ); -} +import { Box, Fade, LinearProgress } from "@mui/material"; +import { Sidebar } from "Sidebar"; +import { Flex, FlexProps } from "components/generic/Flex"; +import { openWindow } from "components/title-bar/window"; +import { pages } from "pages"; +import { Page } from "pages/Page"; +import { PlaceholderPage } from "pages/PlaceholderPage"; +import { useUIState } from "slices/UIState"; +import { useAnyLoading } from "slices/loading"; +import { PanelState, useView } from "slices/view"; +import { FileDropZone } from "./FileDropZone"; +import { FullscreenModalHost } from "./FullscreenModalHost"; +import { FullscreenProgress } from "./FullscreenProgress"; +import { ViewTree } from "./ViewTree"; + +type SpecimenInspectorProps = Record & FlexProps; + +export function Inspector(props: SpecimenInspectorProps) { + const loading = useAnyLoading(); + const [{ view }, setView] = useView(); + const [, setUIState] = useUIState(); + return ( + <> + + + + onPopOut={(leaf) => { + openWindow({ + page: leaf.content?.type, + }); + }} + onMaximise={(leaf) => { + setUIState(() => ({ fullscreenModal: leaf.content?.type })); + }} + canPopOut={(leaf) => !!pages[leaf.content!.type!]?.allowFullscreen} + root={view} + onChange={(v) => setView(() => ({ view: v }))} + renderLeaf={({ content }) => { + const Content = + pages[content?.type ?? ""]?.content ?? PlaceholderPage; + return ( + + + + ); + }} + /> + + + + + + + + + + ); +} diff --git a/client/src/components/renderer/Renderer.tsx b/client/src/components/renderer/Renderer.tsx index 5ce4d03..aa70474 100644 --- a/client/src/components/renderer/Renderer.tsx +++ b/client/src/components/renderer/Renderer.tsx @@ -1,44 +1,44 @@ -import { TraceEvent } from "protocol/Trace"; -import { FunctionComponent, RefCallback } from "react"; -import { ComponentEntry, Renderer } from "renderer"; -import { Point } from "./Size"; -import { Layer } from "slices/layers"; - -type Step = { - index: number; - event: TraceEvent; -}; - -type Node = { - key: number; -}; - -export type SelectionInfo = { - current?: Step; - entry?: Step; - node?: Node; - point?: Point; - components?: ComponentEntry[]; -}; - -export type SelectEvent = { - client: Point; - world: Point; - info: SelectionInfo; -}; - -export type RendererProps = { - renderer?: string; - rendererRef?: RefCallback; - onSelect?: (e: SelectEvent) => void; - selection?: Point; - width?: number; - height?: number; - layers?: Layer[]; -}; - -export type RendererComponent = FunctionComponent; - -export type RendererMap = { - [K in string]: RendererComponent; -}; +import { TraceEvent } from "protocol/Trace"; +import { FunctionComponent, RefCallback } from "react"; +import { ComponentEntry, Renderer } from "renderer"; +import { Point } from "./Size"; +import { Layer } from "slices/layers"; + +type Step = { + index: number; + event: TraceEvent; +}; + +type Node = { + key: number; +}; + +export type SelectionInfo = { + current?: Step; + entry?: Step; + node?: Node; + point?: Point; + components?: ComponentEntry[]; +}; + +export type SelectEvent = { + client: Point; + world: Point; + info: SelectionInfo; +}; + +export type RendererProps = { + renderer?: string; + rendererRef?: RefCallback; + onSelect?: (e: SelectEvent) => void; + selection?: Point; + width?: number; + height?: number; + layers?: Layer[]; +}; + +export type RendererComponent = FunctionComponent; + +export type RendererMap = { + [K in string]: RendererComponent; +}; diff --git a/client/src/components/renderer/colors.tsx b/client/src/components/renderer/colors.tsx index 63c9fe3..7cccf96 100644 --- a/client/src/components/renderer/colors.tsx +++ b/client/src/components/renderer/colors.tsx @@ -1,99 +1,99 @@ -import { - amber, - blue, - deepPurple, - green, - orange, - pink, - red, -} from "@mui/material/colors"; -import { ColorTranslator } from "colortranslator"; -import { - Dictionary, - entries, - keys, - lowerCase, - mapValues, - sortBy, - thru, - values, -} from "lodash"; -import { EventTypeColors } from "protocol"; -import { TraceEventType } from "protocol/Trace"; -import { AccentColor, accentColors, getShade } from "theme"; - -function hash(str: string) { - let hash = 5381, - i = str.length; - - while (i) { - hash = (hash * 33) ^ str.charCodeAt(--i); - } - - /* JavaScript does bitwise operations (like XOR, above) on 32-bit signed - * integers. Since we want the results to be always positive, convert the - * signed int to an unsigned by doing an unsigned bitshift. */ - return hash >>> 0; -} - -export const tint = "500"; - -export function hex(h: string) { - return parseInt(h.replace("#", "0x")); -} - -export const searchEventAliases = thru( - { - source: ["source", "start"], - destination: ["destination", "goal", "finish"], - updating: ["update", "updating"], - expanding: ["expanding", "expanding"], - generating: ["generate", "generating", "open", "opening"], - closing: ["close", "closing"], - end: ["finish", "end", "solution"], - }, - (dict) => { - const out: Dictionary = {}; - for (const [k, v] of entries(dict)) { - for (const v1 of v) out[v1] = k; - } - return out; - } -); - -export const colorsHex: EventTypeColors = { - source: green["A400"], - destination: red["A400"], - updating: orange[tint], - expanding: deepPurple[tint], - generating: amber[tint], - closing: pink[tint], - end: blue["A400"], -}; - -export const colors: { [K in TraceEventType]: number } = mapValues( - colorsHex, - hex -); - -export const shades = sortBy( - keys(accentColors) as AccentColor[], - (c) => new ColorTranslator(getShade(c, "dark")).H -); - -export function getColor(key?: TraceEventType) { - return hex(getColorHex(key)); -} - -export function getColorHex(key: TraceEventType = "", fallback?: string) { - const builtIn = searchEventAliases[lowerCase(key)]; - if (builtIn) { - return colorsHex[builtIn]; - } else if (fallback) { - return fallback; - } else { - const n = hash(lowerCase(key)); - const colors = values(accentColors); - return colors[n % colors.length][tint]; - } -} +import { + amber, + blue, + deepPurple, + green, + orange, + pink, + red, +} from "@mui/material/colors"; +import { ColorTranslator } from "colortranslator"; +import { + Dictionary, + entries, + keys, + lowerCase, + mapValues, + sortBy, + thru, + values, +} from "lodash"; +import { EventTypeColors } from "protocol"; +import { TraceEventType } from "protocol/Trace"; +import { AccentColor, accentColors, getShade } from "theme"; + +function hash(str: string) { + let hash = 5381, + i = str.length; + + while (i) { + hash = (hash * 33) ^ str.charCodeAt(--i); + } + + /* JavaScript does bitwise operations (like XOR, above) on 32-bit signed + * integers. Since we want the results to be always positive, convert the + * signed int to an unsigned by doing an unsigned bitshift. */ + return hash >>> 0; +} + +export const tint = "500"; + +export function hex(h: string) { + return parseInt(h.replace("#", "0x")); +} + +export const searchEventAliases = thru( + { + source: ["source", "start"], + destination: ["destination", "goal", "finish"], + updating: ["update", "updating"], + expanding: ["expanding", "expanding"], + generating: ["generate", "generating", "open", "opening"], + closing: ["close", "closing"], + end: ["finish", "end", "solution"], + }, + (dict) => { + const out: Dictionary = {}; + for (const [k, v] of entries(dict)) { + for (const v1 of v) out[v1] = k; + } + return out; + } +); + +export const colorsHex: EventTypeColors = { + source: green["A400"], + destination: red["A400"], + updating: orange[tint], + expanding: deepPurple[tint], + generating: amber[tint], + closing: pink[tint], + end: blue["A400"], +}; + +export const colors: { [K in TraceEventType]: number } = mapValues( + colorsHex, + hex +); + +export const shades = sortBy( + keys(accentColors) as AccentColor[], + (c) => new ColorTranslator(getShade(c, "dark")).H +); + +export function getColor(key?: TraceEventType) { + return hex(getColorHex(key)); +} + +export function getColorHex(key: TraceEventType = "", fallback?: string) { + const builtIn = searchEventAliases[lowerCase(key)]; + if (builtIn) { + return colorsHex[builtIn]; + } else if (fallback) { + return fallback; + } else { + const n = hash(lowerCase(key)); + const colors = values(accentColors); + return colors[n % colors.length][tint]; + } +} diff --git a/client/src/components/renderer/index.tsx b/client/src/components/renderer/index.tsx index bd2fbce..3eba141 100644 --- a/client/src/components/renderer/index.tsx +++ b/client/src/components/renderer/index.tsx @@ -1,5 +1,5 @@ -import { mapParsers } from "./map-parser"; - -export function getParser(key = "") { - return mapParsers[key]; -} +import { mapParsers } from "./map-parser"; + +export function getParser(key = "") { + return mapParsers[key]; +} diff --git a/client/src/components/script-editor/FunctionTemplate.tsx b/client/src/components/script-editor/FunctionTemplate.tsx index f956cbe..1ea0f41 100644 --- a/client/src/components/script-editor/FunctionTemplate.tsx +++ b/client/src/components/script-editor/FunctionTemplate.tsx @@ -1,35 +1,35 @@ -type TypeKeywordMap = { - string: string; - number: number; - any: any; - boolean: boolean; -}; - -export type TypeOf = T extends keyof TypeKeywordMap - ? TypeKeywordMap[T] - : never; - -export type KeywordOf = - | keyof { - [K in keyof TypeKeywordMap as T extends TypeKeywordMap[K] - ? K - : never]: TypeKeywordMap[K]; - } - | "any"; - -export type FunctionTemplate< - Params extends [...any] = [], - ReturnType = void -> = { - name: string; - description: string; - params: { - [K in keyof Params]: { - name: string; - defaultValue?: Params[K]; - type: KeywordOf; - }; - }; - returnType: KeywordOf; - defaultReturnValue?: ReturnType; -}; +type TypeKeywordMap = { + string: string; + number: number; + any: any; + boolean: boolean; +}; + +export type TypeOf = T extends keyof TypeKeywordMap + ? TypeKeywordMap[T] + : never; + +export type KeywordOf = + | keyof { + [K in keyof TypeKeywordMap as T extends TypeKeywordMap[K] + ? K + : never]: TypeKeywordMap[K]; + } + | "any"; + +export type FunctionTemplate< + Params extends [...any] = [], + ReturnType = void +> = { + name: string; + description: string; + params: { + [K in keyof Params]: { + name: string; + defaultValue?: Params[K]; + type: KeywordOf; + }; + }; + returnType: KeywordOf; + defaultReturnValue?: ReturnType; +}; diff --git a/client/src/components/script-editor/ScriptEditor.tsx b/client/src/components/script-editor/ScriptEditor.tsx index 91e575c..a5e841e 100644 --- a/client/src/components/script-editor/ScriptEditor.tsx +++ b/client/src/components/script-editor/ScriptEditor.tsx @@ -1,82 +1,82 @@ -import Editor, { useMonaco } from "@monaco-editor/react"; -import { CircularProgress, Theme, useTheme } from "@mui/material"; -import { Flex } from "components/generic/Flex"; -import { debounce } from "lodash"; -import { ComponentProps } from "react"; -import AutoSize from "react-virtualized-auto-sizer"; - -const DELAY = 2500; - -export function ScriptEditor({ - code, - onChange, -}: { - code?: string; - onChange?: (code?: string) => void; -}) { - const theme = useTheme(); - - useMonacoTheme(theme); - - return ( - - - {({ width, height }) => ( - } - height={height} - language="javascript" - defaultValue={code} - onChange={debounce((v) => onChange?.(v), DELAY)} - options={{ - minimap: { - enabled: false, - }, - }} - /> - )} - - - ); -} - -export function useMonacoTheme(theme: Theme) { - const monaco = useMonaco(); - monaco?.editor?.defineTheme("posthoc-dark", { - base: "vs-dark", - inherit: true, - rules: [], - colors: { - "editor.background": theme.palette.background.paper, - }, - }); -} - -export function ScriptViewer(props: ComponentProps) { - const theme = useTheme(); - useMonacoTheme(theme); - return ( - - - {({ width, height }) => ( - } - height={height} - language="javascript" - {...props} - options={{ - minimap: { - enabled: false, - }, - ...props.options, - }} - /> - )} - - - ); -} +import Editor, { useMonaco } from "@monaco-editor/react"; +import { CircularProgress, Theme, useTheme } from "@mui/material"; +import { Flex } from "components/generic/Flex"; +import { debounce } from "lodash"; +import { ComponentProps } from "react"; +import AutoSize from "react-virtualized-auto-sizer"; + +const DELAY = 2500; + +export function ScriptEditor({ + code, + onChange, +}: { + code?: string; + onChange?: (code?: string) => void; +}) { + const theme = useTheme(); + + useMonacoTheme(theme); + + return ( + + + {({ width, height }) => ( + } + height={height} + language="javascript" + defaultValue={code} + onChange={debounce((v) => onChange?.(v), DELAY)} + options={{ + minimap: { + enabled: false, + }, + }} + /> + )} + + + ); +} + +export function useMonacoTheme(theme: Theme) { + const monaco = useMonaco(); + monaco?.editor?.defineTheme("posthoc-dark", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": theme.palette.background.paper, + }, + }); +} + +export function ScriptViewer(props: ComponentProps) { + const theme = useTheme(); + useMonacoTheme(theme); + return ( + + + {({ width, height }) => ( + } + height={height} + language="javascript" + {...props} + options={{ + minimap: { + enabled: false, + }, + ...props.options, + }} + /> + )} + + + ); +} diff --git a/client/src/components/script-editor/call.tsx b/client/src/components/script-editor/call.tsx index 57353e4..497af24 100644 --- a/client/src/components/script-editor/call.tsx +++ b/client/src/components/script-editor/call.tsx @@ -2,41 +2,41 @@ import memo from "memoizee"; import { FunctionTemplate } from "./FunctionTemplate"; import { templates } from "./templates"; -type TemplateMap = typeof templates; - -type Key = keyof TemplateMap; - -type ReturnTypeOf = TemplateMap[T] extends FunctionTemplate< - [...any], - infer R -> - ? R - : never; - -type ParamsOf = TemplateMap[T] extends FunctionTemplate< - infer R, - any -> - ? R - : []; - -const fn = memo( - (script: string, method: string) => - // eslint-disable-next-line no-new-func - new Function( - "params", - `${script}; return ${method}.apply(null, params);` - ) as (params: any[]) => any -); - -export function call( - script: string, - method: T, - params: ParamsOf -): ReturnTypeOf { - try { - return fn(script, method)(params); - } catch { - return templates[method].defaultReturnValue as ReturnTypeOf; - } +type TemplateMap = typeof templates; + +type Key = keyof TemplateMap; + +type ReturnTypeOf = TemplateMap[T] extends FunctionTemplate< + [...any], + infer R +> + ? R + : never; + +type ParamsOf = TemplateMap[T] extends FunctionTemplate< + infer R, + any +> + ? R + : []; + +const fn = memo( + (script: string, method: string) => + // eslint-disable-next-line no-new-func + new Function( + "params", + `${script}; return ${method}.apply(null, params);` + ) as (params: any[]) => any +); + +export function call( + script: string, + method: T, + params: ParamsOf +): ReturnTypeOf { + try { + return fn(script, method)(params); + } catch { + return templates[method].defaultReturnValue as ReturnTypeOf; + } } \ No newline at end of file diff --git a/client/src/components/script-editor/makeTemplate.tsx b/client/src/components/script-editor/makeTemplate.tsx index 18835de..b737c1d 100644 --- a/client/src/components/script-editor/makeTemplate.tsx +++ b/client/src/components/script-editor/makeTemplate.tsx @@ -1,49 +1,49 @@ import { chunk, join, map, split } from "lodash"; import { FunctionTemplate } from "./FunctionTemplate"; -type GenericFunctionTemplate = FunctionTemplate<[...any], any>; - -function makeTypeString({ returnType, params }: GenericFunctionTemplate) { - return `@type {(${join( - map(params, (p) => `${p.name}: ${p.type}`), - ", " - )}) => ${returnType}}`; -} - -function makeComment(method: GenericFunctionTemplate) { - const [open, prefix, close] = ["/**", " * ", " */"]; - const chunks = map(chunk(split(method.description, " "), 9), (c) => - join(c, " ") - ); - return join( - [ - open, - ...map(chunks, (c) => `${prefix}${c}`), - `${prefix}${makeTypeString(method)}`, - close, - ], - "\n" - ); -} - -function makeBody({ - name, - params, - defaultReturnValue, -}: GenericFunctionTemplate) { - return join( - [ - `function ${name}(${join(map(params, "name"), ", ")}) {`, - ` return ${JSON.stringify(defaultReturnValue)};`, - `}`, - ], - "\n" - ); -} - -export function makeTemplate(methods?: GenericFunctionTemplate[]) { - return join( - map(methods, (m) => join([makeComment(m), makeBody(m)], "\n")), - "\n\n" - ); +type GenericFunctionTemplate = FunctionTemplate<[...any], any>; + +function makeTypeString({ returnType, params }: GenericFunctionTemplate) { + return `@type {(${join( + map(params, (p) => `${p.name}: ${p.type}`), + ", " + )}) => ${returnType}}`; +} + +function makeComment(method: GenericFunctionTemplate) { + const [open, prefix, close] = ["/**", " * ", " */"]; + const chunks = map(chunk(split(method.description, " "), 9), (c) => + join(c, " ") + ); + return join( + [ + open, + ...map(chunks, (c) => `${prefix}${c}`), + `${prefix}${makeTypeString(method)}`, + close, + ], + "\n" + ); +} + +function makeBody({ + name, + params, + defaultReturnValue, +}: GenericFunctionTemplate) { + return join( + [ + `function ${name}(${join(map(params, "name"), ", ")}) {`, + ` return ${JSON.stringify(defaultReturnValue)};`, + `}`, + ], + "\n" + ); +} + +export function makeTemplate(methods?: GenericFunctionTemplate[]) { + return join( + map(methods, (m) => join([makeComment(m), makeBody(m)], "\n")), + "\n\n" + ); } \ No newline at end of file diff --git a/client/src/components/script-editor/templates.tsx b/client/src/components/script-editor/templates.tsx index 38f6bf9..548ff80 100644 --- a/client/src/components/script-editor/templates.tsx +++ b/client/src/components/script-editor/templates.tsx @@ -1,27 +1,27 @@ -import { TraceEvent } from "protocol/Trace"; -import { FunctionTemplate } from "./FunctionTemplate"; -import { EventTree } from "pages/tree.worker"; - -export type ShouldBreak = FunctionTemplate< - [number, TraceEvent, TraceEvent[], EventTree | void, EventTree[] | void], - boolean ->; - -export const shouldBreak: ShouldBreak = { - name: "shouldBreak", - description: - "Define in what situations the debugger should break, in addition to the conditions defined in the standard options.", - params: [ - { name: "step", type: "number" }, - { name: "event", type: "any" }, - { name: "events", type: "any" }, - { name: "parent", type: "any" }, - { name: "children", type: "any" }, - ], - defaultReturnValue: false, - returnType: "boolean", -}; - -export const templates = { - shouldBreak, -}; +import { TraceEvent } from "protocol/Trace"; +import { FunctionTemplate } from "./FunctionTemplate"; +import { EventTree } from "pages/tree.worker"; + +export type ShouldBreak = FunctionTemplate< + [number, TraceEvent, TraceEvent[], EventTree | void, EventTree[] | void], + boolean +>; + +export const shouldBreak: ShouldBreak = { + name: "shouldBreak", + description: + "Define in what situations the debugger should break, in addition to the conditions defined in the standard options.", + params: [ + { name: "step", type: "number" }, + { name: "event", type: "any" }, + { name: "events", type: "any" }, + { name: "parent", type: "any" }, + { name: "children", type: "any" }, + ], + defaultReturnValue: false, + returnType: "boolean", +}; + +export const templates = { + shouldBreak, +}; diff --git a/client/src/components/title-bar/ExportWorkspaceModal.tsx b/client/src/components/title-bar/ExportWorkspaceModal.tsx index 8848be2..986eab5 100644 --- a/client/src/components/title-bar/ExportWorkspaceModal.tsx +++ b/client/src/components/title-bar/ExportWorkspaceModal.tsx @@ -14,7 +14,7 @@ import { ComponentProps, useMemo } from "react"; import { WorkspaceMeta, useUIState } from "slices/UIState"; import { useLoadingState } from "slices/loading"; import { textFieldProps, usePaper } from "theme"; -import { Jimp } from "utils/Jimp"; +import { Jimp, ResizeStrategy } from "jimp"; import { set } from "utils/set"; import { Gallery } from "./Gallery"; @@ -44,17 +44,17 @@ const imageSize = 64; async function resizeImage(s: string) { const a = await Jimp.read(Buffer.from(s.split(",")[1], "base64")); const b = - a.getWidth() < a.getHeight() - ? a.resize(imageSize, Jimp.AUTO) - : a.resize(Jimp.AUTO, imageSize); + a.width < a.height + ? a.resize({ w: imageSize }) + : a.resize({ h: imageSize }); return await b - .crop( - (b.getWidth() - imageSize) / 2, - (b.getHeight() - imageSize) / 2, - imageSize, - imageSize - ) - .getBase64Async("image/jpeg"); + .crop({ + x: (b.width - imageSize) / 2, + y: (b.height - imageSize) / 2, + w: imageSize, + h: imageSize, + }) + .getBase64("image/jpeg"); } export function A() { diff --git a/client/src/components/title-bar/TitleBar.tsx b/client/src/components/title-bar/TitleBar.tsx index 0f84f5e..487a4b9 100644 --- a/client/src/components/title-bar/TitleBar.tsx +++ b/client/src/components/title-bar/TitleBar.tsx @@ -183,197 +183,195 @@ export const TitleBar = () => { }); } - return ( - <> - `1px solid ${t.palette.background.default}`, - minHeight: 36, - paddingLeft: "env(titlebar-area-x, 0px)", - height: visible ? "env(titlebar-area-height, 50px)" : 0, - width: "env(titlebar-area-width, 100%)", - WebkitAppRegion: "drag", - overflowX: "auto", - }} - > - - - - {(!visible || rect.x === 0) && ( - // Hide for macos style windows - - - - )} - {} - {[ - { - key: "view", - items: [ - { - disabled: !canOpenWindows, - key: "panel-new-window", - type: "action", - name: "New window", - action: () => openWindow(), - }, - { type: "divider" }, - { - type: "action", - key: `panel-new-right`, - name: "Add view to the right", - action: () => handleOpenPanel("horizontal"), - }, - { - type: "action", - key: `panel-new-bottom`, - name: "Add view below", - action: () => handleOpenPanel("vertical"), - }, - { type: "divider" }, - { - type: "action", - name: "Reset layout", - key: "panel-reset", - action: () => setView(getDefaultViewTree), - }, - { - type: "action", - name: "Reload window", - key: "panel-reload", - action: () => location.reload(), - }, - // { - // type: "action", - // name: "New workspace", - // action: () => - // openWindow({ linked: false, minimal: false }), - // }, - ], - }, - { - key: "workspace", - items: [ - { - type: "action", - name: "Open workspace", - key: "workspace-load", - action: load, - }, - { - type: "action", - name: "Save workspace", - key: "workspace-save", - action: save, - }, - { type: "divider" }, - { - type: "action", - name: ( - } - /> - ), - key: "workspace-save-metadata", - action: () => setExportModalOpen(true), - }, - ], - }, - { - key: "help", - items: [ - { - type: "action", - name: "Open repository in GitHub", - key: "github", - action: () => open(repository, "_blank"), - }, - { - type: "action", - name: "Changelog", - key: "changelog", - action: () => open(`${changelog}/${version}`, "_blank"), - }, - { - type: "action", - name: "Documentation", - key: "documentation", - action: () => open(docs, "_blank"), - }, - ], - }, - ].map(({ key, items }) => ( - - {(state) => ( - <> - - - {items.map((item, i) => { - if (item.type === "action") { - const { name, key, action } = item; - return ( - { - action?.(); - state.close(); - }} - > - {name} - - ); - } else { - return ; - } - })} - - - - {startCase(key)} - - - )} - - ))} - {/* - - */} - - - - - setExportModalOpen(false)} - /> - - ); + return (<> + `1px solid ${t.palette.background.default}`, + minHeight: 36, + paddingLeft: "env(titlebar-area-x, 0px)", + height: visible ? "env(titlebar-area-height, 50px)" : 0, + width: "env(titlebar-area-width, 100%)", + WebkitAppRegion: "drag", + overflowX: "auto", + }} + > + + + + {(!visible || rect.x === 0) && ( + // Hide for macos style windows + ( + + ) + )} + {} + {[ + { + key: "view", + items: [ + { + disabled: !canOpenWindows, + key: "panel-new-window", + type: "action", + name: "New window", + action: () => openWindow(), + }, + { type: "divider" }, + { + type: "action", + key: `panel-new-right`, + name: "Add view to the right", + action: () => handleOpenPanel("horizontal"), + }, + { + type: "action", + key: `panel-new-bottom`, + name: "Add view below", + action: () => handleOpenPanel("vertical"), + }, + { type: "divider" }, + { + type: "action", + name: "Reset layout", + key: "panel-reset", + action: () => setView(getDefaultViewTree), + }, + { + type: "action", + name: "Reload window", + key: "panel-reload", + action: () => location.reload(), + }, + // { + // type: "action", + // name: "New workspace", + // action: () => + // openWindow({ linked: false, minimal: false }), + // }, + ], + }, + { + key: "workspace", + items: [ + { + type: "action", + name: "Open workspace", + key: "workspace-load", + action: load, + }, + { + type: "action", + name: "Save workspace", + key: "workspace-save", + action: save, + }, + { type: "divider" }, + { + type: "action", + name: ( + } + /> + ), + key: "workspace-save-metadata", + action: () => setExportModalOpen(true), + }, + ], + }, + { + key: "help", + items: [ + { + type: "action", + name: "Open repository in GitHub", + key: "github", + action: () => open(repository, "_blank"), + }, + { + type: "action", + name: "Changelog", + key: "changelog", + action: () => open(`${changelog}/${version}`, "_blank"), + }, + { + type: "action", + name: "Documentation", + key: "documentation", + action: () => open(docs, "_blank"), + }, + ], + }, + ].map(({ key, items }) => ( + + {(state) => ( + <> + + + {items.map((item, i) => { + if (item.type === "action") { + const { name, key, action } = item; + return ( + { + action?.(); + state.close(); + }} + > + {name} + + ); + } else { + return ; + } + })} + + + + {startCase(key)} + + + )} + + ))} + {/* + + */} + + + + + setExportModalOpen(false)} + /> + ); }; export function CommandsButton() { diff --git a/client/src/global.d.ts b/client/src/global.d.ts index cc761f1..c7d7e70 100644 --- a/client/src/global.d.ts +++ b/client/src/global.d.ts @@ -1,26 +1,26 @@ -declare module "*?worker&url" { - const src: string; - export default src; -} - -declare interface Navigator { - windowControlsOverlay: WindowControlsOverlay; -} - -declare interface WindowControlsOverlay extends EventTarget { - visible: boolean; - getTitlebarAreaRect(): DOMRect; -} - -declare interface WindowControlsOverlayGeometryChangeEvent extends Event { - titlebarAreaRect?: DOMRect; - visible?: boolean; -} - -declare module "nearest-pantone" { - export function getClosestColor(hex: string): { - pantone: string; - name: string; - hex: string; - }; -} +declare module "*?worker&url" { + const src: string; + export default src; +} + +declare interface Navigator { + windowControlsOverlay: WindowControlsOverlay; +} + +declare interface WindowControlsOverlay extends EventTarget { + visible: boolean; + getTitlebarAreaRect(): DOMRect; +} + +declare interface WindowControlsOverlayGeometryChangeEvent extends Event { + titlebarAreaRect?: DOMRect; + visible?: boolean; +} + +declare module "nearest-pantone" { + export function getClosestColor(hex: string): { + pantone: string; + name: string; + hex: string; + }; +} diff --git a/client/src/hooks/useBreakpoints.tsx b/client/src/hooks/useBreakpoints.tsx index 88cf4f3..a09480b 100644 --- a/client/src/hooks/useBreakpoints.tsx +++ b/client/src/hooks/useBreakpoints.tsx @@ -1,135 +1,135 @@ -import { useUntrustedLayers } from "components/inspector/useUntrustedLayers"; -import { call } from "components/script-editor/call"; -import { get, toLower as lower, startCase } from "lodash"; -import memo from "memoizee"; -import { useTreeMemo } from "pages/TreeWorkerLegacy"; -import { EventTree } from "pages/treeLegacy.worker"; -import { TraceEvent, TraceEventType } from "protocol"; -import { useMemo } from "react"; -import { UploadedTrace } from "slices/UIState"; -import { useLayer } from "slices/layers"; - -type ApplyOptions = { - value: number; - reference: number; - step: number; - events: TraceEvent[]; - event: TraceEvent; - node: EventTree; - type?: TraceEventType; - property: string; -}; - -export type Comparator = { - key: string; - apply: (options: ApplyOptions) => boolean; - needsReference?: boolean; -}; - -export type Breakpoint = { - key: string; - property?: string; - reference?: number; - condition?: Comparator; - active?: boolean; - type?: TraceEventType; -}; - -export type DebugLayerData = { - code?: string; - monotonicF?: boolean; - monotonicG?: boolean; - breakpoints?: Breakpoint[]; - trace?: UploadedTrace; -}; - -export function useBreakpoints(key?: string) { - const { layer } = useLayer(key); - const { isTrusted } = useUntrustedLayers(); - const { monotonicF, monotonicG, breakpoints, code, trace } = - layer?.source ?? {}; - const content = trace?.content; - const { result } = useTreeMemo( - { - trace: content, - step: content?.events?.length, - radius: undefined, - }, - [content] - ); - - return useMemo(() => { - const events = content?.events ?? []; // the actual trace array - const trees = treeToDict(result?.tree ?? []); - return memo((step: number) => { - const event = events[step]; - if (event) { - try { - // Check breakpoints in the breakpoints section - for (const { - active, - condition, - type, - property = "", - reference = 0, - } of breakpoints ?? []) { - const isType = !type || type === event.type; - - const match = () => - condition?.apply?.({ - type, - event: event, - property, - value: get(event, property), - reference, - step, - events, - node: trees[step], - }); - if (active && isType && match()) { - return condition?.needsReference - ? { - result: `${property} ${lower( - startCase(condition?.key) - )} ${reference}`, - } - : { - result: `${property} ${lower(startCase(condition?.key))}`, - }; - } - } - // Check breakpoints in the script editor section - if ( - isTrusted && - call(code ?? "", "shouldBreak", [ - step, - event, - events, - trees[step]?.parent, - trees[step]?.children ?? [], - ]) - ) { - return { result: "Script editor" }; - } - } catch (e) { - return { error: `${e}` }; - } - } - return { result: "" }; - }); - }, [isTrusted, code, content, breakpoints, monotonicF, monotonicG, result]); -} - -type TreeDict = { - [K in number]: EventTree; -}; - -function treeToDict(trees: EventTree[] = [], dict: TreeDict = {}) { - for (const tree of trees) { - for (const event of tree.events) { - dict[event.step] = tree; - } - treeToDict(tree.children, dict); - } - return dict; -} +import { useUntrustedLayers } from "components/inspector/useUntrustedLayers"; +import { call } from "components/script-editor/call"; +import { get, toLower as lower, startCase } from "lodash"; +import memo from "memoizee"; +import { useTreeMemo } from "pages/TreeWorkerLegacy"; +import { EventTree } from "pages/treeLegacy.worker"; +import { TraceEvent, TraceEventType } from "protocol"; +import { useMemo } from "react"; +import { UploadedTrace } from "slices/UIState"; +import { useLayer } from "slices/layers"; + +type ApplyOptions = { + value: number; + reference: number; + step: number; + events: TraceEvent[]; + event: TraceEvent; + node: EventTree; + type?: TraceEventType; + property: string; +}; + +export type Comparator = { + key: string; + apply: (options: ApplyOptions) => boolean; + needsReference?: boolean; +}; + +export type Breakpoint = { + key: string; + property?: string; + reference?: number; + condition?: Comparator; + active?: boolean; + type?: TraceEventType; +}; + +export type DebugLayerData = { + code?: string; + monotonicF?: boolean; + monotonicG?: boolean; + breakpoints?: Breakpoint[]; + trace?: UploadedTrace; +}; + +export function useBreakpoints(key?: string) { + const { layer } = useLayer(key); + const { isTrusted } = useUntrustedLayers(); + const { monotonicF, monotonicG, breakpoints, code, trace } = + layer?.source ?? {}; + const content = trace?.content; + const { result } = useTreeMemo( + { + trace: content, + step: content?.events?.length, + radius: undefined, + }, + [content] + ); + + return useMemo(() => { + const events = content?.events ?? []; // the actual trace array + const trees = treeToDict(result?.tree ?? []); + return memo((step: number) => { + const event = events[step]; + if (event) { + try { + // Check breakpoints in the breakpoints section + for (const { + active, + condition, + type, + property = "", + reference = 0, + } of breakpoints ?? []) { + const isType = !type || type === event.type; + + const match = () => + condition?.apply?.({ + type, + event: event, + property, + value: get(event, property), + reference, + step, + events, + node: trees[step], + }); + if (active && isType && match()) { + return condition?.needsReference + ? { + result: `${property} ${lower( + startCase(condition?.key) + )} ${reference}`, + } + : { + result: `${property} ${lower(startCase(condition?.key))}`, + }; + } + } + // Check breakpoints in the script editor section + if ( + isTrusted && + call(code ?? "", "shouldBreak", [ + step, + event, + events, + trees[step]?.parent, + trees[step]?.children ?? [], + ]) + ) { + return { result: "Script editor" }; + } + } catch (e) { + return { error: `${e}` }; + } + } + return { result: "" }; + }); + }, [isTrusted, code, content, breakpoints, monotonicF, monotonicG, result]); +} + +type TreeDict = { + [K in number]: EventTree; +}; + +function treeToDict(trees: EventTree[] = [], dict: TreeDict = {}) { + for (const tree of trees) { + for (const event of tree.events) { + dict[event.step] = tree; + } + treeToDict(tree.children, dict); + } + return dict; +} diff --git a/client/src/hooks/usePlaybackState.tsx b/client/src/hooks/usePlaybackState.tsx index 828290e..5e1bbfe 100644 --- a/client/src/hooks/usePlaybackState.tsx +++ b/client/src/hooks/usePlaybackState.tsx @@ -1,115 +1,115 @@ -import { PlaybackLayerData } from "components/app-bar/Playback"; -import { useSnackbar } from "components/generic/Snackbar"; -import { clamp, min, range, set, trimEnd } from "lodash"; -import { produce } from "produce"; -import { useEffect, useMemo } from "react"; -import { useLayer } from "slices/layers"; -import { useBreakpoints } from "./useBreakpoints"; - -function cancellable(f: () => Promise, g: (result: T) => void) { - let cancelled = false; - requestAnimationFrame(async () => { - const result = await f(); - if (!cancelled) g(result); - }); - return () => { - cancelled = true; - }; -} - -export function usePlaybackState(key?: string) { - const { layer, setLayer, setKey } = useLayer(key); - const notify = useSnackbar(); - const shouldBreak = useBreakpoints(key); - - useEffect(() => { - if (key) setKey(key); - }, [key]); - - const { playback, playbackTo, step: _step = 0 } = layer?.source ?? {}; - - const step = min([playbackTo, _step]) ?? 0; - - const ready = !!playbackTo; - const playing = playback === "playing"; - const [start, end] = [0, (playbackTo ?? 1) - 1]; - - return useMemo(() => { - function setPlaybackState(s: Partial) { - setLayer( - produce(layer, (l) => set(l!, "source", { ...l?.source, ...s }))! - ); - } - const state = { - start, - end, - step, - canPlay: ready && !playing && step < end, - canPause: ready && playing, - canStop: ready && step, - canStepForward: ready && !playing && step < end, - canStepBackward: ready && !playing && step > 0, - }; - - const pause = (n = 0) => { - // notify("Playback paused"); - setPlaybackState({ playback: "paused", step: stepBy(n) }); - }; - - const tick = (n = 1) => - setPlaybackState({ playback: "playing", step: stepBy(n) }); - - const stepWithBreakpointCheck = (count: number, offset: number = 0) => - cancellable( - async () => { - for (const i of range(offset, count)) { - const r = shouldBreak(step + i); - if (r.result || r.error) return { ...r, offset: i }; - } - return { result: "", offset: 0, error: undefined }; - }, - ({ result, offset, error }) => { - if (!error) { - if (result) { - notify(`Breakpoint hit: ${result}`, `Step ${step + offset}`); - pause(offset); - } else tick(count); - } else { - notify(`${trimEnd(error, ".")}`, `Step ${step + offset}`); - pause(); - } - } - ); - - const findBreakpoint = (direction: 1 | -1 = 1) => { - let i; - for (i = step + direction; i <= end && i >= 0; i += direction) { - if (shouldBreak(i)?.result) break; - } - return i; - }; - - const stepBy = (n: number) => clamp(step + n, start, end); - - const callbacks = { - play: () => { - // notify("Playback started"); - setPlaybackState({ playback: "playing", step: stepBy(1) }); - }, - pause, - stepTo: (n = 0) => setPlaybackState({ step: clamp(n, start, end) }), - stop: () => setPlaybackState({ step: start, playback: "paused" }), - stepForward: () => setPlaybackState({ step: stepBy(1) }), - stepBackward: () => setPlaybackState({ step: stepBy(-1) }), - tick, - findBreakpoint, - stepWithBreakpointCheck, - }; - - return { - playing: playback === "playing", - ...state, - ...callbacks, - }; - }, [end, playback, playing, ready, start, step, setLayer]); -} +import { PlaybackLayerData } from "components/app-bar/Playback"; +import { useSnackbar } from "components/generic/Snackbar"; +import { clamp, min, range, set, trimEnd } from "lodash"; +import { produce } from "produce"; +import { useEffect, useMemo } from "react"; +import { useLayer } from "slices/layers"; +import { useBreakpoints } from "./useBreakpoints"; + +function cancellable(f: () => Promise, g: (result: T) => void) { + let cancelled = false; + requestAnimationFrame(async () => { + const result = await f(); + if (!cancelled) g(result); + }); + return () => { + cancelled = true; + }; +} + +export function usePlaybackState(key?: string) { + const { layer, setLayer, setKey } = useLayer(key); + const notify = useSnackbar(); + const shouldBreak = useBreakpoints(key); + + useEffect(() => { + if (key) setKey(key); + }, [key]); + + const { playback, playbackTo, step: _step = 0 } = layer?.source ?? {}; + + const step = min([playbackTo, _step]) ?? 0; + + const ready = !!playbackTo; + const playing = playback === "playing"; + const [start, end] = [0, (playbackTo ?? 1) - 1]; + + return useMemo(() => { + function setPlaybackState(s: Partial) { + setLayer( + produce(layer, (l) => set(l!, "source", { ...l?.source, ...s }))! + ); + } + const state = { + start, + end, + step, + canPlay: ready && !playing && step < end, + canPause: ready && playing, + canStop: ready && step, + canStepForward: ready && !playing && step < end, + canStepBackward: ready && !playing && step > 0, + }; + + const pause = (n = 0) => { + // notify("Playback paused"); + setPlaybackState({ playback: "paused", step: stepBy(n) }); + }; + + const tick = (n = 1) => + setPlaybackState({ playback: "playing", step: stepBy(n) }); + + const stepWithBreakpointCheck = (count: number, offset: number = 0) => + cancellable( + async () => { + for (const i of range(offset, count)) { + const r = shouldBreak(step + i); + if (r.result || r.error) return { ...r, offset: i }; + } + return { result: "", offset: 0, error: undefined }; + }, + ({ result, offset, error }) => { + if (!error) { + if (result) { + notify(`Breakpoint hit: ${result}`, `Step ${step + offset}`); + pause(offset); + } else tick(count); + } else { + notify(`${trimEnd(error, ".")}`, `Step ${step + offset}`); + pause(); + } + } + ); + + const findBreakpoint = (direction: 1 | -1 = 1) => { + let i; + for (i = step + direction; i <= end && i >= 0; i += direction) { + if (shouldBreak(i)?.result) break; + } + return i; + }; + + const stepBy = (n: number) => clamp(step + n, start, end); + + const callbacks = { + play: () => { + // notify("Playback started"); + setPlaybackState({ playback: "playing", step: stepBy(1) }); + }, + pause, + stepTo: (n = 0) => setPlaybackState({ step: clamp(n, start, end) }), + stop: () => setPlaybackState({ step: start, playback: "paused" }), + stepForward: () => setPlaybackState({ step: stepBy(1) }), + stepBackward: () => setPlaybackState({ step: stepBy(-1) }), + tick, + findBreakpoint, + stepWithBreakpointCheck, + }; + + return { + playing: playback === "playing", + ...state, + ...callbacks, + }; + }, [end, playback, playing, ready, start, step, setLayer]); +} diff --git a/client/src/hooks/useScrollState.tsx b/client/src/hooks/useScrollState.tsx index 84119b8..77c4de2 100644 --- a/client/src/hooks/useScrollState.tsx +++ b/client/src/hooks/useScrollState.tsx @@ -1,47 +1,47 @@ import { useEffect, useRef, useState } from "react"; -export function useScrollState(threshold: number = 128) { - const [showControls, setShowControls] = useState(true); - const [isAbsoluteTop, setIsAbsoluteTop] = useState(true); - const [isTop, setIsTop] = useState(true); - const [target, setTarget] = useState(null); - const lastTop = useRef(0); - useEffect(() => { - if (target) { - const listener = () => { - { - const newIsTop = target.scrollTop <= threshold; - if (newIsTop !== isTop) { - setIsTop(newIsTop); - } - } - { - const newIsTop = target.scrollTop <= 1; - if (newIsTop !== isAbsoluteTop) { - setIsAbsoluteTop(newIsTop); - } - } - if (lastTop.current - target.scrollTop) { - if ( - Math.abs(lastTop.current - target.scrollTop) > 2 && - lastTop.current >= 0 - ) { - setShowControls(lastTop.current > target.scrollTop); - } - lastTop.current = target.scrollTop; - } - }; - target.addEventListener("scroll", listener, { passive: true }); - return () => { - target.removeEventListener("scroll", listener); - }; - } - }, [target, isTop, isAbsoluteTop, lastTop, threshold]); - return [ - showControls || isTop, - isTop, - isAbsoluteTop, - target, - setTarget, - ] as const; +export function useScrollState(threshold: number = 128) { + const [showControls, setShowControls] = useState(true); + const [isAbsoluteTop, setIsAbsoluteTop] = useState(true); + const [isTop, setIsTop] = useState(true); + const [target, setTarget] = useState(null); + const lastTop = useRef(0); + useEffect(() => { + if (target) { + const listener = () => { + { + const newIsTop = target.scrollTop <= threshold; + if (newIsTop !== isTop) { + setIsTop(newIsTop); + } + } + { + const newIsTop = target.scrollTop <= 1; + if (newIsTop !== isAbsoluteTop) { + setIsAbsoluteTop(newIsTop); + } + } + if (lastTop.current - target.scrollTop) { + if ( + Math.abs(lastTop.current - target.scrollTop) > 2 && + lastTop.current >= 0 + ) { + setShowControls(lastTop.current > target.scrollTop); + } + lastTop.current = target.scrollTop; + } + }; + target.addEventListener("scroll", listener, { passive: true }); + return () => { + target.removeEventListener("scroll", listener); + }; + } + }, [target, isTop, isAbsoluteTop, lastTop, threshold]); + return [ + showControls || isTop, + isTop, + isAbsoluteTop, + target, + setTarget, + ] as const; } \ No newline at end of file diff --git a/client/src/hooks/useSmallDisplay.tsx b/client/src/hooks/useSmallDisplay.tsx index 0f830ef..9768bfc 100644 --- a/client/src/hooks/useSmallDisplay.tsx +++ b/client/src/hooks/useSmallDisplay.tsx @@ -1,6 +1,6 @@ import { useMediaQuery, useTheme } from "@mui/material"; -export function useSmallDisplay() { - const theme = useTheme(); - return useMediaQuery(theme.breakpoints.down("sm")); +export function useSmallDisplay() { + const theme = useTheme(); + return useMediaQuery(theme.breakpoints.down("sm")); } \ No newline at end of file diff --git a/client/src/index.tsx b/client/src/index.tsx index a4370e7..09bdda6 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -1,39 +1,39 @@ -import "./requestIdleCallbackPolyfill"; -import App from "App"; -import "index.css"; -import "overlayscrollbars/overlayscrollbars.css"; -import { createRoot } from "react-dom/client"; -import { SliceProvider as EnvironmentProvider } from "slices/SliceProvider"; -import { UIStateProvider } from "slices/UIState"; -import { BusyProvider } from "slices/busy"; -import { ConnectionsProvider } from "slices/connections"; -import { FeaturesProvider } from "slices/features"; -import { LayersProvider } from "slices/layers"; -import { LoadingProvider } from "slices/loading"; -import { LogProvider } from "slices/log"; -import { RendererProvider } from "slices/renderers"; -import { ScreenshotsProvider } from "slices/screenshots"; -import { SettingsProvider } from "slices/settings"; -import { ViewProvider } from "slices/view"; - -const root = createRoot(document.getElementById("root")!); - -const slices = [ - BusyProvider, - SettingsProvider, - ConnectionsProvider, - FeaturesProvider, - UIStateProvider, - LoadingProvider, - RendererProvider, - LogProvider, - ViewProvider, - LayersProvider, - ScreenshotsProvider, -]; - -root.render( - - - -); +import "./requestIdleCallbackPolyfill"; +import App from "App"; +import "index.css"; +import "overlayscrollbars/overlayscrollbars.css"; +import { createRoot } from "react-dom/client"; +import { SliceProvider as EnvironmentProvider } from "slices/SliceProvider"; +import { UIStateProvider } from "slices/UIState"; +import { BusyProvider } from "slices/busy"; +import { ConnectionsProvider } from "slices/connections"; +import { FeaturesProvider } from "slices/features"; +import { LayersProvider } from "slices/layers"; +import { LoadingProvider } from "slices/loading"; +import { LogProvider } from "slices/log"; +import { RendererProvider } from "slices/renderers"; +import { ScreenshotsProvider } from "slices/screenshots"; +import { SettingsProvider } from "slices/settings"; +import { ViewProvider } from "slices/view"; + +const root = createRoot(document.getElementById("root")!); + +const slices = [ + BusyProvider, + SettingsProvider, + ConnectionsProvider, + FeaturesProvider, + UIStateProvider, + LoadingProvider, + RendererProvider, + LogProvider, + ViewProvider, + LayersProvider, + ScreenshotsProvider, +]; + +root.render( + + + +); diff --git a/client/src/layers/map/index.tsx b/client/src/layers/map/index.tsx index f480834..2274c3a 100644 --- a/client/src/layers/map/index.tsx +++ b/client/src/layers/map/index.tsx @@ -94,7 +94,7 @@ export const controller = { t.palette.error.main} + color="error" sx={{ whiteSpace: "pre-wrap", mb: 1, diff --git a/client/src/layers/trace/index.tsx b/client/src/layers/trace/index.tsx index cab2eeb..e2a8592 100644 --- a/client/src/layers/trace/index.tsx +++ b/client/src/layers/trace/index.tsx @@ -211,7 +211,7 @@ export const controller = { t.palette.error.main} + color="error" sx={{ whiteSpace: "pre-wrap", mb: 1, @@ -225,7 +225,7 @@ export const controller = { t.palette.error.main} + color="error" sx={{ whiteSpace: "pre-wrap", mb: 1, diff --git a/client/src/pages/ExplorePage.tsx b/client/src/pages/ExplorePage.tsx index 084f17a..58a5a0f 100644 --- a/client/src/pages/ExplorePage.tsx +++ b/client/src/pages/ExplorePage.tsx @@ -373,7 +373,7 @@ export function ExplorePage({ template: Page }: PageContentProps) { } return ( - + ( Explore explore @@ -427,15 +427,17 @@ export function ExplorePage({ template: Page }: PageContentProps) { hiddenLabel fullWidth sx={{ maxWidth: 480 }} - InputProps={{ - startAdornment: ( - - - - ), - }} onChange={(e) => setSearch(e.target.value)} placeholder="Search examples" + slotProps={{ + input: { + startAdornment: ( + + + + ), + } + }} />
- + ) ); } diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index ad2110f..4a5f772 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -331,7 +331,14 @@ export function TrustedOriginListEditor() { [trustedOrigins] ); return ( - + // + // {keys(mapParsers).map((c) => ( + // + // + // + // ))} + // + ( - + ) + ); +} +export function MapParserListEditor() { + return ( // // {keys(mapParsers).map((c) => ( // @@ -363,11 +374,7 @@ export function TrustedOriginListEditor() { // // ))} // - ); -} -export function MapParserListEditor() { - return ( - + ( button={false} sortable @@ -388,13 +395,6 @@ export function MapParserListEditor() { key: "", })} /> - - // - // {keys(mapParsers).map((c) => ( - // - // - // - // ))} - // + ) ); } diff --git a/client/src/public/manifest.json b/client/src/public/manifest.json index 0970ea9..adfdb3c 100644 --- a/client/src/public/manifest.json +++ b/client/src/public/manifest.json @@ -1,9 +1,9 @@ { "short_name": "Posthoc", "name": "Posthoc", - "version": "1.2.5-2", + "version": "1.2.5-4", "description": "Understand sequential decision-making through visualisation.", - "version_name": "1.2.5-2; early August 2024", + "version_name": "1.2.5-4; mid November 2024", "repository": "https://github.com/ShortestPathLab/posthoc-app", "changelog": "http://posthoc.pathfinding.ai/blog", "docs": "https://posthoc.pathfinding.ai/docs/overview", diff --git a/client/src/services/ConnectionsService.tsx b/client/src/services/ConnectionsService.tsx index a871f95..b4b8ba2 100644 --- a/client/src/services/ConnectionsService.tsx +++ b/client/src/services/ConnectionsService.tsx @@ -1,53 +1,53 @@ -import { getTransport } from "client"; -import { useSnackbar } from "components/generic/Snackbar"; -import { useEffect } from "react"; -import { Connection, useConnections } from "slices/connections"; -import { useLoadingState } from "slices/loading"; -import { useSettings } from "slices/settings"; -import { timed } from "utils/timed"; - -export function ConnectionsService() { - const notify = useSnackbar(); - const [{ remote }] = useSettings(); - const [, setConnections] = useConnections(); - const usingLoadingState = useLoadingState("connections"); - - useEffect(() => { - let aborted = false; - let cs: Connection[] = []; - usingLoadingState(async () => { - if (remote?.length) { - for (const { transport: t, url, disabled } of remote) { - // Truthy value includes undefined - if (disabled !== true) { - notify(`Connecting to ${url}...`); - const tp = new (getTransport(t))({ url }); - await tp.connect(); - const { result, delta } = await timed(() => tp.call("about")); - if (result) { - notify(`Connected to ${result.name}`); - cs = [ - ...cs, - { - ...result, - url, - ping: delta, - transport: () => tp, - }, - ]; - } else await tp.disconnect(); - } - if (!aborted) setConnections(() => cs); - } - if (!aborted) - notify(`Connected to ${cs.length} of ${remote.length} solvers`); - } - }); - return () => { - aborted = true; - cs.map((c) => c.transport().disconnect()); - }; - }, [JSON.stringify(remote), setConnections, notify, usingLoadingState]); - - return <>; -} +import { getTransport } from "client"; +import { useSnackbar } from "components/generic/Snackbar"; +import { useEffect } from "react"; +import { Connection, useConnections } from "slices/connections"; +import { useLoadingState } from "slices/loading"; +import { useSettings } from "slices/settings"; +import { timed } from "utils/timed"; + +export function ConnectionsService() { + const notify = useSnackbar(); + const [{ remote }] = useSettings(); + const [, setConnections] = useConnections(); + const usingLoadingState = useLoadingState("connections"); + + useEffect(() => { + let aborted = false; + let cs: Connection[] = []; + usingLoadingState(async () => { + if (remote?.length) { + for (const { transport: t, url, disabled } of remote) { + // Truthy value includes undefined + if (disabled !== true) { + notify(`Connecting to ${url}...`); + const tp = new (getTransport(t))({ url }); + await tp.connect(); + const { result, delta } = await timed(() => tp.call("about")); + if (result) { + notify(`Connected to ${result.name}`); + cs = [ + ...cs, + { + ...result, + url, + ping: delta, + transport: () => tp, + }, + ]; + } else await tp.disconnect(); + } + if (!aborted) setConnections(() => cs); + } + if (!aborted) + notify(`Connected to ${cs.length} of ${remote.length} solvers`); + } + }); + return () => { + aborted = true; + cs.map((c) => c.transport().disconnect()); + }; + }, [JSON.stringify(remote), setConnections, notify, usingLoadingState]); + + return <>; +} diff --git a/client/src/services/RendererService.tsx b/client/src/services/RendererService.tsx index 2fdc6f1..01260b3 100644 --- a/client/src/services/RendererService.tsx +++ b/client/src/services/RendererService.tsx @@ -1,59 +1,59 @@ -import renderers from "internal-renderers"; -import { Dictionary } from "lodash"; -import { useAsync } from "react-async-hook"; -import { RendererDefinition } from "renderer"; -import url from "url-parse"; -import { Renderer, useRenderers } from "slices/renderers"; -import { useSettings } from "slices/settings"; - -type RendererTransportOptions = { url: string }; - -interface RendererTransport { - get(): Promise>; -} - -export type RendererTransportConstructor = new ( - options: RendererTransportOptions -) => RendererTransport; - -type RendererTransportEntry = { - name: string; - constructor: RendererTransportConstructor; -}; - -export class NativeRendererTransport implements RendererTransport { - constructor(readonly options: RendererTransportOptions) {} - async get() { - const { hostname } = url(this.options.url); - return renderers[hostname]; - } -} - -export const transports: Dictionary = { - native: { - name: "Internal", - constructor: NativeRendererTransport, - }, -}; - -export function RendererService() { - const [{ renderer }] = useSettings(); - const [, setRenderers] = useRenderers(); - - useAsync(async () => { - const rs: Renderer[] = []; - for (const { transport, url, key, disabled } of renderer ?? []) { - if (!disabled) { - const t = new transports[transport].constructor({ url }); - rs.push({ - key, - url, - renderer: await t.get(), - }); - } - } - setRenderers(() => rs); - }, [JSON.stringify(renderer), setRenderers]); - - return <>; -} +import renderers from "internal-renderers"; +import { Dictionary } from "lodash"; +import { useAsync } from "react-async-hook"; +import { RendererDefinition } from "renderer"; +import url from "url-parse"; +import { Renderer, useRenderers } from "slices/renderers"; +import { useSettings } from "slices/settings"; + +type RendererTransportOptions = { url: string }; + +interface RendererTransport { + get(): Promise>; +} + +export type RendererTransportConstructor = new ( + options: RendererTransportOptions +) => RendererTransport; + +type RendererTransportEntry = { + name: string; + constructor: RendererTransportConstructor; +}; + +export class NativeRendererTransport implements RendererTransport { + constructor(readonly options: RendererTransportOptions) {} + async get() { + const { hostname } = url(this.options.url); + return renderers[hostname]; + } +} + +export const transports: Dictionary = { + native: { + name: "Internal", + constructor: NativeRendererTransport, + }, +}; + +export function RendererService() { + const [{ renderer }] = useSettings(); + const [, setRenderers] = useRenderers(); + + useAsync(async () => { + const rs: Renderer[] = []; + for (const { transport, url, key, disabled } of renderer ?? []) { + if (!disabled) { + const t = new transports[transport].constructor({ url }); + rs.push({ + key, + url, + renderer: await t.get(), + }); + } + } + setRenderers(() => rs); + }, [JSON.stringify(renderer), setRenderers]); + + return <>; +} diff --git a/client/src/slices/SliceProvider.tsx b/client/src/slices/SliceProvider.tsx index 7b8db16..8980cf3 100644 --- a/client/src/slices/SliceProvider.tsx +++ b/client/src/slices/SliceProvider.tsx @@ -1,32 +1,32 @@ import { map, reduce } from "lodash"; -import { - cloneElement, - createElement, - FunctionComponent, - ReactNode, -} from "react"; - -type SliceProviderProps = { - slices?: FunctionComponent[]; - services?: FunctionComponent[]; - children?: ReactNode; -}; - -export function SliceProvider({ - slices, - children, - services, -}: SliceProviderProps) { - return ( - <> - {reduce( - map(slices, (s) => createElement(s)), - (prev, next) => cloneElement(next, {}, prev), - <> - {children} - {map(services, (s, i) => createElement(s, { key: i }))} - - )} - - ); +import { + cloneElement, + createElement, + FunctionComponent, + ReactNode, +} from "react"; + +type SliceProviderProps = { + slices?: FunctionComponent[]; + services?: FunctionComponent[]; + children?: ReactNode; +}; + +export function SliceProvider({ + slices, + children, + services, +}: SliceProviderProps) { + return ( + <> + {reduce( + map(slices, (s) => createElement(s)), + (prev, next) => cloneElement(next, {}, prev), + <> + {children} + {map(services, (s, i) => createElement(s, { key: i }))} + + )} + + ); } \ No newline at end of file diff --git a/client/src/slices/UIState.ts b/client/src/slices/UIState.ts index 3dbb46c..755ee55 100644 --- a/client/src/slices/UIState.ts +++ b/client/src/slices/UIState.ts @@ -1,85 +1,85 @@ -import { Feature, FeatureDescriptor } from "protocol/FeatureQuery"; -import { ParamsOf } from "protocol/Message"; -import { PathfindingTask } from "protocol/SolveTask"; -import { Trace } from "protocol/Trace"; -import { createSlice } from "./createSlice"; -import { nanoid as id } from "nanoid"; -import { pages } from "pages"; - -export type Map = Partial< - Feature & { - format: string; - source?: string; - } ->; - -type BusyState = { - busy?: { [K in string]: string }; -}; - -export type Specimen = { - specimen?: Trace; - map?: string; - error?: string; -} & Partial>; - -export type UploadedTrace = FeatureDescriptor & { - content?: Trace; - source?: string; - /** - * Uniquely identifies a trace. - * The difference between this and `id` is that `key` changes whenever - * the contents of the trace change, but `id` stays the same. - */ - key?: string; -}; - -export type TrustedState = { - isTrusted?: boolean; - origin?: string; -}; - -export type WorkspaceMeta = { - screenshots?: string[]; - size?: number; - author?: string; -} & FeatureDescriptor; - -type WorkspaceMetaState = { - workspaceMeta: WorkspaceMeta; -}; - -type SidebarState = { - sidebarOpen: boolean; -}; - -type FullscreenModalState = { - fullscreenModal?: keyof typeof pages; - depth?: number; -}; - -export type UIState = BusyState & - WorkspaceMetaState & - FullscreenModalState & - SidebarState & - TrustedState; - -export const [useUIState, UIStateProvider] = createSlice< - UIState, - Partial ->({ - sidebarOpen: false, - busy: {}, - depth: 0, - fullscreenModal: undefined, - workspaceMeta: { - id: id(), - name: "", - description: "", - screenshots: [], - author: "", - size: 0, - }, - isTrusted: false, - origin: undefined, -}); +import { Feature, FeatureDescriptor } from "protocol/FeatureQuery"; +import { ParamsOf } from "protocol/Message"; +import { PathfindingTask } from "protocol/SolveTask"; +import { Trace } from "protocol/Trace"; +import { createSlice } from "./createSlice"; +import { nanoid as id } from "nanoid"; +import { pages } from "pages"; + +export type Map = Partial< + Feature & { + format: string; + source?: string; + } +>; + +type BusyState = { + busy?: { [K in string]: string }; +}; + +export type Specimen = { + specimen?: Trace; + map?: string; + error?: string; +} & Partial>; + +export type UploadedTrace = FeatureDescriptor & { + content?: Trace; + source?: string; + /** + * Uniquely identifies a trace. + * The difference between this and `id` is that `key` changes whenever + * the contents of the trace change, but `id` stays the same. + */ + key?: string; +}; + +export type TrustedState = { + isTrusted?: boolean; + origin?: string; +}; + +export type WorkspaceMeta = { + screenshots?: string[]; + size?: number; + author?: string; +} & FeatureDescriptor; + +type WorkspaceMetaState = { + workspaceMeta: WorkspaceMeta; +}; + +type SidebarState = { + sidebarOpen: boolean; +}; + +type FullscreenModalState = { + fullscreenModal?: keyof typeof pages; + depth?: number; +}; + +export type UIState = BusyState & + WorkspaceMetaState & + FullscreenModalState & + SidebarState & + TrustedState; + +export const [useUIState, UIStateProvider] = createSlice< + UIState, + Partial +>({ + sidebarOpen: false, + busy: {}, + depth: 0, + fullscreenModal: undefined, + workspaceMeta: { + id: id(), + name: "", + description: "", + screenshots: [], + author: "", + size: 0, + }, + isTrusted: false, + origin: undefined, +}); diff --git a/client/src/slices/busy.ts b/client/src/slices/busy.ts index e6ba815..e9092de 100644 --- a/client/src/slices/busy.ts +++ b/client/src/slices/busy.ts @@ -1,38 +1,38 @@ -import { delay, isUndefined, omitBy } from "lodash"; -import { useCallback } from "react"; -import { createSlice } from "./createSlice"; -import { merge } from "./reducers"; - -export const LARGE_FILE_B = 20 * 1024 * 1024; - -type Busy = { - [K in string]?: string; -}; - -export const [useBusy, BusyProvider] = createSlice( - {}, - { reduce: (a, b) => omitBy(merge(a, b), isUndefined) } -); - -function wait(ms: number) { - return new Promise((res) => delay(res, ms)); -} - -export function useBusyState(key: string) { - const [, dispatch] = useBusy(); - - return useCallback( - async (task: () => Promise, description: string) => { - dispatch(() => ({ [key]: description })); - wait(300); - const out = await task(); - dispatch(() => ({ [key]: undefined })); - return out; - }, - [key, dispatch] - ); -} - -export function formatByte(b: number) { - return `${(b / (1024 * 1024)).toFixed(2)} MB`; -} +import { delay, isUndefined, omitBy } from "lodash"; +import { useCallback } from "react"; +import { createSlice } from "./createSlice"; +import { merge } from "./reducers"; + +export const LARGE_FILE_B = 20 * 1024 * 1024; + +type Busy = { + [K in string]?: string; +}; + +export const [useBusy, BusyProvider] = createSlice( + {}, + { reduce: (a, b) => omitBy(merge(a, b), isUndefined) } +); + +function wait(ms: number) { + return new Promise((res) => delay(res, ms)); +} + +export function useBusyState(key: string) { + const [, dispatch] = useBusy(); + + return useCallback( + async (task: () => Promise, description: string) => { + dispatch(() => ({ [key]: description })); + wait(300); + const out = await task(); + dispatch(() => ({ [key]: undefined })); + return out; + }, + [key, dispatch] + ); +} + +export function formatByte(b: number) { + return `${(b / (1024 * 1024)).toFixed(2)} MB`; +} diff --git a/client/src/slices/connections.ts b/client/src/slices/connections.ts index 350516d..093cc6b 100644 --- a/client/src/slices/connections.ts +++ b/client/src/slices/connections.ts @@ -1,15 +1,15 @@ -import { CheckConnectionResponse } from "protocol/CheckConnection"; -import { createSlice } from "./createSlice"; -import { replace } from "./reducers"; -import { Transport } from "client/Transport"; - -export type Connection = CheckConnectionResponse["result"] & { - transport: () => Transport; - url: string; - ping: number; -}; - -export const [useConnections, ConnectionsProvider] = createSlice( - [], - { reduce: replace } -); +import { CheckConnectionResponse } from "protocol/CheckConnection"; +import { createSlice } from "./createSlice"; +import { replace } from "./reducers"; +import { Transport } from "client/Transport"; + +export type Connection = CheckConnectionResponse["result"] & { + transport: () => Transport; + url: string; + ping: number; +}; + +export const [useConnections, ConnectionsProvider] = createSlice( + [], + { reduce: replace } +); diff --git a/client/src/slices/createSlice.tsx b/client/src/slices/createSlice.tsx index 30c0e79..d5f83cc 100644 --- a/client/src/slices/createSlice.tsx +++ b/client/src/slices/createSlice.tsx @@ -1,80 +1,80 @@ -import { noop } from "lodash"; -import { - ReactNode, - createContext, - useCallback, - useContext, - useMemo, - useReducer, - useState, -} from "react"; -import { useAsync, useGetSet } from "react-use"; -import { Reducer, merge } from "./reducers"; -import { nanoid } from "nanoid"; - -type Slice = [ - T, - (next: (prev: T) => U, dontCommit?: boolean) => void, - boolean, - string -]; - -type Options = { - init?: () => Promise; - effect?: (state: { prev: T; next: T }) => void; - reduce?: Reducer; -}; - -export function createSlice( - initialState: T, - { init, effect, reduce = merge }: Options = {} -) { - const Store = createContext>([ - initialState, - noop, - false, - nanoid(), - ]); - return [ - // Hook - () => useContext(Store), - // Context - ({ children }: { children?: ReactNode }) => { - const [initialised, setInitialised] = useState(false); - const [get, set] = useGetSet(initialState); - const [commit, reduceCommit] = useReducer(() => nanoid(), nanoid()); - const reduceSlice = useCallback( - (n: (prev: T) => U, c?: boolean) => { - // console.log(n); - const next = reduce(get(), n(get())); - effect?.({ prev: get(), next }); - if (!c) reduceCommit?.(); - set(next); - }, - [get, reduceCommit] - ); - const slice = useMemo>( - () => [get(), reduceSlice, initialised, commit], - [get(), reduceSlice, initialised, commit] - ); - useAsync(async () => { - const r = await init?.(); - if (r) reduceSlice(() => r); - setInitialised(true); - }); - return {children}; - }, - ] as const; -} - -export function withLocalStorage(key: string, def: T) { - return { - init: () => { - const cache = localStorage.getItem(key); - if (cache) { - return JSON.parse(cache); - } else return def; - }, - effect: ({ next }) => localStorage.setItem(key, JSON.stringify(next)), - } as Options; -} +import { noop } from "lodash"; +import { + ReactNode, + createContext, + useCallback, + useContext, + useMemo, + useReducer, + useState, +} from "react"; +import { useAsync, useGetSet } from "react-use"; +import { Reducer, merge } from "./reducers"; +import { nanoid } from "nanoid"; + +type Slice = [ + T, + (next: (prev: T) => U, dontCommit?: boolean) => void, + boolean, + string +]; + +type Options = { + init?: () => Promise; + effect?: (state: { prev: T; next: T }) => void; + reduce?: Reducer; +}; + +export function createSlice( + initialState: T, + { init, effect, reduce = merge }: Options = {} +) { + const Store = createContext>([ + initialState, + noop, + false, + nanoid(), + ]); + return [ + // Hook + () => useContext(Store), + // Context + ({ children }: { children?: ReactNode }) => { + const [initialised, setInitialised] = useState(false); + const [get, set] = useGetSet(initialState); + const [commit, reduceCommit] = useReducer(() => nanoid(), nanoid()); + const reduceSlice = useCallback( + (n: (prev: T) => U, c?: boolean) => { + // console.log(n); + const next = reduce(get(), n(get())); + effect?.({ prev: get(), next }); + if (!c) reduceCommit?.(); + set(next); + }, + [get, reduceCommit] + ); + const slice = useMemo>( + () => [get(), reduceSlice, initialised, commit], + [get(), reduceSlice, initialised, commit] + ); + useAsync(async () => { + const r = await init?.(); + if (r) reduceSlice(() => r); + setInitialised(true); + }); + return {children}; + }, + ] as const; +} + +export function withLocalStorage(key: string, def: T) { + return { + init: () => { + const cache = localStorage.getItem(key); + if (cache) { + return JSON.parse(cache); + } else return def; + }, + effect: ({ next }) => localStorage.setItem(key, JSON.stringify(next)), + } as Options; +} diff --git a/client/src/slices/features.ts b/client/src/slices/features.ts index 531e6e7..ea409db 100644 --- a/client/src/slices/features.ts +++ b/client/src/slices/features.ts @@ -1,20 +1,20 @@ -import { FeatureDescriptor } from "protocol/FeatureQuery"; -import { createSlice } from "./createSlice"; - -type FeatureDescriptorWithSource = FeatureDescriptor & { - source: string; -}; - -export type Features = { - algorithms: FeatureDescriptorWithSource[]; - maps: (FeatureDescriptorWithSource & { type: string })[]; - formats: FeatureDescriptorWithSource[]; - traces: FeatureDescriptorWithSource[]; -}; - -export const [useFeatures, FeaturesProvider] = createSlice({ - algorithms: [], - maps: [], - formats: [], - traces: [], -}); +import { FeatureDescriptor } from "protocol/FeatureQuery"; +import { createSlice } from "./createSlice"; + +type FeatureDescriptorWithSource = FeatureDescriptor & { + source: string; +}; + +export type Features = { + algorithms: FeatureDescriptorWithSource[]; + maps: (FeatureDescriptorWithSource & { type: string })[]; + formats: FeatureDescriptorWithSource[]; + traces: FeatureDescriptorWithSource[]; +}; + +export const [useFeatures, FeaturesProvider] = createSlice({ + algorithms: [], + maps: [], + formats: [], + traces: [], +}); diff --git a/client/src/slices/loading.ts b/client/src/slices/loading.ts index 0820bfb..d1b4d0c 100644 --- a/client/src/slices/loading.ts +++ b/client/src/slices/loading.ts @@ -1,57 +1,57 @@ -import { some, values } from "lodash"; -import { useCallback } from "react"; -import { createSlice } from "./createSlice"; -import { produce } from "produce"; - -type Loading = { - specimen: number; - map: number; - connections: number; - features: number; - general: number; -}; - -type A = { action: "start" | "end"; key: keyof Loading }; - -export const [useLoading, LoadingProvider] = createSlice( - { - specimen: 0, - connections: 0, - features: 0, - map: 0, - general: 0, - }, - { - reduce: (prev, { action, key }: A) => { - return produce(prev, (draft) => { - switch (action) { - case "start": - draft[key] += 1; - break; - case "end": - draft[key] -= 1; - } - return draft; - }); - }, - } -); - -export function useAnyLoading() { - const [loading] = useLoading(); - return some(values(loading)); -} - -export function useLoadingState(key: keyof Loading = "general") { - const [, dispatch] = useLoading(); - - return useCallback( - async (task: () => Promise) => { - dispatch(() => ({ action: "start", key })); - const out = await task(); - dispatch(() => ({ action: "end", key })); - return out; - }, - [key, dispatch] - ); -} +import { some, values } from "lodash"; +import { useCallback } from "react"; +import { createSlice } from "./createSlice"; +import { produce } from "produce"; + +type Loading = { + specimen: number; + map: number; + connections: number; + features: number; + general: number; +}; + +type A = { action: "start" | "end"; key: keyof Loading }; + +export const [useLoading, LoadingProvider] = createSlice( + { + specimen: 0, + connections: 0, + features: 0, + map: 0, + general: 0, + }, + { + reduce: (prev, { action, key }: A) => { + return produce(prev, (draft) => { + switch (action) { + case "start": + draft[key] += 1; + break; + case "end": + draft[key] -= 1; + } + return draft; + }); + }, + } +); + +export function useAnyLoading() { + const [loading] = useLoading(); + return some(values(loading)); +} + +export function useLoadingState(key: keyof Loading = "general") { + const [, dispatch] = useLoading(); + + return useCallback( + async (task: () => Promise) => { + dispatch(() => ({ action: "start", key })); + const out = await task(); + dispatch(() => ({ action: "end", key })); + return out; + }, + [key, dispatch] + ); +} diff --git a/client/src/slices/log.ts b/client/src/slices/log.ts index 122013b..828a1a4 100644 --- a/client/src/slices/log.ts +++ b/client/src/slices/log.ts @@ -1,21 +1,21 @@ -import { createSlice } from "./createSlice"; - -type LogEntry = { - content: string; - timestamp?: string; -}; - -type Log = LogEntry[]; - -type LogAction = { action: "append"; log: LogEntry } | { action: "clear" }; - -export const [useLog, LogProvider] = createSlice([], { - reduce: (prev, next) => { - switch (next.action) { - case "append": - return [next.log, ...prev]; - case "clear": - return []; - } - }, -}); +import { createSlice } from "./createSlice"; + +type LogEntry = { + content: string; + timestamp?: string; +}; + +type Log = LogEntry[]; + +type LogAction = { action: "append"; log: LogEntry } | { action: "clear" }; + +export const [useLog, LogProvider] = createSlice([], { + reduce: (prev, next) => { + switch (next.action) { + case "append": + return [next.log, ...prev]; + case "clear": + return []; + } + }, +}); diff --git a/client/src/slices/renderers.ts b/client/src/slices/renderers.ts index 73c5890..94573b9 100644 --- a/client/src/slices/renderers.ts +++ b/client/src/slices/renderers.ts @@ -2,12 +2,12 @@ import { RendererDefinition, RendererEvents, RendererOptions } from "renderer"; import { createSlice } from "./createSlice"; import { replace } from "./reducers"; -export type Renderer = { - key: string; - url: string; - renderer: RendererDefinition; -}; - -export const [useRenderers, RendererProvider] = createSlice([], { - reduce: replace, +export type Renderer = { + key: string; + url: string; + renderer: RendererDefinition; +}; + +export const [useRenderers, RendererProvider] = createSlice([], { + reduce: replace, }); \ No newline at end of file diff --git a/client/src/slices/screenshots.ts b/client/src/slices/screenshots.ts index 6268f1b..acb43bf 100644 --- a/client/src/slices/screenshots.ts +++ b/client/src/slices/screenshots.ts @@ -1,18 +1,18 @@ -import { filter, flow, isUndefined, keys, omit } from "lodash"; -import { createSlice } from "./createSlice"; -import { merge } from "./reducers"; - -const removeUndefinedValues = >(obj: T) => - omit( - obj, - filter(keys(obj), (key) => isUndefined(obj[key])) - ); - -export const [useScreenshots, ScreenshotsProvider] = createSlice< - Record Promise) | undefined> ->( - {}, - { - reduce: flow(merge, removeUndefinedValues), - } -); +import { filter, flow, isUndefined, keys, omit } from "lodash"; +import { createSlice } from "./createSlice"; +import { merge } from "./reducers"; + +const removeUndefinedValues = >(obj: T) => + omit( + obj, + filter(keys(obj), (key) => isUndefined(obj[key])) + ); + +export const [useScreenshots, ScreenshotsProvider] = createSlice< + Record Promise) | undefined> +>( + {}, + { + reduce: flow(merge, removeUndefinedValues), + } +); diff --git a/client/src/slices/settings.ts b/client/src/slices/settings.ts index 533e7c1..90b5c9d 100644 --- a/client/src/slices/settings.ts +++ b/client/src/slices/settings.ts @@ -1,70 +1,70 @@ -import type { pages } from "pages"; -import { createSlice, withLocalStorage } from "./createSlice"; -import { AccentColor } from "theme"; - -export type Sources = { - trustedOrigins?: string[]; -}; - -export type Remote = { - url: string; - transport: string; - key: string; - disabled?: boolean; -}; - -export type Renderer = { - url: string; - key: string; - transport: string; - disabled?: boolean; -}; - -export type Settings = { - remote?: Remote[]; - renderer?: Renderer[]; - "playback/playbackRate"?: number; - "appearance/acrylic"?: boolean; - "appearance/theme"?: "dark" | "light"; - "appearance/accentColor"?: AccentColor; - "behaviour/showOnStart"?: keyof typeof pages; -} & Sources; - -export const defaultRemotes = [ - { - url: `internal://basic-maps`, - transport: "native", - key: "default-internal", - }, - { - url: `https://cdn.jsdelivr.net/gh/ShortestPathLab/posthoc-app@adapter-warthog-wasm-dist/warthog-wasm.mjs`, - transport: "ipc", - key: "default-ipc", - }, -]; - -export const defaultRenderers = [ - { - url: `internal://d2-renderer/`, - key: "d2-renderer", - transport: "native", - }, -]; - -export const defaultPlaybackRate = 1; - -export const defaults = { - renderer: defaultRenderers, - remote: defaultRemotes, - trustedOrigins: [], - "playback/playbackRate": defaultPlaybackRate, - "appearance/theme": "dark", - "appearance/acrylic": true, - "appearance/accentColor": "blue", - "behaviour/showOnStart": "explore", -} as Settings; - -export const [useSettings, SettingsProvider] = createSlice( - {}, - withLocalStorage("settings", defaults) -); +import type { pages } from "pages"; +import { createSlice, withLocalStorage } from "./createSlice"; +import { AccentColor } from "theme"; + +export type Sources = { + trustedOrigins?: string[]; +}; + +export type Remote = { + url: string; + transport: string; + key: string; + disabled?: boolean; +}; + +export type Renderer = { + url: string; + key: string; + transport: string; + disabled?: boolean; +}; + +export type Settings = { + remote?: Remote[]; + renderer?: Renderer[]; + "playback/playbackRate"?: number; + "appearance/acrylic"?: boolean; + "appearance/theme"?: "dark" | "light"; + "appearance/accentColor"?: AccentColor; + "behaviour/showOnStart"?: keyof typeof pages; +} & Sources; + +export const defaultRemotes = [ + { + url: `internal://basic-maps`, + transport: "native", + key: "default-internal", + }, + { + url: `https://cdn.jsdelivr.net/gh/ShortestPathLab/posthoc-app@adapter-warthog-wasm-dist/warthog-wasm.mjs`, + transport: "ipc", + key: "default-ipc", + }, +]; + +export const defaultRenderers = [ + { + url: `internal://d2-renderer/`, + key: "d2-renderer", + transport: "native", + }, +]; + +export const defaultPlaybackRate = 1; + +export const defaults = { + renderer: defaultRenderers, + remote: defaultRemotes, + trustedOrigins: [], + "playback/playbackRate": defaultPlaybackRate, + "appearance/theme": "dark", + "appearance/acrylic": true, + "appearance/accentColor": "blue", + "behaviour/showOnStart": "explore", +} as Settings; + +export const [useSettings, SettingsProvider] = createSlice( + {}, + withLocalStorage("settings", defaults) +); diff --git a/client/src/theme.tsx b/client/src/theme.tsx index 30b7280..0c2dce9 100644 --- a/client/src/theme.tsx +++ b/client/src/theme.tsx @@ -1,147 +1,147 @@ -import { - alpha, - colors, - createTheme, - SxProps, - TextFieldProps, - Theme, -} from "@mui/material"; -import { constant, floor, times } from "lodash"; -import { useSettings } from "slices/settings"; - -export type AccentColor = Exclude; - -export type Shade = keyof (typeof colors)[AccentColor]; - -export const { common, ...accentColors } = colors; - -const shadow = ` - 0px 4px 9px -1px rgb(0 0 0 / 4%), - 0px 5px 24px 0px rgb(0 0 0 / 4%), - 0px 10px 48px 0px rgb(0 0 0 / 4%) -`; - -export const getShade = ( - color: AccentColor = "blue", - mode: "light" | "dark" = "light", - shadeLight: Shade = "A700", - shadeDark: Shade = "A100" -) => { - return colors[color][mode === "dark" ? shadeDark : shadeLight]; -}; - -const fontFamily = `"Inter", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", - "Droid Sans", "Helvetica Neue", "Arial", sans-serif`; -const headingFamily = `"Inter Tight", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", - "Droid Sans", "Helvetica Neue", "Arial", sans-serif`; - -export const makeTheme = (mode: "light" | "dark", theme: AccentColor) => - createTheme({ - palette: { - primary: { main: getShade(theme, mode) }, - mode, - background: - mode === "dark" - ? // ? { default: "#101418", paper: "#14191f" } - { default: "#0a0c10", paper: "#111317" } - : { default: "#ebecef", paper: "#ffffff" }, - }, - typography: { - allVariants: { - fontFamily, - }, - h1: { fontFamily: headingFamily }, - h2: { fontFamily: headingFamily }, - h3: { fontFamily: headingFamily }, - h4: { fontFamily: headingFamily }, - h5: { fontFamily: headingFamily }, - h6: { fontFamily: headingFamily }, - button: { - textTransform: "none", - fontWeight: 400, - letterSpacing: 0, - backgroundColor: "background.paper", - }, - subtitle2: { - marginTop: 6, - fontWeight: 400, - }, - }, - components: { - MuiPopover: { - styleOverrides: { - paper: { - backgroundImage: - "linear-gradient(rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.06))", - }, - }, - }, - MuiTooltip: { - styleOverrides: { - tooltip: { - backgroundImage: "linear-gradient(#1c2128, #1c2128)", - fontFamily, - }, - }, - }, - MuiTypography: { - styleOverrides: { - body1: { - fontWeight: 400, - fontSize: "0.875rem", - }, - overline: { - fontWeight: 400, - textTransform: "none", - letterSpacing: 0, - fontSize: "0.875rem", - }, - h4: { - marginBottom: 12, - }, - h6: { - fontWeight: 500, - }, - }, - }, - }, - shadows: ["", ...times(24, constant(shadow))] as any, - }); - -export function useAcrylic(color?: string): SxProps { - const [{ "appearance/acrylic": acrylic }] = useSettings(); - return acrylic - ? { - backdropFilter: "blur(16px)", - background: ({ palette }) => - alpha(color ?? palette.background.paper, 0.75), - } - : { - backdropFilter: "blur(0px)", - background: ({ palette }) => color ?? palette.background.paper, - }; -} - -export function usePaper(): (e?: number) => SxProps { - return (elevation: number = 1) => ({ - borderRadius: 1, - transition: ({ transitions }) => - transitions.create(["background-color", "box-shadow"]), - boxShadow: ({ shadows, palette }) => - palette.mode === "dark" - ? shadows[1] - : shadows[Math.max(floor(elevation) - 1, 0)], - backgroundColor: ({ palette }) => - palette.mode === "dark" - ? alpha(palette.action.disabledBackground, elevation * 0.02) - : palette.background.paper, - border: ({ palette }) => - palette.mode === "dark" - ? `1px solid ${alpha(palette.text.primary, elevation * 0.08)}` - : `1px solid ${alpha(palette.text.primary, elevation * 0.16)}`, - }); -} - -export const textFieldProps = { - variant: "filled", -} satisfies TextFieldProps; +import { + alpha, + colors, + createTheme, + SxProps, + TextFieldProps, + Theme, +} from "@mui/material"; +import { constant, floor, times } from "lodash"; +import { useSettings } from "slices/settings"; + +export type AccentColor = Exclude; + +export type Shade = keyof (typeof colors)[AccentColor]; + +export const { common, ...accentColors } = colors; + +const shadow = ` + 0px 4px 9px -1px rgb(0 0 0 / 4%), + 0px 5px 24px 0px rgb(0 0 0 / 4%), + 0px 10px 48px 0px rgb(0 0 0 / 4%) +`; + +export const getShade = ( + color: AccentColor = "blue", + mode: "light" | "dark" = "light", + shadeLight: Shade = "A700", + shadeDark: Shade = "A100" +) => { + return colors[color][mode === "dark" ? shadeDark : shadeLight]; +}; + +const fontFamily = `"Inter", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", + "Droid Sans", "Helvetica Neue", "Arial", sans-serif`; +const headingFamily = `"Inter Tight", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", + "Droid Sans", "Helvetica Neue", "Arial", sans-serif`; + +export const makeTheme = (mode: "light" | "dark", theme: AccentColor) => + createTheme({ + palette: { + primary: { main: getShade(theme, mode) }, + mode, + background: + mode === "dark" + ? // ? { default: "#101418", paper: "#14191f" } + { default: "#0a0c10", paper: "#111317" } + : { default: "#ebecef", paper: "#ffffff" }, + }, + typography: { + allVariants: { + fontFamily, + }, + h1: { fontFamily: headingFamily }, + h2: { fontFamily: headingFamily }, + h3: { fontFamily: headingFamily }, + h4: { fontFamily: headingFamily }, + h5: { fontFamily: headingFamily }, + h6: { fontFamily: headingFamily }, + button: { + textTransform: "none", + fontWeight: 400, + letterSpacing: 0, + backgroundColor: "background.paper", + }, + subtitle2: { + marginTop: 6, + fontWeight: 400, + }, + }, + components: { + MuiPopover: { + styleOverrides: { + paper: { + backgroundImage: + "linear-gradient(rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.06))", + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + backgroundImage: "linear-gradient(#1c2128, #1c2128)", + fontFamily, + }, + }, + }, + MuiTypography: { + styleOverrides: { + body1: { + fontWeight: 400, + fontSize: "0.875rem", + }, + overline: { + fontWeight: 400, + textTransform: "none", + letterSpacing: 0, + fontSize: "0.875rem", + }, + h4: { + marginBottom: 12, + }, + h6: { + fontWeight: 500, + }, + }, + }, + }, + shadows: ["", ...times(24, constant(shadow))] as any, + }); + +export function useAcrylic(color?: string): SxProps { + const [{ "appearance/acrylic": acrylic }] = useSettings(); + return acrylic + ? { + backdropFilter: "blur(16px)", + background: ({ palette }) => + alpha(color ?? palette.background.paper, 0.75), + } + : { + backdropFilter: "blur(0px)", + background: ({ palette }) => color ?? palette.background.paper, + }; +} + +export function usePaper(): (e?: number) => SxProps { + return (elevation: number = 1) => ({ + borderRadius: 1, + transition: ({ transitions }) => + transitions.create(["background-color", "box-shadow"]), + boxShadow: ({ shadows, palette }) => + palette.mode === "dark" + ? shadows[1] + : shadows[Math.max(floor(elevation) - 1, 0)], + backgroundColor: ({ palette }) => + palette.mode === "dark" + ? alpha(palette.action.disabledBackground, elevation * 0.02) + : palette.background.paper, + border: ({ palette }) => + palette.mode === "dark" + ? `1px solid ${alpha(palette.text.primary, elevation * 0.08)}` + : `1px solid ${alpha(palette.text.primary, elevation * 0.16)}`, + }); +} + +export const textFieldProps = { + variant: "filled", +} satisfies TextFieldProps; diff --git a/client/src/utils/Jimp.tsx b/client/src/utils/Jimp.tsx deleted file mode 100644 index 5728d46..0000000 --- a/client/src/utils/Jimp.tsx +++ /dev/null @@ -1,7 +0,0 @@ -//@ts-nocheck - -import type LibJimp from "jimp"; -import * as _Jimp from "jimp/browser/lib/jimp"; - -export const Jimp: typeof LibJimp = - typeof self !== "undefined" ? self.Jimp || _Jimp : _Jimp;