diff --git a/client/src/components/app-bar/FeaturePicker.tsx b/client/src/components/app-bar/FeaturePicker.tsx index 8b45b255..f8a7a46c 100644 --- a/client/src/components/app-bar/FeaturePicker.tsx +++ b/client/src/components/app-bar/FeaturePicker.tsx @@ -2,7 +2,7 @@ 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 { find, map, startCase, truncate } from "lodash"; +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"; @@ -57,8 +57,8 @@ export function FeaturePicker({ !item.hidden)?.length || disabled} icon={selected?.icon ? getIcon(selected.icon, selected.color) : icon} arrow={arrow} > diff --git a/client/src/components/app-bar/Input.tsx b/client/src/components/app-bar/Input.tsx index 4accccb9..d5530cf6 100644 --- a/client/src/components/app-bar/Input.tsx +++ b/client/src/components/app-bar/Input.tsx @@ -9,6 +9,7 @@ import { 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(); } diff --git a/client/src/components/app-bar/upload.tsx b/client/src/components/app-bar/upload.tsx index a3b6a2f0..d25492bf 100644 --- a/client/src/components/app-bar/upload.tsx +++ b/client/src/components/app-bar/upload.tsx @@ -3,13 +3,8 @@ import { find, startCase } from "lodash"; import { Feature, FeatureDescriptor } from "protocol/FeatureQuery"; import { UploadedTrace } from "slices/UIState"; import { parseYamlAsync } from "workers/async"; - -function ext(s: string) { - return s.split(".").pop(); -} -function name(s: string) { - return s.split(".").shift(); -} +import { name, ext } from "../../utils/path"; +import { nanoid as id } from "nanoid"; const customId = "internal/custom"; @@ -25,7 +20,9 @@ export const custom = ( id: customId, }); -const FORMATS = ["json", "yaml", "yml"]; +export const EXTENSIONS = ["json", "yaml", "yml"]; + +const FORMATS = EXTENSIONS.map((c) => `.trace.${c}`); export type FileHandle = { file: File; @@ -36,31 +33,40 @@ export async function uploadTrace(): Promise< FileHandle | undefined > { const f = await file({ - accept: FORMATS.map((c) => `.trace.${c}`), + accept: FORMATS, strict: true, }); if (f) { - return { - file: f, - read: async () => { - if (FORMATS.includes(ext(f.name)!)) { - const content = await f.text(); - const parsed = await parseYamlAsync(content); - return { - ...custom(), - format: parsed?.format, - content: parsed, - name: startCase(name(f.name)), - type: customId, - }; - } else { - throw new Error(`The format (${ext(f.name)}) is unsupported.`); - } - }, - }; + return readUploadedTrace(f); } } +export function readUploadedTrace(f: File) { + return { + file: f, + read: async () => { + if (isTraceFormat(f)) { + const content = await f.text(); + const parsed = await parseYamlAsync(content); + return { + ...custom(), + format: parsed?.format, + content: parsed, + name: startCase(name(f.name)), + type: customId, + key: id(), + }; + } else { + throw new Error(`The format (${ext(f.name)}) is unsupported.`); + } + }, + }; +} + +export function isTraceFormat(f: File) { + return !!find(FORMATS, (r) => f.name.endsWith(r)); +} + export async function uploadMap( accept: FeatureDescriptor[] ): Promise< @@ -71,20 +77,24 @@ export async function uploadMap( strict: true, }); if (f) { - return { - file: f, - read: async () => { - if (find(accept, { id: ext(f.name) })) { - return { - ...custom(), - format: ext(f.name), - content: await f.text(), - name: startCase(name(f.name)), - } as Feature & { format?: string }; - } else { - throw new Error(`The format (${ext(f.name)}) is unsupported.`); - } - }, - }; + return readUploadedMap(f, accept); } } + +export function readUploadedMap(f: File, accept: FeatureDescriptor[]) { + return { + file: f, + read: async () => { + if (find(accept, { id: ext(f.name) })) { + return { + ...custom(), + format: ext(f.name), + content: await f.text(), + name: startCase(name(f.name)), + } as Feature & { format?: string }; + } else { + throw new Error(`The format (${ext(f.name)}) is unsupported.`); + } + }, + }; +} diff --git a/client/src/components/generic/Modal.tsx b/client/src/components/generic/Modal.tsx index 93ba9f0c..5dccd9da 100644 --- a/client/src/components/generic/Modal.tsx +++ b/client/src/components/generic/Modal.tsx @@ -1,20 +1,26 @@ import { ArrowBack } from "@mui/icons-material"; -import { ResizeSensor } from "css-element-queries"; -import PopupState from "material-ui-popup-state"; -import { ScrollPanel, usePanel } from "./ScrollPanel"; -import { useScrollState } from "hooks/useScrollState"; -import { useSmallDisplay } from "hooks/useSmallDisplay"; 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 { useTitleBarVisible } from "components/title-bar/TitleBar"; +import { merge } from "lodash"; import { cloneElement, ComponentProps, @@ -25,6 +31,8 @@ import { useEffect, useState, } from "react"; +import { useAcrylic, usePaper } from "theme"; +import { Scroll } from "./Scrollbars"; export function AppBarTitle({ children }: { children?: ReactNode }) { return {children}; @@ -35,7 +43,6 @@ export type Props = { actions?: ReactNode; width?: string | number; height?: string | number; - onTarget?: (target: HTMLDivElement | null) => void; variant?: "default" | "submodal"; scrollable?: boolean; }; @@ -61,6 +68,7 @@ export function ModalAppBar({ simple, position = "sticky", }: ModalAppBarProps) { + const sm = useSmallDisplay(); const panel = usePanel(); const theme = useTheme(); const [, , isAbsoluteTop, , setTarget] = useScrollState(); @@ -70,20 +78,32 @@ export function ModalAppBar({ const styles = isAbsoluteTop ? { - background: theme.palette.background.paper, + background: sm + ? theme.palette.background.default + : theme.palette.background.paper, ...(!simple && { boxShadow: theme.shadows[0], }), ...style, } : { - background: theme.palette.background.paper, + background: sm + ? theme.palette.background.default + : theme.palette.background.paper, ...(!simple && { boxShadow: theme.shadows[4], }), ...elevatedStyle, }; + function renderTitle(label: ReactNode) { + return typeof label === "string" ? ( + {label} + ) : ( + label + ); + } + return ( - {children} + {renderTitle(children)} )} @@ -138,7 +158,9 @@ export function ModalAppBar({ mountOnEnter unmountOnExit > - {elevatedChildren} + + {renderTitle(elevatedChildren)} + )} @@ -152,7 +174,6 @@ export default function Modal({ actions, width = 480, height, - onTarget, variant = "default", scrollable = true, ...props @@ -168,6 +189,7 @@ export default function Modal({ const [contentRef, setContentRef] = useState(null); const [hasOverflowingChildren, setHasOverflowingChildren] = useState(false); const [childHeight, setChildHeight] = useState(0); + const titleBarVisible = useTitleBarVisible(); useEffect(() => { if (target && contentRef && !sm && !height) { @@ -194,6 +216,11 @@ export default function Modal({ - -
setContentRef(e)} - style={{ width: "100%", height: "100%" }} - > +
setContentRef(e)} style={{ width: "100%" }}> {content}
- + {actions}
); } export function ManagedModal({ - options: ModalProps, appBar: ModalAppBarProps, trigger = () => <>, children, + popover, + slotProps, }: { options?: ComponentProps; - trigger?: (onClick: (e: SyntheticEvent) => void) => ReactElement; + trigger?: ( + onClick: (e: SyntheticEvent) => void, + isOpen: boolean + ) => ReactElement; appBar?: ModalAppBarProps; children?: ReactNode; + popover?: boolean; + slotProps?: { + popover?: Partial; + paper?: Partial; + modal?: Partial; + }; }) { + const paper = usePaper(); + const acrylic = useAcrylic(); + const sm = useSmallDisplay(); + const shouldDisplayPopover = popover && !sm; return ( - {({ open, close, isOpen }) => { + {(state) => { + const { open, close, isOpen } = state; return ( <> - {cloneElement(trigger(open))} - - - {children ?? ModalProps?.children} - + {cloneElement(trigger(open, isOpen))} + {shouldDisplayPopover ? ( + + + {children ?? slotProps?.modal?.children} + + + ) : ( + + + {children ?? slotProps?.modal?.children} + + )} ); }} ); } + +export type ManagedModalProps = ComponentProps; diff --git a/client/src/components/generic/Property.tsx b/client/src/components/generic/Property.tsx index 06751914..c786efe9 100644 --- a/client/src/components/generic/Property.tsx +++ b/client/src/components/generic/Property.tsx @@ -2,10 +2,11 @@ import { Typography as Type, TypographyProps as TypeProps, } from "@mui/material"; -import { get, isNull, round, truncate } from "lodash"; +import { get, isNull, round, startCase, truncate } from "lodash"; import { CSSProperties, ReactNode } from "react"; import { Flex } from "./Flex"; import { Space } from "./Space"; +import beautify from "json-beautify"; type Props = { label?: ReactNode; @@ -40,7 +41,7 @@ export function renderProperty(obj: any, simple: boolean = false) { } } case "string": - return `${obj}`; + return startCase(`${obj}`); case "undefined": return "null"; default: @@ -49,9 +50,9 @@ export function renderProperty(obj: any, simple: boolean = false) { {isNull(obj) ? "null" : get(obj, "constructor.name") ?? typeof obj} ) : ( - - {truncate(JSON.stringify(obj).replace("\n", ", "), { - length: 30, + + {truncate(beautify(obj, undefined as any, 2), { + length: 100, })} ); diff --git a/client/src/components/inspector/EventInspector.tsx b/client/src/components/inspector/EventInspector.tsx index 65535406..6f130507 100644 --- a/client/src/components/inspector/EventInspector.tsx +++ b/client/src/components/inspector/EventInspector.tsx @@ -13,12 +13,18 @@ import { Typography as Type, useTheme, } from "@mui/material"; +import { IconButtonWithTooltip as IconButton } from "components/generic/IconButtonWithTooltip"; import { getColorHex } from "components/renderer/colors"; import { omit, pick, startCase } from "lodash"; import { TraceEvent } from "protocol/Trace"; import { ReactNode } from "react"; import { useCss } from "react-use"; -import { ESSENTIAL_PROPS, OMIT_PROPS, PropertyList } from "./PropertyList"; +import { + ESSENTIAL_PROPS, + OMIT_PROPS, + PropertyDialog, + PropertyList, +} from "./PropertyList"; type EventInspectorProps = { event?: TraceEvent; @@ -47,12 +53,8 @@ export function EventInspector({ }: EventInspectorProps) { const { spacing } = useTheme(); - const cls = useCss({ - "& .info-button": { opacity: 0 }, - "&:hover .info-button": { - opacity: 1, - }, - }); + const buttonCls = useCss({}); + const extrasCls = useCss({}); const omitProps = omit(event, ...OMIT_PROPS); @@ -61,68 +63,88 @@ export function EventInspector({ const extraProps = omit(omitProps, ...ESSENTIAL_PROPS); return ( - .${extrasCls}`]: { opacity: 0 }, + [`&:hover > .${extrasCls}`]: { opacity: 1 }, + [`&:hover > .${buttonCls}`]: { pr: 8 }, }} > - - {index} - {label && } - - - - {startCase(`${event?.type ?? "unsupported"} ${event?.id ?? "-"}`)}{" "} - - } - secondaryTypographyProps={{ - component: "div", - whiteSpace: "nowrap", - overflow: "hidden", + *": { flex: 0 }, - }} - > - - - - } - /> - t.zIndex.modal - 1 } }, + > + + {index} + {label && } + + + + {startCase(`${event?.type ?? "unsupported"} ${event?.id ?? "-"}`)}{" "} + + } + secondaryTypographyProps={{ + component: "div", + whiteSpace: "nowrap", + overflow: "hidden", + }} + secondary={ + *": { flex: 0 }, + }} + > + + + + } + /> + + - - - } > - - - - - + ( + } + > + )} + /> + + ); } diff --git a/client/src/components/inspector/FileDropZone.tsx b/client/src/components/inspector/FileDropZone.tsx new file mode 100644 index 00000000..5c54dc2e --- /dev/null +++ b/client/src/components/inspector/FileDropZone.tsx @@ -0,0 +1,81 @@ +import { WorkspacesOutlined } from "@mui/icons-material"; +import { Backdrop, Stack, Typography as Type } from "@mui/material"; +import { useSnackbar } from "components/generic/Snackbar"; +import { useWorkspace } from "hooks/useWorkspace"; +import { layerHandlers } from "layers/layerHandlers"; +import { entries, head } from "lodash"; +import { nanoid as id } from "nanoid"; +import pluralize, { plural } from "pluralize"; +import { producify } from "produce"; +import { useState } from "react"; +import { FileDrop } from "react-file-drop"; +import { formatByte, useBusyState } from "slices/busy"; +import { useLayers } from "slices/layers"; +import { useAcrylic } from "theme"; + +export function FileDropZone() { + const acrylic = useAcrylic() as any; + const { load: loadWorkspace } = useWorkspace(); + const [open, setOpen] = useState(false); + const [, setLayers] = useLayers(); + const usingBusyState = useBusyState("file-drop-import"); + const notify = useSnackbar(); + + async function importFiles(fs: File[]) { + let totalClaimed = 0; + for (const [file, i] of fs.map((...args) => args)) { + for (const [type, { claimImportedFile }] of entries(layerHandlers)) { + const outcome = await claimImportedFile?.(file); + if (outcome?.claimed) { + await usingBusyState(async () => { + const layer = await outcome.layer(notify); + setLayers( + producify((prev) => + prev.layers.push({ + key: id(), + source: { type, ...layer }, + }) + ) + ); + }, `${i + 1} of ${fs.length}: Opening ${type} (${formatByte(file.size)})`); + totalClaimed += 1; + continue; + } + } + } + if (!totalClaimed) { + const success = await loadWorkspace(head(fs)); + if (success) return; + } + notify( + `Couldn't open ${fs.length} of ${pluralize("file", fs.length, true)}` + ); + } + + return ( + <> + setOpen(false)} + onFrameDragEnter={() => setOpen(true)} + onFrameDrop={() => setOpen(false)} + onDragLeave={() => setOpen(false)} + onDrop={(f) => f && importFiles(Array.from(f as any))} + > + t.zIndex.tooltip + 1, + }} + open={open} + > + + + + Import + + + + + + ); +} diff --git a/client/src/components/inspector/FullscreenModalHost.tsx b/client/src/components/inspector/FullscreenModalHost.tsx index db7ca6fc..9d57dea2 100644 --- a/client/src/components/inspector/FullscreenModalHost.tsx +++ b/client/src/components/inspector/FullscreenModalHost.tsx @@ -60,7 +60,7 @@ export const FullscreenPage = withSlots( ; - variant?: TypographyVariant; - max?: number; - simple?: boolean; -} & FlexProps) { - const sorted = _(event) +const sortEventKeys = (e: PropertyListProps["event"]) => + _(e) .entries() .filter(([, v]) => !isUndefined(v)) .sortBy(([k]) => indexOf(ALL_PROPS, k) + 1 || Number.MAX_SAFE_INTEGER) .value(); + +type PropertyListProps = { + event?: Dictionary; + variant?: TypographyVariant; + max?: number; + simple?: boolean; +}; + +export function PropertyDialog({ + event, + max = 10, + simple, + variant, + ...rest +}: PropertyListProps & DialogProps) { + const sorted = sortEventKeys(event); + return ( + Event Properties }, + trigger: (onClick) => ( + + ), + } as DialogProps, + rest + )} + > + {[ + { + name: "common", + props: filter(sorted, ([k]) => OMIT_PROPS.includes(k)), + }, + { + name: "search", + props: filter(sorted, ([k]) => ESSENTIAL_PROPS.includes(k)), + }, + { + name: "other", + props: filter(sorted, ([k]) => !ALL_PROPS.includes(k)), + }, + ].map(({ name, props }, i) => ( + <> + {!!i && } + + {startCase(name)} + + + {map(props, ([key, value]) => ( + + + + ))} + + + ))} + + ); +} + +export function PropertyList(props: PropertyListProps & FlexProps) { + const { event, variant = "body2", max = 10, simple, ...rest } = props; + + const sorted = sortEventKeys(event); return ( <> - + {map(slice(sorted, 0, max), ([k, v], i) => ( ))} - {sorted.length > max && ( - Properties }} - trigger={(onClick) => ( - - )} - > - {[ - { - name: "common", - props: filter(sorted, ([k]) => OMIT_PROPS.includes(k)), - }, - { - name: "search", - props: filter(sorted, ([k]) => ESSENTIAL_PROPS.includes(k)), - }, - { - name: "other", - props: filter(sorted, ([k]) => !ALL_PROPS.includes(k)), - }, - ].map(({ name, props }, i) => ( - <> - {!!i && } - - {startCase(name)} - - - {map(props, ([key, value]) => ( - - - - ))} - - - ))} - - )} + {sorted.length > max && !simple && } ); diff --git a/client/src/components/inspector/SelectionMenu.tsx b/client/src/components/inspector/SelectionMenu.tsx index 28386267..5e0818eb 100644 --- a/client/src/components/inspector/SelectionMenu.tsx +++ b/client/src/components/inspector/SelectionMenu.tsx @@ -137,7 +137,7 @@ function useSelectionMenu() { () => chain(layers) .reduce((A, l) => { - const B = getLayerHandler(l)?.getSelectionInfo ?? identity; + const B = getLayerHandler(l)?.provideSelectionInfo ?? identity; return ({ children, event }: SelectionInfoProviderProps) => ( {(a) => {(b) => children?.(merge(a, b))}} diff --git a/client/src/components/inspector/ViewTree.tsx b/client/src/components/inspector/ViewTree.tsx index 70a8537e..df51ac81 100644 --- a/client/src/components/inspector/ViewTree.tsx +++ b/client/src/components/inspector/ViewTree.tsx @@ -4,7 +4,7 @@ import { Box, useTheme } from "@mui/material"; import { Flex } from "components/generic/Flex"; import { filter, find, flatMap, forEach, map, pick, sumBy } from "lodash"; import { nanoid } from "nanoid"; -import { produce, produce2 } from "produce"; +import { produce, transaction } from "produce"; import { Context, ReactNode, createContext, useContext, useMemo } from "react"; import { DndProvider, useDrag, useDrop } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; @@ -118,7 +118,7 @@ export function ViewLeaf({ const context = useMemo(() => { const handleSplit = (orientation: "vertical" | "horizontal") => onChange?.( - produce2(root, (draft) => ({ + transaction(root, (draft) => ({ key: nanoid(), type: "branch", orientation, @@ -277,7 +277,7 @@ export function ViewBranch(props: ViewBranchProps) { } onClose={() => onChange?.( - produce2(root, (draft) => { + transaction(root, (draft) => { draft.children.splice(i, 1); if (draft.children.length === 1) { if (draft.children[0].type === "leaf") { diff --git a/client/src/components/inspector/WorkspaceDropZone.tsx b/client/src/components/inspector/WorkspaceDropZone.tsx deleted file mode 100644 index 2b87a7d8..00000000 --- a/client/src/components/inspector/WorkspaceDropZone.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { WorkspacesOutlined } from "@mui/icons-material"; -import { Backdrop, Stack, Typography as Type } from "@mui/material"; -import { useWorkspace } from "hooks/useWorkspace"; -import { head } from "lodash"; -import { useState } from "react"; -import { FileDrop } from "react-file-drop"; -import { useAcrylic } from "theme"; - -export function WorkspaceDropZone() { - const acrylic = useAcrylic() as any; - const { load } = useWorkspace(); - const [open, setOpen] = useState(false); - return ( - <> - setOpen(false)} - onFrameDragEnter={() => setOpen(true)} - onFrameDrop={() => setOpen(false)} - onDragLeave={() => setOpen(false)} - onDrop={(f) => f?.length && load(head(f))} - > - t.zIndex.tooltip + 1, - }} - open={open} - > - - - - Open workspace - - - - - - ); -} diff --git a/client/src/components/inspector/index.tsx b/client/src/components/inspector/index.tsx index 1e5e7980..5b5ff1bc 100644 --- a/client/src/components/inspector/index.tsx +++ b/client/src/components/inspector/index.tsx @@ -8,7 +8,7 @@ import { useAnyLoading } from "slices/loading"; import { PanelState, useView } from "slices/view"; import { FullscreenProgress } from "./FullscreenProgress"; import { ViewTree } from "./ViewTree"; -import { WorkspaceDropZone } from "./WorkspaceDropZone"; +import { FileDropZone } from "./FileDropZone"; import { FullscreenModalHost } from "./FullscreenModalHost"; type SpecimenInspectorProps = Record & FlexProps; @@ -45,7 +45,7 @@ export function Inspector(props: SpecimenInspectorProps) { - + ); } diff --git a/client/src/components/layer-editor/LayerEditor.tsx b/client/src/components/layer-editor/LayerEditor.tsx index 852cb6e3..e04f516e 100644 --- a/client/src/components/layer-editor/LayerEditor.tsx +++ b/client/src/components/layer-editor/LayerEditor.tsx @@ -1,10 +1,8 @@ -import { TabContext, TabList } from "@mui/lab"; import { Box, ButtonBase, Chip, Divider, - Popover, Stack, Tab, Tabs, @@ -14,6 +12,7 @@ import { } from "@mui/material"; import { FeaturePicker } from "components/app-bar/FeaturePicker"; import { Flex } from "components/generic/Flex"; +import { ManagedModal as Dialog } from "components/generic/Modal"; import { Space } from "components/generic/Space"; import { inferLayerName } from "layers/inferLayerName"; import { getLayerHandler, layerHandlers } from "layers/layerHandlers"; @@ -27,7 +26,6 @@ import { startCase, truncate, } from "lodash"; -import PopupState, { bindPopover, bindTrigger } from "material-ui-popup-state"; import { produce } from "produce"; import { ForwardedRef, @@ -144,143 +142,144 @@ function Component( return ( <> - - {(state) => ( - <> - - - *": { - overflow: "hidden", - whiteSpace: "nowrap", - textOverflow: "ellipsis", - }, - }} - > - {name} - - {startCase(value.source?.type)} - - - {!!error && ( - - t.palette.error.main, - flex: 0, - }} - label={`${truncate(`${error}`, { length: 8 })}`} - size="small" - /> - - )} + ( + + + + {getLayerHandler(value).icon} - - - - - - setDraft?.( - produce(draft, (d) => set(d, "name", e.target.value)) - ) - } - /> - - - - setDraft?.( - produce(draft, (d) => { - set(d, "source.type", v); - }) - ) - } - value={ - draft.source?.type ?? first(keys(layerHandlers)) ?? "" - } - > - {keys(layerHandlers).map((s) => ( - - ))} - - - - - {renderHeading("Source Options")} - {draft.source?.type && - createElement(layerHandlers[draft.source.type].editor, { - onChange: (e) => setDraft(e(draft)), - value: draft, - })} - {renderHeading("Layer Options")} - {renderOption( - "Transparency", - ({ - id: c, - name: `${c}%`, - }))} - value={draft.transparency ?? "0"} - arrow - onChange={(e) => - setDraft?.( - produce(draft, (d) => set(d, "transparency", e)) - ) - } - /> - )} - {renderOption( - "Display Mode", - - setDraft?.( - produce(draft, (d) => set(d, "displayMode", e)) - ) - } - /> - )} + *": { + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + }, + }} + > + {name} + + {startCase(value.source?.type)} + - - + {!!error && ( + + t.palette.error.main, + flex: 0, + }} + label={`${truncate(`${error}`, { length: 8 })}`} + size="small" + /> + + )} + + )} - + > + + + setDraft?.(produce(draft, (d) => set(d, "name", e.target.value))) + } + /> + + + setDraft?.( + produce(draft, (d) => { + set(d, "source", { type: v }); + }) + ) + } + value={draft.source?.type ?? first(keys(layerHandlers)) ?? ""} + > + {keys(layerHandlers).map((s) => ( + + ))} + + + + + {renderHeading("Source Options")} + {draft.source?.type && + createElement(layerHandlers[draft.source.type].editor, { + onChange: (e) => setDraft(e(draft)), + value: draft, + })} + {renderHeading("Layer Options")} + {renderOption( + "Transparency", + ({ + id: c, + name: `${c}%`, + }))} + value={draft.transparency ?? "0"} + arrow + onChange={(e) => + setDraft?.(produce(draft, (d) => set(d, "transparency", e))) + } + /> + )} + {renderOption( + "Display Mode", + + setDraft?.(produce(draft, (d) => set(d, "displayMode", e))) + } + /> + )} + + ); } diff --git a/client/src/components/title-bar/TitleBar.tsx b/client/src/components/title-bar/TitleBar.tsx index 835618aa..7e357238 100644 --- a/client/src/components/title-bar/TitleBar.tsx +++ b/client/src/components/title-bar/TitleBar.tsx @@ -25,7 +25,7 @@ import { useEffect, useState, } from "react"; -import { useView } from "slices/view"; +import { getDefaultViewTree, useView } from "slices/view"; import { ExportWorkspaceModal } from "./ExportWorkspaceModal"; function MenuEntry({ @@ -48,11 +48,8 @@ function MenuEntry({ ); } -export const TitleBar = () => { - const { save, load } = useWorkspace(); +export function useTitleBarVisible() { const [visible, setVisible] = useState(false); - const [, setView] = useView(); - const [exportModalOpen, setExportModalOpen] = useState(false); useEffect(() => { if ("windowControlsOverlay" in navigator) { const f = () => { @@ -67,6 +64,14 @@ export const TitleBar = () => { ); } }, [setVisible]); + return visible; +} + +export const TitleBar = () => { + const { save, load } = useWorkspace(); + const visible = useTitleBarVisible(); + const [, setView] = useView(); + const [exportModalOpen, setExportModalOpen] = useState(false); function handleOpenPanel(type: string) { setView(({ view }) => { // const orientation = @@ -113,7 +118,7 @@ export const TitleBar = () => { {[ { - key: "Panel", + key: "panel", items: values(pages).map(({ name, id, icon }) => ({ key: `panel-open-${id}`, name: , @@ -143,6 +148,11 @@ export const TitleBar = () => { key: "workspace-save-metadata", action: () => setExportModalOpen(true), }, + { + name: "Reset Layout", + key: "workspace-reset", + action: () => setView(getDefaultViewTree), + }, ], }, { diff --git a/client/src/hooks/useWorkspace.tsx b/client/src/hooks/useWorkspace.tsx index 5580ebcb..7e0705d8 100644 --- a/client/src/hooks/useWorkspace.tsx +++ b/client/src/hooks/useWorkspace.tsx @@ -56,10 +56,10 @@ export function useWorkspace() { setUIState(() => parsed.UIState); } }, `Opening workspace (${formatByte(f.size)})`); - } else { - notify(`${f?.name} is not a workspace file`); + return true; } } + return false; }, save: async (raw?: boolean, name?: string) => { notify("Saving workspace..."); diff --git a/client/src/index.css b/client/src/index.css index 4ce38347..ee27f15c 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -49,8 +49,3 @@ code { min-width: 64px; } } - -#pathfinder { - position: "fixed"; - top: 100vh; -} diff --git a/client/src/layers/LayerController.tsx b/client/src/layers/LayerController.tsx index 18389e20..b960d229 100644 --- a/client/src/layers/LayerController.tsx +++ b/client/src/layers/LayerController.tsx @@ -2,7 +2,7 @@ import { EditorSetterProps } from "components/Editor"; import { SelectionMenuContent } from "components/inspector/SelectionMenu"; import { SelectEvent } from "components/renderer/Renderer"; import { TraceEvent } from "protocol"; -import { FC, ReactNode } from "react"; +import { FC, ReactElement, ReactNode } from "react"; import { Layer } from "slices/layers"; export type SelectionInfoProvider = FC<{ @@ -13,11 +13,19 @@ export type SelectionInfoProvider = FC<{ export type LayerController = { key: K; + icon: ReactElement; editor: FC>>; renderer: FC<{ layer?: Layer; index?: number }>; service?: FC>>; inferName: (layer: Layer) => string; steps?: (layer?: Layer) => TraceEvent[]; - getSelectionInfo?: SelectionInfoProvider; error?: (layer?: Layer) => string | boolean | undefined; + provideSelectionInfo?: SelectionInfoProvider; + claimImportedFile?: (file: File) => Promise< + | { + claimed: true; + layer: (notify: (s: string) => void) => Promise; + } + | { claimed: false } + >; }; diff --git a/client/src/layers/map/index.tsx b/client/src/layers/map/index.tsx index 2a72058c..677a06c4 100644 --- a/client/src/layers/map/index.tsx +++ b/client/src/layers/map/index.tsx @@ -1,21 +1,33 @@ +import { MapTwoTone } from "@mui/icons-material"; import { CircularProgress, Typography } from "@mui/material"; import { MapPicker } from "components/app-bar/Input"; +import { custom, readUploadedMap } from "components/app-bar/upload"; import { Heading, Option } from "components/layer-editor/Option"; import { getParser } from "components/renderer"; import { NodeList } from "components/renderer/NodeList"; +import { mapParsers } from "components/renderer/map-parser"; import { ParsedMap } from "components/renderer/map-parser/Parser"; import { useEffectWhen } from "hooks/useEffectWhen"; import { useMapContent } from "hooks/useMapContent"; import { useMapOptions } from "hooks/useMapOptions"; import { useParsedMap } from "hooks/useParsedMap"; import { LayerController, inferLayerName } from "layers"; -import { isUndefined, map, round, set, startCase } from "lodash"; +import { + entries, + get, + isUndefined, + keys, + map, + round, + set, + startCase, +} from "lodash"; import { nanoid as id } from "nanoid"; import { withProduce } from "produce"; import { useMemo } from "react"; import { Map } from "slices/UIState"; import { Layer, useLayer } from "slices/layers"; -import { usePaper } from "theme"; +import { ext, name } from "utils/path"; export type MapLayerData = { map?: Map; @@ -27,13 +39,42 @@ export type MapLayer = Layer; export const controller = { key: "map", + icon: , inferName: (layer) => layer?.source?.map ? `${layer.source.map.name} (${startCase(layer.source.map.format)})` : "Untitled Map", error: (layer) => layer?.source?.parsedMap?.error, + claimImportedFile: async (file) => + keys(mapParsers).includes(ext(file.name)) + ? { + claimed: true, + layer: async (notify) => { + notify("Opening map..."); + try { + const output = readUploadedMap( + file, + entries(mapParsers).map(([k]) => ({ + id: k, + })) + ); + return { map: { ...(await output.read()) } }; + } catch (e) { + console.error(e); + notify(`Error opening, ${get(e, "message")}`); + return { + map: { + key: id(), + id: custom().id, + error: get(e, "message"), + name: startCase(name(file.name)), + }, + }; + } + }, + } + : { claimed: false }, editor: withProduce(({ value, produce }) => { - const paper = usePaper(); const { result: Editor } = useMapOptions(value?.source?.map); return ( <> @@ -115,7 +156,7 @@ export const controller = { ); return <>; }), - getSelectionInfo: ({ children, event, layer: key }) => { + provideSelectionInfo: ({ children, event, layer: key }) => { const { layer, setLayer, layers } = useLayer(key); const { parsedMap } = layer?.source ?? {}; const { point, node } = useMemo(() => { diff --git a/client/src/layers/query/index.tsx b/client/src/layers/query/index.tsx index e57eb63a..79a0f146 100644 --- a/client/src/layers/query/index.tsx +++ b/client/src/layers/query/index.tsx @@ -2,6 +2,7 @@ import { CodeOutlined, PlaceOutlined as DestinationIcon, LayersOutlined, + RouteTwoTone, TripOriginOutlined as StartIcon, } from "@mui/icons-material"; import { Box, Typography as Type } from "@mui/material"; @@ -14,7 +15,7 @@ import { useEffectWhenAsync } from "hooks/useEffectWhen"; import { LayerController, inferLayerName } from "layers"; import { MapLayer, MapLayerData } from "layers/map"; import { TraceLayerData, controller as traceController } from "layers/trace"; -import { filter, find, map, merge, reduce, set } from "lodash"; +import { filter, find, map, merge, omit, reduce, set } from "lodash"; import { nanoid as id } from "nanoid"; import { produce, withProduce } from "produce"; import { useMemo } from "react"; @@ -44,8 +45,9 @@ export type QueryLayerData = { } & TraceLayerData; export const controller = { - ...traceController, + ...omit(traceController, "claimImportedFile"), key: "query", + icon: , editor: withProduce(({ value, produce }) => { const { algorithm } = value?.source ?? {}; const { @@ -189,8 +191,9 @@ export const controller = { return <>{}; }), inferName: (l) => l.source?.trace?.name ?? "Untitled Query", - getSelectionInfo: ({ children, event, layer: key }) => { - const TraceLayerSelectionInfoProvider = traceController.getSelectionInfo; + provideSelectionInfo: ({ children, event, layer: key }) => { + const TraceLayerSelectionInfoProvider = + traceController.provideSelectionInfo; const { layer, setLayer, layers } = useLayer(key); const mapLayerData = useMemo(() => { const filteredLayers = filter(layers, { diff --git a/client/src/layers/trace/index.tsx b/client/src/layers/trace/index.tsx index edb6a85f..600732e9 100644 --- a/client/src/layers/trace/index.tsx +++ b/client/src/layers/trace/index.tsx @@ -1,10 +1,15 @@ -import { ArrowOutwardRounded } from "@mui/icons-material"; +import { ArrowOutwardRounded, RouteTwoTone } from "@mui/icons-material"; import { Box, Typography, useTheme } from "@mui/material"; import { TracePicker } from "components/app-bar/Input"; import { PlaybackLayerData, PlaybackService, } from "components/app-bar/Playback"; +import { + custom, + isTraceFormat, + readUploadedTrace, +} from "components/app-bar/upload"; import { PropertyList } from "components/inspector/PropertyList"; import { Heading, Option } from "components/layer-editor/Option"; import { TracePreview } from "components/layer-editor/TracePreview"; @@ -22,6 +27,7 @@ import { constant, findLast, forEach, + get, head, isUndefined, keyBy, @@ -39,7 +45,7 @@ import { useEffect, useMemo } from "react"; import { useThrottle } from "react-use"; import { UploadedTrace } from "slices/UIState"; import { Layer, useLayer } from "slices/layers"; -import { usePaper } from "theme"; +import { name } from "utils/path"; const isNullish = (x: KeyRef): x is Exclude => x === undefined || x === null; @@ -112,20 +118,42 @@ export type TraceLayer = Layer; export const controller = { key: "trace", + icon: , inferName: (layer) => layer.source?.trace?.name ?? "Untitled Trace", error: (layer) => layer?.source?.trace?.error || layer?.source?.parsedTrace?.error, + claimImportedFile: async (file) => + isTraceFormat(file) + ? { + claimed: true, + layer: async (notify) => { + notify("Opening trace..."); + try { + const output = readUploadedTrace(file); + return { trace: { ...(await output.read()) } }; + } catch (e) { + console.error(e); + notify(`Error opening, ${get(e, "message")}`); + return { + trace: { + key: id(), + id: custom().id, + error: get(e, "message"), + name: startCase(name(file.name)), + }, + }; + } + }, + } + : { claimed: false }, editor: withProduce(({ value, produce }) => { - const paper = usePaper(); return ( <>