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; left: 0px;
right: 0px; right: 0px;
top: 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 { .window {
position: absolute; position: absolute;

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

@ -1,16 +1,12 @@
'use strict'; 'use strict';
const React = require("react"); const React = require("react");
const createReactClass = require("create-react-class"); const classnames = require("classnames");
const throttleit = require("throttleit");
const euclideanDistance = require("euclidean-distance");
const defaultValue = require("default-value"); const defaultValue = require("default-value");
const Window = require("./window"); const Window = require("./window");
const { useDraggable, mouseMove } = require("../hooks/draggable");
/* 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;
let windowRefs = new Map(); let windowRefs = new Map();
@ -25,156 +21,214 @@ function trackRef(window_) {
}; };
} }
module.exports = function WindowManager({store, children, windowMoveThreshold_}) { function SideBar({elementRef, side, onMouseOver, onMouseOut, highlight}) {
let [movingType, setMovingType] = React.useState(null); function onMouseOverHandler(event) {
let [movingWindow, setMovingWindow] = React.useState(null); onMouseOver(event, side);
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);
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(() => { </div>
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; function WindowSpace({children}) {
return (
if (movingWindowThresholdMet === false) { <div className="windowSpace">
let origin = [movingWindowOriginX, movingWindowOriginY]; {children}
let position = [lastMouseX, lastMouseY]; </div>
);
if (euclideanDistance(origin, position) > windowMoveThreshold) { }
thresholdNewlyMet = true;
setMovingWindowThresholdMet(true); function ContentSpace({children}) {
} return (
} <div className="contentSpace">
{children}
</div>
);
}
if (movingWindowThresholdMet || thresholdNewlyMet === true) { module.exports = function WindowManager({store, children, windowMoveThreshold}) {
/* Note: we handle this out-of-band, to avoid going through React's rendering cycle for every move event. */ let [isResizing, setIsResizing] = React.useState(false);
let element = windowRefs.get(movingWindow); let [isMoving, setIsMoving] = React.useState(false);
let [lastDragType, setLastDragType] = React.useState(null);
if (movingType === "move") { let [dragOperationData, setDragOperationData] = React.useState(null);
let {x, y} = getCurrentMoveValues(); let [draggedWindow, setDraggedWindow] = React.useState(null);
element.style.transform = `translate(${x}px, ${y}px)`;
} else if (movingType === "resize") { let [highlightBar, setHighlightBar] = React.useState(null);
let {width, height} = getCurrentResizeValues(); let [leftBarRef, setLeftBarRef] = React.useState(null);
element.style.width = `${width}px`; let [rightBarRef, setRightBarRef] = React.useState(null);
element.style.height = `${height}px`; 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);
setDraggedWindow(null);
/* 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. */ }, [newX, newY]);
setProcessMouseMove(() => processMouseMove);
}, [movingWindow]); function isInArea(x, y, bounds) {
return (x >= bounds.left && x < bounds.right && y >= bounds.top && y < bounds.bottom);
function getCurrentMoveValues() {
return {
x: lastMouseX - movingWindowBarX,
y: lastMouseY - movingWindowBarY
};
} }
function getCurrentResizeValues() { function isInLeftBar(x, y) {
return { return isInArea(x, y, leftBarBounds);
width: movingWindowOriginalWidth + (lastMouseX - movingWindowOriginX), }
height: movingWindowOriginalHeight + (lastMouseY - movingWindowOriginY)
}; 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? */ /* FIXME: Consider sidestepping React entirely for the mousemove event handling. How much do synthetic events slow things down? */
function handleMouseMove(event) {
if (movingWindow != null) { function handleDragStart(type, window_, event, currentX, currentY) {
lastMouseX = event.pageX; setDraggedWindow(window_.id);
lastMouseY = event.pageY;
processMouseMove(); 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) { setLastDragType(type);
setMovingType(type);
setMovingWindow(window_.id);
setMovingWindowThresholdMet(false);
setMovingWindowBarX(barX);
setMovingWindowBarY(barY);
setMovingWindowOriginX(event.pageX);
setMovingWindowOriginY(event.pageY);
/* 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. */ /* 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; mouseMove(event.pageX, event.pageY);
lastMouseY = event.pageY;
console.log("started drag of type", type);
} }
function handleTitleMouseDown (window_, event, barX, barY) { function handleTitleMouseDown (window_, event) {
return handleDragStart("move", window_, event, barX, barY); return handleDragStart("move", window_, event, window_.x, window_.y);
} }
function handleResizerMouseDown(window_, event, barX, barY) { function handleResizerMouseDown(window_, event) {
setMovingWindowOriginalWidth(window_.width); return handleDragStart("resize", window_, event, window_.width, window_.height);
setMovingWindowOriginalHeight(window_.height);
return handleDragStart("resize", window_, event, barX, barY);
} }
function handleMouseUp () { function handleMouseUp () {
if (movingWindow != null) { if (draggedWindow != null) {
if (movingType === "move") { setIsMoving(false);
let {x, y} = getCurrentMoveValues(); setIsResizing(false);
store.setPosition(movingWindow, x, y); }
} else if (movingType === "resize") { }
let {width, height} = getCurrentResizeValues();
store.setDimensions(movingWindow, width, height);
}
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 */ /* TODO: Tiled layout */
return ( return (
<div className="windowManager" onMouseMove={handleMouseMove} onMouseUp={handleMouseUp}> <div className="windowManager" onMouseMove={handleMouseMove} onMouseUp={handleMouseUp}>
{children} <ContentSpace>
{store.getAll().toArray().map((window_) => { {children}
let windowStyle = { </ContentSpace>
transform: `translate(${window_.x}px, ${window_.y}px)`, <WindowSpace>
width: window_.width, <SideBar elementRef={setLeftBarRef} side="left" highlight={highlightBar === "left"} onMouseOver={onMouseOverBar} onMouseOut={onMouseOutBar} />
height: window_.height, {store.getFloating().toArray().map((window_) => {
zIndex: window_.zIndex let windowStyle = {
}; transform: `translate(${window_.x}px, ${window_.y}px)`,
width: window_.width,
let handlers = { height: window_.height,
onTitleMouseDown: (...args) => { zIndex: window_.zIndex
return handleTitleMouseDown(window_, ...args); };
},
onResizerMouseDown: (...args) => { let handlers = {
return handleResizerMouseDown(window_, ...args); onTitleMouseDown: (...args) => {
}, return handleTitleMouseDown(window_, ...args);
onMouseDown: () => { },
store.focus(window_.id); onResizerMouseDown: (...args) => {
}, return handleResizerMouseDown(window_, ...args);
onClose: () => { },
store.close(window_.id); onMouseDown: () => {
} store.focus(window_.id);
}; },
onClose: () => {
return ( store.close(window_.id);
<Window elementRef={trackRef(window_.id)} key={window_.id} style={windowStyle} title={window_.title} isActive={window_.isActive} resizable={window_.resizable} {...handlers}> }
{window_.contents} };
</Window>
); 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> </div>
); );
}; };

@ -1,5 +1,78 @@
"use strict"; "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; right: 0px;
top: 0px; top: 0px;
bottom: 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 { .window {
@ -115,7 +149,7 @@
.resizer { .resizer {
@include unselectable; @include unselectable;
position: absolute; position: absolute;
/* FIXME: Make the below values configurable? */ /* FIXME: Make the below values configurable? */

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

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

Loading…
Cancel
Save