waveterm/frontend/app/block/block.tsx

409 lines
14 KiB
TypeScript
Raw Normal View History

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { CodeEdit } from "@/app/view/codeedit";
import { PlotView } from "@/app/view/plotview";
2024-05-28 21:12:28 +02:00
import { PreviewView } from "@/app/view/preview";
import { TerminalView } from "@/app/view/term/term";
import { ErrorBoundary } from "@/element/errorboundary";
2024-05-17 07:48:23 +02:00
import { CenteredDiv } from "@/element/quickelems";
import { ContextMenuModel } from "@/store/contextmenu";
import { atoms, setBlockFocus, useBlockAtom } from "@/store/global";
2024-05-28 21:12:28 +02:00
import * as WOS from "@/store/wos";
2024-06-21 23:44:11 +02:00
import * as util from "@/util/util";
import clsx from "clsx";
import * as jotai from "jotai";
2024-05-28 21:12:28 +02:00
import * as React from "react";
import "./block.less";
const HoverPixels = 15;
const HoverTimeoutMs = 100;
interface BlockProps {
blockId: string;
Implement outer drop direction, add rudimentary drag preview image rendering (#29) This PR adds support for Outer variants of each DropDirection. When calculating the drop direction, the cursor position is calculated relevant to the box over which it is hovering. The following diagram shows how drop directions are calculated. The colored in center is currently not supported, it is assigned to the top, bottom, left, right direction for now, though it will ultimately be its own distinct direction. ![IMG_3505](https://github.com/wavetermdev/thenextwave/assets/16651283/a7ea7387-b95d-4831-9e29-d3225b824c97) When an outer drop direction is provided for a move operation, if the reference node flexes in the same axis as the drop direction, the new node will be inserted at the same level as the parent of the reference node. If the reference node flexes in a different direction or the reference node does not have a grandparent, the operation will fall back to its non-Outer variant. This also removes some chatty debug statements, adds a blur to the currently-dragging node to indicate that it cannot be dropped onto, and simplifies the deriving of the layout state atom from the tab atom so there's no longer another intermediate derived atom for the layout node. This also adds rudimentary support for rendering custom preview images for any tile being dragged. Right now, this is a simple block containing the block ID, but this can be anything. This resolves an issue where letting React-DnD generate its own previews could take up to a half second, and would block dragging until complete. For Monaco, this was outright failing. It also fixes an issue where the tile layout could animate on first paint. Now, I use React Suspense to prevent the layout from displaying until all the children have loaded.
2024-06-11 22:03:41 +02:00
onClose?: () => void;
2024-06-20 08:00:57 +02:00
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
2024-06-22 00:15:38 +02:00
const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/;
function processTitleString(titleString: string): React.ReactNode[] {
if (titleString == null) {
2024-06-21 23:44:11 +02:00
return null;
}
const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9-]+))?>/g;
let lastIdx = 0;
let match;
let partsStack = [[]];
2024-06-22 00:15:38 +02:00
while ((match = tagRegex.exec(titleString)) != null) {
2024-06-21 23:44:11 +02:00
const lastPart = partsStack[partsStack.length - 1];
2024-06-22 00:15:38 +02:00
const before = titleString.substring(lastIdx, match.index);
2024-06-21 23:44:11 +02:00
lastPart.push(before);
lastIdx = match.index + match[0].length;
const [_, isClosing, tagName, tagParam] = match;
if (tagName == "icon" && !isClosing) {
if (tagParam == null) {
continue;
}
if (!tagParam.match(/^[a-z0-9-]+$/)) {
continue;
}
lastPart.push(<i key={match.index} className={`fa fa-solid fa-${tagParam}`} />);
continue;
}
if (tagName == "c" || tagName == "color") {
if (isClosing) {
if (partsStack.length <= 1) {
continue;
}
partsStack.pop();
continue;
}
2024-06-22 00:15:38 +02:00
if (tagParam == null) {
continue;
}
if (!tagParam.match(colorRegex)) {
continue;
}
2024-06-21 23:44:11 +02:00
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;
}
}
2024-06-22 00:15:38 +02:00
partsStack[partsStack.length - 1].push(titleString.substring(lastIdx));
2024-06-21 23:44:11 +02:00
return partsStack[0];
}
2024-05-16 22:22:46 +02:00
function getBlockHeaderText(blockIcon: string, blockData: Block): React.ReactNode {
if (!blockData) {
return "no block data";
}
let blockIconElem: React.ReactNode = null;
if (!util.isBlank(blockIcon)) {
2024-06-22 01:59:09 +02:00
let iconColor = blockData?.meta?.["icon:color"];
2024-06-22 00:15:38 +02:00
if (iconColor && !iconColor.match(colorRegex)) {
iconColor = null;
}
let iconStyle = null;
if (!util.isBlank(iconColor)) {
iconStyle = { color: iconColor };
}
if (blockIcon.match(/^[a-z0-9-]+$/)) {
blockIconElem = (
<i key="icon" style={iconStyle} className={`block-frame-icon fa fa-solid fa-${blockIcon}`} />
);
2024-06-22 00:15:38 +02:00
}
}
2024-06-21 23:44:11 +02:00
if (!util.isBlank(blockData?.meta?.title)) {
try {
2024-06-22 00:15:38 +02:00
const rtn = processTitleString(blockData.meta.title) ?? [];
if (blockIconElem) {
rtn.unshift(blockIconElem);
2024-06-22 00:15:38 +02:00
}
return rtn;
2024-06-21 23:44:11 +02:00
} catch (e) {
console.error("error processing title", blockData.meta.title, e);
return [blockIconElem, blockData.meta.title];
2024-06-21 23:44:11 +02:00
}
}
return [blockIconElem, `${blockData?.view} [${blockData.oid.substring(0, 8)}]`];
}
2024-06-20 08:00:57 +02:00
interface FramelessBlockHeaderProps {
blockId: string;
onClose?: () => void;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
const FramelessBlockHeader = ({ blockId, onClose, dragHandleRef }: FramelessBlockHeaderProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
Implement outer drop direction, add rudimentary drag preview image rendering (#29) This PR adds support for Outer variants of each DropDirection. When calculating the drop direction, the cursor position is calculated relevant to the box over which it is hovering. The following diagram shows how drop directions are calculated. The colored in center is currently not supported, it is assigned to the top, bottom, left, right direction for now, though it will ultimately be its own distinct direction. ![IMG_3505](https://github.com/wavetermdev/thenextwave/assets/16651283/a7ea7387-b95d-4831-9e29-d3225b824c97) When an outer drop direction is provided for a move operation, if the reference node flexes in the same axis as the drop direction, the new node will be inserted at the same level as the parent of the reference node. If the reference node flexes in a different direction or the reference node does not have a grandparent, the operation will fall back to its non-Outer variant. This also removes some chatty debug statements, adds a blur to the currently-dragging node to indicate that it cannot be dropped onto, and simplifies the deriving of the layout state atom from the tab atom so there's no longer another intermediate derived atom for the layout node. This also adds rudimentary support for rendering custom preview images for any tile being dragged. Right now, this is a simple block containing the block ID, but this can be anything. This resolves an issue where letting React-DnD generate its own previews could take up to a half second, and would block dragging until complete. For Monaco, this was outright failing. It also fixes an issue where the tile layout could animate on first paint. Now, I use React Suspense to prevent the layout from displaying until all the children have loaded.
2024-06-11 22:03:41 +02:00
return (
2024-06-20 08:00:57 +02:00
<div key="header" className="block-header" ref={dragHandleRef}>
<div className="block-header-text text-fixed">{getBlockHeaderText(null, blockData)}</div>
Implement outer drop direction, add rudimentary drag preview image rendering (#29) This PR adds support for Outer variants of each DropDirection. When calculating the drop direction, the cursor position is calculated relevant to the box over which it is hovering. The following diagram shows how drop directions are calculated. The colored in center is currently not supported, it is assigned to the top, bottom, left, right direction for now, though it will ultimately be its own distinct direction. ![IMG_3505](https://github.com/wavetermdev/thenextwave/assets/16651283/a7ea7387-b95d-4831-9e29-d3225b824c97) When an outer drop direction is provided for a move operation, if the reference node flexes in the same axis as the drop direction, the new node will be inserted at the same level as the parent of the reference node. If the reference node flexes in a different direction or the reference node does not have a grandparent, the operation will fall back to its non-Outer variant. This also removes some chatty debug statements, adds a blur to the currently-dragging node to indicate that it cannot be dropped onto, and simplifies the deriving of the layout state atom from the tab atom so there's no longer another intermediate derived atom for the layout node. This also adds rudimentary support for rendering custom preview images for any tile being dragged. Right now, this is a simple block containing the block ID, but this can be anything. This resolves an issue where letting React-DnD generate its own previews could take up to a half second, and would block dragging until complete. For Monaco, this was outright failing. It also fixes an issue where the tile layout could animate on first paint. Now, I use React Suspense to prevent the layout from displaying until all the children have loaded.
2024-06-11 22:03:41 +02:00
{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;
preview: boolean;
children?: React.ReactNode;
blockRef?: React.RefObject<HTMLDivElement>;
2024-06-20 08:00:57 +02:00
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
2024-06-20 08:00:57 +02:00
const BlockFrame_Tech = ({
blockId,
onClose,
onClick,
preview,
blockRef,
dragHandleRef,
children,
}: BlockFrameProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
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;
}
function handleContextMenu(e: React.MouseEvent<HTMLDivElement>) {
let menu: ContextMenuItem[] = [];
menu.push({
label: "Close",
click: onClose,
});
ContextMenuModel.showContextMenu(menu, e);
}
2024-06-21 23:44:11 +02:00
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}
ref={blockRef}
2024-06-21 23:44:11 +02:00
style={style}
>
<div className="block-frame-tech-header" ref={dragHandleRef} onContextMenu={handleContextMenu}>
{getBlockHeaderText(blockIcon, blockData)}
2024-06-20 08:00:57 +02:00
</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>
);
};
2024-06-20 08:00:57 +02:00
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}
>
2024-06-20 08:00:57 +02:00
<FramelessBlockHeader blockId={blockId} onClose={onClose} dragHandleRef={dragHandleRef} />
</div>
{preview ? <div className="block-frame-preview" /> : children}
</div>
);
};
const BlockFrame = (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;
}
2024-06-22 00:15:38 +02:00
let FrameElem = BlockFrame_Tech;
// if 0 or 1 blocks, use frameless, otherwise use tech
const numBlocks = tabData?.blockids?.length ?? 0;
if (numBlocks <= 1) {
2024-06-22 00:15:38 +02:00
FrameElem = BlockFrame_Frameless;
}
if (blockData?.meta?.["frame"] === "tech") {
FrameElem = BlockFrame_Tech;
} else if (blockData?.meta?.["frame"] === "frameless") {
FrameElem = BlockFrame_Frameless;
}
2024-06-22 00:15:38 +02:00
return <FrameElem {...props} />;
};
function blockViewToIcon(view: string): string {
console.log("blockViewToIcon", view);
if (view == "term") {
return "square-terminal";
}
if (view == "preview") {
return "file";
}
return null;
}
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;
}
2024-06-20 08:00:57 +02:00
const Block = ({ 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));
React.useLayoutEffect(() => {
if (!blockClicked) {
return;
}
setBlockClicked(false);
const focusWithin = blockRef.current?.contains(document.activeElement);
if (!focusWithin) {
focusElemRef.current?.focus();
}
setBlockFocus(blockId);
}, [blockClicked]);
if (!blockId || !blockData) return null;
if (blockDataLoading) {
blockElem = <CenteredDiv>Loading...</CenteredDiv>;
} else if (blockData.view === "term") {
blockElem = <TerminalView blockId={blockId} />;
} else if (blockData.view === "preview") {
blockElem = <PreviewView blockId={blockId} />;
} else if (blockData.view === "plot") {
blockElem = <PlotView />;
} else if (blockData.view === "codeedit") {
2024-06-14 08:54:04 +02:00
blockElem = <CodeEdit text={null} filename={null} />;
}
return (
<BlockFrame
blockId={blockId}
onClose={onClose}
preview={false}
onClick={() => setBlockClicked(true)}
blockRef={blockRef}
2024-06-20 08:00:57 +02:00
dragHandleRef={dragHandleRef}
>
<div key="focuselem" className="block-focuselem">
<input type="text" value="" ref={focusElemRef} onChange={() => {}} />
</div>
<div key="content" className="block-content">
<ErrorBoundary>
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{blockElem}</React.Suspense>
</ErrorBoundary>
</div>
</BlockFrame>
);
};
export { Block, BlockFrame };