| /* |
| * Copyright (C) 2015 Apple Inc. All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions |
| * are met: |
| * 1. Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * 2. Redistributions in binary form must reproduce the above copyright |
| * notice, this list of conditions and the following disclaimer in the |
| * documentation and/or other materials provided with the distribution. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' |
| * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, |
| * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS |
| * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF |
| * THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| WI.View = class View extends WI.Object |
| { |
| constructor(element) |
| { |
| super(); |
| |
| this._element = element || document.createElement("div"); |
| this._element.__view = this; |
| this._parentView = null; |
| this._subviews = []; |
| this._dirty = false; |
| this._dirtyDescendantsCount = 0; |
| this._isAttachedToRoot = false; |
| this._layoutReason = null; |
| this._didInitialLayout = false; |
| } |
| |
| // Static |
| |
| static fromElement(element) |
| { |
| if (!element || !(element instanceof HTMLElement)) |
| return null; |
| |
| if (element.__view instanceof WI.View) |
| return element.__view; |
| return null; |
| } |
| |
| static rootView() |
| { |
| if (!WI.View._rootView) { |
| // Since the root view is attached by definition, it does not go through the |
| // normal view attachment process. Simply mark it as attached. |
| WI.View._rootView = new WI.View(document.body); |
| WI.View._rootView._isAttachedToRoot = true; |
| } |
| |
| return WI.View._rootView; |
| } |
| |
| // Public |
| |
| get element() { return this._element; } |
| get layoutPending() { return this._dirty; } |
| get parentView() { return this._parentView; } |
| get subviews() { return this._subviews; } |
| get isAttached() { return this._isAttachedToRoot; } |
| |
| isDescendantOf(view) |
| { |
| let parentView = this._parentView; |
| while (parentView) { |
| if (parentView === view) |
| return true; |
| parentView = parentView.parentView; |
| } |
| |
| return false; |
| } |
| |
| addSubview(view) |
| { |
| this.insertSubviewBefore(view, null); |
| } |
| |
| insertSubviewBefore(view, referenceView) |
| { |
| console.assert(view instanceof WI.View); |
| console.assert(!referenceView || referenceView instanceof WI.View); |
| console.assert(view !== WI.View._rootView, "Root view cannot be a subview."); |
| |
| if (this._subviews.includes(view)) { |
| console.assert(false, "Cannot add view that is already a subview.", view); |
| return; |
| } |
| |
| const beforeIndex = referenceView ? this._subviews.indexOf(referenceView) : this._subviews.length; |
| if (beforeIndex === -1) { |
| console.assert(false, "Cannot insert view. Invalid reference view.", referenceView); |
| return; |
| } |
| |
| this._subviews.insertAtIndex(view, beforeIndex); |
| |
| console.assert(!view.element.parentNode || this._element.contains(view.element.parentNode), "Subview DOM element must be a descendant of the parent view element."); |
| if (!view.element.parentNode) |
| this._element.insertBefore(view.element, referenceView ? referenceView.element : null); |
| |
| view._didMoveToParent(this); |
| } |
| |
| removeSubview(view) |
| { |
| console.assert(view instanceof WI.View); |
| console.assert(this._element.contains(view.element), "Subview DOM element must be a child of the parent view element."); |
| |
| let index = this._subviews.lastIndexOf(view); |
| if (index === -1) { |
| console.assert(false, "Cannot remove view which isn't a subview.", view); |
| return; |
| } |
| |
| view._didMoveToParent(null); |
| |
| this._subviews.splice(index, 1); |
| view.element.remove(); |
| } |
| |
| removeAllSubviews() |
| { |
| for (let subview of this._subviews) |
| subview._didMoveToParent(null); |
| |
| this._subviews = []; |
| this._element.removeChildren(); |
| } |
| |
| replaceSubview(oldView, newView) |
| { |
| console.assert(oldView !== newView, "Cannot replace subview with itself."); |
| if (oldView === newView) |
| return; |
| |
| this.insertSubviewBefore(newView, oldView); |
| this.removeSubview(oldView); |
| } |
| |
| updateLayout(layoutReason) |
| { |
| this.cancelLayout(); |
| |
| this._setLayoutReason(layoutReason); |
| this._layoutSubtree(); |
| } |
| |
| updateLayoutIfNeeded(layoutReason) |
| { |
| if (!this._dirty && this._didInitialLayout) |
| return; |
| |
| this.updateLayout(layoutReason); |
| } |
| |
| needsLayout(layoutReason) |
| { |
| this._setLayoutReason(layoutReason); |
| |
| if (this._dirty) |
| return; |
| |
| WI.View._scheduleLayoutForView(this); |
| } |
| |
| cancelLayout() |
| { |
| WI.View._cancelScheduledLayoutForView(this); |
| } |
| |
| // Protected |
| |
| get layoutReason() { return this._layoutReason; } |
| get didInitialLayout() { return this._didInitialLayout; } |
| |
| attached() |
| { |
| // Implemented by subclasses. |
| } |
| |
| detached() |
| { |
| // Implemented by subclasses. |
| } |
| |
| initialLayout() |
| { |
| // Implemented by subclasses. |
| |
| // Called once when the view is shown for the first time. |
| // Views with complex DOM subtrees should create UI elements in |
| // initialLayout rather than at construction time. |
| } |
| |
| layout() |
| { |
| // Implemented by subclasses. |
| |
| // Not responsible for recursing to child views. |
| // Should not be called directly; use updateLayout() instead. |
| } |
| |
| didLayoutSubtree() |
| { |
| // Implemented by subclasses. |
| |
| // Called after the view and its entire subtree have finished layout. |
| } |
| |
| sizeDidChange() |
| { |
| // Implemented by subclasses. |
| |
| // Called after initialLayout, and before layout. |
| } |
| |
| // Private |
| |
| _didMoveToParent(parentView) |
| { |
| this._parentView = parentView; |
| |
| let isAttachedToRoot = this.isDescendantOf(WI.View._rootView); |
| this._didMoveToWindow(isAttachedToRoot); |
| |
| if (!this._parentView) |
| return; |
| |
| let pendingLayoutsCount = this._dirtyDescendantsCount; |
| if (this._dirty) |
| pendingLayoutsCount++; |
| |
| let view = this._parentView; |
| while (view) { |
| view._dirtyDescendantsCount += pendingLayoutsCount; |
| view = view.parentView; |
| } |
| } |
| |
| _didMoveToWindow(isAttachedToRoot) |
| { |
| if (this._isAttachedToRoot === isAttachedToRoot) |
| return; |
| |
| this._isAttachedToRoot = isAttachedToRoot; |
| if (this._isAttachedToRoot) { |
| WI.View._scheduleLayoutForView(this); |
| this.attached(); |
| } else { |
| if (this._dirty) |
| this.cancelLayout(); |
| this.detached(); |
| } |
| |
| for (let view of this._subviews) |
| view._didMoveToWindow(isAttachedToRoot); |
| } |
| |
| _layoutSubtree() |
| { |
| this._dirty = false; |
| this._dirtyDescendantsCount = 0; |
| let isInitialLayout = !this._didInitialLayout; |
| |
| if (isInitialLayout) { |
| console.assert(WI.setReentrantCheck(this, "initialLayout"), "ERROR: calling `initialLayout` while already in it", this); |
| this.initialLayout(); |
| this._didInitialLayout = true; |
| } |
| |
| if (this._layoutReason === WI.View.LayoutReason.Resize || isInitialLayout) { |
| console.assert(WI.setReentrantCheck(this, "sizeDidChange"), "ERROR: calling `sizeDidChange` while already in it", this); |
| this.sizeDidChange(); |
| console.assert(WI.clearReentrantCheck(this, "sizeDidChange"), "ERROR: missing return from `sizeDidChange`", this); |
| } |
| |
| let savedLayoutReason = this._layoutReason; |
| if (isInitialLayout) { |
| // The initial layout should always be treated as dirty. |
| this._setLayoutReason(); |
| } |
| |
| console.assert(WI.setReentrantCheck(this, "layout"), "ERROR: calling `layout` while already in it", this); |
| this.layout(); |
| console.assert(WI.clearReentrantCheck(this, "layout"), "ERROR: missing return from `layout`", this); |
| |
| // Ensure that the initial layout override doesn't affects to subviews. |
| this._layoutReason = savedLayoutReason; |
| |
| if (WI.settings.debugEnableLayoutFlashing.value) |
| this._drawLayoutFlashingOutline(isInitialLayout); |
| |
| for (let view of this._subviews) { |
| view._setLayoutReason(this._layoutReason); |
| view._layoutSubtree(); |
| } |
| |
| this._layoutReason = null; |
| |
| console.assert(WI.setReentrantCheck(this, "didLayoutSubtree"), "ERROR: calling `didLayoutSubtree` while already in it", this); |
| this.didLayoutSubtree(); |
| console.assert(WI.clearReentrantCheck(this, "didLayoutSubtree"), "ERROR: missing return from `didLayoutSubtree`", this); |
| } |
| |
| _setLayoutReason(layoutReason) |
| { |
| this._layoutReason = layoutReason || WI.View.LayoutReason.Dirty; |
| } |
| |
| _drawLayoutFlashingOutline(isInitialLayout) |
| { |
| if (this._layoutFlashingTimeout) |
| clearTimeout(this._layoutFlashingTimeout); |
| else |
| this._layoutFlashingPreviousOutline = this._element.style.outline; |
| |
| let hue = isInitialLayout ? 20 : 40; |
| this._element.style.outline = `1px solid hsla(${hue}, 100%, 51%, 0.8)`; |
| |
| this._layoutFlashingTimeout = setTimeout(() => { |
| if (this._element) |
| this._element.style.outline = this._layoutFlashingPreviousOutline; |
| |
| this._layoutFlashingTimeout = undefined; |
| this._layoutFlashingPreviousOutline = null; |
| }, 500); |
| } |
| |
| // Layout controller logic |
| |
| static _scheduleLayoutForView(view) |
| { |
| view._dirty = true; |
| |
| let parentView = view.parentView; |
| while (parentView) { |
| parentView._dirtyDescendantsCount++; |
| parentView = parentView.parentView; |
| } |
| |
| if (!view._isAttachedToRoot) |
| return; |
| |
| if (WI.View._scheduledLayoutUpdateIdentifier) |
| return; |
| |
| WI.View._scheduledLayoutUpdateIdentifier = requestAnimationFrame(WI.View._visitViewTreeForLayout); |
| } |
| |
| static _cancelScheduledLayoutForView(view) |
| { |
| let cancelledLayoutsCount = view._dirtyDescendantsCount; |
| if (view.layoutPending) |
| cancelledLayoutsCount++; |
| |
| let parentView = view.parentView; |
| while (parentView) { |
| parentView._dirtyDescendantsCount = Math.max(0, parentView._dirtyDescendantsCount - cancelledLayoutsCount); |
| parentView = parentView.parentView; |
| } |
| |
| view._dirty = false; |
| |
| if (!WI.View._scheduledLayoutUpdateIdentifier) |
| return; |
| |
| let rootView = WI.View._rootView; |
| if (!rootView || rootView._dirtyDescendantsCount) |
| return; |
| |
| // No views need layout, so cancel the pending requestAnimationFrame. |
| cancelAnimationFrame(WI.View._scheduledLayoutUpdateIdentifier); |
| WI.View._scheduledLayoutUpdateIdentifier = undefined; |
| } |
| |
| static _visitViewTreeForLayout() |
| { |
| console.assert(WI.View._rootView, "Cannot layout view tree without a root."); |
| |
| WI.View._scheduledLayoutUpdateIdentifier = undefined; |
| |
| let views = [WI.View._rootView]; |
| while (views.length) { |
| let view = views.shift(); |
| if (view.layoutPending) |
| view._layoutSubtree(); |
| else if (view._dirtyDescendantsCount) { |
| views.pushAll(view.subviews); |
| view._dirtyDescendantsCount = 0; |
| } |
| } |
| } |
| }; |
| |
| WI.View.LayoutReason = { |
| Dirty: Symbol("layout-reason-dirty"), |
| Resize: Symbol("layout-reason-resize") |
| }; |
| |
| WI.View._rootView = null; |
| WI.View._scheduledLayoutUpdateIdentifier = undefined; |