waveterm/frontend/app/block/block.tsx
2024-07-05 15:47:35 -07:00

677 lines
23 KiB
TypeScript

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { CodeEdit } from "@/app/view/codeedit";
import { PlotView } from "@/app/view/plotview";
import { PreviewView } from "@/app/view/preview";
import { TerminalView } from "@/app/view/term/term";
import { WaveAi } from "@/app/view/waveai";
import { WebView } from "@/app/view/webview";
import { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems";
import { ContextMenuModel } from "@/store/contextmenu";
import { atoms, globalStore, setBlockFocus, useBlockAtom } from "@/store/global";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
import * as util from "@/util/util";
import clsx from "clsx";
import * as jotai from "jotai";
import * as React from "react";
import "./block.less";
const HoverPixels = 15;
const HoverTimeoutMs = 100;
interface BlockProps {
blockId: string;
onClose?: () => void;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/;
function processTitleString(titleString: string): React.ReactNode[] {
if (titleString == null) {
return null;
}
const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g;
let lastIdx = 0;
let match;
let partsStack = [[]];
while ((match = tagRegex.exec(titleString)) != null) {
const lastPart = partsStack[partsStack.length - 1];
const before = titleString.substring(lastIdx, match.index);
lastPart.push(before);
lastIdx = match.index + match[0].length;
const [_, isClosing, tagName, tagParam] = match;
if (tagName == "icon" && !isClosing) {
if (tagParam == null) {
continue;
}
const iconClass = util.makeIconClass(tagParam, false);
if (iconClass == null) {
continue;
}
lastPart.push(<i key={match.index} className={iconClass} />);
continue;
}
if (tagName == "c" || tagName == "color") {
if (isClosing) {
if (partsStack.length <= 1) {
continue;
}
partsStack.pop();
continue;
}
if (tagParam == null) {
continue;
}
if (!tagParam.match(colorRegex)) {
continue;
}
let children = [];
const rtag = React.createElement("span", { key: match.index, style: { color: tagParam } }, children);
lastPart.push(rtag);
partsStack.push(children);
continue;
}
if (tagName == "i" || tagName == "b") {
if (isClosing) {
if (partsStack.length <= 1) {
continue;
}
partsStack.pop();
continue;
}
let children = [];
const rtag = React.createElement(tagName, { key: match.index }, children);
lastPart.push(rtag);
partsStack.push(children);
continue;
}
}
partsStack[partsStack.length - 1].push(titleString.substring(lastIdx));
return partsStack[0];
}
function getBlockHeaderIcon(blockIcon: string, blockData: Block): React.ReactNode {
let blockIconElem: React.ReactNode = null;
if (util.isBlank(blockIcon)) {
blockIcon = "square";
}
let iconColor = blockData?.meta?.["icon:color"];
if (iconColor && !iconColor.match(colorRegex)) {
iconColor = null;
}
let iconStyle = null;
if (!util.isBlank(iconColor)) {
iconStyle = { color: iconColor };
}
const iconClass = util.makeIconClass(blockIcon, true);
if (iconClass != null) {
blockIconElem = <i key="icon" style={iconStyle} className={clsx(`block-frame-icon`, iconClass)} />;
}
return blockIconElem;
}
function getBlockHeaderText(blockIcon: string, blockData: Block, settings: SettingsConfigType): React.ReactNode {
if (!blockData) {
return "no block data";
}
let blockIdStr = "";
if (settings?.blockheader?.showblockids) {
blockIdStr = ` [${blockData.oid.substring(0, 8)}]`;
}
let blockIconElem = getBlockHeaderIcon(blockIcon, blockData);
if (!util.isBlank(blockData?.meta?.title)) {
try {
const rtn = processTitleString(blockData.meta.title) ?? [];
return [blockIconElem, ...rtn, blockIdStr == "" ? null : blockIdStr];
} catch (e) {
console.error("error processing title", blockData.meta.title, e);
return [blockIconElem, blockData.meta.title + blockIdStr];
}
}
let viewString = blockData?.view;
if (blockData.controller == "cmd") {
viewString = "cmd";
}
return [blockIconElem, viewString + blockIdStr];
}
function handleHeaderContextMenu(e: React.MouseEvent<HTMLDivElement>, blockData: Block, onClose: () => void) {
e.preventDefault();
e.stopPropagation();
let menu: ContextMenuItem[] = [];
menu.push({
label: "Focus Block",
click: () => {
alert("Not Implemented");
},
});
menu.push({
label: "Move to New Window",
click: () => {
let currentTabId = globalStore.get(atoms.activeTabId);
try {
services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid);
} catch (e) {
console.error("error moving block to new window", e);
}
},
});
menu.push({ type: "separator" });
menu.push({
label: "Copy BlockId",
click: () => {
navigator.clipboard.writeText(blockData.oid);
},
});
menu.push({ type: "separator" });
menu.push({
label: "Close Block",
click: onClose,
});
ContextMenuModel.showContextMenu(menu, e);
}
interface FramelessBlockHeaderProps {
blockId: string;
onClose?: () => void;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
const FramelessBlockHeader = React.memo(({ blockId, onClose, dragHandleRef }: FramelessBlockHeaderProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom);
return (
<div
key="header"
className="block-header"
ref={dragHandleRef}
onContextMenu={(e) => handleHeaderContextMenu(e, blockData, onClose)}
>
<div className="block-header-text text-fixed">{getBlockHeaderText(null, blockData, settingsConfig)}</div>
{onClose && (
<div className="close-button" onClick={onClose}>
<i className="fa fa-solid fa-xmark-large" />
</div>
)}
</div>
);
});
const hoverStateOff = "off";
const hoverStatePending = "pending";
const hoverStateOn = "on";
interface BlockFrameProps {
blockId: string;
onClose?: () => void;
onClick?: () => void;
onFocusCapture?: React.FocusEventHandler<HTMLDivElement>;
preview: boolean;
numBlocksInTab?: number;
children?: React.ReactNode;
blockRef?: React.RefObject<HTMLDivElement>;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
const BlockFrame_Tech_Component = ({
blockId,
onClose,
onClick,
onFocusCapture,
preview,
blockRef,
dragHandleRef,
children,
}: BlockFrameProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom);
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
return jotai.atom((get) => {
const winData = get(atoms.waveWindow);
return winData?.activeblockid === blockId;
});
});
let isFocused = jotai.useAtomValue(isFocusedAtom);
const blockIcon = useBlockIcon(blockId);
if (preview) {
isFocused = true;
}
let style: React.CSSProperties = {};
if (!isFocused && blockData?.meta?.["frame:bordercolor"]) {
style.borderColor = blockData.meta["frame:bordercolor"];
}
if (isFocused && blockData?.meta?.["frame:bordercolor:focused"]) {
style.borderColor = blockData.meta["frame:bordercolor:focused"];
}
return (
<div
className={clsx(
"block",
"block-frame-tech",
isFocused ? "block-focused" : null,
preview ? "block-preview" : null
)}
onClick={onClick}
onFocusCapture={onFocusCapture}
ref={blockRef}
style={style}
>
<div
className="block-frame-tech-header"
ref={dragHandleRef}
onContextMenu={(e) => handleHeaderContextMenu(e, blockData, onClose)}
>
{getBlockHeaderText(blockIcon, blockData, settingsConfig)}
</div>
<div className={clsx("block-frame-tech-close")} onClick={onClose}>
<i className="fa fa-solid fa-xmark fa-fw" />
</div>
{preview ? <div className="block-frame-preview" /> : children}
</div>
);
};
const BlockFrame_Tech = React.memo(BlockFrame_Tech_Component) as typeof BlockFrame_Tech_Component;
const BlockFrame_Default_Component = ({
blockId,
onClose,
onClick,
preview,
blockRef,
dragHandleRef,
numBlocksInTab,
onFocusCapture,
children,
}: BlockFrameProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom);
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
return jotai.atom((get) => {
const winData = get(atoms.waveWindow);
return winData?.activeblockid === blockId;
});
});
let isFocused = jotai.useAtomValue(isFocusedAtom);
const blockIcon = useBlockIcon(blockId);
if (preview) {
isFocused = true;
}
let style: React.CSSProperties = {};
if (!isFocused && blockData?.meta?.["frame:bordercolor"]) {
style.borderColor = blockData.meta["frame:bordercolor"];
}
if (isFocused && blockData?.meta?.["frame:bordercolor:focused"]) {
style.borderColor = blockData.meta["frame:bordercolor:focused"];
}
let handleSettings = (e: React.MouseEvent<any>) => {
let menuItems: ContextMenuItem[] = [];
menuItems.push({
label: "Focus Block",
click: () => {
alert("Not Implemented");
},
});
menuItems.push({ label: "Minimize" });
menuItems.push({ type: "separator" });
menuItems.push({
label: "Move to New Window",
click: () => {
let currentTabId = globalStore.get(atoms.activeTabId);
try {
services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid);
} catch (e) {
console.error("error moving block to new window", e);
}
},
});
menuItems.push({ type: "separator" });
menuItems.push({
label: "Copy BlockId",
click: () => {
navigator.clipboard.writeText(blockData.oid);
},
});
menuItems.push({ type: "separator" });
menuItems.push({ label: "Close", click: onClose });
ContextMenuModel.showContextMenu(menuItems, e);
};
if (preview) {
handleSettings = null;
}
return (
<div
className={clsx(
"block",
"block-frame-default",
isFocused ? "block-focused" : null,
preview ? "block-preview" : null,
numBlocksInTab == 1 ? "block-no-highlight" : null
)}
onClick={onClick}
onFocusCapture={onFocusCapture}
ref={blockRef}
style={style}
>
<div
className="block-frame-default-header"
ref={dragHandleRef}
onContextMenu={(e) => handleHeaderContextMenu(e, blockData, onClose)}
>
<div className="block-frame-default-header-iconview">
<div className="block-frame-view-icon">{getBlockHeaderIcon(blockIcon, blockData)}</div>
<div className="block-frame-view-type">{blockViewToName(blockData?.view)}</div>
{settingsConfig?.blockheader?.showblockids && (
<div className="block-frame-blockid">[{blockId.substring(0, 8)}]</div>
)}
</div>
<div className="flex-spacer"></div>
<div className="block-frame-end-icons">
<div className="block-frame-settings" onClick={handleSettings}>
<i className="fa fa-solid fa-cog fa-fw" />
</div>
<div className={clsx("block-frame-default-close")} onClick={onClose}>
<i className="fa fa-solid fa-xmark-large fa-fw" />
</div>
</div>
</div>
{preview ? <div className="block-frame-preview" /> : children}
</div>
);
};
const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component;
const BlockFrame_Frameless = ({
blockId,
onClose,
onClick,
preview,
blockRef,
dragHandleRef,
children,
}: BlockFrameProps) => {
const localBlockRef = React.useRef<HTMLDivElement>(null);
const [showHeader, setShowHeader] = React.useState(preview ? true : false);
const hoverState = React.useRef(hoverStateOff);
// this forward the lcal ref to the blockRef
React.useImperativeHandle(blockRef, () => localBlockRef.current);
React.useEffect(() => {
if (preview) {
return;
}
const block = localBlockRef.current;
let hoverTimeout: NodeJS.Timeout = null;
const handleMouseMove = (event) => {
const rect = block.getBoundingClientRect();
if (event.clientY - rect.top <= HoverPixels) {
if (hoverState.current == hoverStateOff) {
hoverTimeout = setTimeout(() => {
if (hoverState.current == hoverStatePending) {
hoverState.current = hoverStateOn;
setShowHeader(true);
}
}, HoverTimeoutMs);
hoverState.current = hoverStatePending;
}
} else {
if (hoverTimeout) {
if (hoverState.current == hoverStatePending) {
hoverState.current = hoverStateOff;
}
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
}
};
block.addEventListener("mousemove", handleMouseMove);
return () => {
block.removeEventListener("mousemove", handleMouseMove);
};
});
let mouseLeaveHandler = () => {
if (preview) {
return;
}
setShowHeader(false);
hoverState.current = hoverStateOff;
};
return (
<div
className="block block-frame-frameless"
ref={localBlockRef}
onMouseLeave={mouseLeaveHandler}
onClick={onClick}
>
<div
className={clsx("block-header-animation-wrap", showHeader ? "is-showing" : null)}
onMouseLeave={mouseLeaveHandler}
>
<FramelessBlockHeader blockId={blockId} onClose={onClose} dragHandleRef={dragHandleRef} />
</div>
{preview ? <div className="block-frame-preview" /> : children}
</div>
);
};
const BlockFrame = React.memo((props: BlockFrameProps) => {
const blockId = props.blockId;
const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const tabData = jotai.useAtomValue(atoms.tabAtom);
if (!blockId || !blockData) {
return null;
}
let FrameElem = BlockFrame_Default;
const numBlocks = tabData?.blockids?.length ?? 0;
// Preview should always render as the full tech frame
if (!props.preview) {
// if 0 or 1 blocks, use frameless, otherwise use tech
// if (numBlocks <= 1) {
// FrameElem = BlockFrame_Frameless;
// }
if (blockData?.meta?.["frame"] === "tech") {
FrameElem = BlockFrame_Tech;
} else if (blockData?.meta?.["frame"] === "frameless") {
FrameElem = BlockFrame_Frameless;
}
}
return <FrameElem {...props} numBlocksInTab={numBlocks} />;
});
function blockViewToIcon(view: string): string {
console.log("blockViewToIcon", view);
if (view == "term") {
return "terminal";
}
if (view == "preview") {
return "file";
}
if (view == "web") {
return "globe";
}
if (view == "waveai") {
return "sparkles";
}
return null;
}
function blockViewToName(view: string): string {
if (view == "term") {
return "Terminal";
}
if (view == "preview") {
return "Preview";
}
if (view == "web") {
return "Web";
}
if (view == "waveai") {
return "WaveAI";
}
return view;
}
function useBlockIcon(blockId: string): string {
const blockIconOverrideAtom = useBlockAtom<string>(blockId, "blockicon:override", () => {
return jotai.atom<string>(null);
});
const blockIconAtom = useBlockAtom<string>(blockId, "blockicon", () => {
return jotai.atom((get) => {
console.log("atom-blockicon", blockId);
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId));
const blockData = get(blockAtom);
const metaIcon = blockData?.meta?.icon;
if (!util.isBlank(metaIcon)) {
console.log("atom-blockicon-meta", metaIcon);
return metaIcon;
}
const overrideVal = get(blockIconOverrideAtom);
if (overrideVal != null) {
return overrideVal;
}
return blockViewToIcon(blockData?.view);
});
});
const blockIcon = jotai.useAtomValue(blockIconAtom);
return blockIcon;
}
const wm = new WeakMap();
let wmCounter = 0;
function getObjectId(obj: any): number {
if (!wm.has(obj)) {
wm.set(obj, wmCounter++);
}
return wm.get(obj);
}
const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => {
let blockElem: JSX.Element = null;
const focusElemRef = React.useRef<HTMLInputElement>(null);
const blockRef = React.useRef<HTMLDivElement>(null);
const [blockClicked, setBlockClicked] = React.useState(false);
const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const [focusedChild, setFocusedChild] = React.useState(null);
const isFocusedAtom = useBlockAtom<boolean>(blockId, "isFocused", () => {
return jotai.atom((get) => {
const winData = get(atoms.waveWindow);
return winData.activeblockid === blockId;
});
});
let isFocused = jotai.useAtomValue(isFocusedAtom);
React.useLayoutEffect(() => {
setBlockClicked(isFocused);
}, [isFocused]);
React.useLayoutEffect(() => {
if (!blockClicked) {
return;
}
setBlockClicked(false);
const focusWithin = blockRef.current?.contains(document.activeElement);
if (!focusWithin) {
setFocusTarget();
}
setBlockFocus(blockId);
}, [blockClicked]);
React.useLayoutEffect(() => {
if (focusedChild == null) {
return;
}
setBlockFocus(blockId);
}, [focusedChild, blockId]);
// treat the block as clicked on creation
const setBlockClickedTrue = React.useCallback(() => {
setBlockClicked(true);
}, []);
const determineFocusedChild = React.useCallback(
(event: React.FocusEvent<HTMLDivElement, Element>) => {
setFocusedChild(event.target);
},
[setFocusedChild]
);
const getFocusableChildren = React.useCallback(() => {
if (blockRef.current == null) {
return [];
}
return Array.from(
blockRef.current.querySelectorAll(
'a[href], area[href], input:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex="0"]'
)
).filter((elem) => elem.id != `${blockId}-dummy-focus`);
}, [blockRef.current]);
const setFocusTarget = React.useCallback(() => {
const focusableChildren = getFocusableChildren();
if (focusableChildren.length == 0) {
focusElemRef.current.focus({ preventScroll: true });
} else {
(focusableChildren[0] as HTMLElement).focus({ preventScroll: true });
}
}, [focusElemRef.current, getFocusableChildren]);
if (!blockId || !blockData) return null;
if (blockDataLoading) {
blockElem = <CenteredDiv>Loading...</CenteredDiv>;
} else if (blockData.view === "term") {
blockElem = <TerminalView key={blockId} blockId={blockId} />;
} else if (blockData.view === "preview") {
blockElem = <PreviewView key={blockId} blockId={blockId} />;
} else if (blockData.view === "plot") {
blockElem = <PlotView key={blockId} />;
} else if (blockData.view === "codeedit") {
blockElem = <CodeEdit key={blockId} text={null} filename={null} />;
} else if (blockData.view === "web") {
blockElem = <WebView key={blockId} parentRef={blockRef} initialUrl={blockData.meta.url} />;
} else if (blockData.view === "waveai") {
blockElem = <WaveAi key={blockId} parentRef={blockRef} />;
}
return (
<BlockFrame
key={blockId}
blockId={blockId}
onClose={onClose}
preview={false}
onClick={setBlockClickedTrue}
blockRef={blockRef}
dragHandleRef={dragHandleRef}
onFocusCapture={(e) => determineFocusedChild(e)}
>
<div key="focuselem" className="block-focuselem">
<input
type="text"
value=""
ref={focusElemRef}
id={`${blockId}-dummy-focus`}
onChange={() => {}}
disabled={getFocusableChildren().length > 0}
/>
</div>
<div key="content" className="block-content">
<ErrorBoundary>
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{blockElem}</React.Suspense>
</ErrorBoundary>
</div>
</BlockFrame>
);
});
export { Block, BlockFrame };