Extract out drag handling code, add dock sidebars

feature/core
Sven Slootweg 6 years ago
parent 2016c0dc44
commit 4efe0c0ad0

@ -6,7 +6,29 @@
left: 0px;
right: 0px;
top: 0px;
bottom: 0px; }
bottom: 0px;
display: grid;
grid-template-rows: auto 1fr; }
.windowManager .windowSpace {
position: relative; }
.windowManager .sidebar {
position: absolute;
top: 0px;
bottom: 0px;
min-width: 80px;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
border-color: #121b26;
border-width: 1px; }
.windowManager .sidebar.side-left {
left: 0px;
border-right-style: solid; }
.windowManager .sidebar.side-right {
right: 0px;
border-left-style: solid; }
.windowManager .sidebar.highlight {
background-color: rgba(9, 14, 54, 0.3);
border-color: #0d4c99; }
.window {
position: absolute;

@ -5,7 +5,8 @@ body {
z-index: 0;
display: inline;
position: relative;
top: -24px;
margin-top: -24px;
margin-bottom: -24px;
margin-left: 20px;
color: white;
font-size: 64px;

@ -1,16 +1,12 @@
'use strict';
const React = require("react");
const createReactClass = require("create-react-class");
const throttleit = require("throttleit");
const euclideanDistance = require("euclidean-distance");
const classnames = require("classnames");
const defaultValue = require("default-value");
const Window = require("./window");
/* These can be stored outside a component as effective globals, because there can only be one mouse position anyway. */
let lastMouseX = 0;
let lastMouseY = 0;
const { useDraggable, mouseMove } = require("../hooks/draggable");
let windowRefs = new Map();
@ -25,156 +21,214 @@ function trackRef(window_) {
};
}
module.exports = function WindowManager({store, children, windowMoveThreshold_}) {
let [movingType, setMovingType] = React.useState(null);
let [movingWindow, setMovingWindow] = React.useState(null);
let [movingWindowBarX, setMovingWindowBarX] = React.useState(null);
let [movingWindowBarY, setMovingWindowBarY] = React.useState(null);
let [movingWindowOriginalWidth, setMovingWindowOriginalWidth] = React.useState(null);
let [movingWindowOriginalHeight, setMovingWindowOriginalHeight] = React.useState(null);
let [movingWindowOriginX, setMovingWindowOriginX] = React.useState(null);
let [movingWindowOriginY, setMovingWindowOriginY] = React.useState(null);
let [movingWindowThresholdMet, setMovingWindowThresholdMet] = React.useState(false);
function SideBar({elementRef, side, onMouseOver, onMouseOut, highlight}) {
function onMouseOverHandler(event) {
onMouseOver(event, side);
}
let [processMouseMove, setProcessMouseMove] = React.useState(null);
function onMouseOutHandler(event) {
onMouseOut(event, side);
}
let windowMoveThreshold = defaultValue(windowMoveThreshold_, 6);
return (
<div ref={elementRef} className={classnames("sidebar", `side-${side}`, {highlight: highlight})} onMouseOver={onMouseOverHandler} onMouseOut={onMouseOutHandler}>
React.useEffect(() => {
processMouseMove = throttleit(() => {
/* Yes, the below check is there for a reason; just in case the `movingWindow` state changes between the call to the throttled wrapper and the wrapped function itself. */
if (movingWindow != null) {
let thresholdNewlyMet = false;
if (movingWindowThresholdMet === false) {
let origin = [movingWindowOriginX, movingWindowOriginY];
let position = [lastMouseX, lastMouseY];
if (euclideanDistance(origin, position) > windowMoveThreshold) {
thresholdNewlyMet = true;
setMovingWindowThresholdMet(true);
}
}
</div>
);
}
function WindowSpace({children}) {
return (
<div className="windowSpace">
{children}
</div>
);
}
function ContentSpace({children}) {
return (
<div className="contentSpace">
{children}
</div>
);
}
if (movingWindowThresholdMet || thresholdNewlyMet === true) {
/* Note: we handle this out-of-band, to avoid going through React's rendering cycle for every move event. */
let element = windowRefs.get(movingWindow);
if (movingType === "move") {
let {x, y} = getCurrentMoveValues();
element.style.transform = `translate(${x}px, ${y}px)`;
} else if (movingType === "resize") {
let {width, height} = getCurrentResizeValues();
element.style.width = `${width}px`;
element.style.height = `${height}px`;
module.exports = function WindowManager({store, children, windowMoveThreshold}) {
let [isResizing, setIsResizing] = React.useState(false);
let [isMoving, setIsMoving] = React.useState(false);
let [lastDragType, setLastDragType] = React.useState(null);
let [dragOperationData, setDragOperationData] = React.useState(null);
let [draggedWindow, setDraggedWindow] = React.useState(null);
let [highlightBar, setHighlightBar] = React.useState(null);
let [leftBarRef, setLeftBarRef] = React.useState(null);
let [rightBarRef, setRightBarRef] = React.useState(null);
let [leftBarBounds, setLeftBarBounds] = React.useState(null);
let [rightBarBounds, setRightBarBounds] = React.useState(null);
let [newX, newY] = useDraggable({
isDragging: isResizing || isMoving,
operationData: dragOperationData,
threshold: defaultValue(windowMoveThreshold, 6),
onChange: (xValue, yValue, mouseX, mouseY) => {
/* NOTE: we handle this out-of-band, to avoid going through React's rendering cycle for every move event. */
let element = windowRefs.get(draggedWindow);
if (isMoving) {
/* NOTE: We *only* change the highlightBar state if necessary, because otherwise we would still go through a rendering cycle for every move event after all. */
if (isInLeftBar(mouseX, mouseY)) {
if (highlightBar !== "left") {
setHighlightBar("left");
}
} else if (isInRightBar(mouseX, mouseY)) {
if (highlightBar !== "right") {
setHighlightBar("right");
}
} else {
if (highlightBar != null) {
setHighlightBar(null);
}
}
element.style.transform = `translate(${xValue}px, ${yValue}px)`;
} else if (isResizing) {
element.style.width = `${xValue}px`;
element.style.height = `${yValue}px`;
}
}
});
React.useEffect(() => {
if (leftBarRef != null) {
setLeftBarBounds(leftBarRef.getBoundingClientRect());
} else {
setLeftBarBounds(null);
}
if (rightBarRef != null) {
setRightBarBounds(rightBarRef.getBoundingClientRect());
} else {
setRightBarBounds(null);
}
}, [leftBarRef, rightBarRef]);
React.useEffect(() => {
if (draggedWindow != null) {
if (lastDragType === "move") {
store.setPosition(draggedWindow, newX, newY);
} else if (lastDragType === "resize") {
store.setDimensions(draggedWindow, newX, newY);
}
}, 10);
/* NOTE: We first manually set the processMouseMove above, to ensure that the new function instance is immediately available in the render code below. */
/* HACK: React interprets functions as lazy setters, therefore we return the wrapped processMouseMove function from an arrow function. */
setProcessMouseMove(() => processMouseMove);
}, [movingWindow]);
function getCurrentMoveValues() {
return {
x: lastMouseX - movingWindowBarX,
y: lastMouseY - movingWindowBarY
};
setDraggedWindow(null);
}
}, [newX, newY]);
function isInArea(x, y, bounds) {
return (x >= bounds.left && x < bounds.right && y >= bounds.top && y < bounds.bottom);
}
function getCurrentResizeValues() {
return {
width: movingWindowOriginalWidth + (lastMouseX - movingWindowOriginX),
height: movingWindowOriginalHeight + (lastMouseY - movingWindowOriginY)
};
function isInLeftBar(x, y) {
return isInArea(x, y, leftBarBounds);
}
function isInRightBar(x, y) {
return isInArea(x, y, rightBarBounds);
}
/* NOTE: Due to how synthetic events work in React, we need to separate out the 'get coordinates from event' and 'do something with the coordinates' step; if we throttle the coordinate extraction logic, we'll run into problems when synthetic events have already been cleared for reuse by the time we try to obtain the coordinates. Therefore, `processMouseMove` contains all the throttled logic, whereas the coordinate extraction happens on *every* mousemove event. */
/* FIXME: Consider sidestepping React entirely for the mousemove event handling. How much do synthetic events slow things down? */
function handleMouseMove(event) {
if (movingWindow != null) {
lastMouseX = event.pageX;
lastMouseY = event.pageY;
processMouseMove();
function handleDragStart(type, window_, event, currentX, currentY) {
setDraggedWindow(window_.id);
setDragOperationData({
initialMouseX: event.pageX,
initialMouseY: event.pageY,
initialValueX: currentX, // width or X pos
initialValueY: currentY // height or Y pos
});
if (type === "move") {
setIsMoving(true);
} else if (type === "resize") {
setIsResizing(true);
}
}
function handleDragStart(type, window_, event, barX, barY) {
setMovingType(type);
setMovingWindow(window_.id);
setMovingWindowThresholdMet(false);
setMovingWindowBarX(barX);
setMovingWindowBarY(barY);
setMovingWindowOriginX(event.pageX);
setMovingWindowOriginY(event.pageY);
setLastDragType(type);
/* NOTE: The below is to ensure that the window doesn't jump, if the user clicks the titlebar but never moves the mouse. No mousemove event is fired in that scenario, so if we don't set the last-known coordinates here, the window would jump based on whatever coordinate the mouse was last at during the *previous* window drag operation. */
lastMouseX = event.pageX;
lastMouseY = event.pageY;
console.log("started drag of type", type);
mouseMove(event.pageX, event.pageY);
}
function handleTitleMouseDown (window_, event, barX, barY) {
return handleDragStart("move", window_, event, barX, barY);
function handleTitleMouseDown (window_, event) {
return handleDragStart("move", window_, event, window_.x, window_.y);
}
function handleResizerMouseDown(window_, event, barX, barY) {
setMovingWindowOriginalWidth(window_.width);
setMovingWindowOriginalHeight(window_.height);
return handleDragStart("resize", window_, event, barX, barY);
function handleResizerMouseDown(window_, event) {
return handleDragStart("resize", window_, event, window_.width, window_.height);
}
function handleMouseUp () {
if (movingWindow != null) {
if (movingType === "move") {
let {x, y} = getCurrentMoveValues();
store.setPosition(movingWindow, x, y);
} else if (movingType === "resize") {
let {width, height} = getCurrentResizeValues();
store.setDimensions(movingWindow, width, height);
}
if (draggedWindow != null) {
setIsMoving(false);
setIsResizing(false);
}
}
setMovingWindow(null);
function handleMouseMove(event) {
mouseMove(event.pageX, event.pageY);
}
function onMouseOverBar(_event, side) {
if (isMoving) {
setHighlightBar(side);
}
}
function onMouseOutBar(_event, _side) {
setHighlightBar(null);
}
/* TODO: Tiled layout */
return (
<div className="windowManager" onMouseMove={handleMouseMove} onMouseUp={handleMouseUp}>
{children}
{store.getAll().toArray().map((window_) => {
let windowStyle = {
transform: `translate(${window_.x}px, ${window_.y}px)`,
width: window_.width,
height: window_.height,
zIndex: window_.zIndex
};
let handlers = {
onTitleMouseDown: (...args) => {
return handleTitleMouseDown(window_, ...args);
},
onResizerMouseDown: (...args) => {
return handleResizerMouseDown(window_, ...args);
},
onMouseDown: () => {
store.focus(window_.id);
},
onClose: () => {
store.close(window_.id);
}
};
return (
<Window elementRef={trackRef(window_.id)} key={window_.id} style={windowStyle} title={window_.title} isActive={window_.isActive} resizable={window_.resizable} {...handlers}>
{window_.contents}
</Window>
);
})}
<ContentSpace>
{children}
</ContentSpace>
<WindowSpace>
<SideBar elementRef={setLeftBarRef} side="left" highlight={highlightBar === "left"} onMouseOver={onMouseOverBar} onMouseOut={onMouseOutBar} />
{store.getFloating().toArray().map((window_) => {
let windowStyle = {
transform: `translate(${window_.x}px, ${window_.y}px)`,
width: window_.width,
height: window_.height,
zIndex: window_.zIndex
};
let handlers = {
onTitleMouseDown: (...args) => {
return handleTitleMouseDown(window_, ...args);
},
onResizerMouseDown: (...args) => {
return handleResizerMouseDown(window_, ...args);
},
onMouseDown: () => {
store.focus(window_.id);
},
onClose: () => {
store.close(window_.id);
}
};
return (
<Window elementRef={trackRef(window_.id)} key={window_.id} style={windowStyle} title={window_.title} isActive={window_.isActive} resizable={window_.resizable} {...handlers}>
{window_.contents}
</Window>
);
})}
<SideBar elementRef={setRightBarRef} side="right" highlight={highlightBar === "right"} onMouseOver={onMouseOverBar} onMouseOut={onMouseOutBar} />
</WindowSpace>
</div>
);
};

@ -1,5 +1,78 @@
"use strict";
const React = require("react");
// const React = require("react");
const euclideanDistance = require("euclidean-distance");
const throttleit = require("throttleit");
// const useForceUpdate = require("use-force-update");
// TODO
/* CAUTION: This hook has a bunch of effectively-global state! This is fine since a) there can only ever be one drag operation on a document at a time anyway, and b) this appears to be necessary to sidestep performance limitations introduced by React's state-handling cycles, but it's definitely something you shouldn't normally do. */
let operationData;
let isDragging;
let lastMouseX;
let lastMouseY;
let newX;
let newY;
let onChangeCallback;
let thresholdMet = false;
let dragThreshold;
function getCurrentValues() {
return {
x: operationData.initialValueX + (lastMouseX - operationData.initialMouseX),
y: operationData.initialValueY + (lastMouseY - operationData.initialMouseY)
};
}
let processMouseMove = throttleit(() => {
/* Yes, the below check is there for a reason; just in case the `movingWindow` state changes between the call to the throttled wrapper and the wrapped function itself. */
if (onChangeCallback != null) {
if (thresholdMet === false) {
let origin = [operationData.initialMouseX, operationData.initialMouseY];
let position = [lastMouseX, lastMouseY];
if (euclideanDistance(origin, position) > dragThreshold) {
thresholdMet = true;
}
}
if (thresholdMet === true) {
let {x, y} = getCurrentValues();
newX = x;
newY = y;
onChangeCallback(x, y, lastMouseX, lastMouseY);
}
}
}, 10);
module.exports = {
mouseMove: function (x, y) {
/* NOTE: Due to how synthetic events work in React, we need to separate out the 'get coordinates from event' and 'do something with the coordinates' step; if we throttle the coordinate extraction logic, we'll run into problems when synthetic events have already been cleared for reuse by the time we try to obtain the coordinates. Therefore, `processMouseMove` contains all the throttled logic, whereas the coordinate extraction happens on *every* mousemove event. */
lastMouseX = x;
lastMouseY = y;
if (isDragging) {
processMouseMove();
}
},
useDraggable: function (options) {
let dragEnded;
if (isDragging === true && options.isDragging === false) {
dragEnded = true;
} else {
dragEnded = false;
}
operationData = options.operationData;
dragThreshold = options.threshold;
onChangeCallback = options.onChange;
isDragging = options.isDragging;
if (dragEnded) {
return [newX, newY];
} else {
return [null, null];
}
}
};

@ -15,6 +15,40 @@
right: 0px;
top: 0px;
bottom: 0px;
display: grid;
grid-template-rows: auto 1fr;
.windowSpace {
position: relative;
}
.sidebar {
position: absolute;
top: 0px;
bottom: 0px;
min-width: 80px;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
border-color: rgb(18, 27, 38);
border-width: 1px;
&.side-left {
left: 0px;
border-right-style: solid;
}
&.side-right {
right: 0px;
border-left-style: solid;
}
&.highlight {
background-color: rgba(9, 14, 54, 0.3);
border-color: rgb(13, 76, 153);
}
}
}
.window {
@ -115,7 +149,7 @@
.resizer {
@include unselectable;
position: absolute;
/* FIXME: Make the below values configurable? */

@ -13,7 +13,8 @@ body
// top: 16px;
display: inline;
position: relative;
top: -24px;
margin-top: -24px;
margin-bottom: -24px;
margin-left: 20px;
color: white;
font-size: 64px;

@ -6,9 +6,27 @@ const immutable = require("immutable");
module.exports = function createWindowStore({onUpdated}) {
let windows = immutable.OrderedMap([]);
let floatingWindows = immutable.OrderedMap([]);
let leftSidebarWindows = immutable.OrderedMap([]);
let rightSidebarWindows = immutable.OrderedMap([]);
let activeWindow;
let lastZIndex = 0;
function updateWindowIndexes() {
floatingWindows = windows.filter((window_) => window_.sidebar == null);
leftSidebarWindows = windows.filter((window_) => window_.sidebar === "left");
rightSidebarWindows = windows.filter((window_) => window_.sidebar === "right");
}
function setSide(id, side) {
let targetWindow = windows.get(id);
targetWindow.sidebar = side;
updateWindowIndexes();
onUpdated();
}
return {
add: function (window_) {
let id = nanoid();
@ -21,6 +39,8 @@ module.exports = function createWindowStore({onUpdated}) {
height: window_.initialHeight
}));
updateWindowIndexes();
this.focus(id);
/* NOTE: We let .focus call onUpdated. QUESTION: Is this not deduplicated by React? */
@ -49,19 +69,35 @@ module.exports = function createWindowStore({onUpdated}) {
},
setDimensions: function (id, width, height) {
let targetWindow = windows.get(id);
targetWindow.width = width;
targetWindow.height = height;
onUpdated();
},
moveToSidebar: function (id, side) {
setSide(id, side);
},
makeFloating: function (id) {
setSide(id, null);
},
close: function (id) {
windows = windows.delete(id);
updateWindowIndexes();
onUpdated();
},
getAll: function () {
return windows;
},
getFloating: function () {
return floatingWindows;
},
getLeftSidebar: function () {
return leftSidebarWindows;
},
getRightSidebar: function () {
return rightSidebarWindows;
},
getActiveWindow: function () {
return activeWindow;
}

Loading…
Cancel
Save