mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
74c8044c73
Adds a new setting for the gap size between tiles in a layout. Also updates the resize handle calculations so they are dynamically generated based on the gap size. Also updates the styling for the resize handles to be more robust. This also updates the default gap size to 3px. This also slims out the Block Frame padding so it is just enough that the blocks don't overlap when there's no gap.
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
// Copyright 2024, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { useWaveObjectValue } from "@/app/store/wos";
|
|
import { Workspace } from "@/app/workspace/workspace";
|
|
import { ContextMenuModel } from "@/store/contextmenu";
|
|
import { PLATFORM, WOS, atoms, getApi, globalStore, useSettingsPrefixAtom } from "@/store/global";
|
|
import { appHandleKeyDown } from "@/store/keymodel";
|
|
import { getWebServerEndpoint } from "@/util/endpoints";
|
|
import { getElemAsStr } from "@/util/focusutil";
|
|
import * as keyutil from "@/util/keyutil";
|
|
import * as util from "@/util/util";
|
|
import useResizeObserver from "@react-hook/resize-observer";
|
|
import clsx from "clsx";
|
|
import Color from "color";
|
|
import * as csstree from "css-tree";
|
|
import debug from "debug";
|
|
import * as jotai from "jotai";
|
|
import "overlayscrollbars/overlayscrollbars.css";
|
|
import * as React from "react";
|
|
import { DndProvider } from "react-dnd";
|
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
|
import { debounce } from "throttle-debounce";
|
|
import "./app.less";
|
|
import { CenteredDiv } from "./element/quickelems";
|
|
|
|
const dlog = debug("wave:app");
|
|
const focusLog = debug("wave:focus");
|
|
|
|
const App = () => {
|
|
let Provider = jotai.Provider;
|
|
return (
|
|
<Provider store={globalStore}>
|
|
<AppInner />
|
|
</Provider>
|
|
);
|
|
};
|
|
|
|
function isContentEditableBeingEdited() {
|
|
const activeElement = document.activeElement;
|
|
return (
|
|
activeElement &&
|
|
activeElement.getAttribute("contenteditable") !== null &&
|
|
activeElement.getAttribute("contenteditable") !== "false"
|
|
);
|
|
}
|
|
|
|
function canEnablePaste() {
|
|
const activeElement = document.activeElement;
|
|
return activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || isContentEditableBeingEdited();
|
|
}
|
|
|
|
function canEnableCopy() {
|
|
const sel = window.getSelection();
|
|
return !util.isBlank(sel?.toString());
|
|
}
|
|
|
|
function canEnableCut() {
|
|
const sel = window.getSelection();
|
|
if (document.activeElement?.classList.contains("xterm-helper-textarea")) {
|
|
return false;
|
|
}
|
|
return !util.isBlank(sel?.toString()) && canEnablePaste();
|
|
}
|
|
|
|
function handleContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
|
e.preventDefault();
|
|
const canPaste = canEnablePaste();
|
|
const canCopy = canEnableCopy();
|
|
const canCut = canEnableCut();
|
|
if (!canPaste && !canCopy && !canCut) {
|
|
return;
|
|
}
|
|
let menu: ContextMenuItem[] = [];
|
|
if (canCut) {
|
|
menu.push({ label: "Cut", role: "cut" });
|
|
}
|
|
if (canCopy) {
|
|
menu.push({ label: "Copy", role: "copy" });
|
|
}
|
|
if (canPaste) {
|
|
menu.push({ label: "Paste", role: "paste" });
|
|
}
|
|
ContextMenuModel.showContextMenu(menu, e);
|
|
}
|
|
|
|
function AppSettingsUpdater() {
|
|
const windowSettings = useSettingsPrefixAtom("window");
|
|
React.useEffect(() => {
|
|
const isTransparentOrBlur =
|
|
(windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false;
|
|
const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1);
|
|
let baseBgColor = windowSettings?.["window:bgcolor"];
|
|
if (isTransparentOrBlur) {
|
|
document.body.classList.add("is-transparent");
|
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
if (baseBgColor == null) {
|
|
baseBgColor = rootStyles.getPropertyValue("--main-bg-color").trim();
|
|
}
|
|
const color = new Color(baseBgColor);
|
|
const rgbaColor = color.alpha(opacity).string();
|
|
document.body.style.backgroundColor = rgbaColor;
|
|
} else {
|
|
document.body.classList.remove("is-transparent");
|
|
document.body.style.opacity = null;
|
|
}
|
|
}, [windowSettings]);
|
|
return null;
|
|
}
|
|
|
|
function appFocusIn(e: FocusEvent) {
|
|
focusLog("focusin", getElemAsStr(e.target), "<=", getElemAsStr(e.relatedTarget));
|
|
}
|
|
|
|
function appFocusOut(e: FocusEvent) {
|
|
focusLog("focusout", getElemAsStr(e.target), "=>", getElemAsStr(e.relatedTarget));
|
|
}
|
|
|
|
function appSelectionChange(e: Event) {
|
|
const selection = document.getSelection();
|
|
focusLog("selectionchange", getElemAsStr(selection.anchorNode));
|
|
}
|
|
|
|
function AppFocusHandler() {
|
|
return null;
|
|
|
|
// for debugging
|
|
React.useEffect(() => {
|
|
document.addEventListener("focusin", appFocusIn);
|
|
document.addEventListener("focusout", appFocusOut);
|
|
document.addEventListener("selectionchange", appSelectionChange);
|
|
const ivId = setInterval(() => {
|
|
const activeElement = document.activeElement;
|
|
if (activeElement instanceof HTMLElement) {
|
|
focusLog("activeElement", getElemAsStr(activeElement));
|
|
}
|
|
}, 2000);
|
|
return () => {
|
|
document.removeEventListener("focusin", appFocusIn);
|
|
document.removeEventListener("focusout", appFocusOut);
|
|
document.removeEventListener("selectionchange", appSelectionChange);
|
|
clearInterval(ivId);
|
|
};
|
|
});
|
|
return null;
|
|
}
|
|
|
|
function encodeFileURL(file: string) {
|
|
const webEndpoint = getWebServerEndpoint();
|
|
return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`;
|
|
}
|
|
|
|
function processBackgroundUrls(cssText: string): string {
|
|
if (util.isBlank(cssText)) {
|
|
return null;
|
|
}
|
|
cssText = cssText.trim();
|
|
if (cssText.endsWith(";")) {
|
|
cssText = cssText.slice(0, -1);
|
|
}
|
|
const attrRe = /^background(-image):\s*/;
|
|
cssText = cssText.replace(attrRe, "");
|
|
const ast = csstree.parse("background: " + cssText, {
|
|
context: "declaration",
|
|
});
|
|
let hasJSUrl = false;
|
|
csstree.walk(ast, {
|
|
visit: "Url",
|
|
enter(node) {
|
|
const originalUrl = node.value.trim();
|
|
if (originalUrl.startsWith("javascript:")) {
|
|
hasJSUrl = true;
|
|
return;
|
|
}
|
|
const newUrl = encodeFileURL(originalUrl);
|
|
node.value = newUrl;
|
|
},
|
|
});
|
|
if (hasJSUrl) {
|
|
console.log("invalid background, contains a 'javascript' protocol url which is not allowed");
|
|
return null;
|
|
}
|
|
const rtnStyle = csstree.generate(ast);
|
|
if (rtnStyle == null) {
|
|
return null;
|
|
}
|
|
return rtnStyle.replace(/^background:\s*/, "");
|
|
}
|
|
|
|
function AppBackground() {
|
|
const bgRef = React.useRef<HTMLDivElement>(null);
|
|
const tabId = jotai.useAtomValue(atoms.activeTabId);
|
|
const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
|
|
const bgAttr = tabData?.meta?.bg;
|
|
const style: React.CSSProperties = {};
|
|
if (!util.isBlank(bgAttr)) {
|
|
try {
|
|
const processedBg = processBackgroundUrls(bgAttr);
|
|
if (!util.isBlank(processedBg)) {
|
|
const opacity = util.boundNumber(tabData?.meta?.["bg:opacity"], 0, 1) ?? 0.5;
|
|
style.opacity = opacity;
|
|
style.background = processedBg;
|
|
const blendMode = tabData?.meta?.["bg:blendmode"];
|
|
if (!util.isBlank(blendMode)) {
|
|
style.backgroundBlendMode = blendMode;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("error processing background", e);
|
|
}
|
|
}
|
|
const getAvgColor = React.useCallback(
|
|
debounce(30, () => {
|
|
if (
|
|
bgRef.current &&
|
|
PLATFORM !== "darwin" &&
|
|
bgRef.current &&
|
|
"windowControlsOverlay" in window.navigator
|
|
) {
|
|
const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect();
|
|
const bgRect = bgRef.current.getBoundingClientRect();
|
|
if (titlebarRect && bgRect) {
|
|
const windowControlsLeft = titlebarRect.width - titlebarRect.height;
|
|
const windowControlsRect: Dimensions = {
|
|
top: titlebarRect.top,
|
|
left: windowControlsLeft,
|
|
height: titlebarRect.height,
|
|
width: bgRect.width - bgRect.left - windowControlsLeft,
|
|
};
|
|
getApi().updateWindowControlsOverlay(windowControlsRect);
|
|
}
|
|
}
|
|
}),
|
|
[bgRef, style]
|
|
);
|
|
React.useLayoutEffect(getAvgColor, [getAvgColor]);
|
|
useResizeObserver(bgRef, getAvgColor);
|
|
|
|
return <div ref={bgRef} className="app-background" style={style} />;
|
|
}
|
|
|
|
const AppKeyHandlers = () => {
|
|
React.useEffect(() => {
|
|
const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown);
|
|
document.addEventListener("keydown", staticKeyDownHandler);
|
|
|
|
return () => {
|
|
document.removeEventListener("keydown", staticKeyDownHandler);
|
|
};
|
|
}, []);
|
|
return null;
|
|
};
|
|
|
|
const AppInner = () => {
|
|
const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(false);
|
|
const prefersReducedMotionSetting = jotai.useAtomValue(atoms.reducedMotionPreferenceAtom);
|
|
const client = jotai.useAtomValue(atoms.client);
|
|
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
|
const isFullScreen = jotai.useAtomValue(atoms.isFullScreen);
|
|
|
|
React.useEffect(() => {
|
|
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
setPrefersReducedMotion(!reducedMotionQuery || reducedMotionQuery.matches);
|
|
reducedMotionQuery.addEventListener("change", () => {
|
|
setPrefersReducedMotion(reducedMotionQuery.matches);
|
|
});
|
|
}, []);
|
|
|
|
if (client == null || windowData == null) {
|
|
return (
|
|
<div className="mainapp">
|
|
<AppBackground />
|
|
<CenteredDiv>invalid configuration, client or window was not loaded</CenteredDiv>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={clsx("mainapp", PLATFORM, {
|
|
fullscreen: isFullScreen,
|
|
"prefers-reduced-motion": prefersReducedMotion || prefersReducedMotionSetting,
|
|
})}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
<AppBackground />
|
|
<AppKeyHandlers />
|
|
<AppFocusHandler />
|
|
<AppSettingsUpdater />
|
|
<DndProvider backend={HTML5Backend}>
|
|
<Workspace />
|
|
</DndProvider>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { App };
|