diff --git a/app/client/components/ChartView.ts b/app/client/components/ChartView.ts index 95856c79..a0aa1e00 100644 --- a/app/client/components/ChartView.ts +++ b/app/client/components/ChartView.ts @@ -351,6 +351,11 @@ export class ChartView extends Disposable { private _resizeChart() { if (this.isDisposed() || !Plotly || !this._chartDom.parentNode) { return; } + // Check if the chart is visible before resizing. If it's not visible, Plotly will throw an error. + const display = window.getComputedStyle(this._chartDom).display; + if (!display || display === 'none') { + return; + } Plotly.Plots.resize(this._chartDom); } diff --git a/app/client/components/CursorMonitor.ts b/app/client/components/CursorMonitor.ts index 0d83aa5d..2590ee40 100644 --- a/app/client/components/CursorMonitor.ts +++ b/app/client/components/CursorMonitor.ts @@ -93,6 +93,11 @@ export class CursorMonitor extends Disposable { } const position = this._readPosition(viewId); if (position) { + // Don't restore position if this is a collapsed section. + const collapsed = doc.viewModel.activeCollapsedSections.peek(); + if (position.sectionId && collapsed.includes(position.sectionId)) { + return; + } // Ignore error with finding desired cell. await doc.recursiveMoveToCursorPos(position, true, true); } diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index f0b2ed56..24424bec 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -25,7 +25,6 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {DocPluginManager} from 'app/client/lib/DocPluginManager'; import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; import {makeT} from 'app/client/lib/localization'; -import {allCommands} from 'app/client/components/commands'; import {createSessionObs} from 'app/client/lib/sessionObs'; import {setTestState} from 'app/client/lib/testState'; import {selectFiles} from 'app/client/lib/uploads'; @@ -243,7 +242,10 @@ export class GristDoc extends DisposableWithEvents { this.docModel.views.createFloatingRowModel(toKo(ko, this.activeViewId) as ko.Computed)); // When active section is changed, clear the maximized state. - this.autoDispose(this.viewModel.activeSectionId.subscribe(() => { + this.autoDispose(this.viewModel.activeSectionId.subscribe((id) => { + if (id === this.maximizedSectionId.get()) { + return; + } this.maximizedSectionId.set(null); // If we have layout, update it. if (!this.viewLayout?.isDisposed()) { @@ -1058,7 +1060,10 @@ export class GristDoc extends DisposableWithEvents { // We can only open a popup for a section. if (!hash.sectionId) { return; } // We might open popup either for a section in this view or some other section (like Raw Data Page). - if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId && !s.isCollapsed.peek())) { + if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) { + if (this.viewLayout) { + this.viewLayout.previousSectionId = this.viewModel.activeSectionId.peek(); + } this.viewModel.activeSectionId(hash.sectionId); // If the anchor link is valid, set the cursor. if (hash.colRef && hash.rowId) { @@ -1069,7 +1074,7 @@ export class GristDoc extends DisposableWithEvents { view?.setCursorPos({ sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex }); } } - allCommands.maximizeActiveSection.run(); + this.viewLayout?.maximized.set(hash.sectionId); return; } // We will borrow active viewModel and will trick him into believing that diff --git a/app/client/components/Layout.ts b/app/client/components/Layout.ts index 20f5da79..de881ce4 100644 --- a/app/client/components/Layout.ts +++ b/app/client/components/Layout.ts @@ -114,14 +114,14 @@ export class LayoutBox extends Disposable implements ContentBox { this)); this.isMaximized = this.autoDispose(ko.pureComputed(() => { - const leafId = this.layout?.maximized(); + const leafId = this.layout?.maximizedLeaf(); if (!leafId) { return false; } if (leafId === this.leafId()) { return true; } return this.childBoxes.all().some(function(child) { return child.isMaximized(); }); }, this)); this.isHidden = this.autoDispose(ko.pureComputed(() => { // If there isn't any maximized box, then no box is hidden. - const maximized = this.layout?.maximized(); + const maximized = this.layout?.maximizedLeaf(); if (!maximized) { return false; } return !this.isMaximized(); }, this)); @@ -150,10 +150,10 @@ export class LayoutBox extends Disposable implements ContentBox { return this.dom || (this.dom = this.autoDispose(this.buildDom())); } public maximize() { - if (this.layout.maximized.peek() !== this.leafId.peek()) { - this.layout.maximized(this.leafId()); + if (this.layout.maximizedLeaf.peek() !== this.leafId.peek()) { + this.layout.maximizedLeaf(this.leafId()); } else { - this.layout.maximized(null); + this.layout.maximizedLeaf(null); } } public buildDom() { @@ -371,7 +371,7 @@ export class Layout extends Disposable { public trigger: BackboneEvents["trigger"]; // set by Backbone public stopListening: BackboneEvents["stopListening"]; // set by Backbone - public maximized: ko.Observable; + public maximizedLeaf: ko.Observable; public rootBox: ko.Observable; public createLeafFunc: (id: string) => HTMLElement; public fillWindow: boolean; @@ -381,7 +381,7 @@ export class Layout extends Disposable { private _leafIdMap: Map|null; public create(boxSpec: BoxSpec, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) { - this.maximized = observable(null as (string|null)); + this.maximizedLeaf = observable(null as (string|null)); this.rootBox = observable(null as any); this.createLeafFunc = createLeafFunc; this._leafIdMap = null; @@ -422,7 +422,7 @@ export class Layout extends Disposable { return dom('div.layout_root', domData('layoutModel', this), toggleClass('layout_fill_window', this.fillWindow), - toggleClass('layout_box_maximized', this.maximized), + toggleClass('layout_box_maximized', this.maximizedLeaf), scope(this.rootBox, (rootBox: LayoutBox) => { return rootBox ? rootBox.getDom() : null; }) diff --git a/app/client/components/LayoutTray.ts b/app/client/components/LayoutTray.ts index 3c27b97d..508bc19b 100644 --- a/app/client/components/LayoutTray.ts +++ b/app/client/components/LayoutTray.ts @@ -1,15 +1,18 @@ -import {buildCollapsedSectionDom} from 'app/client/components/buildViewSectionDom'; +import BaseView from 'app/client/components/BaseView'; +import {buildCollapsedSectionDom, buildViewSectionDom} from 'app/client/components/buildViewSectionDom'; import * as commands from 'app/client/components/commands'; import {ContentBox} from 'app/client/components/Layout'; import type {ViewLayout} from 'app/client/components/ViewLayout'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {detachNode} from 'app/client/lib/dom'; import {Signal} from 'app/client/lib/Signal'; import {urlState} from 'app/client/models/gristUrlState'; import {TransitionWatcher} from 'app/client/ui/transitions'; import {theme} from 'app/client/ui2018/cssVars'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {isNonNullish} from 'app/common/gutil'; -import {Disposable, dom, makeTestId, MutableObsArray, obsArray, Observable, styled} from 'grainjs'; +import {Computed, Disposable, dom, IDisposable, IDisposableOwner, + makeTestId, obsArray, Observable, styled} from 'grainjs'; import isEqual from 'lodash/isEqual'; const testId = makeTestId('test-layoutTray-'); @@ -36,6 +39,8 @@ export class LayoutTray extends DisposableWithEvents { public layout = CollapsedLayout.create(this, this); // Whether we are active (have a dotted border, that indicates we are ready to receive a drop) public active = Signal.create(this, false); + + private _rootElement: HTMLElement; constructor(public viewLayout: ViewLayout) { super(); // Create a proxy for the LayoutEditor. It will mimic the same interface as CollapsedLeaf. @@ -44,14 +49,7 @@ export class LayoutTray extends DisposableWithEvents { // Build layout using saved settings. this.layout.buildLayout(this.viewLayout.viewModel.collapsedSections.peek()); - // Whenever we add or remove box, update the model. - this.layout.boxes.addListener(l => this.viewLayout.viewModel.activeCollapsedSections(this.layout.leafIds())); - // Whenever saved settings are changed, rebuild the layout using them. - this.autoDispose(this.viewLayout.viewModel.collapsedSections.subscribe((val) => { - this.viewLayout.viewModel.activeCollapsedSections(val); - this.layout.buildLayout(val); - })); this._registerCommands(); @@ -65,10 +63,10 @@ export class LayoutTray extends DisposableWithEvents { // No one took it, so we should handle it if we are over the tray. if (this.over.state.get()) { const leafId = value.leafId(); - // Ask it to remove itself from the target. - value.removeFromLayout(); // Add it as a last element. this.layout.addBox(leafId); + // Ask it to remove itself from the target. + value.removeFromLayout(); } } // Clear the state, any other listener will get null. @@ -77,8 +75,15 @@ export class LayoutTray extends DisposableWithEvents { // Now wire up active state. + // When a drag is started, get the top point of the tray, over which we will activate. + let topPoint = 48; // By default it is 48 pixels. + this.autoDispose(externalLeaf.drag.listen(d => { + if (!d) { return; } + topPoint = (this._rootElement.parentElement?.getBoundingClientRect().top ?? 61) - 13; + })); + // First we can be activated when a drag has started and we have some boxes. - this.drag.map(drag => drag && this.layout.boxes.get().length > 0) + this.drag.map(drag => drag && this.layout.count.get() > 0) .flag() // Map to a boolean, and emit only when the value changes. .filter(Boolean) // Only emit when it is set to true .pipe(this.active); @@ -89,7 +94,7 @@ export class LayoutTray extends DisposableWithEvents { const drag = on(externalLeaf.drag); if (!drag) { return false; } const mouseEvent = on(externalLeaf.dragMove); - const over = mouseEvent && mouseEvent.clientY < 48; + const over = mouseEvent && mouseEvent.clientY < topPoint; return !!over; }).flag().filter(Boolean).pipe(this.active); @@ -97,8 +102,48 @@ export class LayoutTray extends DisposableWithEvents { this.drag.flag().filter(d => !d).pipe(this.active); } + public replaceLayout() { + const savedSections = this.viewLayout.viewModel.collapsedSections.peek(); + this.viewLayout.viewModel.activeCollapsedSections(savedSections); + const boxes = this.layout.buildLayout(savedSections); + return { + dispose() { + boxes.forEach(box => box.dispose()); + boxes.length = 0; + } + }; + } + + /** + * Builds a popup for a maximized section. + */ + public buildPopup(owner: IDisposableOwner, selected: Observable, close: () => void) { + const section = Observable.create(owner, null); + owner.autoDispose(selected.addListener((cur, prev) => { + if (prev && !cur) { + const vs = this.viewLayout.gristDoc.docModel.viewSections.getRowModel(prev); + const vi = vs.viewInstance.peek(); + if (vi) { + detachNode(vi.viewPane); + } + } + section.set(cur); + })); + return dom.domComputed(section, (id) => { + if (!id) { return null; } + return dom.update( + buildViewSectionDom({ + gristDoc: this.viewLayout.gristDoc, + sectionRowId: id, + draggable: false, + focusable: false, + }) + ); + }); + } + public buildDom() { - return cssCollapsedTray( + return this._rootElement = cssCollapsedTray( testId('editor'), // When drag is active we should show a dotted border around the tray. cssCollapsedTray.cls('-is-active', this.active.state), @@ -112,7 +157,7 @@ export class LayoutTray extends DisposableWithEvents { this.layout.buildDom(), // But show only if there are any sections in the tray (even if those are empty or drop target sections) // or we can accept a drop. - dom.show(use => use(this.layout.boxes).length > 0 || use(this.active.state)), + dom.show(use => use(this.layout.count) > 0 || use(this.active.state)), ); } @@ -123,6 +168,8 @@ export class LayoutTray extends DisposableWithEvents { }); } + + private _registerCommands() { const viewLayout = this.viewLayout; // Add custom commands for options in the menu. @@ -143,7 +190,7 @@ export class LayoutTray extends DisposableWithEvents { viewLayout.layoutEditor.layout.getAllLeafIds().filter(x => x !== leafId)[0] ); - // Add the box to our collapsed editor. + // Add the box to our collapsed editor (it will transfer the viewInstance). this.layout.addBox(leafId); // Remove it from the main layout. @@ -156,8 +203,10 @@ export class LayoutTray extends DisposableWithEvents { // Get the section that is collapsed and clicked (we are setting this value). const leafId = viewLayout.viewModel.activeCollapsedSectionId(); if (!leafId) { return; } - this.layout.removeBy(leafId); viewLayout.viewModel.activeCollapsedSectionId(0); + viewLayout.viewModel.activeCollapsedSections( + viewLayout.viewModel.activeCollapsedSections.peek().filter(x => x !== leafId) + ); viewLayout.viewModel.activeSectionId(leafId); viewLayout.saveLayoutSpec(); }, @@ -202,10 +251,10 @@ class CollapsedDropZone extends Disposable { this.autoDispose(model.active.distinct().listen(ok => { if (ok) { - pushedLeaf = EmptyLeaf.create(layout.boxes, this.model); - layout.boxes.push(pushedLeaf); + pushedLeaf = EmptyLeaf.create(null, this.model); + layout.addBox(pushedLeaf); } else if (pushedLeaf) { - layout.remove(pushedLeaf); + layout.destroy(pushedLeaf); } })); } @@ -273,7 +322,7 @@ class CollapsedDropZone extends Disposable { return this._animation.get() > 0; } private _calculate(parentRect: DOMRect) { - const boxes = this.model.layout.boxes.get(); + const boxes = this.model.layout.all(); const rects: Array = []; // Boxes can be wrapped, we will detect the line offset. let lineOffset = 12; @@ -356,64 +405,95 @@ class CollapsedDropZone extends Disposable { * UI component that renders and owns all the collapsed leaves. */ class CollapsedLayout extends Disposable { - public boxes = this.autoDispose(obsArray()); public rootElement: HTMLElement; + /** + * Leaves owner. Adding or removing leaves will not dispose them automatically, as they are released and + * return to the caller. Only those leaves that were not removed will be disposed with the layout. + */ + public holder = ArrayHolder.create(this); + /** + * Number of leaves in the layout. + */ + public count: Computed; + + private _boxes = this.autoDispose(obsArray()); constructor(protected model: LayoutTray) { super(); + + // Whenever we add or remove box, update the model. This is used to test if the section is collapsed or not. + this._boxes.addListener(l => model.viewLayout.viewModel.activeCollapsedSections(this.leafIds())); + + this.count = Computed.create(this, use => use(this._boxes).length); + } + + public all() { + return this._boxes.get(); } public buildLayout(leafs: number[]) { - if (isEqual(leafs, this.boxes.get().map((box) => box.id.get()))) { return; } - this.boxes.splice(0, this.boxes.get().length, - ...leafs.map((id) => CollapsedLeaf.create(this.boxes, this.model, id))); + if (isEqual(leafs, this._boxes.get().map((box) => box.id.get()))) { return []; } + const removed = this._boxes.splice(0, this._boxes.get().length, + ...leafs.map((id) => CollapsedLeaf.create(this.holder, this.model, id))); + removed.forEach((box) => this.holder.release(box)); + return removed; } - public addBox(id: number, index?: number) { + public addBox(id: number|Leaf, index?: number) { index ??= -1; - const box = CollapsedLeaf.create(this.boxes, this.model, id); + const box = typeof id === 'number' ? CollapsedLeaf.create(this.holder, this.model, id): id; + if (typeof id !== 'number') { + this.holder.autoDispose(box); + } return this.insert(index, box); } public indexOf(box: Leaf) { - return this.boxes.get().indexOf(box); + return this._boxes.get().indexOf(box); } public insert(index: number, leaf: Leaf) { + this.holder.autoDispose(leaf); if (index < 0) { - this.boxes.push(leaf); + this._boxes.push(leaf); } else { - this.boxes.splice(index, 0, leaf); + this._boxes.splice(index, 0, leaf); } return leaf; } - public remove(leaf: Leaf | undefined) { - const index = this.boxes.get().indexOf(leaf!); + /** + * Removes the leaf from the list but doesn't dispose it. + */ + public remove(leaf: Leaf) { + const index = this._boxes.get().indexOf(leaf); if (index >= 0) { - this.boxes.splice(index, 1); + const removed = this._boxes.splice(index, 1)[0]; + if (removed) { + this.holder.release(removed); + } + return removed || null; } + return null; } - public removeAt(index: number) { - index = index < 0 ? this.boxes.get().length - 1 : index; - removeFromObsArray(this.boxes, (l, i) => i === index); - } - - public removeBy(id: number) { - removeFromObsArray(this.boxes, box => box.id.get() === id); + /** + * Removes and dispose the leaf from the list. + */ + public destroy(leaf: Leaf) { + this.remove(leaf)?.dispose(); } public leafIds() { - return this.boxes.get().map(l => l.id.get()).filter(x => x && typeof x === 'number'); + return this._boxes.get().map(l => l.id.get()).filter(x => x && typeof x === 'number'); } public buildDom() { return (this.rootElement = cssLayout( testId('layout'), useDragging(), - dom.hide(use => use(this.boxes).length === 0), - dom.forEach(this.boxes, line => line.buildDom()) + dom.hide(use => use(this._boxes).length === 0), + dom.forEach(this._boxes, line => line.buildDom()) )); } } @@ -466,8 +546,8 @@ class EmptyLeaf extends Leaf { // Replace the empty leaf with the dropped box. const myIndex = this.model.layout.indexOf(this); const leafId = box.leafId(); - box.removeFromLayout(); this.model.layout.addBox(leafId, myIndex); + box.removeFromLayout(); }) ); } @@ -515,7 +595,7 @@ class TargetLeaf extends EmptyLeaf { return new Promise((resolve) => { const watcher = new TransitionWatcher(this.rootElement); watcher.onDispose(() => { - this.model.layout.remove(this); + this.model.layout.destroy(this); resolve(undefined); }); this.rootElement.style.width = '0px'; @@ -531,15 +611,39 @@ class CollapsedLeaf extends Leaf implements Draggable, Dropped { // content changes or put it in the floater. private _content: Observable = Observable.create(this, null); + // Computed to get the view instance from the viewSection. + private _viewInstance: Computed; + + // An observable for the dom that holds the viewInstance and displays it in a hidden element. + // This is owned by this leaf and is disposed separately from the dom that is returned by buildDom. Like a + // singleton, this element will be moved from one "instance" (a result of buildDom) to another. + // When a leaf is removed from the dom (e.g. when we remove the collapsed section or move it to the main area) + // the dom of this element is disposed, but the hidden element stays with this instance and can be disposed + // later on, giving anyone a chance to grab the viewInstance and display it somewhere else. + private _hiddenViewInstance: Observable = Observable.create(this, null); + // Helper to keeping track of the index of the leaf in the layout. private _indexWhenDragged = 0; constructor(protected model: LayoutTray, id: number) { super(); this.id.set(id); + this._viewInstance = Computed.create(this, use => { + const sections = use(use(this.model.viewLayout.viewModel.viewSections).getObservable()); + const view = sections.find(s => use(s.id) === use(this.id)); + if (!view) { return null; } + const instance = use(view.viewInstance); + return instance; + }); + this._hiddenViewInstance.set(cssHidden(dom.maybe(this._viewInstance, view => view.viewPane))); + this.onDispose(() => { + const instance = this._hiddenViewInstance.get(); + instance && dom.domDispose(instance); + }); } + public buildDom() { this._content.set(this.model.buildContentDom(this.id.get())); - return (this.rootElement = cssBox( + return this.rootElement = cssBox( testId('leaf-box'), dom.domComputed(this._content, c => c), // Add draggable interface. @@ -565,8 +669,9 @@ class CollapsedLeaf extends Leaf implements Draggable, Dropped { }).catch(() => {}); e.preventDefault(); e.stopPropagation(); - }) - )); + }), + detachedNode(this._hiddenViewInstance), + ); } // Implement the drag interface. All those methods are called by the draggable helper. @@ -580,7 +685,9 @@ class CollapsedLeaf extends Leaf implements Draggable, Dropped { const clone = CollapsedLeaf.create(floater, this.model, this.id.get()); clone._indexWhenDragged = this.model.layout.indexOf(this); this.model.drag.emit(clone); - this.model.layout.remove(this); + + // Remove self from the layout (it will dispose this instance, but the viewInstance was moved to the floater) + this.model.layout.destroy(this); return clone; } @@ -607,7 +714,7 @@ class CollapsedLeaf extends Leaf implements Draggable, Dropped { public removeFromLayout() { // Set the id to 0 so that the layout doesn't try to read me back. this.id.set(0); - this.model.layout.remove(this); + this.model.layout.destroy(this); } public leafId() { @@ -743,13 +850,14 @@ class ExternalLeaf extends Disposable implements Dropped { const part = dropTargeter.activeTarget; dropTargeter.removeTargetHints(); const leaf = dropped.leafId(); - dropped.removeFromLayout(); const box = externalEditor.layout.buildLayoutBox({leaf}); + dropped.removeFromLayout(); if (part.isChild) { part.box.addChild(box, part.isAfter); } else { part.box.addSibling(box, part.isAfter); } + this.model.viewLayout.viewModel.activeSectionId(leaf); this.model.drop.state.set(null); } }) @@ -758,28 +866,25 @@ class ExternalLeaf extends Disposable implements Dropped { } /** - * Dropped interface implementation. It calls _collapseSection to make it obvious that this is - * what is happening, we are only called when a section in the main area is collapsed (dragged onto the valid target - * in the tray). + * Dropped interface implementation, it is called only when a section in the main area is collapsed (dragged + * onto the valid target in the tray). */ public removeFromLayout() { - this._collapseSection(); - } - - public leafId() { - return this._drop.state.get()?.leafId.peek() || 0; - } - - private _collapseSection() { const droppedBox = this._drop.state.get(); if (!droppedBox) { return; } const leafId = this.leafId(); const otherSection = this.model.viewLayout.layoutEditor .layout.getAllLeafIds().find(x => typeof x === 'number' && x !== leafId); this.model.viewLayout.viewModel.activeSectionId(otherSection); + // We can safely remove the box, because we should be called after viewInstance is grabbed by + // the tray. this.model.viewLayout.layoutEditor.doRemoveBox(droppedBox); } + public leafId() { + return this._drop.state.get()?.leafId.peek() || 0; + } + /** * Monitors the external floater element, and if it is on top of the collapsed tray, replaces its content. */ @@ -837,10 +942,56 @@ class ExternalLeaf extends Disposable implements Dropped { } } +/** + * A class that holds an array of IDisposable objects, and disposes them all when it is disposed. + * The difference from a MultipleHolder is that it can release individual disposables from the array. + */ +class ArrayHolder extends Disposable { + private _array: IDisposable[] = []; + + constructor() { + super(); + this.onDispose(() => { + const seen = new Set(); + for (const obj of this._array) { + if (!seen.has(obj)) { + seen.add(obj); + obj.dispose(); + } + } + this._array = []; + }); + } + + public autoDispose(obj: T): T { + this._array.push(obj); + return obj; + } + + public release(obj: IDisposable) { + const index = this._array.indexOf(obj); + if (index >= 0) { + return this._array.splice(index, 1); + } + return null; + } +} + function syncHover(obs: Signal) { return [dom.on('mouseenter', () => obs.emit(true)), dom.on('mouseleave', () => obs.emit(false))]; } +/** + * Helper function that renders an element from an observable, but prevents it from being disposed. + * Used to keep viewInstance from being disposed when it is added as a child in various containers. + */ +function detachedNode(node: Observable) { + return [ + dom.maybe(node, n => n), + dom.onDispose(() => node.get() && detachNode(node.get())) + ]; +} + /** * Finds element that is marked as draggable from the mouse event. */ @@ -876,6 +1027,8 @@ function useDragging() { let isDragging = false; let dragged: Draggable|null = null; let floater: MiniFloater|null = null; + let downX: number|null = null; + let downY: number|null = null; const listener = (ev: MouseEvent) => { switch (ev.type) { case 'mousedown': @@ -895,6 +1048,8 @@ function useDragging() { justStarted = true; G.$(G.window).on('mousemove', mouseMoveListener); G.$(G.window).on('mouseup', mouseUpListener); + downX = ev.clientX; + downY = ev.clientY; return false; case 'mouseup': if (!dragged) { @@ -918,19 +1073,21 @@ function useDragging() { floater = null; return false; case 'mousemove': - // We start the drag with a first mousemove event. We don't want to start dragging if the mouse - // hasn't moved. if (justStarted) { - justStarted = false; - if (dragged?.dragStart) { - // Drag element has an opportunity to return a new draggable object. - dragged = dragged.dragStart(ev as DragEvent, floater!); - if (!dragged) { - return; + const slightMove = downX && downY && + (Math.abs(ev.clientX - downX) > 3 || Math.abs(ev.clientY - downY) > 3); + if (slightMove) { + justStarted = false; + if (dragged?.dragStart) { + // Drag element has an opportunity to return a new draggable object. + dragged = dragged.dragStart(ev as DragEvent, floater!); + if (!dragged) { + return; + } } + // Now we are dragging. + isDragging = true; } - // Now we are dragging. - isDragging = true; } if (!isDragging) { return; @@ -949,13 +1106,6 @@ function useDragging() { }; } -function removeFromObsArray(boxes: MutableObsArray, selector: (box: T, index: number) => boolean) { - const index = boxes.get().findIndex(selector); - if (index !== -1) { - boxes.splice(index, 1); - } -} - /** * A virtual rectangle that is relative to a DOMRect. */ @@ -1078,3 +1228,5 @@ const cssVirtualPart = styled('div', ` z-index: 10; background: rgba(0, 0, 0, 0.1); `); + +const cssHidden = styled('div', `display: none;`); diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 0b251815..e2c322e9 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -22,7 +22,7 @@ import * as ko from 'knockout'; import * as _ from 'underscore'; import debounce from 'lodash/debounce'; import {Computed, computedArray, Disposable, dom, fromKo, Holder, - IDomComponent, Observable, styled, subscribe} from 'grainjs'; + IDomComponent, MultiHolder, Observable, styled, subscribe} from 'grainjs'; // tslint:disable:no-console @@ -49,23 +49,8 @@ export class ViewSectionHelper extends Disposable { constructor(gristDoc: GristDoc, vs: ViewSectionRec) { super(); this.onDispose(() => vs.viewInstance(null)); - // If this is a collapsed section (but not active), don't create an instance (or remove the old one). - // Collapsed section can be expanded and shown in the popup window, it will be active then. - // This is important to avoid recreating the instance when the section is collapsed, but mainly for the - // charts as they are not able to handle being detached from the dom. - const hidden = Computed.create(this, (use) => { - // Note: this is a separate computed from the one below (with subscribe method), because we don't want - // trigger it unnecessarily. - return use(vs.isCollapsed) && use(gristDoc.externalSectionId) !== use(vs.id); - }); this.autoDispose(subscribe((use) => { - // Destroy the instance if the section is hidden. - if (use(hidden)) { - this._instance.clear(); - vs.viewInstance(null); - return; - } // Rebuild the section when its type changes or its underlying table. const table = use(vs.table); const Cons = getInstanceConstructor(use(vs.parentKey)); @@ -86,6 +71,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { public viewModel: ViewRec; public layoutSpec: ko.Computed; public maximized: Observable; + public previousSectionId = 0; // Used to restore focus after a maximized section is closed. public isResizing = Observable.create(this, false); public layout: Layout; public layoutEditor: LayoutEditor; @@ -199,26 +185,49 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { }; this.autoDispose(commands.createGroup(commandGroup, this, true)); - this.maximized = fromKo(this.layout.maximized) as any; - this.autoDispose(this.maximized.addListener(val => { - const section = this.viewModel.activeSection.peek(); - // If section is not disposed and it is not a deleted row. - if (!section.isDisposed() && section.id.peek()) { - section?.viewInstance.peek()?.onResize(); + this.maximized = fromKo(this.layout.maximizedLeaf) as any; + this.autoDispose(this.maximized.addListener((sectionId, prev) => { + // If we are closing popup, resize all sections. + if (!sectionId) { + this._onResize(); + // Reset active section to the first one if the section is popup is collapsed. + if (prev + && this.viewModel.activeCollapsedSections.peek().includes(prev) + && this.previousSectionId) { + // Make sure that previous section exists still. + if (this.viewModel.viewSections.peek().all() + .some(s => !s.isDisposed() && s.id.peek() === this.previousSectionId)) { + this.viewModel.activeSectionId(this.previousSectionId); + } + } + } else { + // Otherwise resize only active one (the one in popup). + const section = this.viewModel.activeSection.peek(); + if (!section.isDisposed() && section.id.peek()) { + section?.viewInstance.peek()?.onResize(); + } } })); } public buildDom() { + const owner = MultiHolder.create(null); const close = () => this.maximized.set(null); + const mainBoxInPopup = Computed.create(owner, use => this.layout.getAllLeafIds().includes(use(this.maximized))); + const miniBoxInPopup = Computed.create(owner, use => use(mainBoxInPopup) ? null : use(this.maximized)); return cssOverlay( + dom.autoDispose(owner), cssOverlay.cls('-active', use => !!use(this.maximized)), testId('viewLayout-overlay'), - cssLayoutContainer( + cssVFull( this.layoutTray.buildDom(), cssLayoutWrapper( - cssLayoutWrapper.cls('-active', use => !!use(this.maximized)), - this.layout.rootElem, + cssLayoutWrapper.cls('-active', use => Boolean(use(this.maximized))), + dom.update( + this.layout.rootElem, + dom.hide(use => Boolean(use(miniBoxInPopup))), + ), + this.layoutTray.buildPopup(owner, miniBoxInPopup, close), ), ), dom.maybe(use => !!use(this.maximized), () => @@ -265,12 +274,20 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { // Removes a view section from the current view. Should only be called if there is // more than one viewsection in the view. public removeViewSection(viewSectionRowId: number) { + this.maximized.set(null); this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError); } - public rebuildLayout(layoutSpec: object) { + public rebuildLayout(layoutSpec: BoxSpec) { + // Rebuild the collapsed section layout. In return we will get all leaves that were + // removed from collapsed dom. Some of them will hold a view instance dom. + const oldTray = this.layoutTray.replaceLayout(); + // Build the normal layout. While building, some leaves will grab the view instance dom + // and attach it to their dom (and detach them from the old layout in the process). this.layout.buildLayout(layoutSpec, true); this._onResize(); + // Dispose the old layout. This will dispose the view instances that were not reused. + oldTray.dispose(); } private _maximizeActiveSection() { @@ -479,7 +496,7 @@ const cssCloseButton = styled(icon, ` } `); -const cssLayoutContainer = styled('div', ` +const cssVFull = styled('div', ` height: 100%; display: flex; flex-direction: column; diff --git a/app/client/components/buildViewSectionDom.ts b/app/client/components/buildViewSectionDom.ts index 23b77dbc..77bbb9bb 100644 --- a/app/client/components/buildViewSectionDom.ts +++ b/app/client/components/buildViewSectionDom.ts @@ -29,13 +29,13 @@ export function buildCollapsedSectionDom(options: { const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId); const typeComputed = Computed.create(null, use => getWidgetTypes(use(vs.parentKey) as any).icon); return cssMiniSection( - testId(`minilayout-section-${sectionRowId}`), - testId(`minilayout-section`), + testId(`collapsed-section-${sectionRowId}`), + testId(`collapsed-section`), cssDragHandle( dom.domComputed(typeComputed, (type) => icon(type)), dom('div', {style: 'margin-right: 16px;'}), dom.maybe((use) => use(use(vs.table).summarySourceTable), () => cssSigmaIcon('Pivot', testId('sigma'))), - dom('span.viewsection_title_font', testId('viewsection-title'), + dom('span.viewsection_title_font', testId('collapsed-section-title'), dom.text(vs.titleDef), ), ), @@ -102,7 +102,7 @@ export function buildViewSectionDom(options: { dom('div.viewsection_truncated', 'Not all data is shown') ), dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)), - viewInstance.viewPane, + viewInstance.viewPane ), dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()), ]), diff --git a/app/client/lib/Signal.ts b/app/client/lib/Signal.ts index c43f8a6e..f450fcee 100644 --- a/app/client/lib/Signal.ts +++ b/app/client/lib/Signal.ts @@ -109,6 +109,7 @@ export class Signal implements IDisposable, IDisposableOwner { */ public pipe(signal: Signal) { this.autoDispose(this.listen(value => signal.emit(value))); + return this; } /** diff --git a/app/client/models/SearchModel.ts b/app/client/models/SearchModel.ts index ef10ae4f..a5163313 100644 --- a/app/client/models/SearchModel.ts +++ b/app/client/models/SearchModel.ts @@ -128,7 +128,16 @@ class PageRecWrapper implements ISearchablePageRec { } public viewSections(): ViewSectionRec[] { - return this._page.view.peek().viewSections.peek().peek(); + const sections = this._page.view.peek().viewSections.peek().peek(); + const collapsed = new Set(this._page.view.peek().activeCollapsedSections.peek()); + const activeSectionId = this._page.view.peek().activeSectionId.peek(); + // If active section is collapsed, it means it is rendered in the popup, so narrow + // down the search to only it. + const inPopup = collapsed.has(activeSectionId); + if (inPopup) { + return sections.filter((s) => s.getRowId() === activeSectionId); + } + return sections.filter((s) => !collapsed.has(s.getRowId())); } public activeSectionId() { diff --git a/app/client/models/entities/ViewRec.ts b/app/client/models/entities/ViewRec.ts index cd650857..b4f48d9c 100644 --- a/app/client/models/entities/ViewRec.ts +++ b/app/client/models/entities/ViewRec.ts @@ -38,26 +38,33 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void { this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec); + this.activeCollapsedSectionId = ko.observable(0); + + this.collapsedSections = this.autoDispose(ko.pureComputed(() => { + const allSections = new Set(this.viewSections().all().map(x => x.id())); + const collapsed: number[] = (this.layoutSpecObj().collapsed || []).map(x => x.leaf as number); + return collapsed.filter(x => allSections.has(x)); + })); + this.activeCollapsedSections = ko.observable(this.collapsedSections.peek()); + // An observable for the ref of the section last selected by the user. this.activeSectionId = koUtil.observableWithDefault(ko.observable(), () => { // The default function which is used when the conditional case is true. // Read may occur for recently disposed sections, must check condition first. - return !this.isDisposed() && - // `!this.getRowId()` implies that this is an empty (non-existent) view record - // which happens when viewing the raw data tables, in which case the default is no active view section. - this.getRowId() && this.viewSections().all().length > 0 ? this.viewSections().at(0)!.getRowId() : 0; + // `!this.getRowId()` implies that this is an empty (non-existent) view record + // which happens when viewing the raw data tables, in which case the default is no active view section. + + if (this.isDisposed() || !this.getRowId()) { return 0; } + const all = this.viewSections().all(); + const collapsed = new Set(this.activeCollapsedSections()); + const visible = all.filter(x => !collapsed.has(x.id())); + + return visible.length > 0 ? visible[0].getRowId() : 0; }); this.activeSection = refRecord(docModel.viewSections, this.activeSectionId); - this.activeCollapsedSectionId = ko.observable(0); - this.collapsedSections = this.autoDispose(ko.pureComputed(() => { - const allSections = new Set(this.viewSections().all().map(x => x.id())); - const collapsed: number[] = (this.layoutSpecObj().collapsed || []).map(x => x.leaf as number); - return collapsed.filter(x => allSections.has(x)); - })); - this.activeCollapsedSections = ko.observable(this.collapsedSections.peek()); // If the active section is removed, set the next active section to be the default. this._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted())); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index f0e884d6..e6366bd1 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -103,8 +103,8 @@ export interface IColsSelect { * TODO It would be nice if mocha-webdriver allowed exact string match in findContent() (it now * supports a substring match, but we still need a helper for an exact match). */ -export function exactMatch(value: string): RegExp { - return new RegExp(`^${escapeRegExp(value)}$`); +export function exactMatch(value: string, flags?: string): RegExp { + return new RegExp(`^${escapeRegExp(value)}$`, flags); } /** @@ -230,8 +230,11 @@ export function getSection(sectionOrTitle: string|WebElement): WebElement|WebEle /** * Click into a section without disrupting cursor positions. */ -export async function selectSectionByTitle(title: string) { +export async function selectSectionByTitle(title: string|RegExp) { try { + if (typeof title === 'string') { + title = new RegExp("^" + escapeRegExp(title) + "$", 'i'); + } // .test-viewsection is a special 1px width element added for tests only. await driver.findContent(`.test-viewsection-title`, title).find(".test-viewsection-blank").click(); } catch (e) { @@ -408,6 +411,13 @@ export function getDetailCell(colOrOptions: string|ICellSelect, rowNum?: number, return new WebElementPromise(driver, getVisibleDetailCells(options).then((elems) => elems[0])); } +/** + * Gets a cell on a single card page. + */ +export function getCardCell(col: string, section?: string) { + return getDetailCell({col, rowNum: 1, section}); +} + /** * Returns the cell containing the cursor in the active section, works for both Grid and Detail. @@ -2546,6 +2556,8 @@ export async function setRefShowColumn(col: string) { await waitForServer(); } + + /** * Returns "Data from table" setting value of a reference column. */ @@ -2562,6 +2574,19 @@ export async function setRefTable(table: string) { await waitForServer(); } +/** + * Changes "Select by" of the current section. + */ +export async function selectBy(table: string|RegExp) { + await toggleSidePanel('right', 'open'); + await driver.find('.test-right-tab-pagewidget').click(); + await driver.find('.test-config-data').click(); + await driver.find('.test-right-select-by').click(); + table = typeof table === 'string' ? exactMatch(table) : table; + await driver.findContentWait('.test-select-menu li', table, 200).click(); + await waitForServer(); +} + // Add column to sort. export async function addColumnToSort(colName: RegExp|string) { await driver.find(".test-sort-config-add").click();