diff --git a/app/images/icons/novnc-icon-35x21.svg b/app/images/icons/novnc-icon-35x21.svg new file mode 100644 index 000000000..774c334ae --- /dev/null +++ b/app/images/icons/novnc-icon-35x21.svg @@ -0,0 +1,98 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/styles/base.css b/app/styles/base.css index 33f0f3596..5e71ae730 100644 --- a/app/styles/base.css +++ b/app/styles/base.css @@ -117,7 +117,8 @@ html { .noVNC_center > * { pointer-events: auto; } -.noVNC_vcenter { + +.noVNC_crosscenter { display: flex !important; flex-direction: column; justify-content: center; @@ -129,9 +130,29 @@ html { padding: 0 !important; pointer-events: none; } -.noVNC_vcenter > * { +.noVNC_crosscenter > * { pointer-events: auto; } +.noVNC_right .noVNC_crosscenter { + left: auto; + right: 0; +} +.noVNC_top.noVNC_crosscenter, +.noVNC_top .noVNC_crosscenter { + flex-direction: row; + width: 100%; + height: auto; +} +.noVNC_bottom.noVNC_crosscenter, +.noVNC_bottom .noVNC_crosscenter { + flex-direction: row; + width: 100%; + height: auto; +} +.noVNC_bottom .noVNC_crosscenter { + top: auto; + bottom: 0; +} /* ---------------------------------------- * Layering @@ -231,10 +252,18 @@ html { :root.noVNC_connected #noVNC_control_bar_anchor.noVNC_idle { opacity: 0.8; } +#noVNC_control_bar_anchor:is(.noVNC_top, .noVNC_bottom) { + /* Edge misrenders animations wihthout this */ + transform: translateY(0); +} #noVNC_control_bar_anchor.noVNC_right { left: auto; right: 0; } +#noVNC_control_bar_anchor.noVNC_bottom { + top: auto; + bottom: 0; +} #noVNC_control_bar { position: relative; @@ -249,10 +278,34 @@ html { -webkit-user-select: none; -webkit-touch-callout: none; /* Disable iOS image long-press popup */ } +.noVNC_right #noVNC_control_bar { + left: 100%; + border-radius: 12px 0 0 12px; +} +.noVNC_top #noVNC_control_bar { + left: auto; + /* FIXME: We want to mirror the left and right modes here and use a + relative top offset (-100%), but it doesn't resolve + correctly against the anchor height reference */ + top: -55px; + border-radius: 0 0 12px 12px; +} +.noVNC_bottom #noVNC_control_bar { + left: auto; + /* FIXME: We want to mirror the left and right modes here and use a + relative top offset (100%), but it doesn't resolve + correctly against the anchor height reference */ + top: 55px; + border-radius: 12px 12px 0 0; +} #noVNC_control_bar.noVNC_open { box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5); left: 0; } +:is(.noVNC_top, .noVNC_bottom) #noVNC_control_bar.noVNC_open { + left: auto; + top: 0; +} #noVNC_control_bar::before { /* This extra element is to get a proper shadow */ content: ""; @@ -263,20 +316,23 @@ html { left: -30px; transition: box-shadow 0.5s ease-in-out; } -#noVNC_control_bar.noVNC_open::before { - box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5); -} -.noVNC_right #noVNC_control_bar { - left: 100%; - border-radius: 12px 0 0 12px; +.noVNC_right #noVNC_control_bar::before { + visibility: hidden; } -.noVNC_right #noVNC_control_bar.noVNC_open { - left: 0; +.noVNC_top #noVNC_control_bar::before { + height: 30px; + width: 100%; + top: -30px; + bottom: auto; } -.noVNC_right #noVNC_control_bar::before { +.noVNC_bottom #noVNC_control_bar::before { visibility: hidden; } +#noVNC_control_bar.noVNC_open::before { + box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5); +} + #noVNC_control_bar_handle { position: absolute; left: -15px; @@ -288,41 +344,96 @@ html { cursor: pointer; border-radius: 6px; background-color: var(--novnc-darkblue); - background-image: url("../images/handle_bg.svg"); - background-repeat: no-repeat; - background-position: right; box-shadow: 3px 3px 0px rgba(0, 0, 0, 0.5); } +:root:not(.noVNC_connected) #noVNC_control_bar_handle { + display: none; +} +:is(.noVNC_top, .noVNC_bottom) #noVNC_control_bar_handle { + transform: translateX(35px); + top: -15px; + left: 0; + width: 50px; + height: calc(100% + 30px); +} + +#noVNC_control_bar_handle::before { + content: ""; + background: url("../images/handle_bg.svg"); + background-repeat: no-repeat; + position: absolute; + top: 0; + right: 0; + width: 15px; + height: 50px; +} +.noVNC_right #noVNC_control_bar_handle::before { + left: 0; + right: auto; +} +.noVNC_top #noVNC_control_bar_handle::before { + left: 0; + right: auto; + transform-origin: bottom left; + transform: rotate(90deg) translateX(20px); +} +.noVNC_bottom #noVNC_control_bar_handle::before { + left: 0; + right: auto; + transform-origin: bottom left; + transform: rotate(90deg) translateX(-50px); +} + #noVNC_control_bar_handle:after { content: ""; transition: transform 0.5s ease-in-out; - background: url("../images/handle.svg"); + background: url("../images/handle.svg") no-repeat center; + background-size: 5px 6px; position: absolute; - top: 22px; /* (50px-6px)/2 */ - right: 5px; - width: 5px; - height: 6px; + top: 20px; /* (50px-10px)/2 */ + right: 3px; + transform: none; + width: 10px; + height: 10px; + transform-origin: center; } -#noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after { - transform: translateX(1px) rotate(180deg); +.noVNC_right #noVNC_control_bar_handle:after { + left: 3px; + right: auto; + transform: rotate(180deg); } -:root:not(.noVNC_connected) #noVNC_control_bar_handle { - display: none; +.noVNC_top #noVNC_control_bar_handle:after { + left: 20px; + right: auto; + top: auto; + bottom: 3px; + transform: rotate(90deg); } -.noVNC_right #noVNC_control_bar_handle { - background-position: left; +.noVNC_bottom #noVNC_control_bar_handle:after { + left: 20px; + right: auto; + top: 3px; + bottom: auto; + transform: rotate(-90deg); } -.noVNC_right #noVNC_control_bar_handle:after { - left: 5px; - right: 0; + +#noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after { transform: translateX(1px) rotate(180deg); } .noVNC_right #noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after { - transform: none; + transform: translateX(-1px); +} +.noVNC_top #noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after { + transform: translateY(1px) rotate(-90deg); } +.noVNC_bottom #noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after { + transform: translateY(-1px) rotate(90deg); +} + /* Larger touch area for the handle, used when a touch screen is available */ #noVNC_control_bar_handle div { position: absolute; + left: auto; right: -35px; top: 0; width: 50px; @@ -338,35 +449,66 @@ html { left: -35px; right: auto; } +.noVNC_top #noVNC_control_bar_handle div { + left: 0; + right: auto; + top: auto; + bottom: -35px; + width: 100%; + height: 50px; +} +.noVNC_bottom #noVNC_control_bar_handle div { + left: 0; + right: auto; + top: -35px; + bottom: auto; + width: 100%; + height: 50px; +} #noVNC_control_bar > .noVNC_scroll { max-height: 100vh; /* Chrome is buggy with 100% */ overflow-x: hidden; overflow-y: auto; - padding: 0 10px; + padding: 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + gap: 10px 0; } - -#noVNC_control_bar > .noVNC_scroll > * { - display: block; - margin: 10px auto; +:is(.noVNC_top, .noVNC_bottom) > #noVNC_control_bar > .noVNC_scroll { + max-width: 100vw; /* Chrome is buggy with 100% */ + overflow-x: auto; + overflow-y: hidden; + flex-direction: row; + gap: 0 10px; } /* Control bar hint */ -#noVNC_hint_anchor { +.noVNC_hint_anchor { position: fixed; + left: -50px; +} +.noVNC_hint_anchor.noVNC_right { + left: auto; right: -50px; +} +.noVNC_hint_anchor.noVNC_top { left: auto; + top: -50px; } -#noVNC_control_bar_anchor.noVNC_right + #noVNC_hint_anchor { - left: -50px; - right: auto; +.noVNC_hint_anchor.noVNC_bottom { + left: auto; + top: auto; + bottom: -50px; } -#noVNC_control_bar_hint { - position: relative; - transform: scale(0); +.noVNC_control_bar_hint { width: 100px; height: 50%; max-height: 600px; + position: relative; + transform: scale(0); visibility: hidden; opacity: 0; @@ -376,13 +518,19 @@ html { border-radius: 12px; transition-delay: 0s; } -#noVNC_control_bar_hint.noVNC_active { +:is(.noVNC_top, .noVNC_bottom) .noVNC_control_bar_hint { + width: 50%; + height: 100px; + max-width: 600px; + max-height: none; +} +.noVNC_control_bar_hint.noVNC_active { visibility: visible; opacity: 1; transition-delay: 0.2s; transform: scale(1); } -#noVNC_control_bar_hint.noVNC_notransition { +.noVNC_control_bar_hint.noVNC_notransition { transition: none !important; } @@ -390,7 +538,6 @@ html { #noVNC_control_bar .noVNC_button { min-width: unset; padding: 4px 4px; - vertical-align: middle; border:1px solid rgba(255, 255, 255, 0.2); border-radius: 6px; background-color: transparent; @@ -411,7 +558,7 @@ html { box-sizing: border-box; /* so max-width don't have to care about padding */ max-width: calc(100vw - 75px - 25px); /* minus left and right margins */ - max-height: 100vh; /* Chrome is buggy with 100% */ + max-height: calc(100vh - 75px - 25px); /* minus top and bottom margins */ overflow-x: hidden; overflow-y: auto; @@ -431,16 +578,24 @@ html { opacity: 1; transform: translateX(75px); } -.noVNC_right .noVNC_vcenter { - left: auto; - right: 0; -} .noVNC_right .noVNC_panel { transform: translateX(-25px); } .noVNC_right .noVNC_panel.noVNC_open { transform: translateX(-75px); } +.noVNC_top .noVNC_panel { + transform: translateY(25px); +} +.noVNC_top .noVNC_panel.noVNC_open { + transform: translateY(75px); +} +.noVNC_bottom .noVNC_panel { + transform: translateY(-25px); +} +.noVNC_bottom .noVNC_panel.noVNC_open { + transform: translateY(-75px); +} .noVNC_panel > * { display: block; @@ -536,13 +691,26 @@ html { /* Control bar content */ #noVNC_control_bar .noVNC_logo { - font-size: 13px; + display: block; + max-width: 35px; + max-height: 35px; + object-fit: contain; } .noVNC_logo + hr { /* Remove all but top border */ border: none; border-top: 1px solid rgba(255, 255, 255, 0.2); + width: 35px; + height: 1px; + margin: 0; +} +:is(.noVNC_top, .noVNC_bottom) .noVNC_logo + hr { + /* Remove all but left border */ + border-left: 1px solid rgba(255, 255, 255, 0.2); + border-top: none; + width: 1px; + height: 35px; } :root:not(.noVNC_connected) #noVNC_view_drag_button { @@ -550,16 +718,15 @@ html { } /* noVNC Touch Device only buttons */ -:root:not(.noVNC_connected) #noVNC_mobile_buttons { +:root:not(.noVNC_connected) #noVNC_keyboard_button { display: none; } @media not all and (any-pointer: coarse) { - /* FIXME: The button for the virtual keyboard is the only button in this - group of "mobile buttons". It is bad to assume that no touch - devices have physical keyboards available. Hopefully we can get - a media query for this: + /* FIXME: It is bad to assume that no touch devices have physical + keyboards available. Hopefully we can get a media query + for this: https://github.com/w3c/csswg-drafts/issues/3871 */ - :root.noVNC_connected #noVNC_mobile_buttons { + :root.noVNC_connected #noVNC_keyboard_button { display: none; } } @@ -573,6 +740,18 @@ html { background-color: var(--novnc-darkgrey); border: none; padding: 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + gap: 10px 0; +} +#noVNC_modifiers > * { + margin: 0; +} +:is(.noVNC_top, .noVNC_bottom) #noVNC_modifiers { + flex-direction: row; + gap: 0 10px; } /* Shutdown/Reboot */ diff --git a/app/ui.js b/app/ui.js index 24d32d55b..2f9e602c0 100644 --- a/app/ui.js +++ b/app/ui.js @@ -37,6 +37,8 @@ const UI = { controlbarGrabbed: false, controlbarDrag: false, + controlbarMouseDownClientX: 0, + controlbarMouseDownOffsetX: 0, controlbarMouseDownClientY: 0, controlbarMouseDownOffsetY: 0, @@ -110,8 +112,11 @@ const UI = { } // Restore control bar position - if (WebUtil.readSetting('controlbar_pos') === 'right') { - UI.toggleControlbarSide(); + const pos = WebUtil.readSetting('controlbar_pos'); + if (['left', 'right', 'top', 'bottom'].includes(pos)) { + UI.toggleControlbarSide(pos); + } else { + UI.toggleControlbarSide('left'); } UI.initFullscreen(); @@ -575,7 +580,15 @@ const UI = { } }, - toggleControlbarSide() { + getControlbarPos() { + const anchor = document.getElementById('noVNC_control_bar_anchor'); + if (anchor.classList.contains('noVNC_right')) return 'right'; + if (anchor.classList.contains('noVNC_top')) return 'top'; + if (anchor.classList.contains('noVNC_bottom')) return 'bottom'; + return 'left'; + }, + + toggleControlbarSide(pos) { // Temporarily disable animation, if bar is displayed, to avoid weird // movement. The transitionend-event will not fire when display=none. const bar = document.getElementById('noVNC_control_bar'); @@ -586,13 +599,12 @@ const UI = { } const anchor = document.getElementById('noVNC_control_bar_anchor'); - if (anchor.classList.contains("noVNC_right")) { - WebUtil.writeSetting('controlbar_pos', 'left'); - anchor.classList.remove("noVNC_right"); - } else { - WebUtil.writeSetting('controlbar_pos', 'right'); - anchor.classList.add("noVNC_right"); + + anchor.classList.remove('noVNC_right', 'noVNC_top', 'noVNC_bottom'); + if (['right', 'top', 'bottom'].includes(pos)) { + anchor.classList.add(`noVNC_${pos}`); } + WebUtil.writeSetting('controlbar_pos', pos); // Consider this a movement of the handle UI.controlbarDrag = true; @@ -602,19 +614,21 @@ const UI = { }, showControlbarHint(show, animate=true) { - const hint = document.getElementById('noVNC_control_bar_hint'); + const getPos = element => + ['right', 'top', 'bottom'].find(pos => + element.classList.contains(`noVNC_${pos}`) + ) ?? 'left'; - if (animate) { - hint.classList.remove("noVNC_notransition"); - } else { - hint.classList.add("noVNC_notransition"); - } + const anchor = document.getElementById('noVNC_control_bar_anchor'); + const anchorPos = getPos(anchor); - if (show) { - hint.classList.add("noVNC_active"); - } else { - hint.classList.remove("noVNC_active"); - } + document.querySelectorAll('.noVNC_control_bar_hint').forEach((hint) => { + const hintPos = getPos(hint.parentElement); + const shouldShow = show && (hintPos !== anchorPos); + + hint.classList.toggle('noVNC_active', shouldShow); + hint.classList.toggle('noVNC_notransition', !animate || !shouldShow); + }); }, dragControlbarHandle(e) { @@ -622,28 +636,62 @@ const UI = { const ptr = getPointerEvent(e); - const anchor = document.getElementById('noVNC_control_bar_anchor'); - if (ptr.clientX < (window.innerWidth * 0.1)) { - if (anchor.classList.contains("noVNC_right")) { - UI.toggleControlbarSide(); + let controlBarPos = UI.getControlbarPos(); + + if (ptr.clientX < (window.innerWidth * 0.1) && + ptr.clientY > (window.innerHeight * 0.25) && + ptr.clientY < (window.innerHeight * 0.75)) { + if (controlBarPos !== 'left') { + UI.toggleControlbarSide('left'); + controlBarPos = 'left'; + } + + } else if (ptr.clientX > (window.innerWidth * 0.9) && + ptr.clientY > (window.innerHeight * 0.25) && + ptr.clientY < (window.innerHeight * 0.75)) { + if (controlBarPos !== 'right') { + UI.toggleControlbarSide('right'); + controlBarPos = 'right'; } - } else if (ptr.clientX > (window.innerWidth * 0.9)) { - if (!anchor.classList.contains("noVNC_right")) { - UI.toggleControlbarSide(); + + // Slightly increased height thresholds since 10% of the + // height proved small in practice + } else if (ptr.clientX > (window.innerWidth * 0.25) && + ptr.clientX < (window.innerWidth * 0.75) && + ptr.clientY < (window.innerHeight * 0.2)) { + if (controlBarPos !== 'top') { + UI.toggleControlbarSide('top'); + controlBarPos = 'top'; + } + + } else if (ptr.clientX > (window.innerWidth * 0.25) && + ptr.clientX < (window.innerWidth * 0.75) && + ptr.clientY > (window.innerHeight * 0.8)) { + if (controlBarPos !== 'bottom') { + UI.toggleControlbarSide("bottom"); + controlBarPos = 'bottom'; } } + const isVertical = controlBarPos === 'left' || controlBarPos === 'right'; + if (!UI.controlbarDrag) { - const dragDistance = Math.abs(ptr.clientY - UI.controlbarMouseDownClientY); + const dragDistance = isVertical + ? Math.abs(ptr.clientY - UI.controlbarMouseDownClientY) + : Math.abs(ptr.clientX - UI.controlbarMouseDownClientX); if (dragDistance < dragThreshold) return; UI.controlbarDrag = true; } - const eventY = ptr.clientY - UI.controlbarMouseDownOffsetY; - - UI.moveControlbarHandle(eventY); + if (isVertical) { + const eventY = ptr.clientY - UI.controlbarMouseDownOffsetY; + UI.moveControlbarHandle(eventY, true); + } else { + const eventX = ptr.clientX - UI.controlbarMouseDownOffsetX; + UI.moveControlbarHandle(eventX, false); + } e.preventDefault(); e.stopPropagation(); @@ -652,41 +700,56 @@ const UI = { }, // Move the handle but don't allow any position outside the bounds - moveControlbarHandle(viewportRelativeY) { + moveControlbarHandle(viewportRelativeCoord, isVertical) { const handle = document.getElementById("noVNC_control_bar_handle"); - const handleHeight = handle.getBoundingClientRect().height; + + const handleSpan = isVertical + ? handle.getBoundingClientRect().height + : handle.getBoundingClientRect().width; + const controlbarBounds = document.getElementById("noVNC_control_bar") .getBoundingClientRect(); + const controlbarBoundsStart = isVertical + ? controlbarBounds.top + : controlbarBounds.left; + const controlbarBoundsSpan = isVertical + ? controlbarBounds.height + : controlbarBounds.width; + const margin = 10; // These heights need to be non-zero for the below logic to work - if (handleHeight === 0 || controlbarBounds.height === 0) { + if (handleSpan === 0 || controlbarBoundsSpan === 0) { return; } - let newY = viewportRelativeY; + let newCoord = viewportRelativeCoord; // Check if the coordinates are outside the control bar - if (newY < controlbarBounds.top + margin) { - // Force coordinates to be below the top of the control bar - newY = controlbarBounds.top + margin; + if (newCoord < controlbarBoundsStart + margin) { + // Force coordinates to be below the start of the control bar + newCoord = controlbarBoundsStart + margin; - } else if (newY > controlbarBounds.top + - controlbarBounds.height - handleHeight - margin) { - // Force coordinates to be above the bottom of the control bar - newY = controlbarBounds.top + - controlbarBounds.height - handleHeight - margin; + } else if (newCoord > controlbarBoundsStart + + controlbarBoundsSpan - handleSpan - margin) { + // Force coordinates to be before the end of the control bar + newCoord = controlbarBoundsStart + + controlbarBoundsSpan - handleSpan - margin; } // Corner case: control bar too small for stable position - if (controlbarBounds.height < (handleHeight + margin * 2)) { - newY = controlbarBounds.top + - (controlbarBounds.height - handleHeight) / 2; + if (controlbarBoundsSpan < (handleSpan + margin * 2)) { + newCoord = controlbarBoundsStart + + (controlbarBoundsSpan - handleSpan) / 2; } // The transform needs coordinates that are relative to the parent - const parentRelativeY = newY - controlbarBounds.top; - handle.style.transform = "translateY(" + parentRelativeY + "px)"; + const parentRelativeCoord = newCoord - controlbarBoundsStart; + if (isVertical) { + handle.style.transform = "translateY(" + parentRelativeCoord + "px)"; + } else { + handle.style.transform = "translateX(" + parentRelativeCoord + "px)"; + } }, updateControlbarHandle() { @@ -694,7 +757,15 @@ const UI = { // the move function expects coordinates relative the the viewport. const handle = document.getElementById("noVNC_control_bar_handle"); const handleBounds = handle.getBoundingClientRect(); - UI.moveControlbarHandle(handleBounds.top); + + const controlBarPos = UI.getControlbarPos(); + const isVertical = controlBarPos === 'left' || controlBarPos === 'right'; + + if (isVertical) { + UI.moveControlbarHandle(handleBounds.top, true); + } else { + UI.moveControlbarHandle(handleBounds.left, false); + } }, controlbarHandleMouseUp(e) { @@ -732,6 +803,8 @@ const UI = { UI.controlbarMouseDownClientY = ptr.clientY; UI.controlbarMouseDownOffsetY = ptr.clientY - bounds.top; + UI.controlbarMouseDownClientX = ptr.clientX; + UI.controlbarMouseDownOffsetX = ptr.clientX - bounds.left; e.preventDefault(); e.stopPropagation(); UI.keepControlbar(); diff --git a/vnc.html b/vnc.html index c36a0f078..ad7f67a45 100644 --- a/vnc.html +++ b/vnc.html @@ -110,14 +110,14 @@ -
- +
-

no
VNC

+
@@ -127,16 +127,14 @@

no
VNC

title="Move/Drag viewport"> -
- -
+ -
+
no
VNC -
+
Power @@ -178,7 +176,7 @@

no
VNC

-
+
Clipboard @@ -199,7 +197,7 @@

no
VNC

-
+
Settings @@ -332,8 +330,20 @@

no
VNC

-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+