UNPKG

@eclipse-scout/core

Version:
1,427 lines (1,272 loc) 66 kB
/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { AbstractLayout, Action, arrays, BenchColumnLayoutData, BusyIndicatorOptions, BusySupport, cookies, DeferredGlassPaneTarget, DesktopBench, DesktopEventMap, DesktopFormController, DesktopHeader, DesktopLayout, DesktopModel, DesktopNavigation, DesktopNotification, Device, DisableBrowserF5ReloadKeyStroke, DisableBrowserTabSwitchingKeyStroke, DisplayParent, DisplayViewId, EnumObject, Event, EventEmitter, EventHandler, FileChooser, FileChooserController, Form, GlassPaneTarget, HtmlComponent, HtmlEnvironment, InitModelOf, KeyStrokeContext, Menu, MessageBox, MessageBoxController, NativeNotificationVisibility, ObjectIdProvider, ObjectOrChildModel, ObjectOrModel, objects, OfflineDesktopNotification, OpenUriHandler, Outline, OutlineContent, OutlineViewButton, Popup, ReloadPageOptions, ResponsiveHandler, scout, SimpleTabArea, SimpleTabBox, Splitter, SplitterMoveEndEvent, SplitterMoveEvent, SplitterPositionChangeEvent, strings, styles, Tooltip, Tree, TreeDisplayStyle, UnsavedFormChangesForm, URL, ViewButton, webstorage, Widget, widgets } from '../index'; import $ from 'jquery'; export class Desktop extends Widget implements DesktopModel, DisplayParent { declare model: DesktopModel; declare eventMap: DesktopEventMap; declare self: Desktop; displayStyle: DesktopDisplayStyle; title: string; selectViewTabsKeyStrokesEnabled: boolean; selectViewTabsKeyStrokeModifier: string; cacheSplitterPosition: boolean; browserHistoryEntry: BrowserHistoryEntry; logoId: string; logoUrl: string; navigationVisible: boolean; navigationHandleVisible: boolean; logoActionEnabled: boolean; benchVisible: boolean; headerVisible: boolean; geolocationServiceAvailable: boolean; benchLayoutData: BenchColumnLayoutData; nativeNotificationDefaults: NativeNotificationDefaults; menus: Menu[]; addOns: Widget[]; dialogs: Form[]; views: Form[]; keyStrokes: Action[]; viewButtons: ViewButton[]; messageBoxes: MessageBox[]; fileChoosers: FileChooser[]; outline: Outline; activeForm: Form; selectedViewTabs: Map<DisplayViewId, Form>; notifications: DesktopNotification[]; navigation: DesktopNavigation; header: DesktopHeader; bench: DesktopBench; splitter: Splitter; splitterVisible: boolean; formController: DesktopFormController; messageBoxController: MessageBoxController; fileChooserController: FileChooserController; initialFormRendering: boolean; resizing: boolean; offline: boolean; inBackground: boolean; openUriHandler: OpenUriHandler; theme: string; dense: boolean; animateLayoutChange: boolean; url: URL; responsiveHandler: ResponsiveHandler; busySupport: BusySupport; $notifications: JQuery; $overlaySeparator: JQuery; /** @internal */ _resizeHandler: (event: JQuery.ResizeEvent) => void; protected _glassPaneTargetFilters: GlassPaneTargetFilter[]; protected _offlineNotification: OfflineDesktopNotification; /** event listeners */ protected _selectedViewDestroyHandler: EventHandler<Event<Form>>; protected _popstateHandler: (event: JQuery.TriggeredEvent) => void; protected _repositionTooltipsHandler: () => void; constructor() { super(); this.title = null; this.selectViewTabsKeyStrokesEnabled = true; this.selectViewTabsKeyStrokeModifier = 'control'; this.cacheSplitterPosition = true; this.browserHistoryEntry = null; this.logoId = null; this.navigationVisible = true; this.navigationHandleVisible = true; this.logoActionEnabled = false; this.benchVisible = true; this.headerVisible = true; this.geolocationServiceAvailable = Device.get().supportsGeolocation(); this.benchLayoutData = null; this.nativeNotificationDefaults = null; this.menus = []; this.addOns = []; this.dialogs = []; this.views = []; this.keyStrokes = []; this.viewButtons = []; this.messageBoxes = []; this.fileChoosers = []; this.outline = null; this.activeForm = null; this.selectedViewTabs = new Map(); this.notifications = []; this.navigation = null; this.header = null; this.bench = null; this.splitter = null; this.splitterVisible = false; this.formController = null; this.messageBoxController = null; this.fileChooserController = null; this.initialFormRendering = false; this.offline = false; this.inBackground = false; this.openUriHandler = null; this.theme = null; this.dense = false; this.url = null; this.busySupport = scout.create(BusySupport, {parent: this}); this.$notifications = null; this.$overlaySeparator = null; this._addWidgetProperties(['viewButtons', 'menus', 'views', 'selectedViewTabs', 'dialogs', 'outline', 'messageBoxes', 'notifications', 'fileChoosers', 'addOns', 'keyStrokes', 'activeForm', 'focusedElement']); this._addPreserveOnPropertyChangeProperties(['focusedElement', 'selectedViewTabs']); this._glassPaneTargetFilters = []; this._selectedViewDestroyHandler = this._onSelectedViewDestroy.bind(this); this._repositionTooltipsHandler = null; } static DisplayStyle = { /** * Default style with header, navigation (outline) and bench (forms). */ DEFAULT: 'default', /** * In this style, only the bench is visible, header and navigation are invisible. * * Currently, you'll also have to manually set {@link navigationVisible} and {@link headerVisible} to false. */ BENCH: 'bench', /** * Compact style that can be used for mobile devices where navigation and bench are never visible simultaneously. */ COMPACT: 'compact' } as const; /** * The action that should be performed when handling an "open URI" event. */ static UriAction = { /** * The object represented by the URI should be downloaded rather than be handled by the browser's rendering engine. * It should make the "Save as..." dialog appear which allows the user to store the resource to his local file system.<br> * The application's location does not change, and no browser windows or tabs are opened.<br> * <br> *<b>Important:</b> This action only works if the HTTP header <i>Content-Disposition: attachment</i> is present on the response of the object to be downloaded.<br> */ DOWNLOAD: 'download', /** * The object represented by the URI should be opened by the browser rather than just be downloaded. * This will only work if the browser knows how to handle the given URI. * E.g. if it points to a pdf file, most browsers will be able to display it using their pdf viewer. Other files may just be downloaded. * If the URI points to a website, it will be opened in a separate window or tab. * <br> * This is also the preferred action to open URIs with <b>special protocols</b> that are registered in the user's system and delegated to some "protocol * handler". This handler may then perform actions in a third party application (e.g. <i>mailto:xyz@example.com</i> * would open the system's mail application).<br> * Note that this action may open the object in a new window or tab which may be prevented by * the browser's popup blocker mechanism. */ OPEN: 'open', /** * The content represented by the URI should be rendered by the browser and displayed in a new window or tab.<br> * The application's location does not change. Note that this action may be prevented by the browser's popup blocker mechanism. */ NEW_WINDOW: 'newWindow', /** * The content represented by the URI should be rendered by the browser and displayed in a new non-modal popup * window. * Unlike {@link UriAction.NEW_WINDOW} the newly opened window is limited, i.e. it does not contain the location, the toolbar and the menubar. * This may not work on every browser (e.g. on a mobile browser the action {@link UriAction.POPUP_WINDOW} will likely behave the same way as {@link UriAction.NEW_WINDOW}).<br> * The application's location does not change. Note that this action may be prevented by the browser's popup blocker * mechanism. */ POPUP_WINDOW: 'popupWindow', /** * The content represented by the URI should be opened in the same window. * This will mainly just replace the location of the current window. * If the URI points to another website, the current application will be unloaded and replaced. * If it points to a file or uses a special protocol, the handling depends on the used browser. */ SAME_WINDOW: 'sameWindow' } as const; static DEFAULT_THEME = 'default'; protected override _init(model: InitModelOf<this>) { // Note: session and desktop are tightly coupled. Because a lot of widgets want to register // a listener on the desktop in their init phase, they access the desktop by calling 'this.session.desktop' // that's why we need this instance as early as possible. When that happens they access a desktop which is // not yet fully initialized. But anyway, it's already possible to attach a listener, for instance. // Because of this line of code here, we don't have to set the variable in App.ts, after the desktop has been // created. Also note that Scout Java uses a different pattern to solve the same problem, there a VirtualDesktop // is used during initialization. When initialization is done, all registered listeners on the virtual desktop // are copied to the real desktop instance. let session = model.session || model.parent.session; session.desktop = this; // Needs to be initialized before notifications are created because notifications read this value during init model.nativeNotificationDefaults = this._createNativeNotificationDefaults(model); super._init(model); this.url = new URL(); this._initTheme(); this.formController = scout.create(DesktopFormController, { displayParent: this, session: this.session }); this.messageBoxController = scout.create(MessageBoxController, { displayParent: this, session: this.session }); this.fileChooserController = scout.create(FileChooserController, { displayParent: this, session: this.session }); this._resizeHandler = this.onResize.bind(this); this._popstateHandler = this.onPopstate.bind(this); this.updateSplitterVisibility(); this.resolveTextKeys(['title']); this._setViews(this.views); this._setViewButtons(this.viewButtons); this._setSelectedViewTabs(this.selectedViewTabs); this._setMenus(this.menus); this._setKeyStrokes(this.keyStrokes); this._setBenchLayoutData(this.benchLayoutData); this._setDisplayStyle(this.displayStyle); this._setDense(this.dense); this.openUriHandler = scout.create(OpenUriHandler, { session: this.session }); this._glassPaneTargetFilters.push((targetElem, element) => { // Exclude all child elements of the given widget // Use case: element is a popup and has tooltip open. The tooltip is displayed in the desktop and considered as glass pane target by the selector above let target = scout.widget(targetElem); return !element.has(target); }); } protected override _createKeyStrokeContext(): KeyStrokeContext { return new KeyStrokeContext(); } protected override _initKeyStrokeContext() { super._initKeyStrokeContext(); this.keyStrokeContext.invokeAcceptInputOnActiveValueField = true; this.keyStrokeContext.registerKeyStrokes([ new DisableBrowserF5ReloadKeyStroke(this), new DisableBrowserTabSwitchingKeyStroke(this) ]); } protected _createNativeNotificationDefaults(model: DesktopModel): NativeNotificationDefaults { return $.extend({ title: model.title, iconId: model.logoId }, model.nativeNotificationDefaults); } /** @see DesktopModel.nativeNotificationDefaults */ setNativeNotificationDefaults(defaults: NativeNotificationDefaults) { this.setProperty('nativeNotificationDefaults', defaults); } protected override _render() { this.$container = this.$parent; this.$container.addClass('desktop'); this.htmlComp = HtmlComponent.install(this.$container, this.session); this.htmlComp.setLayout(this._createLayout()); // Attach resize listener before other elements can add their own resize listener (e.g. an addon) to make sure it is executed first this.$container.window() .on('resize', this._resizeHandler) .on('popstate', this._popstateHandler); // Desktop elements are added before this separator, all overlays are opened after (dialogs, popups, tooltips etc.) this.$overlaySeparator = this.$container.appendDiv('overlay-separator').setVisible(false); this._renderDense(); this._renderNavigationVisible(); this._renderHeaderVisible(); this._renderBenchVisible(); this._renderTitle(); this._renderLogoUrl(); this._renderSplitterVisible(); this._renderInBackground(); this._renderNavigationHandleVisible(); this._renderNotifications(); this._renderBrowserHistoryEntry(); this.addOns.forEach(addOn => addOn.render()); // prevent general drag and drop, dropping a file anywhere in the application must not open this file in browser this._setupDragAndDrop(); this._disableContextMenu(); } protected override _remove() { this.formController.remove(); this.messageBoxController.remove(); this.fileChooserController.remove(); this.$container.window() .off('resize', this._resizeHandler) .off('popstate', this._popstateHandler); super._remove(); } protected override _postRender() { super._postRender(); // Render attached forms, message boxes and file choosers. this.initialFormRendering = true; this._renderDisplayChildrenOfOutline(); this.formController.render(); this.messageBoxController.render(); this.fileChooserController.render(); this.initialFormRendering = false; } protected _setDisplayStyle(displayStyle: DesktopDisplayStyle) { this._setProperty('displayStyle', displayStyle); let compact = this.displayStyle === Desktop.DisplayStyle.COMPACT; if (this.header) { if (this.header.tabArea) { const DisplayStyle = SimpleTabArea.DisplayStyle; this.header.tabArea.setDisplayStyle(compact ? DisplayStyle.SPREAD_EVEN : DisplayStyle.DEFAULT); } this.header.setToolBoxVisible(!compact); this.header.animateRemoval = compact; } if (this.navigation) { this.navigation.setToolBoxVisible(compact); this.navigation.htmlComp.layoutData.fullWidth = compact; } if (this.bench) { this.bench.setOutlineContentVisible(!compact); } if (this.outline) { this.outline.setCompact(compact); this.outline.setEmbedDetailContent(compact); } } /** @see BusySupport.setBusy */ setBusy(busy: boolean | BusyIndicatorOptions) { this.busySupport.setBusy(busy); } /** * @returns true if the desktop has a busy indicator active. */ get busy(): boolean { return this.busySupport.isBusy(); } /** @see DesktopModel.dense */ setDense(dense: boolean) { this.setProperty('dense', dense); } protected _setDense(dense: boolean) { this._setProperty('dense', dense); styles.clearCache(); HtmlEnvironment.get().init(this.dense ? 'dense' : null); } protected _renderDense() { this.$container.toggleClass('dense', this.dense); } protected _createLayout(): AbstractLayout { return new DesktopLayout(this); } /** * Displays attached forms, message boxes and file choosers. * Outline does not need to be rendered to show the child elements, it needs to be active (necessary if navigation is invisible) */ protected _renderDisplayChildrenOfOutline() { if (!this.outline) { return; } this.outline.formController.render(); this.outline.messageBoxController.render(); this.outline.fileChooserController.render(); // Restore the selected views after an outline change or select them initially based on the model state if (this.outline.selectedViewTabs.size > 0) { this.outline.selectedViewTabs.forEach(selectedView => { this.formController._activateView(selectedView); }); } else { // views on the outline are not activated by default. Check for modal views on this outline let modalViews = this.outline.views.filter(view => view.modal); // activate each modal view in the order it was originally activated modalViews.forEach(this.formController._activateView.bind(this.formController)); } } protected _removeDisplayChildrenOfOutline() { if (!this.outline) { return; } this.outline.formController.remove(); this.outline.messageBoxController.remove(); this.outline.fileChooserController.remove(); } computeParentForDisplayParent(displayParent: DisplayParent): Widget { // Outline must not be used as parent, otherwise the children (form, messageboxes etc.) would be removed if navigation is made invisible // The functions _render/removeDisplayChildrenOfOutline take care that the elements are correctly rendered/removed on an outline switch let parent = displayParent; if (displayParent instanceof Outline) { parent = this; } return parent; } protected _renderTitle() { let title = this.title; if (title === undefined || title === null) { return; } let $scoutDivs = $('div.scout'); if ($scoutDivs.length <= 1) { // only set document title in non-portlet case $scoutDivs.document(true).title = title; } } protected _renderActiveForm() { // NOP -> is handled in _setFormActivated when ui changes active form or if model changes form in _onFormShow/_onFormActivate } protected _renderBench() { if (this.bench) { return; } this.bench = this._createBench(); this.bench.render(); this.bench.$container.insertBefore(this.$overlaySeparator); this.invalidateLayoutTree(); } protected _createBench(): DesktopBench { return scout.create(DesktopBench, { parent: this, animateRemoval: true, headerTabArea: this.header ? this.header.tabArea : undefined, outlineContentVisible: this.displayStyle !== Desktop.DisplayStyle.COMPACT }); } protected _removeBench() { if (!this.bench) { return; } this.bench.on('destroy', () => { this.bench = null; this.invalidateLayoutTree(); }); this.bench.destroy(); } protected _renderBenchVisible() { this.animateLayoutChange = this.rendered; if (this.benchVisible) { this._renderBench(); this._renderInBackground(); } else { this._removeBench(); } } protected _renderNavigation() { if (this.navigation) { return; } this.navigation = this._createNavigation(); this.navigation.render(); this.navigation.$container.prependTo(this.$container); this.$container.removeClass('navigation-invisible'); this.invalidateLayoutTree(); } protected _createNavigation(): DesktopNavigation { return scout.create(DesktopNavigation, { parent: this, outline: this.outline, toolBoxVisible: this.displayStyle === Desktop.DisplayStyle.COMPACT, layoutData: { fullWidth: this.displayStyle === Desktop.DisplayStyle.COMPACT } }); } protected _removeNavigation() { this.$container.addClass('navigation-invisible'); if (!this.navigation) { return; } this.navigation.destroy(); this.navigation = null; this.invalidateLayoutTree(); } protected _renderNavigationVisible() { this.animateLayoutChange = this.rendered; if (this.navigationVisible) { this._renderNavigation(); } else { if (!this.animateLayoutChange) { this._removeNavigation(); } else { // re layout to trigger animation this.invalidateLayoutTree(); } } } protected _renderHeader() { if (this.header) { return; } this.header = this._createHeader(); this.header.render(); if (this.navigation && this.navigation.rendered) { this.header.$container.insertAfter(this.navigation.$container); } else { this.header.$container.prependTo(this.$container); } // register header tab area if (this.bench) { this.bench._setTabArea(this.header.tabArea); } this.invalidateLayoutTree(); } protected _createHeader(): DesktopHeader { let compact = this.displayStyle === Desktop.DisplayStyle.COMPACT; return scout.create(DesktopHeader, { parent: this, logoUrl: this.logoUrl, animateRemoval: compact, toolBoxVisible: !compact, tabArea: { displayStyle: compact ? SimpleTabArea.DisplayStyle.SPREAD_EVEN : SimpleTabArea.DisplayStyle.DEFAULT } }); } protected _removeHeader() { if (!this.header) { return; } this.header.on('destroy', () => { this.invalidateLayoutTree(); this.header = null; }); this.header.destroy(); } protected _renderHeaderVisible() { if (this.headerVisible) { this._renderHeader(); } else { this._removeHeader(); } } protected _renderLogoUrl() { if (this.header) { this.header.setLogoUrl(this.logoUrl); } } protected _renderSplitterVisible() { if (this.splitterVisible) { this._renderSplitter(); } else { this._removeSplitter(); } } protected _renderSplitter() { if (this.splitter || !this.navigation) { return; } this.splitter = scout.create(Splitter, { parent: this, $anchor: this.navigation.$container, $root: this.$container }); this.splitter.render(); this.splitter.$container.insertBefore(this.$overlaySeparator); this.splitter.on('move', this._onSplitterMove.bind(this)); this.splitter.on('moveEnd', this._onSplitterMoveEnd.bind(this)); this.splitter.on('positionChange', this._onSplitterPositionChange.bind(this)); this.updateSplitterPosition(); } protected _removeSplitter() { if (!this.splitter) { return; } this.splitter.destroy(); this.splitter = null; } protected _renderInBackground() { this.$container.toggleClass('in-background', this.inBackground && this.displayStyle !== Desktop.DisplayStyle.COMPACT); if (this.bench) { this.bench.$container.toggleClass('drop-shadow', this.inBackground); } } protected _renderBrowserHistoryEntry() { if (!Device.get().supportsHistoryApi()) { return; } let myWindow = this.$container.window(true), history = this.browserHistoryEntry; if (history) { let historyPath = this._createHistoryPath(history); let setStateFunc = (this.rendered ? myWindow.history.pushState : myWindow.history.replaceState).bind(myWindow.history); let historyState: DesktopHistoryState = {deepLinkPath: history.deepLinkPath}; setStateFunc(historyState, history.title, historyPath); } } /** * Takes the {@link BrowserHistoryEntry.path} and appends additional URL parameters. */ protected _createHistoryPath(history: BrowserHistoryEntry): string { if (!history.pathVisible) { return ''; } let historyPath = history.path; let cloneUrl = this.url.clone(); cloneUrl.removeParameter('dl'); cloneUrl.removeParameter('i'); if (objects.countOwnProperties(cloneUrl.parameterMap) > 0) { let pathUrl = new URL(historyPath); for (let paramName in cloneUrl.parameterMap) { pathUrl.addParameter(paramName, cloneUrl.getParameter(paramName) as string); } historyPath = pathUrl.toString({alwaysFirst: ['dl', 'i']}); } return historyPath; } protected _setupDragAndDrop() { let dragEnterOrOver = (event: JQuery.DragEnterEvent | JQuery.DragOverEvent) => { event.stopPropagation(); event.preventDefault(); // change cursor to forbidden (no dropping allowed) event.originalEvent.dataTransfer.dropEffect = 'none'; }; this.$container.on('dragenter', dragEnterOrOver); this.$container.on('dragover', dragEnterOrOver); this.$container.on('drop', (event: JQuery.DropEvent) => { event.stopPropagation(); event.preventDefault(); }); } updateSplitterVisibility() { // Splitter should only be visible if navigation and bench are visible, but never in compact mode (to prevent unnecessary splitter rendering) this.setSplitterVisible(this.navigationVisible && this.benchVisible && this.displayStyle !== Desktop.DisplayStyle.COMPACT); } setSplitterVisible(visible: boolean) { this.setProperty('splitterVisible', visible); } updateSplitterPosition() { if (!this.splitter) { return; } let storedSplitterPosition = this.cacheSplitterPosition && this._loadCachedSplitterPosition(); if (storedSplitterPosition) { // Restore splitter position let splitterPosition = parseInt(storedSplitterPosition, 10); this.splitter.setPosition(splitterPosition); this.invalidateLayoutTree(); } else { // Set initial splitter position (default defined by css) this.splitter.setPosition(); this.invalidateLayoutTree(); } } protected _disableContextMenu() { // Switch off browser's default context menu for the entire scout desktop (except input fields) this.$container.on('contextmenu', event => { if (event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA' && !event.target.isContentEditable) { event.preventDefault(); } }); } /** @see DesktopModel.outline */ setOutline(outline: Outline) { if (this.outline === outline) { return; } try { if (this.bench) { this.bench.setChanging(true); } if (this.rendered) { this._removeDisplayChildrenOfOutline(); } this.outline = outline; this._setDisplayStyle(this.displayStyle); this._setOutlineActivated(); if (this.navigation) { this.navigation.setOutline(this.outline); } // call render after triggering event so glasspane rendering taking place can refer to the current outline content this.trigger('outlineChange'); if (this.rendered) { this._renderDisplayChildrenOfOutline(); } } finally { if (this.bench) { this.bench.setChanging(false); } } } protected _setViews(views: Form[]) { if (views) { views.forEach(view => view.setDisplayParent(this)); } this._setProperty('views', views); } protected _setViewButtons(viewButtons: ViewButton[]) { this.updateKeyStrokes(viewButtons, this.viewButtons); this._setProperty('viewButtons', viewButtons); } /** @see DesktopModel.menus */ setMenus(menus: ObjectOrChildModel<Menu>[]) { if (this.header) { this.header.setMenus(menus); } } protected _setMenus(menus: Menu[]) { this.updateKeyStrokes(menus, this.menus); this._setProperty('menus', menus); } protected _setKeyStrokes(keyStrokes: Action[]) { this.updateKeyStrokes(keyStrokes, this.keyStrokes); this._setProperty('keyStrokes', keyStrokes); } /** @see DesktopModel.navigationHandleVisible */ setNavigationHandleVisible(visible: boolean) { this.setProperty('navigationHandleVisible', visible); } protected _renderNavigationHandleVisible() { this.$container.toggleClass('has-navigation-handle', this.navigationHandleVisible); } /** @see DesktopModel.navigationVisible */ setNavigationVisible(visible: boolean) { this.setProperty('navigationVisible', visible); this.updateSplitterVisibility(); } /** @see DesktopModel.benchVisible */ setBenchVisible(visible: boolean) { this.setProperty('benchVisible', visible); this.updateSplitterVisibility(); } /** @see DesktopModel.headerVisible */ setHeaderVisible(visible: boolean) { this.setProperty('headerVisible', visible); } protected _setBenchLayoutData(layoutData: ObjectOrModel<BenchColumnLayoutData>) { layoutData = BenchColumnLayoutData.ensure(layoutData); this._setProperty('benchLayoutData', layoutData); } protected _setInBackground(inBackground: boolean) { this._setProperty('inBackground', inBackground); } outlineDisplayStyle(): TreeDisplayStyle { if (this.outline) { return this.outline.displayStyle; } } shrinkNavigation() { if (this.outline && this.outline.toggleBreadcrumbStyleEnabled && this.navigationVisible && this.outlineDisplayStyle() === Tree.DisplayStyle.DEFAULT) { this.outline.setDisplayStyle(Tree.DisplayStyle.BREADCRUMB); } else { this.setNavigationVisible(false); } } enlargeNavigation() { if (this.outline && this.navigationVisible && this.outlineDisplayStyle() === Tree.DisplayStyle.BREADCRUMB) { this.outline.setDisplayStyle(Tree.DisplayStyle.DEFAULT); if (this.cacheSplitterPosition && this.splitter) { this.validateLayoutTree(); this._storeCachedSplitterPosition(this.splitter.position); } } else { this.setNavigationVisible(true); } } /** * @param headerVisible whether the desktop header should be visible. Default is true. */ switchToBench(headerVisible?: boolean) { this.setHeaderVisible(scout.nvl(headerVisible, true)); this.setBenchVisible(true); this.setNavigationVisible(false); } switchToNavigation() { this.setNavigationVisible(true); this.setHeaderVisible(false); this.setBenchVisible(false); } revalidateHeaderLayout() { if (this.header) { this.header.revalidateLayout(); } } goOffline() { if (this.offline) { return; } this.offline = true; this._removeOfflineNotification(); this._offlineNotification = scout.create(OfflineDesktopNotification, { parent: this }); this._offlineNotification.show(); } goOnline() { this._removeOfflineNotification(); } protected _removeOfflineNotification() { if (this._offlineNotification) { setTimeout(this.removeNotification.bind(this, this._offlineNotification), 3000); this._offlineNotification = null; } } addNotification(notification: DesktopNotification) { if (!notification) { return; } this.notifications.push(notification); if (this.rendered) { this._renderNotification(notification); } } protected _renderNotification(notification: DesktopNotification) { if (this.$notifications) { // Bring to front this.$notifications.appendTo(this.$container); } else { this.$notifications = this.$container.appendDiv('desktop-notifications'); } notification.fadeIn(this.$notifications); if (notification.duration > 0) { notification.removeTimeout = setTimeout(notification.hide.bind(notification), notification.duration); notification.one('remove', () => this.removeNotification(notification)); } } protected _renderNotifications() { this.notifications.forEach(notification => this._renderNotification(notification)); } /** * Removes the given notification. * @param notification Either an instance of DesktopNavigation or a String containing an ID of a notification instance. */ removeNotification(desktopNotification: DesktopNotification | string) { let notification: DesktopNotification; if (typeof desktopNotification === 'string') { notification = arrays.find(this.notifications, n => desktopNotification === n.id); } else { notification = desktopNotification; } if (!notification) { return; } if (notification.removeTimeout) { clearTimeout(notification.removeTimeout); } arrays.remove(this.notifications, notification); if (!this.rendered) { return; } if (notification.rendered) { notification.one('remove', this._onNotificationRemove.bind(this)); } notification.fadeOut(); } getPopups(): Popup[] { if (!this.$container) { return []; } let popups: Popup[] = []; this.$container.children('.popup').each((i, elem) => { let $popup = $(elem); let popup = widgets.get($popup); if (popup instanceof Popup) { popups.push(popup); } }); return popups; } getPopupsFor(widget: Widget): Popup[] { return this.getPopups().filter(popup => widget.has(popup)); } /** * Removes every popup which is a descendant of the given widget. */ removePopupsFor(widget: Widget) { this.getPopupsFor(widget).forEach(popup => popup.remove()); } /** * Opens the uri using {@link OpenUriHandler} * @param uri the uri to open * @param action the action to be performed on the given uri. Default is Desktop.UriAction.OPEN. */ openUri(uri: string, action?: DesktopUriAction) { if (!this.rendered) { this._postRenderActions.push(this.openUri.bind(this, uri, action)); return; } this.openUriHandler.openUri(uri, action); } bringOutlineToFront() { if (!this.rendered) { this._postRenderActions.push(this.bringOutlineToFront.bind(this)); return; } if (!this.inBackground || this.displayStyle === Desktop.DisplayStyle.BENCH) { return; } this._setInBackground(false); this._setOutlineActivated(); if (this.navigationVisible) { this.navigation.bringToFront(); } if (this.benchVisible) { this.bench.bringToFront(); } if (this.headerVisible) { this.header.bringToFront(); } this._renderInBackground(); } sendOutlineToBack() { if (this.inBackground) { return; } this._setInBackground(true); if (this.navigationVisible) { this.navigation.sendToBack(); } if (this.benchVisible) { this.bench.sendToBack(); } if (this.headerVisible) { this.header.sendToBack(); } this._renderInBackground(); } /** * === Method required for objects that act as 'displayParent' === * * Returns 'true' if the Desktop is currently accessible to the user. */ inFront(): boolean { return true; // Desktop is always available to the user. } /** * === Method required for objects that act as 'displayParent' === * * @returns the DOM elements to paint a glassPanes over, once a modal Form, message-box, file-chooser or wait-dialog is showed with the Desktop as its 'displayParent'. */ protected override _glassPaneTargets(element: Widget): GlassPaneTarget[] { // Do not return $container, because this is the parent of all forms and message boxes. Otherwise, no form could gain focus, even the form requested desktop modality. let $glassPaneTargets = this.$container .children() .not('.splitter') // exclude splitter to be locked .not('.desktop-notifications') // exclude notification box like 'connection interrupted' to be locked .not('.overlay-separator'); // exclude overlay separator (marker element) if (element) { if (element.$container) { $glassPaneTargets = $glassPaneTargets.not(element.$container); } let overlays = this.$overlaySeparator.nextAll().toArray(); let nextSiblings = []; // If the element is an overlay, get all next siblings and exclude them because they must not be covered if (element.$container && overlays.indexOf(element.$container[0]) > -1) { nextSiblings = element.$container.nextAll().toArray(); } // The top-most element should not have a glass-pane (#274353) let topMostElement = null; if (overlays.length) { for (let i = overlays.length - 1; i >= 0; i--) { // Don't consider filtered glass-pane targets like the HelpPopup.js // These targets stand outside the regular modality hierarchy. let overlay = overlays[i]; if (!this._isGlassPaneTargetFiltered(overlay, element)) { continue; } topMostElement = overlay; break; // stop looking further } } $glassPaneTargets = $glassPaneTargets.filter((i, targetElem) => { if (nextSiblings.indexOf(targetElem) > -1) { return false; } if (targetElem === topMostElement) { return false; } return this._isGlassPaneTargetFiltered(targetElem, element); }); } let glassPaneTargets: GlassPaneTarget[]; if (element instanceof Form && element.displayHint === Form.DisplayHint.VIEW) { $glassPaneTargets = $glassPaneTargets .not('.desktop-bench') .not('.desktop-header'); if (this.header && this.header.toolBox && this.header.toolBox.$container) { $glassPaneTargets.push(this.header.toolBox.$container); } glassPaneTargets = $.makeArray($glassPaneTargets); arrays.pushAll(glassPaneTargets, this._getBenchGlassPaneTargetsForView(element)); } else { glassPaneTargets = $.makeArray($glassPaneTargets); } // When a popup-window is opened its container must also be added to the result this._pushPopupWindowGlassPaneTargets(glassPaneTargets, element); return glassPaneTargets; } protected _isGlassPaneTargetFiltered(targetElem: HTMLElement, element: Widget): boolean { return this._glassPaneTargetFilters.every(filter => filter(targetElem, element)); } /** * Adds a filter which is applied when the glass pane targets are collected. * If the filter returns <code>false</code>, the target won't be accepted and not covered by a glass pane. * This filter should be used primarily for elements like the help-popup which stand outside the regular modality hierarchy. * * @param filter a function with the parameter target and element. Target is the element which * would be covered by a glass pane, element is the element the user interacts with (e.g. the modal dialog). * @see _glassPaneTargets */ addGlassPaneTargetFilter(filter: GlassPaneTargetFilter) { this._glassPaneTargetFilters.push(filter); } removeGlassPaneTargetFilter(filter: GlassPaneTargetFilter) { arrays.remove(this._glassPaneTargetFilters, filter); } /** * This 'deferred' object is used because popup windows are not immediately usable when they're opened. * That's why we must render the glass-pane of a popup window later. Which means, at the point in time * when its $container is created and ready for usage. To avoid race conditions we must also wait until * the glass pane renderer is ready. Only when both conditions are fulfilled, we can render the glass * pane. */ protected _deferredGlassPaneTarget(popupWindow: EventEmitter & { $container: JQuery }): DeferredGlassPaneTarget { let deferred = new DeferredGlassPaneTarget(); popupWindow.one('init', () => deferred.ready([popupWindow.$container])); return deferred; } protected _getBenchGlassPaneTargetsForView(view: Form): JQuery[] { let $glassPanes: JQuery[] = []; $glassPanes = $glassPanes.concat(this._getTabGlassPaneTargetsForView(view, this.header)); if (this.bench) { this.bench.visibleTabBoxes().forEach(tabBox => { if (!tabBox.rendered) { return; } if (tabBox.hasView(view)) { $glassPanes = $glassPanes.concat(this._getTabGlassPaneTargetsForView(view, tabBox)); } else { $glassPanes.push(tabBox.$container); } }); } return $glassPanes; } protected _getTabGlassPaneTargetsForView(view: Form, tabBox: SimpleTabBox<OutlineContent> | DesktopHeader): JQuery[] { let $glassPanes: JQuery[] = []; if (tabBox && tabBox.tabArea) { tabBox.tabArea.tabs.forEach(tab => { if (tab.view !== view) { $glassPanes.push(tab.$container); // Workaround for javascript not being able to prevent hover event propagation: // In case of tabs, the hover selector is defined on the element that is the direct parent // of the glass pane. Under these circumstances, the hover style isn't be prevented by the glass pane. tab.$container.addClass('glasspane-parent'); } }); } return $glassPanes; } protected _pushPopupWindowGlassPaneTargets(glassPaneTargets: GlassPaneTarget[], element: Widget) { this.formController.popupWindows.forEach(popupWindow => { if (element === popupWindow.form) { // Don't block form itself return; } glassPaneTargets.push(popupWindow.initialized ? popupWindow.$container[0] : this._deferredGlassPaneTarget(popupWindow)); }); } showForm(form: Form, position?: number) { let displayParent: DisplayParent = form.displayParent || this; form.setDisplayParent(displayParent); this._setFormActivated(form); // register listener to recover active form when child dialog is removed displayParent.formController.registerAndRender(form, position, true); } hideForm(form: Form) { if (!form.displayParent) { // showForm has probably never been called -> nothing to do here // May happen if form.close() is called immediately after form.open() without waiting for the open promise to resolve // Hint: it is not possible to check whether the form is rendered and then return (which would be the obvious thing to do). // Reason: Forms in popup windows are removed before getting closed, see DesktopFormController._onPopupWindowUnload return; } if (this.displayStyle === Desktop.DisplayStyle.COMPACT && form.isView() && this.benchVisible) { let openViews = this.bench.getViews().slice(); arrays.remove(openViews, form); if (openViews.length === 0) { // Hide bench and show navigation if this is the last view to be hidden this.switchToNavigation(); } } form.displayParent.formController.unregisterAndRemove(form); if (!this.benchVisible || this.bench.getViews().length === 0) { // Bring outline to front if last view has been closed, // even if bench is invisible (compact case) to update state correctly and reshow elements (dialog etc.) linked to the outline this.bringOutlineToFront(); } } /** * @see Form.isShown */ isFormShown(form: Form): boolean { let displayParent = form.displayParent || this; return displayParent.formController.isFormShown(form); } /** * Collects all forms that are currently shown, independent of the {@link Form.displayParent}. * This means, forms that have an {@link Outline} or another {@link Form} as display parent, are returned as well. * * *Note*: `shown` does not necessarily mean, the user can see the content of the form for sure, see {@link Form.isShown}. */ getShownForms(): Form[] { let forms = []; let displayParents = [this, ...this.getOutlines()]; for (let displayParent of displayParents) { forms = forms.concat(displayParent.views).concat(displayParent.dialogs); } // Form can also be a display parent, collect child forms as well for (let form of forms) { forms = forms.concat(form.views).concat(form.dialogs); } return forms; } findFormWithExclusiveKey(exclusiveKey: any, formClass?: new() => Form): Form { let key = exclusiveKey; if (typeof exclusiveKey === 'function') { key = exclusiveKey(); } scout.assertValue(key, 'ExclusiveKey expected'); for (let form of this.getShownForms()) { if (formClass && !(form instanceof formClass)) { continue; } if (objects.equals(form.exclusiveKey(), key)) { return form; } } return null; } /** * Creates a new form using the given `formClass` and `formModel` unless there is already a form shown that is an instance of the `formClass` and has the same `exclusiveKey`. * @param exclusiveKey can be anything, a primitive, an object or a function returning the key. */ createFormExclusive<TForm extends Form>(formClass: new() => TForm, formModel: InitModelOf<TForm>, exclusiveKey: any | (() => any)): TForm; /** * Creates a new form using the given `formCreator` unless there is already a form shown with the same `exclusiveKey`. * @param exclusiveKey can be anything, a primitive, an object or a function returning the key. */ createFormExclusive<TForm extends Form>(formCreator: () => TForm, exclusiveKey: any | (() => any)): TForm; createFormExclusive<TForm extends Form>(formClassOrCreator: (new() => TForm) | (() => TForm), formModelOrExclusiveKey: any, exclusiveKey?: any): TForm { let formClass; let formCreator; let formModel; if (objects.isSameOrExtendsClass(formClassOrCreator, Form)) { formClass = formClassOrCreator; formModel = formModelOrExclusiveKey; } else { formCreator = formClassOrCreator; exclusiveKey = formModelOrExclusiveKey; } // If there is a form with the same exclusive key and form class, return it let form = !objects.isNullOrUndefined(exclusiveKey) && this.findFormWithExclusiveKey(exclusiveKey, formClass) as TForm; if (form) { return form; } // Otherwise, create a new form if (formClass) { form = scout.create(formClass, $.extend({}, formModel, {exclusiveKey})); } else { form = formCreator(); form.setExclusiveKey(exclusiveKey); } return form; } /** * Brings the form into foreground so the user can see and work with it. * * In case of a {@link Form.DisplayHint.VIEW}, the tab will be selected. * In case of a {@link Form.DisplayHint.DIALOG}, the form will be moved to the front of all dialogs. * * If the form belongs to an outline (has {@link Form.displayParent} set to that outline) that is currently not active, the outline will be activated first. * * Only one form can be active at once. The currently active form is reflected by {@link activeForm}. */ activateForm(form: Form) { let displayParent = form?.displayParent || this; displayParent.formController.activateForm(form); } /** @internal */ _setOutlineActivated() { this._setFormActivated(null); if (this.outline) { this.outline.activateCurrentPage(); } } /** @internal */ _setFormActivated(form: Form) { // If desktop is in rendering process it can not set a new active form. Instead, the active form from the model is selected. if (!this.rendered || this.initialFormRendering) { return; } if (this.activeForm === form) { return; } this.activeForm = form; if (!form) { // no form is activated -> show outline this.bringOutlineToFront(); } else if (form.isView() && !form.detailForm && this.bench && this.bench.hasView(form)) { // view form was activated. send the outline to back to ensure the form is attached // exclude detail forms even though detail forms usually are not activated // Also only consider "real" views used in the bench and ignore other views (e.g. used in a form menu) this.sendOutlineToBack(); this._updateSelectedViewTabs(form); } this.triggerFormActivate(form); } protected _setSelectedViewTabs(views: Map<DisplayViewId, Form> | Form[]) { this.selectedViewTabs = this.prepareSelectedViewTabs(views); } prepareSelectedViewTabs(views: Map<DisplayViewId, Form> | Form[]): Map<DisplayViewId, Form> { let map = new Map(); views.forEach(view => { map.set(DesktopBench.normalizeDisplayViewId(view.displayViewId), view); view.one('destroy', this._selectedViewDestroyHandler); }); return map; } protected _updateSelectedViewTabs(form: Form) { let displayViewId = DesktopBench.normalizeDisplayViewId(form.displayViewId); let selectedViewTabs = this.selectedViewTabs; if (form.displayParent instanceof Outline) { selectedViewTabs = form.displayParent.selectedViewTabs; } else if (form.displayParent instanceof Desktop) { // As soon as a desktop tab was selected, don't remember outline tabs in that section anymore // because it should not automatically unselect a desktop tab when switching outlines this.getOutlines().forEach(outline => outline.selectedViewTabs.delete(displayViewId)); } else { return; } let previousForm = selectedViewTabs.get(displayViewId); if (previousForm === form) { return; } if (previousForm) { previousForm.off('destroy', this._selectedViewDestroyHandler); } selectedViewTabs.set(displayViewId, form); form.one('destroy', this._selectedViewDestroyHandler); } protected _onSelectedViewDestroy(event: Event<Form>) { let form = event.source; let displayViewId = DesktopBench.normalizeDisplayViewId(form.displayViewId); this.getOutlines().forEach(outline => outline.selectedViewTabs.delete(displayViewId)); this.selectedViewTabs.delete(displayViewId); } getOutlines(): Outline[] { let outlines = new Set<Outline>(); for (let child of this.children) { if (child instanceof Outline) { outlines.add(child); } if (child instanceof OutlineViewButton && child.outline) { outlines.add(child.outline); } } return Array.from(outlines); } triggerFormActivate(form: Form) { this.trigger('formActivate', { form: form }); } cancelViews(forms: Form[]): JQuery.Promise<void> { let event = this.trigger('cancelForms', { forms: forms }); if (!event.def