1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

cleanup Draggable into ResizingAnchor (#989)

* Rename `Draggable` as `ResizingAnchor` since that is what it is used for and shouldn't be mistaken as a general draggable item

* Refactor `ResizingAnchor` to be much more smart about how it handles mouse movements. Allow it to know which direction the resizing should be in allowing it to produce exact resized values.

* Add event handlers for the min and max extents so that actions can be triggered when those extents are passed (in either direction)

* Add double click support

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2020-10-01 11:37:27 -04:00 committed by GitHub
parent 135282da91
commit 9ecfeb316c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 376 additions and 209 deletions

View File

@ -14,10 +14,6 @@
left: 0;
bottom: 0;
z-index: 100;
> .resizer {
display: none;
}
}
}
@ -65,23 +61,6 @@
}
}
.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;
}

View File

@ -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();
}
}

View File

@ -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<Props> {
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<HTMLElement>) => {
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<Props> {
@autobind()
renderTab(tab: IDockTab) {
if (isTerminalTab(tab)) {
return <TerminalTab value={tab}/>
return <TerminalTab value={tab} />
}
if (isCreateResourceTab(tab) || isEditResourceTab(tab)) {
return <DockTab value={tab} icon="edit"/>
return <DockTab value={tab} icon="edit" />
}
if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) {
return <DockTab value={tab} icon={<Icon svg="install"/>}/>
return <DockTab value={tab} icon={<Icon svg="install" />} />
}
}
@ -86,11 +66,11 @@ export class Dock extends React.Component<Props> {
if (!isOpen || !tab) return;
return (
<div className="tab-content" style={{ flexBasis: height }}>
{isCreateResourceTab(tab) && <CreateResource tab={tab}/>}
{isEditResourceTab(tab) && <EditResource tab={tab}/>}
{isInstallChartTab(tab) && <InstallChart tab={tab}/>}
{isUpgradeChartTab(tab) && <UpgradeChart tab={tab}/>}
{isTerminalTab(tab) && <TerminalWindow tab={tab}/>}
{isCreateResourceTab(tab) && <CreateResource tab={tab} />}
{isEditResourceTab(tab) && <EditResource tab={tab} />}
{isInstallChartTab(tab) && <InstallChart tab={tab} />}
{isUpgradeChartTab(tab) && <UpgradeChart tab={tab} />}
{isTerminalTab(tab) && <TerminalWindow tab={tab} />}
</div>
)
}
@ -104,11 +84,16 @@ export class Dock extends React.Component<Props> {
onKeyDown={this.onKeydown}
tabIndex={-1}
>
<Draggable
className={cssNames("resizer", { disabled: !hasTabs() })}
horizontal={false}
onStart={this.onResizeStart}
onEnter={this.onResize}
<ResizingAnchor
disabled={!hasTabs()}
getCurrentExtent={() => dockStore.height}
minExtent={dockStore.minHeight}
maxExtent={dockStore.maxHeight}
direction={ResizeDirection.VERTICAL}
onStart={dockStore.open}
onMinExtentSubceed={dockStore.close}
onMinExtentExceed={dockStore.open}
onDrag={dockStore.setHeight}
/>
<div className="tabs-container flex align-center" onDoubleClick={prevDefault(toggle)}>
<Tabs
@ -121,11 +106,11 @@ export class Dock extends React.Component<Props> {
<div className="dock-menu box grow">
<MenuActions usePortal triggerIcon={{ material: "add", className: "new-dock-tab", tooltip: <Trans>New tab</Trans> }} closeOnScroll={false}>
<MenuItem className="create-terminal-tab" onClick={() => createTerminalTab()}>
<Icon small svg="terminal" size={15}/>
<Icon small svg="terminal" size={15} />
<Trans>Terminal session</Trans>
</MenuItem>
<MenuItem className="create-resource-tab" onClick={() => createResourceTab()}>
<Icon small material="create"/>
<Icon small material="create" />
<Trans>Create resource</Trans>
</MenuItem>
</MenuActions>
@ -133,7 +118,7 @@ export class Dock extends React.Component<Props> {
{hasTabs() && (
<>
<Icon
material={fullSize ? "fullscreen_exit": "fullscreen"}
material={fullSize ? "fullscreen_exit" : "fullscreen"}
tooltip={fullSize ? <Trans>Exit full size mode</Trans> : <Trans>Fit to window</Trans>}
onClick={toggleFillSize}
/>

View File

@ -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;

View File

@ -1,5 +0,0 @@
body.dragging {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
}

View File

@ -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<Props, DraggableState> {
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<any>) => {
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 (
<div className={cssNames("Draggable", className)} onMouseDown={this.onDragInit}>
{children}
</div>
);
}
}

View File

@ -1 +0,0 @@
export * from './draggable'

View File

@ -0,0 +1 @@
export * from './resizing-anchor'

View File

@ -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;
}
}
}

View File

@ -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<Props> {
@observable lastMouseEvent?: MouseEvent
@observable.ref ref?: React.RefObject<HTMLDivElement>;
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<HTMLDivElement>()
}
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 <div
ref={this.ref}
className={cssNames("ResizingAnchor", direction, placement, { disabled })}
onMouseDown={this.onDragInit}
onDoubleClick={onDoubleClick}
/>
}
}

View File

@ -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"