mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Make the ResizeAnchor much more explicit, powerful, and customizable
- Add min/max exceed/subceed events - Add customizing desired growth direction - Simplfy onDrag signature Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
a25540b9ad
commit
4bb46a797f
@ -27,12 +27,8 @@ export class DockStore {
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected storage = createStorage("dock", {}); // keep settings in localStorage
|
protected storage = createStorage("dock", {}); // keep settings in localStorage
|
||||||
public defaultTabId = this.initialTabs[0].id;
|
public readonly defaultTabId = this.initialTabs[0].id;
|
||||||
private _minHeight = 100;
|
public readonly minHeight = 100;
|
||||||
|
|
||||||
set minHeight(val: number) {
|
|
||||||
this._minHeight = val
|
|
||||||
}
|
|
||||||
|
|
||||||
@observable isOpen = false;
|
@observable isOpen = false;
|
||||||
@observable fullSize = false;
|
@observable fullSize = false;
|
||||||
@ -45,19 +41,24 @@ export class DockStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get defaultHeight() {
|
get defaultHeight() {
|
||||||
return Math.round(window.innerHeight / 2.5);
|
return Math.round(window.innerHeight * 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
get maxHeight() {
|
get maxHeight() {
|
||||||
const mainLayoutHeader = 40;
|
const mainLayoutHeader = 40
|
||||||
const mainLayoutTabs = 33;
|
const mainLayoutTabs = 33
|
||||||
const mainLayoutMargin = 16;
|
const mainLayoutMargin = 16
|
||||||
const dockTabs = 33;
|
const dockTabs = 33
|
||||||
return window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs;
|
const preferedMax = window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs
|
||||||
|
return Math.max(preferedMax, this.minHeight) // don't let max < min
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
Object.assign(this, this.storage.get());
|
const stored = this.storage.get()
|
||||||
|
if (Object.entries(stored).length > 0) {
|
||||||
|
// don't override perfectly good defaults in the class decl
|
||||||
|
Object.assign(this, stored);
|
||||||
|
}
|
||||||
|
|
||||||
reaction(() => ({
|
reaction(() => ({
|
||||||
isOpen: this.isOpen,
|
isOpen: this.isOpen,
|
||||||
@ -69,7 +70,6 @@ export class DockStore {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// adjust terminal height if window size changes
|
// adjust terminal height if window size changes
|
||||||
this.checkMaxHeight();
|
|
||||||
window.addEventListener("resize", throttle(this.checkMaxHeight, 250));
|
window.addEventListener("resize", throttle(this.checkMaxHeight, 250));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,20 +175,19 @@ export class DockStore {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
selectTab(tabId: TabId) {
|
selectTab(tabId: TabId) {
|
||||||
const tab = this.getTabById(tabId);
|
this.selectedTabId = this.getTabById(tabId)?.id ?? null;
|
||||||
this.selectedTabId = tab ? tab.id : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
setHeight(height?: number) {
|
setHeight(height?: number) {
|
||||||
this.height = Math.max(0, Math.min(height || this._minHeight, this.maxHeight));
|
this.height = Math.max(this.minHeight, Math.min(height || this.minHeight, this.maxHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
reset() {
|
reset() {
|
||||||
this.selectedTabId = this.defaultTabId;
|
this.selectedTabId = this.defaultTabId;
|
||||||
this.tabs.replace(this.initialTabs);
|
this.tabs.replace(this.initialTabs);
|
||||||
this.height = this.defaultHeight;
|
this.setHeight(this.defaultHeight);
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import React, { Fragment } from "react";
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Trans } from "@lingui/macro";
|
import { Trans } from "@lingui/macro";
|
||||||
import { autobind, cssNames, prevDefault } from "../../utils";
|
import { autobind, cssNames, prevDefault } from "../../utils";
|
||||||
import { ResizingAnchor, ResizeDragEvent, ResizeDirection } from "../resizing-anchor";
|
import { ResizingAnchor, ResizeDirection } from "../resizing-anchor";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { Tabs } from "../tabs/tabs";
|
import { Tabs } from "../tabs/tabs";
|
||||||
import { MenuItem } from "../menu";
|
import { MenuItem } from "../menu";
|
||||||
@ -29,27 +29,6 @@ interface Props {
|
|||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class Dock extends React.Component<Props> {
|
export class Dock extends React.Component<Props> {
|
||||||
onResizeStart = () => {
|
|
||||||
const { isOpen, open, setHeight } = dockStore;
|
|
||||||
if (!isOpen) {
|
|
||||||
open();
|
|
||||||
setHeight();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onResize = ({ movementY }: ResizeDragEvent) => {
|
|
||||||
const { isOpen, close, height, setHeight, minHeight, defaultHeight } = dockStore;
|
|
||||||
console.log(height, movementY)
|
|
||||||
const newHeight = height + movementY;
|
|
||||||
if (height > newHeight && newHeight < minHeight) {
|
|
||||||
setHeight(defaultHeight);
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
else if (isOpen) {
|
|
||||||
setHeight(newHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeydown = (evt: React.KeyboardEvent<HTMLElement>) => {
|
onKeydown = (evt: React.KeyboardEvent<HTMLElement>) => {
|
||||||
const { close, closeTab, selectedTab } = dockStore;
|
const { close, closeTab, selectedTab } = dockStore;
|
||||||
if (!selectedTab) return;
|
if (!selectedTab) return;
|
||||||
@ -107,9 +86,14 @@ export class Dock extends React.Component<Props> {
|
|||||||
>
|
>
|
||||||
<ResizingAnchor
|
<ResizingAnchor
|
||||||
disabled={!hasTabs()}
|
disabled={!hasTabs()}
|
||||||
|
getCurrentExtent={() => dockStore.height}
|
||||||
|
minExtent={dockStore.minHeight}
|
||||||
|
maxExtent={dockStore.maxHeight}
|
||||||
direction={ResizeDirection.VERTICAL}
|
direction={ResizeDirection.VERTICAL}
|
||||||
onStart={this.onResizeStart}
|
onStart={dockStore.open}
|
||||||
onDrag={this.onResize}
|
onMinExtentSubceed={dockStore.close}
|
||||||
|
onMinExtentExceed={dockStore.open}
|
||||||
|
onDrag={dockStore.setHeight}
|
||||||
/>
|
/>
|
||||||
<div className="tabs-container flex align-center" onDoubleClick={prevDefault(toggle)}>
|
<div className="tabs-container flex align-center" onDoubleClick={prevDefault(toggle)}>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@ -121,6 +121,8 @@ export class Terminal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fit = () => {
|
fit = () => {
|
||||||
|
// Since this function is debounced we need to read this value as late as possible
|
||||||
|
if (!this.isActive) return;
|
||||||
this.fitAddon.fit();
|
this.fitAddon.fit();
|
||||||
const { cols, rows } = this.xterm;
|
const { cols, rows } = this.xterm;
|
||||||
this.api.sendTerminalSize(cols, rows);
|
this.api.sendTerminalSize(cols, rows);
|
||||||
@ -150,7 +152,6 @@ export class Terminal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onResize = () => {
|
onResize = () => {
|
||||||
if (!this.isActive) return;
|
|
||||||
this.fitLazy();
|
this.fitLazy();
|
||||||
this.focus();
|
this.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import "./resizing-anchor.scss";
|
import "./resizing-anchor.scss";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cssNames, noop } from "../../utils";
|
import { action, observable } from "mobx";
|
||||||
import { action, computed, observable } from "mobx";
|
|
||||||
import _ from "lodash"
|
import _ from "lodash"
|
||||||
|
import { findDOMNode } from "react-dom";
|
||||||
|
import { cssNames, noop } from "../../utils";
|
||||||
|
|
||||||
export enum ResizeDirection {
|
export enum ResizeDirection {
|
||||||
HORIZONTAL = "horizontal",
|
HORIZONTAL = "horizontal",
|
||||||
@ -26,58 +27,160 @@ export enum ResizeSide {
|
|||||||
TRAILING = "trailing",
|
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 {
|
interface Props {
|
||||||
direction: ResizeDirection;
|
direction: ResizeDirection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getCurrentExtent should return the current prominent dimention in the
|
||||||
|
* given resizing direction. Width for HORIZONTAL and height for VERTICAL
|
||||||
|
*/
|
||||||
|
getCurrentExtent: () => number;
|
||||||
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placement?: ResizeSide;
|
placement?: ResizeSide;
|
||||||
onStart?: (data: ResizeStartEvent) => void;
|
growthDirection?: ResizeGrowthDirection;
|
||||||
onDrag?: (data: ResizeDragEvent) => void;
|
|
||||||
onEnd?: (data: ResizeEndEvent) => void;
|
// 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 InitialPosition {
|
enum MovementTrigger {
|
||||||
initX: number;
|
EXCEED_MAX = "exceed",
|
||||||
initY: number;
|
SUBCEED_MIN = "subceed",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CurrentPosition {
|
interface MovementCalc {
|
||||||
pageX: number;
|
newExtent?: number;
|
||||||
pageY: number;
|
otherTrigger?: MovementTrigger;
|
||||||
|
ignore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MovementData {
|
interface Position {
|
||||||
movementX: number;
|
readonly pageX: number;
|
||||||
movementY: number;
|
readonly pageY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResizeStartEvent = InitialPosition
|
/**
|
||||||
export type ResizeDragEvent = InitialPosition & CurrentPosition & MovementData
|
* Return the direction delta, but ignore drags leading up to a moved item
|
||||||
export type ResizeEndEvent = InitialPosition & CurrentPosition
|
* 1. `->|` => return `false`
|
||||||
|
* 2. `<-|` => return `directed length (P1, P2)` (negative)
|
||||||
function calculateMovement(from: CurrentPosition, to: CurrentPosition): MovementData {
|
* 3. `-|>` => return `directed length (M, P2)` (positive)
|
||||||
return {
|
* 4. `<|-` => return `directed length (M, P2)` (negative)
|
||||||
movementX: from.pageX - to.pageX,
|
* 5. `|->` => return `directed length (P1, P2)` (positive)
|
||||||
movementY: from.pageY - to.pageY,
|
* 6. `|<-` => return `false`
|
||||||
}
|
* @param P the starting position on the number line
|
||||||
|
* @param Q 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, B: number): number | false {
|
||||||
|
if (P1 < B) {
|
||||||
|
if (P2 >= B) {
|
||||||
|
// case 3
|
||||||
|
return Math.abs(B - P2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProps: Partial<Props> = {
|
if (P2 < P1) {
|
||||||
onStart: noop,
|
// case 2
|
||||||
onDrag: noop,
|
return -Math.abs(P1 - P2)
|
||||||
onEnd: noop,
|
}
|
||||||
disabled: false,
|
|
||||||
placement: ResizeSide.LEADING,
|
// case 1
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (P2 < B) {
|
||||||
|
// case 4
|
||||||
|
return -Math.abs(B - P2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (P1 < P2) {
|
||||||
|
// case 5
|
||||||
|
return Math.abs(P1 - P2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// case 6
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ResizingAnchor extends React.PureComponent<Props> {
|
export class ResizingAnchor extends React.PureComponent<Props> {
|
||||||
@observable startingPosition?: ResizeStartEvent
|
@observable lastMouseEvent?: MouseEvent
|
||||||
@observable lastEvent?: MouseEvent
|
@observable elem: HTMLElement
|
||||||
|
|
||||||
static defaultProps = defaultProps
|
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"
|
static IS_RESIZING = "resizing"
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props)
|
super(props)
|
||||||
|
if (props.maxExtent < props.minExtent) {
|
||||||
|
throw new Error("maxExtent must be >= minExtent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.elem = findDOMNode(this) as HTMLElement
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
@ -86,10 +189,10 @@ export class ResizingAnchor extends React.PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
onDragInit = ({ pageX, pageY, buttons }: React.MouseEvent<any>) => {
|
onDragInit = (event: React.MouseEvent) => {
|
||||||
const { onStart } = this.props
|
const { onStart, onlyButtons } = this.props
|
||||||
|
|
||||||
if (buttons !== 1) {
|
if (typeof onlyButtons === "number" && onlyButtons !== event.buttons) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,32 +200,74 @@ export class ResizingAnchor extends React.PureComponent<Props> {
|
|||||||
document.addEventListener("mouseup", this.onDragEnd)
|
document.addEventListener("mouseup", this.onDragEnd)
|
||||||
document.body.classList.add(ResizingAnchor.IS_RESIZING)
|
document.body.classList.add(ResizingAnchor.IS_RESIZING)
|
||||||
|
|
||||||
this.startingPosition = {
|
this.lastMouseEvent = undefined
|
||||||
initX: pageX,
|
onStart()
|
||||||
initY: pageY
|
|
||||||
}
|
}
|
||||||
this.lastEvent = undefined
|
|
||||||
|
|
||||||
onStart(this.startingPosition)
|
calculateDelta(from: Position, to: Position): number | false {
|
||||||
|
const boundingBox = this.elem.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) => {
|
onDrag = _.throttle((event: MouseEvent) => {
|
||||||
const { onDrag } = this.props
|
/**
|
||||||
const { initX, initY } = this.startingPosition
|
* Some notes to help understand the following:
|
||||||
const { pageX, pageY } = event
|
* - A browser's origin point is in the top left of the screen
|
||||||
const { movementX, movementY } = calculateMovement(this.lastEvent ?? event, event)
|
* - 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.
|
||||||
|
*/
|
||||||
|
|
||||||
onDrag({ movementX, movementY, pageX, pageY, initY, initX })
|
if (!this.lastMouseEvent) {
|
||||||
this.lastEvent = event
|
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)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastMouseEvent = event
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
onDragEnd = (event: MouseEvent) => {
|
onDragEnd = (event: MouseEvent) => {
|
||||||
const { onEnd } = this.props
|
this.props.onEnd()
|
||||||
const { initX, initY } = this.startingPosition
|
|
||||||
const { pageX, pageY } = event
|
|
||||||
|
|
||||||
onEnd(({ initX, initY, pageX, pageY }))
|
|
||||||
document.removeEventListener("mousemove", this.onDrag)
|
document.removeEventListener("mousemove", this.onDrag)
|
||||||
document.removeEventListener("mouseup", this.onDragEnd)
|
document.removeEventListener("mouseup", this.onDragEnd)
|
||||||
document.body.classList.remove(ResizingAnchor.IS_RESIZING)
|
document.body.classList.remove(ResizingAnchor.IS_RESIZING)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user