UNPKG

@bokeh/bokehjs

Version:

Interactive, novel data visualization

556 lines 21 kB
import { UIElement, UIElementView } from "../ui/ui_element"; import { DOMNode } from "../dom/dom_node"; import { Text } from "../dom/text"; import { Signal } from "../../core/signaling"; import { InlineStyleSheet, px, div, bounding_box, dom_ready } from "../../core/dom"; import { isString } from "../../core/util/types"; import { build_view } from "../../core/build_views"; import { BBox } from "../../core/util/bbox"; import { find, remove, min as amin } from "../../core/util/array"; import { enumerate } from "../../core/util/iterator"; import { assert } from "../../core/util/assert"; import * as Box from "../common/box_kinds"; import { Coordinate } from "../coordinates/coordinate"; import { Or, Ref } from "../../core/kinds"; import dialogs_css, * as dialogs from "../../styles/dialogs.css"; import icons_css from "../../styles/icons.css"; // Make sure this at least an order of magnitude lower than --bokeh-top-level. const base_z_index = 1000; const UIElementLike = Or(Ref(UIElement), Ref(DOMNode)); const _stacking_order = []; const _minimization_area = (() => { const el = div(); const shadow_el = el.attachShadow({ mode: "open" }); const stylesheet = new InlineStyleSheet(` :host { display: flex; flex-direction: column; flex-wrap: nowrap; position: fixed; left: 0; bottom: 0; width: max-content; height: max-content; } :host:empty { display: none; } `); stylesheet.install(shadow_el); void dom_ready().then(() => document.body.append(el)); return el; })(); export class DialogView extends UIElementView { static __name__ = "DialogView"; _title; _content; children_views() { return [...super.children_views(), this._title, this._content]; } _position = new InlineStyleSheet(); _stacking = new InlineStyleSheet(); stylesheets() { return [...super.stylesheets(), dialogs_css, icons_css, this._position, this._stacking]; } async lazy_initialize() { await super.lazy_initialize(); const title = (() => { const { title } = this.model; return isString(title) || title == null ? new Text({ content: title ?? "" }) : title; })(); const content = (() => { const { content } = this.model; return isString(content) ? new Text({ content }) : content; })(); this._title = await build_view(title, { parent: this }); this._content = await build_view(content, { parent: this }); } connect_signals() { super.connect_signals(); const { visible } = this.model.properties; this.connect(visible.change, () => this._toggle(this.model.visible)); } remove() { remove(_stacking_order, this); this._content.remove(); this._title.remove(); super.remove(); } _has_rendered = false; _handles; _pin_el; _collapse_el; _minimize_el; _maximize_el; _close_el; _reposition(position) { this._position.replace(":host", { left: "left" in position ? px(position.left) : "unset", right: "right" in position ? px(position.right) : "unset", top: "top" in position ? px(position.top) : "unset", bottom: "bottom" in position ? px(position.bottom) : "unset", width: "width" in position ? px(position.width) : "unset", height: "height" in position ? px(position.height) : "unset", }); this.update_bbox(); } render() { super.render(); this._title.render(); this._content.render(); const inner_el = div({ class: dialogs.inner }); this.shadow_el.append(inner_el); const header_el = div({ class: dialogs.header }); const content_el = div({ class: dialogs.content }, this._content.el); const footer_el = div({ class: dialogs.footer }); inner_el.append(header_el); inner_el.append(content_el); inner_el.append(footer_el); const grip_el = div({ class: dialogs.grip }); const title_el = div({ class: dialogs.title }, grip_el, this._title.el); const controls_el = div({ class: dialogs.controls }); header_el.append(title_el, controls_el); const pin_el = div({ class: [dialogs.ctrl, dialogs.pin], title: "Pin" }); pin_el.addEventListener("click", () => this.pin()); this._pin_el = pin_el; const collapse_el = div({ class: [dialogs.ctrl, dialogs.collapse], title: "Collapse" }); collapse_el.addEventListener("click", () => this.collapse()); this._collapse_el = collapse_el; const minimize_el = div({ class: [dialogs.ctrl, dialogs.minimize], title: "Minimize" }); minimize_el.addEventListener("click", () => this.minimize()); this._minimize_el = minimize_el; const maximize_el = div({ class: [dialogs.ctrl, dialogs.maximize], title: "Maximize" }); maximize_el.addEventListener("click", () => this.maximize()); this._maximize_el = maximize_el; const close_el = div({ class: [dialogs.ctrl, dialogs.close], title: "Close" }); close_el.addEventListener("click", () => this.close()); this._close_el = close_el; if (this.model.pinnable) { controls_el.append(pin_el); } if (this.model.collapsible) { controls_el.append(collapse_el); } if (this.model.minimizable) { controls_el.append(minimize_el); } if (this.model.maximizable) { controls_el.append(maximize_el); } if (this.model.closable) { controls_el.append(close_el); } const handles = this._handles = { // area area: title_el, // edges top: div({ class: [dialogs.handle, dialogs.resize_top] }), bottom: div({ class: [dialogs.handle, dialogs.resize_bottom] }), left: div({ class: [dialogs.handle, dialogs.resize_left] }), right: div({ class: [dialogs.handle, dialogs.resize_right] }), // corners top_left: div({ class: [dialogs.handle, dialogs.resize_top_left] }), top_right: div({ class: [dialogs.handle, dialogs.resize_top_right] }), bottom_left: div({ class: [dialogs.handle, dialogs.resize_bottom_left] }), bottom_right: div({ class: [dialogs.handle, dialogs.resize_bottom_right] }), }; this.shadow_el.append(handles.top, handles.bottom, handles.left, handles.right, handles.top_left, handles.top_right, handles.bottom_left, handles.bottom_right); let state = null; const cancel = () => { state = null; document.removeEventListener("pointermove", pointer_move); document.removeEventListener("pointerup", pointer_up); document.removeEventListener("keydown", key_press); this.el.classList.remove(dialogs.interacting); }; const pointer_move = (event) => { assert(state != null); event.preventDefault(); this.el.classList.add(dialogs.interacting); const dx = event.x - state.xy.x; const dy = event.y - state.xy.y; const { target, bbox } = state; const delta_bbox = this._move_bbox(target, bbox, dx, dy); this._reposition(delta_bbox); }; const pointer_up = (event) => { assert(state != null); event.preventDefault(); cancel(); }; const key_press = (event) => { if (event.key == "Escape") { assert(state != null); event.preventDefault(); const { left, top, width, height } = state.bbox; this._reposition({ left, top, width, height }); cancel(); } }; this.el.addEventListener("pointerdown", (event) => { assert(state == null); this.bring_to_front(); const target = this._hit_target(event.composedPath()); if (target == null || !this._can_hit(target)) { return; } event.preventDefault(); const { x, y } = event; state = { bbox: bounding_box(this.el), xy: { x, y }, target, }; document.addEventListener("pointermove", pointer_move); document.addEventListener("pointerup", pointer_up); document.addEventListener("keydown", key_press); const target_el = this._handles[target]; target_el.setPointerCapture(event.pointerId); }); title_el.addEventListener("wheel", (event) => { const dy = event.deltaY; if ((dy < 0 && !this._collapsed) || (dy > 0 && this._collapsed)) { event.preventDefault(); event.stopPropagation(); this.collapse(); } }); this._has_rendered = true; if (this.model.visible) { this.bring_to_front(); } } get resizable() { const { resizable } = this.model; return { left: resizable == "left" || resizable == "x" || resizable == "all", right: resizable == "right" || resizable == "x" || resizable == "all", top: resizable == "top" || resizable == "y" || resizable == "all", bottom: resizable == "bottom" || resizable == "y" || resizable == "all", }; } _hit_target(path) { const { _handles } = this; for (const el of path) { switch (el) { case _handles.area: return "area"; case _handles.top: return "top"; case _handles.bottom: return "bottom"; case _handles.left: return "left"; case _handles.right: return "right"; case _handles.top_left: return "top_left"; case _handles.top_right: return "top_right"; case _handles.bottom_left: return "bottom_left"; case _handles.bottom_right: return "bottom_right"; } } return null; } _can_hit(target) { if (this._minimized || this._maximized) { return false; } const { left, right, top, bottom } = this.resizable; switch (target) { case "top_left": return top && left; case "top_right": return top && right; case "bottom_left": return bottom && left; case "bottom_right": return bottom && right; case "left": return left; case "right": return right; case "top": return top; case "bottom": return bottom; case "area": return this.model.movable != "none"; } } _move_bbox(target, bbox, dx, dy) { const resolve = (dim, limit) => { if (limit instanceof Coordinate) { return this.resolve_as_scalar(limit, dim); } else { return NaN; } }; const slimits = BBox.from_lrtb({ left: resolve("x", this.model.left_limit), right: resolve("x", this.model.right_limit), top: resolve("y", this.model.top_limit), bottom: resolve("y", this.model.bottom_limit), }); const [dl, dr, dt, db] = (() => { const { symmetric } = this.model; const [Dx, Dy] = symmetric ? [-dx, -dy] : [0, 0]; switch (target) { // corners case "top_left": return [dx, Dx, dy, Dy]; case "top_right": return [Dx, dx, dy, Dy]; case "bottom_left": return [dx, Dx, Dy, dy]; case "bottom_right": return [Dx, dx, Dy, dy]; // edges case "left": return [dx, Dx, 0, 0]; case "right": return [Dx, dx, 0, 0]; case "top": return [0, 0, dy, Dy]; case "bottom": return [0, 0, Dy, dy]; // area case "area": { switch (this.model.movable) { case "both": return [dx, dx, dy, dy]; case "x": return [dx, dx, 0, 0]; case "y": return [0, 0, dy, dy]; case "none": return [0, 0, 0, 0]; } } } })(); const min = (a, b) => amin([a, b]); const sgn = (v) => v < 0 ? -1 : (v > 0 ? 1 : 0); let { left, right, left_sign, right_sign } = (() => { const left = bbox.left + dl; const right = bbox.right + dr; const left_sign = sgn(dl); const right_sign = sgn(dr); if (left <= right) { return { left, right, left_sign, right_sign }; } else { return { left: right, right: left, left_sign: right_sign, right_sign: left_sign }; } })(); let { top, bottom, top_sign, bottom_sign } = (() => { const top = bbox.top + dt; const bottom = bbox.bottom + db; const top_sign = sgn(dt); const bottom_sign = sgn(db); if (top <= bottom) { return { top, bottom, top_sign, bottom_sign }; } else { return { top: bottom, bottom: top, top_sign: bottom_sign, bottom_sign: top_sign }; } })(); const Dl = left - slimits.left; const Dr = slimits.right - right; const Dh = min(Dl < 0 ? Dl : NaN, Dr < 0 ? Dr : NaN); if (isFinite(Dh) && Dh < 0) { left += -left_sign * (-Dh); right += -right_sign * (-Dh); } const Dt = top - slimits.top; const Db = slimits.bottom - bottom; const Dv = min(Dt < 0 ? Dt : NaN, Db < 0 ? Db : NaN); if (isFinite(Dv) && Dv < 0) { top += -top_sign * (-Dv); bottom += -bottom_sign * (-Dv); } return BBox.from_lrtb({ left, right, top, bottom }); } _pinned = false; pin() { const { _pinned } = this; for (const dialog_view of _stacking_order) { if (dialog_view == this) { this._pin(!_pinned); } else { dialog_view._pin(false); } } if (!_pinned) { this.bring_to_front(); } } _pin(value) { if (this._pinned != value) { this._pinned = value; this.el.classList.toggle(dialogs.pinned, this._pinned); this._pin_el.title = this._pinned ? "Unpin" : "Pin"; } } _normal_bbox = null; _collapsed = false; collapse() { const position = (() => { if (!this._collapsed) { this._minimize(false); this._maximize(false); if (this._normal_bbox == null) { this._normal_bbox = bounding_box(this.el); } const { left, top, width } = this._normal_bbox; return { left, top, width, height: "max-content" }; } else { const { _normal_bbox } = this; assert(_normal_bbox != null); this._normal_bbox = null; return _normal_bbox; } })(); this._reposition(position); this._collapse(!this._collapsed); } _collapse(value) { if (this._collapsed != value) { this._collapsed = value; this.el.classList.toggle(dialogs.collapsed, this._collapsed); this._collapse_el.title = this._collapsed ? "Restore" : "Collapse"; } } _minimized = false; minimize() { const position = (() => { if (!this._minimized) { this._pin(false); this._collapse(false); this._maximize(false); if (this._normal_bbox == null) { this._normal_bbox = bounding_box(this.el); } return { width: "auto", height: "max-content" }; } else { const { _normal_bbox } = this; assert(_normal_bbox != null); this._normal_bbox = null; return _normal_bbox; } })(); this._reposition(position); this._minimize(!this._minimized); } _minimize(value) { if (this._minimized != value) { this._minimized = value; const target = value ? (_minimization_area.shadowRoot ?? _minimization_area) : document.body; target.append(this.el); this.el.classList.toggle(dialogs.minimized, this._minimized); this._minimize_el.title = this._minimized ? "Restore" : "Minimize"; } } _maximized = false; maximize() { const position = (() => { if (!this._maximized) { this._collapse(false); this._minimize(false); if (this._normal_bbox == null) { this._normal_bbox = bounding_box(this.el); } return { left: 0, top: 0, width: "100%", height: "100%" }; } else { const { _normal_bbox } = this; assert(_normal_bbox != null); this._normal_bbox = null; return _normal_bbox; } })(); this._reposition(position); this._maximize(!this._maximized); } _maximize(value) { if (this._maximized != value) { this._maximized = value; this.el.classList.toggle(dialogs.maximized, this._maximized); this._maximize_el.title = this._maximized ? "Restore" : "Maximize"; } } restore() { this._collapse(false); this._minimize(false); this._maximize(false); const { _normal_bbox } = this; if (_normal_bbox != null) { this._reposition(_normal_bbox); this._normal_bbox = null; } } _toggle(show) { if (show) { const target = document.body; if (!this._has_rendered) { this.render_to(target); this.r_after_render(); } if (!this.el.isConnected) { target.append(this.el); } this.bring_to_front(); } else { remove(_stacking_order, this); this.el.remove(); } } displayed = new Signal(this, "displayed"); get is_open() { return this.model.visible; } toggle(force) { const visible = force ?? !this.model.visible; this.model.setv({ visible }, { check_eq: false }); this.displayed.emit(visible); } open() { this.toggle(true); } close() { switch (this.model.close_action) { case "hide": { this.toggle(false); break; } case "destroy": { this.remove(); break; } } } bring_to_front() { if (!_stacking_order.includes(this)) { _stacking_order.push(this); } const pinned = find(_stacking_order, (view) => view._pinned); if (pinned != null) { remove(_stacking_order, pinned); } remove(_stacking_order, this); _stacking_order.push(this); if (pinned != null) { _stacking_order.push(pinned); } for (const [dialog_view, i] of enumerate(_stacking_order)) { dialog_view._stacking.replace(":host", { "z-index": `${base_z_index + i}`, }); } } } export class Dialog extends UIElement { static __name__ = "Dialog"; constructor(attrs) { super(attrs); } static { this.prototype.default_view = DialogView; this.define(({ Bool, Str, Ref, Or, Nullable, Enum }) => ({ title: [Nullable(Or(Str, Ref(DOMNode), Ref(UIElement))), null], content: [Or(Str, Ref(DOMNode), Ref(UIElement))], pinnable: [Bool, true], collapsible: [Bool, true], minimizable: [Bool, true], maximizable: [Bool, true], closable: [Bool, true], close_action: [Enum("hide", "destroy"), "destroy"], resizable: [Box.Resizable, "all"], movable: [Box.Movable, "both"], symmetric: [Bool, false], top_limit: [Box.Limit, null], bottom_limit: [Box.Limit, null], left_limit: [Box.Limit, null], right_limit: [Box.Limit, null], })); } } //# sourceMappingURL=dialog.js.map