From dda453714a1582b4a4ca61415c571e3a721776ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Thu, 19 Jan 2023 17:48:45 +0100 Subject: [PATCH] (core) Migrating LayoutEditor.js to ES Summary: Migrating LayoutEditor.js to typescript. Test Plan: Existing tests should pass. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3769 --- app/client/components/DetailView.css | 7 +- app/client/components/Layout.ts | 11 +- app/client/components/LayoutEditor.js | 738 ---------------------- app/client/components/LayoutEditor.ts | 806 ++++++++++++++++++++++++ app/client/components/LayoutPreview.css | 7 - app/client/components/LayoutPreview.js | 40 -- app/client/declarations.d.ts | 1 - 7 files changed, 820 insertions(+), 790 deletions(-) delete mode 100644 app/client/components/LayoutEditor.js create mode 100644 app/client/components/LayoutEditor.ts delete mode 100644 app/client/components/LayoutPreview.css delete mode 100644 app/client/components/LayoutPreview.js diff --git a/app/client/components/DetailView.css b/app/client/components/DetailView.css index bd38265a..d7f4d876 100644 --- a/app/client/components/DetailView.css +++ b/app/client/components/DetailView.css @@ -11,7 +11,12 @@ background: var(--grist-theme-page-panels-main-panel-bg, white); z-index: 1; - margin-top: -3px; + margin-top: -6px; +} + +.layout_box_maximized .record-layout-editor { + padding-left: 16px; + padding-right: 16px; } .g_record_detail_inner > .layout_root { diff --git a/app/client/components/Layout.ts b/app/client/components/Layout.ts index 8ced4ab0..23e848a9 100644 --- a/app/client/components/Layout.ts +++ b/app/client/components/Layout.ts @@ -63,15 +63,20 @@ import * as ko from 'knockout'; import {computed, isObservable, observable, utils} from 'knockout'; import {identity, last, uniqueId} from 'underscore'; +export interface ContentBox { + leafId: ko.Observable; + leafContent: ko.Observable; +} + /** * A LayoutBox is the node in the hierarchy of boxes comprising the layout. This class is used for * rendering as well as for the code editor. Since it may be rendered many times on a page, it's * important for it to be efficient. * @param {Layout} layout: The Layout object that manages this LayoutBox. */ -export class LayoutBox extends Disposable { +export class LayoutBox extends Disposable implements ContentBox { public layout: Layout; - public dom: Element | null = null; + public dom: HTMLElement | null = null; public leafId: ko.Observable; // probably number for section id public parentBox: ko.Observable; public childBoxes: KoArray; @@ -171,7 +176,7 @@ export class LayoutBox extends Disposable { /** * Moves the leaf id and content from another layoutBox, unsetting them in the source one. */ - public takeLeafFrom(sourceLayoutBox: LayoutBox) { + public takeLeafFrom(sourceLayoutBox: ContentBox) { this.leafId(sourceLayoutBox.leafId.peek()); // Note that we detach the node, so that the old box doesn't destroy its DOM. this.leafContent(detachNode(sourceLayoutBox.leafContent.peek())); diff --git a/app/client/components/LayoutEditor.js b/app/client/components/LayoutEditor.js deleted file mode 100644 index cd1878a1..00000000 --- a/app/client/components/LayoutEditor.js +++ /dev/null @@ -1,738 +0,0 @@ -/** - * The LayoutEditor can be attached to a Layout object to allow changing it. - * - * Issues: - * TODO: Hitting ESC while dragging should revert smoothly. We can collapse the original leaf, but - * not remove it. On Cancel, we would uncollapse it, and remove the newly-inserted targetBox. - * TODO: UNDO should work. It's OK to just rebuild the old layout without any transition. In other - * words, this may be fine to do fully outside of LayoutEditor. - * TODO: if mouseup over an active hint of the DropTargeter, it might be a better experience to - * reposition to that spot. - * - * TEST CASES THAT SHOULD BE VERIFIED AFTER ANY CHANGE. - * These refer to test/client/components/sampleLayout.js, testable at - * http://localhost:8080/testKoForm.html#topTab=4. - * 1. Drag #1 down and up its container element, pausing at borders. Elements around that border - * should smoothly float to open space for it. Dropping it should cause no jumps. - * 2. Drag #1 down to top of #6. A grey "drop target" rectangle should appear. Hovering over it - * should open space over #6. After that, dragging to bottom of #6 and back to top of #6 should - * open the space automatically without the "drop target". - * 3. Drag #3 right and left in its container, pausing at borders. Elements should again smoothly - * float to open space for it. Dropping it should cause no jumps. - * 4. Drag #4 down into #5, positioning above #5, below, to the left (splitting #5 horizontally) - * or to the right. - * 5. Drop #4 onto the leftmost "drop target" on the left side of #5. It should end up as 1/3 of - * the width of the entire layout, spanning the full height above #6. Drop it back to its place - * between #3 and #9. - * 6. Resizing: every vertical line should allow dragging it left or right to resize. The "resize" - * mouse pointer should appear over a few pixels to the left and right of the border, it should - * not be a difficult area to target. (This gets messed up if overflow:hidden is set on the box - * elements.) - * 7. Drag box 3 to trash; hovering should make it disappear from Layout, mousing back should - * bring it back. Mouse-up over the trash icon should leave it out of the layout. - * 8. Drag boxes 3, 9, 10, 2, 7, 1 (8 should stretch vertically), 5 to trash. They should - * disappear with other elements shrinking or expanding to close the gap. - * 9. Adding a new element: Drag "+ Add New" box to between 1 and 2. A "drop target" should - * appear, allowing you to insert it. Same for adding between 3 and 4. Should be no jumps. - * 10. Drag new element to above #3: three possible drop targets should appear. Hover over each in - * turn, starting from the bottommost part, and make sure it gets inserted in the right level. - */ - - -var _ = require('underscore'); -var ko = require('knockout'); -var assert = require('assert'); -var Promise = require('bluebird'); -var BackboneEvents = require('backbone').Events; - -var dispose = require('app/client/lib/dispose'); -var {Delay} = require('app/client/lib/Delay'); -var dom = require('app/client/lib/dom'); -var kd = require('app/client/lib/koDom'); -var Layout = require('./Layout'); - -/** - * Use the browser globals in a way that allows replacing them with mocks in tests. - */ -var G = require('../lib/browserGlobals').get('window', 'document', '$'); - -//---------------------------------------------------------------------- - -/** - * The Floater class represents a floating version of the element being dragged around. Its size - * corresponds to the box being dragged. It lets the user see what's being repositioned. - */ -function Floater(fillWindow) { - this.leafId = ko.observable(null); - this.leafContent = ko.observable(null); - this.fillWindow = fillWindow || false; - - this.floaterElem = this.autoDispose(dom('div.layout_editor_floater', - kd.show(this.leafContent), - kd.scope(this.leafContent, function(leafContent) { - return leafContent; - }) - )); - G.document.body.appendChild(this.floaterElem); - - this.mouseOffsetX = 0; - this.mouseOffsetY = 0; - this.lastMouseEvent = null; -} -dispose.makeDisposable(Floater); - -Floater.prototype.onInitialMouseMove = function(mouseEvent, sourceBox) { - var rect = sourceBox.dom.getBoundingClientRect(); - this.floaterElem.style.width = rect.width + 'px'; - this.floaterElem.style.height = rect.height + 'px'; - this.mouseOffsetX = 0.2 * rect.width; - this.mouseOffsetY = 0.1 * rect.height; - this.onMouseMove(mouseEvent); - - this.leafId(sourceBox.leafId()); - this.leafContent(sourceBox.leafContent()); - // We use a dummy non-null leafId here, to ensure that sourceBox remains considered a leaf. - sourceBox.leafId('empty'); - sourceBox.leafContent(dom('div.layout_editor_empty_space', - kd.style('margin', (rect.height * 0.02) + 'px'), - kd.style('min-height', (rect.height * 0.96) + 'px') - )); -}; - -Floater.prototype.onMouseUp = function() { - this.lastMouseEvent = null; -}; - -Floater.prototype.onMouseMove = function(mouseEvent) { - this.lastMouseEvent = mouseEvent; - this.floaterElem.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px'; - this.floaterElem.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px'; -}; - -//---------------------------------------------------------------------- - -/** - * When the user hovers near the edge of a box, we call the direction the "affinity", and it - * indicates where an insertion is to happen. Affinities are represented by numbers 0 - 3. The - * functions below distinguish top-down vs left-right, and top/left vs down/right. - */ -//var AFFINITY_NAMES = { 0: 'TOP', 1: 'DOWN', 2: 'LEFT', 3: 'RIGHT' }; -function isAffinityUpDown(affinity) { return (affinity >> 1) === 0; } -function isAffinityAfter(affinity) { return (affinity & 1) === 1; } - -//---------------------------------------------------------------------- - -/** - * DropOverlay is a rectangular indicator that's displayed over a leaf box under the mouse - * pointer, and shows regions of affinity towards one of the borders. It also computes which - * region the user is targeting, and returns an affinity value. - */ -function DropOverlay() { - this.overlayElem = this.autoDispose(dom('div.layout_editor_drop_overlay')); - this.overlayRect = null; - this.hBorder = null; - this.vBorder = null; -} -dispose.makeDisposable(DropOverlay); - -/** - * Hides the overlay box by detaching it from the current element, if any. - */ -DropOverlay.prototype.detach = function() { - if (this.overlayElem.parentNode) { - this.overlayElem.parentNode.removeChild(this.overlayElem); - } -}; - -function getFrac(distance, max) { - return distance < max ? distance / max : Infinity; -} - -/** - * Shows the overlay box over the given element. - */ -DropOverlay.prototype.attach = function(targetElem) { - var rect = this.overlayRect = targetElem.getBoundingClientRect(); - /* - // If uncommented, this will show areas of affinity when hovering over a box. This is helpful in - // debugging, and may be helpful to users too, but makes the interface feel more cluttered. - if (this.overlayElem.parentNode !== targetElem) { - // This also automatically removes it from the old parent, if any. - targetElem.appendChild(this.overlayElem); - } - */ - - // Areas of affinity are essentially fat borders, proportional to width and height. In addition, - // to avoid overly disproportionate regions, we use twice the smaller dimension to limit the - // larger dimension. - this.hBorder = Math.floor(Math.min(rect.height, rect.width * 2) / 3); - this.vBorder = Math.floor(Math.min(rect.width, rect.height * 2) / 3); - var s = this.overlayElem.style; - s.borderTopWidth = s.borderBottomWidth = this.hBorder + 'px'; - s.borderLeftWidth = s.borderRightWidth = this.vBorder + 'px'; -}; - -/** - * If the mouse is over a region of affinity, returns the affinity as an 0-3 integer (see - * AFFINITY_NAMES above). Otherwise, returns -1. - */ -DropOverlay.prototype.getAffinity = function(mouseEvent) { - var rect = this.overlayRect; - var x = mouseEvent.clientX - rect.left, - y = mouseEvent.clientY - rect.top, - top = getFrac(y, this.hBorder), - down = getFrac(rect.height - y, this.hBorder), - left = getFrac(x, this.vBorder), - right = getFrac(rect.width - x, this.vBorder), - minValue = Math.min(top, down, left, right); - - return (minValue === Infinity ? -1 : [top, down, left, right].indexOf(minValue)); -}; - -//---------------------------------------------------------------------- - -/** - * DropTargeter displays a set of rectangles, each of which represents a particular allowed - * insertion point for the element being dragged. It only shows the insertion points at the edge - * of a particular layoutBox as indicated by DropOverlay. - */ -function DropTargeter(rootElem) { - this.rootElem = rootElem; - this.targetsDom = null; - this.currentBox = null; - this.currentAffinity = null; - this.delayedInsertion = Delay.create(); - this.activeTarget = null; - this.autoDisposeCallback(this.removeTargetHints); -} -dispose.makeDisposable(DropTargeter); -_.extend(DropTargeter.prototype, BackboneEvents); - -DropTargeter.prototype.removeTargetHints = function() { - this.activeTarget = null; - this.delayedInsertion.cancel(); - if (this.targetsDom) { - ko.removeNode(this.targetsDom); - this.targetsDom = null; - } - this.currentBox = null; - this.currentAffinity = null; -}; - -DropTargeter.prototype.updateTargetHints = function(layoutBox, affinity, overlay, prevTargetBox) { - // Nothing to update. - if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) { - return; - } - this.removeTargetHints(); - if (affinity === -1) { - return; - } - this.currentBox = layoutBox; - this.currentAffinity = affinity; - - var upDown = isAffinityUpDown(affinity); - var isAfter = isAffinityAfter(affinity); - - var targetParts = []; - // Allow dragging a leaf into another leaf as a child, splitting the latter into two. - // But don't allow dragging a leaf box into itself, that makes no sense. - if (upDown === layoutBox.isVBox() && layoutBox !== prevTargetBox) { - targetParts.push({ box: layoutBox, isChild: true, isAfter: isAfter }); - } - while (layoutBox) { - if (upDown === layoutBox.isHBox()) { - var children = layoutBox.childBoxes.peek(); - // If one of two children is prevTargetBox, replace the last target hint since it - // will be redundant once prevTargetBox is removed. - if (children.length === 2 && prevTargetBox.parentBox() === layoutBox) { - targetParts.splice(targetParts.length - 1, 1, - { box: layoutBox, isChild: false, isAfter: isAfter }); - } - // If there is only one child (which may happen for the root box), the target hint - // is redundant. - else if (prevTargetBox !== layoutBox && prevTargetBox !== layoutBox.getSiblingBox(isAfter) && - children.length !== 1) { - targetParts.push({ box: layoutBox, isChild: false, isAfter: isAfter }); - } - if (isAfter && !layoutBox.isLastChild()) { break; } - if (!isAfter && !layoutBox.isFirstChild()) { break; } - } - layoutBox = layoutBox.parentBox(); - } - if (targetParts.length === 0) { - return; - } - - // Render the hint parts. - if (!isAfter) { - targetParts.reverse(); - } - - // The same code works for both horizontal and vertical situation. For ease of thinking about - // it, we pretend below that we are dealing with an up-down situation (drop hints are horizontal - // wide boxes stacked vertically), and use properties that are named using the up-down - // situation, but whose values might reflect a left-right situation. - var pTop = upDown ? 'top' : 'left', - pHeight = upDown ? 'height' : 'width', - pLeft = upDown ? 'left' : 'top', - pWidth = upDown ? 'width' : 'height', - totalHeight = upDown ? overlay.hBorder : overlay.vBorder, - singleHeight = Math.floor(totalHeight / targetParts.length); - - // Adjust to account for the rounding-down above. - totalHeight = singleHeight * targetParts.length; - - var outerRect = this.rootElem.getBoundingClientRect(); - var innerRect = this.currentBox.dom.getBoundingClientRect(); - - var self = this; - this.targetsDom = dom('div.layout_editor_drop_targeter', - kd.style(pTop, - (innerRect[pTop] - outerRect[pTop] + - (isAfter ? innerRect[pHeight] - totalHeight : 0)) + 'px' - ), - targetParts.map(function(part, index) { - var rect = part.box.dom.getBoundingClientRect(); - return dom('div.layout_editor_drop_target', function(elem) { - elem.style[pHeight] = (singleHeight + 1) + 'px'; // 1px of overlap for better looks - elem.style[pWidth] = rect[pWidth] + 'px'; - elem.style[pLeft] = (rect[pLeft] - outerRect[pLeft]) + 'px'; - elem.style[pTop] = (singleHeight * index) + 'px'; - }, - dom.on('mouseenter', function() { - this.classList.add("layout_hover"); - self.activeTarget = part; - var padDir = upDown ? (isAfter ? 'Bottom' : 'Top') : (isAfter ? 'Right' : 'Left'); - var padding = 'padding' + padDir; - part.box.dom.style.transition = 'padding .3s'; - part.box.dom.style[padding] = '20px'; - }), - dom.on('mouseleave', function() { - this.classList.remove("layout_hover"); - self.activeTarget = null; - part.box.dom.style.padding = '0'; - }), - dom.on('transitionend', this.triggerInsertion.bind(this, part)) - ); - }, this) - ); - this.rootElem.appendChild(this.targetsDom); -}; - -DropTargeter.prototype.triggerInsertion = function(part) { - this.removeTargetHints(); - this.trigger('insertBox', function(box) { - if (part.isChild) { - part.box.addChild(box, part.isAfter); - } else { - part.box.addSibling(box, part.isAfter); - } - }); -}; - -DropTargeter.prototype.accelerateInsertion = function() { - if (this.activeTarget) { - this.activeTarget.box.dom.style.transition = ''; - this.activeTarget.box.dom.style.padding = '0'; - this.triggerInsertion(this.activeTarget); - } -}; - -//---------------------------------------------------------------------- - -/** - * When a LayoutEditor is created for a given Layout object, it makes it possible to drag - * LayoutBoxes to change the layout. - * - * When a user drags a box, its content migrates temporarily to the Floater element, which moves - * with the mouse cursor. As the user drags, the space for the element will open up here or there, - * by adding an appropriate empty targetBox. DropOverlay and DropTargeter together decide the - * insertion point for the drag operations. - * - * NOTES: - * There is some awkwardness in sizing: in a vertically laid out box, the last box takes up all - * available space, so moving it away does not show a transition (the box transitions to empty in - * theory, but it still takes all the same available space). - */ -function LayoutEditor(layout) { - this.layout = layout; - this.rootElem = layout.rootElem; - - this.layout.buildLayout(this.layout.getLayoutSpec(), true); - this.floater = this.autoDispose(Floater.create(this.layout.fillWindow)); - this.dropOverlay = this.autoDispose(DropOverlay.create()); - this.dropTargeter = this.autoDispose(DropTargeter.create(this.rootElem)); - this.listenTo(this.dropTargeter, 'insertBox', this.onInsertBox); - - // This is a place to put LayoutBoxes that should NOT be shown, but SHOULD be possible to - // measure. It's used when a new box is being moved into the editor. - this.measuringBox = this.autoDispose(dom('div.layout_editor_measuring_box')); - this.rootElem.appendChild(this.measuringBox); - - // For better experience, we prevent new repositions while a transition is active, and we - // require some work (leaving and re-entering affinity area) after a previous transition ends. - this.transitionPromise = Promise.resolve(); - this.trashDelay = Delay.create(); - - // TODO: We don't use originalBox at the moment, but may want to, specifically to collapse it - // without removing, and restore if the user hits "Escape". - // This is the box the user clicked, to move its content elsewhere. - this.originalBox = null; - - // The new box into which the content is to be inserted. During a move operation, it starts out - // with this.originalBox. - this.targetBox = null; - - // Make all LayoutBoxes resizable. Update whenever the layout changes. - this.layout.forEachBox(this.makeResizable, this); - this.listenTo(this.layout, 'layoutChanged', function() { - this.layout.forEachBox(this.makeResizable, this); - }); - - var self = this; - this.boundMouseDown = function(ev) { return self.handleMouseDown(ev, this); }; - this.boundMouseMove = this.handleMouseMove.bind(this); - this.boundMouseUp = this.handleMouseUp.bind(this); - G.$(this.rootElem).on('mousedown', '.layout_leaf', this.boundMouseDown); - - this.initialMouseDown = false; - - this.lastTriggered = 'stop'; - - this.autoDisposeCallback(function() { - G.$(G.window).off('mouseup', this.boundMouseUp); - G.$(G.window).off('mousemove', this.boundMouseMove); - G.$(this.rootElem).off('mousedown', this.boundMouseDown); - if (!this.layout.isDisposed()) { - this.layout.buildLayout(this.layout.getLayoutSpec(), false); - this.layout.forEachBox(this.unmakeResizable, this); - } - }); -} -exports.LayoutEditor = LayoutEditor; - -dispose.makeDisposable(LayoutEditor); -_.extend(LayoutEditor.prototype, BackboneEvents); - -LayoutEditor.prototype.triggerUserEditStart = function() { - assert(this.lastTriggered === 'stop', "UserEditStart triggered twice in succession"); - this.lastTriggered = 'start'; - // This attribute allows browser tests to tell when an edit is in progress. - this.rootElem.setAttribute('data-useredit', 'start'); - this.layout.trigger('layoutUserEditStart'); -}; - -LayoutEditor.prototype.triggerUserEditStop = function() { - assert(this.lastTriggered === 'start', "UserEditStop triggered twice in succession"); - this.lastTriggered = 'stop'; - this.layout.trigger('layoutUserEditStop'); - // This attribute allows browser tests to tell when an edit is finished. - this.rootElem.setAttribute('data-useredit', 'stop'); -}; - -LayoutEditor.prototype.makeResizable = function(box) { - // Do not add resizable if: - // Box already resizable, box is not vertically resizable, box is last in it`s group. - if (G.$(box.dom).resizable('instance') || (box.isHBox() && !this.layout.fillWindow) || - box.isLastChild()) { - return; - } - var helperObj = { box: box }; - var isWidth = box.isVBox(); - G.$(box.dom).resizable({ - handles: isWidth ? 'e' : 's', - start: this.onResizeStart.bind(this, helperObj, isWidth), - resize: this.onResizeMove.bind(this, helperObj, isWidth), - stop: this.triggerUserEditStop.bind(this) - }); -}; - -LayoutEditor.prototype.unmakeResizable = function(box) { - if (G.$(box.dom).resizable("instance")) { - // Resizable widget is set for this box. - G.$(box.dom).resizable('destroy'); - } -}; - -LayoutEditor.prototype.onResizeStart = function(helperObj, isWidth, event, ui) { - this.triggerUserEditStart(); - var size = isWidth ? ui.originalSize.width : ui.originalSize.height; - helperObj.scalePerFlexUnit = size / (helperObj.box.flexSize() || 1); - var allSiblings = helperObj.box.parentBox().childBoxes.peek(); - var index = allSiblings.indexOf(helperObj.box); - helperObj.nextSiblings = allSiblings.slice(index + 1); - helperObj.origNextSizes = helperObj.nextSiblings.map(function(b) { return b.flexSize(); }); - helperObj.origSize = helperObj.box.flexSize(); - function adder(sum, box) { return sum + box.flexSize.peek(); } - helperObj.sumPrev = allSiblings.slice(0, index).reduce(adder, 0); - helperObj.sumAll = allSiblings.reduce(adder, 0); - helperObj.sumNext = helperObj.sumAll - helperObj.sumPrev; -}; - -// We'll snap to 1/NumSteps of total size. The choice of 60 allows many evenly-sized layouts. -var NumSteps = 60; - -function round(value, multipleOf) { - return Math.round(value / multipleOf) * multipleOf; -} - -function snap(flexSize, sumPrev, sumAll) { - var endEdge = round(sumPrev + flexSize, sumAll / NumSteps); - return Math.min(endEdge, sumAll) - sumPrev; -} - -LayoutEditor.prototype.onResizeMove = function(helperObj, isWidth, event, ui) { - var sizePx = isWidth ? ui.size.width : ui.size.height; - var newSize = sizePx / helperObj.scalePerFlexUnit; - - // We need some amount of snapping to make it easier to align boxes. The way we'll do it is to - // adjust flexSize of the box being resized and all following boxes so that boundaries end up at - // multiples of fullSize / NumSteps. - - newSize = snap(newSize, helperObj.sumPrev, helperObj.sumAll); - var siblingsFactor = (helperObj.sumNext - newSize) / (helperObj.sumNext - helperObj.origSize); - var sumPrev = helperObj.sumPrev + newSize; - var newSizes = []; - helperObj.origNextSizes.forEach(function(size) { - var s = snap(size * siblingsFactor, sumPrev, helperObj.sumAll); - sumPrev += s; - newSizes.push(s); - }); - - if (newSize <= 0 || newSizes.some(function(size) { return size <= 0; })) { - return; // This isn't an acceptable position. - } - if (newSize !== helperObj.box.flexSize.peek()) { - helperObj.box.flexSize(newSize); - helperObj.nextSiblings.forEach(function(b, i) { - b.flexSize(newSizes[i]); - }); - this.layout.trigger('layoutResized'); - } -}; - -LayoutEditor.prototype.handleMouseDown = function(event, elem) { - if (event.button !== 0 || event.target.classList.contains('ui-resizable-handle')) { - return; - } - if (event.target.classList.contains('layout_grabbable')) { - this.initialMouseDown = true; - this.originalBox = ko.utils.domData.get(elem, 'layoutBox'); - assert(this.originalBox, "MouseDown on element without an associated layoutBox"); - G.$(G.window).on('mousemove', this.boundMouseMove); - G.$(G.window).on('mouseup', this.boundMouseUp); - return false; - } -}; - -LayoutEditor.prototype.dragInNewBox = function(event, leafId) { - var box = this.layout.buildLayoutBox({leaf: leafId}); - - // Place this box into a measuring div. - this.measuringBox.appendChild(box.getDom()); - - this.handleMouseDown(event, box.dom); -}; - -LayoutEditor.prototype.startDragBox = function(event, box) { - this.triggerUserEditStart(); - this.targetBox = box; - this.floater.onInitialMouseMove(event, box); -}; - -LayoutEditor.prototype.handleMouseUp = function(event) { - G.$(G.window).off('mousemove', this.boundMouseMove); - G.$(G.window).off('mouseup', this.boundMouseUp); - - if (this.initialMouseDown) { - this.initialMouseDown = false; - return; - } - - this.targetBox.takeLeafFrom(this.floater); - if (this.dropTargeter.activeTarget) { - this.dropTargeter.accelerateInsertion(); - } else { - resizeLayoutBox(this.targetBox, 'reset'); - } - - this.dropTargeter.removeTargetHints(); - this.dropOverlay.detach(); - - this.transitionPromise.bind(this).finally(function() { - this.floater.onMouseUp(); - resizeLayoutBox(this.targetBox, 'reset'); - this.targetBox = this.originalBox = null; - dispose.emptyNode(this.measuringBox); - this.triggerUserEditStop(); - }); -}; - -LayoutEditor.prototype.removeContainingBox = function(elem) { - var box = this.layout.getContainingBox(elem); - if (box && !box.isDomDetached()) { - this.triggerUserEditStart(); - this.targetBox = box; - var rect = box.dom.getBoundingClientRect(); - box.leafId('empty'); - box.leafContent(dom('div.layout_editor_empty_space', - kd.style('min-height', rect.height + 'px') - )); - this.onInsertBox(_.noop, rect); - this.triggerUserEditStop(); - } -}; - -LayoutEditor.prototype.handleMouseMove = function(event) { - // Make sure the grabbed box still exists - if (this.originalBox.isDisposed()) { - return; - } - - if (this.initialMouseDown) { - this.initialMouseDown = false; - this.startDragBox(event, this.originalBox); - } - this.floater.onMouseMove(event); - - if (this.transitionPromise.isPending()) { - // Don't attempt to do any repositioning while another reposition is happening. - return; - } - - // Handle dragging to trash. - if (dom.findAncestor(event.target, null, '.layout_trash')) { - var isTrashed = this.targetBox && this.targetBox.isDomDetached(); - if (!this.trashDelay.isPending() && !isTrashed) { - // To "trash" a box, we call onInsertBox with noop for the inserter function. The new box - // will still be created, just not attached to anything. - this.trashDelay.schedule(100, this.onInsertBox, this, _.noop); - } - return; - } - this.trashDelay.cancel(); - - // See if we are over a layout_leaf, and that the leaf is in the same layout as the dragged - // element. If so, we are dealing with repositioning. - var elem = dom.findAncestor(event.target, this.rootElem, '.' + this.layout.leafId); - if (elem) { - var hoverBox = ko.utils.domData.get(elem, 'layoutBox'); - this.dropOverlay.attach(elem); - var affinity = this.dropOverlay.getAffinity(event); - this.dropTargeter.updateTargetHints(hoverBox, affinity, this.dropOverlay, this.targetBox); - } else if (!dom.findAncestor(event.target, this.rootElem, '.layout_editor_drop_target')) { - this.dropTargeter.removeTargetHints(); - } -}; - - -/** - * Resizes the given LayoutBox to transition it when it's supposed to expand or collapse. It only - * affects the height for HBoxes, and only the width for VBoxes. For rows, we use an explicit - * height. For columns we rely on 'flex-grow' property. - * A rectangle object: set the relevant style according to the values there. - * 'reset': unset the relevant style, to revert to the values associated with CSS classes. - * 'collapse': collapse to empty size. - * 'current': set and explicit value for the relevant style, which is needed for transitions. - */ -function resizeLayoutBox(layoutBox, sizeRect) { - var reset = (sizeRect === 'reset'); - var collapse = (sizeRect === 'collapse'); - if (sizeRect === 'current') { - sizeRect = layoutBox.dom.getBoundingClientRect(); - } - if (layoutBox.isHBox()) { - layoutBox.dom.style.height = (reset ? '' : (collapse ? '0px' : sizeRect.height + 'px')); - } else { - layoutBox.dom.style.width = (reset ? '' : (collapse ? '0px' : sizeRect.width + 'px')); - } - layoutBox.dom.style.opacity = collapse ? '0.0' : '1.0'; -} - -function rectDesc(rect) { - return (typeof rect === 'string') ? rect : - Math.floor(rect.width) + "x" + Math.floor(rect.height); -} - -/** - * Resizes the given LayoutBox smoothly from starting to ending position, where startRect and - * endRect are one of the values documented in 'resizeLayoutBox'. - */ -function resizeLayoutBoxSmoothly(layoutBox, startRect, endRect) { - if (layoutBox.isDomDetached()) { - return Promise.resolve(); - } - var prevFlexGrow = layoutBox.dom.style.flexGrow; - layoutBox.dom.style.flexGrow = 0; - resizeLayoutBox(layoutBox, startRect); - - // Force the layout engine to compute the current state of the layoutBox.dom element before - // applying the transition. This follows the recommendation here, and seems to work: - // https://timtaubert.de/blog/2012/09/css-transitions-for-dynamically-created-dom-elements/ - _.pick(G.window.getComputedStyle(layoutBox.dom), 'height', 'width'); - - // Start the transition. - layoutBox.dom.classList.add('layout_editor_resize_transition'); - return new Promise(function(resolve, reject) { - dom.once(layoutBox.dom, 'transitionend', function() { resolve(); }); - resizeLayoutBox(layoutBox, endRect); - }) - .timeout(600) // Transitions are only 400ms long, so complain if nothing happened for longer. - .catch(Promise.TimeoutError, function() { - console.error("LayoutEditor.resizeLayoutBoxSmoothly %s %s->%s: transition didn't run", - layoutBox, rectDesc(startRect), rectDesc(endRect)); - // We keep going. It should look like something's wrong and jumpy, but it should still be - // usable and not cause errors elsewhere. - }) - .finally(function() { - layoutBox.dom.classList.remove('layout_editor_resize_transition'); - layoutBox.dom.style.flexGrow = prevFlexGrow; - }); -} - -LayoutEditor.prototype.onInsertBox = function(inserterFunc, optRect) { - // Create a new LayoutBox, and insert it using inserterFunc. - // Shrink prevTargetBox to 0. Create a new target box, initially shrunk, and grow it. - var prevTargetBox = this.targetBox; - - this.targetBox = Layout.LayoutBox.create(this.layout); - this.targetBox.takeLeafFrom(prevTargetBox); - this.targetBox.flexSize(prevTargetBox.flexSize()); - - // Sizing boxes vertically requires extra care that the sum of values doesn't change. - this.targetBox.getDom(); // Make sure its dom is created. - - //console.log("onInsertBox %s -> %s", prevTargetBox, this.targetBox); - var transitionPromiseResolve; - this.transitionPromise = new Promise(function(resolve, reject) { - transitionPromiseResolve = resolve; - }); - - inserterFunc(this.targetBox); - - var prevRect = prevTargetBox.dom.getBoundingClientRect(); - - // Set previous box size to 0 for accurate measurement of new target box - var prevFlexGrow = prevTargetBox.dom.style.flexGrow; - prevTargetBox.dom.style.flexGrow = 0; - - var targetRect = this.targetBox.dom.getBoundingClientRect(); - - prevTargetBox.dom.style.flexGrow = prevFlexGrow; - - return Promise.all([ - resizeLayoutBoxSmoothly(prevTargetBox, prevRect, 'collapse'), - resizeLayoutBoxSmoothly(this.targetBox, 'collapse', targetRect), - ]) - .bind(this).then(function() { - prevTargetBox.dispose(); - if (this.targetBox) { - resizeLayoutBox(this.targetBox, 'reset'); - this.dropOverlay.attach(this.targetBox.dom); - } - - transitionPromiseResolve(); - this.layout.trigger('layoutResized'); - }); -}; diff --git a/app/client/components/LayoutEditor.ts b/app/client/components/LayoutEditor.ts new file mode 100644 index 00000000..3613b4f3 --- /dev/null +++ b/app/client/components/LayoutEditor.ts @@ -0,0 +1,806 @@ +/** + * The LayoutEditor can be attached to a Layout object to allow changing it. + * + * Issues: + * TODO: Hitting ESC while dragging should revert smoothly. We can collapse the original leaf, but + * not remove it. On Cancel, we would uncollapse it, and remove the newly-inserted targetBox. + * TODO: UNDO should work. It's OK to just rebuild the old layout without any transition. In other + * words, this may be fine to do fully outside of LayoutEditor. + * TODO: if mouseup over an active hint of the DropTargeter, it might be a better experience to + * reposition to that spot. + * + * TEST CASES THAT SHOULD BE VERIFIED AFTER ANY CHANGE. + * These refer to test/client/components/sampleLayout.js, testable at + * http://localhost:8080/testKoForm.html#topTab=4. + * 1. Drag #1 down and up its container element, pausing at borders. Elements around that border + * should smoothly float to open space for it. Dropping it should cause no jumps. + * 2. Drag #1 down to top of #6. A grey "drop target" rectangle should appear. Hovering over it + * should open space over #6. After that, dragging to bottom of #6 and back to top of #6 should + * open the space automatically without the "drop target". + * 3. Drag #3 right and left in its container, pausing at borders. Elements should again smoothly + * float to open space for it. Dropping it should cause no jumps. + * 4. Drag #4 down into #5, positioning above #5, below, to the left (splitting #5 horizontally) + * or to the right. + * 5. Drop #4 onto the leftmost "drop target" on the left side of #5. It should end up as 1/3 of + * the width of the entire layout, spanning the full height above #6. Drop it back to its place + * between #3 and #9. + * 6. Resizing: every vertical line should allow dragging it left or right to resize. The "resize" + * mouse pointer should appear over a few pixels to the left and right of the border, it should + * not be a difficult area to target. (This gets messed up if overflow:hidden is set on the box + * elements.) + * 7. Drag box 3 to trash; hovering should make it disappear from Layout, mousing back should + * bring it back. Mouse-up over the trash icon should leave it out of the layout. + * 8. Drag boxes 3, 9, 10, 2, 7, 1 (8 should stretch vertically), 5 to trash. They should + * disappear with other elements shrinking or expanding to close the gap. + * 9. Adding a new element: Drag "+ Add New" box to between 1 and 2. A "drop target" should + * appear, allowing you to insert it. Same for adding between 3 and 4. Should be no jumps. + * 10. Drag new element to above #3: three possible drop targets should appear. Hover over each in + * turn, starting from the bottommost part, and make sure it gets inserted in the right level. + */ + + +import {extend, noop, pick} from 'underscore'; +import {observable, removeNode, utils} from 'knockout'; +import assert from 'assert'; +import Promise from 'bluebird'; +import {Events as BackboneEvents} from 'backbone'; + +import {Disposable, emptyNode} from 'app/client/lib/dispose'; +import {Delay} from 'app/client/lib/Delay'; +import dom from 'app/client/lib/dom'; +import koDom from 'app/client/lib/koDom'; +import {ContentBox, Layout, LayoutBox} from './Layout'; +import * as ko from 'knockout'; +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; + +/** + * Use the browser globals in a way that allows replacing them with mocks in tests. + */ +const G = getBrowserGlobals('document', 'window', '$'); + +//---------------------------------------------------------------------- + +class HelperBox { + public box!: LayoutBox; + public scalePerFlexUnit: number = 0; + public nextSiblings: LayoutBox[] = []; + public origNextSizes: number[] = []; + public origSize: number = 0; + public sumAll: number = 0; + public sumPrev: number = 0; + public sumNext: number = 0; + constructor(data?: Partial) { + if (data) { + extend(this, data); + } + } +} + +interface TargetPart { + box: LayoutBox; + isChild: boolean; + isAfter: boolean; +} + +interface JqueryUI { + size: { width: number, height: number }; + position: { left: number, top: number }; + originalPosition: { left: number, top: number }; + originalSize: { width: number, height: number }; +} + +/** + * The Floater class represents a floating version of the element being dragged around. Its size + * corresponds to the box being dragged. It lets the user see what's being repositioned. + */ +class Floater extends Disposable implements ContentBox { + public leafId: ko.Observable; + public leafContent: ko.Observable; + public fillWindow: boolean; + public floaterElem: HTMLElement; + public mouseOffsetX: number; + public mouseOffsetY: number; + public lastMouseEvent: MouseEvent | null; + + public create(fillWindow?: boolean) { + this.leafId = observable(null); + this.leafContent = observable(null); + this.fillWindow = fillWindow || false; + + this.floaterElem = this.autoDispose(dom('div.layout_editor_floater', + koDom.show(this.leafContent), + koDom.scope(this.leafContent, (leafContent: Element) => { + return leafContent; + }) + )); + G.document.body.appendChild(this.floaterElem); + + this.mouseOffsetX = 0; + this.mouseOffsetY = 0; + this.lastMouseEvent = null; + } + public onInitialMouseMove(mouseEvent: MouseEvent, sourceBox: LayoutBox) { + const rect = sourceBox.dom!.getBoundingClientRect(); + this.floaterElem.style.width = rect.width + 'px'; + this.floaterElem.style.height = rect.height + 'px'; + this.mouseOffsetX = 0.2 * rect.width; + this.mouseOffsetY = 0.1 * rect.height; + this.onMouseMove(mouseEvent); + + this.leafId(sourceBox.leafId()); + this.leafContent(sourceBox.leafContent()); + // We use a dummy non-null leafId here, to ensure that sourceBox remains considered a leaf. + sourceBox.leafId('empty'); + sourceBox.leafContent(dom('div.layout_editor_empty_space', + koDom.style('margin', (rect.height * 0.02) + 'px'), + koDom.style('min-height', (rect.height * 0.96) + 'px') + )); + } + public onMouseUp() { + this.lastMouseEvent = null; + } + public onMouseMove(mouseEvent: MouseEvent) { + this.lastMouseEvent = mouseEvent; + this.floaterElem.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px'; + this.floaterElem.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px'; + } +} + + + + +//---------------------------------------------------------------------- + +/** + * DropOverlay is a rectangular indicator that's displayed over a leaf box under the mouse + * pointer, and shows regions of affinity towards one of the borders. It also computes which + * region the user is targeting, and returns an affinity value. + */ +class DropOverlay extends Disposable { + public overlayElem: HTMLElement; + public overlayRect: DOMRect|null; + public hBorder: number | null; + public vBorder: number | null; + public create() { + this.overlayElem = this.autoDispose(dom('div.layout_editor_drop_overlay')); + this.overlayRect = null; + this.hBorder = null; + this.vBorder = null; + } + /** + * Hides the overlay box by detaching it from the current element, if any. + */ + public detach() { + if (this.overlayElem.parentNode) { + this.overlayElem.parentNode.removeChild(this.overlayElem); + } + } + /** + * Shows the overlay box over the given element. + */ + public attach(targetElem: HTMLElement) { + const rect = this.overlayRect = targetElem.getBoundingClientRect(); + /* + // If uncommented, this will show areas of affinity when hovering over a box. This is helpful in + // debugging, and may be helpful to users too, but makes the interface feel more cluttered. + if (this.overlayElem.parentNode !== targetElem) { + // This also automatically removes it from the old parent, if any. + targetElem.appendChild(this.overlayElem); + } + */ + // Areas of affinity are essentially fat borders, proportional to width and height. In addition, + // to avoid overly disproportionate regions, we use twice the smaller dimension to limit the + // larger dimension. + this.hBorder = Math.floor(Math.min(rect.height, rect.width * 2) / 3); + this.vBorder = Math.floor(Math.min(rect.width, rect.height * 2) / 3); + const s = this.overlayElem.style; + s.borderTopWidth = s.borderBottomWidth = this.hBorder + 'px'; + s.borderLeftWidth = s.borderRightWidth = this.vBorder + 'px'; + } + /** + * If the mouse is over a region of affinity, returns the affinity as an 0-3 integer (see + * AFFINITY_NAMES above). Otherwise, returns -1. + */ + public getAffinity(mouseEvent: MouseEvent) { + const rect = this.overlayRect!; + const x = mouseEvent.clientX - rect.left, y = mouseEvent.clientY - rect.top; + const top = getFrac(y, this.hBorder!), down = getFrac(rect.height - y, this.hBorder!); + const left = getFrac(x, this.vBorder!), right = getFrac(rect.width - x, this.vBorder!); + const minValue = Math.min(top, down, left, right); + + return (minValue === Infinity ? -1 : [top, down, left, right].indexOf(minValue)); + } +} + +//---------------------------------------------------------------------- + +/** + * DropTargeter displays a set of rectangles, each of which represents a particular allowed + * insertion point for the element being dragged. It only shows the insertion points at the edge + * of a particular layoutBox as indicated by DropOverlay. + */ +class DropTargeter extends Disposable { + public listenTo: BackboneEvents["listenTo"]; + public trigger: BackboneEvents["trigger"]; + public stopListening: BackboneEvents["stopListening"]; + public rootElem: HTMLElement; + public targetsDom: HTMLElement|null; + public currentBox: LayoutBox | null; + public currentAffinity: number | null; + public delayedInsertion: Delay; + public activeTarget: TargetPart|null; + + public create(rootElem: HTMLElement) { + this.rootElem = rootElem; + this.targetsDom = null; + this.currentBox = null; + this.currentAffinity = null; + this.delayedInsertion = Delay.create(); + this.activeTarget = null; + this.autoDisposeCallback(this.removeTargetHints); + } + public removeTargetHints() { + this.activeTarget = null; + this.delayedInsertion.cancel(); + if (this.targetsDom) { + removeNode(this.targetsDom); + this.targetsDom = null; + } + this.currentBox = null; + this.currentAffinity = null; + } + public updateTargetHints( + layoutBox: LayoutBox|null, + affinity: number, + overlay: DropOverlay, + prevTargetBox: LayoutBox + ) { + // Nothing to update. + if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) { + return; + } + this.removeTargetHints(); + if (affinity === -1) { + return; + } + this.currentBox = layoutBox; + this.currentAffinity = affinity; + + const upDown = isAffinityUpDown(affinity); + const isAfter = isAffinityAfter(affinity); + + const targetParts: TargetPart[] = []; + // Allow dragging a leaf into another leaf as a child, splitting the latter into two. + // But don't allow dragging a leaf box into itself, that makes no sense. + if (upDown === layoutBox.isVBox() && layoutBox !== prevTargetBox) { + targetParts.push({box: layoutBox, isChild: true, isAfter: isAfter}); + } + while (layoutBox) { + if (upDown === layoutBox.isHBox()) { + const children = layoutBox.childBoxes.peek(); + // If one of two children is prevTargetBox, replace the last target hint since it + // will be redundant once prevTargetBox is removed. + if (children.length === 2 && prevTargetBox.parentBox() === layoutBox) { + targetParts.splice(targetParts.length - 1, 1, + {box: layoutBox, isChild: false, isAfter: isAfter}); + } + // If there is only one child (which may happen for the root box), the target hint + // is redundant. + else if (prevTargetBox !== layoutBox && prevTargetBox !== layoutBox.getSiblingBox(isAfter) && + children.length !== 1) { + targetParts.push({box: layoutBox, isChild: false, isAfter: isAfter}); + } + if (isAfter && !layoutBox.isLastChild()) { break; } + if (!isAfter && !layoutBox.isFirstChild()) { break; } + } + layoutBox = layoutBox.parentBox(); + } + if (targetParts.length === 0) { + return; + } + + // Render the hint parts. + if (!isAfter) { + targetParts.reverse(); + } + + // The same code works for both horizontal and vertical situation. For ease of thinking about + // it, we pretend below that we are dealing with an up-down situation (drop hints are horizontal + // wide boxes stacked vertically), and use properties that are named using the up-down + // situation, but whose values might reflect a left-right situation. + const pTop = upDown ? 'top' : 'left', pHeight = upDown ? 'height' : 'width', + pLeft = upDown ? 'left' : 'top', pWidth = upDown ? 'width' : 'height'; + let totalHeight = upDown ? overlay.hBorder! : overlay.vBorder!; + const singleHeight = Math.floor(totalHeight / targetParts.length); + + // Adjust to account for the rounding-down above. + totalHeight = singleHeight * targetParts.length; + + const outerRect = this.rootElem.getBoundingClientRect(); + const innerRect = this.currentBox.dom!.getBoundingClientRect(); + + const self = this; + this.targetsDom = dom('div.layout_editor_drop_targeter', + koDom.style(pTop, + (innerRect[pTop] - outerRect[pTop] + + (isAfter ? innerRect[pHeight] - totalHeight : 0)) + 'px' + ), + targetParts.map((part, index) => { + const rect = part.box.dom!.getBoundingClientRect(); + return dom('div.layout_editor_drop_target', (elem: HTMLDivElement) => { + elem.style[pHeight] = (singleHeight + 1) + 'px'; // 1px of overlap for better looks + elem.style[pWidth] = rect[pWidth] + 'px'; + elem.style[pLeft] = (rect[pLeft] - outerRect[pLeft]) + 'px'; + elem.style[pTop] = (singleHeight * index) + 'px'; + }, + dom.on('mouseenter', function(this: HTMLElement) { + this.classList.add("layout_hover"); + self.activeTarget = part; + const padDir = upDown ? (isAfter ? 'Bottom' : 'Top') : (isAfter ? 'Right' : 'Left'); + const padding = 'padding' + padDir; + part.box.dom!.style.transition = 'padding .3s'; + part.box.dom!.style[padding as any] = '20px'; + }), + dom.on('mouseleave', function(this: HTMLElement) { + this.classList.remove("layout_hover"); + self.activeTarget = null; + part.box.dom!.style.padding = '0'; + }), + dom.on('transitionend', this.triggerInsertion.bind(this, part)) + ); + }) + ); + this.rootElem.appendChild(this.targetsDom!); + } + public triggerInsertion(part: TargetPart) { + this.removeTargetHints(); + this.trigger('insertBox', (box: LayoutBox) => { + if (part.isChild) { + part.box.addChild(box, part.isAfter); + } else { + part.box.addSibling(box, part.isAfter); + } + }); + } + public accelerateInsertion() { + if (this.activeTarget) { + this.activeTarget.box.dom!.style.transition = ''; + this.activeTarget.box.dom!.style.padding = '0'; + this.triggerInsertion(this.activeTarget); + } + } +} + +extend(DropTargeter.prototype, BackboneEvents); + +//---------------------------------------------------------------------- + +/** + * When a LayoutEditor is created for a given Layout object, it makes it possible to drag + * LayoutBoxes to change the layout. + * + * When a user drags a box, its content migrates temporarily to the Floater element, which moves + * with the mouse cursor. As the user drags, the space for the element will open up here or there, + * by adding an appropriate empty targetBox. DropOverlay and DropTargeter together decide the + * insertion point for the drag operations. + * + * NOTES: + * There is some awkwardness in sizing: in a vertically laid out box, the last box takes up all + * available space, so moving it away does not show a transition (the box transitions to empty in + * theory, but it still takes all the same available space). + */ +export class LayoutEditor extends Disposable { + public layout: Layout; + public rootElem: HTMLElement; + public floater: Floater; + public dropOverlay: DropOverlay; + public dropTargeter: DropTargeter; + public measuringBox: HTMLElement; + + public listenTo: BackboneEvents["listenTo"]; + public trigger: BackboneEvents["trigger"]; + public stopListening: BackboneEvents["stopListening"]; + + public transitionPromise: Promise; + public trashDelay: Delay; + public originalBox: LayoutBox|null; + public targetBox: LayoutBox|null; + public boundMouseDown: (ev: MouseEvent, el: HTMLElement) => void; + public boundMouseMove: (ev: MouseEvent, el: HTMLElement) => void; + public boundMouseUp: (ev: MouseEvent, el: HTMLElement) => void; + public initialMouseDown: boolean; + public lastTriggered: string; + + public create(layout: Layout) { + this.layout = layout; + this.rootElem = layout.rootElem; + + this.layout.buildLayout(this.layout.getLayoutSpec(), true); + this.floater = this.autoDispose(Floater.create(this.layout.fillWindow)); + this.dropOverlay = this.autoDispose(DropOverlay.create()); + this.dropTargeter = this.autoDispose(DropTargeter.create(this.rootElem)); + this.listenTo(this.dropTargeter, 'insertBox', this.onInsertBox); + + // This is a place to put LayoutBoxes that should NOT be shown, but SHOULD be possible to + // measure. It's used when a new box is being moved into the editor. + this.measuringBox = this.autoDispose(dom('div.layout_editor_measuring_box')); + this.rootElem.appendChild(this.measuringBox); + + // For better experience, we prevent new repositions while a transition is active, and we + // require some work (leaving and re-entering affinity area) after a previous transition ends. + this.transitionPromise = Promise.resolve(); + this.trashDelay = Delay.create(); + + // TODO: We don't use originalBox at the moment, but may want to, specifically to collapse it + // without removing, and restore if the user hits "Escape". + // This is the box the user clicked, to move its content elsewhere. + this.originalBox = null; + + // The new box into which the content is to be inserted. During a move operation, it starts out + // with this.originalBox. + this.targetBox = null; + + // Make all LayoutBoxes resizable. Update whenever the layout changes. + this.layout.forEachBox(this.makeResizable, this); + this.listenTo(this.layout, 'layoutChanged', () => { + this.layout.forEachBox(this.makeResizable, this); + }); + + const self = this; + this.boundMouseDown = function(this: HTMLElement, ev: MouseEvent) { + return self.handleMouseDown(ev, this); + }; + this.boundMouseMove = this.handleMouseMove.bind(this); + this.boundMouseUp = this.handleMouseUp.bind(this); + G.$(this.rootElem).on('mousedown', '.layout_leaf', this.boundMouseDown); + + this.initialMouseDown = false; + + this.lastTriggered = 'stop'; + + this.autoDisposeCallback(() => { + G.$(G.window).off('mouseup', this.boundMouseUp); + G.$(G.window).off('mousemove', this.boundMouseMove); + G.$(this.rootElem).off('mousedown', this.boundMouseDown); + if (!this.layout.isDisposed()) { + this.layout.buildLayout(this.layout.getLayoutSpec(), false); + this.layout.forEachBox(this.unmakeResizable, this); + } + }); + } + public triggerUserEditStart() { + assert(this.lastTriggered === 'stop', "UserEditStart triggered twice in succession"); + this.lastTriggered = 'start'; + // This attribute allows browser tests to tell when an edit is in progress. + this.rootElem.setAttribute('data-useredit', 'start'); + this.layout.trigger('layoutUserEditStart'); + } + public triggerUserEditStop() { + assert(this.lastTriggered === 'start', "UserEditStop triggered twice in succession"); + this.lastTriggered = 'stop'; + this.layout.trigger('layoutUserEditStop'); + // This attribute allows browser tests to tell when an edit is finished. + this.rootElem.setAttribute('data-useredit', 'stop'); + } + public makeResizable(box: LayoutBox) { + // Do not add resizable if: + // Box already resizable, box is not vertically resizable, box is last in it`s group. + if (G.$(box.dom).resizable('instance') || (box.isHBox() && !this.layout.fillWindow) || + box.isLastChild()) { + return; + } + const helperObj = new HelperBox({box}); + const isWidth = box.isVBox(); + G.$(box.dom).resizable({ + handles: isWidth ? 'e' : 's', + start: this.onResizeStart.bind(this, helperObj, isWidth), + resize: this.onResizeMove.bind(this, helperObj, isWidth), + stop: this.triggerUserEditStop.bind(this) + }); + } + public unmakeResizable(box: LayoutBox) { + if (G.$(box.dom).resizable("instance")) { + // Resizable widget is set for this box. + G.$(box.dom).resizable('destroy'); + } + } + public onResizeStart(helperObj: HelperBox, isWidth: boolean, event: MouseEvent, ui: JqueryUI) { + this.triggerUserEditStart(); + const size = isWidth ? ui.originalSize.width : ui.originalSize.height; + helperObj.scalePerFlexUnit = size / (helperObj.box.flexSize() || 1); + const allSiblings = helperObj.box.parentBox()!.childBoxes.peek(); + const index = allSiblings.indexOf(helperObj.box); + helperObj.nextSiblings = allSiblings.slice(index + 1); + helperObj.origNextSizes = helperObj.nextSiblings.map(function(b) { return b.flexSize(); }); + helperObj.origSize = helperObj.box.flexSize(); + helperObj.sumPrev = allSiblings.slice(0, index).reduce(adder, 0); + helperObj.sumAll = allSiblings.reduce(adder, 0); + helperObj.sumNext = helperObj.sumAll - helperObj.sumPrev; + } + public onResizeMove(helperObj: HelperBox, isWidth: boolean, event: MouseEvent, ui: JqueryUI) { + const sizePx = isWidth ? ui.size.width : ui.size.height; + let newSize = sizePx / helperObj.scalePerFlexUnit; + + // We need some amount of snapping to make it easier to align boxes. The way we'll do it is to + // adjust flexSize of the box being resized and all following boxes so that boundaries end up at + // multiples of fullSize / NumSteps. + newSize = snap(newSize, helperObj.sumPrev, helperObj.sumAll); + const siblingsFactor = (helperObj.sumNext - newSize) / (helperObj.sumNext - helperObj.origSize); + let sumPrev = helperObj.sumPrev + newSize; + const newSizes: number[] = []; + helperObj.origNextSizes.forEach(function(size) { + const s = snap(size * siblingsFactor, sumPrev, helperObj.sumAll); + sumPrev += s; + newSizes.push(s); + }); + + if (newSize <= 0 || newSizes.some(size => size <= 0)) { + return; // This isn't an acceptable position. + } + if (newSize !== helperObj.box.flexSize.peek()) { + helperObj.box.flexSize(newSize); + helperObj.nextSiblings.forEach(function(b, i) { + b.flexSize(newSizes[i]); + }); + this.layout.trigger('layoutResized'); + } + } + public handleMouseDown(event: MouseEvent, elem: HTMLElement) { + const target = (event.target as HTMLElement); + if (event.button !== 0 || target?.classList.contains('ui-resizable-handle')) { + return; + } + if (target?.classList.contains('layout_grabbable')) { + this.initialMouseDown = true; + this.originalBox = utils.domData.get(elem, 'layoutBox'); + assert(this.originalBox, "MouseDown on element without an associated layoutBox"); + G.$(G.window).on('mousemove', this.boundMouseMove); + G.$(G.window).on('mouseup', this.boundMouseUp); + return false; + } + } + // Exposed for tests + public dragInNewBox(event: MouseEvent, leafId: number) { + const box = this.layout.buildLayoutBox({leaf: leafId}); + + // Place this box into a measuring div. + this.measuringBox.appendChild(box.getDom()); + + this.handleMouseDown(event, box.dom!); + } + public startDragBox(event: MouseEvent, box: LayoutBox) { + this.triggerUserEditStart(); + this.targetBox = box; + this.floater.onInitialMouseMove(event, box); + } + public handleMouseUp(event: MouseEvent) { + G.$(G.window).off('mousemove', this.boundMouseMove); + G.$(G.window).off('mouseup', this.boundMouseUp); + + if (this.initialMouseDown) { + this.initialMouseDown = false; + return; + } + + this.targetBox!.takeLeafFrom(this.floater); + if (this.dropTargeter.activeTarget) { + this.dropTargeter.accelerateInsertion(); + } else { + resizeLayoutBox(this.targetBox!, 'reset'); + } + + this.dropTargeter.removeTargetHints(); + this.dropOverlay.detach(); + + this.transitionPromise.finally(() => { + this.floater.onMouseUp(); + resizeLayoutBox(this.targetBox!, 'reset'); + this.targetBox = this.originalBox = null; + emptyNode(this.measuringBox); + this.triggerUserEditStop(); + }); + } + public removeContainingBox(elem: HTMLElement) { + const box = this.layout.getContainingBox(elem); + if (box && !box.isDomDetached()) { + this.triggerUserEditStart(); + this.targetBox = box; + const rect = box.dom.getBoundingClientRect(); + box.leafId('empty'); + box.leafContent(dom('div.layout_editor_empty_space', + koDom.style('min-height', rect.height + 'px') + )); + this.onInsertBox(noop).catch(noop); + this.triggerUserEditStop(); + } + } + public handleMouseMove(event: MouseEvent) { + // Make sure the grabbed box still exists + if (!this.originalBox || this.originalBox?.isDisposed()) { + return; + } + + if (this.initialMouseDown) { + this.initialMouseDown = false; + this.startDragBox(event, this.originalBox); + } + this.floater.onMouseMove(event); + + if (this.transitionPromise.isPending()) { + // Don't attempt to do any repositioning while another reposition is happening. + return; + } + + // Handle dragging to trash. + if (dom.findAncestor(event.target, null, '.layout_trash')) { + const isTrashed = this.targetBox && this.targetBox.isDomDetached(); + if (!this.trashDelay.isPending() && !isTrashed) { + // To "trash" a box, we call onInsertBox with noop for the inserter function. The new box + // will still be created, just not attached to anything. + this.trashDelay.schedule(100, this.onInsertBox, this, noop); + } + return; + } + this.trashDelay.cancel(); + + // See if we are over a layout_leaf, and that the leaf is in the same layout as the dragged + // element. If so, we are dealing with repositioning. + const elem = dom.findAncestor(event.target, this.rootElem, '.' + this.layout.leafId); + if (elem) { + const hoverBox = utils.domData.get(elem, 'layoutBox'); + this.dropOverlay.attach(elem); + const affinity = this.dropOverlay.getAffinity(event); + this.dropTargeter.updateTargetHints(hoverBox, affinity, this.dropOverlay, this.targetBox!); + } else if (!dom.findAncestor(event.target, this.rootElem, '.layout_editor_drop_target')) { + this.dropTargeter.removeTargetHints(); + } + } + public async onInsertBox(inserterFunc: (box: LayoutBox) => void) { + // Create a new LayoutBox, and insert it using inserterFunc. + // Shrink prevTargetBox to 0. Create a new target box, initially shrunk, and grow it. + const prevTargetBox = this.targetBox!; + + this.targetBox = LayoutBox.create(this.layout); + this.targetBox.takeLeafFrom(prevTargetBox); + this.targetBox.flexSize(prevTargetBox.flexSize()); + + // Sizing boxes vertically requires extra care that the sum of values doesn't change. + this.targetBox.getDom(); // Make sure its dom is created. + + + //console.log("onInsertBox %s -> %s", prevTargetBox, this.targetBox); + let transitionPromiseResolve!: () => void; + this.transitionPromise = new Promise(function(resolve, reject) { + transitionPromiseResolve = resolve; + }); + + inserterFunc(this.targetBox); + + const prevRect = prevTargetBox.dom!.getBoundingClientRect(); + + // Set previous box size to 0 for accurate measurement of new target box + const prevFlexGrow = prevTargetBox.dom!.style.flexGrow; + prevTargetBox.dom!.style.flexGrow = '0'; + + const targetRect = this.targetBox.dom!.getBoundingClientRect(); + + prevTargetBox.dom!.style.flexGrow = prevFlexGrow; + + await Promise.all([ + resizeLayoutBoxSmoothly(prevTargetBox, prevRect, 'collapse'), + resizeLayoutBoxSmoothly(this.targetBox, 'collapse', targetRect), + ]); + prevTargetBox.dispose(); + if (this.targetBox) { + resizeLayoutBox(this.targetBox, 'reset'); + this.dropOverlay.attach(this.targetBox.dom!); + } + transitionPromiseResolve(); + this.layout.trigger('layoutResized'); + } +} + +extend(LayoutEditor.prototype, BackboneEvents); + + +//---------------------------------------------------------------------- + +/** + * When the user hovers near the edge of a box, we call the direction the "affinity", and it + * indicates where an insertion is to happen. Affinities are represented by numbers 0 - 3. The + * functions below distinguish top-down vs left-right, and top/left vs down/right. + */ +//const AFFINITY_NAMES = { 0: 'TOP', 1: 'DOWN', 2: 'LEFT', 3: 'RIGHT' }; +function isAffinityUpDown(affinity: number): boolean { + return (affinity >> 1) === 0; +} + +function isAffinityAfter(affinity: number): boolean { + return (affinity & 1) === 1; +} + +function getFrac(distance: number, max: number): number { + return distance < max ? distance / max : Infinity; +} + +// We'll snap to 1/NumSteps of total size. The choice of 60 allows many evenly-sized layouts. +const NumSteps = 60; + +function round(value: number, multipleOf: number) { + return Math.round(value / multipleOf) * multipleOf; +} + +function snap(flexSize: number, sumPrev: number, sumAll: number) { + const endEdge = round(sumPrev + flexSize, sumAll / NumSteps); + return Math.min(endEdge, sumAll) - sumPrev; +} + + +/** + * Resizes the given LayoutBox to transition it when it's supposed to expand or collapse. It only + * affects the height for HBoxes, and only the width for VBoxes. For rows, we use an explicit + * height. For columns we rely on 'flex-grow' property. + * A rectangle object: set the relevant style according to the values there. + * 'reset': unset the relevant style, to revert to the values associated with CSS classes. + * 'collapse': collapse to empty size. + * 'current': set and explicit value for the relevant style, which is needed for transitions. + */ +function resizeLayoutBox(layoutBox: LayoutBox, sizeRect: string|DOMRect) { + const reset = (sizeRect === 'reset'); + const collapse = (sizeRect === 'collapse'); + if (sizeRect === 'current') { + sizeRect = layoutBox.dom!.getBoundingClientRect(); + } + if (layoutBox.isHBox()) { + layoutBox.dom!.style.height = (reset ? '' : (collapse ? '0px' : (sizeRect as DOMRect).height + 'px')); + } else { + layoutBox.dom!.style.width = (reset ? '' : (collapse ? '0px' : (sizeRect as DOMRect).width + 'px')); + } + layoutBox.dom!.style.opacity = collapse ? '0.0' : '1.0'; +} + +function rectDesc(rect: string|DOMRect) { + return (typeof rect === 'string') ? rect : + Math.floor(rect.width) + "x" + Math.floor(rect.height); +} + +/** + * Resizes the given LayoutBox smoothly from starting to ending position, where startRect and + * endRect are one of the values documented in 'resizeLayoutBox'. + */ +function resizeLayoutBoxSmoothly(layoutBox: LayoutBox, startRect: string|DOMRect, endRect: string|DOMRect) { + if (layoutBox.isDomDetached()) { + return Promise.resolve(); + } + const prevFlexGrow = layoutBox.dom!.style.flexGrow; + layoutBox.dom!.style.flexGrow = '0'; + resizeLayoutBox(layoutBox, startRect); + + // Force the layout engine to compute the current state of the layoutBox.dom element before + // applying the transition. This follows the recommendation here, and seems to work: + // https://timtaubert.de/blog/2012/09/css-transitions-for-dynamically-created-dom-elements/ + pick(G.window.getComputedStyle(layoutBox.dom), 'height', 'width'); + + // Start the transition. + layoutBox.dom!.classList.add('layout_editor_resize_transition'); + return new Promise(function(resolve, reject) { + dom.once(layoutBox.dom, 'transitionend', function() { resolve(); }); + resizeLayoutBox(layoutBox, endRect); + }) + .timeout(600) // Transitions are only 400ms long, so complain if nothing happened for longer. + .catch(Promise.TimeoutError, function() { + console.error("LayoutEditor.resizeLayoutBoxSmoothly %s %s->%s: transition didn't run", + layoutBox, rectDesc(startRect), rectDesc(endRect)); + // We keep going. It should look like something's wrong and jumpy, but it should still be + // usable and not cause errors elsewhere. + }) + .finally(function() { + layoutBox.dom!.classList.remove('layout_editor_resize_transition'); + layoutBox.dom!.style.flexGrow = prevFlexGrow; + }); +} + + +function adder(sum: number, box: LayoutBox) { + return sum + box.flexSize.peek(); +} diff --git a/app/client/components/LayoutPreview.css b/app/client/components/LayoutPreview.css deleted file mode 100644 index 4a68a18d..00000000 --- a/app/client/components/LayoutPreview.css +++ /dev/null @@ -1,7 +0,0 @@ -.layout_preview_leaf { - position: relative; - flex: 1 1 0px; - background-color: black; - margin: 1px 0 0 1px; - border-radius: 1px; -} diff --git a/app/client/components/LayoutPreview.js b/app/client/components/LayoutPreview.js deleted file mode 100644 index 765060d8..00000000 --- a/app/client/components/LayoutPreview.js +++ /dev/null @@ -1,40 +0,0 @@ -var ko = require('knockout'); -var dom = require('../lib/dom'); -var dispose = require('../lib/dispose'); -var Layout = require('./Layout'); - -/** - * LayoutPreview - Represents a preview for a single layout. Builds an icon that takes - * the size of its container showing a version of the layout made from solid blocks. - * An optional map between leafId and hex color strings may be used to color the blocks. - * The map may be an observable, but it is only consulted on changes to layoutSpecObj. - */ -function LayoutPreview(layoutSpecObj, optColorMap) { - var self = this; - this.layoutSpecObj = layoutSpecObj; - this.colorMap = optColorMap || {}; - - this.layout = this.autoDispose( - Layout.Layout.create(this.layoutSpecObj(), - function(leafId) { - var content = dom('div.layout_preview_leaf'); - var colorMap = ko.unwrap(self.colorMap); - content.style.backgroundColor = colorMap[leafId] || "#000"; - return content; - }, true) - ); - - // When the layoutSpec changes, rebuild. - this.autoDispose(this.layoutSpecObj.subscribe(function(spec) { - this.layout.buildLayout(this.layoutSpecObj(), true); - }, this)); - -} -dispose.makeDisposable(LayoutPreview); - - -LayoutPreview.prototype.buildDom = function() { - return this.layout.rootElem; -}; - -module.exports = LayoutPreview; diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 17f2768a..719910b4 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -4,7 +4,6 @@ declare module "app/client/components/CodeEditorPanel"; declare module "app/client/components/DetailView"; declare module "app/client/components/DocConfigTab"; declare module "app/client/components/GridView"; -declare module "app/client/components/LayoutEditor"; declare module "app/client/components/commandList"; declare module "app/client/lib/Mousetrap"; declare module "app/client/lib/browserGlobals";