diff --git a/src/renderer/components/dock/dock.scss b/src/renderer/components/dock/dock.scss index 6b2d90925dd4..8c7ab7e07d77 100644 --- a/src/renderer/components/dock/dock.scss +++ b/src/renderer/components/dock/dock.scss @@ -14,10 +14,6 @@ left: 0; bottom: 0; z-index: 100; - - > .resizer { - display: none; - } } } @@ -65,24 +61,7 @@ } } - .resizer { - $height: 12px; - - position: absolute; - top: -$height / 2; - left: 0; - right: 0; - bottom: 100%; - height: $height; - cursor: row-resize; - z-index: 10; - - &.disabled { - pointer-events: none; - } - } - .AceEditor { border: none; } -} \ No newline at end of file +} diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index c1b70cc92544..2fed511e755e 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -27,8 +27,8 @@ export class DockStore { ]; protected storage = createStorage("dock", {}); // keep settings in localStorage - public defaultTabId = this.initialTabs[0].id; - public minHeight = 100; + public readonly defaultTabId = this.initialTabs[0].id; + public readonly minHeight = 100; @observable isOpen = false; @observable fullSize = false; @@ -45,11 +45,12 @@ export class DockStore { } get maxHeight() { - const mainLayoutHeader = 40; - const mainLayoutTabs = 33; - const mainLayoutMargin = 16; - const dockTabs = 33; - return window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs; + const mainLayoutHeader = 40 + const mainLayoutTabs = 33 + const mainLayoutMargin = 16 + const dockTabs = 33 + const preferedMax = window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs + return Math.max(preferedMax, this.minHeight) // don't let max < min } constructor() { @@ -65,7 +66,6 @@ export class DockStore { }); // adjust terminal height if window size changes - this.checkMaxHeight(); window.addEventListener("resize", throttle(this.checkMaxHeight, 250)); } @@ -171,20 +171,19 @@ export class DockStore { @action selectTab(tabId: TabId) { - const tab = this.getTabById(tabId); - this.selectedTabId = tab ? tab.id : null; + this.selectedTabId = this.getTabById(tabId)?.id ?? null; } @action - setHeight(height: number) { - this.height = Math.max(0, Math.min(height, this.maxHeight)); + setHeight(height?: number) { + this.height = Math.max(this.minHeight, Math.min(height || this.minHeight, this.maxHeight)); } @action reset() { this.selectedTabId = this.defaultTabId; this.tabs.replace(this.initialTabs); - this.height = this.defaultHeight; + this.setHeight(this.defaultHeight); this.close(); } } diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index d384c366087a..c020d206728b 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -4,7 +4,7 @@ import React, { Fragment } from "react"; import { observer } from "mobx-react"; import { Trans } from "@lingui/macro"; import { autobind, cssNames, prevDefault } from "../../utils"; -import { Draggable, DraggableState } from "../draggable"; +import { ResizingAnchor, ResizeDirection } from "../resizing-anchor"; import { Icon } from "../icon"; import { Tabs } from "../tabs/tabs"; import { MenuItem } from "../menu"; @@ -29,28 +29,8 @@ interface Props { @observer export class Dock extends React.Component { - onResizeStart = () => { - const { isOpen, open, setHeight, minHeight } = dockStore; - if (!isOpen) { - open(); - setHeight(minHeight); - } - } - - onResize = ({ offsetY }: DraggableState) => { - const { isOpen, close, height, setHeight, minHeight, defaultHeight } = dockStore; - const newHeight = height + offsetY; - if (height > newHeight && newHeight < minHeight) { - setHeight(defaultHeight); - close(); - } - else if (isOpen) { - setHeight(newHeight); - } - } - onKeydown = (evt: React.KeyboardEvent) => { - const { close, closeTab, selectedTab, fullSize, toggleFillSize } = dockStore; + const { close, closeTab, selectedTab } = dockStore; if (!selectedTab) return; const { code, ctrlKey, shiftKey } = evt.nativeEvent; if (shiftKey && code === "Escape") { @@ -71,13 +51,13 @@ export class Dock extends React.Component { @autobind() renderTab(tab: IDockTab) { if (isTerminalTab(tab)) { - return + return } if (isCreateResourceTab(tab) || isEditResourceTab(tab)) { - return + return } if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) { - return }/> + return } /> } } @@ -86,11 +66,11 @@ export class Dock extends React.Component { if (!isOpen || !tab) return; return (
- {isCreateResourceTab(tab) && } - {isEditResourceTab(tab) && } - {isInstallChartTab(tab) && } - {isUpgradeChartTab(tab) && } - {isTerminalTab(tab) && } + {isCreateResourceTab(tab) && } + {isEditResourceTab(tab) && } + {isInstallChartTab(tab) && } + {isUpgradeChartTab(tab) && } + {isTerminalTab(tab) && }
) } @@ -104,11 +84,16 @@ export class Dock extends React.Component { onKeyDown={this.onKeydown} tabIndex={-1} > - dockStore.height} + minExtent={dockStore.minHeight} + maxExtent={dockStore.maxHeight} + direction={ResizeDirection.VERTICAL} + onStart={dockStore.open} + onMinExtentSubceed={dockStore.close} + onMinExtentExceed={dockStore.open} + onDrag={dockStore.setHeight} />
{
New tab }} closeOnScroll={false}> createTerminalTab()}> - + Terminal session createResourceTab()}> - + Create resource @@ -133,7 +118,7 @@ export class Dock extends React.Component { {hasTabs() && ( <> Exit full size mode : Fit to window} onClick={toggleFillSize} /> diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index f02fd478f220..cea1e91292ca 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -121,6 +121,8 @@ export class Terminal { } fit = () => { + // Since this function is debounced we need to read this value as late as possible + if (!this.isActive) return; this.fitAddon.fit(); const { cols, rows } = this.xterm; this.api.sendTerminalSize(cols, rows); @@ -150,7 +152,6 @@ export class Terminal { } onResize = () => { - if (!this.isActive) return; this.fitLazy(); this.focus(); } @@ -176,8 +177,8 @@ export class Terminal { if (this.xterm.hasSelection()) return false; break; - // Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim - // https://github.com/kontena/lens-app/issues/156#issuecomment-534906480 + // Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim + // https://github.com/kontena/lens-app/issues/156#issuecomment-534906480 case "KeyW": evt.preventDefault(); break; diff --git a/src/renderer/components/draggable/draggable.scss b/src/renderer/components/draggable/draggable.scss deleted file mode 100644 index e99e306ce2eb..000000000000 --- a/src/renderer/components/draggable/draggable.scss +++ /dev/null @@ -1,5 +0,0 @@ -body.dragging { - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; -} \ No newline at end of file diff --git a/src/renderer/components/draggable/draggable.tsx b/src/renderer/components/draggable/draggable.tsx deleted file mode 100644 index 12fd02092935..000000000000 --- a/src/renderer/components/draggable/draggable.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import "./draggable.scss"; -import React from "react"; -import { cssNames, IClassName, noop } from "../../utils"; -import throttle from "lodash/throttle"; - -export interface DraggableEventHandler { - (state: DraggableState): void; -} - -interface Props { - className?: IClassName; - vertical?: boolean; - horizontal?: boolean; - onStart?: DraggableEventHandler; - onEnter?: DraggableEventHandler; - onEnd?: DraggableEventHandler; -} - -export interface DraggableState { - inited?: boolean; - changed?: boolean; - initX?: number; - initY?: number; - pageX?: number; - pageY?: number; - offsetX?: number; - offsetY?: number; -} - -const initState: DraggableState = { - inited: false, - changed: false, - offsetX: 0, - offsetY: 0, -}; - -export class Draggable extends React.PureComponent { - public state = initState; - - static IS_DRAGGING = "dragging" - - static defaultProps: Props = { - vertical: true, - horizontal: true, - onStart: noop, - onEnter: noop, - onEnd: noop, - }; - - constructor(props: Props) { - super(props); - document.addEventListener("mousemove", this.onDrag); - document.addEventListener("mouseup", this.onDragEnd); - } - - componentWillUnmount() { - document.removeEventListener("mousemove", this.onDrag); - document.removeEventListener("mouseup", this.onDragEnd); - } - - onDragInit = (evt: React.MouseEvent) => { - document.body.classList.add(Draggable.IS_DRAGGING); - const { pageX, pageY } = evt; - this.setState({ - inited: true, - initX: pageX, - initY: pageY, - pageX: pageX, - pageY: pageY, - }) - } - - onDrag = throttle((evt: MouseEvent) => { - const { vertical, horizontal, onEnter, onStart } = this.props; - const { inited, pageX, pageY } = this.state; - const offsetX = pageX - evt.pageX; - const offsetY = pageY - evt.pageY; - let changed = false; - if (horizontal && offsetX !== 0) changed = true; - if (vertical && offsetY !== 0) changed = true; - if (inited && changed) { - const start = !this.state.changed; - const state = Object.assign({}, this.state, { - changed: true, - pageX: evt.pageX, - pageY: evt.pageY, - offsetX: offsetX, - offsetY: offsetY, - }); - if (start) onStart(state); - this.setState(state, () => onEnter(state)); - } - }, 100) - - onDragEnd = (evt: MouseEvent) => { - const { pageX, pageY } = evt; - const { inited, changed, initX, initY } = this.state; - if (inited) { - document.body.classList.remove(Draggable.IS_DRAGGING); - this.setState(initState, () => { - if (!changed) return; - const state = Object.assign({}, this.state, { - offsetX: initX - pageX, - offsetY: initY - pageY, - }); - this.props.onEnd(state); - }); - } - } - - render() { - const { className, children } = this.props; - return ( -
- {children} -
- ); - } -} \ No newline at end of file diff --git a/src/renderer/components/draggable/index.ts b/src/renderer/components/draggable/index.ts deleted file mode 100644 index 0a3b92d235ff..000000000000 --- a/src/renderer/components/draggable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './draggable' \ No newline at end of file diff --git a/src/renderer/components/resizing-anchor/index.ts b/src/renderer/components/resizing-anchor/index.ts new file mode 100644 index 000000000000..6197be8d84d4 --- /dev/null +++ b/src/renderer/components/resizing-anchor/index.ts @@ -0,0 +1 @@ +export * from './resizing-anchor' diff --git a/src/renderer/components/resizing-anchor/resizing-anchor.scss b/src/renderer/components/resizing-anchor/resizing-anchor.scss new file mode 100644 index 000000000000..310727af2c26 --- /dev/null +++ b/src/renderer/components/resizing-anchor/resizing-anchor.scss @@ -0,0 +1,46 @@ +body.resizing { + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; +} + +.ResizingAnchor { + $dimension: 12px; + + position: absolute; + z-index: 10; + + &.disabled { + display: none; + } + + &.vertical { + left: 0; + right: 0; + cursor: row-resize; + height: $dimension; + + &.leading { + top: -$dimension / 2; + } + + &.trailing { + bottom: -$dimension / 2; + } + } + + &.horizontal { + top: 0; + bottom: 0; + cursor: col-resize; + width: $dimension; + + &.leading { + left: -$dimension / 2; + } + + &.trailing { + right: -$dimension / 2; + } + } +} diff --git a/src/renderer/components/resizing-anchor/resizing-anchor.tsx b/src/renderer/components/resizing-anchor/resizing-anchor.tsx new file mode 100644 index 000000000000..a5a8ba208605 --- /dev/null +++ b/src/renderer/components/resizing-anchor/resizing-anchor.tsx @@ -0,0 +1,281 @@ +import "./resizing-anchor.scss"; +import React from "react"; +import { action, observable } from "mobx"; +import _ from "lodash" +import { findDOMNode } from "react-dom"; +import { cssNames, noop } from "../../utils"; + +export enum ResizeDirection { + HORIZONTAL = "horizontal", + VERTICAL = "vertical", +} + +/** + * ResizeSide is for customizing where the area should be rendered. + * That location is determined in conjunction with the `ResizeDirection` using the following table: + * + * +----------+------------+----------+ + * | | HORIZONTAL | VERTICAL | + * +----------+------------+----------+ + * | LEADING | left | top | + * +----------+------------+----------+ + * | TRAILING | right | bottom | + * +----------+------------+----------+ + */ +export enum ResizeSide { + LEADING = "leading", + TRAILING = "trailing", +} + +/** + * ResizeGrowthDirection determines how the anchor interprets the drag. + * + * Because the origin of the screen is top left a drag from bottom to top + * results in a negative directional delta. However, if the component being + * dragged grows in the opposite direction, this needs to be compensated for. + */ +export enum ResizeGrowthDirection { + TOP_TO_BOTTOM = 1, + BOTTOM_TO_TOP = -1, + LEFT_TO_RIGHT = 1, + RIGHT_TO_LEFT = -1, +} + +interface Props { + direction: ResizeDirection; + + /** + * getCurrentExtent should return the current prominent dimension in the + * given resizing direction. Width for HORIZONTAL and height for VERTICAL + */ + getCurrentExtent: () => number; + + disabled?: boolean; + placement?: ResizeSide; + growthDirection?: ResizeGrowthDirection; + + // Ability to restrict which mouse buttons are allowed to resize this component + // Reference: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons + onlyButtons?: number; + + // onStart is called when the ResizeAnchor is first clicked (mouse down) + onStart?: () => void; + + // onEnd is called when the ResizeAnchor is released (mouse up) + onEnd?: () => void; + + /** + * onDrag is called whenever there is a mousemove event. All calls will be + * bounded by matching `onStart` and `onEnd` calls. + */ + onDrag?: (newExtent: number) => void; + + // onDoubleClick is called when the the ResizeAnchor is double clicked + onDoubleClick?: () => void; + + /** + * The following two extents represent the max and min values set to `onDrag` + */ + maxExtent?: number; + minExtent?: number; + + /** + * The following events are triggerred with respect to the above values. + * - The "__Exceed" call will be made when the unbounded extent goes from + * < the above to >= the above + * - The "__Subceed" call is similar but is triggered when the unbounded + * extent goes from >= the above to < the above. + */ + onMaxExtentExceed?: () => void; + onMaxExtentSubceed?: () => void; + onMinExtentSubceed?: () => void; + onMinExtentExceed?: () => void; +} + +interface Position { + readonly pageX: number; + readonly pageY: number; +} + +/** + * Return the direction delta, but ignore drags leading up to a moved item + * 1. `->|` => return `false` + * 2. `<-|` => return `directed length (M, P2)` (negative) + * 3. `-|>` => return `directed length (M, P2)` (positive) + * 4. `<|-` => return `directed length (M, P2)` (negative) + * 5. `|->` => return `directed length (M, P2)` (positive) + * 6. `|<-` => return `false` + * @param P1 the starting position on the number line + * @param P2 the ending position on the number line + * @param M a third point that determines if the delta is meaningful + * @returns the directional difference between including appropriate sign. + */ +function directionDelta(P1: number, P2: number, M: number): number | false { + const delta = Math.abs(M - P2) + + if (P1 < M) { + if (P2 >= M) { + // case 3 + return delta + } + + if (P2 < P1) { + // case 2 + return -delta + } + + // case 1 + return false + } + + if (P2 < M) { + // case 4 + return -delta + } + + if (P1 < P2) { + // case 5 + return delta + } + + // case 6 + return false +} + +export class ResizingAnchor extends React.PureComponent { + @observable lastMouseEvent?: MouseEvent + @observable.ref ref?: React.RefObject; + + static defaultProps = { + onStart: noop, + onDrag: noop, + onEnd: noop, + onMaxExtentExceed: noop, + onMinExtentExceed: noop, + onMinExtentSubceed: noop, + onMaxExtentSubceed: noop, + onDoubleClick: noop, + disabled: false, + growthDirection: ResizeGrowthDirection.BOTTOM_TO_TOP, + maxExtent: Number.POSITIVE_INFINITY, + minExtent: 0, + placement: ResizeSide.LEADING, + } + static IS_RESIZING = "resizing" + + constructor(props: Props) { + super(props) + if (props.maxExtent < props.minExtent) { + throw new Error("maxExtent must be >= minExtent") + } + + this.ref = React.createRef() + } + + componentWillUnmount() { + document.removeEventListener("mousemove", this.onDrag) + document.removeEventListener("mouseup", this.onDragEnd) + } + + @action + onDragInit = (event: React.MouseEvent) => { + const { onStart, onlyButtons } = this.props + + if (typeof onlyButtons === "number" && onlyButtons !== event.buttons) { + return + } + + document.addEventListener("mousemove", this.onDrag) + document.addEventListener("mouseup", this.onDragEnd) + document.body.classList.add(ResizingAnchor.IS_RESIZING) + + this.lastMouseEvent = undefined + onStart() + } + + calculateDelta(from: Position, to: Position): number | false { + const node = this.ref.current + if (!node) { + return false + } + + const boundingBox = node.getBoundingClientRect() + + if (this.props.direction === ResizeDirection.HORIZONTAL) { + const barX = Math.round(boundingBox.x + (boundingBox.width / 2)) + return directionDelta(from.pageX, to.pageX, barX) + } else { // direction === ResizeDirection.VERTICAL + const barY = Math.round(boundingBox.y + (boundingBox.height / 2)) + return directionDelta(from.pageY, to.pageY, barY) + } + } + + onDrag = _.throttle((event: MouseEvent) => { + /** + * Some notes to help understand the following: + * - A browser's origin point is in the top left of the screen + * - X increases going from left to right + * - Y increases going from top to bottom + * - Since the resize bar should always be a rectangle, use its centre + * line (in the resizing direction) as the line for determining if + * the bar has "jumped around" + * + * Desire: + * - Always ignore movement in the non-resizing direction + * - Figure out how much the user has "dragged" the resize bar + * - If the resize bar has jumped around, compensate by ignoring movement + * in the resizing direction if it is moving "towards" the resize bar's + * new location. + */ + + if (!this.lastMouseEvent) { + this.lastMouseEvent = event + return + } + + const { maxExtent, minExtent, getCurrentExtent, growthDirection } = this.props + const { onDrag, onMaxExtentExceed, onMinExtentSubceed, onMaxExtentSubceed, onMinExtentExceed } = this.props + const delta = this.calculateDelta(this.lastMouseEvent, event) + + // always update the last mouse event + this.lastMouseEvent = event + + if (delta === false) { + return + } + + const previousExtent = getCurrentExtent() + const unboundedExtent = previousExtent + (delta * growthDirection) + const boundedExtent = Math.round(Math.max(minExtent, Math.min(maxExtent, unboundedExtent))) + onDrag(boundedExtent) + + if (previousExtent <= minExtent && minExtent <= unboundedExtent) { + onMinExtentExceed() + } else if (previousExtent >= minExtent && minExtent >= unboundedExtent) { + onMinExtentSubceed() + } + if (previousExtent <= maxExtent && maxExtent <= unboundedExtent) { + onMaxExtentExceed() + } else if (previousExtent >= maxExtent && maxExtent >= unboundedExtent) { + onMaxExtentSubceed() + } + }, 100) + + @action + onDragEnd = (_event: MouseEvent) => { + this.props.onEnd() + document.removeEventListener("mousemove", this.onDrag) + document.removeEventListener("mouseup", this.onDragEnd) + document.body.classList.remove(ResizingAnchor.IS_RESIZING) + } + + render() { + const { disabled, direction, placement, onDoubleClick } = this.props + return
+ } +} diff --git a/yarn.lock b/yarn.lock index 300efc58cef7..f64e36b703b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8181,17 +8181,17 @@ mobx-observable-history@^1.0.3: history "^4.10.1" mobx "^5.15.4" -mobx-react-lite@2: - version "2.0.7" - resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-2.0.7.tgz#1bfb3b4272668e288047cf0c7940b14e91cba284" - integrity sha512-YKAh2gThC6WooPnVZCoC+rV1bODAKFwkhxikzgH18wpBjkgTkkR9Sb0IesQAH5QrAEH/JQVmy47jcpQkf2Au3Q== +mobx-react-lite@>=2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-2.2.2.tgz#87c217dc72b4e47b22493daf155daf3759f868a6" + integrity sha512-2SlXALHIkyUPDsV4VTKVR9DW7K3Ksh1aaIv3NrNJygTbhXe2A9GrcKHZ2ovIiOp/BXilOcTYemfHHZubP431dg== mobx-react@^6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-6.2.2.tgz#45e8e7c4894cac8399bba0a91060d7cfb8ea084b" - integrity sha512-Us6V4ng/iKIRJ8pWxdbdysC6bnS53ZKLKlVGBqzHx6J+gYPYbOotWvhHZnzh/W5mhpYXxlXif4kL2cxoWJOplQ== + version "6.3.0" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-6.3.0.tgz#7d11799f988bbdadc49e725081993b18baa20329" + integrity sha512-C14yya2nqEBRSEiJjPkhoWJLlV8pcCX3m2JRV7w1KivwANJqipoiPx9UMH4pm6QNMbqDdvJqoyl+LqNu9AhvEQ== dependencies: - mobx-react-lite "2" + mobx-react-lite ">=2.2.0" mobx@^5.15.4: version "5.15.4"