import log from '../lib/log'; const ADD_MONITOR_RECT = 'scratch-gui/monitors/ADD_MONITOR_RECT'; const MOVE_MONITOR_RECT = 'scratch-gui/monitors/MOVE_MONITOR_RECT'; const RESIZE_MONITOR_RECT = 'scratch-gui/monitors/RESIZE_MONITOR_RECT'; const REMOVE_MONITOR_RECT = 'scratch-gui/monitors/REMOVE_MONITOR_RECT'; const initialState = { monitors: {}, savedMonitorPositions: {} }; // Verify that the rectangle formed by the 2 points is well-formed const _verifyRect = function (upperStart, lowerEnd) { if (isNaN(upperStart.x) || isNaN(upperStart.y) || isNaN(lowerEnd.x) || isNaN(lowerEnd.y)) { return false; } if (!(upperStart.x < lowerEnd.x)) { return false; } if (!(upperStart.y < lowerEnd.y)) { return false; } return true; }; const _addMonitorRect = function (state, action) { if (state.monitors.hasOwnProperty(action.monitorId)) { log.error(`Can't add monitor, monitor with id ${action.monitorId} already exists.`); return state; } if (!_verifyRect(action.upperStart, action.lowerEnd)) { log.error(`Monitor rectangle not formatted correctly`); return state; } return { monitors: Object.assign({}, state.monitors, { [action.monitorId]: { upperStart: action.upperStart, lowerEnd: action.lowerEnd } }), savedMonitorPositions: action.savePosition ? Object.assign({}, state.savedMonitorPositions, { [action.monitorId]: {x: action.upperStart.x, y: action.upperStart.y} }) : state.savedMonitorPositions }; }; const _moveMonitorRect = function (state, action) { if (!state.monitors.hasOwnProperty(action.monitorId)) { log.error(`Can't move monitor, monitor with id ${action.monitorId} does not exist.`); return state; } if (isNaN(action.newX) || isNaN(action.newY)) { log.error(`Monitor rectangle not formatted correctly`); return state; } const oldMonitor = state.monitors[action.monitorId]; if (oldMonitor.upperStart.x === action.newX && oldMonitor.upperStart.y === action.newY) { // Hasn't moved return state; } const monitorWidth = oldMonitor.lowerEnd.x - oldMonitor.upperStart.x; const monitorHeight = oldMonitor.lowerEnd.y - oldMonitor.upperStart.y; return { monitors: Object.assign({}, state.monitors, { [action.monitorId]: { upperStart: {x: action.newX, y: action.newY}, lowerEnd: {x: action.newX + monitorWidth, y: action.newY + monitorHeight} } }), // User generated position is saved savedMonitorPositions: Object.assign({}, state.savedMonitorPositions, { [action.monitorId]: {x: action.newX, y: action.newY} }) }; }; const _resizeMonitorRect = function (state, action) { if (!state.monitors.hasOwnProperty(action.monitorId)) { log.error(`Can't resize monitor, monitor with id ${action.monitorId} does not exist.`); return state; } if (isNaN(action.newWidth) || isNaN(action.newHeight) || action.newWidth <= 0 || action.newHeight <= 0) { log.error(`Monitor rectangle not formatted correctly`); return state; } const oldMonitor = state.monitors[action.monitorId]; const newMonitor = { upperStart: oldMonitor.upperStart, lowerEnd: { x: oldMonitor.upperStart.x + action.newWidth, y: oldMonitor.upperStart.y + action.newHeight } }; if (newMonitor.lowerEnd.x === oldMonitor.lowerEnd.x && newMonitor.lowerEnd.y === oldMonitor.lowerEnd.y) { // no change return state; } return { monitors: Object.assign({}, state.monitors, {[action.monitorId]: newMonitor}), savedMonitorPositions: state.savedMonitorPositions }; }; const _removeMonitorRect = function (state, action) { if (!state.monitors.hasOwnProperty(action.monitorId)) { log.error(`Can't remove monitor, monitor with id ${action.monitorId} does not exist.`); return state; } const newMonitors = Object.assign({}, state.monitors); delete newMonitors[action.monitorId]; return { monitors: newMonitors, savedMonitorPositions: state.savedMonitorPositions }; }; const reducer = function (state, action) { if (typeof state === 'undefined') state = initialState; switch (action.type) { case ADD_MONITOR_RECT: return _addMonitorRect(state, action); case MOVE_MONITOR_RECT: return _moveMonitorRect(state, action); case RESIZE_MONITOR_RECT: return _resizeMonitorRect(state, action); case REMOVE_MONITOR_RECT: return _removeMonitorRect(state, action); default: return state; } }; // Init position -------------------------- const PADDING = 5; // @todo fix these numbers when we fix https://github.com/LLK/scratch-gui/issues/980 const SCREEN_WIDTH = 400; const SCREEN_HEIGHT = 300; const SCREEN_EDGE_BUFFER = 40; const _rectsIntersect = function (rect1, rect2) { // If one rectangle is on left side of other if (rect1.upperStart.x >= rect2.lowerEnd.x || rect2.upperStart.x >= rect1.lowerEnd.x) return false; // If one rectangle is above other if (rect1.upperStart.y >= rect2.lowerEnd.y || rect2.upperStart.y >= rect1.lowerEnd.y) return false; return true; }; // We need to place a monitor with the given width and height. Return a rect defining where it should be placed. const getInitialPosition = function (state, monitorId, eltWidth, eltHeight) { // If this monitor was purposefully moved to a certain position before, put it back in that position if (state.savedMonitorPositions.hasOwnProperty(monitorId)) { const saved = state.savedMonitorPositions[monitorId]; return { upperStart: saved, lowerEnd: {x: saved.x + eltWidth, y: saved.y + eltHeight} }; } // Try all starting positions for the new monitor to find one that doesn't intersect others const endXs = [0]; const endYs = [0]; let lastX = null; let lastY = null; for (const monitor in state.monitors) { let x = state.monitors[monitor].lowerEnd.x; x = Math.ceil(x / 50) * 50; // Try to choose a sensible "tab width" so more monitors line up endXs.push(x); endYs.push(Math.ceil(state.monitors[monitor].lowerEnd.y)); } endXs.sort((a, b) => a - b); endYs.sort((a, b) => a - b); // We'll use plan B if the monitor doesn't fit anywhere (too long or tall) let planB = null; for (const x of endXs) { if (x === lastX) { continue; } lastX = x; outer: for (const y of endYs) { if (y === lastY) { continue; } lastY = y; const monitorRect = { upperStart: {x: x + PADDING, y: y + PADDING}, lowerEnd: {x: x + PADDING + eltWidth, y: y + PADDING + eltHeight} }; // Intersection testing rect that includes padding const rect = { upperStart: {x, y}, lowerEnd: {x: x + eltWidth + (2 * PADDING), y: y + eltHeight + (2 * PADDING)} }; for (const monitor in state.monitors) { if (_rectsIntersect(state.monitors[monitor], rect)) { continue outer; } } // If the rect overlaps the ends of the screen if (rect.lowerEnd.x > SCREEN_WIDTH || rect.lowerEnd.y > SCREEN_HEIGHT) { // If rect is not too close to completely off screen, set it as plan B if (!planB && !(rect.upperStart.x + SCREEN_EDGE_BUFFER > SCREEN_WIDTH || rect.upperStart.y + SCREEN_EDGE_BUFFER > SCREEN_HEIGHT)) { planB = monitorRect; } continue; } return monitorRect; } } // If the monitor is too long to fit anywhere, put it in the leftmost spot available // that intersects the right or bottom edge and isn't too close to the edge. if (planB) { return planB; } // If plan B fails and there's nowhere reasonable to put it, plan C is to place the monitor randomly const randX = Math.ceil(Math.random() * (SCREEN_WIDTH / 2)); const randY = Math.ceil(Math.random() * (SCREEN_HEIGHT - SCREEN_EDGE_BUFFER)); return { upperStart: { x: randX, y: randY }, lowerEnd: { x: randX + eltWidth, y: randY + eltHeight } }; }; // Action creators ------------------------ /** * @param {!string} monitorId Id to add * @param {!object} upperStart upper point defining the rectangle * @param {!number} upperStart.x X of top point that defines the monitor location * @param {!number} upperStart.y Y of top point that defines the monitor location * @param {!object} lowerEnd lower point defining the rectangle * @param {!number} lowerEnd.x X of bottom point that defines the monitor location * @param {!number} lowerEnd.y Y of bottom point that defines the monitor location * @param {?boolean} savePosition True if the placement should be saved when adding the monitor * @returns {object} action to add a new monitor at the location */ const addMonitorRect = function (monitorId, upperStart, lowerEnd, savePosition) { return { type: ADD_MONITOR_RECT, monitorId: monitorId, upperStart: upperStart, lowerEnd: lowerEnd, savePosition: savePosition }; }; /** * @param {!string} monitorId Id for monitor to move * @param {!number} newX X of top point that defines the monitor location * @param {!number} newY Y of top point that defines the monitor location * @returns {object} action to move an existing monitor to the location */ const moveMonitorRect = function (monitorId, newX, newY) { return { type: MOVE_MONITOR_RECT, monitorId: monitorId, newX: newX, newY: newY }; }; /** * @param {!string} monitorId Id for monitor to resize * @param {!number} newWidth Width to set monitor to * @param {!number} newHeight Height to set monitor to * @returns {object} action to resize an existing monitor to the given dimensions */ const resizeMonitorRect = function (monitorId, newWidth, newHeight) { return { type: RESIZE_MONITOR_RECT, monitorId: monitorId, newWidth: newWidth, newHeight: newHeight }; }; /** * @param {!string} monitorId Id for monitor to remove * @returns {object} action to remove an existing monitor */ const removeMonitorRect = function (monitorId) { return { type: REMOVE_MONITOR_RECT, monitorId: monitorId }; }; export { reducer as default, addMonitorRect, getInitialPosition, moveMonitorRect, resizeMonitorRect, removeMonitorRect, PADDING, SCREEN_HEIGHT, SCREEN_WIDTH };