Make it easy to construct CSS and HTML from JavaScript plugins

Scoping CSS rules across plugins can be slightly tricky, as the
namespace is global for the entire browser window.  Allow plugins to
create unique names using Gerrit.css() and then use those inside of
HTML with class="{style.foo}" style replacements in the Gerrit.html()
function.

This style of development makes native HTML and CSS more natural
to use inside of a plugin's JavaScript, and reduces the risks of
conflicting CSS rules with core Gerrit or another plugin.

Event handler registration is also supported, making it easier to
attach functions to handle onclick for buttons and anchors.  Handler
registration avoids circular references between the function's
environment and the DOM node, ensuring garbage collection works.

Change-Id: Ic29a4ec0c15eedef4f4ce72031193f1896742dc5
This commit is contained in:
Shawn Pearce 2013-12-07 22:41:36 -08:00
parent df3f94cf36
commit 210b5395aa
3 changed files with 289 additions and 0 deletions

View File

@ -549,6 +549,28 @@ The `Gerrit` object is the only symbol provided into the global
namespace by Gerrit Code Review. All top-level functions can be
accessed through this name.
[[Gerrit_css]]
Gerrit.css()
~~~~~~~~~~~~
Creates a new unique CSS class and injects it into the document.
The name of the class is returned and can be used by the plugin.
See link:#Gerrit_html[`Gerrit.html()`] for an easy way to use
generated class names.
Classes created with this function should be created once at install
time and reused throughout the plugin. Repeatedly creating the same
class will explode the global stylesheet.
.Signature
[source,javascript]
----
Gerrit.install(function(self)) {
var style = {
name: Gerrit.css('background: #fff; color: #000;'),
};
});
----
[[Gerrit_delete]]
=== Gerrit.delete()
Issues a DELETE REST API request to the Gerrit server. For plugin
@ -626,6 +648,114 @@ If the URL passed matches `http://...`, `https://...`, or `//...`
the current browser window will navigate to the non-Gerrit URL.
The user can return to Gerrit with the back button.
[[Gerrit_html]]
Gerrit.html()
~~~~~~~~~~~~~
Parses an HTML fragment after performing template replacements. If
the HTML has a single root element or node that node is returned,
otherwise it is wrapped inside a `<div>` and the div is returned.
.Signature
[source,javascript]
----
Gerrit.html(htmlText, options, wantElements);
----
* htmlText: string of HTML to be parsed. A new unattached `<div>` is
created in the browser's document and the innerHTML property is
assigned to the passed string, after performing replacements. If
the div has exactly one child, that child will be returned instead
of the div.
* options: optional object reference supplying replacements for any
`{name}` references in htmlText. Navigation through objects is
supported permitting `{style.bar}` to be replaced with `"foo"` if
options was `{style: {bar: "foo"}}`. Value replacements are HTML
escaped before being inserted into the document fragment.
* wantElements: if options is given and wantElements is also true
an object consisting of `{root: parsedElement, elements: {...}}` is
returned instead of the parsed element. The elements object contains
a property for each element using `id={name}` in htmlText.
.Example
[source,javascript]
----
var style = {bar: Gerrit.css('background: yellow')};
Gerrit.html(
'<span class="{style.bar}">Hello {name}!</span>',
{style: style, name: "World"});
----
Event handlers can be automatically attached to elements referenced
through an attribute id. Object navigation is not supported for ids,
and the parser strips the id attribute before returning the result.
Handler functions must begin with `on` and be a function to be
installed on the element. This approach is useful for onclick and
other handlers that do not want to create circular references that
will eventually leak browser memory.
.Example
[source,javascript]
----
var options = {
link: {
onclick: function(e) { window.close() },
},
};
Gerrit.html('<a href="javascript:;" id="{link}">Close</a>', options);
----
When using options to install handlers care must be taken to not
accidentally include the returned element into the event handler's
closure. This is why options is built before calling `Gerrit.html()`
and not inline as a shown above with "Hello World".
DOM nodes can optionally be returned, allowing handlers to access the
elements identified by `id={name}` at a later point in time.
.Example
[source,javascript]
----
var w = Gerrit.html(
'<div>Name: <input type="text" id="{name}"></div>'
+ '<div>Age: <input type="text" id="{age}"></div>'
+ '<button id="{submit}"><div>Save</div></button>',
{
submit: {
onclick: function(s) {
var e = w.elements;
window.alert(e.name.value + " is " + e.age.value);
},
},
}, true);
----
To prevent memory leaks `w.root` and `w.elements` should be set to
null when the elements are no longer necessary. Screens can use
link:#screen_onUnload[screen.onUnload()] to define a callback function
to perform this cleanup:
[source,javascript]
----
var w = Gerrit.html(...);
screen.body.appendElement(w.root);
screen.onUnload(function() { w.clear() });
----
[[Gerrit_injectCss]]
Gerrit.injectCss()
~~~~~~~~~~~~~~~~~~
Injects CSS rules into the document by appending onto the end of the
existing rule list. CSS rules are global to the entire application
and must be manually scoped by each plugin. For an automatic scoping
alternative see link:#Gerrit_css[`css()`].
[source,javascript]
----
Gerrit.injectCss('.myplugin_bg {background: #000}');
----
[[Gerrit_install]]
=== Gerrit.install()
Registers a new plugin by invoking the supplied initialization

View File

@ -27,6 +27,7 @@ public class ApiGlue {
public static void init() {
init0();
ActionContext.init();
HtmlTemplate.init();
Plugin.init();
addHistoryHook();
}
@ -44,6 +45,7 @@ public class ApiGlue {
project_actions: {},
getPluginName: @com.google.gerrit.client.api.ApiGlue::getPluginName(),
injectCss: @com.google.gwt.dom.client.StyleInjector::inject(Ljava/lang/String;),
install: function (f) {
var p = this._getPluginByUrl(@com.google.gerrit.client.api.PluginName::getCallerUrl()());
@com.google.gerrit.client.api.ApiGlue::install(

View File

@ -0,0 +1,157 @@
// Copyright (C) 2013 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.client.api;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.StyleInjector;
import com.google.gwt.user.client.DOM;
import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
final class HtmlTemplate {
static native void init() /*-{
var ElementSet = function(r,e) {
this.root = r;
this.elements = e;
};
ElementSet.prototype = {
clear: function() {
this.root = null;
this.elements = null;
},
};
$wnd.Gerrit.css = @com.google.gerrit.client.api.HtmlTemplate::css(Ljava/lang/String;);
$wnd.Gerrit.html = function(h,r,w) {
var i = {};
if (r) {
h = h.replace(
/\sid=['"]\{([a-z_][a-z0-9_]*)\}['"]|\{([a-z0-9._-]+)\}/gi,
function(m,a,b) {
if (a)
return @com.google.gerrit.client.api.HtmlTemplate::id(
Lcom/google/gerrit/client/api/HtmlTemplate$IdMap;
Ljava/lang/String;)
(i,a);
return @com.google.gerrit.client.api.HtmlTemplate::html(
Lcom/google/gerrit/client/api/HtmlTemplate$ReplacementMap;
Ljava/lang/String;)
(r,b);
});
}
var e = @com.google.gerrit.client.api.HtmlTemplate::parseHtml(
Ljava/lang/String;Lcom/google/gerrit/client/api/HtmlTemplate$IdMap;
Lcom/google/gerrit/client/api/HtmlTemplate$ReplacementMap;
Z)
(h,i,r,!!w);
return w ? new ElementSet(e,i) : e;
};
}-*/;
private static final String css(String css) {
String name = DOM.createUniqueId();
StyleInjector.inject("." + name + "{" + css + "}");
return name;
}
private static final String id(IdMap idMap, String key) {
String id = DOM.createUniqueId();
idMap.put(id, key);
return " id='" + id + "'";
}
private static final String html(ReplacementMap opts, String id) {
int d = id.indexOf('.');
if (0 < d) {
String name = id.substring(0, d);
String rest = id.substring(d + 1);
return html(opts.map(name), rest);
}
return new SafeHtmlBuilder().append(opts.str(id)).asString();
}
private static final Node parseHtml(
String html,
IdMap ids,
ReplacementMap opts,
boolean wantElements) {
Element div = Document.get().createDivElement();
div.setInnerHTML(html);
if (!ids.isEmpty()) {
attachHandlers(div, ids, opts, wantElements);
}
if (div.getChildCount() == 1) {
return div.getFirstChild();
}
return div;
}
private static void attachHandlers(
Element e,
IdMap ids,
ReplacementMap opts,
boolean wantElements) {
if (e.getId() != null) {
String key = ids.get(e.getId());
if (key != null) {
ids.remove(e.getId());
if (wantElements) {
ids.put(key, e);
}
e.setId(null);
opts.map(key).attachHandlers(e);
}
}
for (Element c = e.getFirstChildElement(); c != null;) {
attachHandlers(c, ids, opts, wantElements);
c = c.getNextSiblingElement();
}
}
private static class ReplacementMap extends JavaScriptObject {
final native ReplacementMap map(String n) /*-{ return this[n] }-*/;
final native String str(String n) /*-{ return ''+this[n] }-*/;
final native void attachHandlers(Element e) /*-{
for (var k in this) {
var f = this[k];
if (k.substring(0, 2) == 'on' && typeof f == 'function')
e[k] = f;
}
}-*/;
protected ReplacementMap() {
}
}
private static class IdMap extends JavaScriptObject {
final native String get(String i) /*-{ return this[i] }-*/;
final native void remove(String i) /*-{ delete this[i] }-*/;
final native void put(String i, String k) /*-{ this[i] = k }-*/;
final native void put(String k, Element e) /*-{ this[k] = e }-*/;
final native boolean isEmpty() /*-{
for (var i in this)
return false;
return true;
}-*/;
protected IdMap() {
}
}
private HtmlTemplate() {
}
}