1314 lines
43 KiB
TypeScript
1314 lines
43 KiB
TypeScript
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
|
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
|
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
|
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
|
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
|
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
|
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
|
// ┃ This file is part of the Perspective library, distributed under the terms ┃
|
|
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
|
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
|
|
|
import { find, toArray } from "@lumino/algorithm";
|
|
import { CommandRegistry } from "@lumino/commands";
|
|
|
|
import { SplitPanel, Panel, DockPanel } from "@lumino/widgets";
|
|
import uniqBy from "lodash/uniqBy";
|
|
import { DebouncedFunc, DebouncedFuncLeading, isEqual } from "lodash";
|
|
import type {
|
|
HTMLPerspectiveViewerElement,
|
|
ViewerConfig,
|
|
} from "@perspective-dev/viewer";
|
|
import type * as psp from "@perspective-dev/client";
|
|
import type * as psp_viewer from "@perspective-dev/viewer";
|
|
import injectedStyles from "../../../dist/css/injected.css";
|
|
import { PerspectiveDockPanel } from "./dockpanel";
|
|
import { WorkspaceMenu } from "./menu";
|
|
import { createCommands } from "./commands";
|
|
import { PerspectiveViewerWidget } from "./widget";
|
|
import type { Filter } from "@perspective-dev/client";
|
|
|
|
class AsyncMutex {
|
|
_lock: Promise<unknown> | null;
|
|
|
|
constructor() {
|
|
this._lock = null;
|
|
}
|
|
|
|
lock<A>(continuation: () => Promise<A>): Promise<A> {
|
|
if (this._lock !== null) {
|
|
return this._lock.then(() => this.lock(continuation));
|
|
}
|
|
|
|
this._lock = new Promise((x, y) =>
|
|
continuation()
|
|
.then((z) => {
|
|
this._lock = null;
|
|
x(z);
|
|
})
|
|
.catch((e) => {
|
|
this._lock = null;
|
|
y(e);
|
|
}),
|
|
);
|
|
|
|
return this._lock as Promise<A>;
|
|
}
|
|
}
|
|
|
|
export type PerspectiveSplitArea = {
|
|
type: "split-area";
|
|
sizes: number[];
|
|
orientation: "horizontal" | "vertical";
|
|
children: PerspectiveLayout[];
|
|
};
|
|
|
|
export type PerspectiveTabArea = {
|
|
type: "tab-area";
|
|
currentIndex: number;
|
|
widgets: string[];
|
|
};
|
|
|
|
export type PerspectiveLayout = PerspectiveSplitArea | PerspectiveTabArea;
|
|
|
|
export interface PerspectiveWorkspaceConfig {
|
|
sizes: number[];
|
|
viewers: Record<string, psp_viewer.ViewerConfigUpdate>;
|
|
detail: { main: PerspectiveLayout | null };
|
|
master?: {
|
|
sizes: number[];
|
|
widgets: string[];
|
|
};
|
|
}
|
|
|
|
const DEFAULT_WORKSPACE_SIZE = [1, 3];
|
|
let ID_COUNTER = 0;
|
|
|
|
export function genId(workspace: PerspectiveWorkspaceConfig) {
|
|
let i = `PERSPECTIVE_GENERATED_ID_${ID_COUNTER++}`;
|
|
if (Object.keys(workspace.viewers).includes(i)) {
|
|
i = genId(workspace);
|
|
}
|
|
return i;
|
|
}
|
|
|
|
/// This function takes a workspace config and viewer config and adds the
|
|
/// viewer config to the workspace config, returning a new workspace config.
|
|
/// This is a slightly different algorithm from the Lumino one,
|
|
/// which will be used on internal workspace actions (such as duplication).
|
|
/// It currently attaches the viewer using a split-right style,
|
|
/// (see Lumino docklayout.ts for documentation on insert modes).
|
|
export function addViewer(
|
|
workspace: PerspectiveWorkspaceConfig,
|
|
config: psp_viewer.ViewerConfigUpdate,
|
|
id: string,
|
|
): PerspectiveWorkspaceConfig {
|
|
const GOLDEN_RATIO = 0.618;
|
|
/// ensures that the sum of the input is 1
|
|
/// keeps the relative size of the elements
|
|
function normalize(sizes: number[]) {
|
|
const sum = sizes.reduce((a, b) => a + b, 0);
|
|
return sum === 1 ? sizes : sizes.map((size) => size / sum);
|
|
}
|
|
|
|
if (workspace.detail.main === null) {
|
|
return {
|
|
sizes: workspace.sizes,
|
|
viewers: {
|
|
...workspace.viewers,
|
|
[id]: config,
|
|
},
|
|
detail: {
|
|
main: {
|
|
type: "split-area",
|
|
sizes: [1],
|
|
orientation: "horizontal",
|
|
children: [
|
|
{
|
|
type: "tab-area",
|
|
currentIndex: 0,
|
|
widgets: [id],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
master: workspace.master,
|
|
};
|
|
} else if (
|
|
workspace.detail.main.type === "tab-area" ||
|
|
(workspace.detail.main.type === "split-area" &&
|
|
workspace.detail.main.orientation === "vertical")
|
|
) {
|
|
return {
|
|
sizes: workspace.sizes,
|
|
viewers: {
|
|
...workspace.viewers,
|
|
[id]: config,
|
|
},
|
|
detail: {
|
|
main: {
|
|
type: "split-area",
|
|
sizes: [0.5, 0.5],
|
|
orientation: "horizontal",
|
|
children: [
|
|
workspace.detail.main,
|
|
{
|
|
type: "tab-area",
|
|
currentIndex: 0,
|
|
widgets: [id],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
master: workspace.master,
|
|
};
|
|
} else if (
|
|
workspace.detail.main.type === "split-area" &&
|
|
workspace.detail.main.orientation === "horizontal"
|
|
) {
|
|
return {
|
|
sizes: workspace.sizes,
|
|
viewers: {
|
|
...workspace.viewers,
|
|
[id]: config,
|
|
},
|
|
detail: {
|
|
main: {
|
|
type: "split-area",
|
|
sizes: normalize([
|
|
...normalize(workspace.detail.main.sizes),
|
|
GOLDEN_RATIO,
|
|
]),
|
|
orientation: "horizontal",
|
|
children: [
|
|
...workspace.detail.main.children,
|
|
{
|
|
type: "tab-area",
|
|
currentIndex: 0,
|
|
widgets: [id],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
master: workspace.master,
|
|
};
|
|
} else {
|
|
throw new Error("Unknown workspace state");
|
|
}
|
|
}
|
|
|
|
export class PerspectiveWorkspace extends SplitPanel {
|
|
private dockpanel: PerspectiveDockPanel;
|
|
private detailPanel: Panel;
|
|
private masterPanel: SplitPanel;
|
|
client: psp.Client[];
|
|
element: HTMLElement;
|
|
menu_elem: HTMLElement;
|
|
private listeners: WeakMap<PerspectiveViewerWidget, () => void>;
|
|
private indicator: HTMLElement;
|
|
private commands: CommandRegistry;
|
|
private _menu?: WorkspaceMenu;
|
|
private _minimizedLayoutSlots?: Promise<DockPanel.ILayoutConfig>;
|
|
private _minimizedLayout?: DockPanel.ILayoutConfig;
|
|
private _maximizedWidget?: PerspectiveViewerWidget;
|
|
private _last_updated_state?: PerspectiveWorkspaceConfig;
|
|
_mutex: AsyncMutex;
|
|
// private _context_menu?: Menu & { init_overlay?: () => void };
|
|
|
|
constructor(element: HTMLElement) {
|
|
super({ orientation: "horizontal" });
|
|
this.addClass("perspective-workspace");
|
|
this.dockpanel = new PerspectiveDockPanel(this);
|
|
this.detailPanel = new Panel();
|
|
this.detailPanel.layout!.fitPolicy = "set-no-constraint";
|
|
this.detailPanel.addClass("perspective-scroll-panel");
|
|
this.detailPanel.addWidget(this.dockpanel);
|
|
this.masterPanel = new SplitPanel({ orientation: "vertical" });
|
|
this.masterPanel.addClass("master-panel");
|
|
this._mutex = new AsyncMutex();
|
|
this.dockpanel.layoutModified.connect(() => {
|
|
this.workspaceUpdated();
|
|
});
|
|
|
|
this.addWidget(this.detailPanel);
|
|
this.element = element;
|
|
this.listeners = new WeakMap();
|
|
this.client = [];
|
|
this.indicator = this.init_indicator();
|
|
this.commands = createCommands(this, this.indicator);
|
|
this.menu_elem = document.createElement("perspective-workspace-menu");
|
|
this.menu_elem.attachShadow({ mode: "open" });
|
|
this.menu_elem.shadowRoot!.innerHTML = `<style>:host{position:absolute;}${injectedStyles}</style>`;
|
|
|
|
this.element.shadowRoot!.insertBefore(
|
|
this.menu_elem,
|
|
this.element.shadowRoot!.lastElementChild!,
|
|
);
|
|
|
|
element.addEventListener("contextmenu", (event) =>
|
|
this.showContextMenu(null, event),
|
|
);
|
|
}
|
|
|
|
get_context_menu(): WorkspaceMenu | undefined {
|
|
return this._menu;
|
|
}
|
|
|
|
get_dock_panel(): PerspectiveDockPanel {
|
|
return this.dockpanel;
|
|
}
|
|
|
|
init_indicator() {
|
|
const exists = document.querySelector("body > perspective-indicator");
|
|
if (exists) {
|
|
return exists as HTMLElement;
|
|
}
|
|
const indicator = document.createElement("perspective-indicator");
|
|
indicator.style.position = "fixed";
|
|
indicator.style.pointerEvents = "none";
|
|
document.body.appendChild(indicator);
|
|
return indicator;
|
|
}
|
|
|
|
apply_indicator_theme() {
|
|
const theme_name = JSON.parse(
|
|
window
|
|
.getComputedStyle(this.element)
|
|
.getPropertyValue("--theme-name")
|
|
.trim(),
|
|
);
|
|
|
|
this.indicator.setAttribute("theme", theme_name);
|
|
}
|
|
|
|
/***************************************************************************
|
|
*
|
|
* `<perspective-workspace>` Public API
|
|
*
|
|
*/
|
|
|
|
async save(): Promise<PerspectiveWorkspaceConfig> {
|
|
return await this._mutex.lock(async () => {
|
|
const is_settings = this.dockpanel.mode === "single-document";
|
|
let detail = is_settings
|
|
? await this._minimizedLayoutSlots
|
|
: await PerspectiveDockPanel.mapWidgets(
|
|
async (widget) =>
|
|
(
|
|
widget as PerspectiveViewerWidget
|
|
).viewer.getAttribute("slot"),
|
|
this.dockpanel.saveLayout(),
|
|
);
|
|
|
|
const layout: PerspectiveWorkspaceConfig = {
|
|
sizes: [...this.relativeSizes()],
|
|
detail: detail as { main: PerspectiveLayout },
|
|
viewers: {},
|
|
master: undefined as
|
|
| { widgets: string[]; sizes: number[] }
|
|
| undefined,
|
|
};
|
|
|
|
if (this.masterPanel.isAttached) {
|
|
const master = {
|
|
widgets: this.masterPanel.widgets.map(
|
|
(widget) =>
|
|
(
|
|
widget as PerspectiveViewerWidget
|
|
).viewer.getAttribute("slot")!,
|
|
),
|
|
sizes: [...this.masterPanel.relativeSizes()],
|
|
};
|
|
|
|
layout.master = master;
|
|
}
|
|
|
|
// const viewers: Record<string, ViewerConfigUpdate> = {};
|
|
for (const widget of this.masterPanel.widgets) {
|
|
const psp_widget = widget as PerspectiveViewerWidget;
|
|
layout.viewers[psp_widget.viewer.getAttribute("slot")!] =
|
|
(await psp_widget.save()) as psp_viewer.ViewerConfigUpdate;
|
|
}
|
|
|
|
const widgets = PerspectiveDockPanel.getWidgets(
|
|
is_settings
|
|
? this._minimizedLayout!
|
|
: this.dockpanel.saveLayout(),
|
|
);
|
|
|
|
await Promise.all(
|
|
widgets.map(async (widget) => {
|
|
const psp_widget = widget as PerspectiveViewerWidget;
|
|
const slot = psp_widget.viewer.getAttribute("slot")!;
|
|
layout.viewers[slot] =
|
|
(await psp_widget.save()) as psp_viewer.ViewerConfigUpdate;
|
|
layout.viewers[slot]!.settings = false;
|
|
}),
|
|
);
|
|
|
|
return layout;
|
|
});
|
|
}
|
|
|
|
async restore(value: PerspectiveWorkspaceConfig) {
|
|
await this._mutex.lock(async () => {
|
|
const {
|
|
sizes,
|
|
master,
|
|
detail,
|
|
viewers: viewer_configs = {},
|
|
} = structuredClone(value);
|
|
|
|
if (master && master.widgets!.length > 0) {
|
|
this.setupMasterPanel(sizes || DEFAULT_WORKSPACE_SIZE);
|
|
} else {
|
|
if (this.masterPanel.isAttached) {
|
|
this.detailPanel.removeClass("has-master-panel");
|
|
this.masterPanel.close();
|
|
}
|
|
|
|
this.addWidget(this.detailPanel);
|
|
}
|
|
|
|
let tasks: Promise<void>[] = [];
|
|
|
|
// Using ES generators as context managers ..
|
|
for (const viewers of this._capture_viewers()) {
|
|
for (const widgets of this._capture_widgets()) {
|
|
for (const v of viewers) {
|
|
v.removeAttribute("class");
|
|
}
|
|
|
|
const callback = this._restore_callback.bind(
|
|
this,
|
|
viewer_configs,
|
|
viewers,
|
|
widgets,
|
|
);
|
|
|
|
if (detail) {
|
|
const detailLayout =
|
|
await PerspectiveDockPanel.mapWidgets(
|
|
(name: string) =>
|
|
callback.bind(this, false)(name),
|
|
detail,
|
|
);
|
|
|
|
this.dockpanel.mode = "multiple-document";
|
|
this.dockpanel.restoreLayout(detailLayout);
|
|
tasks = tasks.concat(
|
|
PerspectiveDockPanel.getWidgets(detailLayout).map(
|
|
(x) =>
|
|
(
|
|
x as PerspectiveViewerWidget
|
|
).viewer.flush(),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (master) {
|
|
// tasks = tasks.concat(
|
|
|
|
const tasks2: any[] = [],
|
|
names: string[] = [];
|
|
master.widgets!.map((name) => {
|
|
names.push(name);
|
|
tasks2.push(callback.bind(this, true)(name));
|
|
return name;
|
|
});
|
|
|
|
// return name;
|
|
tasks.push(
|
|
Promise.all(tasks2).then((x) => {
|
|
master.widgets = master.widgets!.map((name) => {
|
|
const idx = names.indexOf(name);
|
|
const task = x[idx];
|
|
return task;
|
|
});
|
|
}),
|
|
);
|
|
|
|
// const widgets = await Promise.all(tasks);
|
|
|
|
// );
|
|
|
|
master.sizes &&
|
|
this.masterPanel.setRelativeSizes(master.sizes);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(tasks);
|
|
});
|
|
}
|
|
|
|
*_capture_widgets() {
|
|
const widgets = this.getAllWidgets();
|
|
yield widgets;
|
|
for (const widget of widgets) {
|
|
if (!widget.node.isConnected) {
|
|
widget.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
*_capture_viewers() {
|
|
const viewers = Array.from(
|
|
this.element.children,
|
|
) as HTMLPerspectiveViewerElement[];
|
|
|
|
yield viewers;
|
|
const ending_widgets = this.getAllWidgets();
|
|
for (const viewer of viewers) {
|
|
let widget = ending_widgets.find((x) => {
|
|
const psp_widget = x as PerspectiveViewerWidget;
|
|
return psp_widget.viewer === viewer;
|
|
});
|
|
|
|
if (
|
|
!widget &&
|
|
Array.from(this.element.children).indexOf(viewer) > -1
|
|
) {
|
|
this.element.removeChild(viewer);
|
|
viewer.delete();
|
|
viewer.free();
|
|
}
|
|
}
|
|
}
|
|
|
|
async _restore_callback(
|
|
viewers: Record<string, psp_viewer.ViewerConfigUpdate>,
|
|
starting_viewers: HTMLPerspectiveViewerElement[],
|
|
starting_widgets: PerspectiveViewerWidget[],
|
|
master: boolean,
|
|
widgetName: string,
|
|
) {
|
|
let viewer_config;
|
|
viewer_config = viewers[widgetName];
|
|
|
|
let viewer =
|
|
!!widgetName &&
|
|
starting_viewers.find((x) => x.getAttribute("slot") === widgetName);
|
|
|
|
let widget;
|
|
if (viewer) {
|
|
widget = starting_widgets.find((x) => x.viewer === viewer);
|
|
if (widget) {
|
|
await widget.restore({ ...viewer_config });
|
|
} else {
|
|
widget = await this._createWidget({
|
|
config: { ...viewer_config },
|
|
viewer,
|
|
});
|
|
}
|
|
} else if (viewer_config) {
|
|
widget = await this._createWidgetAndNode({
|
|
config: { ...viewer_config },
|
|
slot: widgetName,
|
|
});
|
|
} else {
|
|
throw new Error(
|
|
`Could not find or create <perspective-viewer> for slot "${widgetName}"`,
|
|
);
|
|
}
|
|
|
|
if (master) {
|
|
widget.viewer.classList.add("workspace-master-widget");
|
|
await widget.viewer.restore({
|
|
plugin_config: { edit_mode: "SELECT_ROW_TREE" },
|
|
});
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-global-filter",
|
|
this.on_global_filter_callback,
|
|
);
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-select",
|
|
this.on_select_callback,
|
|
);
|
|
|
|
// TODO remove event listener
|
|
this.masterPanel.addWidget(widget);
|
|
}
|
|
|
|
return widget;
|
|
}
|
|
|
|
_validate(table: any) {
|
|
if (!table || !("view" in table) || typeof table?.view !== "function") {
|
|
throw new Error(
|
|
"Only `perspective.Table()` instances can be added to `tables`",
|
|
);
|
|
}
|
|
return table;
|
|
}
|
|
|
|
_set_listener(name: string, table: psp.Table | Promise<psp.Table>) {
|
|
if (table instanceof Promise) {
|
|
table = table.then(this._validate);
|
|
} else {
|
|
this._validate(table);
|
|
}
|
|
}
|
|
|
|
_delete_listener(name: string) {
|
|
this.getAllWidgets().some((widget) => {
|
|
const psp_widget = widget as PerspectiveViewerWidget;
|
|
if (psp_widget.viewer.getAttribute("table") === name) {
|
|
psp_widget.viewer.eject();
|
|
}
|
|
});
|
|
}
|
|
|
|
async update_widget_for_viewer(viewer: HTMLPerspectiveViewerElement) {
|
|
let slot_name = viewer.getAttribute("slot");
|
|
if (!slot_name) {
|
|
slot_name = this._gen_id();
|
|
viewer.setAttribute("slot", slot_name);
|
|
}
|
|
|
|
const table_name = viewer.getAttribute("table");
|
|
if (table_name) {
|
|
const slot = this.node.querySelector(`slot[name=${slot_name}]`);
|
|
if (!slot) {
|
|
console.warn(
|
|
`Undocked ${viewer.outerHTML}, creating default layout`,
|
|
);
|
|
|
|
const widget = await this._createWidget({
|
|
// config: {},
|
|
viewer,
|
|
});
|
|
|
|
this.dockpanel.addWidget(widget);
|
|
this.dockpanel.activateWidget(widget);
|
|
}
|
|
} else {
|
|
console.warn(`No table set for ${viewer.outerHTML}`);
|
|
}
|
|
}
|
|
|
|
remove_unslotted_widgets(viewers: HTMLPerspectiveViewerElement[]) {
|
|
const widgets = this.getAllWidgets();
|
|
for (const widget of widgets) {
|
|
const psp_widget = widget as PerspectiveViewerWidget;
|
|
let missing = viewers.indexOf(psp_widget.viewer) === -1;
|
|
if (missing) {
|
|
psp_widget.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
update_details_panel(viewers: HTMLPerspectiveViewerElement[]) {
|
|
if (this.masterPanel.widgets.length === 0) {
|
|
this.masterPanel.close();
|
|
}
|
|
}
|
|
|
|
/***************************************************************************
|
|
*
|
|
* Workspace-level contextmenu actions
|
|
*
|
|
*/
|
|
|
|
async duplicate(widget: PerspectiveViewerWidget): Promise<void> {
|
|
if (this.dockpanel.mode === "single-document") {
|
|
const _task =
|
|
await this._maximizedWidget!.viewer.toggleConfig(false);
|
|
|
|
this._unmaximize();
|
|
}
|
|
|
|
const config = (await widget.save()) as psp_viewer.ViewerConfigUpdate;
|
|
config.settings = false;
|
|
config.title = config.title ? `${config.title} (*)` : "";
|
|
const duplicate = await this._createWidgetAndNode({
|
|
config,
|
|
slot: undefined,
|
|
});
|
|
|
|
this.dockpanel.addWidget(duplicate, {
|
|
mode: "split-right",
|
|
ref: widget,
|
|
});
|
|
|
|
await duplicate.viewer.flush();
|
|
}
|
|
|
|
toggleMasterDetail(widget: PerspectiveViewerWidget) {
|
|
const isGlobalFilter = widget.parent !== this.dockpanel;
|
|
this.element.dispatchEvent(
|
|
new CustomEvent("workspace-toggle-global-filter", {
|
|
detail: {
|
|
widget,
|
|
isGlobalFilter: !isGlobalFilter,
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (isGlobalFilter) {
|
|
this.makeDetail(widget);
|
|
} else {
|
|
if (this.dockpanel.mode === "single-document") {
|
|
this.toggleSingleDocument(widget);
|
|
}
|
|
this.makeMaster(widget);
|
|
}
|
|
}
|
|
|
|
_maximize(widget: PerspectiveViewerWidget) {
|
|
widget.viewer.classList.add("widget-maximize");
|
|
if (!this._minimizedLayout) {
|
|
this._minimizedLayout = this.dockpanel.saveLayout();
|
|
this._minimizedLayoutSlots = PerspectiveDockPanel.mapWidgets(
|
|
async (widget: PerspectiveViewerWidget) =>
|
|
widget.viewer.getAttribute("slot"),
|
|
this.dockpanel.saveLayout(),
|
|
);
|
|
}
|
|
|
|
this._maximizedWidget = widget;
|
|
this.dockpanel.mode = "single-document";
|
|
this.dockpanel.activateWidget(widget);
|
|
}
|
|
|
|
_unmaximize() {
|
|
this._maximizedWidget!.viewer.classList.remove("widget-maximize");
|
|
this.dockpanel.mode = "multiple-document";
|
|
this.dockpanel.restoreLayout(this._minimizedLayout!);
|
|
this._minimizedLayout = undefined;
|
|
}
|
|
|
|
toggleSingleDocument(widget: PerspectiveViewerWidget) {
|
|
if (this.dockpanel.mode !== "single-document") {
|
|
this._maximize(widget);
|
|
} else {
|
|
this._unmaximize();
|
|
}
|
|
}
|
|
|
|
async _filterViewer(
|
|
viewer: HTMLPerspectiveViewerElement,
|
|
removeFilters: psp.Filter[],
|
|
insertFilters: psp.Filter[],
|
|
candidates: Set<string>,
|
|
) {
|
|
const config = await viewer.save();
|
|
|
|
await viewer.restore({
|
|
filter: config.filter
|
|
.filter((x) => !removeFilters.find((y) => y[0] === x[0]))
|
|
.concat(insertFilters),
|
|
});
|
|
}
|
|
|
|
async onPerspectiveSelect(
|
|
filterFun: (config: ViewerConfig) => boolean,
|
|
event: CustomEvent,
|
|
) {
|
|
const config = await (
|
|
event.target as HTMLPerspectiveViewerElement
|
|
).save();
|
|
|
|
if (!filterFun(config)) {
|
|
return;
|
|
}
|
|
|
|
const candidates = new Set([
|
|
...(config["group_by"] || []),
|
|
...(config["split_by"] || []),
|
|
...(config.filter || []).map((x: psp.Filter) => x[0]),
|
|
]);
|
|
|
|
const removeFilters = (
|
|
(event.detail.removeConfigs ?? []) as psp.ViewConfigUpdate[]
|
|
).flatMap((x) => x.filter ?? []);
|
|
const insertFilters = (
|
|
(event.detail.insertConfigs ?? []) as psp.ViewConfigUpdate[]
|
|
).flatMap((x) => x.filter ?? []);
|
|
|
|
toArray(this.dockpanel.widgets()).forEach((widget) => {
|
|
this._filterViewer(
|
|
(widget as PerspectiveViewerWidget).viewer,
|
|
removeFilters,
|
|
insertFilters,
|
|
candidates,
|
|
);
|
|
});
|
|
}
|
|
|
|
private on_select_callback = this.onPerspectiveSelect.bind(
|
|
this,
|
|
(config) => config.plugin !== "Datagrid",
|
|
);
|
|
|
|
private on_global_filter_callback = this.onPerspectiveSelect.bind(
|
|
this,
|
|
// (config) => config.plugin === "Datagrid",
|
|
(config) => true,
|
|
);
|
|
|
|
async makeMaster(widget: PerspectiveViewerWidget) {
|
|
if (widget.viewer.hasAttribute("settings")) {
|
|
await widget.toggleConfig();
|
|
}
|
|
|
|
widget.viewer.classList.add("workspace-master-widget");
|
|
await widget.viewer.restore({
|
|
plugin_config: { edit_mode: "SELECT_ROW_TREE" },
|
|
});
|
|
if (!this.masterPanel.isAttached) {
|
|
this.detailPanel.close();
|
|
this.setupMasterPanel(DEFAULT_WORKSPACE_SIZE);
|
|
}
|
|
|
|
this.masterPanel.addWidget(widget);
|
|
widget.isHidden && widget.show();
|
|
widget.viewer.restyleElement();
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-global-filter",
|
|
this.on_global_filter_callback,
|
|
);
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-select",
|
|
this.on_select_callback,
|
|
);
|
|
}
|
|
|
|
async makeDetail(widget: PerspectiveViewerWidget) {
|
|
widget.viewer.classList.remove("workspace-master-widget");
|
|
await widget.viewer.restore({
|
|
plugin_config: { edit_mode: "READ_ONLY" },
|
|
});
|
|
this.dockpanel.addWidget(widget, { mode: `split-left` });
|
|
if (this.masterPanel.widgets.length === 0) {
|
|
this.detailPanel.close();
|
|
this.masterPanel.close();
|
|
this.detailPanel.removeClass("has-master-panel");
|
|
this.addWidget(this.detailPanel);
|
|
}
|
|
|
|
widget.viewer.restyleElement();
|
|
widget.viewer.removeEventListener(
|
|
"perspective-global-filter",
|
|
this.on_global_filter_callback,
|
|
);
|
|
|
|
widget.viewer.removeEventListener(
|
|
"perspective-select",
|
|
this.on_select_callback,
|
|
);
|
|
}
|
|
|
|
/***************************************************************************
|
|
*
|
|
* Context Menu
|
|
*
|
|
*/
|
|
|
|
createContextMenu(widget: PerspectiveViewerWidget | null) {
|
|
this._menu = new WorkspaceMenu(
|
|
this.menu_elem.shadowRoot!,
|
|
this.element,
|
|
{
|
|
commands: this.commands,
|
|
},
|
|
);
|
|
|
|
const tabbar = find(
|
|
this.dockpanel.tabBars(),
|
|
(bar) => bar.currentTitle?.owner === widget,
|
|
);
|
|
|
|
this._menu.init_overlay = () => {
|
|
if (widget) {
|
|
widget.addClass("context-focus");
|
|
widget.viewer.classList.add("context-focus");
|
|
tabbar && tabbar.node.classList.add("context-focus");
|
|
this.element.classList.add("context-menu");
|
|
this.addClass("context-menu");
|
|
if (
|
|
widget.viewer.classList.contains("workspace-master-widget")
|
|
) {
|
|
this._menu!.node.classList.add("workspace-master-menu");
|
|
} else {
|
|
this._menu!.node.classList.remove("workspace-master-menu");
|
|
}
|
|
}
|
|
};
|
|
|
|
if (widget?.parent === this.dockpanel || widget === null) {
|
|
this._menu.addItem({
|
|
type: "submenu",
|
|
command: "workspace:newmenu",
|
|
submenu: (() => {
|
|
const submenu = new WorkspaceMenu(
|
|
this.menu_elem.shadowRoot!,
|
|
this.element,
|
|
{
|
|
commands: this.commands,
|
|
},
|
|
);
|
|
|
|
(async () => {
|
|
const names = await Promise.all(
|
|
this.client.map((c) => c.get_hosted_table_names()),
|
|
).then((x) => x.flat());
|
|
|
|
for (const table of names) {
|
|
let args;
|
|
if (widget !== null) {
|
|
args = {
|
|
table,
|
|
widget_name:
|
|
widget.viewer.getAttribute("slot"),
|
|
};
|
|
} else {
|
|
args = { table };
|
|
}
|
|
|
|
submenu.insertItem(0, {
|
|
command: "workspace:new",
|
|
args,
|
|
});
|
|
}
|
|
})();
|
|
|
|
const widgets = PerspectiveDockPanel.getWidgets(
|
|
this.dockpanel.saveLayout(),
|
|
);
|
|
|
|
if (widgets.length > 0) {
|
|
submenu.addItem({ type: "separator" });
|
|
}
|
|
|
|
let seen = new Set();
|
|
for (const target_widget of widgets) {
|
|
if (!seen.has(target_widget.title.label)) {
|
|
let args;
|
|
if (widget !== null) {
|
|
args = {
|
|
target_widget_name:
|
|
target_widget.viewer.getAttribute(
|
|
"slot",
|
|
),
|
|
widget_name:
|
|
widget.viewer.getAttribute("slot"),
|
|
};
|
|
} else {
|
|
args = {
|
|
target_widget_name:
|
|
target_widget.viewer.getAttribute(
|
|
"slot",
|
|
),
|
|
};
|
|
}
|
|
|
|
submenu.addItem({
|
|
command: "workspace:newview",
|
|
args,
|
|
});
|
|
|
|
seen.add(target_widget.title.label);
|
|
}
|
|
}
|
|
|
|
submenu.title.label = "New Table";
|
|
return submenu;
|
|
})(),
|
|
});
|
|
}
|
|
|
|
if (widget) {
|
|
const widget_name = widget.viewer.getAttribute("slot");
|
|
if (widget?.parent === this.dockpanel) {
|
|
this._menu.addItem({ type: "separator" });
|
|
}
|
|
|
|
this._menu.addItem({
|
|
command: "workspace:duplicate",
|
|
args: { widget_name },
|
|
});
|
|
|
|
this._menu.addItem({
|
|
command: "workspace:master",
|
|
args: { widget_name },
|
|
});
|
|
|
|
this._menu.addItem({ type: "separator" });
|
|
|
|
this._menu.addItem({
|
|
command: "workspace:settings",
|
|
args: { widget_name },
|
|
});
|
|
|
|
this._menu.addItem({
|
|
command: "workspace:reset",
|
|
args: { widget_name },
|
|
});
|
|
this._menu.addItem({
|
|
command: "workspace:export",
|
|
args: { widget_name },
|
|
});
|
|
this._menu.addItem({
|
|
command: "workspace:copy",
|
|
args: { widget_name },
|
|
});
|
|
|
|
this._menu.addItem({ type: "separator" });
|
|
|
|
this._menu.addItem({
|
|
command: "workspace:close",
|
|
args: { widget_name },
|
|
});
|
|
this._menu.addItem({
|
|
command: "workspace:help",
|
|
});
|
|
}
|
|
|
|
this._menu.aboutToClose.connect(() => {
|
|
if (widget) {
|
|
this.element.classList.remove("context-menu");
|
|
this.removeClass("context-menu");
|
|
widget.removeClass("context-focus");
|
|
tabbar?.node?.classList.remove("context-focus");
|
|
}
|
|
});
|
|
|
|
return this._menu;
|
|
}
|
|
|
|
showContextMenu(widget: PerspectiveViewerWidget | null, event: MouseEvent) {
|
|
if (!event.shiftKey) {
|
|
const menu = this.createContextMenu(widget);
|
|
menu.init_overlay?.();
|
|
const rect = this.element.getBoundingClientRect();
|
|
menu.open(event.clientX - rect.x, event.clientY - rect.y, {
|
|
host: this.menu_elem.shadowRoot as unknown as HTMLElement,
|
|
});
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
}
|
|
|
|
/***************************************************************************
|
|
*
|
|
* Context Menu
|
|
*
|
|
*/
|
|
|
|
clearLayout() {
|
|
this.getAllWidgets().forEach((widget) => widget.close());
|
|
this.widgets.forEach((widget) => widget.close());
|
|
this.detailPanel.close();
|
|
if (this.masterPanel.isAttached) {
|
|
this.detailPanel.removeClass("has-master-panel");
|
|
this.masterPanel.close();
|
|
}
|
|
}
|
|
|
|
setupMasterPanel(sizes: number[]) {
|
|
this.detailPanel.addClass("has-master-panel");
|
|
this.addWidget(this.masterPanel);
|
|
this.addWidget(this.detailPanel);
|
|
this.setRelativeSizes(sizes);
|
|
}
|
|
|
|
async addViewer(
|
|
config: psp_viewer.ViewerConfigUpdate,
|
|
is_global_filter?: boolean,
|
|
) {
|
|
await this._mutex.lock(async () => {
|
|
if (this.dockpanel.mode === "single-document") {
|
|
const _task = this._maximizedWidget!.viewer.toggleConfig(false);
|
|
this._unmaximize();
|
|
}
|
|
|
|
const widget = await this._createWidgetAndNode({ config });
|
|
if (is_global_filter) {
|
|
if (!this.masterPanel.isAttached) {
|
|
this.setupMasterPanel(DEFAULT_WORKSPACE_SIZE);
|
|
}
|
|
|
|
this.masterPanel.addWidget(widget);
|
|
} else {
|
|
if (!this.detailPanel.isAttached) {
|
|
this.addWidget(this.detailPanel);
|
|
}
|
|
this.dockpanel.addWidget(widget, { mode: "split-right" });
|
|
}
|
|
|
|
this.update();
|
|
});
|
|
}
|
|
|
|
/*********************************************************************
|
|
* Widget helper methods
|
|
*/
|
|
|
|
async _createWidgetAndNode({
|
|
config,
|
|
slot: slotname,
|
|
}: {
|
|
config: psp_viewer.ViewerConfigUpdate;
|
|
slot?: string;
|
|
}) {
|
|
const node = this._createNode(slotname);
|
|
const table = config.table;
|
|
const viewer = document.createElement("perspective-viewer");
|
|
viewer.setAttribute(
|
|
"slot",
|
|
node!.querySelector("slot")!.getAttribute("name")!,
|
|
);
|
|
|
|
if (table) {
|
|
viewer.setAttribute("table", table);
|
|
}
|
|
|
|
for (const client of this.client) {
|
|
const tables = await client.get_hosted_table_names();
|
|
if (table && tables.indexOf(table) > -1) {
|
|
await viewer.load(client);
|
|
return await this._createWidget({
|
|
config,
|
|
elem: node as HTMLElement,
|
|
viewer,
|
|
});
|
|
}
|
|
}
|
|
|
|
throw new Error(`Table "${table}" not found`);
|
|
}
|
|
|
|
_gen_id() {
|
|
let genId = `PERSPECTIVE_GENERATED_ID_${ID_COUNTER++}`;
|
|
if (this.element.querySelector(`[slot=${genId}]`)) {
|
|
genId = this._gen_id();
|
|
}
|
|
return genId;
|
|
}
|
|
|
|
_createNode(slotname?: string): HTMLElement {
|
|
let node = this.node.querySelector(`slot[name=${slotname}]`);
|
|
if (slotname === undefined || !node) {
|
|
const slot = document.createElement("slot");
|
|
slotname = slotname || this._gen_id();
|
|
slot.setAttribute("name", slotname);
|
|
const div = document.createElement("div");
|
|
div.classList.add("viewer-container");
|
|
div.appendChild(slot);
|
|
node = document.createElement("div");
|
|
node.classList.add("workspace-widget");
|
|
node.appendChild(div);
|
|
} else {
|
|
node = node.parentElement!.parentElement;
|
|
}
|
|
|
|
return node as HTMLElement;
|
|
}
|
|
|
|
async _createWidget({
|
|
config,
|
|
elem,
|
|
viewer,
|
|
}: {
|
|
config?: psp_viewer.ViewerConfigUpdate;
|
|
elem?: Element;
|
|
viewer: HTMLPerspectiveViewerElement;
|
|
}) {
|
|
let node: HTMLElement = elem as HTMLElement;
|
|
if (!node) {
|
|
const slotname = viewer.getAttribute("slot") || undefined;
|
|
node = this.node.querySelector(`slot[name=${slotname}]`)!;
|
|
if (!node) {
|
|
node = this._createNode(slotname)!;
|
|
} else {
|
|
node = node.parentElement!.parentElement!;
|
|
}
|
|
}
|
|
|
|
const onAttach = () => {
|
|
if (widget.viewer.parentElement !== this.element) {
|
|
this.element.appendChild(widget.viewer);
|
|
}
|
|
|
|
const event = new CustomEvent("workspace-new-view", {
|
|
detail: { config, widget },
|
|
});
|
|
|
|
this.element.dispatchEvent(event);
|
|
};
|
|
|
|
const widget = new PerspectiveViewerWidget({ node, viewer, onAttach });
|
|
if (config) {
|
|
await widget.restore(config);
|
|
}
|
|
|
|
widget.title.closable = true;
|
|
this._addWidgetEventListeners(widget);
|
|
return widget;
|
|
}
|
|
|
|
_addWidgetEventListeners(widget: PerspectiveViewerWidget) {
|
|
if (this.listeners.has(widget)) {
|
|
this.listeners.get(widget)!();
|
|
}
|
|
|
|
const contextMenu = (event: MouseEvent) =>
|
|
this.showContextMenu(widget, event);
|
|
|
|
const updated = async (event: CustomEvent) => {
|
|
this.workspaceUpdated();
|
|
// Sometimes plugins or other external code fires this event and
|
|
// does not populate this field!
|
|
const config =
|
|
typeof event.detail === "undefined"
|
|
? await widget.viewer.save()
|
|
: event.detail;
|
|
|
|
widget.title.label = config.title;
|
|
widget._title = config.title;
|
|
widget._is_pivoted = config.group_by?.length > 0;
|
|
};
|
|
|
|
widget.node.addEventListener("contextmenu", contextMenu);
|
|
|
|
// Settings
|
|
const settings_before = (event: CustomEvent) => {
|
|
if (event.detail && this.dockpanel.mode !== "single-document") {
|
|
this._maximize(widget);
|
|
}
|
|
};
|
|
|
|
const settings_after = (event: CustomEvent) => {
|
|
if (!event.detail && this.dockpanel.mode === "single-document") {
|
|
this._unmaximize();
|
|
}
|
|
};
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-status-indicator-click",
|
|
(event) => {
|
|
widget._titlebar_callback?.(event as MouseEvent);
|
|
},
|
|
);
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-toggle-settings-before",
|
|
settings_before,
|
|
);
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-toggle-settings",
|
|
settings_after,
|
|
);
|
|
|
|
const delete_before = () => {
|
|
if (!widget._deleted) {
|
|
widget._deleted = true;
|
|
widget.close();
|
|
}
|
|
};
|
|
|
|
const delete_after = (event: CustomEvent) => {
|
|
widget._titlebar?.handleEvent(event.detail as PointerEvent);
|
|
};
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-table-delete-before",
|
|
delete_before,
|
|
);
|
|
|
|
widget.viewer.addEventListener(
|
|
"perspective-statusbar-pointerdown",
|
|
delete_after,
|
|
);
|
|
|
|
// @ts-ignore
|
|
widget.viewer.addEventListener("perspective-config-update", updated);
|
|
|
|
this.listeners.set(widget, () => {
|
|
widget.node.removeEventListener("contextmenu", contextMenu);
|
|
widget.viewer.removeEventListener(
|
|
"perspective-table-delete-before",
|
|
delete_before,
|
|
);
|
|
|
|
widget.viewer.removeEventListener(
|
|
"perspective-table-delete",
|
|
delete_after,
|
|
);
|
|
|
|
widget.viewer.removeEventListener(
|
|
"perspective-toggle-settings",
|
|
settings_before,
|
|
);
|
|
|
|
widget.viewer.removeEventListener(
|
|
"perspective-toggle-settings",
|
|
settings_after,
|
|
);
|
|
|
|
// @ts-ignore
|
|
widget.viewer.removeEventListener(
|
|
"perspective-config-update",
|
|
updated,
|
|
);
|
|
});
|
|
}
|
|
|
|
getWidgetByName(name: string): PerspectiveViewerWidget | null {
|
|
return (
|
|
this.getAllWidgets().find(
|
|
(x) => x.viewer.getAttribute("slot") === name,
|
|
) || null
|
|
);
|
|
}
|
|
|
|
getAllWidgets(): PerspectiveViewerWidget[] {
|
|
return [
|
|
...(this.masterPanel.widgets as PerspectiveViewerWidget[]),
|
|
...toArray(this.dockpanel.widgets()),
|
|
] as PerspectiveViewerWidget[];
|
|
}
|
|
|
|
/***************************************************************************
|
|
*
|
|
* `workspace-layout-update` event
|
|
*
|
|
*/
|
|
|
|
_throttle?: DebouncedFuncLeading<() => Promise<void>>;
|
|
|
|
async workspaceUpdated() {
|
|
// if (!this._throttle) {
|
|
// this._throttle = throttle(async () => {
|
|
const layout = await this.save();
|
|
if (layout) {
|
|
if (this._last_updated_state) {
|
|
if (isEqual(this._last_updated_state, layout)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
this._last_updated_state =
|
|
layout as any as PerspectiveWorkspaceConfig;
|
|
|
|
this.element.dispatchEvent(
|
|
new CustomEvent("workspace-layout-update", {
|
|
detail: { layout },
|
|
}),
|
|
);
|
|
}
|
|
// }, 0);
|
|
// }
|
|
|
|
// this._throttle();
|
|
}
|
|
}
|