diff --git a/frontend/app/view/term.tsx b/frontend/app/view/term.tsx index c0f54e53b..bb85e8bc5 100644 --- a/frontend/app/view/term.tsx +++ b/frontend/app/view/term.tsx @@ -8,8 +8,9 @@ import { FitAddon } from "@xterm/addon-fit"; import type { ITheme } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; import * as React from "react"; -import { debounce } from "throttle-debounce"; +import useResizeObserver from "@react-hook/resize-observer"; +import { debounce } from "throttle-debounce"; import "./view.less"; import "/public/xterm.css"; @@ -61,91 +62,94 @@ const TerminalView = ({ blockId }: { blockId: string }) => { const termRef = React.useRef(null); const initialLoadRef = React.useRef({ loaded: false, heldData: [] }); - React.useEffect(() => { - if (!connectElemRef.current) { - return; - } - console.log("terminal created"); - const term = new Terminal({ - theme: getThemeFromCSSVars(connectElemRef.current), - fontSize: 12, - fontFamily: "Hack", - drawBoldTextInBrightColors: false, - fontWeight: "normal", - fontWeightBold: "bold", - }); - termRef.current = term; - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - term.open(connectElemRef.current); - fitAddon.fit(); - BlockService.SendCommand(blockId, { - command: "controller:input", - termsize: { rows: term.rows, cols: term.cols }, - }); - term.onData((data) => { - const b64data = btoa(data); - const inputCmd = { command: "controller:input", blockid: blockId, inputdata64: b64data }; - BlockService.SendCommand(blockId, inputCmd); - }); - // resize observer - const handleResize_debounced = debounce(50, handleResize); - const rszObs = new ResizeObserver(() => { - handleResize_debounced(fitAddon, blockId, term); - }); - rszObs.observe(connectElemRef.current); - - // block subject - const blockSubject = getBlockSubject(blockId); - blockSubject.subscribe((data) => { - // base64 decode - const decodedData = base64ToArray(data.ptydata); - if (initialLoadRef.current.loaded) { - term.write(decodedData); - } else { - initialLoadRef.current.heldData.push(decodedData); - } - }); - - return () => { - term.dispose(); - rszObs.disconnect(); - blockSubject.release(); - }; - }, []); + const [fitAddon, setFitAddon] = React.useState(null); + const [term, setTerm] = React.useState(null); React.useEffect(() => { - if (!termRef.current) { - return; - } - // load data from filestore - const startTs = Date.now(); - let loadedBytes = 0; - const localTerm = termRef.current; // avoids devmode double effect running issue (terminal gets created twice) - const usp = new URLSearchParams(); - usp.set("zoneid", blockId); - usp.set("name", "main"); - fetch("/wave/file?" + usp.toString()) - .then((resp) => { - if (resp.ok) { - return resp.arrayBuffer(); - } - console.log("error loading file", resp.status, resp.statusText); - }) - .then((data: ArrayBuffer) => { - const uint8View = new Uint8Array(data); - localTerm.write(uint8View); - loadedBytes = uint8View.byteLength; - }) - .finally(() => { - initialLoadRef.current.heldData.forEach((data) => { - localTerm.write(data); - }); - initialLoadRef.current.loaded = true; - initialLoadRef.current.heldData = []; - console.log(`terminal loaded file ${loadedBytes} bytes, ${Date.now() - startTs}ms`); + if (connectElemRef.current && !term) { + console.log("terminal created"); + const newTerm = new Terminal({ + theme: getThemeFromCSSVars(connectElemRef.current), + fontSize: 12, + fontFamily: "Hack", + drawBoldTextInBrightColors: false, + fontWeight: "normal", + fontWeightBold: "bold", }); - }, []); + termRef.current = newTerm; + const newFitAddon = new FitAddon(); + newTerm.loadAddon(newFitAddon); + newTerm.open(connectElemRef.current); + newFitAddon.fit(); + BlockService.SendCommand(blockId, { + command: "controller:input", + termsize: { rows: newTerm.rows, cols: newTerm.cols }, + }); + newTerm.onData((data) => { + const b64data = btoa(data); + const inputCmd = { command: "controller:input", blockid: blockId, inputdata64: b64data }; + BlockService.SendCommand(blockId, inputCmd); + }); + + // block subject + const blockSubject = getBlockSubject(blockId); + blockSubject.subscribe((data) => { + // base64 decode + const decodedData = base64ToArray(data.ptydata); + if (initialLoadRef.current.loaded) { + newTerm.write(decodedData); + } else { + initialLoadRef.current.heldData.push(decodedData); + } + }); + + setTerm(newTerm); + setFitAddon(newFitAddon); + + return () => { + newTerm.dispose(); + blockSubject.release(); + }; + } + }, [connectElemRef]); + + const handleResizeCallback = React.useCallback(() => { + debounce(50, () => handleResize(fitAddon, blockId, term)); + }, [fitAddon, term]); + + useResizeObserver(connectElemRef, handleResizeCallback); + + React.useEffect(() => { + if (termRef.current) { + // load data from filestore + const startTs = Date.now(); + let loadedBytes = 0; + const localTerm = termRef.current; // avoids devmode double effect running issue (terminal gets created twice) + const usp = new URLSearchParams(); + usp.set("zoneid", blockId); + usp.set("name", "main"); + fetch("/wave/file?" + usp.toString()) + .then((resp) => { + if (resp.ok) { + return resp.arrayBuffer(); + } + console.log("error loading file", resp.status, resp.statusText); + }) + .then((data: ArrayBuffer) => { + const uint8View = new Uint8Array(data); + localTerm.write(uint8View); + loadedBytes = uint8View.byteLength; + }) + .finally(() => { + initialLoadRef.current.heldData.forEach((data) => { + localTerm.write(data); + }); + initialLoadRef.current.loaded = true; + initialLoadRef.current.heldData = []; + console.log(`terminal loaded file ${loadedBytes} bytes, ${Date.now() - startTs}ms`); + }); + } + }, [termRef]); return (
diff --git a/frontend/faraday/lib/TileLayout.tsx b/frontend/faraday/lib/TileLayout.tsx index c9fdc937f..149b43d17 100644 --- a/frontend/faraday/lib/TileLayout.tsx +++ b/frontend/faraday/lib/TileLayout.tsx @@ -5,6 +5,7 @@ import clsx from "clsx"; import { CSSProperties, RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useDrag, useDragLayer, useDrop } from "react-dnd"; +import useResizeObserver from "@react-hook/resize-observer"; import { useLayoutTreeStateReducerAtom } from "./layoutAtom.js"; import { ContentRenderer, @@ -126,24 +127,9 @@ export const TileLayout = ({ layoutTreeStateAtom, className, renderContent, // Update the transforms whenever we drag something and whenever the layout updates. useLayoutEffect(() => { updateTransforms(); - }, [activeDrag, layoutTreeState]); + }, [activeDrag, layoutTreeState, updateTransforms]); - // Update the transforms on first render and again whenever the window resizes. I had to do a slightly hacky thing - // because I noticed that the window handler wasn't updating when the callback changed so I remove it each time and - // reattach the new callback. - const [resizeObserver, setResizeObserver] = useState(undefined); - useEffect(() => { - if (overlayContainerRef.current) { - console.log("replace resize listener"); - if (resizeObserver) resizeObserver.disconnect(); - const newResizeObserver = new ResizeObserver(updateTransforms); - newResizeObserver.observe(overlayContainerRef.current); - setResizeObserver(newResizeObserver); - return () => { - newResizeObserver.disconnect(); - }; - } - }, [updateTransforms, overlayContainerRef]); + useResizeObserver(overlayContainerRef, () => updateTransforms()); // Ensure that we don't see any jostling in the layout when we're rendering it the first time. // `animate` will be disabled until after the transforms have all applied the first time. diff --git a/package.json b/package.json index 3fe1f3465..973c7a09f 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@monaco-editor/loader": "^1.4.0", "@monaco-editor/react": "^4.6.0", "@observablehq/plot": "^0.6.14", + "@react-hook/resize-observer": "^2.0.1", "@tanstack/react-table": "^8.17.3", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", diff --git a/yarn.lock b/yarn.lock index d82202f98..4e37748bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1901,6 +1901,13 @@ __metadata: languageName: node linkType: hard +"@juggle/resize-observer@npm:^3.3.1": + version: 3.4.0 + resolution: "@juggle/resize-observer@npm:3.4.0" + checksum: 10c0/12930242357298c6f2ad5d4ec7cf631dfb344ca7c8c830ab7f64e6ac11eb1aae486901d8d880fd08fb1b257800c160a0da3aee1e7ed9adac0ccbb9b7c5d93347 + languageName: node + linkType: hard + "@mdx-js/react@npm:^3.0.0": version: 3.0.1 resolution: "@mdx-js/react@npm:3.0.1" @@ -2324,6 +2331,37 @@ __metadata: languageName: node linkType: hard +"@react-hook/latest@npm:^1.0.2": + version: 1.0.3 + resolution: "@react-hook/latest@npm:1.0.3" + peerDependencies: + react: ">=16.8" + checksum: 10c0/d6a166c21121da519a516e8089ba28a2779d37b6017732ab55476c0d354754ad215394135765254f8752a7c6661c3fb868d088769a644848602f00f8821248ed + languageName: node + linkType: hard + +"@react-hook/passive-layout-effect@npm:^1.2.0": + version: 1.2.1 + resolution: "@react-hook/passive-layout-effect@npm:1.2.1" + peerDependencies: + react: ">=16.8" + checksum: 10c0/5c9e6b3df1c91fc2b1d4f711ca96b5f8cb3f6a13a2e97dac7cce623e58d7ee57999c45db3778d0af0b2522b3a5b7463232ef21cb3ee9900437172d48f766d933 + languageName: node + linkType: hard + +"@react-hook/resize-observer@npm:^2.0.1": + version: 2.0.1 + resolution: "@react-hook/resize-observer@npm:2.0.1" + dependencies: + "@juggle/resize-observer": "npm:^3.3.1" + "@react-hook/latest": "npm:^1.0.2" + "@react-hook/passive-layout-effect": "npm:^1.2.0" + peerDependencies: + react: ">=18" + checksum: 10c0/f36b181b1faecbe3894a23e3ad9d1206afc64287d60fa55f3018679a03161a2ecefc857575ee2a2dc2008cc6d3ded760d45bf6b4bf2102d5da6460aa349317db + languageName: node + linkType: hard + "@rollup/pluginutils@npm:^5.0.2": version: 5.1.0 resolution: "@rollup/pluginutils@npm:5.1.0" @@ -11065,6 +11103,7 @@ __metadata: "@monaco-editor/loader": "npm:^1.4.0" "@monaco-editor/react": "npm:^4.6.0" "@observablehq/plot": "npm:^0.6.14" + "@react-hook/resize-observer": "npm:^2.0.1" "@storybook/addon-essentials": "npm:^8.1.4" "@storybook/addon-interactions": "npm:^8.1.4" "@storybook/addon-links": "npm:^8.1.4"