From f98d8461aae965d69cbba2d0707394313b1985eb Mon Sep 17 00:00:00 2001 From: Kevin Zheng Date: Thu, 21 Nov 2024 14:02:57 +1100 Subject: [PATCH] Update dependencies --- bun.lockb | Bin 515928 -> 514544 bytes client/package.json | 38 +- client/src/App.tsx | 238 ++-- client/src/client/SocketIOTransport.ts | 98 +- .../src/components/app-bar/FeaturePicker.tsx | 180 +-- .../components/app-bar/FeaturePickerMulti.tsx | 138 +-- client/src/components/app-bar/Input.tsx | 306 ++--- client/src/components/app-bar/Playback.tsx | 484 ++++---- .../breakpoint-editor/BreakpointEditor.tsx | 183 +-- .../BreakpointListEditor.tsx | 150 +-- .../breakpoint-editor/comparators.tsx | 66 +- .../breakpoint-editor/eventTypes.tsx | 14 +- .../breakpoint-editor/intrinsicProperties.tsx | 2 +- .../breakpoint-editor/propertyPaths.tsx | 2 +- client/src/components/generic/Flex.tsx | 36 +- .../generic/IconButtonWithTooltip.tsx | 62 +- client/src/components/generic/LazyList.tsx | 278 ++--- client/src/components/generic/ListEditor.tsx | 1048 ++++++++--------- client/src/components/generic/Modal.tsx | 768 ++++++------ client/src/components/generic/Overline.tsx | 64 +- client/src/components/generic/Property.tsx | 170 +-- client/src/components/generic/ScrollPanel.tsx | 114 +- client/src/components/generic/Select.tsx | 234 ++-- client/src/components/generic/SelectMulti.tsx | 202 ++-- client/src/components/generic/Snackbar.tsx | 282 ++--- client/src/components/generic/Space.tsx | 4 +- client/src/components/generic/Switch.tsx | 32 +- .../components/inspector/EventInspector.tsx | 342 +++--- .../components/inspector/SelectionMenu.tsx | 352 +++--- client/src/components/inspector/index.tsx | 122 +- client/src/components/renderer/Renderer.tsx | 88 +- client/src/components/renderer/colors.tsx | 198 ++-- client/src/components/renderer/index.tsx | 10 +- .../script-editor/FunctionTemplate.tsx | 70 +- .../components/script-editor/ScriptEditor.tsx | 164 +-- client/src/components/script-editor/call.tsx | 74 +- .../components/script-editor/makeTemplate.tsx | 90 +- .../components/script-editor/templates.tsx | 54 +- .../title-bar/ExportWorkspaceModal.tsx | 22 +- client/src/components/title-bar/TitleBar.tsx | 380 +++--- client/src/global.d.ts | 52 +- client/src/hooks/useBreakpoints.tsx | 270 ++--- client/src/hooks/usePlaybackState.tsx | 230 ++-- client/src/hooks/useScrollState.tsx | 88 +- client/src/hooks/useSmallDisplay.tsx | 6 +- client/src/index.tsx | 78 +- client/src/layers/map/index.tsx | 2 +- client/src/layers/trace/index.tsx | 4 +- client/src/pages/ExplorePage.tsx | 20 +- client/src/pages/SettingsPage.tsx | 30 +- client/src/public/manifest.json | 4 +- client/src/services/ConnectionsService.tsx | 106 +- client/src/services/RendererService.tsx | 118 +- client/src/slices/SliceProvider.tsx | 60 +- client/src/slices/UIState.ts | 170 +-- client/src/slices/busy.ts | 76 +- client/src/slices/connections.ts | 30 +- client/src/slices/createSlice.tsx | 160 +-- client/src/slices/features.ts | 40 +- client/src/slices/loading.ts | 114 +- client/src/slices/log.ts | 42 +- client/src/slices/renderers.ts | 16 +- client/src/slices/screenshots.ts | 36 +- client/src/slices/settings.ts | 140 +-- client/src/theme.tsx | 294 ++--- client/src/utils/Jimp.tsx | 7 - 66 files changed, 4673 insertions(+), 4679 deletions(-) delete mode 100644 client/src/utils/Jimp.tsx diff --git a/bun.lockb b/bun.lockb index 4dab9c47d6d9fb06917f747da37cc8fe34e6617e..6938ce0fbd5dab7362e1fc8652cd285ccafc538b 100644 GIT binary patch delta 73209 zcmeFad0dTM+dsVbuARM8l&O*^6@?6uU7E}qC5b|4E}Aq@$S$)8*|IXrSTZLHna2pp zOvpTCp2y$!*ymaH{XF+`zt8)5-}m$WbM?8N>$|?+H680%$2!h+o^{>NjYF;87PeZ{ z(X#3O-nVw_lg%n05Wlc=_;_2P_1a;vS%=?Ge)+nKL&?WKV{X`LmfHboU^5_1R8_#$TW=Kznt*F5#_Q_&uEKOuuz5i0H3dkcj02LHN!z(~ z`hb&peSqYLp;#9Aan}xkpgwQ_IQ8oaY@#sIG8DE@RO{-=HFk5eec8p;H|E{ZvQQw< zL;HF>ilNi8KCR})e+Hy|yay!vP6KKFoj~fh21xcT22#7Aq_D8ic!3}?G&EFZ(@hYy zSJ_XVHXRMfu=wyGtBBBK0shYlWkLIWoO>ql14IGIT^)clp&yXk6dhut41i`OoE{tz z7#|uFEC`NIiAzLHFSH|rrmo@)*|#6T0=^PxD3A-%7N9^Yn+_yTnI7Z@P64M0V|k1Z z4Gd1SicAQON=T*|bxb|qSHxQ zq982~NG`5_inHh!I9Zkpjup$(%plOxElzVLCM3ou#*XVMC;+F;+lT?FemRfxfiyrz z7(_031p}yEQetG7H4KOg92ZN5+=s3OJuW7EERKkv5}ewNjSCI8f^tAvk39&Ajx{h!cV;UJZAs{l5dlvqpWGBrRAJQ5(1T5CGZ5N zab}<$c`Tmq_YDT<${}3kCoJS~4UnR1K9D+01kwOQfVASCe7zxGuL-1ZKdoF5f+jjV zJTgomz`rKO2gV6@L8q-_SkF{Z58@*hZ|Bde2s9kxTn+H)USMKF+2sF?;Anj3YSl1qhp3zB>ouUGR zvTt(1bsI=QX?u%veKwE`p8=$C0$>ms?#dfldyco=<`&?4hg-1iD)^r|)UV=#B|bDH zNx5s_wAXimErF+jdcgR=Fsp>%h>%bG0MCGAu;nT)$_;@OoE7&3f)2nZ0{loGavgp{r!Bb(P6ihPjew!?@v-q%p~+>B zxPI@^P7C^&#~k_q$-vh@%3Rf`r~U_E7G znrQfIMRS9eZav;`3mVDGVSx#WUOuoS^D)YG_%x7a_vJ8}_D?T(KHflns;L9IwrH_F$z;z()bs4X_*B2^1vja$;7|hpi z1`=PV#Z4^G<_sGPq;YNn$-px}T3ASIaAJI9Oz6Tt@IT#8k}&`^7y~4}4z4C%dxriV z0WKy&6XN1SLj{7`GgKRdTXnbzbAYsk^MJI4ry6lstEceo9zK_I1J)i23emu*1VJE` zKUSU;GmfJD4+fyUX>Y&<749`5R&nFPYtvjD9c}#VM?LNNw&p@*u-X`Mh7|y*|4Jam zfF+O?uo~@%Cxk{%pqt)S^rtPYO`D>@MvCS}-9;t_ior%5<&%whyAon!!Xl$WyMa^l zumq&i#Q4BZFq1;^mI>$3+H`#poX$cqkT&iG+7b6P<;F`1Oqvu0^RM�+0@EobvTT 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 M@mp*btsKOaKcw?Y&j0`b diff --git a/client/package.json b/client/package.json index 759ea331..6a5a9cd5 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 1fd327c1..7b2979c5 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 0b1c1e90..b7a01edf 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 fdf8bade..349d3d05 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 f8b795c3..5e005cac 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 e9a7f7fd..647a3191 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 e36db7e5..85b3fe4e 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 1c68281c..8f1c43af 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 54ce746c..b5013b4b 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 66040d96..8cd87b05 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 08be79d9..3bbc60cc 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 b772f1fa..3f6a1a06 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 00f14c61..fa65c395 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 260bf288..87b1396e 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 afae6949..db1b0b71 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 1bc37461..dc61e1bc 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 6ba35251..e44ca3f2 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 ab0302b0..a8792199 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 7d8a7e87..976212d3 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 b61bd8d7..55bb6897 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 6d99e6d3..87007948 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 f4e6d2bf..44ac2c56 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 01b26fbb..565218f6 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 1e6ac28b..02f9030a 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 78f06e86..91acb8e7 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 5ce4d03d..aa704746 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 63c9fe32..7cccf966 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 bd2fbce7..3eba141c 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 f956cbec..1ea0f417 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 91e575c1..a5e841e5 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 57353e42..497af24e 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 18835ded..b737c1d4 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 38f6bf9d..548ff801 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 8848be20..986eab53 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 0f84f5e5..487a4b9f 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 cc761f1d..c7d7e707 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 88cf4f34..a09480b7 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 828290ea..5e1bbfe9 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 84119b87..77c4de20 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 0f830efa..9768bfc1 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 a4370e78..09bdda67 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 f4808345..2274c3a8 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 cab2eeb6..e2a8592c 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 084f17a5..58a5a0f0 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 ad2110f4..4a5f7722 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 0970ea96..adfdb3c3 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 a871f951..b4b8ba2a 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 2fdc6f1f..01260b33 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 7b8db16c..8980cf35 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 3dbb46c5..755ee551 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 e6ba8153..e9092de2 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 350516da..093cc6b9 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 30c0e79a..d5f83cc6 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 531e6e7f..ea409db1 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 0820bfbe..d1b4d0c3 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 122013b4..828a1a46 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 73c58909..94573b9f 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 6268f1b3..acb43bf6 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 533e7c19..90b5c9d3 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 30b72802..0c2dce9a 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 5728d46a..00000000 --- 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;