diff --git a/addon/behaviors/common/row-range.js b/addon/behaviors/common/row-range.js new file mode 100644 index 00000000..286ef301 --- /dev/null +++ b/addon/behaviors/common/row-range.js @@ -0,0 +1,169 @@ +import Ember from 'ember'; + +const { Evented, on, run } = Ember; + +export default Ember.Object.extend(Evented, { + + // passed-in + a: null, // the default anchor point + b: null, // the other point + anchorIsB: false, + anchorAdjustment: 0, + + /* + anchor: Ember.computed('a', 'b', 'anchorIsB', function() { + return this.get('anchorAdjustment') + (this.get('anchorIsB') ? this.get('b') : this.get('a')); + }), + + other: Ember.computed('a', 'b', 'anchorIsB', function() { + return this.get('anchorIsB') ? this.get('a') : this.get('b'); + }), + */ + + realA: Ember.computed('anchorIsB', 'a', 'anchorAdjustment', { + get() { + let a = this.get('a'); + return this.get('anchorIsB') ? a : a + this.get('anchorAdjustment'); + }, + set(key, value) { + this.set('a', this.get('anchorIsB') ? value : value - this.get('anchorAdjustment')); + return value; + } + }), + + realB: Ember.computed('anchorIsB', 'b', 'anchorAdjustment', { + get() { + let b = this.get('b'); + return !this.get('anchorIsB') ? b : b + this.get('anchorAdjustment'); + }, + set(key, value) { + this.set('b', !this.get('anchorIsB') ? value : value - this.get('anchorAdjustment')); + return value; + } + }), + + first: Ember.computed('a', 'b', { + get() { + return Math.min(this.get('a'), this.get('b')); + }, + set(key, value) { + if (this.get('a') <= this.get('b')) { + this.set('a', value); + } else { + this.set('b', value); + } + return value; + } + }), + + last: Ember.computed('a', 'b', { + get() { + return Math.max(this.get('a'), this.get('b')); + }, + set(key, value) { + if (this.get('a') > this.get('b')) { + this.set('a', value); + } else { + this.set('b', value); + } + return value; + } + }), + + realFirst: Ember.computed('realA', 'realB', { + get() { + return Math.min(this.get('realA'), this.get('realB')); + }, + set(key, value) { + if (this.get('realA') <= this.get('realB')) { + this.set('realA', value); + } else { + this.set('realB', value); + } + return value; + } + }), + + realLast: Ember.computed('realA', 'realB', { + get() { + return Math.max(this.get('realA'), this.get('realB')); + }, + set(key, value) { + if (this.get('realA') > this.get('realB')) { + this.set('realA', value); + } else { + this.set('realB', value); + } + return value; + } + }), + + normalize() { + this.setProperties({ + a: this.get('realA'), + b: this.get('realB'), + anchorAdjustment: 0 + }); + if (this.get('anchorIsB')) { + let a = this.get('a'); + let b = this.get('b'); + this.setProperties({ + a: b, + b: a, + anchorIsB: false + }); + } + }, + + move(ltBody, pivot, direction) { + let a = this.get('a'); + let b = this.get('b'); + let n = ltBody.get('table.rows.length'); + let clip = (i) => Math.min(Math.max(0, i), n - 1); + let i; + if (a === b) { + i = clip(b + direction); + this.set('b', i); + } else { + if (pivot === -1) { + pivot = a; + } + if (pivot === a) { + i = clip(b + direction); + this.set('b', i); + } else if (pivot === b) { + i = clip(a + direction); + this.set('a', i); + } else if (direction > 0) { + i = clip(this.get('last') + direction); + this.set('last', i); + } else { + i = clip(this.get('first') + direction); + this.set('first', i); + } + } + run.schedule('afterRender', null, () => ltBody.makeRowAtVisible(i)); + }, + + applyDomModifications(ltBody) { + ltBody.get('scrollableDecorations').pushObject({ + component: 'lt-row-range', + namedArgs: { range: this } + }); + }, + + revertDomModifications(ltBody) { + let decorations = ltBody.get('scrollableDecorations'); + decorations.removeObject(decorations.findBy('namedArgs.range', this)); + }, + + _onHandleMove: on('move', function() { + this.trigger('handleMove', ...arguments); + }), + + _onHandleDrop: on('drop', function() { + this.trigger('handleDrop', ...arguments); + }) + +}); + diff --git a/addon/behaviors/spreadsheet-select.js b/addon/behaviors/spreadsheet-select.js index e8a42bee..2b49c305 100644 --- a/addon/behaviors/spreadsheet-select.js +++ b/addon/behaviors/spreadsheet-select.js @@ -1,78 +1,26 @@ import Ember from 'ember'; -import SelectAll from 'ember-light-table/behaviors/select-all'; +import { keyDown, keyUp } from 'ember-keyboard'; import withBackingField from 'ember-light-table/utils/with-backing-field'; -import { keyDown } from 'ember-keyboard'; +import SelectAll from './select-all'; +import RowRange from './common/row-range'; -const { A } = Ember; - -const RowRange = Ember.Object.extend({ - - // passed-in - a: null, // the default pivot point - b: null, // the other point - - first: Ember.computed('a', 'b', { - get() { - return Math.min(this.get('a'), this.get('b')); - }, - set(key, value) { - if (this.get('a') <= this.get('b')) { - this.set('a', value); - } else { - this.set('b', value); - } - return value; - } - }), - - last: Ember.computed('a', 'b', { - get() { - return Math.max(this.get('a'), this.get('b')); - }, - set(key, value) { - if (this.get('a') > this.get('b')) { - this.set('a', value); - } else { - this.set('b', value); - } - return value; - } - }), - - move(pivot, direction) { - let a = this.get('a'); - let b = this.get('b'); - if (a === b) { - this.set('b', b + direction); - } else { - if (pivot === -1) { - pivot = a; - } - if (pivot === a) { - this.set('b', b + direction); - } else if (pivot === b) { - this.set('a', a + direction); - } else if (direction > 0) { - this.set('last', this.get('last') + direction); - } else { - this.set('first', this.get('first') + direction); - } - } - } -}); +const { A, run } = Ember; export default SelectAll.extend({ init() { this._super(...arguments); - this.events.onNewRange = [ 'rowMouseDown:ctrl' ]; this.events.onExtendRange = [ 'rowMouseDown:shift' ]; this.events.onRangeDown = [ keyDown('ArrowDown+shift') ]; this.events.onRangeUp = [ keyDown('ArrowUp+shift') ]; this.events.onSelectNone = [ 'rowMouseDown:_none', keyDown('ArrowDown'), keyDown('ArrowUp') ]; - this.events.startMouseSelection = [ 'rowMouseDown:_all' ]; - this.events.endMouseSelection = [ 'rowMouseUp:_all' ]; - this.events.rowMouseMove = [ 'rowMouseMove:_all' ]; + this.events.onStopRangeUpDown = [ keyUp('ShiftLeft') ]; + this.events.onRowMouseStartNewSelection = [ 'rowMouseDown:_none' ]; + this.events.onRowMouseStartAddRange = [ 'rowMouseDown:ctrl' ]; + this.events.onRowMouseEndNewSelection = [ 'rowMouseUp:_all' ]; + this.events.onRowMouseEndAddRange = [ 'rowMouseUp:_all' ]; + this.events.onRowMouseNewSelectionMove = [ 'rowMouseMove:_all' ]; + this.events.onRowMouseAddRangeMove = [ 'rowMouseMove:_all' ]; }, /* @@ -80,91 +28,254 @@ export default SelectAll.extend({ */ ranges: withBackingField('_ranges', () => A()), - _selectingUsingMouse: false, + _rangeUpDownActive: false, + _mouseNewSelectionAnchor: null, + _mouseNewSelectionActive: false, + _mouseAddRangeActive: false, + + createNewRange(a, b = a) { + let rn = RowRange.create({ a, b }); + rn.on('handleMove', this, this.onHandleMove); + rn.on('handleDrop', this, this.onHandleDrop); + return rn; + }, + + simplifyRanges() { + let ranges = this.get('ranges'); + let max = + Math.max( + ranges.mapBy('a').reduce((x, y) => Math.max(x, y), -1), + ranges.mapBy('b').reduce((x, y) => Math.max(x, y), -1) + ); + let isSelected = A(); + for (let i = 0; i <= max; i++) { + isSelected.pushObject(this.findRanges(i).get('length') % 2 === 1); + } + let anchor; + if (ranges.get('length')) { + anchor = ranges.objectAt(0).get('a'); + this.resetRanges(); + let rn = null; + for (let i = 0; i <= max; i++) { + let isSel = isSelected.objectAt(i); + if (rn && !isSel) { + rn.set('b', i - 1); + ranges.pushObject(rn); + rn = null; + } else if (!rn && isSel) { + rn = this.createNewRange(i); + } + } + if (rn) { + rn.set('b', max); + ranges.pushObject(rn); + } + } + let rn0 = ranges.find((rn) => rn.get('a') === anchor || rn.get('b') === anchor); + if (rn0) { + let a = rn0.get('a'); + let b = rn0.get('b'); + if (a !== anchor) { + rn0.setProperties({ a: b, b: a }); + } + ranges.removeObject(rn0); + ranges.insertAt(0, rn0); + } + }, + + applyDomModifications(ltBody) { + this.get('ranges').forEach((r) => r.applyDomModifications(ltBody)); + }, + + revertDomModifications(ltBody) { + this.get('ranges').forEach((r) => r.revertDomModifications(ltBody)); + }, + + syncSelection(table) { + table + .get('rows') + .forEach((r,i) => r.set('selected', this.findRanges(i).get('length') % 2 === 1)); + }, + + thenSimplify(ltBody) { + this.revertDomModifications(ltBody); + this.simplifyRanges(); + this.applyDomModifications(ltBody); + }, + + noSimplification(ltBody) { + this.syncSelection(ltBody.get('table')); + this.applyDomModifications(ltBody); + }, + + immediateSimplification(ltBody) { + this.syncSelection(ltBody.get('table')); + this.simplifyRanges(); + this.applyDomModifications(ltBody); + }, findRanges(i) { - return A(this.get('ranges').filter((rn) => rn.get('first') <= i && i <= rn.get('last'))); + return A( + this + .get('ranges') + .filter((rn) => rn.get('realFirst') <= i && i <= rn.get('realLast')) + ); }, resetRanges() { - this.set('ranges', A()); + this.get('ranges').clear(); }, startNewRange(i) { - this.get('ranges').insertAt(0, RowRange.create({ a: i, b: i })); + let rn = this.createNewRange(i); + this.get('ranges').insertAt(0, rn); }, - extendRangeTo(b, table) { + extendRangeTo(ltBody, b) { + let table = ltBody.get('table'); let ranges = this.get('ranges'); if (ranges.get('length')) { - ranges.objectAt(0).set('b', b); + let rn = ranges.objectAt(0); + rn.set('b', b); } else { let a = table.get('focusIndex'); if (a === -1) { a = b; } - ranges.pushObject(RowRange.create({ a, b })); + let rn = this.createNewRange(a, b); + ranges.pushObject(rn); } + run.schedule('afterRender', null, () => ltBody.makeRowAtVisible(b)); }, - moveRange(table, direction) { + moveRange(ltBody, direction) { let ranges = this.get('ranges'); - let focusIndex = table.get('focusIndex'); + let focusIndex = ltBody.get('table.focusIndex'); if (ranges.get('length')) { - ranges.objectAt(0).move(focusIndex, direction); + ranges.objectAt(0).move(ltBody, focusIndex, direction); } else { - this.extendRangeTo(focusIndex + direction, table); + this.extendRangeTo(ltBody, focusIndex + direction); } - this.sync(table); - }, - - sync(table) { - table.get('rows').forEach((r,i) => r.set('selected', this.findRanges(i).get('length') % 2 === 1)); - }, - - _onRowClick(ltBody, row, f) { - let table = ltBody.get('table'); - let i = table.get('rows').indexOf(row); - f.call(this, i, table); - this.sync(table); - }, - - onNewRange(ltBody, ltRow) { - let row = ltRow.get('row'); - this._onRowClick(ltBody, row, this.startNewRange); }, onExtendRange(ltBody, ltRow) { + this.revertDomModifications(ltBody); let row = ltRow.get('row'); - this._onRowClick(ltBody, row, this.extendRangeTo); + this.extendRangeTo(ltBody, ltBody.get('table.rows').indexOf(row)); + this.immediateSimplification(ltBody); }, onSelectNone(ltBody) { + this.revertDomModifications(ltBody); this.resetRanges(); - this.sync(ltBody.get('table')); + this.noSimplification(ltBody); }, onRangeDown(ltBody) { - this.moveRange(ltBody.get('table'), 1); + this._rangeUpDownActive = true; + this.revertDomModifications(ltBody); + this.moveRange(ltBody, 1); + this.noSimplification(ltBody); }, onRangeUp(ltBody) { - this.moveRange(ltBody.get('table'), -1); + this._rangeUpDownActive = true; + this.revertDomModifications(ltBody); + this.moveRange(ltBody, -1); + this.noSimplification(ltBody); }, - startMouseSelection(ltBody, ltRow) { - let row = ltRow.get('row'); - this._selectingUsingMouse = row; + onStopRangeUpDown(ltBody) { + if (this._rangeUpDownActive) { + this._rangeUpDownActive = false; + this.thenSimplify(ltBody); + } }, - endMouseSelection() { - this._selectingUsingMouse = false; + onRowMouseStartNewSelection(ltBody, ltRow) { + this._mouseNewSelectionAnchor = ltBody.get('table.rows').indexOf(ltRow.get('row')); }, - rowMouseMove(ltBody, ltRow) { - let row = ltRow.get('row'); - if (this._selectingUsingMouse && (this._selectingUsingMouse !== row || this.get('ranges.length'))) { - this.onExtendRange(...arguments); + onRowMouseStartAddRange(ltBody, ltRow) { + this.revertDomModifications(ltBody); + let i = ltBody.get('table.rows').indexOf(ltRow.get('row')); + this.get('ranges').insertAt(0, RowRange.create({ a: i, b: i })); + this._mouseAddRangeActive = true; + this.noSimplification(ltBody); + }, + + onRowMouseEndNewSelection() { + this._mouseNewSelectionAnchor = null; + this._mouseNewSelectionActive = false; + }, + + onRowMouseEndAddRange(ltBody) { + this._mouseAddRangeAnchor = null; + this._mouseAddRangeActive = false; + this.thenSimplify(ltBody); + }, + + onRowMouseNewSelectionMove(ltBody, ltRow) { + let ranges = this.get('ranges'); + let i = ltBody.get('table.rows').indexOf(ltRow.get('row')); + if ( + this._mouseNewSelectionAnchor && + !this._mouseNewSelectionActive && + this._mouseNewSelectionAnchor !== i + ) { + this.resetRanges(); + ranges.insertAt( + 0, + RowRange.create({ + a: this._mouseNewSelectionAnchor, + b: this._mouseNewSelectionAnchor + }) + ); + this._mouseNewSelectionActive = true; + } + if (this._mouseNewSelectionActive && ranges.get('length')) { + this.revertDomModifications(ltBody); + ranges.objectAt(0).set('b', i); + this.noSimplification(ltBody); + } + }, + + onRowMouseAddRangeMove(ltBody, ltRow) { + let ranges = this.get('ranges'); + if (this._mouseAddRangeActive && ranges.get('length')) { + this.revertDomModifications(ltBody); + ranges.objectAt(0).set('b', ltBody.get('table.rows').indexOf(ltRow.get('row'))); + this.noSimplification(ltBody); } + }, + + _updateRange(ltBody, range, pointName, position, direction) { + let ltDropRow = ltBody.getLtRowAt(position); + if (ltDropRow) { + let ltRows = ltBody.get('ltRows'); + let i = ltRows.indexOf(ltDropRow); + let side = (ltDropRow.get('top') + ltDropRow.get('height') / 2 - position); + if (side * direction > 0) { + i -= direction; + } + i = Math.max(0, Math.min(i, ltBody.get('table.rows.length'))); + let realFirst = range.get('realFirst'); + let realLast = range.get('realLast'); + if (!(direction < 0 && i > realLast) && !(direction > 0 && i < realFirst)) { + range.set(pointName, i); + this.syncSelection(ltBody.get('table')); + run.schedule('afterRender', null, () => ltBody.makeRowAtVisible(i)); + } + } + }, + + onHandleMove() { + this._updateRange(...arguments); + }, + + onHandleDrop(ltBody, range) { + this._updateRange(...arguments); + range.normalize(); + this.thenSimplify(ltBody); } }); diff --git a/addon/components/lt-body.js b/addon/components/lt-body.js index f00c4be0..aef430ff 100644 --- a/addon/components/lt-body.js +++ b/addon/components/lt-body.js @@ -12,10 +12,13 @@ import MultiSelectBehavior from 'ember-light-table/behaviors/multi-select'; import { behaviorGroupFlag, behaviorFlag } from 'ember-light-table/mixins/has-behaviors'; const { + A: emberArray, Component, computed, + getOwner, observer, - run + run, + $ } = Ember; /** @@ -314,19 +317,22 @@ export default Component.extend(EKMixin, ActivateKeyboardOnFocusMixin, HasBehavi columns: computed.readOnly('table.visibleColumns'), colspan: computed.readOnly('columns.length'), - rowAnchors: null, - - addRowAnchor(anchor) { - _anchors.pushObject(anchor); - }, - - removeRowAnchor(anchor) { - _anchors.removeObject(anchor); - }, + /** + /* Components to add in the scrollable content + * + * @property + * @type {[ { component, namedArgs ]} ]} + * @default [] + */ + scrollableDecorations: null, init() { this._super(...arguments); + if (this.get('scrollableDecorations') === null) { + this.set('scrollableDecorations', emberArray()); + } + /* We can only set `useVirtualScrollbar` once all contextual components have been initialized since fixedHeader and fixedFooter are set on t.head and t.foot @@ -395,23 +401,29 @@ export default Component.extend(EKMixin, ActivateKeyboardOnFocusMixin, HasBehavi } }, - _onFocusedRowChanged: Ember.observer('table.focusIndex', function() { - run.schedule('afterRender', null, () => { - let row = this.$('tr.has-focus'); - if (row.length !== 0) { - let rt = row.position().top - this.$('tr:first-child').position().top; - let rh = row.height(); - let rb = rt + rh; - let h = this.$().height(); - let t = this.get('currentScrollOffset'); - let b = t + h; - if (rt < t) { - this.set('targetScrollOffset', rt); - } else if (rb > b) { - this.set('targetScrollOffset', t + rb - b); - } + makeRowAtVisible(i) { + this.makeRowVisible(this.get('ltRows').objectAt(i).$()); + }, + + makeRowVisible(rowQ) { + if (rowQ.length !== 0) { + let rt = rowQ.position().top - this.$('.scrollable-content').position().top; + let rh = rowQ.height(); + let rb = rt + rh; + let h = this.$().height(); + let t = this.get('currentScrollOffset'); + let b = t + h; + let extraSpace = rh / 2; + if (rt < t) { + this.set('targetScrollOffset', rt - extraSpace); + } else if (rb > b) { + this.set('targetScrollOffset', t + rb - b + extraSpace); } - }); + } + }, + + _onFocusedRowChanged: Ember.observer('table.focusIndex', function() { + run.schedule('afterRender', null, () => this.makeRowVisible(this.$('tr.has-focus'))); }), checkTargetScrollOffset() { @@ -430,6 +442,21 @@ export default Component.extend(EKMixin, ActivateKeyboardOnFocusMixin, HasBehavi } }, + ltRows: computed(function() { + let vrm = getOwner(this).lookup('-view-registry:main'); + let q = this.$('tr:not(.lt-expanded-row)'); + return emberArray($.makeArray(q.map((i, e) => vrm[e.id]))); + }).volatile(), + + getLtRowAt(position) { + return this + .get('ltRows') + .find((ltr) => { + let top = ltr.get('top'); + return top <= position && position < top + ltr.get('height'); + }); + }, + actions: { onRowClick() { this.triggerBehaviorEvent('rowClick', ...arguments); @@ -493,6 +520,7 @@ export default Component.extend(EKMixin, ActivateKeyboardOnFocusMixin, HasBehavi */ onScroll(scrollOffset /* , event */) { this.set('currentScrollOffset', scrollOffset); + this.triggerBehaviorEvent('scroll', ...arguments); this.sendAction('onScroll', ...arguments); }, diff --git a/addon/components/lt-row-range.js b/addon/components/lt-row-range.js new file mode 100644 index 00000000..864b572f --- /dev/null +++ b/addon/components/lt-row-range.js @@ -0,0 +1,189 @@ +import Ember from 'ember'; +import layout from '../templates/components/lt-row-range'; + +const { computed, getOwner, observer, on, run } = Ember; +const { htmlSafe } = Ember.String; + +export default Ember.Component.extend({ + + layout, + + // passed in + namedArgs: null, + + range: computed.reads('namedArgs.range'), + + a: null, + b: null, + startA: null, + startB: null, + offsetA: 0, + offsetB: 0, + movingA: false, + movingB: false, + + ltBody: Ember.computed(function() { + let vrm = getOwner(this).lookup('-view-registry:main'); + let q = this.$().parents('.lt-body-wrap'); + return q.length ? vrm[q[0].id] : null; + }).volatile().readOnly(), + + _getPosition(pointName) { + let i = this.get(pointName); + let r = this.get('ltBody.ltRows').objectAt(i); + let top = r.get('top'); + let directionA = this.get('directionA'); + if (pointName === 'a' && directionA < 0 || pointName === 'b' && directionA > 0) { + return top; + } else { + return top + r.get('height'); + } + }, + + _updateA: observer('range.a', 'range.b', function() { + Ember.run.once(this, this.__updateA); + }), + + __updateA() { + if (!this.get('movingA')) { + let a = this.get('range.a'); + this.set('a', a); + this.set('startA', this._getPosition('a')); + } + }, + + _updateB: observer('range.a', 'range.b', function() { + Ember.run.once(this, this.__updateB); + }), + + __updateB() { + if (!this.get('movingB')) { + let b = this.get('range.b'); + this.set('b', b); + this.set('startB', this._getPosition('b')); + } + }, + + _updateAnchor: observer('movingA', 'movingB', function() { + Ember.run.once(this, this.__updateAnchor); + }), + + __updateAnchor() { + let ma = this.get('movingA'); + let mb = this.get('movingB'); + let rn = this.get('range'); + if (ma && !mb) { + rn.set('anchorIsB', true); + } else if (mb && !ma) { + rn.set('anchorIsB', false); + } + }, + + init() { + this._super(...arguments); + this.get('inverse'); + this.set('a', this.get('range.a')); + this.set('b', this.get('range.b')); + }, + + _computePosition: on('didInsertElement', function() { + this._super(...arguments); + this._updateA(); + this._updateB(); + this.set('boxStyle', this.get('__boxStyle')); + }), + + positionA: computed('startA', 'offsetA', { + get() { + return this.get('startA') + this.get('offsetA'); + }, + set(key, value) { + this.set('offsetA', value - this.get('startA')); + return value; + } + }), + + positionB: computed('startB', 'offsetB', { + get() { + return this.get('startB') + this.get('offsetB'); + }, + set(key, value) { + this.set('offsetB', value - this.get('startB')); + return value; + } + }), + + directionA: computed('a', 'b', function() { + return this.get('a') <= this.get('b') ? -1 : 1; + }).readOnly(), + + directionB: computed('directionA', function() { + return -this.get('directionA'); + }).readOnly(), + + inverse: computed('directionA', 'movingA', 'movingB', 'positionA', 'positionB', function() { + return (this.get('movingA') || this.get('movingB')) && + (this.get('directionA') < 0) !== (this.get('positionA') <= this.get('positionB')); + }).readOnly(), + + _updateAnchorAdjustment: observer('inverse', 'directionA', 'range.anchorIsB', function() { + run.once(this, this.__updateAnchorAdjustment); + }), + + __updateAnchorAdjustment() { + let inv = this.get('inverse'); + let dA = this.get('directionA'); + this.set('range.anchorAdjustment', !inv ? 0 : this.get('range.anchorIsB') ? -dA : dA); + }, + + __boxStyle: computed(function() { + let top = this.get('positionA'); + let height = this.get('positionB') - top; + if (height < 0) { + top += height; + height = -height; + } + let r = this.get('ltBody.ltRows').objectAt(0); + let left = r.get('left'); + let width = r.get('width'); + return htmlSafe(`left: ${left}px; width: ${width}px; top: ${top}px; height: ${height}px;`); + }).volatile().readOnly(), + + _boxStyle: null, + + boxStyle: computed('positionA', 'positionB', { + get() { + let style = this.get('_boxStyle'); + if (style) { + this.set('_boxStyle', null); + return style; + } else if (this.get('ltBody')) { + return this.get('__boxStyle'); + } + }, + set(key, value) { + this.set('_boxstyle', value); + return value; + } + }), + + _updateOffset(pointName, position) { + let X = pointName.toUpperCase(); + this.set(`position${X}`, position); + }, + + actions: { + onDrag(pointName) { + this.set(`moving${pointName.toUpperCase()}`, true); + }, + onMove(pointName, position, direction) { + this._updateOffset(pointName, position); + this.get('range').trigger('move', this.get('ltBody'), this.get('range'), pointName, position, direction); + }, + onDrop(pointName, position, direction) { + this.setProperties({ offsetA: 0, offsetB: 0, movingA: false, movingB: false }); + this.get('range').trigger('drop', this.get('ltBody'), this.get('range'), pointName, position, direction); + } + } + +}); diff --git a/addon/components/lt-row.js b/addon/components/lt-row.js index f1a266e8..3ae2c7d6 100644 --- a/addon/components/lt-row.js +++ b/addon/components/lt-row.js @@ -34,7 +34,21 @@ const Row = Component.extend({ isExpanded: computed.readOnly('row.expanded'), hasFocus: computed.readOnly('row.hasFocus'), - handleTop: false, + left: Ember.computed(function() { + return this.$().offset().left - this.$().parents('.scrollable-content').offset().left; + }).volatile().readOnly(), + + width: Ember.computed(function() { + return this.$().width(); + }).volatile().readOnly(), + + top: Ember.computed(function() { + return this.$().offset().top - this.$().parents('.scrollable-content').offset().top; + }).volatile().readOnly(), + + height: Ember.computed(function() { + return this.$().height(); + }).volatile().readOnly(), _onClick: on('click', function() { this.sendAction('rowClick', this, ...arguments); diff --git a/addon/components/lt-selection-handle.js b/addon/components/lt-selection-handle.js new file mode 100644 index 00000000..c19c8345 --- /dev/null +++ b/addon/components/lt-selection-handle.js @@ -0,0 +1,176 @@ +import Ember from 'ember'; +import layout from '../templates/components/lt-selection-handle'; + +const { computed, getOwner, observer, on, run, $ } = Ember; +const { htmlSafe } = Ember.String; + +export default Ember.Component.extend({ + + layout, + + classNameBindings: [':lt-selection-handle', 'isUp:lt-selection-handle-up:lt-selection-handle-down'], + attributeBindings: ['style'], + + // passed in + rowIndex: null, + direction: null, + inverse: false, + + _initialMousePosition: null, + offset: 0, + + ltBody: Ember.computed(function() { + if (this.$()) { + let vrm = getOwner(this).lookup('-view-registry:main'); + let q = this.$().parents('.lt-body-wrap'); + return q.length ? vrm[q[0].id] : null; + } + }).volatile().readOnly(), + + ltRow: Ember.computed(function() { + let ltBody = this.get('ltBody'); + if (ltBody) { + return ltBody.get('ltRows').objectAt(this.get('rowIndex')); + } + }).volatile().readOnly(), + + isUp: computed('direction', 'inverse', function() { + let inverse = this.get('inverse'); + return this.get('direction') < 0 ? !inverse : inverse; + }).readOnly(), + + position: Ember.computed(function() { + let r = this.get('ltRow'); + let result = r.get('top'); + if (!this.get('isUp')) { + result += r.get('height'); + } + return result; + }).volatile().readOnly(), + + _getMousePosition(event) { + return event.clientY - this.$().parents('.scrollable-content').offset().top; + }, + + _setDomEvents: on('init', function() { + this._domEvents = { + mousemove: this._onMouseMoveJQ, + mouseup: this._onMouseUpJQ + }; + }), + + _removeEvents: on('willDestroyElement', function() { + $('body').off(this._domEvents, this); + }), + + _onMouseDown: on('mouseDown', function(event) { + this._initialMousePosition = this._getMousePosition(event); + $('body').on(this._domEvents, this); + this.sendAction('drag'); + }), + + extra: computed('direction', 'inverse', function() { + return !this.get('ltRow') ? + 0 : + this.get('inverse') ? + this.get('direction') * this.get('ltRow.height') : + 0; + }).readOnly(), + + _onMouseMove(event) { + if (this.get('isDestroyed')) { + $('body').off(this._domEvents); + } else if (this._initialMousePosition) { + let offset = this._getMousePosition(event) - this.get('_initialMousePosition'); + this.set('offset', offset); + this.sendAction('move', offset + this.get('extra') + this.get('position'), this.get('isUp') ? -1 : 1); + } else { + this._onMouseDown(event); + } + }, + + _onMouseMoveJQ(event) { + let that = event.data; + run.scheduleOnce('afterRender', null, () => that._onMouseMove.call(that, event)); + }, + + _onMouseUp(event) { + this._removeEvents(); + if (!this.get('isDestroyed')) { + let offset = this._getMousePosition(event) - this.get('_initialMousePosition'); + this._initialMousePosition = null; + this.set('offset', 0); + this.sendAction('drop', offset + this.get('extra') + this.get('position'), this.get('isUp') ? -1 : 1); + } + }, + + _onMouseUpJQ(event) { + let that = event.data; + run.scheduleOnce('afterRender', null, () => that._onMouseUp.call(that, event)); + }, + + fromBottom: Ember.computed(function() { + let rowQ = this.get('ltRow').$(); + let contentQ = this.$().parents('.scrollable-content'); + let containerQ = this.$().parents('.lt-scrollable'); + return contentQ.offset().top + containerQ.height() - rowQ.offset().top; + }).volatile().readOnly(), + + fromTop: Ember.computed(function() { + let rowQ = this.get('ltRow').$(); + let contentQ = this.$().parents('.scrollable-content'); + return rowQ.offset().top + rowQ.height() - contentQ.offset().top; + }).volatile().readOnly(), + + _onResize: null, + + _attachResizeEventListener: on('didInsertElement', function() { + this._onResize = () => this.set('style', this.get('__style')); + window.addEventListener('resize', this._onResize); + }), + + _removeEventListener: on('willDestroyElement', function() { + window.removeEventListener('resize', this._onResize); + }), + + _forceStyleUpdate: on('didInsertElement', observer('isUp', 'extra', function() { + run.once(this, this.__forceStyleUpdate); + })), + + __forceStyleUpdate() { + this.set('style', this.get('__style')); + }, + + __style: Ember.computed(function() { + if (this.get('ltBody')) { + let isUp = this.get('isUp'); + let y = (isUp ? this.get('fromBottom') : this.get('fromTop')); + let side = isUp ? 'bottom' : 'top'; + let translation = this.get('offset') + this.get('extra'); + return htmlSafe(`${side}: ${y}px; transform: translateY(${translation}px);`); + } + }).volatile().readOnly(), + + _style: null, + + style: Ember.computed('isUp', 'offset', 'extra', 'rowIndex', { + get() { + let style = this.get('_style'); + if (style) { + this.set('_style', null); + return style; + } else { + if (this.get('ltBody')) { + return this.get('__style'); + } else { + return null; + } + } + }, + set(key, value) { + this.set('_style', value); + return value; + } + }) + +}); diff --git a/addon/styles/addon.css b/addon/styles/addon.css index eeacbf6a..192fc0d8 100644 --- a/addon/styles/addon.css +++ b/addon/styles/addon.css @@ -12,7 +12,8 @@ .ember-light-table table { table-layout: fixed; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; width: 100%; } diff --git a/addon/templates/components/lt-body.hbs b/addon/templates/components/lt-body.hbs index c8407420..9eed503d 100644 --- a/addon/templates/components/lt-body.hbs +++ b/addon/templates/components/lt-body.hbs @@ -35,7 +35,8 @@ rowTouchEnd=(action 'onRowTouchEnd') rowTouchCancel=(action 'onRowTouchCancel') rowTouchLeave=(action 'onRowTouchLeave') - rowTouchMove=(action 'onRowTouchMove')}} + rowTouchMove=(action 'onRowTouchMove') + }} {{yield (hash expanded-row=(component lt.spanned-row classes='lt-expanded-row' colspan=colspan yield=row visible=row.expanded) loader=(component lt.spanned-row visible=false) @@ -56,6 +57,13 @@ {{lt.infinity rows=rows onScrolledToBottom=onScrolledToBottom scrollBuffer=scrollBuffer}} {{/if}} +
+ {{#each scrollableDecorations as |decoration|}} + {{component decoration.component namedArgs=decoration.namedArgs}} + {{/each}} +
+
+ {{/lt-scrollable}} {{/with}} diff --git a/addon/templates/components/lt-row-range.hbs b/addon/templates/components/lt-row-range.hbs new file mode 100644 index 00000000..89f79566 --- /dev/null +++ b/addon/templates/components/lt-row-range.hbs @@ -0,0 +1,19 @@ +{{lt-selection-handle + rowIndex=a + direction=directionA + inverse=inverse + drag=(action 'onDrag' 'a') + move=(action 'onMove' 'a') + drop=(action 'onDrop' 'a') +}} +{{lt-selection-handle + rowIndex=b + direction=directionB + inverse=inverse + drag=(action 'onDrag' 'b') + move=(action 'onMove' 'b') + drop=(action 'onDrop' 'b') +}} + +
+ diff --git a/addon/templates/components/lt-row.hbs b/addon/templates/components/lt-row.hbs index c2266435..83d6fcf2 100644 --- a/addon/templates/components/lt-row.hbs +++ b/addon/templates/components/lt-row.hbs @@ -1,6 +1,7 @@ -{{#each columns as |column|}} +{{#each columns as |column i|}} {{component (concat 'light-table/cells/' column.cellType) column row table=table rawValue=(get row column.valuePath) - tableActions=tableActions}} + tableActions=tableActions + }} {{/each}} diff --git a/addon/templates/components/lt-selection-handle.hbs b/addon/templates/components/lt-selection-handle.hbs new file mode 100644 index 00000000..fbd1f6df --- /dev/null +++ b/addon/templates/components/lt-selection-handle.hbs @@ -0,0 +1 @@ +
diff --git a/addon/utils/with-backing-field.js b/addon/utils/with-backing-field.js index 027d0a75..b3525526 100644 --- a/addon/utils/with-backing-field.js +++ b/addon/utils/with-backing-field.js @@ -3,7 +3,7 @@ import Ember from 'ember'; export default function withBackingField(backingField, f) { return Ember.computed(function() { if (!this[backingField]) { - this[backingField] = f(); + this[backingField] = f.call(this); } return this[backingField]; }); diff --git a/app/components/lt-row-range.js b/app/components/lt-row-range.js new file mode 100644 index 00000000..6537e944 --- /dev/null +++ b/app/components/lt-row-range.js @@ -0,0 +1 @@ +export { default } from 'ember-light-table/components/lt-row-range'; diff --git a/app/components/lt-selection-handle.js b/app/components/lt-selection-handle.js new file mode 100644 index 00000000..8ba0db4b --- /dev/null +++ b/app/components/lt-selection-handle.js @@ -0,0 +1 @@ +export { default } from 'ember-light-table/components/lt-selection-handle'; diff --git a/tests/dummy/app/styles/table.less b/tests/dummy/app/styles/table.less index 40452e2a..89791ada 100644 --- a/tests/dummy/app/styles/table.less +++ b/tests/dummy/app/styles/table.less @@ -1,11 +1,21 @@ @border-color: #DADADA; +@range-color: blue; +@range-border: 1px solid @range-color; + +@selected-background-color: lightblue; + +@handle-color: @range-color; + .ember-light-table { width: 95%; margin: 0 auto; - border-collapse: collapse; font-family: 'Open Sans', sans-serif; + .scrollable-content { + padding: 2px; + } + .lt-body-wrap { -webkit-touch-callout: none; -webkit-user-select: none; @@ -65,8 +75,8 @@ .lt-row.has-focus { outline-color: #000; outline-style: solid; - outline-width: 2px; - outline-offset: -2px; + outline-width: 1px; + outline-offset: -1px; } } @@ -74,7 +84,7 @@ height: 50px; &.is-selected { - background-color: #DEDEDE; + background-color: @selected-background-color; } &:not(.is-selected):hover { @@ -145,3 +155,51 @@ tfoot { } } } + +.ember-light-table .scrollable-decorations { + height: 0; + overflow: visible; +} + +.ember-light-table .scrollable-decorations .lt-selection-handle { + position: absolute; + width: 100%; + height: 0; + overflow: visible; + &.lt-selection-handle-up > div { + cursor: n-resize; + bottom: 0; + > div { + top: 1px; + } + } + &.lt-selection-handle-down > div { + cursor: s-resize; + top: 0; + > div { + top: -1px; + } + } + > div { + display: block; + width: 0px; + height: 10px; + position: absolute; + left: 50%; + > div { + width: 25px; + height: 100%; + position: relative; + left: -50%; + border-radius: 2px; + background-color: @handle-color; + } + } +} + +.ember-light-table .scrollable-decorations .lt-row-range-box { + pointer-events: none; + background: transparent; + position: absolute; + border: @range-border; +}