diff --git a/app/client/ui/FloatingPopup.ts b/app/client/ui/FloatingPopup.ts index 15b85fc9..8a740199 100644 --- a/app/client/ui/FloatingPopup.ts +++ b/app/client/ui/FloatingPopup.ts @@ -1,9 +1,12 @@ import {hoverTooltip} from 'app/client/ui/tooltips'; import {isNarrowScreen, isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {Disposable, dom, DomArg, DomContents, makeTestId, Observable, styled} from 'grainjs'; +import {Disposable, dom, DomArg, DomContents, IDisposable, makeTestId, Observable, styled} from 'grainjs'; -const POPUP_PADDING_PX = 16; +const POPUP_INITIAL_PADDING_PX = 16; +const POPUP_MIN_HEIGHT = 300; +const POPUP_MAX_HEIGHT = 711; +const POPUP_HEADER_HEIGHT = 30; const testId = makeTestId('test-floating-popup-'); @@ -19,8 +22,13 @@ export class FloatingPopup extends Disposable { protected _isMinimized = Observable.create(this, false); private _popupElement: HTMLElement | null = null; - private _clientX: number; - private _clientY: number; + private _startX: number; + private _startY: number; + private _initialTop: number; + private _initialBottom: number; + private _initialLeft: number; + private _resize = false; + private _cursorGrab: IDisposable|null = null; constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) { super(); @@ -37,6 +45,7 @@ export class FloatingPopup extends Disposable { this.onDispose(() => { this._closePopup(); + this._cursorGrab?.dispose(); }); } @@ -44,7 +53,7 @@ export class FloatingPopup extends Disposable { this._popupElement = this._buildPopup(); document.body.appendChild(this._popupElement); const topPaddingPx = getTopPopupPaddingPx(); - const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_PADDING_PX; + const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_INITIAL_PADDING_PX; const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx; this._popupElement.style.left = `${initialLeft}px`; this._popupElement.style.top = `${initialTop}px`; @@ -69,61 +78,125 @@ export class FloatingPopup extends Disposable { return this._args; } + private _rememberPosition() { + this._initialLeft = this._popupElement!.offsetLeft; + this._initialTop = this._popupElement!.offsetTop; + this._initialBottom = this._popupElement!.offsetTop + this._popupElement!.offsetHeight; + } + private _handleMouseDown(ev: MouseEvent) { if (ev.button !== 0) { return; } // Only handle left-click. - this._clientX = ev.clientX; - this._clientY = ev.clientY; + this._startX = ev.clientX; + this._startY = ev.clientY; + this._rememberPosition(); document.addEventListener('mousemove', this._handleMouseMove); document.addEventListener('mouseup', this._handleMouseUp); + this._forceCursor(); } private _handleTouchStart(ev: TouchEvent) { - this._clientX = ev.touches[0].clientX; - this._clientY = ev.touches[0].clientY; + this._startX = ev.touches[0].clientX; + this._startY = ev.touches[0].clientY; + this._rememberPosition(); document.addEventListener('touchmove', this._handleTouchMove); document.addEventListener('touchend', this._handleTouchEnd); - } - private _handleMouseMove({clientX, clientY}: MouseEvent) { - this._handleMove(clientX, clientY); + this._resize = false; + this._forceCursor(); } private _handleTouchMove({touches}: TouchEvent) { - this._handleMove(touches[0].clientX, touches[0].clientY); + this._handleMouseMove(touches[0]); } - private _handleMove(clientX: number, clientY: number) { - const deltaX = clientX - this._clientX; - const deltaY = clientY - this._clientY; - let newLeft = this._popupElement!.offsetLeft + deltaX; - let newTop = this._popupElement!.offsetTop + deltaY; - - const topPaddingPx = getTopPopupPaddingPx(); - if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; } - if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; } - if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { - newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX; + private _handleMouseMove({clientX, clientY}: MouseEvent | Touch) { + if (this._resize) { + this._handleResize(clientY); + } else { + this._handleMove(clientX, clientY); } - if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) { - newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx; + } + + private _handleResize(clientY: number) { + const deltaY = clientY - this._startY; + if (this._resize && !isNarrowScreen()) { + // First calculate the boundaries for the new top. + + // First just how much we can resize the popup. + let minTop = this._initialBottom - POPUP_MAX_HEIGHT; + let maxTop = this._initialBottom - POPUP_MIN_HEIGHT; + + // Now how far we can move top (leave at least some padding for mobile). + minTop = Math.max(minTop, getTopPopupPaddingPx()); + // And bottom (we want the header to be visible) + maxTop = Math.min(document.body.offsetHeight - POPUP_HEADER_HEIGHT - 2, maxTop); + + // Now get new top from those boundaries. + const newTop = Math.max(minTop, Math.min(maxTop, this._initialTop + deltaY)); + // And calculate the new height. + const newHeight = this._initialBottom - newTop; + this._popupElement!.style.top = `${newTop}px`; + this._popupElement!.style.setProperty('--height', `${newHeight}px`); + return; } + } + + private _handleMove(clientX: number, clientY: number) { + // Last change in position (from last move). + const deltaX = clientX - this._startX; + const deltaY = clientY - this._startY; + + // Available space where we can put the popup (anchored at top left corner). + const viewPort = { + right: document.body.offsetWidth, + bottom: document.body.offsetHeight, + top: getTopPopupPaddingPx(), + left: 0, + }; + + // Allow some extra space, where we can still move the popup outside the viewport. + viewPort.right += this._popupElement!.offsetWidth - (POPUP_HEADER_HEIGHT + 2) * 4; + viewPort.left -= this._popupElement!.offsetWidth - (POPUP_HEADER_HEIGHT + 2) * 4; + viewPort.bottom += this._popupElement!.offsetHeight - POPUP_HEADER_HEIGHT - 2; // 2px border top + + let newLeft = this._initialLeft + deltaX; + let newTop = this._initialTop + deltaY; + const newRight = (val?: number) => { + if (val !== undefined) { newLeft = val - this._popupElement!.offsetWidth; } + return newLeft + this._popupElement!.offsetWidth; + }; + const newBottom = (val?: number) => { + if (val !== undefined) { newTop = val - this._popupElement!.offsetHeight; } + return newTop + this._popupElement!.offsetHeight; + }; + + // Calculate new position in the padding area. + if (newLeft < viewPort.left) { newLeft = viewPort.left; } + if (newRight() > viewPort.right) { newRight(viewPort.right); } + if (newTop < viewPort.top) { newTop = viewPort.top; } + if (newBottom() > viewPort.bottom) { newBottom(viewPort.bottom); } this._popupElement!.style.left = `${newLeft}px`; this._popupElement!.style.top = `${newTop}px`; - this._clientX = clientX; - this._clientY = clientY; } private _handleMouseUp() { document.removeEventListener('mousemove', this._handleMouseMove); document.removeEventListener('mouseup', this._handleMouseUp); document.body.removeEventListener('mouseleave', this._handleMouseUp); + this._handleMouseEnd(); } private _handleTouchEnd() { document.removeEventListener('touchmove', this._handleTouchMove); document.removeEventListener('touchend', this._handleTouchEnd); document.body.removeEventListener('touchcancel', this._handleTouchEnd); + this._handleMouseEnd(); + } + + private _handleMouseEnd() { + this._resize = false; + this._cursorGrab?.dispose(); } private _handleWindowResize() { @@ -135,10 +208,10 @@ export class FloatingPopup extends Disposable { let newTop = this._popupElement!.offsetTop; const topPaddingPx = getTopPopupPaddingPx(); - if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; } + if (newLeft - POPUP_INITIAL_PADDING_PX < 0) { newLeft = POPUP_INITIAL_PADDING_PX; } if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; } - if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { - newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX; + if (newLeft + POPUP_INITIAL_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { + newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_INITIAL_PADDING_PX; } if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) { newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx; @@ -153,6 +226,17 @@ export class FloatingPopup extends Disposable { {tabIndex: '-1'}, cssPopup.cls('-auto', this._options.autoHeight ?? false), cssPopupHeader( + cssBottomHandle(testId('move-handle')), + dom.maybe(use => !use(this._isMinimized), () => { + return cssResizeTopLayer( + cssTopHandle(testId('resize-handle')), + dom.on('mousedown', () => this._resize = true), + dom.on('dblclick', () => { + this._popupElement?.style.setProperty('--height', `${POPUP_MAX_HEIGHT}px`); + this._repositionPopup(); + }) + ); + }), dom.domComputed(this._isMinimized, isMinimized => { return [ // Copy buttons on the left side of the header, to automatically @@ -223,26 +307,48 @@ export class FloatingPopup extends Disposable { return body; } + + private _forceCursor() { + this._cursorGrab?.dispose(); + const type = this._resize ? 'ns-resize' : 'grabbing'; + const cursorStyle: HTMLStyleElement = document.createElement('style'); + cursorStyle.innerHTML = `*{cursor: ${type}!important;}`; + cursorStyle.id = 'cursor-style'; + document.head.appendChild(cursorStyle); + const cursorOwner = { + dispose() { + if (this.isDisposed()) { return; } + document.head.removeChild(cursorStyle); + }, + isDisposed() { + return !cursorStyle.isConnected; + } + }; + this._cursorGrab = cursorOwner; + } } + function getTopPopupPaddingPx(): number { // On mobile, we need additional padding to avoid blocking the top and bottom bars. - return POPUP_PADDING_PX + (isNarrowScreen() ? 50 : 0); + return POPUP_INITIAL_PADDING_PX + (isNarrowScreen() ? 50 : 0); } -const POPUP_HEIGHT = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`; -const POPUP_HEIGHT_MOBILE = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px) - (2 * 50px)))`; -const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`; +const POPUP_HEIGHT = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`; +const POPUP_HEIGHT_MOBILE = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px) - (2 * 50px)))`; +const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`; const cssPopup = styled('div', ` - position: absolute; + position: fixed; display: flex; flex-direction: column; border: 2px solid ${theme.accentBorder}; border-radius: 5px; z-index: 999; + --height: ${POPUP_MAX_HEIGHT}px; height: ${POPUP_HEIGHT}; width: ${POPUP_WIDTH}; + min-height: ${POPUP_MIN_HEIGHT}px; background-color: ${theme.popupBg}; box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow}; outline: unset; @@ -254,6 +360,7 @@ const cssPopup = styled('div', ` &-minimized { max-width: 225px; height: unset; + min-height: unset; } &-minimized:not(&-mobile) { @@ -267,6 +374,7 @@ const cssPopup = styled('div', ` &-auto { height: auto; max-height: ${POPUP_HEIGHT}; + min-height: unset; } &-auto&-mobile { @@ -283,11 +391,12 @@ const cssPopupHeader = styled('div', ` cursor: grab; padding-left: 4px; padding-right: 4px; - height: 30px; + height: ${POPUP_HEADER_HEIGHT}px; user-select: none; display: flex; justify-content: space-between; position: relative; + isolation: isolate; &:active { cursor: grabbing; } @@ -323,8 +432,36 @@ const cssPopupHeaderButton = styled('div', ` padding: 4px; border-radius: 4px; cursor: pointer; + z-index: 1000; &:hover { background-color: ${theme.hover}; } `); + +const cssResizeTopLayer = styled('div', ` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 70%; + z-index: 500; + cursor: ns-resize; +`); + +const cssTopHandle = styled('div', ` + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + pointer-events: none; + user-select: none; + visibility: hidden; +`); + +const cssBottomHandle = styled(cssTopHandle, ` + top: unset; + bottom: 0; + left: 0; +`); diff --git a/test/nbrowser/DocTutorial.ts b/test/nbrowser/DocTutorial.ts index 921df42a..f6ac4f42 100644 --- a/test/nbrowser/DocTutorial.ts +++ b/test/nbrowser/DocTutorial.ts @@ -108,6 +108,160 @@ describe('DocTutorial', function () { ); }); + it('can be moved around and minimized', async function() { + // Get the initial position of the popup. + const initialDims = await driver.find('.test-floating-popup-window').getRect(); + // Get the move handle. + const mover = await driver.find('.test-floating-popup-move-handle'); + const moverInitial = await mover.getRect(); + + const move = async (pos: {x?: number, y?: number}) => driver.withActions((actions) => actions + .move({origin: driver.find('.test-floating-popup-move-handle')}) + .press() + .move({origin: driver.find('.test-floating-popup-move-handle'), ...pos}) + .release() + ); + + const resize = async (pos: {x?: number, y?: number}) => driver.withActions((actions) => actions + .move({origin: driver.find('.test-floating-popup-resize-handle')}) + .press() + .move({origin: driver.find('.test-floating-popup-resize-handle'), ...pos}) + .release() + ); + + // Move it a little bit down. + await move({y: 100, x: -10}); + + // Check it is moved but not shrinked. + let dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.height, initialDims.height); + // And moving down. + assert.equal(dims.y, initialDims.y + 100); + assert.equal(dims.x, initialDims.x - 10); + + // Now move it a little up and test it doesn't grow. + await move({y: -100}); + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.height, initialDims.height); + assert.equal(dims.y, initialDims.y); + + // Resize it in steps. + await resize({y: 10}); + await resize({y: 10}); + await resize({y: 10}); + await resize({y: 10}); + await resize({y: 10}); + await resize({y: 50}); + + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.height, initialDims.height - 100); + assert.equal(dims.y, initialDims.y + 100); + + // Resize back (in steps, to simulate user actions) + await resize({y: -20}); + await resize({y: -20}); + await resize({y: -20}); + await resize({y: -40}); + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.height, initialDims.height); + assert.equal(dims.y, initialDims.y); + + // Now resize it beyond the maximum size and check it doesn't move. + await resize({y: -10}); + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.height, initialDims.height); + assert.equal(dims.y, initialDims.y); + + // Now resize it to the minimum size. + await resize({y: 700}); + + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.height, 300); + + // Get window inner size. + const windowHeight: any = await driver.executeScript('return window.innerHeight'); + const windowWidth: any = await driver.executeScript('return window.innerWidth'); + assert.equal(dims.y + dims.height, windowHeight - 16); + + // Now move it offscreen as low as possible. + await move({y: windowHeight - dims.y - 10}); + + // Make sure it is still visible. + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.y, windowHeight - 32); // 32px is a header size + assert.isBelow(dims.x, windowWidth - (32 * 4) + 1); // 120px is the right overflow value. + + // Now move it to the right as far as possible. + await move({x: windowWidth - dims.x - 10}); + + // Make sure it is still visible. + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.y, windowHeight - 32); + assert.equal(dims.x, windowWidth - 32 * 4); + + // Now move it to the left as far as possible. + await move({x: -3000}); + + // Make sure it is still visible. + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.isBelow(dims.x, 0); + assert.isAbove(dims.x + dims.width, 30); + + const miniButton = driver.find(".test-floating-popup-minimize-maximize"); + // Now move it back, but this time manually as the move handle is off screen. + await driver.withActions((a) => a + .move({origin: miniButton }) + .press() + .move({origin: miniButton, x: Math.abs(dims.x) + 20}) + .release() + ); + + // Maximize it (it was minimized as we used the button to move it). + await driver.find(".test-floating-popup-minimize-maximize").click(); + + // Now move it to the top as far as possible. + // Move it a little right, so that we don't end up on the logo. Driver is clicking logo sometimes. + await move({y: -windowHeight, x: 100}); + + // Make sure it is still visible. + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.y, 16); + assert.isAbove(dims.x, 100); + assert.isBelow(dims.x, windowWidth); + + // Move back where it was. + let moverNow = await driver.find('.test-floating-popup-move-handle').getRect(); + await move({x: moverInitial.x - moverNow.x}); + // And restore the size by double clicking the resizer. + await driver.withActions((a) => a.doubleClick(driver.find('.test-floating-popup-resize-handle'))); + + // Now test if we can't resize it offscreen. + await move({y: 10000}); + await move({y: -100}); + // Header is about 100px above the viewport. + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.isBelow(dims.y, windowHeight); + assert.isAbove(dims.x, windowHeight - 140); + + // Now resize as far as possible. + await resize({y: 10}); + await resize({y: 300}); + + // Make sure it is still visible. + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.isBelow(dims.y, windowHeight - 16); + + // Now move back and resize. + moverNow = await driver.find('.test-floating-popup-move-handle').getRect(); + await move({x: moverInitial.x - moverNow.x, y: moverInitial.y - moverNow.y}); + await driver.withActions((a) => a.doubleClick(driver.find('.test-floating-popup-resize-handle'))); + + dims = await driver.find('.test-floating-popup-window').getRect(); + assert.equal(dims.height, initialDims.height); + assert.equal(dims.y, initialDims.y); + assert.equal(dims.x, initialDims.x); + }); + it('is visible on all pages', async function() { for (const page of ['access-rules', 'raw', 'code', 'settings']) { await driver.find(`.test-tools-${page}`).click();