waveterm/frontend/app/app.tsx
Evan Simkowitz 74c8044c73
Add gap size setting (#325)
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.
2024-09-04 22:07:47 -07:00

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 };