dynamic header updates (#102)

This commit is contained in:
Mike Sawka 2024-07-08 15:04:48 -07:00 committed by GitHub
parent 0cb9b8940b
commit 848a9af9a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 394 additions and 410 deletions

View File

@ -69,8 +69,8 @@
.block-frame-default-header { .block-frame-default-header {
display: flex; display: flex;
height: 26px; height: 34px;
padding-left: 6px; padding: 4px 0 4px 10px;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
align-self: stretch; align-self: stretch;
@ -84,6 +84,10 @@
gap: 8px; gap: 8px;
color: var(--main-text-color); color: var(--main-text-color);
.block-frame-back-button {
cursor: pointer;
}
.block-frame-view-icon { .block-frame-view-icon {
font-size: var(--header-icon-size); font-size: var(--header-icon-size);
opacity: 0.5; opacity: 0.5;
@ -103,6 +107,12 @@
} }
} }
.block-frame-text {
font: var(--fixed-font);
font-size: 11px;
opacity: 0.7;
}
.block-frame-end-icons { .block-frame-end-icons {
display: flex; display: flex;
align-items: center; align-items: center;
@ -139,109 +149,9 @@
} }
} }
&.block-frame-tech {
border: 2px solid var(--border-color);
border-radius: 7px;
margin: 8px 0 0 0;
padding: 10px 2px 2px 2px;
height: calc(100% - 8px);
width: 100%;
overflow: visible;
.block-header-icon {
font-size: 14px;
padding: 0 0;
}
&.block-preview {
background-color: var(--main-bg-color);
.block-frame-tech-close {
display: none;
}
}
&.block-focused {
border: 2px solid var(--accent-color);
.block-frame-tech-header {
color: var(--main-text-color);
}
}
.block-frame-tech-header {
position: absolute;
max-width: 85%;
left: 50%;
transform: translate(-50%, 0);
overflow: hidden;
text-overflow: ellipsis;
top: -11px;
padding: 4px 6px 4px 6px;
background-color: var(--main-bg-color);
font: var(--fixed-font);
color: var(--secondary-text-color);
white-space: nowrap;
cursor: grab;
}
.block-frame-tech-close {
position: absolute;
top: 0;
right: -2px;
padding: 0 0 1px 1px;
cursor: pointer;
opacity: 0;
&:hover {
color: var(--secondary-text-color);
opacity: 1;
}
}
}
.block-frame-preview { .block-frame-preview {
background-color: var(--main-bg-color); background-color: var(--main-bg-color);
width: 100%; width: 100%;
flex-grow: 1; flex-grow: 1;
} }
} }
.block-header {
display: flex;
flex-direction: row;
flex-shrink: 0;
height: 30px;
width: 100%;
align-items: center;
justify-content: center;
background-color: var(--panel-bg-color);
user-select: none;
-webkit-user-select: none;
cursor: default;
border-radius: 0 0 4px 4px;
cursor: grab;
.block-header-icon {
font-size: 20px;
padding: 0 5px;
}
.block-header-text {
padding: 0 5px;
flex-grow: 1;
white-space: nowrap;
}
.close-button {
font-size: 12px;
padding: 5px 5px 5px 5px;
margin-right: 5px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: var(--highlight-bg-color);
}
}
}

View File

@ -1,12 +1,6 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // 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 { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems"; import { CenteredDiv } from "@/element/quickelems";
import { ContextMenuModel } from "@/store/contextmenu"; import { ContextMenuModel } from "@/store/contextmenu";
@ -14,19 +8,43 @@ import { atoms, globalStore, setBlockFocus, useBlockAtom } from "@/store/global"
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as WOS from "@/store/wos"; import * as WOS from "@/store/wos";
import * as util from "@/util/util"; import * as util from "@/util/util";
import { CodeEdit } from "@/view/codeedit";
import { PlotView } from "@/view/plotview";
import { PreviewView, makePreviewModel } from "@/view/preview";
import { TerminalView } from "@/view/term/term";
import { WaveAi } from "@/view/waveai";
import { WebView } from "@/view/webview";
import clsx from "clsx"; import clsx from "clsx";
import * as jotai from "jotai"; import * as jotai from "jotai";
import * as React from "react"; import * as React from "react";
import "./block.less"; import "./block.less";
const HoverPixels = 15; interface LayoutComponentModel {
const HoverTimeoutMs = 100; onClose?: () => void;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
interface BlockProps { interface BlockProps {
blockId: string; blockId: string;
onClose?: () => void; preview: boolean;
dragHandleRef?: React.RefObject<HTMLDivElement>; layoutModel: LayoutComponentModel;
}
interface BlockComponentModel {
onClick?: () => void;
onFocusCapture?: React.FocusEventHandler<HTMLDivElement>;
blockRef?: React.RefObject<HTMLDivElement>;
}
interface BlockFrameProps {
blockId: string;
blockModel?: BlockComponentModel;
layoutModel?: LayoutComponentModel;
viewModel?: ViewModel;
preview: boolean;
numBlocksInTab?: number;
children?: React.ReactNode;
} }
const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/; const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/;
@ -176,118 +194,13 @@ function handleHeaderContextMenu(e: React.MouseEvent<HTMLDivElement>, blockData:
ContextMenuModel.showContextMenu(menu, e); 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 = ({ const BlockFrame_Default_Component = ({
blockId, blockId,
onClose, layoutModel,
onClick, viewModel,
blockModel,
preview, preview,
blockRef,
dragHandleRef,
numBlocksInTab, numBlocksInTab,
onFocusCapture,
children, children,
}: BlockFrameProps) => { }: BlockFrameProps) => {
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
@ -299,7 +212,10 @@ const BlockFrame_Default_Component = ({
}); });
}); });
let isFocused = jotai.useAtomValue(isFocusedAtom); let isFocused = jotai.useAtomValue(isFocusedAtom);
const blockIcon = useBlockIcon(blockId); const viewIcon = jotai.useAtomValue(viewModel.viewIcon);
const viewText = jotai.useAtomValue(viewModel.viewText);
const hasBackButton = jotai.useAtomValue(viewModel.hasBackButton);
const hasForwardButton = jotai.useAtomValue(viewModel.hasForwardButton);
if (preview) { if (preview) {
isFocused = true; isFocused = true;
} }
@ -339,7 +255,7 @@ const BlockFrame_Default_Component = ({
}, },
}); });
menuItems.push({ type: "separator" }); menuItems.push({ type: "separator" });
menuItems.push({ label: "Close", click: onClose }); menuItems.push({ label: "Close", click: layoutModel?.onClose });
ContextMenuModel.showContextMenu(menuItems, e); ContextMenuModel.showContextMenu(menuItems, e);
}; };
if (preview) { if (preview) {
@ -355,29 +271,35 @@ const BlockFrame_Default_Component = ({
preview ? "block-preview" : null, preview ? "block-preview" : null,
numBlocksInTab == 1 ? "block-no-highlight" : null numBlocksInTab == 1 ? "block-no-highlight" : null
)} )}
onClick={onClick} onClick={blockModel?.onClick}
onFocusCapture={onFocusCapture} onFocusCapture={blockModel?.onFocusCapture}
ref={blockRef} ref={blockModel?.blockRef}
style={style} style={style}
> >
<div <div
className="block-frame-default-header" className="block-frame-default-header"
ref={dragHandleRef} ref={layoutModel?.dragHandleRef}
onContextMenu={(e) => handleHeaderContextMenu(e, blockData, onClose)} onContextMenu={(e) => handleHeaderContextMenu(e, blockData, layoutModel?.onClose)}
> >
<div className="block-frame-default-header-iconview"> <div className="block-frame-default-header-iconview">
<div className="block-frame-view-icon">{getBlockHeaderIcon(blockIcon, blockData)}</div> {hasBackButton && !hasForwardButton && (
<div className="block-frame-back-button" onClick={viewModel.onBack}>
<i className="fa fa-solid fa-chevron-left fa-fw" />
</div>
)}
<div className="block-frame-view-icon">{getBlockHeaderIcon(viewIcon, blockData)}</div>
<div className="block-frame-view-type">{blockViewToName(blockData?.view)}</div> <div className="block-frame-view-type">{blockViewToName(blockData?.view)}</div>
{settingsConfig?.blockheader?.showblockids && ( {settingsConfig?.blockheader?.showblockids && (
<div className="block-frame-blockid">[{blockId.substring(0, 8)}]</div> <div className="block-frame-blockid">[{blockId.substring(0, 8)}]</div>
)} )}
</div> </div>
{util.isBlank(viewText) ? null : <div className="block-frame-text">{viewText}</div>}
<div className="flex-spacer"></div> <div className="flex-spacer"></div>
<div className="block-frame-end-icons"> <div className="block-frame-end-icons">
<div className="block-frame-settings" onClick={handleSettings}> <div className="block-frame-settings" onClick={handleSettings}>
<i className="fa fa-solid fa-cog fa-fw" /> <i className="fa fa-solid fa-cog fa-fw" />
</div> </div>
<div className={clsx("block-frame-default-close")} onClick={onClose}> <div className={clsx("block-frame-default-close")} onClick={layoutModel?.onClose}>
<i className="fa fa-solid fa-xmark-large fa-fw" /> <i className="fa fa-solid fa-xmark-large fa-fw" />
</div> </div>
</div> </div>
@ -390,80 +312,6 @@ const BlockFrame_Default_Component = ({
const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component; 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 BlockFrame = React.memo((props: BlockFrameProps) => {
const blockId = props.blockId; const blockId = props.blockId;
const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
@ -474,18 +322,6 @@ const BlockFrame = React.memo((props: BlockFrameProps) => {
} }
let FrameElem = BlockFrame_Default; let FrameElem = BlockFrame_Default;
const numBlocks = tabData?.blockids?.length ?? 0; 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} />; return <FrameElem {...props} numBlocksInTab={numBlocks} />;
}); });
@ -547,17 +383,78 @@ function useBlockIcon(blockId: string): string {
return blockIcon; return blockIcon;
} }
const wm = new WeakMap(); function getViewElemAndModel(
let wmCounter = 0; blockId: string,
function getObjectId(obj: any): number { blockView: string,
if (!wm.has(obj)) { blockRef: React.RefObject<HTMLDivElement>
wm.set(obj, wmCounter++); ): { viewModel: ViewModel; viewElem: JSX.Element } {
if (blockView == null) {
return { viewElem: null, viewModel: null };
} }
return wm.get(obj); let viewElem: JSX.Element = null;
let viewModel: ViewModel = null;
if (blockView === "term") {
viewElem = <TerminalView key={blockId} blockId={blockId} />;
} else if (blockView === "preview") {
const previewModel = makePreviewModel(blockId);
viewElem = <PreviewView key={blockId} blockId={blockId} model={previewModel} />;
viewModel = previewModel;
} else if (blockView === "plot") {
viewElem = <PlotView key={blockId} />;
} else if (blockView === "codeedit") {
viewElem = <CodeEdit key={blockId} text={null} filename={null} />;
} else if (blockView === "web") {
viewElem = <WebView key={blockId} blockId={blockId} parentRef={blockRef} />;
} else if (blockView === "waveai") {
viewElem = <WaveAi key={blockId} parentRef={blockRef} />;
}
if (viewModel == null) {
viewModel = makeDefaultViewModel(blockId);
}
return { viewElem, viewModel };
} }
const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => { function makeDefaultViewModel(blockId: string): ViewModel {
let blockElem: JSX.Element = null; const blockDataAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId));
let viewModel: ViewModel = {
viewIcon: jotai.atom((get) => {
const blockData = get(blockDataAtom);
return blockViewToIcon(blockData?.view);
}),
viewName: jotai.atom((get) => {
const blockData = get(blockDataAtom);
return blockViewToName(blockData?.view);
}),
viewText: jotai.atom((get) => {
const blockData = get(blockDataAtom);
return blockData?.meta?.title;
}),
hasBackButton: jotai.atom(false),
hasForwardButton: jotai.atom(false),
hasSearch: jotai.atom(false),
};
return viewModel;
}
const BlockPreview = React.memo(({ blockId, layoutModel }: BlockProps) => {
const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
if (!blockData) {
return null;
}
let { viewModel } = getViewElemAndModel(blockId, blockData?.view, null);
return (
<BlockFrame
key={blockId}
blockId={blockId}
layoutModel={layoutModel}
preview={true}
blockModel={null}
viewModel={viewModel}
/>
);
});
const BlockFull = React.memo(({ blockId, layoutModel }: BlockProps) => {
const focusElemRef = React.useRef<HTMLInputElement>(null); const focusElemRef = React.useRef<HTMLInputElement>(null);
const blockRef = React.useRef<HTMLDivElement>(null); const blockRef = React.useRef<HTMLDivElement>(null);
const [blockClicked, setBlockClicked] = React.useState(false); const [blockClicked, setBlockClicked] = React.useState(false);
@ -626,33 +523,30 @@ const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => {
} }
}, [focusElemRef.current, getFocusableChildren]); }, [focusElemRef.current, getFocusableChildren]);
let { viewElem, viewModel } = React.useMemo(
() => getViewElemAndModel(blockId, blockData?.view, blockRef),
[blockId, blockData?.view, blockRef]
);
if (!blockId || !blockData) return null; if (!blockId || !blockData) return null;
if (blockDataLoading) { if (blockDataLoading) {
blockElem = <CenteredDiv>Loading...</CenteredDiv>; viewElem = <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} />;
} }
const blockModel: BlockComponentModel = {
onClick: setBlockClickedTrue,
onFocusCapture: determineFocusedChild,
blockRef: blockRef,
};
return ( return (
<BlockFrame <BlockFrame
key={blockId} key={blockId}
blockId={blockId} blockId={blockId}
onClose={onClose} layoutModel={layoutModel}
preview={false} preview={false}
onClick={setBlockClickedTrue} blockModel={blockModel}
blockRef={blockRef} viewModel={viewModel}
dragHandleRef={dragHandleRef}
onFocusCapture={(e) => determineFocusedChild(e)}
> >
<div key="focuselem" className="block-focuselem"> <div key="focuselem" className="block-focuselem">
<input <input
@ -666,11 +560,18 @@ const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => {
</div> </div>
<div key="content" className="block-content"> <div key="content" className="block-content">
<ErrorBoundary> <ErrorBoundary>
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{blockElem}</React.Suspense> <React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</React.Suspense>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</BlockFrame> </BlockFrame>
); );
}); });
export { Block, BlockFrame }; const Block = React.memo((props: BlockProps) => {
if (props.preview) {
return <BlockPreview {...props} />;
}
return <BlockFull {...props} />;
});
export { Block };

View File

@ -353,6 +353,15 @@ function setBlockFocus(blockId: string) {
WOS.setObjectValue(winData, globalStore.set, true); WOS.setObjectValue(winData, globalStore.set, true);
} }
const objectIdWeakMap = new WeakMap();
let objectIdCounter = 0;
function getObjectId(obj: any): number {
if (!objectIdWeakMap.has(obj)) {
objectIdWeakMap.set(obj, objectIdCounter++);
}
return objectIdWeakMap.get(obj);
}
export { export {
WOS, WOS,
atoms, atoms,
@ -363,6 +372,7 @@ export {
getEventORefSubject, getEventORefSubject,
getEventSubject, getEventSubject,
getFileSubject, getFileSubject,
getObjectId,
globalStore, globalStore,
globalWS, globalWS,
initWS, initWS,

View File

@ -1,7 +1,7 @@
// Copyright 2023, Command Line Inc. // Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { Block, BlockFrame } from "@/app/block/block"; import { Block } from "@/app/block/block";
import { getApi } from "@/store/global"; import { getApi } from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as WOS from "@/store/wos"; import * as WOS from "@/store/wos";
@ -32,18 +32,15 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => {
if (!tabData.blockId || !ready) { if (!tabData.blockId || !ready) {
return null; return null;
} }
return ( const layoutModel = {
<Block onClose: onClose,
key={tabData.blockId} dragHandleRef: dragHandleRef,
blockId={tabData.blockId} };
onClose={onClose} return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={layoutModel} preview={false} />;
dragHandleRef={dragHandleRef}
/>
);
} }
function renderPreview(tabData: TabLayoutData) { function renderPreview(tabData: TabLayoutData) {
return <BlockFrame key={tabData.blockId} blockId={tabData.blockId} preview={true} />; return <Block key={tabData.blockId} blockId={tabData.blockId} layoutModel={null} preview={true} />;
} }
function onNodeDelete(data: TabLayoutData) { function onNodeDelete(data: TabLayoutData) {

View File

@ -466,6 +466,7 @@ interface DirectoryPreviewProps {
} }
function DirectoryPreview({ fileNameAtom }: DirectoryPreviewProps) { function DirectoryPreview({ fileNameAtom }: DirectoryPreviewProps) {
console.log("DirectoryPreview render");
const [searchText, setSearchText] = React.useState(""); const [searchText, setSearchText] = React.useState("");
const [focusIndex, setFocusIndex] = React.useState(0); const [focusIndex, setFocusIndex] = React.useState(0);
const [content, setContent] = React.useState<FileInfo[]>([]); const [content, setContent] = React.useState<FileInfo[]>([]);

View File

@ -2,12 +2,13 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { Markdown } from "@/element/markdown"; import { Markdown } from "@/element/markdown";
import { getBackendHostPort, globalStore, useBlockAtom, useBlockCache } from "@/store/global"; import { getBackendHostPort, getObjectId, globalStore, useBlockAtom } from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as WOS from "@/store/wos"; import * as WOS from "@/store/wos";
import * as util from "@/util/util"; import * as util from "@/util/util";
import clsx from "clsx"; import clsx from "clsx";
import * as jotai from "jotai"; import * as jotai from "jotai";
import { loadable } from "jotai/utils";
import { useRef } from "react"; import { useRef } from "react";
import { CenteredDiv } from "../element/quickelems"; import { CenteredDiv } from "../element/quickelems";
import { CodeEdit } from "./codeedit"; import { CodeEdit } from "./codeedit";
@ -17,6 +18,113 @@ import { DirectoryPreview } from "./directorypreview";
import "./view.less"; import "./view.less";
const MaxFileSize = 1024 * 1024 * 10; // 10MB const MaxFileSize = 1024 * 1024 * 10; // 10MB
const MaxCSVSize = 1024 * 1024 * 1; // 1MB
export class PreviewModel implements ViewModel {
blockId: string;
blockAtom: jotai.Atom<Block>;
viewIcon: jotai.Atom<string>;
viewName: jotai.Atom<string>;
viewText: jotai.Atom<string>;
hasBackButton: jotai.Atom<boolean>;
hasForwardButton: jotai.Atom<boolean>;
hasSearch: jotai.Atom<boolean>;
fileName: jotai.WritableAtom<string, [string], void>;
statFile: jotai.Atom<Promise<FileInfo>>;
fullFile: jotai.Atom<Promise<FullFile>>;
fileMimeType: jotai.Atom<Promise<string>>;
fileMimeTypeLoadable: jotai.Atom<Loadable<string>>;
fileContent: jotai.Atom<Promise<string>>;
setPreviewFileName(fileName: string) {
services.ObjectService.UpdateObjectMeta(`block:${this.blockId}`, { file: fileName });
}
constructor(blockId: string) {
this.blockId = blockId;
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
this.viewIcon = jotai.atom((get) => {
let blockData = get(this.blockAtom);
if (blockData?.meta?.icon) {
return blockData.meta.icon;
}
const mimeType = util.jotaiLoadableValue(get(this.fileMimeTypeLoadable), "");
const fileName = get(this.fileName);
return iconForFile(mimeType, fileName);
});
this.viewName = jotai.atom("Preview");
this.viewText = jotai.atom((get) => {
return get(this.fileName);
});
this.hasBackButton = jotai.atom(true);
this.hasForwardButton = jotai.atom((get) => {
const mimeType = util.jotaiLoadableValue(get(this.fileMimeTypeLoadable), "");
if (mimeType == "directory") {
return true;
}
return false;
});
this.hasSearch = jotai.atom(false);
this.fileName = jotai.atom<string, [string], void>(
(get) => {
return get(this.blockAtom)?.meta?.file;
},
(get, set, update) => {
services.ObjectService.UpdateObjectMeta(`block:${blockId}`, { file: update });
}
);
this.statFile = jotai.atom<Promise<FileInfo>>(async (get) => {
const fileName = get(this.fileName);
if (fileName == null) {
return null;
}
// const statFile = await FileService.StatFile(fileName);
console.log("PreviewModel calling StatFile", fileName);
const statFile = await services.FileService.StatFile(fileName);
return statFile;
});
this.fullFile = jotai.atom<Promise<FullFile>>(async (get) => {
const fileName = get(this.fileName);
if (fileName == null) {
return null;
}
// const file = await FileService.ReadFile(fileName);
const file = await services.FileService.ReadFile(fileName);
return file;
});
this.fileMimeType = jotai.atom<Promise<string>>(async (get) => {
const fileInfo = await get(this.statFile);
return fileInfo?.mimetype;
});
this.fileMimeTypeLoadable = loadable(this.fileMimeType);
this.fileContent = jotai.atom<Promise<string>>(async (get) => {
const fullFile = await get(this.fullFile);
return util.base64ToString(fullFile?.data64);
});
this.onBack = this.onBack.bind(this);
}
onBack() {
const fileName = globalStore.get(this.fileName);
if (fileName == null) {
return;
}
const splitPath = fileName.split("/");
console.log("splitPath-1", splitPath);
splitPath.pop();
console.log("splitPath-2", splitPath);
const newPath = splitPath.join("/");
globalStore.set(this.fileName, newPath);
}
}
function makePreviewModel(blockId: string): PreviewModel {
const previewModel = new PreviewModel(blockId);
return previewModel;
}
function DirNav({ cwdAtom }: { cwdAtom: jotai.WritableAtom<string, [string], void> }) { function DirNav({ cwdAtom }: { cwdAtom: jotai.WritableAtom<string, [string], void> }) {
const [cwd, setCwd] = jotai.useAtom(cwdAtom); const [cwd, setCwd] = jotai.useAtom(cwdAtom);
@ -139,6 +247,9 @@ function CSVViewPreview({
} }
function iconForFile(mimeType: string, fileName: string): string { function iconForFile(mimeType: string, fileName: string): string {
if (mimeType == null) {
mimeType = "unknown";
}
if (mimeType == "application/pdf") { if (mimeType == "application/pdf") {
return "file-pdf"; return "file-pdf";
} else if (mimeType.startsWith("image/")) { } else if (mimeType.startsWith("image/")) {
@ -153,6 +264,7 @@ function iconForFile(mimeType: string, fileName: string): string {
return "file-csv"; return "file-csv";
} else if ( } else if (
mimeType.startsWith("text/") || mimeType.startsWith("text/") ||
mimeType == "application/sql" ||
(mimeType.startsWith("application/") && (mimeType.startsWith("application/") &&
(mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml")))
) { ) {
@ -161,60 +273,21 @@ function iconForFile(mimeType: string, fileName: string): string {
if (fileName == "~" || fileName == "~/") { if (fileName == "~" || fileName == "~/") {
return "home"; return "home";
} }
return "folder"; return "folder-open";
} else { } else {
return "file"; return "file";
} }
} }
function PreviewView({ blockId }: { blockId: string }) { function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel }) {
console.log("render previewview", getObjectId(model));
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`); const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
const fileNameAtom: jotai.WritableAtom<string, [string], void> = useBlockCache(blockId, "preview:filename", () => const fileNameAtom = model.fileName;
jotai.atom<string, [string], void>( const statFileAtom = model.statFile;
(get) => { const fullFileAtom = model.fullFile;
return get(blockAtom)?.meta?.file; const fileMimeTypeAtom = model.fileMimeType;
}, const fileContentAtom = model.fileContent;
(get, set, update) => {
const blockId = get(blockAtom)?.oid;
services.ObjectService.UpdateObjectMeta(`block:${blockId}`, { file: update });
}
)
);
const statFileAtom = useBlockAtom(blockId, "preview:statfile", () =>
jotai.atom<Promise<FileInfo>>(async (get) => {
const fileName = get(fileNameAtom);
if (fileName == null) {
return null;
}
// const statFile = await FileService.StatFile(fileName);
const statFile = await services.FileService.StatFile(fileName);
return statFile;
})
);
const fullFileAtom = useBlockAtom(blockId, "preview:fullfile", () =>
jotai.atom<Promise<FullFile>>(async (get) => {
const fileName = get(fileNameAtom);
if (fileName == null) {
return null;
}
// const file = await FileService.ReadFile(fileName);
const file = await services.FileService.ReadFile(fileName);
return file;
})
);
const fileMimeTypeAtom = useBlockAtom(blockId, "preview:mimetype", () =>
jotai.atom<Promise<string>>(async (get) => {
const fileInfo = await get(statFileAtom);
return fileInfo?.mimetype;
})
);
const fileContentAtom = useBlockAtom(blockId, "preview:filecontent", () =>
jotai.atom<Promise<string>>(async (get) => {
const fullFile = await get(fullFileAtom);
return util.base64ToString(fullFile?.data64);
})
);
let mimeType = jotai.useAtomValue(fileMimeTypeAtom); let mimeType = jotai.useAtomValue(fileMimeTypeAtom);
if (mimeType == null) { if (mimeType == null) {
mimeType = ""; mimeType = "";
@ -241,11 +314,16 @@ function PreviewView({ blockId }: { blockId: string }) {
} else if (mimeType === "text/markdown") { } else if (mimeType === "text/markdown") {
specializedView = <MarkdownPreview contentAtom={fileContentAtom} />; specializedView = <MarkdownPreview contentAtom={fileContentAtom} />;
} else if (mimeType === "text/csv") { } else if (mimeType === "text/csv") {
if (fileInfo.size > MaxCSVSize) {
specializedView = <CenteredDiv>CSV File Too Large to Preview (1MB Max)</CenteredDiv>;
} else {
specializedView = ( specializedView = (
<CSVViewPreview parentRef={ref} contentAtom={fileContentAtom} filename={fileName} readonly={true} /> <CSVViewPreview parentRef={ref} contentAtom={fileContentAtom} filename={fileName} readonly={true} />
); );
}
} else if ( } else if (
mimeType.startsWith("text/") || mimeType.startsWith("text/") ||
mimeType == "application/sql" ||
(mimeType.startsWith("application/") && (mimeType.startsWith("application/") &&
(mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml")))
) { ) {
@ -274,4 +352,4 @@ function PreviewView({ blockId }: { blockId: string }) {
); );
} }
export { PreviewView }; export { PreviewView, makePreviewModel };

View File

@ -4,17 +4,20 @@
import { Button } from "@/app/element/button"; import { Button } from "@/app/element/button";
import { useParentHeight } from "@/app/hook/useParentHeight"; import { useParentHeight } from "@/app/hook/useParentHeight";
import { getApi } from "@/app/store/global"; import { getApi } from "@/app/store/global";
import { WOS } from "@/store/global";
import { WebviewTag } from "electron"; import { WebviewTag } from "electron";
import React, { memo, useEffect, useRef, useState } from "react"; import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import "./webview.less"; import "./webview.less";
interface WebViewProps { interface WebViewProps {
blockId: string;
parentRef: React.MutableRefObject<HTMLDivElement>; parentRef: React.MutableRefObject<HTMLDivElement>;
initialUrl: string;
} }
const WebView = memo(({ parentRef, initialUrl }: WebViewProps) => { const WebView = memo(({ blockId, parentRef }: WebViewProps) => {
const blockData = WOS.useWaveObjectValueWithSuspense<Block>(WOS.makeORef("block", blockId));
const initialUrl = useMemo(() => blockData?.meta?.url, []);
const [url, setUrl] = useState(initialUrl); const [url, setUrl] = useState(initialUrl);
const [inputUrl, setInputUrl] = useState(initialUrl); // Separate state for the input field const [inputUrl, setInputUrl] = useState(initialUrl); // Separate state for the input field
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);

View File

@ -1,6 +1,7 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import type * as jotai from "jotai";
import type * as rxjs from "rxjs"; import type * as rxjs from "rxjs";
declare global { declare global {
@ -114,6 +115,24 @@ declare global {
} }
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void }; type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
interface ViewModel {
viewIcon: jotai.Atom<string>;
viewName: jotai.Atom<string>;
viewText: jotai.Atom<string>;
hasBackButton: jotai.Atom<boolean>;
hasForwardButton: jotai.Atom<boolean>;
hasSearch: jotai.Atom<boolean>;
onBack?: () => void;
onForward?: () => void;
onSearchChange?: (text: string) => void;
onSearch?: (text: string) => void;
getSettingsMenuItems?: () => ContextMenuItem[];
}
// jotai doesn't export this type :/
type Loadable<T> = { state: "loading" } | { state: "hasData"; data: T } | { state: "hasError"; error: unknown };
} }
export {}; export {};

View File

@ -99,4 +99,68 @@ function fireAndForget(f: () => Promise<any>) {
}); });
} }
export { base64ToArray, base64ToString, fireAndForget, isBlank, jsonDeepEqual, makeIconClass, stringToBase64 }; const promiseWeakMap = new WeakMap<Promise<any>, ResolvedValue<any>>();
type ResolvedValue<T> = {
pending: boolean;
error: any;
value: T;
};
// returns the value, pending state, and error of a promise
function getPromiseState<T>(promise: Promise<T>): [T, boolean, any] {
if (promise == null) {
return [null, false, null];
}
if (promiseWeakMap.has(promise)) {
const value = promiseWeakMap.get(promise);
return [value.value, value.pending, value.error];
}
const value: ResolvedValue<T> = {
pending: true,
error: null,
value: null,
};
promise.then(
(result) => {
value.pending = false;
value.error = null;
value.value = result;
},
(error) => {
value.pending = false;
value.error = error;
}
);
promiseWeakMap.set(promise, value);
return [value.value, value.pending, value.error];
}
// returns the value of a promise, or a default value if the promise is still pending (or had an error)
function getPromiseValue<T>(promise: Promise<T>, def: T): T {
const [value, pending, error] = getPromiseState(promise);
if (pending || error) {
return def;
}
return value;
}
function jotaiLoadableValue<T>(value: Loadable<T>, def: T): T {
if (value.state === "hasData") {
return value.data;
}
return def;
}
export {
base64ToArray,
base64ToString,
fireAndForget,
getPromiseState,
getPromiseValue,
isBlank,
jotaiLoadableValue,
jsonDeepEqual,
makeIconClass,
stringToBase64,
};

View File

@ -56,7 +56,7 @@ func getSettingsConfigDefaults() SettingsConfigType {
"text/css": {Icon: "css3-alt fa-brands"}, "text/css": {Icon: "css3-alt fa-brands"},
"text/javascript": {Icon: "js fa-brands"}, "text/javascript": {Icon: "js fa-brands"},
"text/typescript": {Icon: "js fa-brands"}, "text/typescript": {Icon: "js fa-brands"},
"text/golang": {Icon: "go fa-brands"}, "text/golang": {Icon: "golang fa-brands"},
"text/html": {Icon: "html5 fa-brands"}, "text/html": {Icon: "html5 fa-brands"},
"text/less": {Icon: "less fa-brands"}, "text/less": {Icon: "less fa-brands"},
"text/markdown": {Icon: "markdown fa-brands"}, "text/markdown": {Icon: "markdown fa-brands"},

View File

@ -19,6 +19,7 @@
"@/util/*": ["frontend/util/*"], "@/util/*": ["frontend/util/*"],
"@/faraday/*": ["frontend/faraday/*"], "@/faraday/*": ["frontend/faraday/*"],
"@/store/*": ["frontend/app/store/*"], "@/store/*": ["frontend/app/store/*"],
"@/view/*": ["frontend/app/view/*"],
"@/element/*": ["frontend/app/element/*"], "@/element/*": ["frontend/app/element/*"],
"@/bindings/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/service/*"], "@/bindings/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/service/*"],
"@/gopkg/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/*"], "@/gopkg/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/*"],