mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Expanding widgets
Summary: New icon to expand an active section and show it as a popup (just like raw data views). "Show raw data" popup couldn't be reused (as it is basically a different page), so now we have two kinds of popups that look the same. 1. Raw data popup - to show an alien section on a page (a section from a different view). This is used by "Show raw data" button, it is basically a different page that shows an arbitrary section. 2. Layout popup - a popup generated by Layout.ts that basically hides every other section and adds an overlay effect to itself. Other changes - Layout.js was migrated to typescript - "Show raw data" menu item was converted to link Test Plan: new tests Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3764
This commit is contained in:
parent
ff901c06d2
commit
1dafe4bae0
@ -243,8 +243,6 @@ BaseView.commonCommands = {
|
||||
|
||||
copyLink: function() { this.copyLink().catch(reportError); },
|
||||
|
||||
showRawData: function() { this.showRawData().catch(reportError); },
|
||||
|
||||
deleteRecords: function(source) { this.deleteRecords(source); },
|
||||
|
||||
filterByThisCellValue: function() { this.filterByThisCellValue(); },
|
||||
@ -401,13 +399,6 @@ BaseView.prototype.copyLink = async function() {
|
||||
}
|
||||
};
|
||||
|
||||
BaseView.prototype.showRawData = async function() {
|
||||
const sectionId = this.schemaModel.rawViewSectionRef.peek();
|
||||
const anchorUrlState = this.getAnchorLinkForSection(sectionId);
|
||||
anchorUrlState.hash.popup = true;
|
||||
await urlState().pushUrl(anchorUrlState, {replace: true, avoidReload: true});
|
||||
}
|
||||
|
||||
BaseView.prototype.filterByThisCellValue = function() {
|
||||
const rowId = this.viewData.getRowId(this.cursor.rowIndex());
|
||||
const col = this.viewSection.viewFields().peek()[this.cursor.fieldIndex()].column();
|
||||
|
@ -25,6 +25,7 @@ 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';
|
||||
@ -116,8 +117,7 @@ export interface IExtraTool {
|
||||
label: DomContents;
|
||||
content: TabContent[]|IDomComponent;
|
||||
}
|
||||
|
||||
interface PopupOptions {
|
||||
interface RawSectionOptions {
|
||||
viewSection: ViewSectionRec;
|
||||
hash: HashLink;
|
||||
close: () => void;
|
||||
@ -166,6 +166,10 @@ export class GristDoc extends DisposableWithEvents {
|
||||
public readonly hasDocTour: Computed<boolean>;
|
||||
|
||||
public readonly behavioralPromptsManager = this.docPageModel.appModel.behavioralPromptsManager;
|
||||
// One of the section can be shown it the popup (as requested from the Layout), we will
|
||||
// store its id in this variable. When the section is removed, changed or page is changed, we will
|
||||
// hide it be informing the layout about it.
|
||||
public sectionInPopup: Observable<number|null> = Observable.create(this, null);
|
||||
|
||||
private _actionLog: ActionLog;
|
||||
private _undoStack: UndoStack;
|
||||
@ -177,8 +181,8 @@ export class GristDoc extends DisposableWithEvents {
|
||||
private _viewLayout: ViewLayout|null = null;
|
||||
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
|
||||
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
|
||||
private _popupOptions: Observable<PopupOptions|null> = Observable.create(this, null);
|
||||
private _activeContent: Computed<IDocPage|PopupOptions>;
|
||||
private _rawSectionOptions: Observable<RawSectionOptions|null> = Observable.create(this, null);
|
||||
private _activeContent: Computed<IDocPage|RawSectionOptions>;
|
||||
|
||||
|
||||
constructor(
|
||||
@ -224,9 +228,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
const viewId = this.docModel.views.tableData.findRow(docPage === 'GristDocTour' ? 'name' : 'id', docPage);
|
||||
return viewId || use(defaultViewId);
|
||||
});
|
||||
|
||||
this._activeContent = Computed.create(this, use => use(this._popupOptions) ?? use(this.activeViewId));
|
||||
|
||||
this._activeContent = Computed.create(this, use => use(this._rawSectionOptions) ?? use(this.activeViewId));
|
||||
// This viewModel reflects the currently active view, relying on the fact that
|
||||
// createFloatingRowModel() supports an observable rowId for its argument.
|
||||
// Although typings don't reflect it, createFloatingRowModel() accepts non-numeric values,
|
||||
@ -234,6 +236,15 @@ export class GristDoc extends DisposableWithEvents {
|
||||
this.viewModel = this.autoDispose(
|
||||
this.docModel.views.createFloatingRowModel(toKo(ko, this.activeViewId) as ko.Computed<number>));
|
||||
|
||||
// When active section is changed, clear the maximized state.
|
||||
this.autoDispose(this.viewModel.activeSectionId.subscribe(() => {
|
||||
this.sectionInPopup.set(null);
|
||||
// If we have layout, update it.
|
||||
if (!this._viewLayout?.isDisposed()) {
|
||||
this._viewLayout?.maximized.set(null);
|
||||
}
|
||||
}));
|
||||
|
||||
// Grainjs observable reflecting the name of the current document page.
|
||||
this.currentPageName = Computed.create(this, this.activeViewId,
|
||||
(use, docPage) => typeof docPage === 'number' ? use(this.viewModel.name) : docPage);
|
||||
@ -433,23 +444,33 @@ export class GristDoc extends DisposableWithEvents {
|
||||
* Builds the DOM for this GristDoc.
|
||||
*/
|
||||
public buildDom() {
|
||||
const isMaximized = Computed.create(this, use => use(this.sectionInPopup) !== null);
|
||||
const isPopup = Computed.create(this, use => {
|
||||
return use(this.activeViewId) === 'data' // On Raw data page
|
||||
|| use(isMaximized) // Layout has a maximized section visible
|
||||
|| typeof use(this._activeContent) === 'object'; // We are on show raw data popup
|
||||
});
|
||||
return cssViewContentPane(
|
||||
testId('gristdoc'),
|
||||
cssViewContentPane.cls("-contents", use => use(this.activeViewId) === 'data' || use(this._popupOptions) !== null),
|
||||
cssViewContentPane.cls("-contents", isPopup),
|
||||
dom.domComputed(this._activeContent, (content) => {
|
||||
return (
|
||||
content === 'code' ? dom.create(CodeEditorPanel, this) :
|
||||
content === 'acl' ? dom.create(AccessRules, this) :
|
||||
content === 'data' ? dom.create(RawDataPage, this) :
|
||||
content === 'GristDocTour' ? null :
|
||||
typeof content === 'object' ? dom.create(owner => {
|
||||
(typeof content === 'object') ? dom.create(owner => {
|
||||
// In case user changes a page, close the popup.
|
||||
owner.autoDispose(this.activeViewId.addListener(content.close));
|
||||
// In case the section is removed, close the popup.
|
||||
content.viewSection.autoDispose({dispose: content.close});
|
||||
return dom.create(RawDataPopup, this, content.viewSection, content.close);
|
||||
}) :
|
||||
dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, content)))
|
||||
dom.create((owner) => {
|
||||
this._viewLayout = ViewLayout.create(owner, this, content);
|
||||
this._viewLayout.maximized.addListener(n => this.sectionInPopup.set(n));
|
||||
return this._viewLayout;
|
||||
})
|
||||
);
|
||||
}),
|
||||
);
|
||||
@ -998,6 +1019,21 @@ export class GristDoc extends DisposableWithEvents {
|
||||
public async openPopup(hash: HashLink) {
|
||||
// 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)) {
|
||||
this.viewModel.activeSectionId(hash.sectionId);
|
||||
// If the anchor link is valid, set the cursor.
|
||||
if (hash.colRef && hash.rowId) {
|
||||
const activeSection = this.viewModel.activeSection.peek();
|
||||
const fieldIndex = activeSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef);
|
||||
if (fieldIndex >= 0) {
|
||||
const view = await this._waitForView(activeSection);
|
||||
view?.setCursorPos({ sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex });
|
||||
}
|
||||
}
|
||||
allCommands.maximizeActiveSection.run();
|
||||
return;
|
||||
}
|
||||
// We will borrow active viewModel and will trick him into believing that
|
||||
// the section from the link is his viewSection and it is active. Fortunately
|
||||
// he doesn't care. After popup is closed, we will restore the original.
|
||||
@ -1010,12 +1046,12 @@ export class GristDoc extends DisposableWithEvents {
|
||||
// which might be a diffrent view from what we currently have. If the section is
|
||||
// a raw data section it will use `EmptyRowModel` as raw sections don't have parents.
|
||||
popupSection.hasFocus(true);
|
||||
this._popupOptions.set({
|
||||
this._rawSectionOptions.set({
|
||||
hash,
|
||||
viewSection: popupSection,
|
||||
close: () => {
|
||||
// In case we are already close, do nothing.
|
||||
if (!this._popupOptions.get()) { return; }
|
||||
if (!this._rawSectionOptions.get()) { return; }
|
||||
if (popupSection !== prevSection) {
|
||||
// We need to blur raw view section. Otherwise it will automatically be opened
|
||||
// on raw data view. Note: raw data section doesn't have its own view, it uses
|
||||
@ -1028,7 +1064,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
if (!prevSection.isDisposed()) { prevSection.hasFocus(true); }
|
||||
}
|
||||
// Clearing popup data will close this popup.
|
||||
this._popupOptions.set(null);
|
||||
this._rawSectionOptions.set(null);
|
||||
}
|
||||
});
|
||||
// If the anchor link is valid, set the cursor.
|
||||
|
@ -88,3 +88,7 @@
|
||||
.layout_leaf_test_big {
|
||||
min-height: 7rem;
|
||||
}
|
||||
|
||||
.layout_hidden {
|
||||
display: none;
|
||||
}
|
||||
|
@ -1,475 +0,0 @@
|
||||
/**
|
||||
* This module provides the ability to render and edit hierarchical layouts of boxes. Each box may
|
||||
* contain a list of other boxes, and horizontally- and vertically-arranged lists alternating with
|
||||
* the depth in the hierarchy.
|
||||
*
|
||||
* Layout
|
||||
* Layout is a tree of LayoutBoxes (HBoxes and VBoxes). It consists of HBoxes and VBoxes in
|
||||
* alternating levels. The leaves of the tree are LeafBoxes, and those are the only items that
|
||||
* may be moved around, with the structure of Boxes above them changing to accommodate.
|
||||
*
|
||||
* LayoutBox
|
||||
* A LayoutBox is a node in the Layout tree. LayoutBoxes should typically have nothing visual
|
||||
* about them (e.g. no borders) except their dimensions: they serve purely for layout purposes.
|
||||
*
|
||||
* A LayoutBox may be an HBox or a VBox. An HBox may contain multiple VBoxes arranged in a row.
|
||||
* A VBox may contain multiple HBoxes one under the other. Either kind of LayoutBox may contain
|
||||
* a single LeafBox instead of child LayoutBoxes. No LayoutBox may be empty, and no LayoutBox
|
||||
* may contain a single LayoutBox as a child: it must contain either multiple LayoutBox
|
||||
* children, or a single LeafBox.
|
||||
*
|
||||
* LeafBox
|
||||
* A LeafBox is the container for user content, i.e. what needs to be laid out, for example
|
||||
* form elements. LeafBoxes are what the user can drag around to other location in the layout.
|
||||
* All the LeafBoxes in a Layout together fill the entire Layout rectangle. If some parts of
|
||||
* the layout are to be empty, they should still contain an empty LeafBox.
|
||||
*
|
||||
* There is no separate JS class for LeafBoxes, they are simply LayoutBoxes with .layout_leaf
|
||||
* class and set leafId and leafContent member observables.
|
||||
*
|
||||
* Floater
|
||||
* A Floater is a rectangle that floats over the layout with the mouse pointer while the user is
|
||||
* dragging a LeafBox. It contains the content of the LeafBox being dragged, so that the user
|
||||
* can see what is being repositioned.
|
||||
*
|
||||
* DropOverlay
|
||||
* An DropOverlay is a visual aid to the user to indicate area over the current LeafBox where a
|
||||
* drop may be attempted. It also computes the "affinity": which border of the current LeafBox
|
||||
* the user is trying to target as the insertion point.
|
||||
*
|
||||
* DropTargeter
|
||||
* DropTargeter displays a set of rectangles, each of which represents a particular allowed
|
||||
* insertion point for the element being dragged. E.g. dragging an element to the right side of
|
||||
* a LeafBox would display a drop target for each LayoutBox up the tree that allows a sibling
|
||||
* to be inserted on the right.
|
||||
*
|
||||
* Saving Changes
|
||||
* --------------
|
||||
* We don't attempt to save granular changes to the layout, for each drag operation, because
|
||||
* for the user, it's better to finish editing the layout, and only save the end result. Also,
|
||||
* it's not so easy (the structure changes many times while dragging, and a single drag
|
||||
* operation results in a non-trivial diff of the 'before' and 'after' layouts). So instead, we
|
||||
* just have a way to serialize the layout to and from a JSON blob.
|
||||
*/
|
||||
|
||||
|
||||
var ko = require('knockout');
|
||||
var assert = require('assert');
|
||||
var _ = require('underscore');
|
||||
var BackboneEvents = require('backbone').Events;
|
||||
|
||||
var dispose = require('../lib/dispose');
|
||||
var dom = require('../lib/dom');
|
||||
var kd = require('../lib/koDom');
|
||||
var koArray = require('../lib/koArray');
|
||||
|
||||
/**
|
||||
* A LayoutBox is the node in the hierarchy of boxes comprising the layout. This class is used for
|
||||
* rendering as well as for the code editor. Since it may be rendered many times on a page, it's
|
||||
* important for it to be efficient.
|
||||
* @param {Layout} layout: The Layout object that manages this LayoutBox.
|
||||
*/
|
||||
function LayoutBox(layout) {
|
||||
this.layout = layout;
|
||||
this.parentBox = ko.observable(null);
|
||||
this.childBoxes = koArray();
|
||||
this.leafId = ko.observable(null);
|
||||
this.leafContent = ko.observable(null);
|
||||
this.uniqueId = _.uniqueId("lb"); // For logging and debugging.
|
||||
|
||||
this.isVBox = this.autoDispose(ko.computed(function() {
|
||||
return this.parentBox() ? !this.parentBox().isVBox() : true;
|
||||
}, this));
|
||||
this.isHBox = this.autoDispose(ko.computed(function() { return !this.isVBox(); }, this));
|
||||
this.isLeaf = this.autoDispose(ko.computed(function() { return this.leafId() !== null; },
|
||||
this));
|
||||
|
||||
// flexSize represents flexWidth for VBoxes and flexHeight for HBoxes.
|
||||
// Undesirable transition effects are likely when <1, so we set average value
|
||||
// to 100 so that reduction below 1 is rare.
|
||||
this.flexSize = ko.observable(100);
|
||||
|
||||
this.dom = null;
|
||||
|
||||
this._parentBeingDisposed = false;
|
||||
|
||||
// This is an optimization to avoid the wasted cost of removeFromParent during disposal.
|
||||
this._parentBeingDisposed = false;
|
||||
|
||||
this.autoDisposeCallback(function() {
|
||||
if (!this._parentBeingDisposed) {
|
||||
this.removeFromParent();
|
||||
}
|
||||
this.childBoxes.peek().forEach(function(child) {
|
||||
child._parentBeingDisposed = true;
|
||||
child.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
exports.LayoutBox = LayoutBox;
|
||||
dispose.makeDisposable(LayoutBox);
|
||||
|
||||
LayoutBox.prototype.getDom = function() {
|
||||
return this.dom || (this.dom = this.autoDispose(this.buildDom()));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* This helper turns a value, observable, or function (as accepted by koDom functions) into a
|
||||
* plain value. It's used to build a static piece of DOM without subscribing to any of the
|
||||
* observables, to avoid the performance cost of subscribing/unsubscribing.
|
||||
*/
|
||||
function makeStatic(valueOrFunc) {
|
||||
if (ko.isObservable(valueOrFunc) || koArray.isKoArray(valueOrFunc)) {
|
||||
return valueOrFunc.peek();
|
||||
} else if (typeof valueOrFunc === 'function') {
|
||||
return valueOrFunc();
|
||||
} else {
|
||||
return valueOrFunc;
|
||||
}
|
||||
}
|
||||
|
||||
LayoutBox.prototype.buildDom = function() {
|
||||
var self = this;
|
||||
var wrap = this.layout.needDynamic ? _.identity : makeStatic;
|
||||
|
||||
return dom('div.layout_box',
|
||||
kd.toggleClass('layout_leaf', wrap(this.isLeaf)),
|
||||
kd.toggleClass(this.layout.leafId, wrap(this.isLeaf)),
|
||||
kd.cssClass(wrap(function() { return self.isVBox() ? "layout_vbox" : "layout_hbox"; })),
|
||||
kd.cssClass(wrap(function() { return (self.layout.fillWindow ? 'layout_fill_window' :
|
||||
(self.isLastChild() ? 'layout_last_child' : null));
|
||||
})),
|
||||
kd.style('--flex-grow', wrap(function() {
|
||||
return (self.isVBox() || (self.isHBox() && self.layout.fillWindow)) ? self.flexSize() : '';
|
||||
})),
|
||||
kd.domData('layoutBox', this),
|
||||
kd.foreach(wrap(this.childBoxes), function(layoutBox) {
|
||||
return layoutBox.getDom();
|
||||
}),
|
||||
kd.scope(wrap(this.leafContent), function(leafContent) {
|
||||
return leafContent;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves the leaf id and content from another layoutBox, unsetting them in the source one.
|
||||
*/
|
||||
LayoutBox.prototype.takeLeafFrom = function(sourceLayoutBox) {
|
||||
this.leafId(sourceLayoutBox.leafId.peek());
|
||||
// Note that we detach the node, so that the old box doesn't destroy its DOM.
|
||||
this.leafContent(dom.detachNode(sourceLayoutBox.leafContent.peek()));
|
||||
sourceLayoutBox.leafId(null);
|
||||
sourceLayoutBox.leafContent(null);
|
||||
};
|
||||
|
||||
LayoutBox.prototype.setChildren = function(children) {
|
||||
children.forEach(function(child) {
|
||||
child.parentBox(this);
|
||||
}, this);
|
||||
this.childBoxes.assign(children);
|
||||
};
|
||||
|
||||
LayoutBox.prototype.isFirstChild = function() {
|
||||
return this.parentBox() ? this.parentBox().childBoxes.peek()[0] === this : true;
|
||||
};
|
||||
|
||||
LayoutBox.prototype.isLastChild = function() {
|
||||
// Use .all() rather than .peek() because it's used in kd.toggleClass('layout_last_child'), and
|
||||
// we want it to automatically stay correct when childBoxes array changes.
|
||||
return this.parentBox() ? _.last(this.parentBox().childBoxes.all()) === this : true;
|
||||
};
|
||||
|
||||
LayoutBox.prototype.isDomDetached = function() {
|
||||
return !(this.dom && this.dom.parentNode);
|
||||
};
|
||||
|
||||
LayoutBox.prototype.getSiblingBox = function(isAfter) {
|
||||
if (!this.parentBox()) {
|
||||
return null;
|
||||
}
|
||||
var siblings = this.parentBox().childBoxes.peek();
|
||||
var index = siblings.indexOf(this);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
index += (isAfter ? 1 : -1);
|
||||
return (index < 0 || index >= siblings.length ? null : siblings[index]);
|
||||
};
|
||||
|
||||
LayoutBox.prototype._addChild = function(childBox, isAfter, optNextSibling) {
|
||||
assert(childBox.parentBox() === null, "LayoutBox._addChild: child already has parentBox set");
|
||||
var index;
|
||||
if (optNextSibling) {
|
||||
index = this.childBoxes.peek().indexOf(optNextSibling) + (isAfter ? 1 : 0);
|
||||
} else {
|
||||
index = isAfter ? this.childBoxes.peekLength : 0;
|
||||
}
|
||||
childBox.parentBox(this);
|
||||
this.childBoxes.splice(index, 0, childBox);
|
||||
};
|
||||
|
||||
|
||||
LayoutBox.prototype.addSibling = function(childBox, isAfter) {
|
||||
childBox.removeFromParent();
|
||||
var parentBox = this.parentBox();
|
||||
if (parentBox) {
|
||||
// Normally, we just add a sibling as requested.
|
||||
parentBox._addChild(childBox, isAfter, this);
|
||||
} else {
|
||||
// If adding a sibling to the root node (another VBox), we need to create a new root and push
|
||||
// things down two levels (HBox and VBox), and add the sibling to the lower VBox.
|
||||
if (this.childBoxes.peekLength === 1) {
|
||||
// Except when the root has a single child, in which case there is already a good place to
|
||||
// add the new node two levels lower. And we should not create another level because the
|
||||
// root is the only place that can have a single child.
|
||||
var lowerBox = this.childBoxes.peek()[0];
|
||||
assert(!lowerBox.isLeaf(), 'LayoutBox.addSibling: should not have leaf as a single child');
|
||||
lowerBox._addChild(childBox, isAfter);
|
||||
} else {
|
||||
// Create a new root, and add the sibling two levels lower.
|
||||
var vbox = LayoutBox.create(this.layout);
|
||||
var hbox = LayoutBox.create(this.layout);
|
||||
// We don't need removeFromParent here because this only runs when there is no parent.
|
||||
vbox._addChild(hbox, false);
|
||||
hbox._addChild(this, false);
|
||||
hbox._addChild(childBox, isAfter);
|
||||
this.layout.setRoot(vbox);
|
||||
}
|
||||
}
|
||||
this.layout.trigger('layoutChanged');
|
||||
};
|
||||
|
||||
LayoutBox.prototype.addChild = function(childBox, isAfter) {
|
||||
childBox.removeFromParent();
|
||||
if (this.isLeaf()) {
|
||||
// Move the leaf data into a new child, then add the requested childBox.
|
||||
var newBox = LayoutBox.create(this.layout);
|
||||
newBox.takeLeafFrom(this);
|
||||
this._addChild(newBox, 0);
|
||||
}
|
||||
this._addChild(childBox, isAfter);
|
||||
this.layout.trigger('layoutChanged');
|
||||
};
|
||||
|
||||
LayoutBox.prototype.toString = function() {
|
||||
return this.isDisposed() ? this.uniqueId + "[disposed]" : (this.uniqueId +
|
||||
(this.isHBox() ? "H" : "V") +
|
||||
(this.isLeaf() ? "(" + this.leafId() + ")" :
|
||||
"[" + this.childBoxes.peek().map(function(b) { return b.toString(); }).join(",") + "]")
|
||||
);
|
||||
};
|
||||
|
||||
LayoutBox.prototype._removeChildBox = function(childBox) {
|
||||
//console.log("_removeChildBox %s from %s", childBox.toString(), this.toString());
|
||||
var index = this.childBoxes.peek().indexOf(childBox);
|
||||
childBox.parentBox(null);
|
||||
if (index >= 0) {
|
||||
this.childBoxes.splice(index, 1);
|
||||
this.rescaleFlexSizes();
|
||||
}
|
||||
if (this.childBoxes.peekLength === 1) {
|
||||
// If we now have a single child, then something needs to collapse.
|
||||
var lowerBox = this.childBoxes.peek()[0];
|
||||
var parentBox = this.parentBox();
|
||||
if (lowerBox.isLeaf()) {
|
||||
// Move the leaf data into ourselves, and remove the lower box.
|
||||
this.takeLeafFrom(lowerBox);
|
||||
lowerBox.dispose();
|
||||
} else if (parentBox) {
|
||||
// Move grandchildren into our place within our parent, and collapse two levels.
|
||||
// (Unless we are the root, in which case it's OK for us to have a single non-leaf child.)
|
||||
index = parentBox.childBoxes.peek().indexOf(this);
|
||||
assert(index >= 0, 'LayoutBox._removeChildBox: box not found in parent');
|
||||
|
||||
var grandchildBoxes = lowerBox.childBoxes.peek();
|
||||
grandchildBoxes.forEach(function(box) { box.parentBox(parentBox); });
|
||||
parentBox.childBoxes.arraySplice(index, 0, grandchildBoxes);
|
||||
|
||||
lowerBox.childBoxes.splice(0, lowerBox.childBoxes.peekLength);
|
||||
this.removeFromParent();
|
||||
|
||||
lowerBox.dispose();
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to detach a box from its parent without disposing it. If you no longer plan to reattach
|
||||
* the box, you should probably call box.dispose().
|
||||
*/
|
||||
LayoutBox.prototype.removeFromParent = function() {
|
||||
if (this.parentBox()) {
|
||||
this.parentBox()._removeChildBox(this);
|
||||
this.layout.trigger('layoutChanged');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adjust flexSize values of the children so that they add up to at least 1.
|
||||
* Otherwise, Firefox will not stretch them to the full size of the container.
|
||||
*/
|
||||
LayoutBox.prototype.rescaleFlexSizes = function() {
|
||||
// Just scale so that the smallest value is 1.
|
||||
var children = this.childBoxes.peek();
|
||||
var minSize = Math.min.apply(null, children.map(function(b) { return b.flexSize(); }));
|
||||
if (minSize < 1) {
|
||||
children.forEach(function(b) {
|
||||
b.flexSize(b.flexSize() / minSize);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @event layoutChanged: Triggered on changes to the structure of the layout.
|
||||
* @event layoutResized: Triggered on non-structural changes that may affect the size of rootElem.
|
||||
*/
|
||||
function Layout(boxSpec, createLeafFunc, optFillWindow) {
|
||||
this.rootBox = ko.observable(null);
|
||||
this.createLeafFunc = createLeafFunc;
|
||||
this._leafIdMap = null;
|
||||
this.fillWindow = optFillWindow || false;
|
||||
this.needDynamic = false;
|
||||
this.rootElem = this.autoDispose(this.buildDom());
|
||||
|
||||
// Generates a unique id class so boxes can only be placed next to other boxes in this layout.
|
||||
this.leafId = _.uniqueId('layout_leaf_');
|
||||
|
||||
this.buildLayout(boxSpec || {});
|
||||
|
||||
// Invalidate the _leafIdMap when the layout is adjusted.
|
||||
this.listenTo(this, 'layoutChanged', function() { this._leafIdMap = null; });
|
||||
|
||||
this.autoDisposeCallback(function() {
|
||||
if (this.rootBox()) {
|
||||
this.rootBox().dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
exports.Layout = Layout;
|
||||
dispose.makeDisposable(Layout);
|
||||
_.extend(Layout.prototype, BackboneEvents);
|
||||
|
||||
|
||||
/**
|
||||
* Returns a LayoutBox object containing the given DOM element, or null if not found.
|
||||
*/
|
||||
Layout.prototype.getContainingBox = function(elem) {
|
||||
return Layout.getContainingBox(elem, this.rootElem);
|
||||
};
|
||||
|
||||
/**
|
||||
* You can also find the nearest containing LayoutBox without having the Layout object itself by
|
||||
* using Layout.Layout.getContainingBox. The Layout object is then accessible as box.layout.
|
||||
*/
|
||||
Layout.getContainingBox = function(elem, optContainer) {
|
||||
var boxElem = dom.findAncestor(elem, optContainer, '.layout_box');
|
||||
return boxElem ? ko.utils.domData.get(boxElem, 'layoutBox') : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds and returns the leaf layout box containing the content for the given leafId.
|
||||
*/
|
||||
Layout.prototype.getLeafBox = function(leafId) {
|
||||
return this.getLeafIdMap().get(leafId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of all leafIds present in this layout.
|
||||
*/
|
||||
Layout.prototype.getAllLeafIds = function() {
|
||||
return Array.from(this.getLeafIdMap().keys());
|
||||
};
|
||||
|
||||
Layout.prototype.setRoot = function(layoutBox) {
|
||||
this.rootBox(layoutBox);
|
||||
};
|
||||
|
||||
Layout.prototype.buildDom = function() {
|
||||
return dom('div.layout_root',
|
||||
kd.domData('layoutModel', this),
|
||||
kd.toggleClass('layout_fill_window', this.fillWindow),
|
||||
kd.scope(this.rootBox, function(rootBox) {
|
||||
return rootBox ? rootBox.getDom() : null;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls cb on each box in the layout recursively.
|
||||
*/
|
||||
Layout.prototype.forEachBox = function(cb, optContext) {
|
||||
function iter(box) {
|
||||
cb.call(optContext, box);
|
||||
box.childBoxes.peek().forEach(iter);
|
||||
}
|
||||
iter(this.rootBox.peek());
|
||||
};
|
||||
|
||||
Layout.prototype.buildLayoutBox = function(boxSpec) {
|
||||
// Note that this is hot code: it runs when rendering a layout for each record, not only for the
|
||||
// layout editor.
|
||||
var box = LayoutBox.create(this);
|
||||
if (boxSpec.size) {
|
||||
box.flexSize(boxSpec.size);
|
||||
}
|
||||
if (boxSpec.leaf) {
|
||||
box.leafId(boxSpec.leaf);
|
||||
box.leafContent(this.createLeafFunc(box.leafId()));
|
||||
} else if (boxSpec.children) {
|
||||
box.setChildren(boxSpec.children.map(this.buildLayoutBox, this));
|
||||
}
|
||||
return box;
|
||||
};
|
||||
|
||||
Layout.prototype.buildLayout = function(boxSpec, needDynamic) {
|
||||
this.needDynamic = needDynamic;
|
||||
var oldRootBox = this.rootBox();
|
||||
this.rootBox(this.buildLayoutBox(boxSpec));
|
||||
this.trigger('layoutChanged');
|
||||
if (oldRootBox) {
|
||||
oldRootBox.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
Layout.prototype._getBoxSpec = function(layoutBox) {
|
||||
var spec = {};
|
||||
if (layoutBox.isDisposed()) {
|
||||
return spec;
|
||||
}
|
||||
if (layoutBox.flexSize() && layoutBox.flexSize() !== 100) {
|
||||
spec.size = layoutBox.flexSize();
|
||||
}
|
||||
if (layoutBox.isLeaf()) {
|
||||
spec.leaf = layoutBox.leafId();
|
||||
} else {
|
||||
spec.children = layoutBox.childBoxes.peek().map(this._getBoxSpec, this);
|
||||
}
|
||||
return spec;
|
||||
};
|
||||
|
||||
Layout.prototype.getLayoutSpec = function() {
|
||||
return this._getBoxSpec(this.rootBox());
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a Map object mapping leafId to its LayoutBox. This gets invalidated on layoutAdjust
|
||||
* events, and rebuilt on next request.
|
||||
*/
|
||||
Layout.prototype.getLeafIdMap = function() {
|
||||
if (!this._leafIdMap) {
|
||||
this._leafIdMap = new Map();
|
||||
this.forEachBox(function(box) {
|
||||
var leafId = box.leafId.peek();
|
||||
if (leafId !== null) {
|
||||
this._leafIdMap.set(leafId, box);
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
return this._leafIdMap;
|
||||
};
|
494
app/client/components/Layout.ts
Normal file
494
app/client/components/Layout.ts
Normal file
@ -0,0 +1,494 @@
|
||||
/**
|
||||
* This module provides the ability to render and edit hierarchical layouts of boxes. Each box may
|
||||
* contain a list of other boxes, and horizontally- and vertically-arranged lists alternating with
|
||||
* the depth in the hierarchy.
|
||||
*
|
||||
* Layout
|
||||
* Layout is a tree of LayoutBoxes (HBoxes and VBoxes). It consists of HBoxes and VBoxes in
|
||||
* alternating levels. The leaves of the tree are LeafBoxes, and those are the only items that
|
||||
* may be moved around, with the structure of Boxes above them changing to accommodate.
|
||||
*
|
||||
* LayoutBox
|
||||
* A LayoutBox is a node in the Layout tree. LayoutBoxes should typically have nothing visual
|
||||
* about them (e.g. no borders) except their dimensions: they serve purely for layout purposes.
|
||||
*
|
||||
* A LayoutBox may be an HBox or a VBox. An HBox may contain multiple VBoxes arranged in a row.
|
||||
* A VBox may contain multiple HBoxes one under the other. Either kind of LayoutBox may contain
|
||||
* a single LeafBox instead of child LayoutBoxes. No LayoutBox may be empty, and no LayoutBox
|
||||
* may contain a single LayoutBox as a child: it must contain either multiple LayoutBox
|
||||
* children, or a single LeafBox.
|
||||
*
|
||||
* LeafBox
|
||||
* A LeafBox is the container for user content, i.e. what needs to be laid out, for example
|
||||
* form elements. LeafBoxes are what the user can drag around to other location in the layout.
|
||||
* All the LeafBoxes in a Layout together fill the entire Layout rectangle. If some parts of
|
||||
* the layout are to be empty, they should still contain an empty LeafBox.
|
||||
*
|
||||
* There is no separate JS class for LeafBoxes, they are simply LayoutBoxes with .layout_leaf
|
||||
* class and set leafId and leafContent member observables.
|
||||
*
|
||||
* Floater
|
||||
* A Floater is a rectangle that floats over the layout with the mouse pointer while the user is
|
||||
* dragging a LeafBox. It contains the content of the LeafBox being dragged, so that the user
|
||||
* can see what is being repositioned.
|
||||
*
|
||||
* DropOverlay
|
||||
* An DropOverlay is a visual aid to the user to indicate area over the current LeafBox where a
|
||||
* drop may be attempted. It also computes the "affinity": which border of the current LeafBox
|
||||
* the user is trying to target as the insertion point.
|
||||
*
|
||||
* DropTargeter
|
||||
* DropTargeter displays a set of rectangles, each of which represents a particular allowed
|
||||
* insertion point for the element being dragged. E.g. dragging an element to the right side of
|
||||
* a LeafBox would display a drop target for each LayoutBox up the tree that allows a sibling
|
||||
* to be inserted on the right.
|
||||
*
|
||||
* Saving Changes
|
||||
* --------------
|
||||
* We don't attempt to save granular changes to the layout, for each drag operation, because
|
||||
* for the user, it's better to finish editing the layout, and only save the end result. Also,
|
||||
* it's not so easy (the structure changes many times while dragging, and a single drag
|
||||
* operation results in a non-trivial diff of the 'before' and 'after' layouts). So instead, we
|
||||
* just have a way to serialize the layout to and from a JSON blob.
|
||||
*/
|
||||
|
||||
|
||||
import dom, {detachNode, findAncestor} from '../lib/dom';
|
||||
import koArray, {isKoArray, KoArray} from '../lib/koArray';
|
||||
import {cssClass, domData, foreach, scope, style, toggleClass} from '../lib/koDom';
|
||||
import {Disposable} from 'app/client/lib/dispose';
|
||||
import assert from 'assert';
|
||||
import {Events as BackboneEvents} from 'backbone';
|
||||
import * as ko from 'knockout';
|
||||
import {computed, isObservable, observable, utils} from 'knockout';
|
||||
import {identity, last, uniqueId} from 'underscore';
|
||||
|
||||
/**
|
||||
* A LayoutBox is the node in the hierarchy of boxes comprising the layout. This class is used for
|
||||
* rendering as well as for the code editor. Since it may be rendered many times on a page, it's
|
||||
* important for it to be efficient.
|
||||
* @param {Layout} layout: The Layout object that manages this LayoutBox.
|
||||
*/
|
||||
export class LayoutBox extends Disposable {
|
||||
public layout: Layout;
|
||||
public dom: Element | null = null;
|
||||
public leafId: ko.Observable<any>; // probably number for section id
|
||||
public parentBox: ko.Observable<LayoutBox|null>;
|
||||
public childBoxes: KoArray<LayoutBox>;
|
||||
public leafContent: ko.Observable<Element|null>;
|
||||
public uniqueId: string;
|
||||
public isVBox: ko.Computed<boolean>;
|
||||
public isHBox: ko.Computed<boolean>;
|
||||
public isLeaf: ko.Computed<boolean>;
|
||||
public isMaximized: ko.Computed<boolean>;
|
||||
public isHidden: ko.Computed<boolean>;
|
||||
public flexSize: ko.Observable<number>;
|
||||
private _parentBeingDisposed: boolean;
|
||||
|
||||
public create(layout: Layout) {
|
||||
this.layout = layout;
|
||||
this.parentBox = observable(null as any);
|
||||
this.childBoxes = koArray();
|
||||
this.leafId = observable(null);
|
||||
this.leafContent = observable(null as any);
|
||||
this.uniqueId = uniqueId("lb"); // For logging and debugging.
|
||||
|
||||
this.isVBox = this.autoDispose(computed(() => {
|
||||
return this.parentBox() ? !this.parentBox()!.isVBox() : true;
|
||||
}, this));
|
||||
this.isHBox = this.autoDispose(computed(() => { return !this.isVBox(); }));
|
||||
this.isLeaf = this.autoDispose(computed(() => { return this.leafId() !== null; },
|
||||
this));
|
||||
|
||||
this.isMaximized = this.autoDispose(ko.pureComputed(() => {
|
||||
const leafId = this.layout?.maximized();
|
||||
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();
|
||||
if (!maximized) { return false; }
|
||||
return !this.isMaximized();
|
||||
}, this));
|
||||
|
||||
// flexSize represents flexWidth for VBoxes and flexHeight for HBoxes.
|
||||
// Undesirable transition effects are likely when <1, so we set average value
|
||||
// to 100 so that reduction below 1 is rare.
|
||||
this.flexSize = observable(100);
|
||||
|
||||
this.dom = null;
|
||||
|
||||
// This is an optimization to avoid the wasted cost of removeFromParent during disposal.
|
||||
this._parentBeingDisposed = false;
|
||||
|
||||
this.autoDisposeCallback(() => {
|
||||
if (!this._parentBeingDisposed) {
|
||||
this.removeFromParent();
|
||||
}
|
||||
this.childBoxes.peek().forEach(function(child) {
|
||||
child._parentBeingDisposed = true;
|
||||
child.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
public getDom() {
|
||||
return this.dom || (this.dom = this.autoDispose(this.buildDom()));
|
||||
}
|
||||
public maximize() {
|
||||
if (this.layout.maximized.peek() !== this.leafId.peek()) {
|
||||
this.layout.maximized(this.leafId());
|
||||
} else {
|
||||
this.layout.maximized(null);
|
||||
}
|
||||
}
|
||||
public buildDom() {
|
||||
const self = this;
|
||||
const wrap = this.layout.needDynamic ? identity : makeStatic;
|
||||
|
||||
return dom('div.layout_box',
|
||||
toggleClass('layout_leaf', wrap(this.isLeaf)),
|
||||
toggleClass('layout_hidden', this.isHidden),
|
||||
toggleClass(this.layout.leafId, wrap(this.isLeaf)),
|
||||
cssClass(wrap(function() { return self.isVBox() ? "layout_vbox" : "layout_hbox"; })),
|
||||
cssClass(wrap(function() {
|
||||
return (self.layout.fillWindow ? 'layout_fill_window' :
|
||||
(self.isLastChild() ? 'layout_last_child' : null));
|
||||
})),
|
||||
style('--flex-grow', wrap(function() {
|
||||
return (self.isVBox() || (self.isHBox() && self.layout.fillWindow)) ? self.flexSize() : '';
|
||||
})),
|
||||
domData('layoutBox', this),
|
||||
foreach(wrap(this.childBoxes), function(layoutBox: LayoutBox) {
|
||||
return layoutBox.getDom();
|
||||
}),
|
||||
scope(wrap(this.leafContent), function(leafContent: any) {
|
||||
return leafContent;
|
||||
})
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Moves the leaf id and content from another layoutBox, unsetting them in the source one.
|
||||
*/
|
||||
public takeLeafFrom(sourceLayoutBox: LayoutBox) {
|
||||
this.leafId(sourceLayoutBox.leafId.peek());
|
||||
// Note that we detach the node, so that the old box doesn't destroy its DOM.
|
||||
this.leafContent(detachNode(sourceLayoutBox.leafContent.peek()));
|
||||
sourceLayoutBox.leafId(null);
|
||||
sourceLayoutBox.leafContent(null);
|
||||
}
|
||||
public setChildren(children: LayoutBox[]) {
|
||||
children.forEach((child) => child.parentBox(this));
|
||||
this.childBoxes.assign(children);
|
||||
}
|
||||
public isFirstChild() {
|
||||
return this.parentBox() ? this.parentBox()!.childBoxes.peek()[0] === this : true;
|
||||
}
|
||||
public isLastChild() {
|
||||
// Use .all() rather than .peek() because it's used in kd.toggleClass('layout_last_child'), and
|
||||
// we want it to automatically stay correct when childBoxes array changes.
|
||||
return this.parentBox() ? last(this.parentBox()!.childBoxes.all()) === this : true;
|
||||
}
|
||||
public isDomDetached() {
|
||||
return !(this.dom && this.dom.parentNode);
|
||||
}
|
||||
public getSiblingBox(isAfter: boolean) {
|
||||
if (!this.parentBox()) {
|
||||
return null;
|
||||
}
|
||||
const siblings = this.parentBox()!.childBoxes.peek();
|
||||
let index = siblings.indexOf(this);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
index += (isAfter ? 1 : -1);
|
||||
return (index < 0 || index >= siblings.length ? null : siblings[index]);
|
||||
}
|
||||
public _addChild(childBox: LayoutBox, isAfter: boolean, optNextSibling?: LayoutBox) {
|
||||
assert(childBox.parentBox() === null, "LayoutBox._addChild: child already has parentBox set");
|
||||
let index;
|
||||
if (optNextSibling) {
|
||||
index = this.childBoxes.peek().indexOf(optNextSibling) + (isAfter ? 1 : 0);
|
||||
} else {
|
||||
index = isAfter ? this.childBoxes.peekLength : 0;
|
||||
}
|
||||
childBox.parentBox(this);
|
||||
this.childBoxes.splice(index, 0, childBox);
|
||||
}
|
||||
public addSibling(childBox: LayoutBox, isAfter: boolean) {
|
||||
childBox.removeFromParent();
|
||||
const parentBox = this.parentBox();
|
||||
if (parentBox) {
|
||||
// Normally, we just add a sibling as requested.
|
||||
parentBox._addChild(childBox, isAfter, this);
|
||||
} else {
|
||||
// If adding a sibling to the root node (another VBox), we need to create a new root and push
|
||||
// things down two levels (HBox and VBox), and add the sibling to the lower VBox.
|
||||
if (this.childBoxes.peekLength === 1) {
|
||||
// Except when the root has a single child, in which case there is already a good place to
|
||||
// add the new node two levels lower. And we should not create another level because the
|
||||
// root is the only place that can have a single child.
|
||||
const lowerBox = this.childBoxes.peek()[0];
|
||||
assert(!lowerBox.isLeaf(), 'LayoutBox.addSibling: should not have leaf as a single child');
|
||||
lowerBox._addChild(childBox, isAfter);
|
||||
} else {
|
||||
// Create a new root, and add the sibling two levels lower.
|
||||
const vbox = LayoutBox.create(this.layout);
|
||||
const hbox = LayoutBox.create(this.layout);
|
||||
// We don't need removeFromParent here because this only runs when there is no parent.
|
||||
vbox._addChild(hbox, false);
|
||||
hbox._addChild(this, false);
|
||||
hbox._addChild(childBox, isAfter);
|
||||
this.layout.setRoot(vbox);
|
||||
}
|
||||
}
|
||||
this.layout.trigger('layoutChanged');
|
||||
}
|
||||
public addChild(childBox: LayoutBox, isAfter: boolean) {
|
||||
childBox.removeFromParent();
|
||||
if (this.isLeaf()) {
|
||||
// Move the leaf data into a new child, then add the requested childBox.
|
||||
const newBox = LayoutBox.create(this.layout);
|
||||
newBox.takeLeafFrom(this);
|
||||
this._addChild(newBox, false);
|
||||
}
|
||||
this._addChild(childBox, isAfter);
|
||||
this.layout.trigger('layoutChanged');
|
||||
}
|
||||
public toString(): string {
|
||||
return this.isDisposed() ? this.uniqueId + "[disposed]" : (this.uniqueId +
|
||||
(this.isHBox() ? "H" : "V") +
|
||||
(this.isLeaf() ? "(" + this.leafId() + ")" :
|
||||
"[" + this.childBoxes.peek().map(function(b) { return b.toString(); }).join(",") + "]")
|
||||
);
|
||||
}
|
||||
public _removeChildBox(childBox: LayoutBox) {
|
||||
//console.log("_removeChildBox %s from %s", childBox.toString(), this.toString());
|
||||
let index = this.childBoxes.peek().indexOf(childBox);
|
||||
childBox.parentBox(null);
|
||||
if (index >= 0) {
|
||||
this.childBoxes.splice(index, 1);
|
||||
this.rescaleFlexSizes();
|
||||
}
|
||||
if (this.childBoxes.peekLength === 1) {
|
||||
// If we now have a single child, then something needs to collapse.
|
||||
const lowerBox = this.childBoxes.peek()[0];
|
||||
const parentBox = this.parentBox();
|
||||
if (lowerBox.isLeaf()) {
|
||||
// Move the leaf data into ourselves, and remove the lower box.
|
||||
this.takeLeafFrom(lowerBox);
|
||||
lowerBox.dispose();
|
||||
} else if (parentBox) {
|
||||
// Move grandchildren into our place within our parent, and collapse two levels.
|
||||
// (Unless we are the root, in which case it's OK for us to have a single non-leaf child.)
|
||||
index = parentBox.childBoxes.peek().indexOf(this);
|
||||
assert(index >= 0, 'LayoutBox._removeChildBox: box not found in parent');
|
||||
|
||||
const grandchildBoxes = lowerBox.childBoxes.peek();
|
||||
grandchildBoxes.forEach(function(box) { box.parentBox(parentBox); });
|
||||
parentBox.childBoxes.arraySplice(index, 0, grandchildBoxes);
|
||||
|
||||
lowerBox.childBoxes.splice(0, lowerBox.childBoxes.peekLength);
|
||||
this.removeFromParent();
|
||||
|
||||
lowerBox.dispose();
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Helper to detach a box from its parent without disposing it. If you no longer plan to reattach
|
||||
* the box, you should probably call box.dispose().
|
||||
*/
|
||||
public removeFromParent() {
|
||||
if (this.parentBox()) {
|
||||
this.parentBox()!._removeChildBox(this);
|
||||
this.layout.trigger('layoutChanged');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Adjust flexSize values of the children so that they add up to at least 1.
|
||||
* Otherwise, Firefox will not stretch them to the full size of the container.
|
||||
*/
|
||||
public rescaleFlexSizes() {
|
||||
// Just scale so that the smallest value is 1.
|
||||
const children = this.childBoxes.peek();
|
||||
const minSize = Math.min.apply(null, children.map(function(b) { return b.flexSize(); }));
|
||||
if (minSize < 1) {
|
||||
children.forEach(function(b) {
|
||||
b.flexSize(b.flexSize() / minSize);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper turns a value, observable, or function (as accepted by koDom functions) into a
|
||||
* plain value. It's used to build a static piece of DOM without subscribing to any of the
|
||||
* observables, to avoid the performance cost of subscribing/unsubscribing.
|
||||
*/
|
||||
function makeStatic(valueOrFunc: any) {
|
||||
if (isObservable(valueOrFunc) || isKoArray(valueOrFunc)) {
|
||||
return valueOrFunc.peek();
|
||||
} else if (typeof valueOrFunc === 'function') {
|
||||
return valueOrFunc();
|
||||
} else {
|
||||
return valueOrFunc;
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @event layoutChanged: Triggered on changes to the structure of the layout.
|
||||
* @event layoutResized: Triggered on non-structural changes that may affect the size of rootElem.
|
||||
*/
|
||||
export class Layout extends Disposable {
|
||||
/**
|
||||
* You can also find the nearest containing LayoutBox without having the Layout object itself by
|
||||
* using Layout.Layout.getContainingBox. The Layout object is then accessible as box.layout.
|
||||
*/
|
||||
public static getContainingBox(elem: Element|null, optContainer: any) {
|
||||
const boxElem = findAncestor(elem, optContainer, '.layout_box');
|
||||
return boxElem ? utils.domData.get(boxElem, 'layoutBox') : null;
|
||||
}
|
||||
|
||||
public listenTo: BackboneEvents["listenTo"]; // set by Backbone
|
||||
public trigger: BackboneEvents["trigger"]; // set by Backbone
|
||||
public stopListening: BackboneEvents["stopListening"]; // set by Backbone
|
||||
|
||||
public maximized: ko.Observable<string|null>;
|
||||
public rootBox: ko.Observable<LayoutBox|null>;
|
||||
public createLeafFunc: (id: string) => HTMLElement;
|
||||
public fillWindow: boolean;
|
||||
public needDynamic: boolean;
|
||||
public rootElem: HTMLElement;
|
||||
public leafId: string;
|
||||
private _leafIdMap: Map<any, LayoutBox>|null;
|
||||
|
||||
public create(boxSpec: object, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) {
|
||||
this.maximized = observable(null as (string|null));
|
||||
this.rootBox = observable(null as any);
|
||||
this.createLeafFunc = createLeafFunc;
|
||||
this._leafIdMap = null;
|
||||
this.fillWindow = optFillWindow || false;
|
||||
this.needDynamic = false;
|
||||
this.rootElem = this.autoDispose(this.buildDom());
|
||||
|
||||
// Generates a unique id class so boxes can only be placed next to other boxes in this layout.
|
||||
this.leafId = uniqueId('layout_leaf_');
|
||||
|
||||
this.buildLayout(boxSpec || {});
|
||||
|
||||
// Invalidate the _leafIdMap when the layout is adjusted.
|
||||
this.listenTo(this, 'layoutChanged', () => { this._leafIdMap = null; });
|
||||
|
||||
this.autoDisposeCallback(() => {
|
||||
if (this.rootBox()) {
|
||||
this.rootBox()!.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Finds and returns the leaf layout box containing the content for the given leafId.
|
||||
*/
|
||||
public getLeafBox(leafId: string|number) {
|
||||
return this.getLeafIdMap().get(leafId);
|
||||
}
|
||||
/**
|
||||
* Returns the list of all leafIds present in this layout.
|
||||
*/
|
||||
public getAllLeafIds() {
|
||||
return Array.from(this.getLeafIdMap().keys());
|
||||
}
|
||||
public setRoot(layoutBox: LayoutBox) {
|
||||
this.rootBox(layoutBox);
|
||||
}
|
||||
public buildDom() {
|
||||
return dom('div.layout_root',
|
||||
domData('layoutModel', this),
|
||||
toggleClass('layout_fill_window', this.fillWindow),
|
||||
toggleClass('layout_box_maximized', this.maximized),
|
||||
scope(this.rootBox, (rootBox: LayoutBox) => {
|
||||
return rootBox ? rootBox.getDom() : null;
|
||||
})
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Calls cb on each box in the layout recursively.
|
||||
*/
|
||||
public forEachBox(cb: (box: LayoutBox) => void, optContext?: any) {
|
||||
function iter(box: any) {
|
||||
cb.call(optContext, box);
|
||||
box.childBoxes.peek().forEach(iter);
|
||||
}
|
||||
iter(this.rootBox.peek());
|
||||
}
|
||||
public buildLayoutBox(boxSpec: any) {
|
||||
// Note that this is hot code: it runs when rendering a layout for each record, not only for the
|
||||
// layout editor.
|
||||
const box = LayoutBox.create(this);
|
||||
if (boxSpec.size) {
|
||||
box.flexSize(boxSpec.size);
|
||||
}
|
||||
if (boxSpec.leaf) {
|
||||
box.leafId(boxSpec.leaf);
|
||||
box.leafContent(this.createLeafFunc(box.leafId()!));
|
||||
} else if (boxSpec.children) {
|
||||
box.setChildren(boxSpec.children.map(this.buildLayoutBox, this));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
public buildLayout(boxSpec: any, needDynamic = false) {
|
||||
this.needDynamic = needDynamic;
|
||||
const oldRootBox = this.rootBox();
|
||||
this.rootBox(this.buildLayoutBox(boxSpec));
|
||||
this.trigger('layoutChanged');
|
||||
if (oldRootBox) {
|
||||
oldRootBox.dispose();
|
||||
}
|
||||
}
|
||||
public _getBoxSpec(layoutBox: LayoutBox) {
|
||||
const spec: any = {};
|
||||
if (layoutBox.isDisposed()) {
|
||||
return spec;
|
||||
}
|
||||
if (layoutBox.flexSize() && layoutBox.flexSize() !== 100) {
|
||||
spec.size = layoutBox.flexSize();
|
||||
}
|
||||
if (layoutBox.isLeaf()) {
|
||||
spec.leaf = layoutBox.leafId();
|
||||
} else {
|
||||
spec.children = layoutBox.childBoxes.peek().map(this._getBoxSpec, this);
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
public getLayoutSpec() {
|
||||
return this._getBoxSpec(this.rootBox()!);
|
||||
}
|
||||
/**
|
||||
* Returns a Map object mapping leafId to its LayoutBox. This gets invalidated on layoutAdjust
|
||||
* events, and rebuilt on next request.
|
||||
*/
|
||||
public getLeafIdMap() {
|
||||
if (!this._leafIdMap) {
|
||||
this._leafIdMap = new Map<number|string, LayoutBox>();
|
||||
this.forEachBox((box) => {
|
||||
const leafId = box.leafId.peek();
|
||||
if (leafId !== null) {
|
||||
this._leafIdMap!.set(leafId, box);
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
return this._leafIdMap;
|
||||
}
|
||||
/**
|
||||
* Returns a LayoutBox object containing the given DOM element, or null if not found.
|
||||
*/
|
||||
public getContainingBox(elem: Element|null) {
|
||||
return Layout.getContainingBox(elem, this.rootElem);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(Layout.prototype, BackboneEvents);
|
@ -5,6 +5,11 @@
|
||||
|
||||
.viewsection_buttons {
|
||||
margin-left: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.viewsection_title {
|
||||
@ -99,6 +104,19 @@
|
||||
border-left: 1px solid var(--grist-theme-widget-border, var(--grist-color-dark-grey));
|
||||
}
|
||||
|
||||
/* Used by full screen section. Removes the green box-shadow and restores normal color of the border.
|
||||
It still leaves the indicator for the cardlist selection (the green box shadow in card) which looks nice.
|
||||
*/
|
||||
.layout_box_maximized .active_section > .view_data_pane_container{
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--grist-theme-widget-border, var(--grist-color-dark-grey));
|
||||
}
|
||||
/* Remove the drag indicator */
|
||||
.layout_box_maximized .active_section .viewsection_drag_indicator {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
|
||||
.disable_viewpane {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
@ -19,11 +19,10 @@ import {colors, isNarrowScreen, isNarrowScreenObs, mediaSmall, testId, theme} fr
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {mod} from 'app/common/gutil';
|
||||
import {Observable} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import * as _ from 'underscore';
|
||||
import debounce from 'lodash/debounce';
|
||||
import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, styled, subscribe} from 'grainjs';
|
||||
import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, Observable, styled, subscribe} from 'grainjs';
|
||||
|
||||
// tslint:disable:no-console
|
||||
|
||||
@ -71,9 +70,10 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
public docModel = this.gristDoc.docModel;
|
||||
public viewModel: ViewRec;
|
||||
public layoutSpec: ko.Computed<object>;
|
||||
public maximized: Observable<number|null>;
|
||||
|
||||
private _freeze = false;
|
||||
private _layout: any;
|
||||
private _layout: Layout;
|
||||
private _sectionIds: number[];
|
||||
private _isResizing = Observable.create(this, false);
|
||||
|
||||
@ -155,12 +155,12 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
const classInactive = cssLayoutBox.className + '-inactive';
|
||||
this.autoDispose(subscribe(fromKo(this.viewModel.activeSection), (use, section) => {
|
||||
const id = section.getRowId();
|
||||
this._layout.forEachBox((box: {dom: Element}) => {
|
||||
box.dom.classList.add(classInactive);
|
||||
box.dom.classList.remove(classActive);
|
||||
box.dom.classList.remove("transition");
|
||||
this._layout.forEachBox(box => {
|
||||
box.dom!.classList.add(classInactive);
|
||||
box.dom!.classList.remove(classActive);
|
||||
box.dom!.classList.remove("transition");
|
||||
});
|
||||
let elem: Element|null = this._layout.getLeafBox(id)?.dom;
|
||||
let elem: Element|null = this._layout.getLeafBox(id)?.dom || null;
|
||||
while (elem?.matches('.layout_box')) {
|
||||
elem.classList.remove(classInactive);
|
||||
elem.classList.add(classActive);
|
||||
@ -177,12 +177,43 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
prevSection: () => { this._otherSection(-1); },
|
||||
printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); },
|
||||
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
|
||||
maximizeActiveSection: () => { this._maximizeActiveSection(); },
|
||||
cancel: () => {
|
||||
if (this.maximized.get()) {
|
||||
this.maximized.set(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return this._layout.rootElem;
|
||||
const close = () => this.maximized.set(null);
|
||||
return cssOverlay(
|
||||
cssOverlay.cls('-active', use => !!use(this.maximized)),
|
||||
testId('viewLayout-overlay'),
|
||||
cssLayoutWrapper(
|
||||
cssLayoutWrapper.cls('-active', use => !!use(this.maximized)),
|
||||
this._layout.rootElem,
|
||||
),
|
||||
dom.maybe(use => !!use(this.maximized), () =>
|
||||
cssCloseButton('CrossBig',
|
||||
testId('close-button'),
|
||||
dom.on('click', () => close())
|
||||
)
|
||||
),
|
||||
// Close the lightbox when user clicks exactly on the overlay.
|
||||
dom.on('click', (ev, elem) => void (ev.target === elem && this.maximized.get() ? close() : null))
|
||||
);
|
||||
}
|
||||
|
||||
// Freezes the layout until the passed in promise resolves. This is useful to achieve a single
|
||||
@ -204,6 +235,14 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
|
||||
}
|
||||
|
||||
private _maximizeActiveSection() {
|
||||
const activeSection = this.viewModel.activeSection();
|
||||
const activeSectionId = activeSection.getRowId();
|
||||
const activeSectionBox = this._layout.getLeafBox(activeSectionId);
|
||||
if (!activeSectionBox) { return; }
|
||||
activeSectionBox.maximize();
|
||||
}
|
||||
|
||||
private _buildLeafContent(sectionRowId: number) {
|
||||
return buildViewSectionDom({
|
||||
gristDoc: this.gristDoc,
|
||||
@ -219,33 +258,33 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
*/
|
||||
private _updateLayoutSpecWithSections(spec: object) {
|
||||
// We use tmpLayout as a way to manipulate the layout before we get a final spec from it.
|
||||
const tmpLayout = Layout.create(spec, (leafId: number) => dom('div'), true);
|
||||
const tmpLayout = Layout.create(spec, () => dom('div'), true);
|
||||
|
||||
const specFieldIds = tmpLayout.getAllLeafIds();
|
||||
const viewSectionIds = this.viewModel.viewSections().all().map(function(f) { return f.getRowId(); });
|
||||
|
||||
function addToSpec(leafId: number) {
|
||||
const newBox = tmpLayout.buildLayoutBox({ leaf: leafId });
|
||||
const rows = tmpLayout.rootBox().childBoxes.peek();
|
||||
const rows = tmpLayout.rootBox()!.childBoxes.peek();
|
||||
const lastRow = rows[rows.length - 1];
|
||||
if (rows.length >= 1 && lastRow.isLeaf()) {
|
||||
// Add a new child to the last row.
|
||||
lastRow.addChild(newBox, true);
|
||||
} else {
|
||||
// Add a new row.
|
||||
tmpLayout.rootBox().addChild(newBox, true);
|
||||
tmpLayout.rootBox()!.addChild(newBox, true);
|
||||
}
|
||||
return newBox;
|
||||
}
|
||||
|
||||
// For any stale fields (no longer among viewFields), remove them from tmpLayout.
|
||||
_.difference(specFieldIds, viewSectionIds).forEach(function(leafId) {
|
||||
tmpLayout.getLeafBox(leafId).dispose();
|
||||
_.difference(specFieldIds, viewSectionIds).forEach(function(leafId: any) {
|
||||
tmpLayout.getLeafBox(leafId)?.dispose();
|
||||
});
|
||||
|
||||
// For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a
|
||||
// two-column layout, so add a new row, or a second box to the last row if it's a leaf.
|
||||
_.difference(viewSectionIds, specFieldIds).forEach(function(leafId) {
|
||||
_.difference(viewSectionIds, specFieldIds).forEach(function(leafId: any) {
|
||||
// Only add the builder box if it hasn`t already been created
|
||||
addToSpec(leafId);
|
||||
});
|
||||
@ -340,7 +379,7 @@ export function buildViewSectionDom(options: {
|
||||
cssSigmaIcon('Pivot', testId('sigma'))),
|
||||
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
|
||||
viewInstance.buildTitleControls(),
|
||||
dom('span.viewsection_buttons',
|
||||
dom('div.viewsection_buttons',
|
||||
dom.create(viewSectionMenu, gristDoc, vs)
|
||||
)
|
||||
)),
|
||||
@ -471,3 +510,65 @@ const cssDragIcon = styled(icon, `
|
||||
const cssResizing = styled('div', `
|
||||
pointer-events: none;
|
||||
`);
|
||||
|
||||
const cssLayoutWrapper = styled('div', `
|
||||
@media not print {
|
||||
&-active {
|
||||
background: ${theme.mainPanelBg};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
position: relative;
|
||||
}
|
||||
&-active .viewsection_content {
|
||||
margin: 0px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
&-active .viewsection_title {
|
||||
padding: 0px 12px;
|
||||
}
|
||||
&-active .filter_bar {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssOverlay = styled('div', `
|
||||
@media screen {
|
||||
&-active {
|
||||
background-color: ${theme.modalBackdrop};
|
||||
inset: 0px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 20px 56px 20px 56px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
@media screen and ${mediaSmall} {
|
||||
&-active {
|
||||
padding: 22px;
|
||||
padding-top: 30px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCloseButton = styled(icon, `
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.modalBackdropCloseButtonFg};
|
||||
&:hover {
|
||||
--icon-color: ${theme.modalBackdropCloseButtonHoverFg};
|
||||
}
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
@ -111,6 +111,11 @@ exports.groups = [{
|
||||
keys: [],
|
||||
desc: 'Open Custom widget configuration screen',
|
||||
},
|
||||
{
|
||||
name: 'maximizeActiveSection',
|
||||
keys: [],
|
||||
desc: 'Maximize the active section',
|
||||
},
|
||||
{
|
||||
name: 'leftPanelOpen',
|
||||
keys: [],
|
||||
|
3
app/client/declarations.d.ts
vendored
3
app/client/declarations.d.ts
vendored
@ -4,7 +4,6 @@ declare module "app/client/components/CodeEditorPanel";
|
||||
declare module "app/client/components/DetailView";
|
||||
declare module "app/client/components/DocConfigTab";
|
||||
declare module "app/client/components/GridView";
|
||||
declare module "app/client/components/Layout";
|
||||
declare module "app/client/components/LayoutEditor";
|
||||
declare module "app/client/components/commandList";
|
||||
declare module "app/client/lib/Mousetrap";
|
||||
@ -30,6 +29,7 @@ declare module "app/client/components/BaseView" {
|
||||
|
||||
import {Cursor, CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {IGristUrlState} from 'app/common/gristUrls';
|
||||
import {SelectionSummary} from 'app/client/components/SelectionSummary';
|
||||
import {Disposable} from 'app/client/lib/dispose';
|
||||
import BaseRowModel from "app/client/models/BaseRowModel";
|
||||
@ -77,6 +77,7 @@ declare module "app/client/components/BaseView" {
|
||||
public prepareToPrint(onOff: boolean): void;
|
||||
public moveEditRowToCursor(): DataRowModel;
|
||||
public scrollToCursor(sync: boolean): Promise<void>;
|
||||
public getAnchorLinkForSection(sectionId: number): IGristUrlState;
|
||||
}
|
||||
export = BaseView;
|
||||
}
|
||||
|
2
app/client/lib/koArray.d.ts
vendored
2
app/client/lib/koArray.d.ts
vendored
@ -24,8 +24,10 @@ declare class KoArray<T> {
|
||||
public clampIndex(index: number): number|null;
|
||||
public makeLiveIndex(index?: number): ko.Observable<number> & {setLive(live: boolean): void};
|
||||
public setAutoDisposeValues(): this;
|
||||
public arraySplice(start: number, deleteCount: number, items: T[]): T[];
|
||||
}
|
||||
|
||||
declare function syncedKoArray(...args: any[]): any;
|
||||
|
||||
export default function koArray<T>(initialValue?: T[]): KoArray<T>;
|
||||
export function isKoArray(obj: any): obj is KoArray<any>;
|
||||
|
@ -36,10 +36,26 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
|
||||
|
||||
const viewRec = viewSection.view();
|
||||
const isLight = urlState().state.get().params?.style === 'light';
|
||||
|
||||
const sectionId = viewSection.table.peek().rawViewSectionRef.peek();
|
||||
const anchorUrlState = viewInstance.getAnchorLinkForSection(sectionId);
|
||||
anchorUrlState.hash!.popup = true;
|
||||
const rawUrl = urlState().makeUrl(anchorUrlState);
|
||||
|
||||
|
||||
return [
|
||||
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
|
||||
dom.maybe((use) => !use(viewSection.isRaw) && !isLight,
|
||||
() => menuItemCmd(allCommands.showRawData, t("Show raw data"), testId('show-raw-data')),
|
||||
dom.maybe((use) => !use(viewSection.isRaw) && !isLight && !use(gristDoc.sectionInPopup),
|
||||
() => menuItemLink(
|
||||
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
|
||||
dom.on('click', (ev) => {
|
||||
// Replace the current URL so that the back button works as expected (it navigates back from
|
||||
// the current page).
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
urlState().pushUrl(anchorUrlState, { replace: true }).catch(reportError);
|
||||
})
|
||||
)
|
||||
),
|
||||
menuItemCmd(allCommands.printSection, t("Print widget"), testId('print-section')),
|
||||
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {DocModel, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
@ -8,7 +9,7 @@ import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {SortConfig} from 'app/client/ui/SortConfig';
|
||||
import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {menu} from 'app/client/ui2018/menus';
|
||||
import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs';
|
||||
@ -54,9 +55,15 @@ export function viewSectionMenu(
|
||||
const save = () => { doSave(docModel, viewSection).catch(reportError); };
|
||||
const revert = () => doRevert(viewSection);
|
||||
|
||||
// Should we show expand icon.
|
||||
const showExpandIcon = Computed.create(owner, (use) => {
|
||||
return !use(isNarrowScreenObs()) // not on narrow screens
|
||||
&& use(gristDoc.sectionInPopup) !== use(viewSection.id) // not in popup
|
||||
&& !use(viewSection.isRaw); // not in raw mode
|
||||
});
|
||||
|
||||
return [
|
||||
cssFilterMenuWrapper(
|
||||
cssFixHeight.cls(''),
|
||||
cssFilterMenuWrapper.cls('-unsaved', displaySaveObs),
|
||||
testId('wrapper'),
|
||||
cssMenu(
|
||||
@ -130,12 +137,19 @@ export function viewSectionMenu(
|
||||
),
|
||||
cssMenu(
|
||||
testId('viewLayout'),
|
||||
cssFixHeight.cls(''),
|
||||
cssDotsIconWrapper(cssIcon('Dots')),
|
||||
menu(_ctl => makeViewLayoutMenu(viewSection, isReadonly.get()), {
|
||||
...defaultMenuOptions,
|
||||
placement: 'bottom-end',
|
||||
})
|
||||
),
|
||||
dom.maybe(showExpandIcon, () =>
|
||||
cssExpandIconWrapper(
|
||||
cssSmallIcon('Grow'),
|
||||
testId('expandSection'),
|
||||
dom.on('click', () => allCommands.maximizeActiveSection.run()),
|
||||
hoverTooltip('Expand section', {key: 'expandSection'}),
|
||||
),
|
||||
)
|
||||
];
|
||||
}
|
||||
@ -181,7 +195,7 @@ function makeCustomOptions(section: ViewSectionRec) {
|
||||
dom.text(text),
|
||||
cssMenuText.cls(color),
|
||||
cssSpacer(),
|
||||
dom.maybe(use => use(section.activeCustomOptions), () =>
|
||||
dom.maybe(use => Boolean(use(section.activeCustomOptions)), () =>
|
||||
cssMenuIconWrapper(
|
||||
cssIcon('Remove', testId('btn-remove-options'), dom.on('click', () =>
|
||||
section.activeCustomOptions(null)
|
||||
@ -195,22 +209,15 @@ function makeCustomOptions(section: ViewSectionRec) {
|
||||
|
||||
const clsOldUI = styled('div', ``);
|
||||
|
||||
const cssFixHeight = styled('div', `
|
||||
margin-top: -3px; /* Section header is 24px, so need to move this up a little bit */
|
||||
`);
|
||||
|
||||
const cssMenu = styled('div', `
|
||||
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
&.${clsOldUI.className} {
|
||||
margin-top: 0px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
&:hover, &.weasel-popup-open {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
@ -241,8 +248,7 @@ const cssMenuIconWrapper = styled(cssIconWrapper, `
|
||||
`);
|
||||
|
||||
const cssFilterMenuWrapper = styled('div', `
|
||||
display: inline-flex;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
align-items: center;
|
||||
&-unsaved {
|
||||
@ -251,7 +257,6 @@ const cssFilterMenuWrapper = styled('div', `
|
||||
& .${cssMenu.className} {
|
||||
border: none;
|
||||
}
|
||||
|
||||
`);
|
||||
|
||||
const cssIcon = styled(icon, `
|
||||
@ -274,14 +279,31 @@ const cssIcon = styled(icon, `
|
||||
|
||||
const cssDotsIconWrapper = styled(cssIconWrapper, `
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
|
||||
display: flex;
|
||||
.${clsOldUI.className} & {
|
||||
border-radius: 0px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssExpandIconWrapper = styled('div', `
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
&:hover, &.weasel-popup-open {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSmallIcon = styled(cssIcon, `
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
`);
|
||||
|
||||
const cssFilterIconWrapper = styled(cssIconWrapper, `
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
display: flex;
|
||||
&-any {
|
||||
border-radius: 2px;
|
||||
background-color: ${theme.controlSecondaryFg};
|
||||
|
@ -67,6 +67,7 @@ export type IconName = "ChartArea" |
|
||||
"FontStrikethrough" |
|
||||
"FontUnderline" |
|
||||
"FunctionResult" |
|
||||
"Grow" |
|
||||
"Help" |
|
||||
"Home" |
|
||||
"Idea" |
|
||||
@ -200,6 +201,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"FontStrikethrough",
|
||||
"FontUnderline",
|
||||
"FunctionResult",
|
||||
"Grow",
|
||||
"Help",
|
||||
"Home",
|
||||
"Idea",
|
||||
|
@ -68,6 +68,7 @@
|
||||
--icon-FontStrikethrough: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSAxLjgwNTY1ODUsMy4zOTcxNzIgViAxLjczNTc0MzEgSCAxNC4xOTQzNDIgViAzLjM5NzE3MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxMC4wNDI4ODcgViAxNC4xOTY0NiIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxLjczNTc0MzEgViA3LjU1MDc0NCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNNS4zNDUyODIyIDE0LjE5NjQ2SDEwLjY1NDcxOE0xLjgwNTY1ODUgNy41NTA3NDRIMTQuMTk0MzQyIiBzdHJva2U9IiM5MjkyOTkiIHN0cm9rZS13aWR0aD0iMS4xNDMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
|
||||
--icon-FontUnderline: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xNDI5NSAwIDAgMS4xNDI5NSAxLjE0MiAuNzIxKSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNLjM3NSAxMS42MjVoMTEuMjVNOS4zNzUgMS4xMjVWNkM5LjM3NSA2Ljg5NTExIDkuMDE5NDIgNy43NTM1NSA4LjM4NjQ5IDguMzg2NDkgNy43NTM1NSA5LjAxOTQyIDYuODk1MTEgOS4zNzUgNiA5LjM3NXYwQzUuMTA0ODkgOS4zNzUgNC4yNDY0NSA5LjAxOTQyIDMuNjEzNTEgOC4zODY0OSAyLjk4MDU4IDcuNzUzNTUgMi42MjUgNi44OTUxMSAyLjYyNSA2VjEuMTI1TTEuMTI1IDEuMTI1aDNNNy44NzUgMS4xMjVoMyIvPjwvZz48L3N2Zz4=');
|
||||
--icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||
--icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSAxLjVMNi41IDYuNU05LjUgOS41TDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
||||
--icon-Help: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzA5MjY0NDMsMy40MTYzNzEyMSBDMS42NDQ0MDc2OSw0LjY0NDQzMjIyIDEsNi4yNDY5NjEzNyAxLDggQzEsOS43NTMwMzg2MyAxLjY0NDQwNzY5LDExLjM1NTU2NzggMi43MDkyNjQ0MywxMi41ODM2Mjg4IEw0Ljg0MDA3MTI5LDEwLjQ1MjgyMTkgQzQuMzEzNTQwOCw5Ljc3NTQ4MDA3IDQsOC45MjQzNTU3MyA0LDggQzQsNy4wNzU2NDQyNyA0LjMxMzU0MDgsNi4yMjQ1MTk5MyA0Ljg0MDA3MTI5LDUuNTQ3MTc4MDcgTDIuNzA5MjY0NDMsMy40MTYzNzEyMSBaIE0zLjQxNjM3MTIxLDIuNzA5MjY0NDMgTDUuNTQ3MTc4MDcsNC44NDAwNzEyOSBDNi4yMjQ1MTk5Myw0LjMxMzU0MDggNy4wNzU2NDQyNyw0IDgsNCBDOC45MjQzNTU3Myw0IDkuNzc1NDgwMDcsNC4zMTM1NDA4IDEwLjQ1MjgyMTksNC44NDAwNzEyOSBMMTIuNTgzNjI4OCwyLjcwOTI2NDQzIEMxMS4zNTU1Njc4LDEuNjQ0NDA3NjkgOS43NTMwMzg2MywxIDgsMSBDNi4yNDY5NjEzNywxIDQuNjQ0NDMyMjIsMS42NDQ0MDc2OSAzLjQxNjM3MTIxLDIuNzA5MjY0NDMgWiBNNS45MDE5ODczMSw1Ljg1NTYyMzc2IEM1Ljg5NDgyMDAxLDUuODYzNzgzMDcgNS44ODczNDIwNCw1Ljg3MTc2NDc0IDUuODc5NTUzMzksNS44Nzk1NTMzOSBDNS44NzE3NjQ3NCw1Ljg4NzM0MjA0IDUuODYzNzgzMDcsNS44OTQ4MjAwMSA1Ljg1NTYyMzc2LDUuOTAxOTg3MzEgQzUuMzI2Mjk1NSw2LjQ0MjkzOTMyIDUsNy4xODMzNjQ0NiA1LDggQzUsOC44MTY2MzU1NCA1LjMyNjI5NTUsOS41NTcwNjA2OCA1Ljg1NTYyMzc2LDEwLjA5ODAxMjcgQzUuODYzNzgzMDcsMTAuMTA1MTggNS44NzE3NjQ3NCwxMC4xMTI2NTggNS44Nzk1NTMzOSwxMC4xMjA0NDY2IEM1Ljg4NzM0MjA0LDEwLjEyODIzNTMgNS44OTQ4MjAwMSwxMC4xMzYyMTY5IDUuOTAxOTg3MzEsMTAuMTQ0Mzc2MiBDNi40NDI5MzkzMiwxMC42NzM3MDQ1IDcuMTgzMzY0NDYsMTEgOCwxMSBDOC44MTY2MzU1NCwxMSA5LjU1NzA2MDY4LDEwLjY3MzcwNDUgMTAuMDk4MDEyNywxMC4xNDQzNzYyIEMxMC4xMDUxOCwxMC4xMzYyMTY5IDEwLjExMjY1OCwxMC4xMjgyMzUzIDEwLjEyMDQ0NjYsMTAuMTIwNDQ2NiBDMTAuMTI4MjM1MywxMC4xMTI2NTggMTAuMTM2MjE2OSwxMC4xMDUxOCAxMC4xNDQzNzYyLDEwLjA5ODAxMjcgQzEwLjY3MzcwNDUsOS41NTcwNjA2OCAxMSw4LjgxNjYzNTU0IDExLDggQzExLDcuMTgzMzY0NDYgMTAuNjczNzA0NSw2LjQ0MjkzOTMyIDEwLjE0NDM3NjIsNS45MDE5ODczMSBDMTAuMTM2MjE2OSw1Ljg5NDgyMDAxIDEwLjEyODIzNTMsNS44ODczNDIwNCAxMC4xMjA0NDY2LDUuODc5NTUzMzkgQzEwLjExMjY1OCw1Ljg3MTc2NDc0IDEwLjEwNTE4LDUuODYzNzgzMDcgMTAuMDk4MDEyNyw1Ljg1NTYyMzc2IEM5LjU1NzA2MDY4LDUuMzI2Mjk1NSA4LjgxNjYzNTU0LDUgOCw1IEM3LjE4MzM2NDQ2LDUgNi40NDI5MzkzMiw1LjMyNjI5NTUgNS45MDE5ODczMSw1Ljg1NTYyMzc2IFogTTMuNDE2MzcxMjEsMTMuMjkwNzM1NiBDNC42NDQ0MzIyMiwxNC4zNTU1OTIzIDYuMjQ2OTYxMzcsMTUgOCwxNSBDOS43NTMwMzg2MywxNSAxMS4zNTU1Njc4LDE0LjM1NTU5MjMgMTIuNTgzNjI4OCwxMy4yOTA3MzU2IEwxMC40NTI4MjE5LDExLjE1OTkyODcgQzkuNzc1NDgwMDcsMTEuNjg2NDU5MiA4LjkyNDM1NTczLDEyIDgsMTIgQzcuMDc1NjQ0MjcsMTIgNi4yMjQ1MTk5MywxMS42ODY0NTkyIDUuNTQ3MTc4MDcsMTEuMTU5OTI4NyBMMy40MTYzNzEyMSwxMy4yOTA3MzU2IFogTTEzLjI5MDczNTYsMTIuNTgzNjI4OCBDMTQuMzU1NTkyMywxMS4zNTU1Njc4IDE1LDkuNzUzMDM4NjMgMTUsOCBDMTUsNi4yNDY5NjEzNyAxNC4zNTU1OTIzLDQuNjQ0NDMyMjIgMTMuMjkwNzM1NiwzLjQxNjM3MTIxIEwxMS4xNTk5Mjg3LDUuNTQ3MTc4MDcgQzExLjY4NjQ1OTIsNi4yMjQ1MTk5MyAxMiw3LjA3NTY0NDI3IDEyLDggQzEyLDguOTI0MzU1NzMgMTEuNjg2NDU5Miw5Ljc3NTQ4MDA3IDExLjE1OTkyODcsMTAuNDUyODIxOSBMMTMuMjkwNzM1NiwxMi41ODM2Mjg4IFogTTgsMTYgQzMuNTgxNzIyLDE2IDAsMTIuNDE4Mjc4IDAsOCBDMCwzLjU4MTcyMiAzLjU4MTcyMiwwIDgsMCBDMTIuNDE4Mjc4LDAgMTYsMy41ODE3MjIgMTYsOCBDMTYsMTIuNDE4Mjc4IDEyLjQxODI3OCwxNiA4LDE2IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||
--icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||
--icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||
|
6
static/ui-icons/UI/Grow.svg
Normal file
6
static/ui-icons/UI/Grow.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.5 1.5L6.5 6.5" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.5 9.5L14.5 14.5" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M7.5 1.5H1.5V7.5" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M14.5 8.5V14.5H8.5" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 579 B |
@ -238,6 +238,19 @@ export async function selectSectionByTitle(title: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function expandSection(title?: string) {
|
||||
const select = title
|
||||
? driver.findContent(`.test-viewsection-title`, exactMatch(title)).findClosest(".viewsection_title")
|
||||
: driver.find(".active_section");
|
||||
await select.find(".test-section-menu-expandSection").click();
|
||||
}
|
||||
|
||||
export async function getSectionId() {
|
||||
const classList = await driver.find(".active_section").getAttribute("class");
|
||||
const match = classList.match(/test-viewlayout-section-(\d+)/);
|
||||
if (!match) { throw new Error("Could not find section id"); }
|
||||
return parseInt(match[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns visible cells of the GridView from a single column and one or more rows. Options may be
|
||||
|
Loading…
Reference in New Issue
Block a user