blob: 297625e5a5e76930d97c5508e05c3f3da003fa8b [file] [log] [blame]
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
library polymer.polymer_element;
import 'dart:async';
import 'dart:html';
import 'dart:mirrors';
import 'dart:js' as dartJs;
import 'package:custom_element/custom_element.dart';
import 'package:js/js.dart' as js;
import 'package:mdv/mdv.dart' show NodeBinding;
import 'package:observe/observe.dart';
import 'package:observe/src/microtask.dart';
import 'package:polymer_expressions/polymer_expressions.dart';
import 'src/utils_observe.dart' show toCamelCase, toHyphenedName;
/**
* Registers a [PolymerElement]. This is similar to [registerCustomElement]
* but it is designed to work with the `<element>` element and adds additional
* features.
*/
void registerPolymerElement(String localName, PolymerElement create()) {
registerCustomElement(localName, () => create().._initialize(localName));
}
/**
* *Warning*: many features of this class are not fully implemented.
*
* The base class for Polymer elements. It provides convience features on top
* of the custom elements web standard.
*
* Currently it supports publishing attributes via:
*
* <element name="..." attributes="foo, bar, baz">
*
* Any attribute published this way can be used in a data binding expression,
* and it should contain a corresponding DOM field.
*
* *Warning*: due to dart2js mirror limititations, the mapping from HTML
* attribute to element property is a conversion from `dash-separated-words`
* to camelCase, rather than searching for a property with the same name.
*/
// TODO(jmesserly): fix the dash-separated-words issue. Polymer uses lowercase.
class PolymerElement extends CustomElement with _EventsMixin {
// This is a partial port of:
// https://github.com/Polymer/polymer/blob/stable/src/attrs.js
// https://github.com/Polymer/polymer/blob/stable/src/bindProperties.js
// https://github.com/Polymer/polymer/blob/7936ff8/src/declaration/events.js
// https://github.com/Polymer/polymer/blob/7936ff8/src/instance/events.js
// TODO(jmesserly): we still need to port more of the functionality
/// The one syntax to rule them all.
static BindingDelegate _polymerSyntax = new PolymerExpressions();
// TODO(sigmund): delete. The next line is only added to avoid warnings from
// the analyzer (see http://dartbug.com/11672)
Element get host => super.host;
bool get applyAuthorStyles => false;
bool get resetStyleInheritance => false;
/**
* The declaration of this polymer-element, used to extract template contents
* and other information.
*/
static Map<String, Element> _declarations = {};
static Element getDeclaration(String localName) {
if (localName == null) return null;
var element = _declarations[localName];
if (element == null) {
element = document.query('polymer-element[name="$localName"]');
_declarations[localName] = element;
}
return element;
}
Map<String, PathObserver> _publishedAttrs;
Map<String, StreamSubscription> _bindings;
final List<String> _localNames = [];
void _initialize(String localName) {
if (localName == null) return;
var declaration = getDeclaration(localName);
if (declaration == null) return;
if (declaration.attributes['extends'] != null) {
var base = declaration.attributes['extends'];
// Skip normal tags, only initialize parent custom elements.
if (base.contains('-')) _initialize(base);
}
_parseHostEvents(declaration);
_parseLocalEvents(declaration);
_publishAttributes(declaration);
_localNames.add(localName);
}
void _publishAttributes(elementElement) {
_bindings = {};
_publishedAttrs = {};
var attrs = elementElement.attributes['attributes'];
if (attrs != null) {
// attributes='a b c' or attributes='a,b,c'
for (var name in attrs.split(attrs.contains(',') ? ',' : ' ')) {
name = name.trim();
// TODO(jmesserly): PathObserver is overkill here; it helps avoid
// "new Symbol" and other mirrors-related warnings.
_publishedAttrs[name] = new PathObserver(this, toCamelCase(name));
}
}
}
void created() {
// TODO(jmesserly): this breaks until we get some kind of type conversion.
// _publishedAttrs.forEach((name, propObserver) {
// var value = attributes[name];
// if (value != null) propObserver.value = value;
// });
_initShadowRoot();
_addHostListeners();
}
/**
* Creates the document fragment to use for each instance of the custom
* element, given the `<template>` node. By default this is equivalent to:
*
* template.createInstance(this, polymerSyntax);
*
* Where polymerSyntax is a singleton `PolymerExpressions` instance from the
* [polymer_expressions](https://pub.dartlang.org/packages/polymer_expressions)
* package.
*
* You can override this method to change the instantiation behavior of the
* template, for example to use a different data-binding syntax.
*/
DocumentFragment instanceTemplate(Element template) =>
template.createInstance(this, _polymerSyntax);
void _initShadowRoot() {
for (var localName in _localNames) {
var declaration = getDeclaration(localName);
var root = createShadowRoot(localName);
_addInstanceListeners(root, localName);
root.applyAuthorStyles = applyAuthorStyles;
root.resetStyleInheritance = resetStyleInheritance;
var templateNode = declaration.children.firstWhere(
(n) => n.localName == 'template', orElse: () => null);
if (templateNode == null) return;
// Create the contents of the element's ShadowRoot, and add them.
root.nodes.add(instanceTemplate(templateNode));
var extendsName = declaration.attributes['extends'];
_shimCss(root, localName, extendsName);
}
}
NodeBinding createBinding(String name, model, String path) {
var propObserver = _publishedAttrs[name];
if (propObserver != null) {
return new _PolymerBinding(this, name, model, path, propObserver);
}
return super.createBinding(name, model, path);
}
/**
* Using Polymer's platform/src/ShadowCSS.js passing the style tag's content.
*/
void _shimCss(ShadowRoot root, String localName, String extendsName) {
// TODO(terry): Need to detect if ShadowCSS.js has been loaded. Under
// Dartium this wouldn't exist. However, dart:js isn't robust
// to use to detect in both Dartium and dart2js if Platform is
// defined. Instead in Dartium it throws an exception but in
// dart2js it works enough to know if Platform is defined (just
// can't be used for further derefs). This bug is described
// https://code.google.com/p/dart/issues/detail?id=12548
// When fixed only use dart:js. This is necessary under
// Dartium (no compile) we want to run w/o the JS polyfill.
try {
if (dartJs.context["Platform"] == null) { return; }
} on NoSuchMethodError catch (e) { return; }
var platform = js.context["Platform"];
if (platform == null) return;
var shadowCss = platform.ShadowCSS;
if (shadowCss == null) return;
// TODO(terry): Remove calls to shimShadowDOMStyling2 and replace with
// shimShadowDOMStyling when we support unwrapping dart:html
// Element to a JS DOM node.
var shimShadowDOMStyling2 = shadowCss.shimShadowDOMStyling2;
if (shimShadowDOMStyling2 == null) return;
var style = root.query('style');
if (style == null) return;
var scopedCSS = shimShadowDOMStyling2(style.text, localName);
// TODO(terry): Remove when shimShadowDOMStyling is called we don't need to
// replace original CSS with scoped CSS shimShadowDOMStyling
// does that.
style.text = scopedCSS;
}
}
class _PolymerBinding extends NodeBinding {
final PathObserver _publishedAttr;
_PolymerBinding(node, property, model, path, PathObserver this._publishedAttr)
: super(node, property, model, path);
void boundValueChanged(newValue) {
_publishedAttr.value = newValue;
}
}
/**
* Polymer features to handle the syntactic sugar on-* to declare to
* automatically map event handlers to instance methods of the [PolymerElement].
* This mixin is a port of:
* https://github.com/Polymer/polymer/blob/7936ff8/src/declaration/events.js
* https://github.com/Polymer/polymer/blob/7936ff8/src/instance/events.js
*/
abstract class _EventsMixin {
// TODO(sigmund): implement the Dart equivalent of 'inheritDelegates'
// Notes about differences in the implementation below:
// - _templateDelegates: polymer stores the template delegates directly on
// the template node (see in parseLocalEvents: 't.delegates = {}'). Here we
// simply use a separate map, where keys are the name of the
// custom-element.
// - _listenLocal we return true/false and propagate that up, JS
// implementation does't forward the return value.
// - we don't keep the side-table (weak hash map) of unhandled events (see
// handleIfNotHandled)
// - we don't use event.type to dispatch events, instead we save the event
// name with the event listeners. We do so to avoid translating back and
// forth between Dom and Dart event names.
// ---------------------------------------------------------------------------
// The following section was ported from:
// https://github.com/Polymer/polymer/blob/7936ff8/src/declaration/events.js
// ---------------------------------------------------------------------------
/** Maps event names and their associated method in the element class. */
final Map<String, String> _delegates = {};
/** Expected events per element node. */
// TODO(sigmund): investigate whether we need more than 1 set of local events
// per element (why does the js implementation stores 1 per template node?)
final Map<String, Set<String>> _templateDelegates =
new Map<String, Set<String>>();
/** [host] is needed by this mixin, but not defined here. */
Element get host;
/** Attribute prefix used for declarative event handlers. */
static const _eventPrefix = 'on-';
/** Whether an attribute declares an event. */
static bool _isEvent(String attr) => attr.startsWith(_eventPrefix);
/** Extracts events from the element tag attributes. */
void _parseHostEvents(elementElement) {
for (var attr in elementElement.attributes.keys.where(_isEvent)) {
_delegates[toCamelCase(attr)] = elementElement.attributes[attr];
}
}
/** Extracts events under the element's <template>. */
void _parseLocalEvents(elementElement) {
var name = elementElement.attributes["name"];
if (name == null) return;
var events = null;
for (var template in elementElement.queryAll('template')) {
var content = template.content;
if (content != null) {
for (var child in content.children) {
events = _accumulateEvents(child, events);
}
}
}
if (events != null) {
_templateDelegates[name] = events;
}
}
/** Returns all events names listened by [element] and it's children. */
static Set<String> _accumulateEvents(Element element, [Set<String> events]) {
events = events == null ? new Set<String>() : events;
// from: accumulateAttributeEvents, accumulateEvent
events.addAll(element.attributes.keys.where(_isEvent).map(toCamelCase));
// from: accumulateChildEvents
for (var child in element.children) {
_accumulateEvents(child, events);
}
// from: accumulateTemplatedEvents
if (element.isTemplate) {
var content = element.content;
if (content != null) {
for (var child in content.children) {
_accumulateEvents(child, events);
}
}
}
return events;
}
// ---------------------------------------------------------------------------
// The following section was ported from:
// https://github.com/Polymer/polymer/blob/7936ff8/src/instance/events.js
// ---------------------------------------------------------------------------
/** Attaches event listeners on the [host] element. */
void _addHostListeners() {
for (var eventName in _delegates.keys) {
_addNodeListener(host, eventName,
(e) => _hostEventListener(eventName, e));
}
}
void _addNodeListener(node, String onEvent, Function listener) {
// If [node] is an element (typically when listening for host events) we
// use directly the '.onFoo' event stream of the element instance.
if (node is Element) {
reflect(node).getField(new Symbol(onEvent)).reflectee.listen(listener);
return;
}
// When [node] is not an element, most commonly when [node] is the
// shadow-root of the polymer-element, we find the appropriate static event
// stream providers and attach it to [node].
var eventProvider = _eventStreamProviders[onEvent];
if (eventProvider != null) {
eventProvider.forTarget(node).listen(listener);
return;
}
// When no provider is available, mainly because of custom-events, we use
// the underlying event listeners from the DOM.
var eventName = onEvent.substring(2).toLowerCase(); // onOneTwo => onetwo
// Most events names in Dart match those in JS in lowercase except for some
// few events listed in this map. We expect these cases to be handled above,
// but just in case we include them as a safety net here.
var jsNameFixes = const {
'animationend': 'webkitAnimationEnd',
'animationiteration': 'webkitAnimationIteration',
'animationstart': 'webkitAnimationStart',
'doubleclick': 'dblclick',
'fullscreenchange': 'webkitfullscreenchange',
'fullscreenerror': 'webkitfullscreenerror',
'keyadded': 'webkitkeyadded',
'keyerror': 'webkitkeyerror',
'keymessage': 'webkitkeymessage',
'needkey': 'webkitneedkey',
'speechchange': 'webkitSpeechChange',
};
var fixedName = jsNameFixes[eventName];
node.on[fixedName != null ? fixedName : eventName].listen(listener);
}
void _addInstanceListeners(ShadowRoot root, String elementName) {
var events = _templateDelegates[elementName];
if (events == null) return;
for (var eventName in events) {
_addNodeListener(root, eventName,
(e) => _instanceEventListener(eventName, e));
}
}
void _hostEventListener(String eventName, Event event) {
var method = _delegates[eventName];
if (event.bubbles && method != null) {
_dispatchMethod(this, method, event, host);
}
}
void _dispatchMethod(Object receiver, String methodName, Event event,
Node target) {
var detail = event is CustomEvent ? (event as CustomEvent).detail : null;
var args = [event, detail, target];
var method = new Symbol(methodName);
// TODO(sigmund): consider making event listeners list all arguments
// explicitly. Unless VM mirrors are optimized first, this reflectClass call
// will be expensive once custom elements extend directly from Element (see
// dartbug.com/11108).
var methodDecl = reflectClass(receiver.runtimeType).methods[method];
if (methodDecl != null) {
// This will either truncate the argument list or extend it with extra
// null arguments, so it will match the signature.
// TODO(sigmund): consider accepting optional arguments when we can tell
// them appart from named arguments (see http://dartbug.com/11334)
args.length = methodDecl.parameters.where((p) => !p.isOptional).length;
}
reflect(receiver).invoke(method, args);
performMicrotaskCheckpoint();
}
bool _instanceEventListener(String eventName, Event event) {
if (event.bubbles) {
if (event.path == null || !ShadowRoot.supported) {
return _listenLocalNoEventPath(eventName, event);
} else {
return _listenLocal(eventName, event);
}
}
return false;
}
bool _listenLocal(String eventName, Event event) {
var controller = null;
for (var target in event.path) {
// if we hit host, stop
if (target == host) return true;
// find a controller for the target, unless we already found `host`
// as a controller
controller = (controller == host) ? controller : _findController(target);
// if we have a controller, dispatch the event, and stop if the handler
// returns true
if (controller != null
&& handleEvent(controller, eventName, event, target)) {
return true;
}
}
return false;
}
// TODO(sorvell): remove when ShadowDOM polyfill supports event path.
// Note that _findController will not return the expected controller when the
// event target is a distributed node. This is because we cannot traverse
// from a composed node to a node in shadowRoot.
// This will be addressed via an event path api
// https://www.w3.org/Bugs/Public/show_bug.cgi?id=21066
bool _listenLocalNoEventPath(String eventName, Event event) {
var target = event.target;
var controller = null;
while (target != null && target != host) {
controller = (controller == host) ? controller : _findController(target);
if (controller != null
&& handleEvent(controller, eventName, event, target)) {
return true;
}
target = target.parent;
}
return false;
}
// TODO(sigmund): investigate if this implementation is correct. Polymer looks
// up the shadow-root that contains [node] and uses a weak-hashmap to find the
// host associated with that root. This implementation assumes that the
// [node] is under [host]'s shadow-root.
Element _findController(Node node) => host.xtag;
bool handleEvent(
Element controller, String eventName, Event event, Element element) {
// Note: local events are listened only in the shadow root. This dynamic
// lookup is used to distinguish determine whether the target actually has a
// listener, and if so, to determine lazily what's the target method.
var methodName = element.attributes[toHyphenedName(eventName)];
if (methodName != null) {
_dispatchMethod(controller, methodName, event, element);
}
return event.bubbles;
}
}
/** Event stream providers per event name. */
// TODO(sigmund): after dartbug.com/11108 is fixed, consider eliminating this
// table and using reflection instead.
const Map<String, EventStreamProvider> _eventStreamProviders = const {
'onMouseWheel': Element.mouseWheelEvent,
'onTransitionEnd': Element.transitionEndEvent,
'onAbort': Element.abortEvent,
'onBeforeCopy': Element.beforeCopyEvent,
'onBeforeCut': Element.beforeCutEvent,
'onBeforePaste': Element.beforePasteEvent,
'onBlur': Element.blurEvent,
'onChange': Element.changeEvent,
'onClick': Element.clickEvent,
'onContextMenu': Element.contextMenuEvent,
'onCopy': Element.copyEvent,
'onCut': Element.cutEvent,
'onDoubleClick': Element.doubleClickEvent,
'onDrag': Element.dragEvent,
'onDragEnd': Element.dragEndEvent,
'onDragEnter': Element.dragEnterEvent,
'onDragLeave': Element.dragLeaveEvent,
'onDragOver': Element.dragOverEvent,
'onDragStart': Element.dragStartEvent,
'onDrop': Element.dropEvent,
'onError': Element.errorEvent,
'onFocus': Element.focusEvent,
'onInput': Element.inputEvent,
'onInvalid': Element.invalidEvent,
'onKeyDown': Element.keyDownEvent,
'onKeyPress': Element.keyPressEvent,
'onKeyUp': Element.keyUpEvent,
'onLoad': Element.loadEvent,
'onMouseDown': Element.mouseDownEvent,
'onMouseMove': Element.mouseMoveEvent,
'onMouseOut': Element.mouseOutEvent,
'onMouseOver': Element.mouseOverEvent,
'onMouseUp': Element.mouseUpEvent,
'onPaste': Element.pasteEvent,
'onReset': Element.resetEvent,
'onScroll': Element.scrollEvent,
'onSearch': Element.searchEvent,
'onSelect': Element.selectEvent,
'onSelectStart': Element.selectStartEvent,
'onSubmit': Element.submitEvent,
'onTouchCancel': Element.touchCancelEvent,
'onTouchEnd': Element.touchEndEvent,
'onTouchEnter': Element.touchEnterEvent,
'onTouchLeave': Element.touchLeaveEvent,
'onTouchMove': Element.touchMoveEvent,
'onTouchStart': Element.touchStartEvent,
'onFullscreenChange': Element.fullscreenChangeEvent,
'onFullscreenError': Element.fullscreenErrorEvent,
'onAutocomplete': FormElement.autocompleteEvent,
'onAutocompleteError': FormElement.autocompleteErrorEvent,
'onSpeechChange': InputElement.speechChangeEvent,
'onCanPlay': MediaElement.canPlayEvent,
'onCanPlayThrough': MediaElement.canPlayThroughEvent,
'onDurationChange': MediaElement.durationChangeEvent,
'onEmptied': MediaElement.emptiedEvent,
'onEnded': MediaElement.endedEvent,
'onLoadStart': MediaElement.loadStartEvent,
'onLoadedData': MediaElement.loadedDataEvent,
'onLoadedMetadata': MediaElement.loadedMetadataEvent,
'onPause': MediaElement.pauseEvent,
'onPlay': MediaElement.playEvent,
'onPlaying': MediaElement.playingEvent,
'onProgress': MediaElement.progressEvent,
'onRateChange': MediaElement.rateChangeEvent,
'onSeeked': MediaElement.seekedEvent,
'onSeeking': MediaElement.seekingEvent,
'onShow': MediaElement.showEvent,
'onStalled': MediaElement.stalledEvent,
'onSuspend': MediaElement.suspendEvent,
'onTimeUpdate': MediaElement.timeUpdateEvent,
'onVolumeChange': MediaElement.volumeChangeEvent,
'onWaiting': MediaElement.waitingEvent,
'onKeyAdded': MediaElement.keyAddedEvent,
'onKeyError': MediaElement.keyErrorEvent,
'onKeyMessage': MediaElement.keyMessageEvent,
'onNeedKey': MediaElement.needKeyEvent,
'onWebGlContextLost': CanvasElement.webGlContextLostEvent,
'onWebGlContextRestored': CanvasElement.webGlContextRestoredEvent,
'onPointerLockChange': Document.pointerLockChangeEvent,
'onPointerLockError': Document.pointerLockErrorEvent,
'onReadyStateChange': Document.readyStateChangeEvent,
'onSelectionChange': Document.selectionChangeEvent,
'onSecurityPolicyViolation': Document.securityPolicyViolationEvent,
};