diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..fdb4321a --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,30 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: ["eslint:recommended"], + overrides: [ + { + env: { + node: true, + }, + files: [".eslintrc.{js,cjs}"], + parserOptions: { + sourceType: "script", + }, + }, + ], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: [], + rules: { + "no-unused-vars": ["error", { args: "after-used" }], + }, + ignorePatterns: [ + "jupyter_remote_desktop_proxy/static/dist/**", + "webpack.config.js", + ], +}; diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a0c50bd..cf374582 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -68,6 +68,15 @@ repos: hooks: - id: flake8 + # Lint: JS code + - repo: https://github.com/pre-commit/mirrors-eslint + rev: "v8.56.0" # Use the sha / tag you want to point at + hooks: + - id: eslint + files: \.jsx?$ + types: [file] + exclude: jupyter_remote_desktop_proxy/static/dist + # Content here is mostly copied from other locations, so lets not make # formatting changes in it. exclude: share diff --git a/js/index.css b/js/index.css new file mode 100644 index 00000000..3f08653c --- /dev/null +++ b/js/index.css @@ -0,0 +1,97 @@ +/** +* Derived from https://github.com/novnc/noVNC/blob/v1.4.0/vnc_lite.html, which was licensed +* under the 2-clause BSD license +*/ + +html { + /** + Colors from https://github.com/jupyter/design/blob/main/brandguide/brand_guide.pdf + **/ + --jupyter-main-brand-color: #f37626; + --jupyter-dark-grey: #4d4d4d; + --jupyter-medium-dark-grey: #616161; + --jupyter-medium-grey: #757575; + --jupyter-grey: #9e9e9e; + + --topbar-height: 32px; + + /* Use Jupyter Brand fonts */ + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +body { + height: 100vh; + display: flex; + flex-direction: column; + background-color: var(--jupyter-medium-dark-grey); +} + +#top-bar { + background-color: var(--jupyter-dark-grey); + color: white; + border-bottom: 1px white; + display: flex; + align-items: center; +} + +#logo { + padding: 0 24px; +} + +#logo img { + height: 24px; +} + +#menu { + display: flex; + font-weight: bold; + margin-left: auto; + font-size: 12px; +} + +#menu li { + border-right: 1px var(--jupyter-grey) solid; + padding: 12px 0px; +} + +#menu li:last-child { + border-right: 0; +} + +#menu a { + color: white; + text-decoration: none; + padding: 12px 8px; +} + +#menu a:hover, +#menu a.active { + background-color: var(--jupyter-medium-grey); +} + +li#status-container { + padding-right: 8px; +} + +#status-label { + font-weight: normal; +} + +#screen { + flex: 1; + /* fill remaining space */ + overflow: hidden; +} + +/* Clipboard */ +#clipboard-content { + display: flex; + flex-direction: column; + padding: 4px; + gap: 4px; +} + +#clipboard-text { + min-width: 500px; + max-width: 100%; +} diff --git a/js/index.js b/js/index.js index 3a3df481..1c054a29 100644 --- a/js/index.js +++ b/js/index.js @@ -3,16 +3,17 @@ * under the 2-clause BSD license */ +import "reset-css"; +import "./index.css"; + // RFB holds the API to connect and communicate with a VNC server import RFB from "@novnc/novnc/core/rfb"; -let rfb; -let desktopName; +import { setupTooltip } from "./tooltip.js"; -// When this function is called we have -// successfully connected to a server -function connectedToServer(e) { - status("Connected to " + desktopName); +// When this function is called we have successfully connected to a server +function connectedToServer() { + status("Connected"); } // This function is called when we are disconnected @@ -24,119 +25,47 @@ function disconnectedFromServer(e) { } } -// When this function is called, the server requires -// credentials to authenticate -function credentialsAreRequired(e) { - const password = prompt("Password Required:"); - rfb.sendCredentials({ password: password }); -} - -// When this function is called we have received -// a desktop name from the server -function updateDesktopName(e) { - desktopName = e.detail.name; -} - -// Since most operating systems will catch Ctrl+Alt+Del -// before they get a chance to be intercepted by the browser, -// we provide a way to emulate this key sequence. -function sendCtrlAltDel() { - rfb.sendCtrlAltDel(); - return false; -} - // Show a status text in the top bar function status(text) { document.getElementById("status").textContent = text; } -// This function extracts the value of one variable from the -// query string. If the variable isn't defined in the URL -// it returns the default value instead. -function readQueryVariable(name, defaultValue) { - // A URL with a query parameter can look like this: - // https://www.example.com?myqueryparam=myvalue - // - // Note that we use location.href instead of location.search - // because Firefox < 53 has a bug w.r.t location.search - const re = new RegExp(".*[?&]" + name + "=([^&#]*)"), - match = document.location.href.match(re); - - if (match) { - // We have to decode the URL since want the cleartext value - return decodeURIComponent(match[1]); - } - - return defaultValue; -} - -document.getElementById("sendCtrlAltDelButton").onclick = sendCtrlAltDel; - -// Read parameters specified in the URL query string -// By default, use the host and port of server that served this file -const host = readQueryVariable("host", window.location.hostname); -let port = readQueryVariable("port", window.location.port); -const password = readQueryVariable("password"); - -const path = readQueryVariable( - "path", - window.location.pathname.replace(/[^/]*$/, "").substring(1) + "websockify", -); - -// | | | | | | -// | | | Connect | | | -// v v v v v v - -status("Connecting"); - -// Build the websocket URL used to connect -let url; -if (window.location.protocol === "https:") { - url = "wss"; -} else { - url = "ws"; -} -url += "://" + host; -if (port) { - url += ":" + port; -} -url += "/" + path; +// Construct the websockify websocket URL we want to connect to +let websockifyUrl = new URL("websockify", window.location); +websockifyUrl.protocol = window.location.protocol === "https:" ? "wss" : "ws"; // Creating a new RFB object will start a new connection -rfb = new RFB(document.getElementById("screen"), url, { - credentials: { password: password }, -}); +const rfb = new RFB( + document.getElementById("screen"), + websockifyUrl.toString(), + {}, +); // Add listeners to important events from the RFB module rfb.addEventListener("connect", connectedToServer); rfb.addEventListener("disconnect", disconnectedFromServer); -rfb.addEventListener("credentialsrequired", credentialsAreRequired); -rfb.addEventListener("desktopname", updateDesktopName); -// Set parameters that can be changed on an active connection -rfb.viewOnly = readQueryVariable("view_only", false); +// Scale our viewport so the user doesn't have to scroll +rfb.scaleViewport = true; -rfb.scaleViewport = readQueryVariable("scale", true); +// Use a CSS variable to set background color +rfb.background = "var(--jupyter-medium-dark-grey)"; // Clipboard -function toggleClipboardPanel() { - document - .getElementById("noVNC_clipboard_area") - .classList.toggle("noVNC_clipboard_closed"); -} -document - .getElementById("noVNC_clipboard_button") - .addEventListener("click", toggleClipboardPanel); - function clipboardReceive(e) { - document.getElementById("noVNC_clipboard_text").value = e.detail.text; + document.getElementById("clipboard-text").value = e.detail.text; } rfb.addEventListener("clipboard", clipboardReceive); function clipboardSend() { - const text = document.getElementById("noVNC_clipboard_text").value; + const text = document.getElementById("clipboard-text").value; rfb.clipboardPasteFrom(text); } document - .getElementById("noVNC_clipboard_text") + .getElementById("clipboard-text") .addEventListener("change", clipboardSend); + +setupTooltip( + document.getElementById("clipboard-button"), + document.getElementById("clipboard-container"), +); diff --git a/js/tooltip.css b/js/tooltip.css new file mode 100644 index 00000000..dfd09771 --- /dev/null +++ b/js/tooltip.css @@ -0,0 +1,24 @@ +.hidden { + display: none !important; +} + +.tooltip-container { + overflow: visible; /* Needed for the arrow to show up */ + width: max-content; + position: absolute; + top: 0; + left: 0; + background: white; + color: var(--jupyter-dark-grey); + padding: 6px; + border-radius: 4px; + font-size: 90%; +} + +.arrow { + position: absolute; + background: white; + width: 8px; + height: 8px; + transform: rotate(45deg); +} diff --git a/js/tooltip.js b/js/tooltip.js new file mode 100644 index 00000000..20f89de1 --- /dev/null +++ b/js/tooltip.js @@ -0,0 +1,57 @@ +/** + * Setup simplest popover possible to provide popovers. + * + * Mostly follows https://floating-ui.com/docs/tutorial + */ +import { computePosition, flip, shift, offset, arrow } from "@floating-ui/dom"; +import "./tooltip.css"; + +/** + * Setup trigger element to toggle showing / hiding tooltip element + * @param {Element} trigger + * @param {Element} tooltip + */ +export function setupTooltip(trigger, tooltip) { + const arrowElement = tooltip.querySelector(".arrow"); + function updatePosition() { + computePosition(trigger, tooltip, { + placement: "bottom", + middleware: [ + offset(6), + flip(), + shift({ padding: 5 }), + arrow({ element: arrowElement }), + ], + }).then(({ x, y, placement, middlewareData }) => { + Object.assign(tooltip.style, { + left: `${x}px`, + top: `${y}px`, + }); + + // Accessing the data + const { x: arrowX, y: arrowY } = middlewareData.arrow; + + const staticSide = { + top: "bottom", + right: "left", + bottom: "top", + left: "right", + }[placement.split("-")[0]]; + + Object.assign(arrowElement.style, { + left: arrowX != null ? `${arrowX}px` : "", + top: arrowY != null ? `${arrowY}px` : "", + right: "", + bottom: "", + [staticSide]: "-4px", + }); + }); + } + + trigger.addEventListener("click", (e) => { + tooltip.classList.toggle("hidden"); + trigger.classList.toggle("active"); + updatePosition(); + e.preventDefault(); + }); +} diff --git a/jupyter_remote_desktop_proxy/static/index.css b/jupyter_remote_desktop_proxy/static/index.css deleted file mode 100644 index 226934b8..00000000 --- a/jupyter_remote_desktop_proxy/static/index.css +++ /dev/null @@ -1,79 +0,0 @@ -/** -* Derived from https://github.com/novnc/noVNC/blob/v1.4.0/vnc_lite.html, which was licensed -* under the 2-clause BSD license -*/ - -body { - margin: 0; - background-color: dimgrey; - height: 100%; - display: flex; - flex-direction: column; -} - -html { - height: 100%; -} - -#top_bar { - background-color: #6e84a3; - color: white; - font: bold 12px Helvetica; - padding: 6px 5px 4px 5px; - border-bottom: 1px outset; -} - -#status { - text-align: center; -} - -#sendCtrlAltDelButton { - position: fixed; - top: 0px; - right: 0px; - border: 1px outset; - padding: 5px 5px 4px 5px; - cursor: pointer; -} - -#screen { - flex: 1; - /* fill remaining space */ - overflow: hidden; -} - -/* Clipboard */ -#noVNC_clipboard_area { - position: fixed; - top: 0px; - left: 0px; -} - -#noVNC_clipboard_button { - border: 1px outset; - cursor: pointer; -} - -#noVNC_clipboard_button img { - height: 24px; - vertical-align: middle; -} - -#noVNC_clipboard_button .label { - padding: 5px 5px 4px 0px; -} - -#noVNC_clipboard { - /* Full screen, minus padding and left and right margins */ - max-width: calc(100vw - 2 * 15px - 75px - 25px); - background-color: #6e84a3; -} - -.noVNC_clipboard_closed #noVNC_clipboard { - display: none; -} - -#noVNC_clipboard_text { - width: 500px; - max-width: 100%; -} diff --git a/jupyter_remote_desktop_proxy/static/index.html b/jupyter_remote_desktop_proxy/static/index.html index 376f261e..688f02a7 100644 --- a/jupyter_remote_desktop_proxy/static/index.html +++ b/jupyter_remote_desktop_proxy/static/index.html @@ -13,30 +13,40 @@ Chrome Frame. --> - + -
-
Loading
- - -
-
- - Clipboard -
-
- -
-
- -
Send CtrlAltDel
+
+ +
+ + + diff --git a/jupyter_remote_desktop_proxy/static/jupyter-logo.svg b/jupyter_remote_desktop_proxy/static/jupyter-logo.svg new file mode 100644 index 00000000..fde0d6e3 --- /dev/null +++ b/jupyter_remote_desktop_proxy/static/jupyter-logo.svg @@ -0,0 +1,88 @@ + +logo-1.svg +Created using Figma 0.90 + + + + + + + + + + + + + + + + + + diff --git a/package.json b/package.json index 11c795b8..00042647 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "dependencies": { - "@novnc/novnc": "^1.4.0" + "@floating-ui/dom": "^1.6.1", + "@novnc/novnc": "^1.4.0", + "reset-css": "^5.0.2" }, "scripts": { "webpack": "webpack", @@ -8,6 +10,10 @@ }, "devDependencies": { "babel-loader": "^9.1.3", + "css-loader": "^6.10.0", + "eslint": "^8.56.0", + "mini-css-extract-plugin": "^2.8.0", + "style-loader": "^3.3.4", "webpack": "^5.90.1", "webpack-cli": "^5.1.4" } diff --git a/webpack.config.js b/webpack.config.js index e86067f6..6ec22bc8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,8 +1,14 @@ const webpack = require("webpack"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const path = require("path"); module.exports = { entry: path.resolve(__dirname, "js/index.js"), + plugins: [ + new MiniCssExtractPlugin({ + filename: "index.css", + }), + ], devtool: "source-map", mode: "development", module: { @@ -14,7 +20,7 @@ module.exports = { }, { test: /\.(css)/, - use: ["style-loader", "css-loader"], + use: [MiniCssExtractPlugin.loader, "css-loader"], }, ], },