tapspace
Version:
A zoomable user interface lib for web apps
528 lines (453 loc) • 16.7 kB
JavaScript
//
// A View into Space, implemented in HTML DOM and CSS
// This module gives a starting point for implementing
// views in other tech such as Canvas or WebGL
//
// Notes:
//
// [1]
// View has handler for 'added' event.
// View's target space has handler for 'childAdded' event.
// When a view is parented on a space, the two handlers cause
// duplicate rendering of the view's AbstractNode.
// To go in details, calling _renderElementFor for the space
// attaches 'childAdded' handler to the space. The handler
// will be fired after execution of the view's 'added'
// handler because the view has been just added as a child
// of the space. To prevent a double call of _renderElementFor
// for the view, the 'childAdded' handler of the space must
// check if the added child is a view or not. If it is the view,
// then there is no need to render it because the view is
// already rendered by the view's 'added' handler.
//
// [2]
// Compute the size of the view directly from DOM
// because detecting viewport resize is difficult.
// https://github.com/taataa/tapspace/issues/55
//
var createElementFor = require('./createElementFor')
var setElementTransform = require('./setElementTransform')
var AbstractRectangle = require('../AbstractRectangle')
var Space = require('../Space')
var extend = require('extend')
var DOM_SPACENODE_PROPERTY = '_tapspace_node'
// Constructor
var SpaceView = function (space) {
// Parameters
// space
// a Space, optional, default to null. Null means that
// the view has not given a space.
//
if (typeof space === 'undefined') space = null
AbstractRectangle.call(this)
// This is the DOM container. Populated in mount().
this._el = null
// Mapping from AbstractNode#id to HTMLElement
// Every HTMLElement created by the view is stored here.
this._elements = {}
// Mapping from AbstractNode#id to a map from an event name to a handler fn.
// Event handlers of every rendered AbstractNode are stored here.
this._handlers = {}
// Mapping from an event name to a handler fn.
// Handlers for the view's events like 'added'. Populated in mount().
this._viewHandlers = {}
// View is now ready to be added onto the space.
// Note that this implicitly sets
// this._parent = space
// Therefore, to access the space, call this.getParent() or this._parent
if (typeof space === 'object' && space !== null) {
// Test if valid space
if (!(space instanceof Space)) {
throw new Error('Parent of a View must be a Space.')
}
this.setParent(space)
}
}
// Prototype mixin
var p = extend({}, AbstractRectangle.prototype)
SpaceView.prototype = p
// Public methods
p.fitScale = function (ipath) {
// Overwrite AbstractRectangle#fitScale() to throw an error
// when the view is not yet mounted and thus has no proper
// dimensions with to fit.
//
if (!this.isMounted()) {
throw new Error(
'View is not yet mounted and thus has no proper size ' +
'for fitting. Call mount() before fitScale().'
)
}
return AbstractRectangle.prototype.fitScale.call(this, ipath)
}
p.fitSize = function () {
// Override AbstractRectangle#fitSize
throw new Error('Use refreshSize to resize the view and fitScale to fit.')
}
p.getElementBySpaceItem = function (abstractNode) {
// Get HTML element representation of the space node.
// Return null if not found.
// Get DOM element
var n = this._elements[abstractNode.id]
// n will be undefined if not found in _elements.
if (n) {
return n
}
return null
}
p.getContainer = function () {
// Return the DOM root element of the space view.
// Return null if not yet mounted.
return this._el
}
p.getSpaceItemByElementId = function (id) {
// Get AbstractNode by HTML element id
// Return null if no node for such id.
var el = document.getElementById(id)
if (el && Object.prototype.hasOwnProperty.call(el, DOM_SPACENODE_PROPERTY)) {
return el[DOM_SPACENODE_PROPERTY]
}
return null
}
p.isMounted = function () {
return this._el !== null
}
p.mount = function (htmlContainer) {
// Begin to use htmlContainer as the DOM root for the space.
//
// Design decision:
// In v3 the HTML container was provided via the constructor.
// This led to a poor coding style where 'new SpaceView'
// was called just for its rendering side-effect. In v4
// we wanted the side-effect to be more explicit and made
// it a separate method 'mount'.
//
// Test if valid dom element
if (!(htmlContainer && 'tagName' in htmlContainer)) {
throw new Error('Container should be a DOM Element')
}
// If same container, no reason to do anything
if (this._el === htmlContainer) {
return
}
// Clear the old container
this.unmount()
this._el = htmlContainer
// Init style.
// Note:
// "position: relative" is needed to enable "overflow: hidden".
// Without "overflow: hidden", if transformed elements get outside
// the initial viewport, Chrome on Android increases
// the dimensions of the document.
this._el.style.position = 'relative'
this._el.style.overflow = 'hidden'
this._el.style.display = 'block'
// Init size in space.
this._setSize(this._el.clientWidth, this._el.clientHeight)
// Render the space.
// Note that this renders also an element for the SpaceView,
// although the element will be 0x0 and meant for the children of the view.
// It is possible that the view is mounted before parented.
if (this._parent) {
this._renderElementFor(this._parent)
}
// A HTMLElement was rendered for the space itself.
// This element is a bit special, because it should not react
// to transformations of the Space (how could it, Space does not transform)
// but to transformations of the SpaceView.
var view = this
this._viewHandlers = {
added: function () {
// If the view becomes attached to a new parent,
// render the content of the new parent.
// See [1] for interaction with the 'childAdded' handler of the parent.
view._renderElementFor(view._parent)
},
transformed: function () {
// View becomes transformed.
// => inverse transform HTMLElement of the Space
// => Browser retransforms HTMLElements of the descendants.
// =>
var el, vel, tr, vtr
el = view._elements[view._parent.id]
vel = view._elements[view.id]
tr = view._T.inverse()
vtr = view._T
setElementTransform(el, tr)
// This undoes the transformation for the view's children
setElementTransform(vel, vtr)
}
}
this.on('added', this._viewHandlers.added)
this.on('transformed', this._viewHandlers.transformed)
}
var setParentSuperProto = p.setParent
p.setParent = function (newParent) {
// Override the AbstractNode#setParent so that only a Space
// is allowed to become the parent of a SpaceView.
if (!(newParent instanceof Space)) {
throw new Error('A View can only be a child of a Space')
}
// Remove elements of the oldParent if the parent is about to change.
// It is easier to remove the elements before _parent changes. That
// would be the case if removal happens as a reaction to 'removed' event.
if (this._parent === newParent) {
return
}
if (this._parent !== null) {
this._removeElementOf(this._parent)
}
setParentSuperProto.call(this, newParent)
}
p.unmount = function () {
// Detach the view from HTML DOM
if (!this.isMounted()) {
// No need to remove anything.
// Unnecessary unmount call can be a programming error
// but sometimes we just want to be sure the view is unmounted.
return
}
// No need to react to events in the space.
this.off('added', this._viewHandlers.added)
this.off('transformed', this._viewHandlers.transformed)
this._viewHandlers = {}
// Detach all rendered elements.
// Note that unmount can be called before first setParent.
if (this._parent) {
this._removeElementOf(this._parent)
}
// Forget now empty container,
// so that possible next mount goes smoothly.
this._el = null
}
p.refreshSize = function () {
// Recompute size from the container element.
// See [2]
if (this.isMounted()) {
this._setSize(this._el.clientWidth, this._el.clientHeight)
} else {
throw new Error('Unmounted view cannot be resized.')
}
}
p.setSize = function () {
// Override AbstractRectangle#setSize
throw new Error('Use refreshSize to resize the view.')
}
p.setISize = function () {
// Override AbstractRectangle#setISize
throw new Error('Use refreshSize to resize the view.')
}
// Private(ish) methods
p._getViewSpecificId = function (abstractNodeId) {
// Each rendered element has own ID. The ID differs from
// the id of space nodes because a space node can become
// visualized through multiple views.
return this.id + '-' + abstractNodeId
}
p._removeElementOf = function (abstractNode) {
// Removes the HTMLElement of abstractNode.
// Does not remove the abstractNode from the view,
// only from DOM.
//
// Note that the give abstractNode is probably already detached from
// the Space. Therefore abstractNode.isRoot() probably returns true and
// cannot be used to determine if the abstractNode is space.
var el, on
var view = this
var n = abstractNode // alias
if (!this.isMounted()) {
throw new Error('Cannot remove element when view is not mounted.')
}
// Recursively remove the elements of the child nodes.
// Does not remove the child nodes.
n.getChildren().forEach(function (child) {
view._removeElementOf(child)
})
// Stop event handlers.
on = this._handlers[n.id]
if (n === this || n === this._parent) {
// When AbstractNode is the Space or SpaceView itself,
// only a handler for childAdded has been created.
n.off('childAdded', on.childAdded)
} else {
n.off('childAdded', on.childAdded)
n.off('removed', on.removed)
n.off('resized', on.resized)
n.off('transformed', on.transformed)
}
// Remove connections to functions and elements.
el = this._elements[n.id]
delete el[DOM_SPACENODE_PROPERTY]
delete this._elements[n.id]
delete this._handlers[n.id]
// Remove from DOM
el.parentElement.removeChild(el)
}
p._renderElementFor = function (abstractNode) {
// Creates the element for abstractNode and renders it
// to the view. Renders also the children of the node.
var el, parentEl, on
var view = this
var n = abstractNode
// Ensure that the view if mounted to DOM. Otherwise
// asking for the render is a bug.
if (!this.isMounted()) {
throw new Error('Do not render elements before mounting the view')
}
// Prevent bugs that double-render elements
if (Object.prototype.hasOwnProperty.call(this._elements, n.id)) {
throw new Error('An element should not be added twice to the same view.')
}
// Create HTMLElement for the node.
// If the node is Space, SpaceGroup, or SpaceView,
// a special 0x0 div is created.
// Provide SpaceView constructor to avoid a circular dependency.
el = createElementFor(n, SpaceView)
// Each must have unique ID so we can reference to them.
el.id = this._getViewSpecificId(n.id)
// Allow reference from the element to the abstractNode and vice versa.
// It becomes important to carefully undo the reference
// to prevent memory leaks.
el[DOM_SPACENODE_PROPERTY] = n
this._elements[n.id] = el
if (n === this._parent) {
// So n, i.e. the AbstractNode, is Space.
// Space has a special handling for its HTMLElement.
// The element is transformed only when the view transforms.
// This is the way how the transformation of the view affects to
// the CSS3 transforms of HTMLElements of the children
// of the space. The browser takes a product of the CSS3 transforms
// of HTMLElement and its parents.
// Add the element to the view's container.
this._el.appendChild(el)
// Define how view should react to changes in Space.
// Note that Space cannot emit 'removed', 'resized', or 'transformed'
on = {
childAdded: function (ev) {
// Child added to root, therefore render it to view.
// However, if the child is the view itself, do not render
// because the view has been already rendered in the view's
// handler for the 'added' event. See note [1] for details.
if (ev.newChild !== view) {
// If parent does not change, we only need to rearrange the elements.
if (ev.oldParent === ev.source) {
view._reorderElementOf(ev.newChild)
} else {
view._renderElementFor(ev.newChild)
}
}
}
}
n.on('childAdded', on.childAdded)
} else {
// Is a descendant of Space
// Add the new element to the parent element.
parentEl = this._elements[n.getParent().id]
parentEl.appendChild(el)
// Define how view should react to changes in AbstractNode
// by setting up handlers.
if (n === this) {
// View itself is a descendant of Space and therefore _renderElementFor
// is called also for the view's AbstractNode.
//
// If the view's AbstractNode becomes removed, the view has
// a special handling for that, defined in unmount().
//
// If the view's AbstractNode becomes resized, the view has
// a special handler for that, defined in mount() TODO.
// The HTMLElement for the view is a 0x0 and should remain so
// and therefore we cannot use the default _resizeElementOf.
//
// If the view becomes transformed, there is no need to
// transform it's HTMLElement because the element should stay still.
// By avoiding unnecessary retransformation we also avoid possible
// rounding errors.
//
// Note that we bound has childAdded handler also for the view.
// This way we can fix elements to the view, like a health bar, or
// dropdown menu.
//
on = {
childAdded: function (ev) {
// Child added. If the parent does not change, we only need to
// rearrange the elements.
if (ev.oldParent === ev.source) {
view._reorderElementOf(ev.newChild)
} else {
view._renderElementFor(ev.newChild)
}
}
}
n.on('childAdded', on.childAdded)
} else {
// Is a descendant of the root and not the view itself.
on = {
childAdded: function (ev) {
// If the parent does not change, we only need to
// rearrange the elements.
if (ev.oldParent === ev.source) {
view._reorderElementOf(ev.newChild)
} else {
view._renderElementFor(ev.newChild)
}
},
removed: function (ev) {
if (ev.oldParent === ev.newParent) {
// Rearrange elements in childAdded handler.
} else {
view._removeElementOf(n)
}
},
resized: function () {
view._resizeElementOf(n)
},
transformed: function () {
// When the space element transforms,
// update the local CSS transform of the dom element.
setElementTransform(view._elements[n.id], n._T)
}
}
n.on('childAdded', on.childAdded)
n.on('removed', on.removed)
n.on('resized', on.resized)
n.on('transformed', on.transformed)
// Set initial size and transformation
if (n instanceof AbstractRectangle) {
on.resized()
}
on.transformed()
}
}
// Store for _removeElementOf
this._handlers[n.id] = on
// Repeat for children to render everything.
n.getChildren().forEach(function (child) {
view._renderElementFor(child)
})
}
p._reorderElementOf = function (item) {
// Item's order has probably changed in space. We manipulate
// the DOM so that the element order matches the order in space.
var el, parentEl, nextSibling, nextEl
el = this._elements[item.id]
parentEl = this._elements[item._parent.id]
nextSibling = item.getNextSibling()
if (nextSibling) {
// Next sibling exists. Move the item's element so that it becomes before.
nextEl = this._elements[nextSibling.id]
parentEl.insertBefore(el, nextEl)
} else {
// Item is the last. Move the element last.
parentEl.appendChild(el)
}
}
p._resizeElementOf = function (item) {
var el, wh
wh = item.getSize()
el = this._elements[item.id]
el.style.width = wh.width + 'px'
el.style.height = wh.height + 'px'
}
// As we override setSize, provide private access to AbstractRectangle#setSize
p._setSize = AbstractRectangle.prototype.setSize
module.exports = SpaceView