diff --git a/app/client/components/ChartView.ts b/app/client/components/ChartView.ts index 72953dfd..95856c79 100644 --- a/app/client/components/ChartView.ts +++ b/app/client/components/ChartView.ts @@ -229,8 +229,7 @@ export class ChartView extends Disposable { this.listenTo(this.sortedRows, 'rowNotify', this._update); this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update)); this.autoDispose(this._formatterComp.subscribe(this._update)); - this.autoDispose(this.gristDoc.docPageModel.appModel.currentTheme.addListener(() => - this._update())); + this.autoDispose(this.gristDoc.docPageModel.appModel.currentTheme.addListener(() => this._update())); } public prepareToPrint(onOff: boolean) { diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 32d20fb8..64904fa1 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -166,10 +166,17 @@ export class GristDoc extends DisposableWithEvents { public readonly hasDocTour: Computed; 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 = Observable.create(this, null); + // One of the section can be expanded (as requested from the Layout), we will + // store its id in this variable. NOTE: expanded section looks exactly the same as a section + // in the popup. But they are rendered differently, as section in popup is probably an external + // section (or raw data section) that is not part of this view. Maximized section is a section + // in the view, so there is no need to render it twice, layout just hides all other sections to make + // the space. + public maximizedSectionId: Observable = Observable.create(this, null); + // This is id of the section that is currently shown in the popup. Probably this is an external + // section, like raw data view, or a section from another view.. + public externalSectionId: Computed; + public viewLayout: ViewLayout|null = null; private _actionLog: ActionLog; private _undoStack: UndoStack; @@ -178,7 +185,6 @@ export class GristDoc extends DisposableWithEvents { private _docHistory: DocHistory; private _discussionPanel: DiscussionPanel; private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard); - private _viewLayout: ViewLayout|null = null; private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour'); private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours'); private _rawSectionOptions: Observable = Observable.create(this, null); @@ -229,6 +235,10 @@ export class GristDoc extends DisposableWithEvents { return viewId || use(defaultViewId); }); this._activeContent = Computed.create(this, use => use(this._rawSectionOptions) ?? use(this.activeViewId)); + this.externalSectionId = Computed.create(this, use => { + const externalContent = use(this._rawSectionOptions); + return externalContent ? use(externalContent.viewSection.id) : null; + }); // 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, @@ -238,10 +248,10 @@ export class GristDoc extends DisposableWithEvents { // When active section is changed, clear the maximized state. this.autoDispose(this.viewModel.activeSectionId.subscribe(() => { - this.sectionInPopup.set(null); + this.maximizedSectionId.set(null); // If we have layout, update it. - if (!this._viewLayout?.isDisposed()) { - this._viewLayout?.maximized.set(null); + if (!this.viewLayout?.isDisposed()) { + this.viewLayout?.maximized.set(null); } })); @@ -444,7 +454,7 @@ export class GristDoc extends DisposableWithEvents { * Builds the DOM for this GristDoc. */ public buildDom() { - const isMaximized = Computed.create(this, use => use(this.sectionInPopup) !== null); + const isMaximized = Computed.create(this, use => use(this.maximizedSectionId) !== null); const isPopup = Computed.create(this, use => { return ['data', 'settings'].includes(use(this.activeViewId) as any) // On Raw data or doc settings pages || use(isMaximized) // Layout has a maximized section visible @@ -468,9 +478,10 @@ export class GristDoc extends DisposableWithEvents { return dom.create(RawDataPopup, this, content.viewSection, content.close); }) : dom.create((owner) => { - this._viewLayout = ViewLayout.create(owner, this, content); - this._viewLayout.maximized.addListener(n => this.sectionInPopup.set(n)); - return this._viewLayout; + this.viewLayout = ViewLayout.create(owner, this, content); + this.viewLayout.maximized.addListener(n => this.maximizedSectionId.set(n)); + owner.onDispose(() => this.viewLayout = null); + return this.viewLayout; }) ); }), @@ -700,7 +711,7 @@ export class GristDoc extends DisposableWithEvents { return section; } - return await this._viewLayout!.freezeUntil(docData.bundleActions( + return await this.viewLayout!.freezeUntil(docData.bundleActions( t("Saved linked section {{title}} in view {{name}}", {title:section.title(), name: viewModel.name()}), async () => { @@ -1021,7 +1032,7 @@ export class GristDoc extends DisposableWithEvents { // We can only open a popup for a section. if (!hash.sectionId) { return; } // We might open popup either for a section in this view or some other section (like Raw Data Page). - if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) { + if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId && !s.isCollapsed.peek())) { this.viewModel.activeSectionId(hash.sectionId); // If the anchor link is valid, set the cursor. if (hash.colRef && hash.rowId) { @@ -1178,7 +1189,7 @@ export class GristDoc extends DisposableWithEvents { // we must read the current layout from the view layout because it can override the one in // `section.layoutSpec` (in particular it provides a default layout when missing from the // latter). - const layoutSpec = this._viewLayout!.layoutSpec(); + const layoutSpec = this.viewLayout!.layoutSpec(); const sectionTitle = section.title(); const sectionId = section.id(); diff --git a/app/client/components/Layout.ts b/app/client/components/Layout.ts index 23e848a9..20f5da79 100644 --- a/app/client/components/Layout.ts +++ b/app/client/components/Layout.ts @@ -66,6 +66,14 @@ import {identity, last, uniqueId} from 'underscore'; export interface ContentBox { leafId: ko.Observable; leafContent: ko.Observable; + dom: HTMLElement|null; +} + +export interface BoxSpec { + leaf?: string|number; + size?: number; + children?: BoxSpec[]; + collapsed?: BoxSpec[]; } /** @@ -372,7 +380,7 @@ export class Layout extends Disposable { public leafId: string; private _leafIdMap: Map|null; - public create(boxSpec: object, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) { + public create(boxSpec: BoxSpec, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) { this.maximized = observable(null as (string|null)); this.rootBox = observable(null as any); this.createLeafFunc = createLeafFunc; @@ -424,13 +432,16 @@ export class Layout extends Disposable { * Calls cb on each box in the layout recursively. */ public forEachBox(cb: (box: LayoutBox) => void, optContext?: any) { + if (!this.rootBox.peek()) { + return; + } function iter(box: any) { cb.call(optContext, box); box.childBoxes.peek().forEach(iter); } iter(this.rootBox.peek()); } - public buildLayoutBox(boxSpec: any) { + public buildLayoutBox(boxSpec: BoxSpec) { // 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); @@ -445,7 +456,7 @@ export class Layout extends Disposable { } return box; } - public buildLayout(boxSpec: any, needDynamic = false) { + public buildLayout(boxSpec: BoxSpec, needDynamic = false) { this.needDynamic = needDynamic; const oldRootBox = this.rootBox(); this.rootBox(this.buildLayoutBox(boxSpec)); @@ -455,7 +466,7 @@ export class Layout extends Disposable { } } public _getBoxSpec(layoutBox: LayoutBox) { - const spec: any = {}; + const spec: BoxSpec = {}; if (layoutBox.isDisposed()) { return spec; } diff --git a/app/client/components/LayoutEditor.ts b/app/client/components/LayoutEditor.ts index 3613b4f3..0c9eb4b9 100644 --- a/app/client/components/LayoutEditor.ts +++ b/app/client/components/LayoutEditor.ts @@ -89,40 +89,42 @@ interface JqueryUI { originalSize: { width: number, height: number }; } +type LeafId = string|number; + /** * The Floater class represents a floating version of the element being dragged around. Its size * corresponds to the box being dragged. It lets the user see what's being repositioned. */ class Floater extends Disposable implements ContentBox { - public leafId: ko.Observable; + public leafId: ko.Observable; public leafContent: ko.Observable; public fillWindow: boolean; - public floaterElem: HTMLElement; + public dom: HTMLElement; public mouseOffsetX: number; public mouseOffsetY: number; public lastMouseEvent: MouseEvent | null; public create(fillWindow?: boolean) { - this.leafId = observable(null); + this.leafId = observable(null); this.leafContent = observable(null); this.fillWindow = fillWindow || false; - this.floaterElem = this.autoDispose(dom('div.layout_editor_floater', + this.dom = this.autoDispose(dom('div.layout_editor_floater', koDom.show(this.leafContent), koDom.scope(this.leafContent, (leafContent: Element) => { return leafContent; }) )); - G.document.body.appendChild(this.floaterElem); + G.document.body.appendChild(this.dom); this.mouseOffsetX = 0; this.mouseOffsetY = 0; this.lastMouseEvent = null; } - public onInitialMouseMove(mouseEvent: MouseEvent, sourceBox: LayoutBox) { + public onInitialMouseMove(mouseEvent: MouseEvent, sourceBox: ContentBox) { const rect = sourceBox.dom!.getBoundingClientRect(); - this.floaterElem.style.width = rect.width + 'px'; - this.floaterElem.style.height = rect.height + 'px'; + this.dom.style.width = rect.width + 'px'; + this.dom.style.height = rect.height + 'px'; this.mouseOffsetX = 0.2 * rect.width; this.mouseOffsetY = 0.1 * rect.height; this.onMouseMove(mouseEvent); @@ -141,8 +143,8 @@ class Floater extends Disposable implements ContentBox { } public onMouseMove(mouseEvent: MouseEvent) { this.lastMouseEvent = mouseEvent; - this.floaterElem.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px'; - this.floaterElem.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px'; + this.dom.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px'; + this.dom.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px'; } } @@ -240,6 +242,10 @@ class DropTargeter extends Disposable { this.autoDisposeCallback(this.removeTargetHints); } public removeTargetHints() { + if (this.activeTarget?.box?.dom) { + this.activeTarget.box.dom.style.transition = ''; + this.activeTarget.box.dom.style.padding = '0'; + } this.activeTarget = null; this.delayedInsertion.cancel(); if (this.targetsDom) { @@ -253,7 +259,7 @@ class DropTargeter extends Disposable { layoutBox: LayoutBox|null, affinity: number, overlay: DropOverlay, - prevTargetBox: LayoutBox + prevTargetBox?: LayoutBox ) { // Nothing to update. if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) { @@ -280,7 +286,7 @@ class DropTargeter extends Disposable { const children = layoutBox.childBoxes.peek(); // If one of two children is prevTargetBox, replace the last target hint since it // will be redundant once prevTargetBox is removed. - if (children.length === 2 && prevTargetBox.parentBox() === layoutBox) { + if (children.length === 2 && prevTargetBox?.parentBox() === layoutBox) { targetParts.splice(targetParts.length - 1, 1, {box: layoutBox, isChild: false, isAfter: isAfter}); } @@ -572,6 +578,7 @@ export class LayoutEditor extends Disposable { this.triggerUserEditStart(); this.targetBox = box; this.floater.onInitialMouseMove(event, box); + this.trigger('dragStart', this.originalBox); } public handleMouseUp(event: MouseEvent) { G.$(G.window).off('mousemove', this.boundMouseMove); @@ -582,16 +589,27 @@ export class LayoutEditor extends Disposable { return; } + // We stopped dragging, any listener can clean its modification + // to the floater element. + this.trigger('dragStop'); this.targetBox!.takeLeafFrom(this.floater); - if (this.dropTargeter.activeTarget) { - this.dropTargeter.accelerateInsertion(); - } else { - resizeLayoutBox(this.targetBox!, 'reset'); + // We dropped back the box to its original position, now + // anyone can hijack the box. + this.trigger('dragDrop', this.targetBox); + + // Check if the box was hijacked by a drop target. + if (this.originalBox?.leafId() !== 'empty') { + if (this.dropTargeter.activeTarget) { + this.dropTargeter.accelerateInsertion(); + } else { + resizeLayoutBox(this.targetBox!, 'reset'); + } } this.dropTargeter.removeTargetHints(); this.dropOverlay.detach(); - + this.trigger('dragEnd'); + // Cleanup for any state. this.transitionPromise.finally(() => { this.floater.onMouseUp(); resizeLayoutBox(this.targetBox!, 'reset'); @@ -600,20 +618,36 @@ export class LayoutEditor extends Disposable { this.triggerUserEditStop(); }); } - public removeContainingBox(elem: HTMLElement) { + + public getBoxFromElement(elem: HTMLElement) { const box = this.layout.getContainingBox(elem); + if (box && !box.isDomDetached()) { + return box; + } + return null; + } + + public getBox(leafId: number) { + return this.layout.getLeafBox(leafId); + } + + public removeContainingBox(box: LayoutBox) { if (box && !box.isDomDetached()) { this.triggerUserEditStart(); this.targetBox = box; - const rect = box.dom.getBoundingClientRect(); - box.leafId('empty'); - box.leafContent(dom('div.layout_editor_empty_space', - koDom.style('min-height', rect.height + 'px') - )); - this.onInsertBox(noop).catch(noop); + this.doRemoveBox(box); this.triggerUserEditStop(); } } + + public doRemoveBox(box: ContentBox) { + const rect = box.dom!.getBoundingClientRect(); + box.leafId('empty'); + box.leafContent(dom('div.layout_editor_empty_space', + koDom.style('min-height', rect.height + 'px') + )); + this.onInsertBox(noop).catch(noop); + } public handleMouseMove(event: MouseEvent) { // Make sure the grabbed box still exists if (!this.originalBox || this.originalBox?.isDisposed()) { @@ -626,6 +660,8 @@ export class LayoutEditor extends Disposable { } this.floater.onMouseMove(event); + this.trigger('dragMove', event, this.originalBox); + if (this.transitionPromise.isPending()) { // Don't attempt to do any repositioning while another reposition is happening. return; @@ -642,7 +678,14 @@ export class LayoutEditor extends Disposable { return; } this.trashDelay.cancel(); + this.updateTargets(event); + } + public updateTargets(event: MouseEvent) { + if (this.transitionPromise.isPending()) { + // Don't attempt to do any repositioning while another reposition is happening. + return; + } // See if we are over a layout_leaf, and that the leaf is in the same layout as the dragged // element. If so, we are dealing with repositioning. const elem = dom.findAncestor(event.target, this.rootElem, '.' + this.layout.leafId); diff --git a/app/client/components/LayoutTray.ts b/app/client/components/LayoutTray.ts new file mode 100644 index 00000000..3c27b97d --- /dev/null +++ b/app/client/components/LayoutTray.ts @@ -0,0 +1,1080 @@ +import {buildCollapsedSectionDom} from 'app/client/components/buildViewSectionDom'; +import * as commands from 'app/client/components/commands'; +import {ContentBox} from 'app/client/components/Layout'; +import type {ViewLayout} from 'app/client/components/ViewLayout'; +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {Signal} from 'app/client/lib/Signal'; +import {urlState} from 'app/client/models/gristUrlState'; +import {TransitionWatcher} from 'app/client/ui/transitions'; +import {theme} from 'app/client/ui2018/cssVars'; +import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; +import {isNonNullish} from 'app/common/gutil'; +import {Disposable, dom, makeTestId, MutableObsArray, obsArray, Observable, styled} from 'grainjs'; +import isEqual from 'lodash/isEqual'; + +const testId = makeTestId('test-layoutTray-'); + +const G = getBrowserGlobals('document', 'window', '$'); + + +/** + * Adds a tray for minimizing and restoring sections. It is built as a plugin for the ViewLayout component. + */ +export class LayoutTray extends DisposableWithEvents { + // We and LayoutEditor will emit this event with the box that is being dragged. When the + // drag is over there will be another event with null. + public drag = Signal.create(this, null); + // Event for dropping, contains a dropped element. + public drop = Signal.create(this, null); + // Monitor if the cursor is over the our tray. + public hovering = Signal.create(this, false); + // If the drag is active and the mouse is over the tray make a signal.. + public over = Signal.compute(this, on => Boolean(on(this.drag) && on(this.hovering))); + // Mouse events during dragging (without a state). + public dragging = Signal.create(this, null); + // Create a layout to actually render the collapsed sections. + public layout = CollapsedLayout.create(this, this); + // Whether we are active (have a dotted border, that indicates we are ready to receive a drop) + public active = Signal.create(this, false); + constructor(public viewLayout: ViewLayout) { + super(); + // Create a proxy for the LayoutEditor. It will mimic the same interface as CollapsedLeaf. + const externalLeaf = ExternalLeaf.create(this, this); + + // Build layout using saved settings. + this.layout.buildLayout(this.viewLayout.viewModel.collapsedSections.peek()); + + // Whenever we add or remove box, update the model. + this.layout.boxes.addListener(l => this.viewLayout.viewModel.activeCollapsedSections(this.layout.leafIds())); + + // Whenever saved settings are changed, rebuild the layout using them. + this.autoDispose(this.viewLayout.viewModel.collapsedSections.subscribe((val) => { + this.viewLayout.viewModel.activeCollapsedSections(val); + this.layout.buildLayout(val); + })); + + this._registerCommands(); + + // Override the drop event, to detect if we are dropped on the tray, and no one else + // gets the value. + this.drop.before((value, emit) => { + // Emit the value, if someone else will handle it, he should grab the state from it. + emit(value); + // See if the state is still there. + if (value && this.drop.state.get()) { + // No one took it, so we should handle it if we are over the tray. + if (this.over.state.get()) { + const leafId = value.leafId(); + // Ask it to remove itself from the target. + value.removeFromLayout(); + // Add it as a last element. + this.layout.addBox(leafId); + } + } + // Clear the state, any other listener will get null. + this.drop.state.set(null); + }); + + // Now wire up active state. + + // 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) + .flag() // Map to a boolean, and emit only when the value changes. + .filter(Boolean) // Only emit when it is set to true + .pipe(this.active); + + // Second, we can be activated when the drag has started by the main layout, and we don't have any boxes yet, but + // mouse pointer is relatively high on the screen. + Signal.compute(this, on => { + const drag = on(externalLeaf.drag); + if (!drag) { return false; } + const mouseEvent = on(externalLeaf.dragMove); + const over = mouseEvent && mouseEvent.clientY < 48; + return !!over; + }).flag().filter(Boolean).pipe(this.active); + + // If a drag has ended, we should deactivate. + this.drag.flag().filter(d => !d).pipe(this.active); + } + + public buildDom() { + return cssCollapsedTray( + testId('editor'), + // When drag is active we should show a dotted border around the tray. + cssCollapsedTray.cls('-is-active', this.active.state), + // If element is over the tray, we should indicate that we are ready by changing a color. + cssCollapsedTray.cls('-is-target', this.over.state), + // Synchronize the hovering state with the event. + syncHover(this.hovering), + // Create a drop zone (below actual sections) + dom.create(CollapsedDropZone, this), + // Build the layout. + this.layout.buildDom(), + // But show only if there are any sections in the tray (even if those are empty or drop target sections) + // or we can accept a drop. + dom.show(use => use(this.layout.boxes).length > 0 || use(this.active.state)), + ); + } + + public buildContentDom(id: string|number) { + return buildCollapsedSectionDom({ + gristDoc: this.viewLayout.gristDoc, + sectionRowId: id, + }); + } + + private _registerCommands() { + const viewLayout = this.viewLayout; + // Add custom commands for options in the menu. + const commandGroup = { + // Collapse visible section. + collapseSection: () => { + const leafId = viewLayout.viewModel.activeSectionId(); + if (!leafId) { return; } + + // Find the box for this section in the layout. + const box = viewLayout.layoutEditor.getBox(leafId); + if (!box) { return; } + + // Change the active section now. This is important as this will destroy the view before we + // remove the box from the dom. Charts are very sensitive for this. + viewLayout.viewModel.activeSectionId( + // We can't collapse last section, so the main layout will always have at least one section. + viewLayout.layoutEditor.layout.getAllLeafIds().filter(x => x !== leafId)[0] + ); + + // Add the box to our collapsed editor. + this.layout.addBox(leafId); + + // Remove it from the main layout. + box.dispose(); + + // And ask the viewLayout to save the specs. + viewLayout.saveLayoutSpec(); + }, + expandSection: () => { + // Get the section that is collapsed and clicked (we are setting this value). + const leafId = viewLayout.viewModel.activeCollapsedSectionId(); + if (!leafId) { return; } + this.layout.removeBy(leafId); + viewLayout.viewModel.activeCollapsedSectionId(0); + viewLayout.viewModel.activeSectionId(leafId); + viewLayout.saveLayoutSpec(); + }, + // Delete collapsed section. + deleteCollapsedSection: () => { + // This section is still in the view (but not in the layout). So we can just remove it. + const leafId = viewLayout.viewModel.activeCollapsedSectionId(); + if (!leafId) { return; } + this.viewLayout.removeViewSection(leafId); + // We need to manually update the layout. Main layout editor doesn't care about missing sections. + // but we can't afford that. Without removing it, user can add another section that will be collapsed + // from the start, as the id will be the same as the one we just removed. + const currentSpec = viewLayout.viewModel.layoutSpecObj(); + const validSections = new Set(viewLayout.viewModel.viewSections.peek().peek().map(vs => vs.id.peek())); + validSections.delete(leafId); + currentSpec.collapsed = currentSpec.collapsed + ?.filter(x => typeof x.leaf === 'number' && validSections.has(x.leaf)); + viewLayout.saveLayoutSpec(currentSpec); + } + }; + this.autoDispose(commands.createGroup(commandGroup, this, true)); + } +} + +/** + * Main component that detects where the section should be dropped. + */ +class CollapsedDropZone extends Disposable { + private _rootElement: HTMLElement; + // Some operations will be blocked when we are waiting for an animation to finish. + private _animation = Observable.create(this, 0); + private _lastTarget: TargetLeaf | undefined; + private _lastIndex = -1; + + constructor(protected model: LayoutTray) { + super(); + // When the drag has started or has finished we will add an empty leaf that can accept + // dragged section. Event is fire only once, and it will be fired with a null when the draggable + // has finished. + let pushedLeaf: EmptyLeaf | undefined; + const layout = model.layout; + + this.autoDispose(model.active.distinct().listen(ok => { + if (ok) { + pushedLeaf = EmptyLeaf.create(layout.boxes, this.model); + layout.boxes.push(pushedLeaf); + } else if (pushedLeaf) { + layout.remove(pushedLeaf); + } + })); + } + + public buildDom() { + const obsRects = Observable.create(this, [] as Array); + return (this._rootElement = cssVirtualZone( + // We are only rendered when mouse is over the tray and it has some dragged leaf with it. + dom.maybeOwned(this.model.over.state, (owner) => { + // Get the bounding rect of the rootElement, virtual rects are relative, so we will be + // adjusting coordinates. + const root = this._rootElement.getBoundingClientRect(); + // We store rects in an observable, that might be used to visualize the zones. + // Create the mouseMove listener. + const listener = async (e: MouseEvent) => { + if (owner.isDisposed() || this._isAnimating()) { + return; + } + // If there are some previous rects (from previous calculation), test if we are still in one of them. + if (this._lastTarget) { + const stillThere = obsRects.get()[this._lastIndex]?.contains(e); + if (stillThere) { + return; + } + } + // Calculate the virtual zones. + obsRects.set(this._calculate(root)); + // Find the one under the mouse. + const underMouse = obsRects.get().findIndex((x) => x?.contains(e)); + // If it is still the same, do nothing. + if (underMouse === this._lastIndex) { return; } + // If we found something, insert a drop target. + if (underMouse !== -1) { + this._insertDropTarget(underMouse) + .catch((err) => console.error(`Failed to insert zone:`, err)); // This should not happen. + return; + } + // We haven't found anything, remove the last drop target. + this._removeDropZone().catch((err) => console.error(`Failed to remove zone:`, err));// This should not happen. + }; + G.window.addEventListener('mousemove', listener); + // When mouse leaves, we need to remove the last drop target. + owner.onDispose(() => { + this._removeDropZone().catch((err) => console.error(`Failed to remove zone:`, err));// This should not happen. + }); + owner.onDispose(() => G.window.removeEventListener('mousemove', listener)); + // For debugging, we can show the virtual zones. + const show = false; + return !show ? null : dom.domComputed( + obsRects, + rects => rects.filter(isNonNullish).map((rect: VRect) => cssVirtualPart( + {style: `left: ${rect.left}px; width: ${rect.width}px; top: ${rect.top}px; height: ${rect.height}px;`} + ))); + }) + )); + } + + private _start() { + this._animation.set(this._animation.get() + 1); + } + private _stop() { + this._animation.set(this._animation.get() - 1); + } + private _isAnimating() { + return this._animation.get() > 0; + } + private _calculate(parentRect: DOMRect) { + const boxes = this.model.layout.boxes.get(); + const rects: Array = []; + // Boxes can be wrapped, we will detect the line offset. + let lineOffset = 12; + // We will always have at least one box, so we can use it to get the height. + const height = boxes[0]?.rootElement.getBoundingClientRect().height; + for (let i = 0; i < boxes.length; i++) { + const box = boxes[i]; + const prev = boxes[i - 1]; + const next = boxes[i + 1]; + + // First handle edge cases (don't add targets for first elements in next lines), it will mess up the wrapping. + if (prev && prev?.rootElement.offsetTop !== box.rootElement.offsetTop) { + rects.push(null); + continue; + } + + // Now handle normal cases. + const root = box.rootElement; + lineOffset = root.offsetTop; + + if (i === 0 && box instanceof CollapsedLeaf) { + // For the first one, we have very little rectangle, from the left + 50px past the left border. + const left = 0; + const right = root.offsetLeft + 50; + rects.push(new VRect(parentRect, { left, top: lineOffset, right, height })); + } else if (box instanceof CollapsedLeaf && i === boxes.length - 1) { + // Last one is very similar, little rectangle on the left part. + const left = root.offsetLeft + root.offsetWidth - 30; + const right = root.offsetLeft + root.offsetWidth + 30; + rects.push(new VRect(parentRect, { left, top: lineOffset, right, height })); + } else if (box instanceof CollapsedLeaf && prev instanceof CollapsedLeaf) { + // In between, we have a rectangle from the left border to the right border. + const leftRoot = prev.rootElement; + const rightRoot = root; + const left = leftRoot.offsetLeft + leftRoot.offsetWidth - 30; + const right = rightRoot.offsetLeft + 30; + rects.push(new VRect(parentRect, { left, top: lineOffset, right, height })); + } else if (next && box instanceof TargetLeaf && i === 0) { + // If this is a first box and it is a target, the first rectangle will be much larger, it should cover + // the TargetLeaf width. + const left = 0; + const right = next.rootElement.offsetLeft; + rects.push(new VRect(parentRect, { left, top: lineOffset, right, height })); + } else if (box instanceof TargetLeaf && prev instanceof CollapsedLeaf && next instanceof CollapsedLeaf) { + // If this box is target between two collapsed boxes, we will have a rectangle from the prev to next + // covering the whole target leaf. + const left = prev.rootElement.offsetLeft + prev.rootElement.offsetWidth - 30; + const right = next.rootElement.offsetLeft + 30; + rects.push(new VRect(parentRect, { left, top: lineOffset, right, height })); + } + } + return rects; + } + private async _insertDropTarget(index: number) { + this._start(); + try { + await this._lastTarget?.remove(); + this._lastTarget = TargetLeaf.create(null, this.model); + await this._lastTarget.insert(index); + this._lastIndex = index; + } finally { + this._stop(); + } + } + private async _removeDropZone() { + if (!this._lastTarget) { return; } + this._start(); + try { + await this._lastTarget?.remove(); + this._lastTarget = undefined; + this._lastIndex = -1; + } finally { + this._stop(); + } + } +} + + +/** + * UI component that renders and owns all the collapsed leaves. + */ +class CollapsedLayout extends Disposable { + public boxes = this.autoDispose(obsArray()); + public rootElement: HTMLElement; + + constructor(protected model: LayoutTray) { + super(); + } + + public buildLayout(leafs: number[]) { + if (isEqual(leafs, this.boxes.get().map((box) => box.id.get()))) { return; } + this.boxes.splice(0, this.boxes.get().length, + ...leafs.map((id) => CollapsedLeaf.create(this.boxes, this.model, id))); + } + + public addBox(id: number, index?: number) { + index ??= -1; + const box = CollapsedLeaf.create(this.boxes, this.model, id); + return this.insert(index, box); + } + + public indexOf(box: Leaf) { + return this.boxes.get().indexOf(box); + } + + public insert(index: number, leaf: Leaf) { + if (index < 0) { + this.boxes.push(leaf); + } else { + this.boxes.splice(index, 0, leaf); + } + return leaf; + } + + public remove(leaf: Leaf | undefined) { + const index = this.boxes.get().indexOf(leaf!); + if (index >= 0) { + this.boxes.splice(index, 1); + } + } + + public removeAt(index: number) { + index = index < 0 ? this.boxes.get().length - 1 : index; + removeFromObsArray(this.boxes, (l, i) => i === index); + } + + public removeBy(id: number) { + removeFromObsArray(this.boxes, box => box.id.get() === id); + } + + public leafIds() { + return this.boxes.get().map(l => l.id.get()).filter(x => x && typeof x === 'number'); + } + + public buildDom() { + return (this.rootElement = cssLayout( + testId('layout'), + useDragging(), + dom.hide(use => use(this.boxes).length === 0), + dom.forEach(this.boxes, line => line.buildDom()) + )); + } +} + +interface Draggable { + dragStart?: (ev: DragEvent, floater: MiniFloater) => Draggable|null; + dragEnd?: (ev: DragEvent, floater: MiniFloater) => void; + drag?: (ev: DragEvent, floater: MiniFloater) => void; + drop?: (ev: DragEvent, floater: MiniFloater) => void; +} + +interface Dropped { + removeFromLayout(): void; + leafId(): number; +} + +/** + * Base class for all the leaves in the layout tray. + */ +abstract class Leaf extends Disposable { + public id = Observable.create(this, 0); + public rootElement: HTMLElement; + public buildDom(): HTMLElement|null { + return null; + } +} + +/** + * Empty leaf that is used to represent the empty space in the collapsed layout. Can be used to drop boxes. + */ +class EmptyLeaf extends Leaf { + public name = Observable.create(this, 'empty'); + + // If we are hovering over the empty leaf. + private _onHover = Signal.create(this, false); + + constructor(protected model: LayoutTray) { + super(); + this.monitorDrop(); + } + + public monitorDrop() { + this.autoDispose( + this.model.drop.listen((box) => { + // If some box was dropped, and the cursor is over this leaf, we will add the box to the layout. + if (!box || !this._onHover.state.get()) { + return; + } + this.model.drop.state.set(null); + // Replace the empty leaf with the dropped box. + const myIndex = this.model.layout.indexOf(this); + const leafId = box.leafId(); + box.removeFromLayout(); + this.model.layout.addBox(leafId, myIndex); + }) + ); + } + + public buildDom() { + return (this.rootElement = cssEmptyBox( + cssEmptyBox.cls('-can-accept', this._onHover.state), + syncHover(this._onHover), + testId('empty-box'), + )); + } +} + +/** + * This is an empty leaf that supports animation when added to the list. + */ +class TargetLeaf extends EmptyLeaf { + public buildDom() { + this.name.set('target'); + const element = super.buildDom(); + dom.update(element, + testId('target-box'), + dom.cls(cssProbe.className), + { style: 'width: 2px;' } + ); + return element; + } + + public insert(index: number) { + // First insert the drop target leaf. + this.model.layout.insert(index, this); + // Force the reflow, so that we can start the animation. + this.rootElement.getBoundingClientRect(); + // Start and wait for the animation to finish. + return new Promise((resolve) => { + const watcher = new TransitionWatcher(this.rootElement); + watcher.onDispose(() => { + resolve(undefined); + }); + this.rootElement.style.width = ''; + }); + } + + public remove() { + return new Promise((resolve) => { + const watcher = new TransitionWatcher(this.rootElement); + watcher.onDispose(() => { + this.model.layout.remove(this); + resolve(undefined); + }); + this.rootElement.style.width = '0px'; + }); + } +} + +/** + * This is the collapsed widget that is shown in the collapsed layout. It can be dragged and dropped. + */ +class CollapsedLeaf extends Leaf implements Draggable, Dropped { + // The content of the leaf that is rendered. Stored in an observable so that we can update it when the + // content changes or put it in the floater. + private _content: Observable = Observable.create(this, null); + + // Helper to keeping track of the index of the leaf in the layout. + private _indexWhenDragged = 0; + constructor(protected model: LayoutTray, id: number) { + super(); + this.id.set(id); + } + public buildDom() { + this._content.set(this.model.buildContentDom(this.id.get())); + return (this.rootElement = cssBox( + testId('leaf-box'), + dom.domComputed(this._content, c => c), + // Add draggable interface. + asDraggable(this), + dom.on('click', (e) => { + this.model.viewLayout.viewModel.activeCollapsedSectionId(this.id.get()); + // Sanity (and type) check. + if (!(e.target instanceof HTMLElement)) { + return; + } + // If the click not landed in a draggable-handle ignore it. Might be a click to open the menu. + if (!e.target.closest('.draggable-handle')) { + return; + } + // Apparently the click was to open the section in the popup. Use the anchor link to do that. + // Show my section on a popup using anchor link. We can't use maximize section for it, as we + // would need to rebuild the layout (as this is not a part of it). + urlState().pushUrl({ + hash: { + sectionId: this.id.get(), + popup: true + } + }).catch(() => {}); + e.preventDefault(); + e.stopPropagation(); + }) + )); + } + + // Implement the drag interface. All those methods are called by the draggable helper. + + public dragStart(ev: DragEvent, floater: MiniFloater) { + // Get the element. + const myElement = this._content.get(); + this._content.set(null); + floater.content.set(myElement); + // Create a clone. + const clone = CollapsedLeaf.create(floater, this.model, this.id.get()); + clone._indexWhenDragged = this.model.layout.indexOf(this); + this.model.drag.emit(clone); + this.model.layout.remove(this); + return clone; + } + + public dragEnd(ev: DragEvent) { + this.model.drag.emit(null); + } + + public drag(ev: DragEvent) { + this.model.dragging.emit(ev); + } + + public drop(ev: DragEvent, floater: MiniFloater) { + // Take back the element. + const element = floater.content.get(); + floater.content.set(null); + this._content.set(element); + this.model.drop.emit(this); + // If I wasn't moved somewhere else, read myself back. + if (this.id.get() !== 0) { + this.model.layout.addBox(this.id.get(), this._indexWhenDragged); + } + } + + public removeFromLayout() { + // Set the id to 0 so that the layout doesn't try to read me back. + this.id.set(0); + this.model.layout.remove(this); + } + + public leafId() { + return this.id.get(); + } +} + +/** + * This is analogous component to the main Floater in the LayoutEditor. It holds the little preview of a widget, + * while it is dragged. + */ +class MiniFloater extends Disposable { + public content: Observable = Observable.create(this, null); + public rootElement: HTMLElement; + constructor() { + super(); + this.rootElement = this.buildDom(); + G.document.body.appendChild(this.rootElement); + this.onDispose(() => { + this.rootElement.remove(); + dom.domDispose(this.rootElement); + }); + } + + public buildDom() { + return cssMiniFloater( + dom.show(use => Boolean(use(this.content))), + // dom.cls('layout_editor_floater'), + dom.domComputed(this.content, c => c) + ); + } + + public onMove(ev: MouseEvent) { + if (this.content.get()) { + this.rootElement.style.left = `${ev.clientX}px`; + this.rootElement.style.top = `${ev.clientY}px`; + } + } +} + +/** + * ExternalLeaf pretends that it is a collapsed leaf and acts as a proxy between collapsed tray and the + * ViewLayout. + */ +class ExternalLeaf extends Disposable implements Dropped { + // If external element is in drag mode + public drag: Signal; + // Event when external leaf is being dragged. + public dragMove: Signal; + + // Event when external leaf is dropped. + private _drop: Signal; + + constructor(protected model: LayoutTray) { + super(); + // Wire up external events to mimic that we are a part. + + // First we will replace all events, so that they won't emit anything if we are the only leaf + // in the layout. + const multipleLeaves = () => this.model.viewLayout.layout.getAllLeafIds().length > 1; + + this.drag = Signal.fromEvents(this, this.model.viewLayout.layoutEditor, 'dragStart', 'dragEnd') + .filter(multipleLeaves); + + this._drop = Signal.fromEvents(this, this.model.viewLayout.layoutEditor, 'dragDrop') + .filter(multipleLeaves); + + this.dragMove = Signal.fromEvents(this, this.model.viewLayout.layoutEditor, 'dragMove') + .filter(multipleLeaves); + + // Now bubble up those events to the model. + + // For dragging we just need to know that it is on or off. + this.drag.map(box => { + // We are tricking the model, we report that we are dragged, not the external leaf. + return box ? this as Dropped : null; + }).distinct().pipe(this.model.drag); + + + // When the external box is dropped, we will pretend that we were dropped. + this._drop.map(x => this as Dropped|null).pipe(this.model.drop); + + // Listen to the inDrag state in the model, if the dragged element is not us, update + // target hits. Otherwise target hits will be updated by the viewLayout. + this.autoDispose(model.dragging.listen(ev => { + // If the dragged box is not us, we need to update the targets. + if (ev && model.drag.state.get() !== this) { + this.model.viewLayout.layoutEditor.updateTargets(ev); + } + })); + + // When drag is started by tray, we need to fire up user edit event. This is only needed + // because the viewLayout has a different UI when user is editing. + const miniDrag = Signal.compute(this, on => on(model.drag) && !on(this.drag)).map(Boolean).distinct(); + this.autoDispose(miniDrag.listen(box => { + if (box) { + this.model.viewLayout.layoutEditor.triggerUserEditStart(); + } else { + const dropTargeter = this.model.viewLayout.layoutEditor.dropTargeter; + dropTargeter.removeTargetHints(); + // Save the layout immediately after the drop. Otherwise we would wait a bit, + // and the section won't be created on time. + this.model.viewLayout.layoutEditor.triggerUserEditStop(); + // Manually save the layout. + this.model.viewLayout.saveLayoutSpec(); + } + })); + + + // We are responsible for saving the layout, when section is collapsed or expanded. + + // Also we need to monitor when mini leaf is dropped, it will trigger a drop event, + // but non-one will listen to it. + this.autoDispose( + model.drop.listen(dropped => { + if (!dropped) { + return; + } + // If I was dropped (collapsed) over the tray, we don't need to do anything here. + // Our leaf was removed already and the layout will be saved by the miniDrag event. + + // If I was dropped anywhere else, we don't need to do anything either, viewLayout will + // take care of it. + if (dropped === this) { + return; + } + // We only care when collapsed widget was dropped over the main area. + const externalEditor = this.model.viewLayout.layoutEditor; + const dropTargeter = this.model.viewLayout.layoutEditor.dropTargeter; + // Check that it was dropped over the main area. + if (dropTargeter?.activeTarget && !dropTargeter?.activeTarget?.box.isDisposed()) { + // Remove the widget from the tray, and at new leaf to the layout. + const part = dropTargeter.activeTarget; + dropTargeter.removeTargetHints(); + const leaf = dropped.leafId(); + dropped.removeFromLayout(); + const box = externalEditor.layout.buildLayoutBox({leaf}); + if (part.isChild) { + part.box.addChild(box, part.isAfter); + } else { + part.box.addSibling(box, part.isAfter); + } + this.model.drop.state.set(null); + } + }) + ); + this._replaceFloater(); + } + + /** + * Dropped interface implementation. It calls _collapseSection to make it obvious that this is + * what is happening, we are only called when a section in the main area is collapsed (dragged onto the valid target + * in the tray). + */ + public removeFromLayout() { + this._collapseSection(); + } + + public leafId() { + return this._drop.state.get()?.leafId.peek() || 0; + } + + private _collapseSection() { + const droppedBox = this._drop.state.get(); + if (!droppedBox) { return; } + const leafId = this.leafId(); + const otherSection = this.model.viewLayout.layoutEditor + .layout.getAllLeafIds().find(x => typeof x === 'number' && x !== leafId); + this.model.viewLayout.viewModel.activeSectionId(otherSection); + this.model.viewLayout.layoutEditor.doRemoveBox(droppedBox); + } + + /** + * Monitors the external floater element, and if it is on top of the collapsed tray, replaces its content. + */ + private _replaceFloater() { + const model = this.model; + // We will replace floater just after it starts till it is about to be dropped. + const period = Signal.fromEvents(model, model.viewLayout.layoutEditor, 'dragStart', 'dragStop'); + const overEditor = Signal.compute(model, on => Boolean(on(period) && on(model.over))).distinct(); + let lastContent: HTMLElement|null = null; + let lastTransform: string|null = null; + let lastX: number|null = null; + let lastY: number|null = null; + // When the external box is on top of the tray, we need to replace the content to be much smaller. + model.autoDispose( + overEditor.listen(over => { + if (over) { + const floater = model.viewLayout.layoutEditor.floater; + const leafId = floater.leafId.peek(); + if (typeof leafId !== 'number') { + return; + } + const content = floater.leafContent.peek() as HTMLElement; + if (content) { + lastContent = content; + // Hide this element. + content.style.display = 'none'; + // Create another element to show in the floater. + const newContent = cssFloaterWrapper(content, buildCollapsedSectionDom({ + gristDoc: model.viewLayout.gristDoc, + sectionRowId: leafId, + })); + floater.leafContent(newContent); + lastTransform = floater.dom.style.transform; + lastX = floater.mouseOffsetX; + lastY = floater.mouseOffsetY; + floater.dom.style.transform = 'none'; + floater.mouseOffsetX = 0; + floater.mouseOffsetY = 0; + } + } else if (lastContent) { + lastContent.style.display = ''; + const floater = model.viewLayout.layoutEditor.floater; + const currentContent = floater.leafContent.peek() as HTMLElement; + floater.leafContent(lastContent); + if (currentContent) { + dom.domDispose(currentContent); + } + lastContent = null; + floater.dom.style.transform = lastTransform!; + floater.mouseOffsetX = lastX!; + floater.mouseOffsetY = lastY!; + } + }) + ); + } +} + +function syncHover(obs: Signal) { + return [dom.on('mouseenter', () => obs.emit(true)), dom.on('mouseleave', () => obs.emit(false))]; +} + +/** + * Finds element that is marked as draggable from the mouse event. + */ +function findDraggable(ev: EventTarget|null) { + if (ev instanceof HTMLElement) { + const target = ev.closest(".draggable-handle")?.closest(".draggable"); + return !target ? null : dom.getData(target, 'draggable') as Draggable; + } + return null; +} + +/** + * Marks a dom element as draggable. It sets a class and a data attribute that is looked up by the useDragging helper. + */ +function asDraggable(item: Draggable) { + return [ + dom.cls('draggable'), + dom.data('draggable', item) + ]; +} + +/** + * Attaches a mouse events for dragging to a parent container. This way we have a single mouse event listener + * for all draggable elements. All events are then delegated to the draggable elements. + * + * When a drag is started a MiniFloater is created, and the draggable element can be moved to the floater. + */ +function useDragging() { + return (el: HTMLElement) => { + // Implement them by hand, using mouseenter, mouseleave, and mousemove events. + // This is a inspired by LayoutEditor.ts. + let justStarted = false; + let isDragging = false; + let dragged: Draggable|null = null; + let floater: MiniFloater|null = null; + const listener = (ev: MouseEvent) => { + switch (ev.type) { + case 'mousedown': + // Only handle left button. + if (ev.button !== 0) { + return; + } + // If we haven't found a draggable element, return. + dragged = findDraggable(ev.target); + if (!dragged) { + return; + } + // If we had floater, dispose it. + floater?.dispose(); + floater = new MiniFloater(); + // Start drag and attach mousemove and mouseup listeners. + justStarted = true; + G.$(G.window).on('mousemove', mouseMoveListener); + G.$(G.window).on('mouseup', mouseUpListener); + return false; + case 'mouseup': + if (!dragged) { + return; + } + justStarted = false; + G.$(G.window).off('mousemove', mouseMoveListener); + G.$(G.window).off('mouseup', mouseUpListener); + + if (isDragging) { + isDragging = false; + if (dragged?.drop) { + dragged.drop(ev as DragEvent, floater!); + } + if (dragged?.dragEnd) { + dragged.dragEnd(ev as DragEvent, floater!); + } + } + dragged = null; + floater?.dispose(); + floater = null; + return false; + case 'mousemove': + // We start the drag with a first mousemove event. We don't want to start dragging if the mouse + // hasn't moved. + if (justStarted) { + justStarted = false; + if (dragged?.dragStart) { + // Drag element has an opportunity to return a new draggable object. + dragged = dragged.dragStart(ev as DragEvent, floater!); + if (!dragged) { + return; + } + } + // Now we are dragging. + isDragging = true; + } + if (!isDragging) { + return; + } + if (dragged?.drag) { + dragged.drag(ev as DragEvent, floater!); + } + floater!.onMove(ev); + return false; + } + }; + const mouseMoveListener = (ev: MouseEvent) => listener(ev); + const mouseUpListener = (ev: MouseEvent) => listener(ev); + dom.autoDisposeElem(el, dom.onElem(G.window, 'mousedown', (e) => listener(e))); + dom.onDisposeElem(el, () => (floater?.dispose(), floater = null)); + }; +} + +function removeFromObsArray(boxes: MutableObsArray, selector: (box: T, index: number) => boolean) { + const index = boxes.get().findIndex(selector); + if (index !== -1) { + boxes.splice(index, 1); + } +} + +/** + * A virtual rectangle that is relative to a DOMRect. + */ +class VRect { + public left: number; + public width: number; + public top: number; + public right: number; + public height: number; + constructor(offset: DOMRect, params: Partial) { + Object.assign(this, params); + this.left += offset.left; + this.right += offset.left; + this.top += offset.top; + this.width = this.right - this.left; + } + public contains(ev: MouseEvent) { + return ev.clientX >= this.left && ev.clientX <= this.right && + ev.clientY >= this.top && ev.clientY <= this.top + this.height; + } +} + +const cssVirtualZone = styled('div', ` + position: absolute; + inset: 0; +`); + + +const cssFloaterWrapper = styled('div', ` + height: 40px; + width: 140px; + max-width: 140px; + background: ${theme.tableBodyBg}; + border: 1px solid ${theme.widgetBorder}; + border-radius: 3px; + -webkit-transform: rotate(5deg) scale(0.8) translate(-10px, 0px); + transform: rotate(5deg) scale(0.8) translate(-10px, 0px); + & .mini_section_container { + overflow: hidden; + white-space: nowrap; + } +`); + +const cssCollapsedTray = styled('div.collapsed_layout', ` + border-radius: 3px; + display: flex; + flex-direction: column; + border-radius: 3px; + display: flex; + overflow: hidden; + transition: height 0.2s; + position: relative; + margin-bottom: 10px; + user-select: none; + + &-is-active { + outline: 2px dashed ${theme.widgetBorder}; + } + &-is-target { + outline: 2px dashed #7B8CEA; + background: rgba(123, 140, 234, 0.1); + } +` +); + +const cssRow = styled('div', `display: flex`); +const cssLayout = styled(cssRow, ` + padding: 12px; + gap: 10px; + flex-wrap: wrap; + position: relative; +`); + +const cssBox = styled('div', ` + border: 1px solid ${theme.widgetBorder}; + border-radius: 3px; + background: ${theme.widgetBg}; + min-width: 120px; + min-height: 34px; + cursor: pointer; +`); + +const cssEmptyBox = styled('div', ` + text-align: center; + text-transform: uppercase; + color: ${theme.widgetBorder}; + font-weight: bold; + letter-spacing: 1px; + border: 2px dashed ${theme.widgetBorder}; + border-radius: 3px; + padding: 8px; + width: 120px; + min-height: 34px; + &-can-accept { + border: 2px dashed #7B8CEA; + background: rgba(123, 140, 234, 0.1); + } +`); + +const cssProbe = styled('div', ` + min-width: 0px; + padding: 0px; + transition: width 0.2s ease-out; +`); + +const cssMiniFloater = styled(cssBox, ` + pointer-events: none; + position: absolute; + overflow: hidden; + pointer-events: none; + z-index: 10; + -webkit-transform: rotate(5deg) scale(0.8); + transform: rotate(5deg) scale(0.8); + transform-origin: top left; +`); + +const cssVirtualPart = styled('div', ` + outline: 1px solid blue; + position: absolute; + z-index: 10; + background: rgba(0, 0, 0, 0.1); +`); diff --git a/app/client/components/RawDataPage.ts b/app/client/components/RawDataPage.ts index ce52cde2..1f74d6f3 100644 --- a/app/client/components/RawDataPage.ts +++ b/app/client/components/RawDataPage.ts @@ -3,12 +3,13 @@ import {DataTables} from 'app/client/components/DataTables'; import {DocumentUsage} from 'app/client/components/DocumentUsage'; import {GristDoc} from 'app/client/components/GristDoc'; import {printViewSection} from 'app/client/components/Printing'; -import {buildViewSectionDom, ViewSectionHelper} from 'app/client/components/ViewLayout'; +import {ViewSectionHelper} from 'app/client/components/ViewLayout'; import {mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; import {reportError} from 'app/client/models/errors'; import {ViewSectionRec} from 'app/client/models/DocModel'; +import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom'; const testId = makeTestId('test-raw-data-'); @@ -76,6 +77,13 @@ export class RawDataPopup extends Disposable { super(); const commandGroup = { cancel: () => { this._onClose(); }, + deleteSection: () => { + // Normally this command is disabled on the menu, but for collapsed section it is active. + if (this._viewSection.isRaw.peek()) { + throw new Error("Can't delete a raw section"); + } + this._gristDoc.docData.sendAction(['RemoveViewSection', this._viewSection.id.peek()]).catch(reportError); + }, }; this.autoDispose(commands.createGroup(commandGroup, this, true)); } @@ -89,7 +97,7 @@ export class RawDataPopup extends Disposable { sectionRowId: this._viewSection.getRowId(), draggable: false, focusable: false, - widgetNameHidden: true + widgetNameHidden: this._viewSection.isRaw.peek(), // We are sometimes used for non raw sections. }) ), cssCloseButton('CrossBig', diff --git a/app/client/components/RecordLayoutEditor.js b/app/client/components/RecordLayoutEditor.js index 09116ef0..6b1339aa 100644 --- a/app/client/components/RecordLayoutEditor.js +++ b/app/client/components/RecordLayoutEditor.js @@ -130,7 +130,8 @@ RecordLayoutEditor.prototype.buildLeafDom = function() { dom.on('click', (ev, elem) => { ev.preventDefault(); ev.stopPropagation(); - this.layoutEditor.removeContainingBox(elem); + const box = this.layoutEditor.getBoxFromElement(elem); + this.layoutEditor.removeContainingBox(box); }) ) ); diff --git a/app/client/components/ViewLayout.css b/app/client/components/ViewLayout.css index 7fc46773..a70eedff 100644 --- a/app/client/components/ViewLayout.css +++ b/app/client/components/ViewLayout.css @@ -17,10 +17,13 @@ cursor: default; height: 24px; margin-left: -16px; /* to include drag handle that shows up on hover */ + white-space: nowrap; +} + +.viewsection_title, .viewsection_title_font { color: var(--grist-theme-text-light, var(--grist-color-slate)); font-size: var(--grist-small-font-size); font-weight: 500; - white-space: nowrap; } .viewsection_content { diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index e1c5be8e..0b251815 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -5,24 +5,24 @@ import {CustomView} from 'app/client/components/CustomView'; import * as DetailView from 'app/client/components/DetailView'; import * as GridView from 'app/client/components/GridView'; import {GristDoc} from 'app/client/components/GristDoc'; -import {Layout} from 'app/client/components/Layout'; +import {BoxSpec, Layout} from 'app/client/components/Layout'; import {LayoutEditor} from 'app/client/components/LayoutEditor'; import {printViewSection} from 'app/client/components/Printing'; import {Delay} from 'app/client/lib/Delay'; import {createObsArray} from 'app/client/lib/koArrayWrap'; import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; -import {filterBar} from 'app/client/ui/FilterBar'; -import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu'; -import {buildWidgetTitle} from 'app/client/ui/WidgetTitle'; -import {colors, isNarrowScreen, isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; +import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; +import {LayoutTray} from 'app/client/components/LayoutTray'; +import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom'; import {mod} from 'app/common/gutil'; import * as ko from 'knockout'; import * as _ from 'underscore'; import debounce from 'lodash/debounce'; -import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, Observable, styled, subscribe} from 'grainjs'; +import {Computed, computedArray, Disposable, dom, fromKo, Holder, + IDomComponent, Observable, styled, subscribe} from 'grainjs'; // tslint:disable:no-console @@ -49,8 +49,23 @@ export class ViewSectionHelper extends Disposable { constructor(gristDoc: GristDoc, vs: ViewSectionRec) { super(); this.onDispose(() => vs.viewInstance(null)); + // If this is a collapsed section (but not active), don't create an instance (or remove the old one). + // Collapsed section can be expanded and shown in the popup window, it will be active then. + // This is important to avoid recreating the instance when the section is collapsed, but mainly for the + // charts as they are not able to handle being detached from the dom. + const hidden = Computed.create(this, (use) => { + // Note: this is a separate computed from the one below (with subscribe method), because we don't want + // trigger it unnecessarily. + return use(vs.isCollapsed) && use(gristDoc.externalSectionId) !== use(vs.id); + }); this.autoDispose(subscribe((use) => { + // Destroy the instance if the section is hidden. + if (use(hidden)) { + this._instance.clear(); + vs.viewInstance(null); + return; + } // Rebuild the section when its type changes or its underlying table. const table = use(vs.table); const Cons = getInstanceConstructor(use(vs.parentKey)); @@ -69,18 +84,22 @@ export class ViewSectionHelper extends Disposable { export class ViewLayout extends DisposableWithEvents implements IDomComponent { public docModel = this.gristDoc.docModel; public viewModel: ViewRec; - public layoutSpec: ko.Computed; + public layoutSpec: ko.Computed; public maximized: Observable; + public isResizing = Observable.create(this, false); + public layout: Layout; + public layoutEditor: LayoutEditor; + public layoutTray: LayoutTray; + public layoutSaveDelay = this.autoDispose(new Delay()); private _freeze = false; - private _layout: Layout; - private _sectionIds: number[]; - private _isResizing = Observable.create(this, false); - + // Exposed for test to indicate that save has not yet been called. + private _savePending = Observable.create(this, false); constructor(public readonly gristDoc: GristDoc, viewId: number) { super(); this.viewModel = this.docModel.views.getRowModel(viewId); + // A Map from viewSection RowModels to corresponding View class instances. // TODO add a test that creating / deleting a section creates/destroys one instance, and // switching pages destroys all instances. @@ -93,40 +112,34 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { () => this._updateLayoutSpecWithSections(this.viewModel.layoutSpecObj())) .extend({rateLimit: 0})); - this._layout = this.autoDispose(Layout.create(this.layoutSpec(), + this.layout = this.autoDispose(Layout.create(this.layoutSpec(), this._buildLeafContent.bind(this), true)); - this._sectionIds = this._layout.getAllLeafIds(); + // When the layoutSpec changes by some means other than the layout editor, rebuild. // This includes adding/removing sections and undo/redo. - this.autoDispose(this.layoutSpec.subscribe((spec) => this._freeze || this._rebuildLayout(spec))); - - const layoutSaveDelay = this.autoDispose(new Delay()); + this.autoDispose(this.layoutSpec.subscribe((spec) => this._freeze || this.rebuildLayout(spec))); - this.listenTo(this._layout, 'layoutUserEditStop', () => { - this._isResizing.set(false); - layoutSaveDelay.schedule(1000, () => { - if (!this._layout) { return; } - - // Only save layout changes when the document isn't read-only. - if (!this.gristDoc.isReadonly.get()) { - (this.viewModel.layoutSpecObj as any).setAndSave(this._layout.getLayoutSpec()); - } - this._onResize(); + this.listenTo(this.layout, 'layoutUserEditStop', () => { + this.isResizing.set(false); + this.layoutSaveDelay.schedule(1000, () => { + this.saveLayoutSpec(); }); }); // Do not save if the user has started editing again. - this.listenTo(this._layout, 'layoutUserEditStart', () => { - layoutSaveDelay.cancel(); - this._isResizing.set(true); + this.listenTo(this.layout, 'layoutUserEditStart', () => { + this.layoutSaveDelay.cancel(); + this._savePending.set(true); + this.isResizing.set(true); }); - this.autoDispose(LayoutEditor.create(this._layout)); + this.layoutEditor = this.autoDispose(LayoutEditor.create(this.layout)); + this.layoutTray = LayoutTray.create(this, this); // Add disposal of this._layout after layoutEditor, so that it gets disposed first, and // layoutEditor doesn't attempt to update it in its own disposal logic. - this.onDispose(() => this._layout.dispose()); + this.onDispose(() => this.layout.dispose()); this.autoDispose(this.gristDoc.resizeEmitter.addListener(this._onResize, this)); @@ -148,19 +161,19 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { // Make resize. view.onResize(); }, 0); - this._layout.rootElem.addEventListener('transitionend', handler); + this.layout.rootElem.addEventListener('transitionend', handler); // Don't need to dispose the listener, as the rootElem is disposed with the layout. const classActive = cssLayoutBox.className + '-active'; const classInactive = cssLayoutBox.className + '-inactive'; this.autoDispose(subscribe(fromKo(this.viewModel.activeSection), (use, section) => { const id = section.getRowId(); - this._layout.forEachBox(box => { + 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 || null; + let elem: Element|null = this.layout.getLeafBox(id)?.dom || null; while (elem?.matches('.layout_box')) { elem.classList.remove(classInactive); elem.classList.add(classActive); @@ -172,10 +185,10 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { })); const commandGroup = { - deleteSection: () => { this._removeViewSection(this.viewModel.activeSectionId()); }, + deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()); }, nextSection: () => { this._otherSection(+1); }, prevSection: () => { this._otherSection(-1); }, - printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); }, + printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); }, sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); }, maximizeActiveSection: () => { this._maximizeActiveSection(); }, cancel: () => { @@ -186,7 +199,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { }; this.autoDispose(commands.createGroup(commandGroup, this, true)); - this.maximized = fromKo(this._layout.maximized) as any; + 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. @@ -201,9 +214,12 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { return cssOverlay( cssOverlay.cls('-active', use => !!use(this.maximized)), testId('viewLayout-overlay'), - cssLayoutWrapper( - cssLayoutWrapper.cls('-active', use => !!use(this.maximized)), - this._layout.rootElem, + cssLayoutContainer( + this.layoutTray.buildDom(), + cssLayoutWrapper( + cssLayoutWrapper.cls('-active', use => !!use(this.maximized)), + this.layout.rootElem, + ), ), dom.maybe(use => !!use(this.maximized), () => cssCloseButton('CrossBig', @@ -212,7 +228,8 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { ) ), // Close the lightbox when user clicks exactly on the overlay. - dom.on('click', (ev, elem) => void (ev.target === elem && this.maximized.get() ? close() : null)) + dom.on('click', (ev, elem) => void (ev.target === elem && this.maximized.get() ? close() : null)), + dom.cls('test-viewLayout-save-pending', this._savePending) ); } @@ -225,20 +242,41 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { return await promise; } finally { this._freeze = false; - this._rebuildLayout(this.layoutSpec.peek()); + this.rebuildLayout(this.layoutSpec.peek()); } } + public saveLayoutSpec(specs?: BoxSpec) { + this._savePending.set(false); + // Cancel the automatic delay. + this.layoutSaveDelay.cancel(); + if (!this.layout) { return; } + // Only save layout changes when the document isn't read-only. + if (!this.gristDoc.isReadonly.get()) { + if (!specs) { + specs = this.layout.getLayoutSpec(); + specs.collapsed = this.viewModel.activeCollapsedSections.peek().map((leaf)=> ({leaf})); + } + this.viewModel.layoutSpecObj.setAndSave(specs).catch(reportError); + } + this._onResize(); + } + // Removes a view section from the current view. Should only be called if there is // more than one viewsection in the view. - private _removeViewSection(viewSectionRowId: number) { + public removeViewSection(viewSectionRowId: number) { this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError); } + public rebuildLayout(layoutSpec: object) { + this.layout.buildLayout(layoutSpec, true); + this._onResize(); + } + private _maximizeActiveSection() { const activeSection = this.viewModel.activeSection(); const activeSectionId = activeSection.getRowId(); - const activeSectionBox = this._layout.getLeafBox(activeSectionId); + const activeSectionBox = this.layout.getLeafBox(activeSectionId); if (!activeSectionBox) { return; } activeSectionBox.maximize(); } @@ -247,7 +285,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { return buildViewSectionDom({ gristDoc: this.gristDoc, sectionRowId, - isResizing: this._isResizing, + isResizing: this.isResizing, viewModel: this.viewModel }); } @@ -256,7 +294,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { * If there is no layout saved, we can create a default layout just from the list of fields for * this view section. By default we just arrange them into a list of rows, two fields per row. */ - private _updateLayoutSpecWithSections(spec: object) { + private _updateLayoutSpecWithSections(spec: BoxSpec) { // We use tmpLayout as a way to manipulate the layout before we get a final spec from it. const tmpLayout = Layout.create(spec, () => dom('div'), true); @@ -278,15 +316,18 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { } // For any stale fields (no longer among viewFields), remove them from tmpLayout. - _.difference(specFieldIds, viewSectionIds).forEach(function(leafId: any) { + _.difference(specFieldIds, viewSectionIds).forEach(function(leafId: string|number) { 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: any) { - // Only add the builder box if it hasn`t already been created - addToSpec(leafId); + const missingLeafs = _.difference(viewSectionIds, specFieldIds); + const collapsedLeafs = new Set((spec.collapsed || []).map(c => c.leaf)); + missingLeafs.forEach(function(leafId: any) { + if (!collapsedLeafs.has(leafId)) { + addToSpec(leafId); + } }); spec = tmpLayout.getLayoutSpec(); @@ -294,11 +335,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { return spec; } - private _rebuildLayout(layoutSpec: object) { - this._layout.buildLayout(layoutSpec, true); - this._onResize(); - this._sectionIds = this._layout.getAllLeafIds(); - } + // Resizes the scrolly windows of all viewSection classes with a 'scrolly' property. private _onResize() { @@ -313,17 +350,17 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { // Select another section in cyclic ordering of sections. Order is counter-clockwise if given a // positive `delta`, clockwise otherwise. private _otherSection(delta: number) { + const sectionIds = this.layout.getAllLeafIds(); const sectionId = this.viewModel.activeSectionId.peek(); - const currentIndex = this._sectionIds.indexOf(sectionId); - const index = mod(currentIndex + delta, this._sectionIds.length); - + const currentIndex = sectionIds.indexOf(sectionId); + const index = mod(currentIndex + delta, sectionIds.length); // update the active section id - this.viewModel.activeSectionId(this._sectionIds[index]); + this.viewModel.activeSectionId(sectionIds[index]); } private _maybeFocusInSection() { // If the focused element is inside a view section, make that section active. - const layoutBox = this._layout.getContainingBox(document.activeElement); + const layoutBox = this.layout.getContainingBox(document.activeElement); if (layoutBox && layoutBox.leafId) { this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek()); } @@ -336,7 +373,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { */ private _openSortFilterMenu(sectionId?: number) { const id = sectionId ?? this.viewModel.activeSectionId(); - const leafBoxDom = this._layout.getLeafBox(id)?.dom; + const leafBoxDom = this.layout.getLeafBox(id)?.dom; if (!leafBoxDom) { return; } const menu: HTMLElement | null = leafBoxDom.querySelector('.test-section-menu-sortAndFilter'); @@ -344,125 +381,6 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { } } -export function buildViewSectionDom(options: { - gristDoc: GristDoc, - sectionRowId: number, - isResizing?: Observable - viewModel?: ViewRec, - // Should show drag anchor. - draggable?: boolean, /* defaults to true */ - // Should show green bar on the left (but preserves active-section class). - focusable?: boolean, /* defaults to true */ - tableNameHidden?: boolean, - widgetNameHidden?: boolean, -}) { - const isResizing = options.isResizing ?? Observable.create(null, false); - const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options; - - // Creating normal section dom - const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId); - return dom('div.view_leaf.viewsection_content.flexvbox.flexauto', - testId(`viewlayout-section-${sectionRowId}`), - !options.isResizing ? dom.autoDispose(isResizing) : null, - cssViewLeaf.cls(''), - cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)), - dom.cls('active_section', vs.hasFocus), - dom.cls('active_section--no-indicator', !focusable), - dom.maybe((use) => use(vs.viewInstance), (viewInstance) => dom('div.viewsection_title.flexhbox', - cssDragIcon('DragDrop', - dom.cls("viewsection_drag_indicator"), - // Makes element grabbable only if grist is not readonly. - dom.cls('layout_grabbable', (use) => !use(gristDoc.isReadonlyKo)), - !draggable ? dom.style("visibility", "hidden") : null - ), - dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () => - cssSigmaIcon('Pivot', testId('sigma'))), - buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))), - viewInstance.buildTitleControls(), - dom('div.viewsection_buttons', - dom.create(viewSectionMenu, gristDoc, vs) - ) - )), - dom.create(filterBar, gristDoc, vs), - dom.maybe(vs.viewInstance, (viewInstance) => [ - dom('div.view_data_pane_container.flexvbox', - cssResizing.cls('', isResizing), - dom.maybe(viewInstance.disableEditing, () => - dom('div.disable_viewpane.flexvbox', 'No data') - ), - dom.maybe(viewInstance.isTruncated, () => - dom('div.viewsection_truncated', 'Not all data is shown') - ), - dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)), - viewInstance.viewPane, - ), - dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()), - ]), - dom.on('mousedown', () => { viewModel?.activeSectionId(sectionRowId); }), - ); -} - -// With new widgetPopup it is hard to click on viewSection without a activating it, hence we -// add a little blank space to use in test. -const cssTestClick = styled(`div`, ` - min-width: 2px; -`); - -const cssSigmaIcon = styled(icon, ` - bottom: 1px; - margin-right: 5px; - background-color: ${theme.lightText} -`); - -const cssViewLeaf = styled('div', ` - @media ${mediaSmall} { - & { - margin: 4px; - } - } -`); - -const cssViewLeafInactive = styled('div', ` - @media screen and ${mediaSmall} { - & { - overflow: hidden; - background: repeating-linear-gradient( - -45deg, - ${theme.widgetInactiveStripesDark}, - ${theme.widgetInactiveStripesDark} 10px, - ${theme.widgetInactiveStripesLight} 10px, - ${theme.widgetInactiveStripesLight} 20px - ); - border: 1px solid ${theme.widgetBorder}; - border-radius: 4px; - padding: 0 2px; - } - &::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - } - &.layout_vbox { - max-width: 32px; - } - &.layout_hbox { - max-height: 32px; - } - & > .viewsection_title.flexhbox { - position: absolute; - } - & > .view_data_pane_container, - & .viewsection_buttons, - & .grist-single-record__menu, - & > .filter_bar { - display: none; - } - } -`); - const cssLayoutBox = styled('div', ` @media screen and ${mediaSmall} { &-active, &-inactive { @@ -491,27 +409,11 @@ const cssLayoutBox = styled('div', ` } `); -// z-index ensure it's above the resizer line, since it's hard to grab otherwise -const cssDragIcon = styled(icon, ` - visibility: hidden; - --icon-color: ${colors.slate}; - top: -1px; - z-index: 100; - - .viewsection_title:hover &.layout_grabbable { - visibility: visible; - } -`); - -// This class is added while sections are being resized (or otherwise edited), to ensure that the -// content of the section (such as an iframe) doesn't interfere with mouse drag-related events. -// (It assumes that contained elements do not set pointer-events to another value; if that were -// important then we'd need to use an overlay element during dragging.) -const cssResizing = styled('div', ` - pointer-events: none; -`); - const cssLayoutWrapper = styled('div', ` + display: flex; + flex-direction: column; + position: relative; + flex-grow: 1; @media not print { &-active { background: ${theme.mainPanelBg}; @@ -536,6 +438,7 @@ const cssLayoutWrapper = styled('div', ` `); const cssOverlay = styled('div', ` + height: 100%; @media screen { &-active { background-color: ${theme.modalBackdrop}; @@ -545,6 +448,9 @@ const cssOverlay = styled('div', ` padding: 20px 56px 20px 56px; position: absolute; } + &-active .collapsed_layout { + display: none !important; + } } @media screen and ${mediaSmall} { &-active { @@ -572,3 +478,9 @@ const cssCloseButton = styled(icon, ` } } `); + +const cssLayoutContainer = styled('div', ` + height: 100%; + display: flex; + flex-direction: column; +`); diff --git a/app/client/components/buildViewSectionDom.ts b/app/client/components/buildViewSectionDom.ts new file mode 100644 index 00000000..23b77dbc --- /dev/null +++ b/app/client/components/buildViewSectionDom.ts @@ -0,0 +1,208 @@ +import BaseView from 'app/client/components/BaseView'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {filterBar} from 'app/client/ui/FilterBar'; +import {cssIcon} from 'app/client/ui/RightPanelStyles'; +import {makeCollapsedLayoutMenu} from 'app/client/ui/ViewLayoutMenu'; +import {cssDotsIconWrapper, cssMenu, viewSectionMenu} from 'app/client/ui/ViewSectionMenu'; +import {buildWidgetTitle} from 'app/client/ui/WidgetTitle'; +import {getWidgetTypes} from 'app/client/ui/widgetTypes'; +import {colors, isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {menu} from 'app/client/ui2018/menus'; +import {Computed, dom, DomElementArg, Observable, styled} from 'grainjs'; +import {defaultMenuOptions} from 'popweasel'; + + +export function buildCollapsedSectionDom(options: { + gristDoc: GristDoc, + sectionRowId: number|string, +}, ...domArgs: DomElementArg[]) { + const {gristDoc, sectionRowId} = options; + if (typeof sectionRowId === 'string') { + return cssMiniSection( + dom('span.viewsection_title_font', + 'Empty' + ) + ); + } + const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId); + const typeComputed = Computed.create(null, use => getWidgetTypes(use(vs.parentKey) as any).icon); + return cssMiniSection( + testId(`minilayout-section-${sectionRowId}`), + testId(`minilayout-section`), + cssDragHandle( + dom.domComputed(typeComputed, (type) => icon(type)), + dom('div', {style: 'margin-right: 16px;'}), + dom.maybe((use) => use(use(vs.table).summarySourceTable), () => cssSigmaIcon('Pivot', testId('sigma'))), + dom('span.viewsection_title_font', testId('viewsection-title'), + dom.text(vs.titleDef), + ), + ), + cssMenu( + testId('section-menu-viewLayout'), + cssDotsIconWrapper(cssIcon('Dots')), + menu(_ctl => makeCollapsedLayoutMenu(vs, gristDoc), { + ...defaultMenuOptions, + placement: 'bottom-end', + }) + ), + ...domArgs + ); +} + + +export function buildViewSectionDom(options: { + gristDoc: GristDoc, + sectionRowId: number, + isResizing?: Observable + viewModel?: ViewRec, + // Should show drag anchor. + draggable?: boolean, /* defaults to true */ + // Should show green bar on the left (but preserves active-section class). + focusable?: boolean, /* defaults to true */ + tableNameHidden?: boolean, + widgetNameHidden?: boolean, +}) { + const isResizing = options.isResizing ?? Observable.create(null, false); + const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options; + + // Creating normal section dom + const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId); + return dom('div.view_leaf.viewsection_content.flexvbox.flexauto', + testId(`viewlayout-section-${sectionRowId}`), + !options.isResizing ? dom.autoDispose(isResizing) : null, + cssViewLeaf.cls(''), + cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)), + dom.cls('active_section', vs.hasFocus), + dom.cls('active_section--no-indicator', !focusable), + dom.maybe((use) => use(vs.viewInstance), (viewInstance) => dom('div.viewsection_title.flexhbox', + cssDragIcon('DragDrop', + dom.cls("viewsection_drag_indicator"), + // Makes element grabbable only if grist is not readonly. + dom.cls('layout_grabbable', (use) => !use(gristDoc.isReadonlyKo)), + !draggable ? dom.style("visibility", "hidden") : null + ), + dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () => + cssSigmaIcon('Pivot', testId('sigma'))), + buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))), + viewInstance.buildTitleControls(), + dom('div.viewsection_buttons', + dom.create(viewSectionMenu, gristDoc, vs) + ) + )), + dom.create(filterBar, gristDoc, vs), + dom.maybe(vs.viewInstance, (viewInstance) => [ + dom('div.view_data_pane_container.flexvbox', + cssResizing.cls('', isResizing), + dom.maybe(viewInstance.disableEditing, () => + dom('div.disable_viewpane.flexvbox', 'No data') + ), + dom.maybe(viewInstance.isTruncated, () => + dom('div.viewsection_truncated', 'Not all data is shown') + ), + dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)), + viewInstance.viewPane, + ), + dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()), + ]), + dom.on('mousedown', () => { viewModel?.activeSectionId(sectionRowId); }), + ); +} + +// With new widgetPopup it is hard to click on viewSection without a activating it, hence we +// add a little blank space to use in test. +const cssTestClick = styled(`div`, ` + min-width: 2px; +`); + +const cssSigmaIcon = styled(icon, ` + bottom: 1px; + margin-right: 5px; + background-color: ${theme.lightText} +`); + +const cssViewLeaf = styled('div', ` + @media ${mediaSmall} { + & { + margin: 4px; + } + } +`); + +const cssViewLeafInactive = styled('div', ` + @media screen and ${mediaSmall} { + & { + overflow: hidden; + background: repeating-linear-gradient( + -45deg, + ${theme.widgetInactiveStripesDark}, + ${theme.widgetInactiveStripesDark} 10px, + ${theme.widgetInactiveStripesLight} 10px, + ${theme.widgetInactiveStripesLight} 20px + ); + border: 1px solid ${theme.widgetBorder}; + border-radius: 4px; + padding: 0 2px; + } + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + &.layout_vbox { + max-width: 32px; + } + &.layout_hbox { + max-height: 32px; + } + & > .viewsection_title.flexhbox { + position: absolute; + } + & > .view_data_pane_container, + & .viewsection_buttons, + & .grist-single-record__menu, + & > .filter_bar { + display: none; + } + } +`); + + +// z-index ensure it's above the resizer line, since it's hard to grab otherwise +const cssDragIcon = styled(icon, ` + visibility: hidden; + --icon-color: ${colors.slate}; + top: -1px; + z-index: 100; + + .viewsection_title:hover &.layout_grabbable { + visibility: visible; + } +`); + +// This class is added while sections are being resized (or otherwise edited), to ensure that the +// content of the section (such as an iframe) doesn't interfere with mouse drag-related events. +// (It assumes that contained elements do not set pointer-events to another value; if that were +// important then we'd need to use an overlay element during dragging.) +const cssResizing = styled('div', ` + pointer-events: none; +`); + +const cssMiniSection = styled('div.mini_section_container', ` + --icon-color: ${colors.lightGreen}; + display: flex; + background: ${theme.mainPanelBg}; + align-items: center; + padding-right: 8px; +`); + +const cssDragHandle = styled('div.draggable-handle', ` + display: flex; + padding: 8px; + flex: 1; + padding-right: 16px; +`); diff --git a/app/client/components/commandList.js b/app/client/components/commandList.js index dc612de1..24fa10f1 100644 --- a/app/client/components/commandList.js +++ b/app/client/components/commandList.js @@ -392,6 +392,18 @@ exports.groups = [{ name: 'deleteSection', keys: [], desc: 'Delete the currently active viewsection' + }, { + name: 'collapseSection', + keys: [], + desc: 'Collapse the currently active viewsection' + }, { + name: 'expandSection', + keys: [], + desc: 'Expand collapsed viewsection' + }, { + name: 'deleteCollapsedSection', + keys: [], + desc: 'Delete collapsed viewsection' }, { name: 'duplicateRows', keys: ['Mod+Shift+d'], diff --git a/app/client/lib/Signal.ts b/app/client/lib/Signal.ts new file mode 100644 index 00000000..c43f8a6e --- /dev/null +++ b/app/client/lib/Signal.ts @@ -0,0 +1,193 @@ +import { DisposableWithEvents } from 'app/common/DisposableWithEvents'; +import { Disposable, IDisposable, IDisposableOwner, Observable } from 'grainjs'; + +/** + * A simple abstraction for events composition. It is an object that can emit a single value of type T, + * and holds the last value emitted. It can be used to compose events from other events. + * + * Simple observables can't be used for this purpose because they are not reentrant. We can't update + * an observable from within a listener, because it won't trigger a new event. + * + * This class is basically a wrapper around Observable, that emits events when the value changes after it is + * set. + * + * Example: + * const signal = Signal.create(null, 0); + * signal.listen(value => console.log(value)); + * const onlyEven = signal.filter(value => value % 2 === 0); + * onlyEven.listen(value => console.log('even', value)); + * + * const flag1 = Signal.create(null, false); + * const flag2 = Signal.create(null, false); + * const flagAnd = Signal.compute(null, on => on(flag1) && on(flag2)); + * // This will still emit multiple times with the same value repeated. + * flagAnd.listen(value => console.log('Both are true', value)); + * + * // This will emit only when both are true, and will ignore further changes while both are true. + * const toggle = flagAnd.distinct(); + * + * // Current value can be accessed via signal.state.get() + * const emitter = Signal.from(null, 0); + * // Emit values only when the toggle is true. + * const emitterWhileAnd = emitter.filter(() => toggle.state.get()); + * // Equivalent to: + * const emitterWhileAnd = Signal.compute(null, on => on(toggle) ? on(emitter) : null).distinct(); + */ +export class Signal implements IDisposable, IDisposableOwner { + /** + * Creates a new event with a default value. A convenience method for creating an event that supports + * generic attribute. + */ + public static create(owner: IDisposableOwner | null, value: T) { + return new Signal(owner, value); + } + + /** + * Creates an event from a set of events. Holds last value emitted by any of the events. + */ + public static fromEvents( + owner: Disposable | null, + emitter: any, + first: string, + ...rest: string[] + ) { + const signal = Signal.create(owner, null); + for(const event of [first, ...rest]) { + signal._emitter.listenTo(emitter, event, (value: any) => signal.emit(value)); + } + return signal as Signal; + } + + /** + * Helper methods that creates a signal that emits the result of a function that takes a function + */ + public static compute(owner: Disposable | null, compute: ComputeFunction) { + const signal = Signal.create(owner, null as any); + const on: any = (s: Signal) => { + if (!signal._listeners.has(s)) { + signal._listeners.add(s); + signal._emitter.listenTo(s._emitter, 'signal', () => signal.emit(compute(on))); + } + return s.state.get(); + }; + signal.state.set(compute(on)); + return signal as Signal; + } + + /** + * Last value emitted if any. + */ + public state: Observable; + + /** + * List of signals that we are listening to. Stored in a WeakSet to avoid memory leaks. + */ + private _listeners: WeakSet = new WeakSet(); + + /** + * Flag that can be changed by stateless() function. It won't hold last value (but can't be used in compute function). + */ + private _emitter: DisposableWithEvents; + + private _beforeHandler: CustomEmitter; + + constructor(owner: IDisposableOwner|null, initialValue: T) { + this._emitter = DisposableWithEvents.create(owner); + this.state = Observable.create(this, initialValue); + } + + public dispose() { + this._emitter.dispose(); + } + + public autoDispose(disposable: IDisposable) { + this._emitter.autoDispose(disposable); + } + + /** + * Push all events from this signal to another signal. + */ + public pipe(signal: Signal) { + this.autoDispose(this.listen(value => signal.emit(value))); + } + + /** + * Modify all values emitted by this signal. + */ + public map(selector: (value: T) => Z): Signal { + const signal = Signal.create(this, selector(this.state.get())); + this.listen(value => { + signal.emit(selector(value)); + }); + return signal; + } + + /** + * Creates a new signal with the same state, but it will only + * emit those values that pass the test implemented by the provided function. + */ + public filter(selector: (value: T) => boolean): Signal { + const signal = Signal.create(this, this.state.get()); + this.listen(value => { + if (selector(value)) { + signal.emit(value); + } + }); + return signal; + } + + /** + * Emit only the value that is different from the previous one. + */ + public distinct(): Signal { + let last = this.state.get(); + const signal = this.filter((value: any) => { + if (value !== last) { + last = value; + return true; + } + return false; + }); + signal.state.set(last); + return signal; + } + + /** + * Emits true or false only when the value is changed from truthy to falsy or vice versa. + */ + public flag() { + return this.map(Boolean).distinct(); + } + + /** + * Listen to changes of the signal. + */ + public listen(handler: (value: T) => any) { + const stateHandler = () => { + handler(this.state.get()); + }; + this._emitter.on('signal', stateHandler); + return { + dispose: () => this._emitter.off('signal', stateHandler), + }; + } + + public emit(value: T) { + if (this._beforeHandler) { + this._beforeHandler(value, (emitted: T) => { + this.state.set(emitted); + this._emitter.trigger('signal', emitted); + }); + } else { + this.state.set(value); + this._emitter.trigger('signal', value); + } + } + + public before(handler: CustomEmitter) { + this._beforeHandler = handler; + } +} + +type ComputeFunction = (on: (s: Signal) => TS) => T; +type CustomEmitter = (value: T, emit: (value: T) => void) => any; diff --git a/app/client/models/entities/ViewRec.ts b/app/client/models/entities/ViewRec.ts index 183fc849..cd650857 100644 --- a/app/client/models/entities/ViewRec.ts +++ b/app/client/models/entities/ViewRec.ts @@ -1,3 +1,4 @@ +import {BoxSpec} from 'app/client/components/Layout'; import {KoArray} from 'app/client/lib/koArray'; import * as koUtil from 'app/client/lib/koUtil'; import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel'; @@ -10,11 +11,21 @@ export interface ViewRec extends IRowModel<"_grist_Views"> { viewSections: ko.Computed>; tabBarItem: ko.Computed>; - layoutSpecObj: modelUtil.ObjObservable; + layoutSpecObj: modelUtil.SaveableObjObservable; // An observable for the ref of the section last selected by the user. activeSectionId: ko.Computed; + // This is active collapsed section id. Set when the widget is clicked. + activeCollapsedSectionId: ko.Observable; + + // Saved collapsed sections. + collapsedSections: ko.Computed; + + // Active collapsed sections, changed by the user, can be different from the + // saved collapsed sections, for a brief moment (editor is buffering changes). + activeCollapsedSections: ko.Observable; + activeSection: ko.Computed; // If the active section is removed, set the next active section to be the default. @@ -39,6 +50,15 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void { this.activeSection = refRecord(docModel.viewSections, this.activeSectionId); + this.activeCollapsedSectionId = ko.observable(0); + + this.collapsedSections = this.autoDispose(ko.pureComputed(() => { + const allSections = new Set(this.viewSections().all().map(x => x.id())); + const collapsed: number[] = (this.layoutSpecObj().collapsed || []).map(x => x.leaf as number); + return collapsed.filter(x => allSections.has(x)); + })); + this.activeCollapsedSections = ko.observable(this.collapsedSections.peek()); + // If the active section is removed, set the next active section to be the default. this._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted())); this.autoDispose(this._isActiveSectionGone.subscribe(gone => { diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 746684e2..168d0c0d 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -56,6 +56,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO // true if this record is its table's rawViewSection, i.e. a 'raw data view' // in which case the UI prevents various things like hiding columns or changing the widget type. isRaw: ko.Computed; + isCollapsed: ko.Computed; borderWidthPx: ko.Computed; @@ -722,4 +723,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): }; this.removeRule = (index: number) => removeRule(docModel, this, index); + + this.isCollapsed = this.autoDispose(ko.pureComputed(() => { + const list = this.view().activeCollapsedSections(); + return list.includes(this.id()); + })); } diff --git a/app/client/ui/ViewLayoutMenu.ts b/app/client/ui/ViewLayoutMenu.ts index 614cbfce..49b9e297 100644 --- a/app/client/ui/ViewLayoutMenu.ts +++ b/app/client/ui/ViewLayoutMenu.ts @@ -4,7 +4,8 @@ import {ViewSectionRec} from 'app/client/models/DocModel'; import {urlState} from 'app/client/models/gristUrlState'; import {testId} from 'app/client/ui2018/cssVars'; import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus'; -import {dom} from 'grainjs'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {dom, UseCB} from 'grainjs'; const t = makeT('ViewLayoutMenu'); @@ -42,10 +43,27 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool anchorUrlState.hash!.popup = true; const rawUrl = urlState().makeUrl(anchorUrlState); + // Count number of rendered sections on the viewLayout. Note that the layout might be detached or cleaned + // when we have an external section in the popup. + const expandedSectionCount = () => gristDoc.viewLayout?.layout.getAllLeafIds().length ?? 0 > 1; + + const dontRemoveSection = () => + !viewRec.getRowId() || viewRec.viewSections().peekLength <= 1 || isReadonly || expandedSectionCount() === 1; + + const dontCollapseSection = () => + dontRemoveSection() || + (gristDoc.externalSectionId.get() === viewSection.getRowId()) || + (gristDoc.maximizedSectionId.get() === viewSection.getRowId()); + + const showRawData = (use: UseCB) => { + return !use(viewSection.isRaw)// Don't show raw data if we're already in raw data. + && !isLight // Don't show raw data in light mode. + ; + }; return [ dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu), - dom.maybe((use) => !use(viewSection.isRaw) && !isLight && !use(gristDoc.sectionInPopup), + dom.maybe(showRawData, () => menuItemLink( { href: rawUrl}, t("Show raw data"), testId('show-raw-data'), dom.on('click', (ev) => { @@ -78,8 +96,44 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"), testId('section-open-configuration')), ), + menuItemCmd(allCommands.collapseSection, t("Collapse widget"), + dom.cls('disabled', dontCollapseSection()), + testId('section-collapse')), menuItemCmd(allCommands.deleteSection, t("Delete widget"), - dom.cls('disabled', !viewRec.getRowId() || viewRec.viewSections().peekLength <= 1 || isReadonly), + dom.cls('disabled', dontRemoveSection()), + testId('section-delete')), + ]; +} + + +/** + * Returns a list of menu items for a view section. + */ +export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: GristDoc) { + const isReadonly = gristDoc.isReadonly.get(); + const isLight = urlState().state.get().params?.style === 'light'; + const sectionId = viewSection.table.peek().rawViewSectionRef.peek(); + const anchorUrlState = { hash: { sectionId, popup: true } }; + const rawUrl = urlState().makeUrl(anchorUrlState); + return [ + dom.maybe((use) => !use(viewSection.isRaw) && !isLight && !use(gristDoc.maximizedSectionId), + () => 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); + }) + ) + ), + menuDivider(), + menuItemCmd(allCommands.expandSection, t("Add to page"), + dom.cls('disabled', isReadonly), + testId('section-expand')), + menuItemCmd(allCommands.deleteCollapsedSection, t("Delete widget"), + dom.cls('disabled', isReadonly), testId('section-delete')), ]; } diff --git a/app/client/ui/ViewSectionMenu.ts b/app/client/ui/ViewSectionMenu.ts index 1b1cadc5..70b375e5 100644 --- a/app/client/ui/ViewSectionMenu.ts +++ b/app/client/ui/ViewSectionMenu.ts @@ -58,7 +58,8 @@ export function viewSectionMenu( // 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(gristDoc.maximizedSectionId) !== use(viewSection.id) // not in when we are maximized + && use(gristDoc.externalSectionId) !== use(viewSection.id) // not in when we are external && !use(viewSection.isRaw); // not in raw mode }); @@ -210,7 +211,7 @@ function makeCustomOptions(section: ViewSectionRec) { const clsOldUI = styled('div', ``); -const cssMenu = styled('div', ` +export const cssMenu = styled('div', ` display: flex; cursor: pointer; border-radius: 3px; @@ -277,7 +278,7 @@ const cssIcon = styled(icon, ` } `); -const cssDotsIconWrapper = styled(cssIconWrapper, ` +export const cssDotsIconWrapper = styled(cssIconWrapper, ` border-radius: 0px 2px 2px 0px; display: flex; .${clsOldUI.className} & { diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index c5585a01..948312fb 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -257,7 +257,7 @@ export function encodeUrl(gristConfig: Partial, queryParams[`${k}_`] = v; } const hashParts: string[] = []; - if (state.hash && state.hash.rowId) { + if (state.hash && (state.hash.rowId || state.hash.popup)) { const hash = state.hash; hashParts.push(state.hash?.popup ? 'a2' : `a1`); for (const key of ['sectionId', 'rowId', 'colRef'] as Array) { diff --git a/test/client/lib/Signal.ts b/test/client/lib/Signal.ts new file mode 100644 index 00000000..08998124 --- /dev/null +++ b/test/client/lib/Signal.ts @@ -0,0 +1,125 @@ +import { assert } from 'chai'; +import { Signal } from 'app/client/lib/Signal'; + +describe('Signal', function() { + it('computes new signal from other events', function() { + const started = Signal.create(null, false); + const hovered = Signal.create(null, false); + const hoverAndStarted = Signal.compute(null, on => on(started) && on(hovered)); + + let flag: any = 'not-called'; + hoverAndStarted.listen(val => flag = val); + + function start(emit: boolean, expected: boolean) { + started.emit(emit); + assert.equal(flag, expected); + flag = 'not-called'; + } + + function hover(emit: boolean, expected: boolean) { + hovered.emit(emit); + assert.equal(flag, expected); + flag = 'not-called'; + } + + start(true, false); + start(false, false); + start(true, false); + + hover(true, true); + hover(false, false); + hover(true, true); + + start(false, false); + }); + + it('works as flag', function() { + const started = Signal.create(null, false); + const hovered = Signal.create(null, false); + const andEvent = Signal.compute(null, on => on(started) && on(hovered)).distinct(); + + const notCalled = {}; + let andCalled = notCalled; + andEvent.listen(val => andCalled = val); + + function start(emit: boolean, expected: any) { + started.emit(emit); + assert.equal(andCalled, expected); + andCalled = notCalled; + } + + start(true, notCalled); + start(false, notCalled); + start(true, notCalled); + + function hover(emit: boolean, expected: any) { + hovered.emit(emit); + assert.equal(andCalled, expected); + andCalled = notCalled; + } + + hover(true, true); + hover(false, false); + hover(true, true); + + start(false, false); + }); + + it('supports basic compositions', function() { + const numbers = Signal.create(null, 0); + const even = numbers.filter(n => n % 2 === 0); + const odd = numbers.filter(n => n % 2 === 1); + + let evenCount = 0; + let oddCount = 0; + even.listen(() => evenCount++); + odd.listen(() => oddCount++); + assert.equal(evenCount, 0); + assert.equal(oddCount, 0); + + numbers.emit(2); + assert.equal(evenCount, 1); + assert.equal(oddCount, 0); + + numbers.emit(3); + assert.equal(evenCount, 1); + assert.equal(oddCount, 1); + + const distinct = numbers.distinct(); + let distinctCount = 0; + distinct.listen(() => distinctCount++); + assert.equal(distinctCount, 0); + + numbers.emit(3); + assert.equal(distinctCount, 0); + numbers.emit(3); + assert.equal(distinctCount, 0); + numbers.emit(4); + assert.equal(distinctCount, 1); + numbers.emit(4); + assert.equal(distinctCount, 1); + numbers.emit(5); + assert.equal(distinctCount, 2); + + const trafficLight = Signal.create(null, false); + const onRoad = numbers.filter(n => !!trafficLight.state.get()); + + let onRoadCount = 0; + onRoad.listen(() => onRoadCount++); + assert.equal(onRoadCount, 0); + numbers.emit(5); + assert.equal(onRoadCount, 0); + trafficLight.emit(true); + assert.equal(onRoadCount, 0); + numbers.emit(6); + assert.equal(onRoadCount, 1); + }); + + it('detects cycles', function() { + const first = Signal.create(null, 0); + const second = Signal.create(null, 0); + first.listen(n => second.emit(n + 1)); + second.listen(n => first.emit(n + 1)); + assert.throws(() => first.emit(1)); + }); +}); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 29352120..f0e884d6 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1060,8 +1060,12 @@ export async function addNewPage( await driver.wait(async () => (await driver.getCurrentUrl()) !== url, 2000); } +type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom'; + // Add a new widget to the current page using the 'Add New' menu. -export async function addNewSection(typeRe: RegExp|string, tableRe: RegExp|string, options?: PageWidgetPickerOptions) { +export async function addNewSection( + typeRe: RegExp|SectionTypes, tableRe: RegExp|string, options?: PageWidgetPickerOptions +) { // Click the 'Add widget to page' entry in the 'Add New' menu await driver.findWait('.test-dp-add-new', 2000).doClick(); await driver.findWait('.test-dp-add-widget-to-page', 500).doClick(); @@ -1515,7 +1519,6 @@ const ColumnMenuOption: { [id: string]: string; } = { Filter: '.test-filter-menu-wrapper' }; - async function openColumnMenuHelper(col: IColHeader|string, option?: string): Promise { await getColumnHeader(typeof col === 'string' ? {col} : col).mouseMove().find('.g-column-main-menu').click(); const menu = await driver.findWait('.grist-floating-menu', 100);