diff --git a/js/components/WindowManager.js b/js/components/WindowManager.js index b2142d1c..eab25de7 100644 --- a/js/components/WindowManager.js +++ b/js/components/WindowManager.js @@ -1,11 +1,27 @@ import React from "react"; import { findDOMNode } from "react-dom"; -import { snapToMany, snapWithin, applySnap } from "../snapUtils"; +import { + snapDiffManyToMany, + boundingBox, + snapWithinDiff, + snap, + traceConnection +} from "../snapUtils"; const WINDOW_HEIGHT = 116; const WINDOW_WIDTH = 275; +const abuts = (a, b) => { + const wouldMoveTo = snap(a, b); + return wouldMoveTo.x !== undefined || wouldMoveTo.y !== undefined; +}; + +const applyDiff = (a, b) => ({ + x: a.x + b.x, + y: a.y + b.y +}); + class WindowManager extends React.Component { constructor(props) { super(props); @@ -42,16 +58,20 @@ class WindowManager extends React.Component { this.windowNodes.push(node); } - otherWindows(element) { - const domNodes = this.windowNodes.map(findDOMNode); - return domNodes.filter(node => node !== element); + windows() { + return this.windowNodes.map(findDOMNode); } - nodeInfo(node) { + otherWindows(element) { + return this.windows.filter(node => node !== element); + } + + nodeInfo(node, i) { const child = node.childNodes[0]; const { height, width } = child.getBoundingClientRect(); const { offsetLeft, offsetTop } = node; return { + i, x: offsetLeft, y: offsetTop, height, @@ -63,44 +83,71 @@ class WindowManager extends React.Component { if (!e.target.classList.contains("draggable")) { return; } + const mouseStart = { x: e.clientX, y: e.clientY }; - const windowStart = this.nodeInfo(e.currentTarget); + const targetNode = this.nodeInfo(e.currentTarget, i); - const otherWindowNodes = this.otherWindows(e.currentTarget).map( - this.nodeInfo - ); + const windows = this.windows(e.currentTarget).map(this.nodeInfo); + + let movingSet = new Set([targetNode]); + + // Only the main window brings other windows along. + if (targetNode.i === 0) { + const findAllConnected = traceConnection(abuts); + movingSet = findAllConnected(windows, targetNode); + } + + const stationary = windows.filter(w => !movingSet.has(w)); + const moving = Array.from(movingSet); const browserSize = { width: window.innerWidth, height: window.innerHeight }; + const box = boundingBox(moving); + const handleMouseMove = ee => { - const diff = { + const proposedDiff = { x: ee.clientX - mouseStart.x, y: ee.clientY - mouseStart.y }; - const proposedWindow = { - ...windowStart, - x: windowStart.x + diff.x, - y: windowStart.y + diff.y + const proposedWindows = moving.map(node => ({ + ...node, + ...applyDiff(node, proposedDiff) + })); + + const proposedBox = { + ...box, + ...applyDiff(box, proposedDiff) }; - const snappedToOthers = snapToMany(proposedWindow, otherWindowNodes); - const snappedToBrowser = snapWithin(proposedWindow, browserSize); + const snapDiff = snapDiffManyToMany(proposedWindows, stationary); - const newPosition = applySnap( - proposedWindow, - snappedToBrowser, - snappedToOthers - ); + const withinDiff = snapWithinDiff(proposedBox, browserSize); - this.setState({ [i]: { top: newPosition.y, left: newPosition.x } }); + let finalDiff = proposedDiff; + if (withinDiff.x !== 0 || withinDiff.y !== 0) { + finalDiff = applyDiff(proposedDiff, withinDiff); + } else { + finalDiff = applyDiff(proposedDiff, snapDiff); + } + + const stateDiff = moving.reduce((diff, window) => { + const newWindowLocation = applyDiff(window, finalDiff); + diff[window.i] = { + top: newWindowLocation.y, + left: newWindowLocation.x + }; + return diff; + }, {}); + + this.setState(stateDiff); }; window.addEventListener("mouseup", () => { diff --git a/js/snapUtils.js b/js/snapUtils.js index 35c0633c..bb601863 100644 --- a/js/snapUtils.js +++ b/js/snapUtils.js @@ -45,6 +45,31 @@ export const snap = (boxA, boxB) => { return { x, y }; }; +export const snapDiff = (a, b) => { + const newPos = snap(a, b); + return { + x: newPos.x === undefined ? 0 : newPos.x - a.x, + y: newPos.y === undefined ? 0 : newPos.y - a.y + }; +}; + +// TODO: Use the first x and y combo +export const snapDiffManyToMany = (as, bs) => { + let x = 0; + let y = 0; + for (const a of as) { + for (const b of bs) { + const diff = snapDiff(a, b); + x = x || diff.x; + y = y || diff.y; + if (x > 0 && y > 0) { + break; + } + } + } + return { x, y }; +}; + export const snapToMany = (boxA, otherBoxes) => { let x, y; @@ -75,6 +100,14 @@ export const snapWithin = (boxA, boundingBox) => { return { x, y }; }; +export const snapWithinDiff = (a, b) => { + const newPos = snapWithin(a, b); + return { + x: newPos.x === undefined ? 0 : newPos.x - a.x, + y: newPos.y === undefined ? 0 : newPos.y - a.y + }; +}; + export const applySnap = (original, ...snaps) => snaps.reduce( (previous, snapped) => ({ @@ -84,3 +117,42 @@ export const applySnap = (original, ...snaps) => }), original ); + +export const boundingBox = nodes => { + const boxes = nodes.slice(); + const firstNode = boxes.pop(); + const bounding = { + top: top(firstNode), + right: right(firstNode), + bottom: bottom(firstNode), + left: left(firstNode) + }; + + boxes.forEach(node => { + bounding.top = Math.min(bounding.top, top(node)); + bounding.right = Math.max(bounding.right, right(node)); + bounding.bottom = Math.max(bounding.bottom, bottom(node)); + bounding.left = Math.min(bounding.left, left(node)); + }); + + return { + x: bounding.left, + y: bounding.top, + width: bounding.right - bounding.left, + height: bounding.bottom - bounding.top + }; +}; + +export const traceConnection = areConnected => (candidates, node) => { + const connected = new Set(); + const checkNode = n => { + for (const candidate of candidates) { + if (!connected.has(candidate) && areConnected(candidate, n)) { + connected.add(candidate); + checkNode(candidate); + } + } + }; + checkNode(node); + return connected; +}; diff --git a/js/snapUtils.test.js b/js/snapUtils.test.js index 5ab3c5fd..65c829d8 100644 --- a/js/snapUtils.test.js +++ b/js/snapUtils.test.js @@ -8,7 +8,10 @@ import { overlapY, overlapX, snapWithin, - applySnap + applySnap, + snapDiff, + boundingBox, + traceConnection } from "./snapUtils"; describe("side functions", () => { @@ -248,3 +251,205 @@ describe("applySnap function", () => { expect(actual).toEqual(expected); }); }); + +describe("snap diff", () => { + it("does not snap if A and B are obviously far apart", () => { + const a = { x: 10, y: 10, width: 100, height: 100 }; + const b = { x: 200, y: 200, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: 0, y: 0 }; + expect(actual).toEqual(expected); + }); + it("snaps the left of A to the right of B", () => { + const a = { x: 120, y: 30, width: 100, height: 100 }; + const b = { x: 10, y: 10, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: -10, y: 0 }; + expect(actual).toEqual(expected); + }); + it("snaps the right of A to the left of B", () => { + const a = { x: 10, y: 30, width: 100, height: 100 }; + const b = { x: 120, y: 10, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: 10, y: 0 }; + expect(actual).toEqual(expected); + }); + it("snaps the top of A to the bottom of B", () => { + const a = { x: 30, y: 10, width: 100, height: 100 }; + const b = { x: 10, y: 120, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: 0, y: 10 }; + expect(actual).toEqual(expected); + }); + it("snaps the bottom of A to the top of B", () => { + const a = { x: 30, y: 120, width: 100, height: 100 }; + const b = { x: 10, y: 10, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: 0, y: -10 }; + expect(actual).toEqual(expected); + }); + it("does not snap to the X axis if A is below B", () => { + const a = { x: 10, y: 10, width: 100, height: 100 }; + const b = { x: 110, y: 150, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: 0, y: 0 }; + expect(actual).toEqual(expected); + }); + it("snaps in both axis if the corners are within SNAP_DISTANCE", () => { + const a = { x: 10, y: 10, width: 100, height: 100 }; + const b = { x: 120, y: 120, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: 10, y: 10 }; + expect(actual).toEqual(expected); + }); + it("snaps the left of A to the left of B", () => { + const a = { x: 10, y: 10, width: 100, height: 100 }; + const b = { x: 15, y: 110, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: 5, y: 0 }; + expect(actual).toEqual(expected); + }); + it("snaps the top of A to the bottom of B", () => { + const a = { x: 10, y: 10, width: 100, height: 100 }; + const b = { x: 110, y: 15, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: 0, y: 5 }; + expect(actual).toEqual(expected); + }); + it("snaps the top left corner of A to the bottom right corner of B", () => { + const a = { x: 110, y: 110, width: 100, height: 100 }; + const b = { x: 0, y: 0, width: 100, height: 100 }; + const actual = snapDiff(a, b); + const expected = { x: -10, y: -10 }; + expect(actual).toEqual(expected); + }); +}); + +describe("snapWithin function", () => { + it("snaps the inner box to the top of the outer box", () => { + const inner = { x: 120, y: 10, width: 100, height: 100 }; + const outer = { width: 1000, height: 1000 }; + const actual = snapWithin(inner, outer); + const expected = { y: 0 }; + expect(actual).toEqual(expected); + }); + it("snaps the inner box to the bottom of the outer box", () => { + const inner = { x: 120, y: 910, width: 100, height: 100 }; + const outer = { width: 1000, height: 1000 }; + const actual = snapWithin(inner, outer); + const expected = { y: 900 }; + expect(actual).toEqual(expected); + }); + it("snaps the inner box to the right of the outer box", () => { + const inner = { x: 910, y: 120, width: 100, height: 100 }; + const outer = { width: 1000, height: 1000 }; + const actual = snapWithin(inner, outer); + const expected = { x: 900 }; + expect(actual).toEqual(expected); + }); + it("snaps the inner box to the left of the outer box", () => { + const inner = { x: 10, y: 120, width: 100, height: 100 }; + const outer = { width: 1000, height: 1000 }; + const actual = snapWithin(inner, outer); + const expected = { x: 0 }; + expect(actual).toEqual(expected); + }); + it("snaps the inner box to the top-left of the outer box", () => { + const inner = { x: 10, y: 10, width: 100, height: 100 }; + const outer = { width: 1000, height: 1000 }; + const actual = snapWithin(inner, outer); + const expected = { x: 0, y: 0 }; + expect(actual).toEqual(expected); + }); +}); + +describe("boundingBox function", () => { + it("does not mutate the given array", () => { + const box = { x: 10, y: 10, width: 100, height: 100 }; + const boxesA = [box]; + const boxesB = [box]; + boundingBox(boxesA); + expect(boxesA).toEqual(boxesB); + }); + it("returns iteself when called on a single box", () => { + const box = { x: 10, y: 10, width: 100, height: 100 }; + expect(boundingBox([box])).toEqual(box); + }); + it("returns the larger box when one fits entire within another", () => { + const boxes = [ + { x: 10, y: 10, width: 100, height: 100 }, + { x: 20, y: 20, width: 80, height: 80 } + ]; + expect(boundingBox(boxes)).toEqual(boxes[0]); + }); + it("returns countaing box of two boxes", () => { + const boxes = [ + { x: 10, y: 10, width: 100, height: 100 }, + { x: 120, y: 220, width: 100, height: 100 } + ]; + expect(boundingBox(boxes)).toEqual({ + x: 10, + y: 10, + width: 210, + height: 310 + }); + }); + it("returns countaing box of three boxes", () => { + const boxes = [ + { x: 10, y: 10, width: 100, height: 100 }, + { x: 120, y: 220, width: 100, height: 100 }, + { x: 3, y: 220, width: 100, height: 100 } + ]; + expect(boundingBox(boxes)).toEqual({ + x: 3, + y: 10, + width: 217, + height: 310 + }); + }); +}); + +describe("applySnap function", () => { + it("does not apply undefined values", () => { + const original = { x: 10, y: 10 }; + const snapped = { x: undefined, y: undefined }; + const actual = applySnap(original, snapped); + const expected = original; + expect(actual).toEqual(expected); + }); + it("preserves other values on original", () => { + const original = { x: 10, y: 10, foo: "bar" }; + const snapped = { x: 50, y: 50 }; + const actual = applySnap(original, snapped); + const expected = { x: 50, y: 50, foo: "bar" }; + expect(actual).toEqual(expected); + }); + it('will clobber original with falsy "0" values', () => { + const original = { x: 10, y: 10 }; + const snapped = { x: 0, y: 0 }; + const actual = applySnap(original, snapped); + expect(actual).toEqual(snapped); + }); + it('will clobber original with falsy "0" values', () => { + const original = { x: 10, y: 10 }; + const snapped = { x: 0, y: 0 }; + const actual = applySnap(original, snapped); + expect(actual).toEqual(snapped); + }); + it("can apply multiple snaps", () => { + const original = { x: 10, y: 10 }; + const snapped1 = { x: 0 }; + const snapped2 = { y: 0 }; + const actual = applySnap(original, snapped1, snapped2); + const expected = { x: 0, y: 0 }; + expect(actual).toEqual(expected); + }); + it("the last snap wins", () => { + const original = { x: 10, y: 10 }; + const snapped1 = { x: 50, y: 20 }; + const snapped2 = { x: 60, y: 60 }; + const actual = applySnap(original, snapped1, snapped2); + const expected = { x: 60, y: 60 }; + expect(actual).toEqual(expected); + }); +});