// 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; } 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(); 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 = ; } 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, 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; } const FramelessBlockHeader = React.memo(({ blockId, onClose, dragHandleRef }: FramelessBlockHeaderProps) => { const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom); return (
handleHeaderContextMenu(e, blockData, onClose)} >
{getBlockHeaderText(null, blockData, settingsConfig)}
{onClose && (
)}
); }); const hoverStateOff = "off"; const hoverStatePending = "pending"; const hoverStateOn = "on"; interface BlockFrameProps { blockId: string; onClose?: () => void; onClick?: () => void; onFocusCapture?: React.FocusEventHandler; preview: boolean; numBlocksInTab?: number; children?: React.ReactNode; blockRef?: React.RefObject; dragHandleRef?: React.RefObject; } const BlockFrame_Tech_Component = ({ blockId, onClose, onClick, onFocusCapture, preview, blockRef, dragHandleRef, children, }: BlockFrameProps) => { const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom); const isFocusedAtom = useBlockAtom(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 (
handleHeaderContextMenu(e, blockData, onClose)} > {getBlockHeaderText(blockIcon, blockData, settingsConfig)}
{preview ?
: children}
); }; 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(WOS.makeORef("block", blockId)); const settingsConfig = jotai.useAtomValue(atoms.settingsConfigAtom); const isFocusedAtom = useBlockAtom(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) => { 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 (
handleHeaderContextMenu(e, blockData, onClose)} >
{getBlockHeaderIcon(blockIcon, blockData)}
{blockViewToName(blockData?.view)}
{settingsConfig?.blockheader?.showblockids && (
[{blockId.substring(0, 8)}]
)}
{preview ?
: children}
); }; 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(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 (
{preview ?
: children}
); }; const BlockFrame = React.memo((props: BlockFrameProps) => { const blockId = props.blockId; const [blockData, blockDataLoading] = WOS.useWaveObjectValue(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 ; }); 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(blockId, "blockicon:override", () => { return jotai.atom(null); }); const blockIconAtom = useBlockAtom(blockId, "blockicon", () => { return jotai.atom((get) => { console.log("atom-blockicon", blockId); const blockAtom = WOS.getWaveObjectAtom(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(null); const blockRef = React.useRef(null); const [blockClicked, setBlockClicked] = React.useState(false); const [blockData, blockDataLoading] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const [focusedChild, setFocusedChild] = React.useState(null); const isFocusedAtom = useBlockAtom(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) => { 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 = Loading...; } else if (blockData.view === "term") { blockElem = ; } else if (blockData.view === "preview") { blockElem = ; } else if (blockData.view === "plot") { blockElem = ; } else if (blockData.view === "codeedit") { blockElem = ; } else if (blockData.view === "web") { blockElem = ; } else if (blockData.view === "waveai") { blockElem = ; } return ( determineFocusedChild(e)} >
{}} disabled={getFocusableChildren().length > 0} />
Loading...}>{blockElem}
); }); export { Block, BlockFrame };