Source RapidContext_Widget_Dialog.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15// Namespace initialization
16if (typeof(RapidContext) == "undefined") {
17    RapidContext = {};
18}
19RapidContext.Widget = RapidContext.Widget || { Classes: {} };
20
21/**
22 * Creates a new dialog widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {string} [attrs.title] the dialog title, defaults to "Dialog"
27 * @param {boolean} [attrs.modal] the modal dialog flag, defaults to `false`
28 * @param {boolean} [attrs.system] the system dialog flag, implies modal,
29 *            defaults to `false`
30 * @param {boolean} [attrs.center] the center dialog flag, defaults to `true`
31 * @param {boolean} [attrs.closeable] the closeable dialog flag, defaults to
32 *            `true`
33 * @param {boolean} [attrs.resizeable] the resize dialog flag, defaults to
34 *            `true`
35 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to `true`
36 * @param {...(Node|Widget|string)} [child] the child widgets or DOM nodes
37 *
38 * @return {Widget} the widget DOM node
39 *
40 * @class The dialog widget class. Used to provide a resizeable and
41 *     moveable window within the current page. Internally it uses a
42 *     number of `<div>` HTML elements.
43 * @extends RapidContext.Widget
44 *
45 * @example <caption>JavaScript</caption>
46 * let h1 = MochiKit.DOM.H1({}, "Hello, world!");
47 * let attrs = { title: "Hello", modal: true };
48 * let helloDialog = RapidContext.Widget.Dialog(attrs, h1);
49 * RapidContext.Util.registerSizeConstraints(helloDialog, "200", "75");
50 *
51 * @example <caption>User Interface XML</caption>
52 * <Dialog id="helloDialog" title="Hello" modal="true" w="200" h="75">
53 *   <h1>Hello, world!</h1>
54 * </Dialog>
55 */
56RapidContext.Widget.Dialog = function (attrs/*, ... */) {
57    let DIV = MochiKit.DOM.DIV;
58    let title = DIV({ "class": "widgetDialogTitle", "data-dialog": "move" }, "Dialog");
59    let close = RapidContext.Widget.Icon({
60        "class": "widgetDialogClose fa fa-times",
61        "title": "Close",
62        "data-dialog": "close"
63    });
64    let resize = DIV({ "class": "widgetDialogResize", "data-dialog": "resize" });
65    let content = DIV({ "class": "widgetDialogContent" });
66    let o = DIV({}, title, close, resize, content);
67    RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Dialog);
68    o.classList.add("widgetDialog");
69    o.resizeContent = o._resizeContent;
70    o._setHidden(true);
71    o.setAttrs(Object.assign({ modal: false, system: false, center: true }, attrs));
72    o.addAll(Array.from(arguments).slice(1));
73    o.on("click", "[data-dialog]", o._handleClick);
74    o.on("mousedown", "[data-dialog]", o._handleMouseDown);
75    return o;
76};
77
78// Register widget class
79RapidContext.Widget.Classes.Dialog = RapidContext.Widget.Dialog;
80
81/**
82 * Emitted when the dialog is shown.
83 *
84 * @name RapidContext.Widget.Dialog#onshow
85 * @event
86 */
87
88/**
89 * Emitted when the dialog is hidden.
90 *
91 * @name RapidContext.Widget.Dialog#onhide
92 * @event
93 */
94
95/**
96 * Emitted when the dialog is moved. The event will be sent
97 * repeatedly when moving with a mouse drag operation.
98 *
99 * @name RapidContext.Widget.Dialog#onmove
100 * @event
101 */
102
103/**
104 * Emitted when the dialog is resized. The event will be sent
105 * repeatedly when resizing with a mouse drag operation.
106 *
107 * @name RapidContext.Widget.Dialog#onresize
108 * @event
109 */
110
111/**
112 * Returns the widget container DOM node.
113 *
114 * @return {Node} the container DOM node
115 */
116RapidContext.Widget.Dialog.prototype._containerNode = function () {
117    return this.lastChild;
118};
119
120/**
121 * Returns the widget style DOM node.
122 *
123 * @return {Node} the style DOM node
124 */
125RapidContext.Widget.Dialog.prototype._styleNode = function () {
126    return this.lastChild;
127};
128
129/**
130 * Handles click events in the dialog. Will close the dialog if an element
131 * with `data-dialog="close"` attribute was clicked.
132 *
133 * @param {Event} evt the DOM Event object
134 */
135RapidContext.Widget.Dialog.prototype._handleClick = function (evt) {
136    if (evt.delegateTarget.dataset.dialog == "close") {
137        this.hide();
138    }
139};
140
141/**
142 * Handles mouse down events in the dialog. Will start move or resize actions
143 * if an element with a `data-dialog="move"` or `data-dialog="resize"`
144 * attribute was clicked.
145 *
146 * @param {Event} evt the DOM Event object
147 */
148RapidContext.Widget.Dialog.prototype._handleMouseDown = function (evt) {
149    let action = evt.delegateTarget.dataset.dialog;
150    if (action == "move" || action == "resize") {
151        evt.preventDefault();
152        let isDim = action == "resize";
153        let x = (isDim ? this.offsetWidth : this.offsetLeft) - evt.pageX;
154        let y = (isDim ? this.offsetHeight : this.offsetTop) - evt.pageY;
155        document._drag = { target: this, action: action, x: x, y: y };
156        document.addEventListener("mouseup", this._handleMouseUp);
157        document.addEventListener("mousemove", this._handleMouseMove);
158    }
159};
160
161/**
162 * Stops a dialog resize or move drag operation and removes event listeners.
163 * Note that this event handler is attached to the root `document`.
164 *
165 * @param {Event} evt the DOM Event object
166 */
167RapidContext.Widget.Dialog.prototype._handleMouseUp = function (evt) {
168    let o = document._drag;
169    if (o && o.target) {
170        // FIXME: Use AbortSignal instead to disconnect
171        document.removeEventListener("mouseup", o.target._handleMouseUp);
172        document.removeEventListener("mousemove", o.target._handleMouseMove);
173    }
174    delete document._drag;
175};
176
177/**
178 * Handles a dialog move drag operation.
179 *
180 * @param {Event} evt the DOM Event object
181 */
182RapidContext.Widget.Dialog.prototype._handleMouseMove = function (evt) {
183    let o = document._drag;
184    if (o && o.action == "move") {
185        o.target.moveTo(o.x + evt.pageX, o.y + evt.pageY);
186    } else if (o && o.action == "resize") {
187        o.target.resizeTo(o.x + evt.pageX, o.y + evt.pageY);
188    }
189};
190
191/**
192 * Updates the dialog or HTML DOM node attributes.
193 *
194 * @param {Object} attrs the widget and node attributes to set
195 * @param {string} [attrs.title] the dialog title
196 * @param {boolean} [attrs.modal] the modal dialog flag
197 * @param {boolean} [attrs.system] the system dialog flag, implies modal
198 * @param {boolean} [attrs.center] the center dialog flag
199 * @param {boolean} [attrs.closeable] the closeable dialog flag
200 * @param {boolean} [attrs.resizeable] the resize dialog flag
201 * @param {boolean} [attrs.hidden] the hidden widget flag
202 */
203RapidContext.Widget.Dialog.prototype.setAttrs = function (attrs) {
204    attrs = Object.assign({}, attrs);
205    if ("title" in attrs) {
206        this.firstChild.innerText = attrs.title || "";
207        delete attrs.title;
208    }
209    if ("modal" in attrs) {
210        attrs.modal = RapidContext.Data.bool(attrs.modal);
211    }
212    if ("system" in attrs) {
213        attrs.system = RapidContext.Data.bool(attrs.system);
214        this.classList.toggle("system", attrs.system);
215    }
216    if ("center" in attrs) {
217        attrs.center = RapidContext.Data.bool(attrs.center);
218    }
219    if ("resizeable" in attrs) {
220        attrs.resizeable = RapidContext.Data.bool(attrs.resizeable);
221        this.childNodes[2].classList.toggle("hidden", !attrs.resizeable);
222    }
223    if ("closeable" in attrs) {
224        attrs.closeable = RapidContext.Data.bool(attrs.closeable);
225        this.childNodes[1].setAttrs({ hidden: !attrs.closeable });
226    }
227    if ("hidden" in attrs) {
228        this._setHiddenDialog(RapidContext.Data.bool(attrs.hidden));
229        delete attrs.hidden;
230    }
231    this.__setAttrs(attrs);
232};
233
234/**
235 * Performs the changes corresponding to setting the `hidden`
236 * widget attribute for the Dialog widget.
237 *
238 * @param {boolean} value the new attribute value
239 */
240RapidContext.Widget.Dialog.prototype._setHiddenDialog = function (value) {
241    if (!!value === this.isHidden()) {
242        // Avoid repetitive show/hide calls
243    } else if (value) {
244        if (this._modalNode != null) {
245            RapidContext.Widget.destroyWidget(this._modalNode);
246            this._modalNode = null;
247        }
248        this.blurAll();
249        this._setHidden(true);
250        this.emit("hide");
251    } else {
252        if (this.parentNode == null) {
253            throw new Error("Cannot show Dialog widget without setting a parent DOM node");
254        }
255        if (this.modal || this.system) {
256            let attrs = { loading: false, message: "", style: { "z-index": "99" } };
257            if (this.system) {
258                attrs.dark = true;
259            }
260            this._modalNode = RapidContext.Widget.Overlay(attrs);
261            this.parentNode.append(this._modalNode);
262        }
263        this._setHidden(false);
264        this.resetScroll();
265        this._resizeContent();
266        this.emit("show");
267    }
268};
269
270/**
271 * Moves the dialog to the specified position (relative to the
272 * parent DOM node). The position will be restrained by the parent
273 * DOM node size.
274 *
275 * @param {number} x the horizontal position (in pixels)
276 * @param {number} y the vertical position (in pixels)
277 */
278RapidContext.Widget.Dialog.prototype.moveTo = function (x, y) {
279    let max = {
280        x: this.parentNode.offsetWidth - this.offsetWidth - 2,
281        y: this.parentNode.offsetHeight - this.offsetHeight - 2
282    };
283    let pos = {
284        x: Math.round(Math.max(0, Math.min(x, max.x))),
285        y: Math.round(Math.max(0, Math.min(y, max.y)))
286    };
287    this.style.left = pos.x + "px";
288    this.style.top = pos.y + "px";
289    let el = this.lastChild;
290    el.style.maxWidth = (this.parentNode.offsetWidth - pos.x - el.offsetLeft - 5) + "px";
291    el.style.maxHeight = (this.parentNode.offsetHeight - pos.y - el.offsetTop - 5) + "px";
292    this.center = false;
293    this.emit("move", { detail: pos });
294};
295
296/**
297 * Moves the dialog to the apparent center (relative to the parent DOM
298 * node). The vertical position actually uses the golden ratio instead
299 * of the geometric center for improved visual alignment.
300 */
301RapidContext.Widget.Dialog.prototype.moveToCenter = function () {
302    this.style.left = "0px";
303    this.style.top = "0px";
304    this.lastChild.style.maxWidth = "";
305    this.lastChild.style.maxHeight = "";
306    let x = (this.parentNode.offsetWidth - this.offsetWidth) / 2;
307    let y = (this.parentNode.offsetHeight - this.offsetHeight) / 2.618;
308    this.moveTo(x, y);
309    this.center = true;
310};
311
312/**
313 * Resizes the dialog to the specified size (in pixels). The size
314 * will be restrained by the parent DOM node size.
315 *
316 * @param {number} w the width (in pixels)
317 * @param {number} h the height (in pixels)
318 *
319 * @return {Dimensions} an object with "w" and "h" properties for the
320 *         actual size used
321 */
322RapidContext.Widget.Dialog.prototype.resizeTo = function (w, h) {
323    let max = {
324        w: this.parentNode.offsetWidth - this.offsetLeft - 2,
325        h: this.parentNode.offsetHeight - this.offsetTop - 2
326    };
327    let dim = {
328        w: Math.round(Math.max(150, Math.min(w, max.w))),
329        h: Math.round(Math.max(100, Math.min(h, max.h)))
330    };
331    this.style.width = dim.w + "px";
332    this.style.height = dim.h + "px";
333    delete this.sizeConstraints; // FIXME: Remove with RapidContext.Util.registerSizeConstraints
334    this.center = false;
335    this._resizeContent();
336    this.emit("resize", { detail: dim });
337    return dim;
338};
339
340/**
341 * Resizes the dialog to the optimal size for the current content.
342 * Note that the size reported by the content may vary depending on
343 * if it has already been displayed, is absolutely positioned, etc.
344 * The size will be restrained by the parent DOM node size.
345 *
346 * @return {Dimensions} an object with "w" and "h" properties for the
347 *         actual size used
348 */
349RapidContext.Widget.Dialog.prototype.resizeToContent = function () {
350    let el = this.lastChild;
351    let w = Math.max(el.scrollWidth, el.offsetWidth) + 2;
352    let h = Math.max(el.scrollHeight, el.offsetHeight) + el.offsetTop + 2;
353    return this.resizeTo(w, h);
354};
355
356/**
357 * Called when dialog content should be resized.
358 */
359RapidContext.Widget.Dialog.prototype._resizeContent = function () {
360    if (!this.isHidden()) {
361        RapidContext.Util.resizeElements(this.lastChild);
362        if (this.center) {
363            this.moveToCenter();
364        }
365    }
366};
367
368/**
369 * Resets the scroll offsets for all child elements in the dialog.
370 */
371RapidContext.Widget.Dialog.prototype.resetScroll = function () {
372    function scrollReset(el) {
373        el.scrollTop = 0;
374        el.scrollLeft = 0;
375    }
376    Array.from(this.querySelectorAll("*")).forEach(scrollReset);
377};
378