mirror of
https://github.com/wavetermdev/waveterm
synced 2026-05-24 09:18:27 +00:00
update widgets bar to be MUCH more responsive (#2311)
This commit is contained in:
parent
0f7159858e
commit
76afdc65ce
5 changed files with 294 additions and 222 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright 2025, Command Line Inc.
|
// Copyright 2025, Command Line Inc.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { cn } from "@/util/util";
|
||||||
import {
|
import {
|
||||||
FloatingPortal,
|
FloatingPortal,
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
|
|
@ -11,7 +12,6 @@ import {
|
||||||
useHover,
|
useHover,
|
||||||
useInteractions,
|
useInteractions,
|
||||||
} from "@floating-ui/react";
|
} from "@floating-ui/react";
|
||||||
import { cn } from "@/util/util";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
|
|
@ -19,12 +19,13 @@ interface TooltipProps {
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
placement?: "top" | "bottom" | "left" | "right";
|
placement?: "top" | "bottom" | "left" | "right";
|
||||||
forceOpen?: boolean;
|
forceOpen?: boolean;
|
||||||
|
disable?: boolean;
|
||||||
divClassName?: string;
|
divClassName?: string;
|
||||||
divStyle?: React.CSSProperties;
|
divStyle?: React.CSSProperties;
|
||||||
divOnClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
divOnClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tooltip({
|
function TooltipInner({
|
||||||
children,
|
children,
|
||||||
content,
|
content,
|
||||||
placement = "top",
|
placement = "top",
|
||||||
|
|
@ -32,7 +33,7 @@ export function Tooltip({
|
||||||
divClassName,
|
divClassName,
|
||||||
divStyle,
|
divStyle,
|
||||||
divOnClick,
|
divOnClick,
|
||||||
}: TooltipProps) {
|
}: Omit<TooltipProps, 'disable'>) {
|
||||||
const [isOpen, setIsOpen] = useState(forceOpen);
|
const [isOpen, setIsOpen] = useState(forceOpen);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const timeoutRef = useRef<number | null>(null);
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
|
@ -63,11 +64,7 @@ export function Tooltip({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
placement,
|
placement,
|
||||||
middleware: [
|
middleware: [offset(10), flip(), shift({ padding: 12 })],
|
||||||
offset(10),
|
|
||||||
flip(),
|
|
||||||
shift({ padding: 12 }),
|
|
||||||
],
|
|
||||||
whileElementsMounted: autoUpdate,
|
whileElementsMounted: autoUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -132,7 +129,7 @@ export function Tooltip({
|
||||||
}}
|
}}
|
||||||
{...getFloatingProps()}
|
{...getFloatingProps()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-white dark:bg-panel border border-border rounded-md px-2 py-1 text-xs text-foreground shadow-xl z-50"
|
"bg-gray-800 border border-border rounded-md px-2 py-1 text-xs text-foreground shadow-xl z-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|
@ -141,4 +138,39 @@ export function Tooltip({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Tooltip({
|
||||||
|
children,
|
||||||
|
content,
|
||||||
|
placement = "top",
|
||||||
|
forceOpen = false,
|
||||||
|
disable = false,
|
||||||
|
divClassName,
|
||||||
|
divStyle,
|
||||||
|
divOnClick,
|
||||||
|
}: TooltipProps) {
|
||||||
|
if (disable) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={divClassName}
|
||||||
|
style={divStyle}
|
||||||
|
onClick={divOnClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipInner
|
||||||
|
children={children}
|
||||||
|
content={content}
|
||||||
|
placement={placement}
|
||||||
|
forceOpen={forceOpen}
|
||||||
|
divClassName={divClassName}
|
||||||
|
divStyle={divStyle}
|
||||||
|
divOnClick={divOnClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// Copyright 2024, Command Line Inc.
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
.notification-popover {
|
|
||||||
padding: 8px 2px 8px 0px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 27px;
|
|
||||||
height: 26px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
display: flex;
|
|
||||||
width: 380px;
|
|
||||||
padding: 10px 0 0;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
column-gap: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 0.5px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: #232323;
|
|
||||||
box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 10px 8px 10px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: var(--main-text-color);
|
|
||||||
font-size: 14px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-all-btn {
|
|
||||||
font-size: 13px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 16px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,8 +13,6 @@ import { NotificationItem } from "./notificationitem";
|
||||||
import { useUpdateNotifier } from "./updatenotifier";
|
import { useUpdateNotifier } from "./updatenotifier";
|
||||||
import { useNotification } from "./usenotification";
|
import { useNotification } from "./usenotification";
|
||||||
|
|
||||||
import "./notificationpopover.scss";
|
|
||||||
|
|
||||||
const NotificationPopover = () => {
|
const NotificationPopover = () => {
|
||||||
useUpdateNotifier();
|
useUpdateNotifier();
|
||||||
const {
|
const {
|
||||||
|
|
@ -51,14 +49,14 @@ const NotificationPopover = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
className="notification-popover"
|
className="w-full pb-2 pt-1 pl-0 pr-0.5 flex items-center justify-center"
|
||||||
placement="left-end"
|
placement="left-end"
|
||||||
offset={{ mainAxis: 20, crossAxis: 2 }}
|
offset={{ mainAxis: 20, crossAxis: 2 }}
|
||||||
onDismiss={handleTogglePopover}
|
onDismiss={handleTogglePopover}
|
||||||
>
|
>
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"notification-trigger-button horizontal-padding-6 vertical-padding-4 border-radius-",
|
"w-[27px] h-[26px] flex justify-center [&>i]:text-[17px] horizontal-padding-6 vertical-padding-4 border-radius-",
|
||||||
addOnClassNames
|
addOnClassNames
|
||||||
)}
|
)}
|
||||||
disabled={notifications.length === 0}
|
disabled={notifications.length === 0}
|
||||||
|
|
@ -67,11 +65,11 @@ const NotificationPopover = () => {
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<PopoverContent className="notification-content">
|
<PopoverContent className="flex w-[380px] pt-2.5 pb-0 px-0 flex-col items-start gap-x-2 rounded-lg border-[0.5px] border-white/12 bg-[#232323] shadow-[0px_8px_32px_0px_rgba(0,0,0,0.25)]">
|
||||||
<div className="header">
|
<div className="flex items-center justify-between w-full px-2.5 pb-2 border-b border-white/8">
|
||||||
<span>Notifications</span>
|
<span className="text-foreground text-sm font-semibold leading-4">Notifications</span>
|
||||||
<Button
|
<Button
|
||||||
className="ghost grey close-all-btn horizontal-padding-3 vertical-padding-3"
|
className="ghost grey text-[13px] font-normal leading-4 text-white/40 horizontal-padding-3 vertical-padding-3"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeAllNotifications();
|
removeAllNotifications();
|
||||||
|
|
@ -98,7 +96,7 @@ const NotificationPopover = () => {
|
||||||
onMouseEnter={() => setHoveredId(notif.id)}
|
onMouseEnter={() => setHoveredId(notif.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
/>
|
/>
|
||||||
{index !== notifications.length - 1 && <div className="divider"></div>}
|
{index !== notifications.length - 1 && <div className="bg-white/8 h-px w-full"></div>}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
|
|
|
||||||
243
frontend/app/workspace/widgets.tsx
Normal file
243
frontend/app/workspace/widgets.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
// Copyright 2025, Command Line Inc.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { Tooltip } from "@/app/element/tooltip";
|
||||||
|
import { NotificationPopover } from "@/app/notification/notificationpopover";
|
||||||
|
import { ContextMenuModel } from "@/app/store/contextmenu";
|
||||||
|
import { RpcApi } from "@/app/store/wshclientapi";
|
||||||
|
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||||
|
import { atoms, createBlock, getApi, isDev } from "@/store/global";
|
||||||
|
import { fireAndForget, isBlank, makeIconClass } from "@/util/util";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetConfigType[] {
|
||||||
|
if (wmap == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const wlist = Object.values(wmap);
|
||||||
|
wlist.sort((a, b) => {
|
||||||
|
return (a["display:order"] ?? 0) - (b["display:order"] ?? 0);
|
||||||
|
});
|
||||||
|
return wlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWidgetSelect(widget: WidgetConfigType) {
|
||||||
|
const blockDef = widget.blockdef;
|
||||||
|
createBlock(blockDef, widget.magnified);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Widget = memo(({ widget, mode }: { widget: WidgetConfigType; mode: "normal" | "compact" | "supercompact" }) => {
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
const labelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "normal" && labelRef.current) {
|
||||||
|
const element = labelRef.current;
|
||||||
|
setIsTruncated(element.scrollWidth > element.clientWidth);
|
||||||
|
}
|
||||||
|
}, [mode, widget.label]);
|
||||||
|
|
||||||
|
const shouldDisableTooltip = mode !== "normal" ? false : !isTruncated;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={widget.description || widget.label}
|
||||||
|
placement="left"
|
||||||
|
disable={shouldDisableTooltip}
|
||||||
|
divClassName={clsx(
|
||||||
|
"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer",
|
||||||
|
mode === "supercompact" ? "text-sm" : "text-lg",
|
||||||
|
widget["display:hidden"] && "hidden"
|
||||||
|
)}
|
||||||
|
divOnClick={() => handleWidgetSelect(widget)}
|
||||||
|
>
|
||||||
|
<div style={{ color: widget.color }}>
|
||||||
|
<i className={makeIconClass(widget.icon, true, { defaultIcon: "browser" })}></i>
|
||||||
|
</div>
|
||||||
|
{mode === "normal" && !isBlank(widget.label) ? (
|
||||||
|
<div
|
||||||
|
ref={labelRef}
|
||||||
|
className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
>
|
||||||
|
{widget.label}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const Widgets = memo(() => {
|
||||||
|
const fullConfig = useAtomValue(atoms.fullConfigAtom);
|
||||||
|
const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const measurementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const helpWidget: WidgetConfigType = {
|
||||||
|
icon: "circle-question",
|
||||||
|
label: "help",
|
||||||
|
blockdef: {
|
||||||
|
meta: {
|
||||||
|
view: "help",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const tipsWidget: WidgetConfigType = {
|
||||||
|
icon: "lightbulb",
|
||||||
|
label: "tips",
|
||||||
|
blockdef: {
|
||||||
|
meta: {
|
||||||
|
view: "tips",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const showHelp = fullConfig?.settings?.["widget:showhelp"] ?? true;
|
||||||
|
const widgets = sortByDisplayOrder(fullConfig?.widgets);
|
||||||
|
|
||||||
|
const checkModeNeeded = useCallback(() => {
|
||||||
|
if (!containerRef.current || !measurementRef.current) return;
|
||||||
|
|
||||||
|
const containerHeight = containerRef.current.clientHeight;
|
||||||
|
const normalHeight = measurementRef.current.scrollHeight;
|
||||||
|
const gracePeriod = 10;
|
||||||
|
|
||||||
|
let newMode: "normal" | "compact" | "supercompact" = "normal";
|
||||||
|
|
||||||
|
if (normalHeight > containerHeight - gracePeriod) {
|
||||||
|
newMode = "compact";
|
||||||
|
|
||||||
|
// Calculate total widget count for supercompact check
|
||||||
|
const totalWidgets = (widgets?.length || 0) + (showHelp ? 2 : 0);
|
||||||
|
const minHeightPerWidget = 32;
|
||||||
|
const requiredHeight = totalWidgets * minHeightPerWidget;
|
||||||
|
|
||||||
|
if (requiredHeight > containerHeight) {
|
||||||
|
newMode = "supercompact";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMode !== mode) {
|
||||||
|
setMode(newMode);
|
||||||
|
}
|
||||||
|
}, [mode, widgets, showHelp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
checkModeNeeded();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [checkModeNeeded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkModeNeeded();
|
||||||
|
}, [widgets, showHelp, checkModeNeeded]);
|
||||||
|
|
||||||
|
const handleWidgetsBarContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const menu: ContextMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: "Edit widgets.json",
|
||||||
|
click: () => {
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const path = `${getApi().getConfigDir()}/widgets.json`;
|
||||||
|
const blockDef: BlockDef = {
|
||||||
|
meta: { view: "preview", file: path },
|
||||||
|
};
|
||||||
|
await createBlock(blockDef, false, true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Show Help Widgets",
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: "On",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: showHelp,
|
||||||
|
click: () => {
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await RpcApi.SetConfigCommand(TabRpcClient, { "widget:showhelp": true });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Off",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: !showHelp,
|
||||||
|
click: () => {
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await RpcApi.SetConfigCommand(TabRpcClient, { "widget:showhelp": false });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ContextMenuModel.showContextMenu(menu, e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex flex-col w-12 overflow-hidden py-1 -ml-1 select-none"
|
||||||
|
onContextMenu={handleWidgetsBarContextMenu}
|
||||||
|
>
|
||||||
|
{mode === "supercompact" ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-0 w-full">
|
||||||
|
{widgets?.map((data, idx) => <Widget key={`widget-${idx}`} widget={data} mode={mode} />)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow" />
|
||||||
|
{showHelp ? (
|
||||||
|
<div className="grid grid-cols-2 gap-0 w-full">
|
||||||
|
<Widget key="tips" widget={tipsWidget} mode={mode} />
|
||||||
|
<Widget key="help" widget={helpWidget} mode={mode} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{widgets?.map((data, idx) => <Widget key={`widget-${idx}`} widget={data} mode={mode} />)}
|
||||||
|
<div className="flex-grow" />
|
||||||
|
{showHelp ? (
|
||||||
|
<>
|
||||||
|
<Widget key="tips" widget={tipsWidget} mode={mode} />
|
||||||
|
<Widget key="help" widget={helpWidget} mode={mode} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isDev() ? <NotificationPopover /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={measurementRef}
|
||||||
|
className="flex flex-col w-12 py-1 -ml-1 select-none absolute -z-10 opacity-0 pointer-events-none"
|
||||||
|
>
|
||||||
|
{widgets?.map((data, idx) => (
|
||||||
|
<Widget key={`measurement-widget-${idx}`} widget={data} mode="normal" />
|
||||||
|
))}
|
||||||
|
<div className="flex-grow" />
|
||||||
|
{showHelp ? (
|
||||||
|
<>
|
||||||
|
<Widget key="measurement-tips" widget={tipsWidget} mode="normal" />
|
||||||
|
<Widget key="measurement-help" widget={helpWidget} mode="normal" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{isDev() ? <NotificationPopover /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { Widgets };
|
||||||
|
|
@ -4,150 +4,13 @@
|
||||||
import { ErrorBoundary } from "@/app/element/errorboundary";
|
import { ErrorBoundary } from "@/app/element/errorboundary";
|
||||||
import { CenteredDiv } from "@/app/element/quickelems";
|
import { CenteredDiv } from "@/app/element/quickelems";
|
||||||
import { ModalsRenderer } from "@/app/modals/modalsrenderer";
|
import { ModalsRenderer } from "@/app/modals/modalsrenderer";
|
||||||
import { NotificationPopover } from "@/app/notification/notificationpopover";
|
|
||||||
import { ContextMenuModel } from "@/app/store/contextmenu";
|
|
||||||
import { RpcApi } from "@/app/store/wshclientapi";
|
|
||||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
|
||||||
import { TabBar } from "@/app/tab/tabbar";
|
import { TabBar } from "@/app/tab/tabbar";
|
||||||
import { TabContent } from "@/app/tab/tabcontent";
|
import { TabContent } from "@/app/tab/tabcontent";
|
||||||
import { atoms, createBlock, getApi, isDev } from "@/store/global";
|
import { Widgets } from "@/app/workspace/widgets";
|
||||||
import { fireAndForget, isBlank, makeIconClass } from "@/util/util";
|
import { atoms } from "@/store/global";
|
||||||
import clsx from "clsx";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
|
||||||
const iconRegex = /^[a-z0-9-]+$/;
|
|
||||||
|
|
||||||
function keyLen(obj: Object): number {
|
|
||||||
if (obj == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Object.keys(obj).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetConfigType[] {
|
|
||||||
if (wmap == null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const wlist = Object.values(wmap);
|
|
||||||
wlist.sort((a, b) => {
|
|
||||||
return (a["display:order"] ?? 0) - (b["display:order"] ?? 0);
|
|
||||||
});
|
|
||||||
return wlist;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Widgets = memo(() => {
|
|
||||||
const fullConfig = useAtomValue(atoms.fullConfigAtom);
|
|
||||||
const helpWidget: WidgetConfigType = {
|
|
||||||
icon: "circle-question",
|
|
||||||
label: "help",
|
|
||||||
blockdef: {
|
|
||||||
meta: {
|
|
||||||
view: "help",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const tipsWidget: WidgetConfigType = {
|
|
||||||
icon: "lightbulb",
|
|
||||||
label: "tips",
|
|
||||||
blockdef: {
|
|
||||||
meta: {
|
|
||||||
view: "tips",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const showHelp = fullConfig?.settings?.["widget:showhelp"] ?? true;
|
|
||||||
const widgets = sortByDisplayOrder(fullConfig?.widgets);
|
|
||||||
|
|
||||||
const handleWidgetsBarContextMenu = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const menu: ContextMenuItem[] = [
|
|
||||||
{
|
|
||||||
label: "Edit widgets.json",
|
|
||||||
click: () => {
|
|
||||||
fireAndForget(async () => {
|
|
||||||
const path = `${getApi().getConfigDir()}/widgets.json`;
|
|
||||||
const blockDef: BlockDef = {
|
|
||||||
meta: { view: "preview", file: path },
|
|
||||||
};
|
|
||||||
await createBlock(blockDef, false, true);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Show Help Widgets",
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "On",
|
|
||||||
type: "checkbox",
|
|
||||||
checked: showHelp,
|
|
||||||
click: () => {
|
|
||||||
fireAndForget(async () => {
|
|
||||||
await RpcApi.SetConfigCommand(TabRpcClient, { "widget:showhelp": true });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Off",
|
|
||||||
type: "checkbox",
|
|
||||||
checked: !showHelp,
|
|
||||||
click: () => {
|
|
||||||
fireAndForget(async () => {
|
|
||||||
await RpcApi.SetConfigCommand(TabRpcClient, { "widget:showhelp": false });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
ContextMenuModel.showContextMenu(menu, e);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex flex-col w-12 overflow-hidden py-1 -ml-1 select-none"
|
|
||||||
onContextMenu={handleWidgetsBarContextMenu}
|
|
||||||
>
|
|
||||||
{widgets?.map((data, idx) => <Widget key={`widget-${idx}`} widget={data} />)}
|
|
||||||
<div className="flex-grow" />
|
|
||||||
{showHelp ? (
|
|
||||||
<>
|
|
||||||
<Widget key="tips" widget={tipsWidget} />
|
|
||||||
<Widget key="help" widget={helpWidget} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{isDev() ? <NotificationPopover /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleWidgetSelect(widget: WidgetConfigType) {
|
|
||||||
const blockDef = widget.blockdef;
|
|
||||||
createBlock(blockDef, widget.magnified);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Widget = memo(({ widget }: { widget: WidgetConfigType }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer",
|
|
||||||
widget["display:hidden"] && "hidden"
|
|
||||||
)}
|
|
||||||
onClick={() => handleWidgetSelect(widget)}
|
|
||||||
title={widget.description || widget.label}
|
|
||||||
>
|
|
||||||
<div style={{ color: widget.color }}>
|
|
||||||
<i className={makeIconClass(widget.icon, true, { defaultIcon: "browser" })}></i>
|
|
||||||
</div>
|
|
||||||
{!isBlank(widget.label) ? (
|
|
||||||
<div className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden">
|
|
||||||
{widget.label}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const WorkspaceElem = memo(() => {
|
const WorkspaceElem = memo(() => {
|
||||||
const tabId = useAtomValue(atoms.staticTabId);
|
const tabId = useAtomValue(atoms.staticTabId);
|
||||||
const ws = useAtomValue(atoms.workspace);
|
const ws = useAtomValue(atoms.workspace);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue