2023-03-22 15:21:53 +00:00
|
|
|
import BaseView from 'app/client/components/BaseView';
|
|
|
|
import {buildCollapsedSectionDom, buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
|
2023-02-24 11:12:55 +00:00
|
|
|
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';
|
2023-03-22 15:21:53 +00:00
|
|
|
import {detachNode} from 'app/client/lib/dom';
|
2023-02-24 11:12:55 +00:00
|
|
|
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';
|
2023-03-22 15:21:53 +00:00
|
|
|
import {Computed, Disposable, dom, IDisposable, IDisposableOwner,
|
|
|
|
makeTestId, obsArray, Observable, styled} from 'grainjs';
|
2023-02-24 11:12:55 +00:00
|
|
|
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<Dropped|null>(this, null);
|
|
|
|
// Event for dropping, contains a dropped element.
|
|
|
|
public drop = Signal.create<Dropped|null>(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<MouseEvent|null>(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);
|
2023-03-22 15:21:53 +00:00
|
|
|
|
|
|
|
private _rootElement: HTMLElement;
|
2023-03-27 16:35:57 +00:00
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
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());
|
|
|
|
|
|
|
|
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();
|
|
|
|
// Add it as a last element.
|
|
|
|
this.layout.addBox(leafId);
|
2023-03-22 15:21:53 +00:00
|
|
|
// Ask it to remove itself from the target.
|
|
|
|
value.removeFromLayout();
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
// Clear the state, any other listener will get null.
|
|
|
|
this.drop.state.set(null);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Now wire up active state.
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
// When a drag is started, get the top point of the tray, over which we will activate.
|
|
|
|
let topPoint = 48; // By default it is 48 pixels.
|
|
|
|
this.autoDispose(externalLeaf.drag.listen(d => {
|
|
|
|
if (!d) { return; }
|
|
|
|
topPoint = (this._rootElement.parentElement?.getBoundingClientRect().top ?? 61) - 13;
|
|
|
|
}));
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
// First we can be activated when a drag has started and we have some boxes.
|
2023-03-22 15:21:53 +00:00
|
|
|
this.drag.map(drag => drag && this.layout.count.get() > 0)
|
2023-02-24 11:12:55 +00:00
|
|
|
.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);
|
2023-03-22 15:21:53 +00:00
|
|
|
const over = mouseEvent && mouseEvent.clientY < topPoint;
|
2023-02-24 11:12:55 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
public replaceLayout() {
|
|
|
|
const savedSections = this.viewLayout.viewModel.collapsedSections.peek();
|
|
|
|
this.viewLayout.viewModel.activeCollapsedSections(savedSections);
|
|
|
|
const boxes = this.layout.buildLayout(savedSections);
|
|
|
|
return {
|
|
|
|
dispose() {
|
|
|
|
boxes.forEach(box => box.dispose());
|
|
|
|
boxes.length = 0;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Builds a popup for a maximized section.
|
|
|
|
*/
|
|
|
|
public buildPopup(owner: IDisposableOwner, selected: Observable<number|null>, close: () => void) {
|
|
|
|
const section = Observable.create<number|null>(owner, null);
|
|
|
|
owner.autoDispose(selected.addListener((cur, prev) => {
|
2023-03-28 15:13:33 +00:00
|
|
|
if (prev) {
|
|
|
|
this.layout.getBox(prev)?.attach();
|
|
|
|
}
|
|
|
|
if (cur) {
|
|
|
|
this.layout.getBox(cur)?.detach();
|
2023-03-22 15:21:53 +00:00
|
|
|
}
|
|
|
|
section.set(cur);
|
|
|
|
}));
|
|
|
|
return dom.domComputed(section, (id) => {
|
|
|
|
if (!id) { return null; }
|
|
|
|
return dom.update(
|
|
|
|
buildViewSectionDom({
|
|
|
|
gristDoc: this.viewLayout.gristDoc,
|
|
|
|
sectionRowId: id,
|
|
|
|
draggable: false,
|
|
|
|
focusable: false,
|
|
|
|
})
|
|
|
|
);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
public buildDom() {
|
2023-03-22 15:21:53 +00:00
|
|
|
return this._rootElement = cssCollapsedTray(
|
2023-02-24 11:12:55 +00:00
|
|
|
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.
|
2023-03-22 15:21:53 +00:00
|
|
|
dom.show(use => use(this.layout.count) > 0 || use(this.active.state)),
|
2023-02-24 11:12:55 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public buildContentDom(id: string|number) {
|
|
|
|
return buildCollapsedSectionDom({
|
|
|
|
gristDoc: this.viewLayout.gristDoc,
|
|
|
|
sectionRowId: id,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
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]
|
|
|
|
);
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
// Add the box to our collapsed editor (it will transfer the viewInstance).
|
2023-02-24 11:12:55 +00:00
|
|
|
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; }
|
|
|
|
viewLayout.viewModel.activeCollapsedSectionId(0);
|
2023-03-22 15:21:53 +00:00
|
|
|
viewLayout.viewModel.activeCollapsedSections(
|
|
|
|
viewLayout.viewModel.activeCollapsedSections.peek().filter(x => x !== leafId)
|
|
|
|
);
|
2023-02-24 11:12:55 +00:00
|
|
|
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) {
|
2023-03-22 15:21:53 +00:00
|
|
|
pushedLeaf = EmptyLeaf.create(null, this.model);
|
|
|
|
layout.addBox(pushedLeaf);
|
2023-02-24 11:12:55 +00:00
|
|
|
} else if (pushedLeaf) {
|
2023-03-22 15:21:53 +00:00
|
|
|
layout.destroy(pushedLeaf);
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
public buildDom() {
|
|
|
|
const obsRects = Observable.create(this, [] as Array<VRect|null>);
|
|
|
|
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) {
|
2023-03-22 15:21:53 +00:00
|
|
|
const boxes = this.model.layout.all();
|
2023-02-24 11:12:55 +00:00
|
|
|
const rects: Array<VRect|null> = [];
|
|
|
|
// 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 rootElement: HTMLElement;
|
2023-03-22 15:21:53 +00:00
|
|
|
/**
|
|
|
|
* Leaves owner. Adding or removing leaves will not dispose them automatically, as they are released and
|
|
|
|
* return to the caller. Only those leaves that were not removed will be disposed with the layout.
|
|
|
|
*/
|
|
|
|
public holder = ArrayHolder.create(this);
|
|
|
|
/**
|
|
|
|
* Number of leaves in the layout.
|
|
|
|
*/
|
|
|
|
public count: Computed<number>;
|
|
|
|
|
|
|
|
private _boxes = this.autoDispose(obsArray<Leaf>());
|
2023-02-24 11:12:55 +00:00
|
|
|
|
|
|
|
constructor(protected model: LayoutTray) {
|
|
|
|
super();
|
2023-03-22 15:21:53 +00:00
|
|
|
|
|
|
|
// Whenever we add or remove box, update the model. This is used to test if the section is collapsed or not.
|
|
|
|
this._boxes.addListener(l => model.viewLayout.viewModel.activeCollapsedSections(this.leafIds()));
|
|
|
|
|
|
|
|
this.count = Computed.create(this, use => use(this._boxes).length);
|
|
|
|
}
|
|
|
|
|
|
|
|
public all() {
|
|
|
|
return this._boxes.get();
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public buildLayout(leafs: number[]) {
|
2023-03-22 15:21:53 +00:00
|
|
|
if (isEqual(leafs, this._boxes.get().map((box) => box.id.get()))) { return []; }
|
|
|
|
const removed = this._boxes.splice(0, this._boxes.get().length,
|
|
|
|
...leafs.map((id) => CollapsedLeaf.create(this.holder, this.model, id)));
|
|
|
|
removed.forEach((box) => this.holder.release(box));
|
|
|
|
return removed;
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
public addBox(id: number|Leaf, index?: number) {
|
2023-02-24 11:12:55 +00:00
|
|
|
index ??= -1;
|
2023-03-22 15:21:53 +00:00
|
|
|
const box = typeof id === 'number' ? CollapsedLeaf.create(this.holder, this.model, id): id;
|
|
|
|
if (typeof id !== 'number') {
|
|
|
|
this.holder.autoDispose(box);
|
|
|
|
}
|
2023-02-24 11:12:55 +00:00
|
|
|
return this.insert(index, box);
|
|
|
|
}
|
|
|
|
|
|
|
|
public indexOf(box: Leaf) {
|
2023-03-22 15:21:53 +00:00
|
|
|
return this._boxes.get().indexOf(box);
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public insert(index: number, leaf: Leaf) {
|
2023-03-22 15:21:53 +00:00
|
|
|
this.holder.autoDispose(leaf);
|
2023-02-24 11:12:55 +00:00
|
|
|
if (index < 0) {
|
2023-03-22 15:21:53 +00:00
|
|
|
this._boxes.push(leaf);
|
2023-02-24 11:12:55 +00:00
|
|
|
} else {
|
2023-03-22 15:21:53 +00:00
|
|
|
this._boxes.splice(index, 0, leaf);
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
return leaf;
|
|
|
|
}
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
/**
|
|
|
|
* Removes the leaf from the list but doesn't dispose it.
|
|
|
|
*/
|
|
|
|
public remove(leaf: Leaf) {
|
|
|
|
const index = this._boxes.get().indexOf(leaf);
|
2023-02-24 11:12:55 +00:00
|
|
|
if (index >= 0) {
|
2023-03-22 15:21:53 +00:00
|
|
|
const removed = this._boxes.splice(index, 1)[0];
|
|
|
|
if (removed) {
|
|
|
|
this.holder.release(removed);
|
|
|
|
}
|
|
|
|
return removed || null;
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
2023-03-22 15:21:53 +00:00
|
|
|
return null;
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
/**
|
|
|
|
* Removes and dispose the leaf from the list.
|
|
|
|
*/
|
|
|
|
public destroy(leaf: Leaf) {
|
|
|
|
this.remove(leaf)?.dispose();
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public leafIds() {
|
2023-03-22 15:21:53 +00:00
|
|
|
return this._boxes.get().map(l => l.id.get()).filter(x => x && typeof x === 'number');
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
2023-03-27 16:35:57 +00:00
|
|
|
public getBox(leaf: number): CollapsedLeaf|undefined {
|
|
|
|
return this._boxes.get().find(l => l.id.get() === leaf) as CollapsedLeaf|undefined;
|
|
|
|
}
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
public buildDom() {
|
|
|
|
return (this.rootElement = cssLayout(
|
|
|
|
testId('layout'),
|
|
|
|
useDragging(),
|
2023-03-22 15:21:53 +00:00
|
|
|
dom.hide(use => use(this._boxes).length === 0),
|
|
|
|
dom.forEach(this._boxes, line => line.buildDom())
|
2023-02-24 11:12:55 +00:00
|
|
|
));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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();
|
|
|
|
this.model.layout.addBox(leafId, myIndex);
|
2023-03-22 15:21:53 +00:00
|
|
|
box.removeFromLayout();
|
2023-02-24 11:12:55 +00:00
|
|
|
})
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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(() => {
|
2023-03-22 15:21:53 +00:00
|
|
|
this.model.layout.destroy(this);
|
2023-02-24 11:12:55 +00:00
|
|
|
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<HTMLElement|null> = Observable.create(this, null);
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
// Computed to get the view instance from the viewSection.
|
|
|
|
private _viewInstance: Computed<BaseView|null>;
|
|
|
|
|
|
|
|
// An observable for the dom that holds the viewInstance and displays it in a hidden element.
|
|
|
|
// This is owned by this leaf and is disposed separately from the dom that is returned by buildDom. Like a
|
|
|
|
// singleton, this element will be moved from one "instance" (a result of buildDom) to another.
|
|
|
|
// When a leaf is removed from the dom (e.g. when we remove the collapsed section or move it to the main area)
|
|
|
|
// the dom of this element is disposed, but the hidden element stays with this instance and can be disposed
|
|
|
|
// later on, giving anyone a chance to grab the viewInstance and display it somewhere else.
|
|
|
|
private _hiddenViewInstance: Observable<HTMLElement|null> = Observable.create(this, null);
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
// Helper to keeping track of the index of the leaf in the layout.
|
|
|
|
private _indexWhenDragged = 0;
|
2023-03-28 15:13:33 +00:00
|
|
|
|
|
|
|
// A helper variable that indicates that this section is in a popup, and we should
|
|
|
|
// make any attempt to grab it and attach to our dom. Note: this is not a computed variable.
|
|
|
|
private _detached = false;
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
constructor(protected model: LayoutTray, id: number) {
|
|
|
|
super();
|
|
|
|
this.id.set(id);
|
2023-03-22 15:21:53 +00:00
|
|
|
this._viewInstance = Computed.create(this, use => {
|
|
|
|
const sections = use(use(this.model.viewLayout.viewModel.viewSections).getObservable());
|
|
|
|
const view = sections.find(s => use(s.id) === use(this.id));
|
|
|
|
if (!view) { return null; }
|
|
|
|
const instance = use(view.viewInstance);
|
|
|
|
return instance;
|
|
|
|
});
|
2023-03-28 15:13:33 +00:00
|
|
|
this._buildHidden();
|
2023-03-22 15:21:53 +00:00
|
|
|
this.onDispose(() => {
|
|
|
|
const instance = this._hiddenViewInstance.get();
|
|
|
|
instance && dom.domDispose(instance);
|
|
|
|
});
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
2023-03-22 15:21:53 +00:00
|
|
|
|
2023-03-28 15:13:33 +00:00
|
|
|
public detach() {
|
|
|
|
this._detached = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
public attach() {
|
|
|
|
this._detached = false;
|
2023-03-27 16:35:57 +00:00
|
|
|
const previous = this._hiddenViewInstance.get();
|
2023-03-28 15:13:33 +00:00
|
|
|
this._buildHidden();
|
2023-03-27 16:35:57 +00:00
|
|
|
previous && dom.domDispose(previous);
|
|
|
|
}
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
public buildDom() {
|
|
|
|
this._content.set(this.model.buildContentDom(this.id.get()));
|
2023-03-22 15:21:53 +00:00
|
|
|
return this.rootElement = cssBox(
|
2023-02-24 11:12:55 +00:00
|
|
|
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();
|
2023-03-22 15:21:53 +00:00
|
|
|
}),
|
|
|
|
detachedNode(this._hiddenViewInstance),
|
|
|
|
);
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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);
|
2023-03-22 15:21:53 +00:00
|
|
|
|
|
|
|
// Remove self from the layout (it will dispose this instance, but the viewInstance was moved to the floater)
|
|
|
|
this.model.layout.destroy(this);
|
2023-02-24 11:12:55 +00:00
|
|
|
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);
|
2023-03-22 15:21:53 +00:00
|
|
|
this.model.layout.destroy(this);
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public leafId() {
|
|
|
|
return this.id.get();
|
|
|
|
}
|
2023-03-28 15:13:33 +00:00
|
|
|
|
|
|
|
private _buildHidden() {
|
|
|
|
this._hiddenViewInstance.set(cssHidden(dom.maybe(this._viewInstance, view => {
|
|
|
|
return this._detached ? null : view.viewPane;
|
|
|
|
})));
|
|
|
|
}
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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<HTMLElement|null> = 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<Dropped>;
|
|
|
|
// Event when external leaf is being dragged.
|
|
|
|
public dragMove: Signal<MouseEvent>;
|
|
|
|
|
|
|
|
// Event when external leaf is dropped.
|
|
|
|
private _drop: Signal<ContentBox>;
|
|
|
|
|
|
|
|
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();
|
|
|
|
const box = externalEditor.layout.buildLayoutBox({leaf});
|
2023-03-22 15:21:53 +00:00
|
|
|
dropped.removeFromLayout();
|
2023-02-24 11:12:55 +00:00
|
|
|
if (part.isChild) {
|
|
|
|
part.box.addChild(box, part.isAfter);
|
|
|
|
} else {
|
|
|
|
part.box.addSibling(box, part.isAfter);
|
|
|
|
}
|
2023-03-22 15:21:53 +00:00
|
|
|
this.model.viewLayout.viewModel.activeSectionId(leaf);
|
2023-02-24 11:12:55 +00:00
|
|
|
this.model.drop.state.set(null);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
|
|
|
this._replaceFloater();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-03-22 15:21:53 +00:00
|
|
|
* Dropped interface implementation, it is called only when a section in the main area is collapsed (dragged
|
|
|
|
* onto the valid target in the tray).
|
2023-02-24 11:12:55 +00:00
|
|
|
*/
|
|
|
|
public removeFromLayout() {
|
|
|
|
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);
|
2023-03-22 15:21:53 +00:00
|
|
|
// We can safely remove the box, because we should be called after viewInstance is grabbed by
|
|
|
|
// the tray.
|
2023-02-24 11:12:55 +00:00
|
|
|
this.model.viewLayout.layoutEditor.doRemoveBox(droppedBox);
|
|
|
|
}
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
public leafId() {
|
|
|
|
return this._drop.state.get()?.leafId.peek() || 0;
|
|
|
|
}
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
/**
|
|
|
|
* 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!;
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
/**
|
|
|
|
* A class that holds an array of IDisposable objects, and disposes them all when it is disposed.
|
|
|
|
* The difference from a MultipleHolder is that it can release individual disposables from the array.
|
|
|
|
*/
|
|
|
|
class ArrayHolder extends Disposable {
|
|
|
|
private _array: IDisposable[] = [];
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
super();
|
|
|
|
this.onDispose(() => {
|
|
|
|
const seen = new Set();
|
|
|
|
for (const obj of this._array) {
|
|
|
|
if (!seen.has(obj)) {
|
|
|
|
seen.add(obj);
|
|
|
|
obj.dispose();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this._array = [];
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public autoDispose<T extends IDisposable>(obj: T): T {
|
|
|
|
this._array.push(obj);
|
|
|
|
return obj;
|
|
|
|
}
|
|
|
|
|
|
|
|
public release(obj: IDisposable) {
|
|
|
|
const index = this._array.indexOf(obj);
|
|
|
|
if (index >= 0) {
|
|
|
|
return this._array.splice(index, 1);
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
function syncHover(obs: Signal) {
|
|
|
|
return [dom.on('mouseenter', () => obs.emit(true)), dom.on('mouseleave', () => obs.emit(false))];
|
|
|
|
}
|
|
|
|
|
2023-03-22 15:21:53 +00:00
|
|
|
/**
|
|
|
|
* Helper function that renders an element from an observable, but prevents it from being disposed.
|
|
|
|
* Used to keep viewInstance from being disposed when it is added as a child in various containers.
|
|
|
|
*/
|
|
|
|
function detachedNode(node: Observable<HTMLElement|null>) {
|
|
|
|
return [
|
|
|
|
dom.maybe(node, n => n),
|
|
|
|
dom.onDispose(() => node.get() && detachNode(node.get()))
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2023-02-24 11:12:55 +00:00
|
|
|
/**
|
|
|
|
* 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;
|
2023-03-22 15:21:53 +00:00
|
|
|
let downX: number|null = null;
|
|
|
|
let downY: number|null = null;
|
2023-02-24 11:12:55 +00:00
|
|
|
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);
|
2023-03-22 15:21:53 +00:00
|
|
|
downX = ev.clientX;
|
|
|
|
downY = ev.clientY;
|
2023-02-24 11:12:55 +00:00
|
|
|
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':
|
|
|
|
if (justStarted) {
|
2023-03-22 15:21:53 +00:00
|
|
|
const slightMove = downX && downY &&
|
|
|
|
(Math.abs(ev.clientX - downX) > 3 || Math.abs(ev.clientY - downY) > 3);
|
|
|
|
if (slightMove) {
|
|
|
|
justStarted = false;
|
|
|
|
if (dragged?.dragStart) {
|
|
|
|
// Drag element has an opportunity to return a new draggable object.
|
|
|
|
dragged = dragged.dragStart(ev as DragEvent, floater!);
|
|
|
|
if (!dragged) {
|
|
|
|
return;
|
|
|
|
}
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
2023-03-22 15:21:53 +00:00
|
|
|
// Now we are dragging.
|
|
|
|
isDragging = true;
|
2023-02-24 11:12:55 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
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));
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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<VRect>) {
|
|
|
|
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};
|
2023-06-29 14:44:37 +00:00
|
|
|
border-radius: 4px;
|
2023-02-24 11:12:55 +00:00
|
|
|
-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', `
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
overflow: hidden;
|
|
|
|
transition: height 0.2s;
|
|
|
|
position: relative;
|
2023-06-29 14:44:37 +00:00
|
|
|
margin: calc(-1 * var(--view-content-page-margin, 12px));
|
|
|
|
margin-bottom: 0;
|
2023-02-24 11:12:55 +00:00
|
|
|
user-select: none;
|
2023-06-29 14:44:37 +00:00
|
|
|
background-color: ${theme.pageBg};
|
|
|
|
border-bottom: 1px solid ${theme.pagePanelsBorder};
|
|
|
|
outline-offset: -1px;
|
2023-02-24 11:12:55 +00:00
|
|
|
|
|
|
|
&-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, `
|
2023-06-29 14:44:37 +00:00
|
|
|
padding: 8px 24px;
|
|
|
|
column-gap: 16px;
|
|
|
|
row-gap: 8px;
|
2023-02-24 11:12:55 +00:00
|
|
|
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);
|
|
|
|
`);
|
2023-03-22 15:21:53 +00:00
|
|
|
|
|
|
|
const cssHidden = styled('div', `display: none;`);
|