mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Fixing couple of bugs in collapsed section layout
Summary: The previous implementation for collapsing sections involved disposing of a view instance (Grid or Chart component). This caused numerous bugs with linking sections as the implementation is located in the BaseView.js. Now the view instance is kept and attached to a dom in a hidden div, so it can respond and function as a normal rendered section. It is also passed from between collapsed and main layout, when sections are dragged or moved using section's menu commands (`collapse` and `add to main page`) It also implies that the ViewLayout must still be rendered when a section is maximized (as it is responsible for the view instance), so the dom, and some logic for rendering it, had to be changed. Test Plan: New and updated Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3826
This commit is contained in:
parent
be8e13df64
commit
a9ff6b9a84
@ -351,6 +351,11 @@ export class ChartView extends Disposable {
|
|||||||
|
|
||||||
private _resizeChart() {
|
private _resizeChart() {
|
||||||
if (this.isDisposed() || !Plotly || !this._chartDom.parentNode) { return; }
|
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);
|
Plotly.Plots.resize(this._chartDom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,6 +93,11 @@ export class CursorMonitor extends Disposable {
|
|||||||
}
|
}
|
||||||
const position = this._readPosition(viewId);
|
const position = this._readPosition(viewId);
|
||||||
if (position) {
|
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.
|
// Ignore error with finding desired cell.
|
||||||
await doc.recursiveMoveToCursorPos(position, true, true);
|
await doc.recursiveMoveToCursorPos(position, true, true);
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,6 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
|||||||
import {DocPluginManager} from 'app/client/lib/DocPluginManager';
|
import {DocPluginManager} from 'app/client/lib/DocPluginManager';
|
||||||
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {allCommands} from 'app/client/components/commands';
|
|
||||||
import {createSessionObs} from 'app/client/lib/sessionObs';
|
import {createSessionObs} from 'app/client/lib/sessionObs';
|
||||||
import {setTestState} from 'app/client/lib/testState';
|
import {setTestState} from 'app/client/lib/testState';
|
||||||
import {selectFiles} from 'app/client/lib/uploads';
|
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<number>));
|
this.docModel.views.createFloatingRowModel(toKo(ko, this.activeViewId) as ko.Computed<number>));
|
||||||
|
|
||||||
// When active section is changed, clear the maximized state.
|
// 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);
|
this.maximizedSectionId.set(null);
|
||||||
// If we have layout, update it.
|
// If we have layout, update it.
|
||||||
if (!this.viewLayout?.isDisposed()) {
|
if (!this.viewLayout?.isDisposed()) {
|
||||||
@ -1058,7 +1060,10 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
// We can only open a popup for a section.
|
// We can only open a popup for a section.
|
||||||
if (!hash.sectionId) { return; }
|
if (!hash.sectionId) { return; }
|
||||||
// We might open popup either for a section in this view or some other section (like Raw Data Page).
|
// 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);
|
this.viewModel.activeSectionId(hash.sectionId);
|
||||||
// If the anchor link is valid, set the cursor.
|
// If the anchor link is valid, set the cursor.
|
||||||
if (hash.colRef && hash.rowId) {
|
if (hash.colRef && hash.rowId) {
|
||||||
@ -1069,7 +1074,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
view?.setCursorPos({ sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex });
|
view?.setCursorPos({ sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allCommands.maximizeActiveSection.run();
|
this.viewLayout?.maximized.set(hash.sectionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// We will borrow active viewModel and will trick him into believing that
|
// We will borrow active viewModel and will trick him into believing that
|
||||||
|
@ -114,14 +114,14 @@ export class LayoutBox extends Disposable implements ContentBox {
|
|||||||
this));
|
this));
|
||||||
|
|
||||||
this.isMaximized = this.autoDispose(ko.pureComputed(() => {
|
this.isMaximized = this.autoDispose(ko.pureComputed(() => {
|
||||||
const leafId = this.layout?.maximized();
|
const leafId = this.layout?.maximizedLeaf();
|
||||||
if (!leafId) { return false; }
|
if (!leafId) { return false; }
|
||||||
if (leafId === this.leafId()) { return true; }
|
if (leafId === this.leafId()) { return true; }
|
||||||
return this.childBoxes.all().some(function(child) { return child.isMaximized(); });
|
return this.childBoxes.all().some(function(child) { return child.isMaximized(); });
|
||||||
}, this));
|
}, this));
|
||||||
this.isHidden = this.autoDispose(ko.pureComputed(() => {
|
this.isHidden = this.autoDispose(ko.pureComputed(() => {
|
||||||
// If there isn't any maximized box, then no box is hidden.
|
// 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; }
|
if (!maximized) { return false; }
|
||||||
return !this.isMaximized();
|
return !this.isMaximized();
|
||||||
}, this));
|
}, this));
|
||||||
@ -150,10 +150,10 @@ export class LayoutBox extends Disposable implements ContentBox {
|
|||||||
return this.dom || (this.dom = this.autoDispose(this.buildDom()));
|
return this.dom || (this.dom = this.autoDispose(this.buildDom()));
|
||||||
}
|
}
|
||||||
public maximize() {
|
public maximize() {
|
||||||
if (this.layout.maximized.peek() !== this.leafId.peek()) {
|
if (this.layout.maximizedLeaf.peek() !== this.leafId.peek()) {
|
||||||
this.layout.maximized(this.leafId());
|
this.layout.maximizedLeaf(this.leafId());
|
||||||
} else {
|
} else {
|
||||||
this.layout.maximized(null);
|
this.layout.maximizedLeaf(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
@ -371,7 +371,7 @@ export class Layout extends Disposable {
|
|||||||
public trigger: BackboneEvents["trigger"]; // set by Backbone
|
public trigger: BackboneEvents["trigger"]; // set by Backbone
|
||||||
public stopListening: BackboneEvents["stopListening"]; // set by Backbone
|
public stopListening: BackboneEvents["stopListening"]; // set by Backbone
|
||||||
|
|
||||||
public maximized: ko.Observable<string|null>;
|
public maximizedLeaf: ko.Observable<string|null>;
|
||||||
public rootBox: ko.Observable<LayoutBox|null>;
|
public rootBox: ko.Observable<LayoutBox|null>;
|
||||||
public createLeafFunc: (id: string) => HTMLElement;
|
public createLeafFunc: (id: string) => HTMLElement;
|
||||||
public fillWindow: boolean;
|
public fillWindow: boolean;
|
||||||
@ -381,7 +381,7 @@ export class Layout extends Disposable {
|
|||||||
private _leafIdMap: Map<any, LayoutBox>|null;
|
private _leafIdMap: Map<any, LayoutBox>|null;
|
||||||
|
|
||||||
public create(boxSpec: BoxSpec, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) {
|
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.rootBox = observable(null as any);
|
||||||
this.createLeafFunc = createLeafFunc;
|
this.createLeafFunc = createLeafFunc;
|
||||||
this._leafIdMap = null;
|
this._leafIdMap = null;
|
||||||
@ -422,7 +422,7 @@ export class Layout extends Disposable {
|
|||||||
return dom('div.layout_root',
|
return dom('div.layout_root',
|
||||||
domData('layoutModel', this),
|
domData('layoutModel', this),
|
||||||
toggleClass('layout_fill_window', this.fillWindow),
|
toggleClass('layout_fill_window', this.fillWindow),
|
||||||
toggleClass('layout_box_maximized', this.maximized),
|
toggleClass('layout_box_maximized', this.maximizedLeaf),
|
||||||
scope(this.rootBox, (rootBox: LayoutBox) => {
|
scope(this.rootBox, (rootBox: LayoutBox) => {
|
||||||
return rootBox ? rootBox.getDom() : null;
|
return rootBox ? rootBox.getDom() : null;
|
||||||
})
|
})
|
||||||
|
@ -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 * as commands from 'app/client/components/commands';
|
||||||
import {ContentBox} from 'app/client/components/Layout';
|
import {ContentBox} from 'app/client/components/Layout';
|
||||||
import type {ViewLayout} from 'app/client/components/ViewLayout';
|
import type {ViewLayout} from 'app/client/components/ViewLayout';
|
||||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||||
|
import {detachNode} from 'app/client/lib/dom';
|
||||||
import {Signal} from 'app/client/lib/Signal';
|
import {Signal} from 'app/client/lib/Signal';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {TransitionWatcher} from 'app/client/ui/transitions';
|
import {TransitionWatcher} from 'app/client/ui/transitions';
|
||||||
import {theme} from 'app/client/ui2018/cssVars';
|
import {theme} from 'app/client/ui2018/cssVars';
|
||||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
import {isNonNullish} from 'app/common/gutil';
|
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';
|
import isEqual from 'lodash/isEqual';
|
||||||
|
|
||||||
const testId = makeTestId('test-layoutTray-');
|
const testId = makeTestId('test-layoutTray-');
|
||||||
@ -36,6 +39,8 @@ export class LayoutTray extends DisposableWithEvents {
|
|||||||
public layout = CollapsedLayout.create(this, this);
|
public layout = CollapsedLayout.create(this, this);
|
||||||
// Whether we are active (have a dotted border, that indicates we are ready to receive a drop)
|
// Whether we are active (have a dotted border, that indicates we are ready to receive a drop)
|
||||||
public active = Signal.create(this, false);
|
public active = Signal.create(this, false);
|
||||||
|
|
||||||
|
private _rootElement: HTMLElement;
|
||||||
constructor(public viewLayout: ViewLayout) {
|
constructor(public viewLayout: ViewLayout) {
|
||||||
super();
|
super();
|
||||||
// Create a proxy for the LayoutEditor. It will mimic the same interface as CollapsedLeaf.
|
// 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.
|
// Build layout using saved settings.
|
||||||
this.layout.buildLayout(this.viewLayout.viewModel.collapsedSections.peek());
|
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();
|
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.
|
// No one took it, so we should handle it if we are over the tray.
|
||||||
if (this.over.state.get()) {
|
if (this.over.state.get()) {
|
||||||
const leafId = value.leafId();
|
const leafId = value.leafId();
|
||||||
// Ask it to remove itself from the target.
|
|
||||||
value.removeFromLayout();
|
|
||||||
// Add it as a last element.
|
// Add it as a last element.
|
||||||
this.layout.addBox(leafId);
|
this.layout.addBox(leafId);
|
||||||
|
// Ask it to remove itself from the target.
|
||||||
|
value.removeFromLayout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clear the state, any other listener will get null.
|
// Clear the state, any other listener will get null.
|
||||||
@ -77,8 +75,15 @@ export class LayoutTray extends DisposableWithEvents {
|
|||||||
|
|
||||||
// Now wire up active state.
|
// 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.
|
// 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.
|
.flag() // Map to a boolean, and emit only when the value changes.
|
||||||
.filter(Boolean) // Only emit when it is set to true
|
.filter(Boolean) // Only emit when it is set to true
|
||||||
.pipe(this.active);
|
.pipe(this.active);
|
||||||
@ -89,7 +94,7 @@ export class LayoutTray extends DisposableWithEvents {
|
|||||||
const drag = on(externalLeaf.drag);
|
const drag = on(externalLeaf.drag);
|
||||||
if (!drag) { return false; }
|
if (!drag) { return false; }
|
||||||
const mouseEvent = on(externalLeaf.dragMove);
|
const mouseEvent = on(externalLeaf.dragMove);
|
||||||
const over = mouseEvent && mouseEvent.clientY < 48;
|
const over = mouseEvent && mouseEvent.clientY < topPoint;
|
||||||
return !!over;
|
return !!over;
|
||||||
}).flag().filter(Boolean).pipe(this.active);
|
}).flag().filter(Boolean).pipe(this.active);
|
||||||
|
|
||||||
@ -97,8 +102,48 @@ export class LayoutTray extends DisposableWithEvents {
|
|||||||
this.drag.flag().filter(d => !d).pipe(this.active);
|
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<number|null>, close: () => void) {
|
||||||
|
const section = Observable.create<number|null>(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() {
|
public buildDom() {
|
||||||
return cssCollapsedTray(
|
return this._rootElement = cssCollapsedTray(
|
||||||
testId('editor'),
|
testId('editor'),
|
||||||
// When drag is active we should show a dotted border around the tray.
|
// When drag is active we should show a dotted border around the tray.
|
||||||
cssCollapsedTray.cls('-is-active', this.active.state),
|
cssCollapsedTray.cls('-is-active', this.active.state),
|
||||||
@ -112,7 +157,7 @@ export class LayoutTray extends DisposableWithEvents {
|
|||||||
this.layout.buildDom(),
|
this.layout.buildDom(),
|
||||||
// But show only if there are any sections in the tray (even if those are empty or drop target sections)
|
// 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.
|
// 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() {
|
private _registerCommands() {
|
||||||
const viewLayout = this.viewLayout;
|
const viewLayout = this.viewLayout;
|
||||||
// Add custom commands for options in the menu.
|
// 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]
|
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);
|
this.layout.addBox(leafId);
|
||||||
|
|
||||||
// Remove it from the main layout.
|
// 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).
|
// Get the section that is collapsed and clicked (we are setting this value).
|
||||||
const leafId = viewLayout.viewModel.activeCollapsedSectionId();
|
const leafId = viewLayout.viewModel.activeCollapsedSectionId();
|
||||||
if (!leafId) { return; }
|
if (!leafId) { return; }
|
||||||
this.layout.removeBy(leafId);
|
|
||||||
viewLayout.viewModel.activeCollapsedSectionId(0);
|
viewLayout.viewModel.activeCollapsedSectionId(0);
|
||||||
|
viewLayout.viewModel.activeCollapsedSections(
|
||||||
|
viewLayout.viewModel.activeCollapsedSections.peek().filter(x => x !== leafId)
|
||||||
|
);
|
||||||
viewLayout.viewModel.activeSectionId(leafId);
|
viewLayout.viewModel.activeSectionId(leafId);
|
||||||
viewLayout.saveLayoutSpec();
|
viewLayout.saveLayoutSpec();
|
||||||
},
|
},
|
||||||
@ -202,10 +251,10 @@ class CollapsedDropZone extends Disposable {
|
|||||||
|
|
||||||
this.autoDispose(model.active.distinct().listen(ok => {
|
this.autoDispose(model.active.distinct().listen(ok => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
pushedLeaf = EmptyLeaf.create(layout.boxes, this.model);
|
pushedLeaf = EmptyLeaf.create(null, this.model);
|
||||||
layout.boxes.push(pushedLeaf);
|
layout.addBox(pushedLeaf);
|
||||||
} else if (pushedLeaf) {
|
} else if (pushedLeaf) {
|
||||||
layout.remove(pushedLeaf);
|
layout.destroy(pushedLeaf);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -273,7 +322,7 @@ class CollapsedDropZone extends Disposable {
|
|||||||
return this._animation.get() > 0;
|
return this._animation.get() > 0;
|
||||||
}
|
}
|
||||||
private _calculate(parentRect: DOMRect) {
|
private _calculate(parentRect: DOMRect) {
|
||||||
const boxes = this.model.layout.boxes.get();
|
const boxes = this.model.layout.all();
|
||||||
const rects: Array<VRect|null> = [];
|
const rects: Array<VRect|null> = [];
|
||||||
// Boxes can be wrapped, we will detect the line offset.
|
// Boxes can be wrapped, we will detect the line offset.
|
||||||
let lineOffset = 12;
|
let lineOffset = 12;
|
||||||
@ -356,64 +405,95 @@ class CollapsedDropZone extends Disposable {
|
|||||||
* UI component that renders and owns all the collapsed leaves.
|
* UI component that renders and owns all the collapsed leaves.
|
||||||
*/
|
*/
|
||||||
class CollapsedLayout extends Disposable {
|
class CollapsedLayout extends Disposable {
|
||||||
public boxes = this.autoDispose(obsArray<Leaf>());
|
|
||||||
public rootElement: HTMLElement;
|
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<number>;
|
||||||
|
|
||||||
|
private _boxes = this.autoDispose(obsArray<Leaf>());
|
||||||
|
|
||||||
constructor(protected model: LayoutTray) {
|
constructor(protected model: LayoutTray) {
|
||||||
super();
|
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[]) {
|
public buildLayout(leafs: number[]) {
|
||||||
if (isEqual(leafs, this.boxes.get().map((box) => box.id.get()))) { return; }
|
if (isEqual(leafs, this._boxes.get().map((box) => box.id.get()))) { return []; }
|
||||||
this.boxes.splice(0, this.boxes.get().length,
|
const removed = this._boxes.splice(0, this._boxes.get().length,
|
||||||
...leafs.map((id) => CollapsedLeaf.create(this.boxes, this.model, id)));
|
...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;
|
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);
|
return this.insert(index, box);
|
||||||
}
|
}
|
||||||
|
|
||||||
public indexOf(box: Leaf) {
|
public indexOf(box: Leaf) {
|
||||||
return this.boxes.get().indexOf(box);
|
return this._boxes.get().indexOf(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
public insert(index: number, leaf: Leaf) {
|
public insert(index: number, leaf: Leaf) {
|
||||||
|
this.holder.autoDispose(leaf);
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
this.boxes.push(leaf);
|
this._boxes.push(leaf);
|
||||||
} else {
|
} else {
|
||||||
this.boxes.splice(index, 0, leaf);
|
this._boxes.splice(index, 0, leaf);
|
||||||
}
|
}
|
||||||
return 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) {
|
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;
|
* Removes and dispose the leaf from the list.
|
||||||
removeFromObsArray(this.boxes, (l, i) => i === index);
|
*/
|
||||||
}
|
public destroy(leaf: Leaf) {
|
||||||
|
this.remove(leaf)?.dispose();
|
||||||
public removeBy(id: number) {
|
|
||||||
removeFromObsArray(this.boxes, box => box.id.get() === id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public leafIds() {
|
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() {
|
public buildDom() {
|
||||||
return (this.rootElement = cssLayout(
|
return (this.rootElement = cssLayout(
|
||||||
testId('layout'),
|
testId('layout'),
|
||||||
useDragging(),
|
useDragging(),
|
||||||
dom.hide(use => use(this.boxes).length === 0),
|
dom.hide(use => use(this._boxes).length === 0),
|
||||||
dom.forEach(this.boxes, line => line.buildDom())
|
dom.forEach(this._boxes, line => line.buildDom())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -466,8 +546,8 @@ class EmptyLeaf extends Leaf {
|
|||||||
// Replace the empty leaf with the dropped box.
|
// Replace the empty leaf with the dropped box.
|
||||||
const myIndex = this.model.layout.indexOf(this);
|
const myIndex = this.model.layout.indexOf(this);
|
||||||
const leafId = box.leafId();
|
const leafId = box.leafId();
|
||||||
box.removeFromLayout();
|
|
||||||
this.model.layout.addBox(leafId, myIndex);
|
this.model.layout.addBox(leafId, myIndex);
|
||||||
|
box.removeFromLayout();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -515,7 +595,7 @@ class TargetLeaf extends EmptyLeaf {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const watcher = new TransitionWatcher(this.rootElement);
|
const watcher = new TransitionWatcher(this.rootElement);
|
||||||
watcher.onDispose(() => {
|
watcher.onDispose(() => {
|
||||||
this.model.layout.remove(this);
|
this.model.layout.destroy(this);
|
||||||
resolve(undefined);
|
resolve(undefined);
|
||||||
});
|
});
|
||||||
this.rootElement.style.width = '0px';
|
this.rootElement.style.width = '0px';
|
||||||
@ -531,15 +611,39 @@ class CollapsedLeaf extends Leaf implements Draggable, Dropped {
|
|||||||
// content changes or put it in the floater.
|
// content changes or put it in the floater.
|
||||||
private _content: Observable<HTMLElement|null> = Observable.create(this, null);
|
private _content: Observable<HTMLElement|null> = Observable.create(this, null);
|
||||||
|
|
||||||
|
// Computed to get the view instance from the viewSection.
|
||||||
|
private _viewInstance: Computed<BaseView|null>;
|
||||||
|
|
||||||
|
// 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<HTMLElement|null> = Observable.create(this, null);
|
||||||
|
|
||||||
// Helper to keeping track of the index of the leaf in the layout.
|
// Helper to keeping track of the index of the leaf in the layout.
|
||||||
private _indexWhenDragged = 0;
|
private _indexWhenDragged = 0;
|
||||||
constructor(protected model: LayoutTray, id: number) {
|
constructor(protected model: LayoutTray, id: number) {
|
||||||
super();
|
super();
|
||||||
this.id.set(id);
|
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() {
|
public buildDom() {
|
||||||
this._content.set(this.model.buildContentDom(this.id.get()));
|
this._content.set(this.model.buildContentDom(this.id.get()));
|
||||||
return (this.rootElement = cssBox(
|
return this.rootElement = cssBox(
|
||||||
testId('leaf-box'),
|
testId('leaf-box'),
|
||||||
dom.domComputed(this._content, c => c),
|
dom.domComputed(this._content, c => c),
|
||||||
// Add draggable interface.
|
// Add draggable interface.
|
||||||
@ -565,8 +669,9 @@ class CollapsedLeaf extends Leaf implements Draggable, Dropped {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
})
|
}),
|
||||||
));
|
detachedNode(this._hiddenViewInstance),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement the drag interface. All those methods are called by the draggable helper.
|
// 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());
|
const clone = CollapsedLeaf.create(floater, this.model, this.id.get());
|
||||||
clone._indexWhenDragged = this.model.layout.indexOf(this);
|
clone._indexWhenDragged = this.model.layout.indexOf(this);
|
||||||
this.model.drag.emit(clone);
|
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;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -607,7 +714,7 @@ class CollapsedLeaf extends Leaf implements Draggable, Dropped {
|
|||||||
public removeFromLayout() {
|
public removeFromLayout() {
|
||||||
// Set the id to 0 so that the layout doesn't try to read me back.
|
// Set the id to 0 so that the layout doesn't try to read me back.
|
||||||
this.id.set(0);
|
this.id.set(0);
|
||||||
this.model.layout.remove(this);
|
this.model.layout.destroy(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public leafId() {
|
public leafId() {
|
||||||
@ -743,13 +850,14 @@ class ExternalLeaf extends Disposable implements Dropped {
|
|||||||
const part = dropTargeter.activeTarget;
|
const part = dropTargeter.activeTarget;
|
||||||
dropTargeter.removeTargetHints();
|
dropTargeter.removeTargetHints();
|
||||||
const leaf = dropped.leafId();
|
const leaf = dropped.leafId();
|
||||||
dropped.removeFromLayout();
|
|
||||||
const box = externalEditor.layout.buildLayoutBox({leaf});
|
const box = externalEditor.layout.buildLayoutBox({leaf});
|
||||||
|
dropped.removeFromLayout();
|
||||||
if (part.isChild) {
|
if (part.isChild) {
|
||||||
part.box.addChild(box, part.isAfter);
|
part.box.addChild(box, part.isAfter);
|
||||||
} else {
|
} else {
|
||||||
part.box.addSibling(box, part.isAfter);
|
part.box.addSibling(box, part.isAfter);
|
||||||
}
|
}
|
||||||
|
this.model.viewLayout.viewModel.activeSectionId(leaf);
|
||||||
this.model.drop.state.set(null);
|
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
|
* Dropped interface implementation, it is called only when a section in the main area is collapsed (dragged
|
||||||
* what is happening, we are only called when a section in the main area is collapsed (dragged onto the valid target
|
* onto the valid target in the tray).
|
||||||
* in the tray).
|
|
||||||
*/
|
*/
|
||||||
public removeFromLayout() {
|
public removeFromLayout() {
|
||||||
this._collapseSection();
|
|
||||||
}
|
|
||||||
|
|
||||||
public leafId() {
|
|
||||||
return this._drop.state.get()?.leafId.peek() || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _collapseSection() {
|
|
||||||
const droppedBox = this._drop.state.get();
|
const droppedBox = this._drop.state.get();
|
||||||
if (!droppedBox) { return; }
|
if (!droppedBox) { return; }
|
||||||
const leafId = this.leafId();
|
const leafId = this.leafId();
|
||||||
const otherSection = this.model.viewLayout.layoutEditor
|
const otherSection = this.model.viewLayout.layoutEditor
|
||||||
.layout.getAllLeafIds().find(x => typeof x === 'number' && x !== leafId);
|
.layout.getAllLeafIds().find(x => typeof x === 'number' && x !== leafId);
|
||||||
this.model.viewLayout.viewModel.activeSectionId(otherSection);
|
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);
|
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.
|
* 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<T extends IDisposable>(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) {
|
function syncHover(obs: Signal) {
|
||||||
return [dom.on('mouseenter', () => obs.emit(true)), dom.on('mouseleave', () => obs.emit(false))];
|
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<HTMLElement|null>) {
|
||||||
|
return [
|
||||||
|
dom.maybe(node, n => n),
|
||||||
|
dom.onDispose(() => node.get() && detachNode(node.get()))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds element that is marked as draggable from the mouse event.
|
* Finds element that is marked as draggable from the mouse event.
|
||||||
*/
|
*/
|
||||||
@ -876,6 +1027,8 @@ function useDragging() {
|
|||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let dragged: Draggable|null = null;
|
let dragged: Draggable|null = null;
|
||||||
let floater: MiniFloater|null = null;
|
let floater: MiniFloater|null = null;
|
||||||
|
let downX: number|null = null;
|
||||||
|
let downY: number|null = null;
|
||||||
const listener = (ev: MouseEvent) => {
|
const listener = (ev: MouseEvent) => {
|
||||||
switch (ev.type) {
|
switch (ev.type) {
|
||||||
case 'mousedown':
|
case 'mousedown':
|
||||||
@ -895,6 +1048,8 @@ function useDragging() {
|
|||||||
justStarted = true;
|
justStarted = true;
|
||||||
G.$(G.window).on('mousemove', mouseMoveListener);
|
G.$(G.window).on('mousemove', mouseMoveListener);
|
||||||
G.$(G.window).on('mouseup', mouseUpListener);
|
G.$(G.window).on('mouseup', mouseUpListener);
|
||||||
|
downX = ev.clientX;
|
||||||
|
downY = ev.clientY;
|
||||||
return false;
|
return false;
|
||||||
case 'mouseup':
|
case 'mouseup':
|
||||||
if (!dragged) {
|
if (!dragged) {
|
||||||
@ -918,19 +1073,21 @@ function useDragging() {
|
|||||||
floater = null;
|
floater = null;
|
||||||
return false;
|
return false;
|
||||||
case 'mousemove':
|
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) {
|
if (justStarted) {
|
||||||
justStarted = false;
|
const slightMove = downX && downY &&
|
||||||
if (dragged?.dragStart) {
|
(Math.abs(ev.clientX - downX) > 3 || Math.abs(ev.clientY - downY) > 3);
|
||||||
// Drag element has an opportunity to return a new draggable object.
|
if (slightMove) {
|
||||||
dragged = dragged.dragStart(ev as DragEvent, floater!);
|
justStarted = false;
|
||||||
if (!dragged) {
|
if (dragged?.dragStart) {
|
||||||
return;
|
// 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) {
|
if (!isDragging) {
|
||||||
return;
|
return;
|
||||||
@ -949,13 +1106,6 @@ function useDragging() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFromObsArray<T>(boxes: MutableObsArray<T>, 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.
|
* A virtual rectangle that is relative to a DOMRect.
|
||||||
*/
|
*/
|
||||||
@ -1078,3 +1228,5 @@ const cssVirtualPart = styled('div', `
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssHidden = styled('div', `display: none;`);
|
||||||
|
@ -22,7 +22,7 @@ import * as ko from 'knockout';
|
|||||||
import * as _ from 'underscore';
|
import * as _ from 'underscore';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import {Computed, computedArray, Disposable, dom, fromKo, Holder,
|
import {Computed, computedArray, Disposable, dom, fromKo, Holder,
|
||||||
IDomComponent, Observable, styled, subscribe} from 'grainjs';
|
IDomComponent, MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||||
|
|
||||||
// tslint:disable:no-console
|
// tslint:disable:no-console
|
||||||
|
|
||||||
@ -49,23 +49,8 @@ export class ViewSectionHelper extends Disposable {
|
|||||||
constructor(gristDoc: GristDoc, vs: ViewSectionRec) {
|
constructor(gristDoc: GristDoc, vs: ViewSectionRec) {
|
||||||
super();
|
super();
|
||||||
this.onDispose(() => vs.viewInstance(null));
|
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) => {
|
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.
|
// Rebuild the section when its type changes or its underlying table.
|
||||||
const table = use(vs.table);
|
const table = use(vs.table);
|
||||||
const Cons = getInstanceConstructor(use(vs.parentKey));
|
const Cons = getInstanceConstructor(use(vs.parentKey));
|
||||||
@ -86,6 +71,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
public viewModel: ViewRec;
|
public viewModel: ViewRec;
|
||||||
public layoutSpec: ko.Computed<BoxSpec>;
|
public layoutSpec: ko.Computed<BoxSpec>;
|
||||||
public maximized: Observable<number|null>;
|
public maximized: Observable<number|null>;
|
||||||
|
public previousSectionId = 0; // Used to restore focus after a maximized section is closed.
|
||||||
public isResizing = Observable.create(this, false);
|
public isResizing = Observable.create(this, false);
|
||||||
public layout: Layout;
|
public layout: Layout;
|
||||||
public layoutEditor: LayoutEditor;
|
public layoutEditor: LayoutEditor;
|
||||||
@ -199,26 +185,49 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
};
|
};
|
||||||
this.autoDispose(commands.createGroup(commandGroup, this, true));
|
this.autoDispose(commands.createGroup(commandGroup, this, true));
|
||||||
|
|
||||||
this.maximized = fromKo(this.layout.maximized) as any;
|
this.maximized = fromKo(this.layout.maximizedLeaf) as any;
|
||||||
this.autoDispose(this.maximized.addListener(val => {
|
this.autoDispose(this.maximized.addListener((sectionId, prev) => {
|
||||||
const section = this.viewModel.activeSection.peek();
|
// If we are closing popup, resize all sections.
|
||||||
// If section is not disposed and it is not a deleted row.
|
if (!sectionId) {
|
||||||
if (!section.isDisposed() && section.id.peek()) {
|
this._onResize();
|
||||||
section?.viewInstance.peek()?.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() {
|
public buildDom() {
|
||||||
|
const owner = MultiHolder.create(null);
|
||||||
const close = () => this.maximized.set(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(
|
return cssOverlay(
|
||||||
|
dom.autoDispose(owner),
|
||||||
cssOverlay.cls('-active', use => !!use(this.maximized)),
|
cssOverlay.cls('-active', use => !!use(this.maximized)),
|
||||||
testId('viewLayout-overlay'),
|
testId('viewLayout-overlay'),
|
||||||
cssLayoutContainer(
|
cssVFull(
|
||||||
this.layoutTray.buildDom(),
|
this.layoutTray.buildDom(),
|
||||||
cssLayoutWrapper(
|
cssLayoutWrapper(
|
||||||
cssLayoutWrapper.cls('-active', use => !!use(this.maximized)),
|
cssLayoutWrapper.cls('-active', use => Boolean(use(this.maximized))),
|
||||||
this.layout.rootElem,
|
dom.update(
|
||||||
|
this.layout.rootElem,
|
||||||
|
dom.hide(use => Boolean(use(miniBoxInPopup))),
|
||||||
|
),
|
||||||
|
this.layoutTray.buildPopup(owner, miniBoxInPopup, close),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
dom.maybe(use => !!use(this.maximized), () =>
|
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
|
// Removes a view section from the current view. Should only be called if there is
|
||||||
// more than one viewsection in the view.
|
// more than one viewsection in the view.
|
||||||
public removeViewSection(viewSectionRowId: number) {
|
public removeViewSection(viewSectionRowId: number) {
|
||||||
|
this.maximized.set(null);
|
||||||
this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
|
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.layout.buildLayout(layoutSpec, true);
|
||||||
this._onResize();
|
this._onResize();
|
||||||
|
// Dispose the old layout. This will dispose the view instances that were not reused.
|
||||||
|
oldTray.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _maximizeActiveSection() {
|
private _maximizeActiveSection() {
|
||||||
@ -479,7 +496,7 @@ const cssCloseButton = styled(icon, `
|
|||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssLayoutContainer = styled('div', `
|
const cssVFull = styled('div', `
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -29,13 +29,13 @@ export function buildCollapsedSectionDom(options: {
|
|||||||
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
|
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
|
||||||
const typeComputed = Computed.create(null, use => getWidgetTypes(use(vs.parentKey) as any).icon);
|
const typeComputed = Computed.create(null, use => getWidgetTypes(use(vs.parentKey) as any).icon);
|
||||||
return cssMiniSection(
|
return cssMiniSection(
|
||||||
testId(`minilayout-section-${sectionRowId}`),
|
testId(`collapsed-section-${sectionRowId}`),
|
||||||
testId(`minilayout-section`),
|
testId(`collapsed-section`),
|
||||||
cssDragHandle(
|
cssDragHandle(
|
||||||
dom.domComputed(typeComputed, (type) => icon(type)),
|
dom.domComputed(typeComputed, (type) => icon(type)),
|
||||||
dom('div', {style: 'margin-right: 16px;'}),
|
dom('div', {style: 'margin-right: 16px;'}),
|
||||||
dom.maybe((use) => use(use(vs.table).summarySourceTable), () => cssSigmaIcon('Pivot', testId('sigma'))),
|
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),
|
dom.text(vs.titleDef),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -102,7 +102,7 @@ export function buildViewSectionDom(options: {
|
|||||||
dom('div.viewsection_truncated', 'Not all data is shown')
|
dom('div.viewsection_truncated', 'Not all data is shown')
|
||||||
),
|
),
|
||||||
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
|
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
|
||||||
viewInstance.viewPane,
|
viewInstance.viewPane
|
||||||
),
|
),
|
||||||
dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()),
|
dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()),
|
||||||
]),
|
]),
|
||||||
|
@ -109,6 +109,7 @@ export class Signal<T = any> implements IDisposable, IDisposableOwner {
|
|||||||
*/
|
*/
|
||||||
public pipe(signal: Signal<T>) {
|
public pipe(signal: Signal<T>) {
|
||||||
this.autoDispose(this.listen(value => signal.emit(value)));
|
this.autoDispose(this.listen(value => signal.emit(value)));
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -128,7 +128,16 @@ class PageRecWrapper implements ISearchablePageRec {
|
|||||||
|
|
||||||
}
|
}
|
||||||
public viewSections(): ViewSectionRec[] {
|
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() {
|
public activeSectionId() {
|
||||||
|
@ -38,18 +38,6 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void {
|
|||||||
|
|
||||||
this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);
|
this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);
|
||||||
|
|
||||||
// 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.activeSection = refRecord(docModel.viewSections, this.activeSectionId);
|
|
||||||
|
|
||||||
this.activeCollapsedSectionId = ko.observable(0);
|
this.activeCollapsedSectionId = ko.observable(0);
|
||||||
|
|
||||||
this.collapsedSections = this.autoDispose(ko.pureComputed(() => {
|
this.collapsedSections = this.autoDispose(ko.pureComputed(() => {
|
||||||
@ -59,6 +47,25 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void {
|
|||||||
}));
|
}));
|
||||||
this.activeCollapsedSections = ko.observable(this.collapsedSections.peek());
|
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.
|
||||||
|
// `!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);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// If the active section is removed, set the next active section to be the default.
|
// If the active section is removed, set the next active section to be the default.
|
||||||
this._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted()));
|
this._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted()));
|
||||||
this.autoDispose(this._isActiveSectionGone.subscribe(gone => {
|
this.autoDispose(this._isActiveSectionGone.subscribe(gone => {
|
||||||
|
@ -103,8 +103,8 @@ export interface IColsSelect<T = WebElement> {
|
|||||||
* TODO It would be nice if mocha-webdriver allowed exact string match in findContent() (it now
|
* 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).
|
* supports a substring match, but we still need a helper for an exact match).
|
||||||
*/
|
*/
|
||||||
export function exactMatch(value: string): RegExp {
|
export function exactMatch(value: string, flags?: string): RegExp {
|
||||||
return new RegExp(`^${escapeRegExp(value)}$`);
|
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.
|
* Click into a section without disrupting cursor positions.
|
||||||
*/
|
*/
|
||||||
export async function selectSectionByTitle(title: string) {
|
export async function selectSectionByTitle(title: string|RegExp) {
|
||||||
try {
|
try {
|
||||||
|
if (typeof title === 'string') {
|
||||||
|
title = new RegExp("^" + escapeRegExp(title) + "$", 'i');
|
||||||
|
}
|
||||||
// .test-viewsection is a special 1px width element added for tests only.
|
// .test-viewsection is a special 1px width element added for tests only.
|
||||||
await driver.findContent(`.test-viewsection-title`, title).find(".test-viewsection-blank").click();
|
await driver.findContent(`.test-viewsection-title`, title).find(".test-viewsection-blank").click();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -408,6 +411,13 @@ export function getDetailCell(colOrOptions: string|ICellSelect, rowNum?: number,
|
|||||||
return new WebElementPromise(driver, getVisibleDetailCells(options).then((elems) => elems[0]));
|
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.
|
* 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();
|
await waitForServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns "Data from table" setting value of a reference column.
|
* Returns "Data from table" setting value of a reference column.
|
||||||
*/
|
*/
|
||||||
@ -2562,6 +2574,19 @@ export async function setRefTable(table: string) {
|
|||||||
await waitForServer();
|
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.
|
// Add column to sort.
|
||||||
export async function addColumnToSort(colName: RegExp|string) {
|
export async function addColumnToSort(colName: RegExp|string) {
|
||||||
await driver.find(".test-sort-config-add").click();
|
await driver.find(".test-sort-config-add").click();
|
||||||
|
Loading…
Reference in New Issue
Block a user