mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
afe73c3e85
Rather than try to track the transition state, which was proving unreliable, I am just directly tracking the node state and determining whether to debounce the inner rect based on whether the user has the prefers-reduced-motion setting or query, whether the node is resizing, and whether it's currently magnified. I'm then using the actual animation time setting to determine how long to debounce.
289 lines
9.7 KiB
TypeScript
289 lines
9.7 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 = jotai.useAtomValue(atoms.prefersReducedMotionAtom);
|
|
const client = jotai.useAtomValue(atoms.client);
|
|
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
|
const isFullScreen = jotai.useAtomValue(atoms.isFullScreen);
|
|
|
|
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,
|
|
})}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
<AppBackground />
|
|
<AppKeyHandlers />
|
|
<AppFocusHandler />
|
|
<AppSettingsUpdater />
|
|
<DndProvider backend={HTML5Backend}>
|
|
<Workspace />
|
|
</DndProvider>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { App };
|