mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
first cut of annotate/materialize
This commit is contained in:
159
packages/liveui/livedocument.js
Normal file
159
packages/liveui/livedocument.js
Normal file
@@ -0,0 +1,159 @@
|
||||
Meteor.ui = Meteor.ui || {};
|
||||
Meteor.ui._doc = Meteor.ui._doc || {};
|
||||
|
||||
(function() {
|
||||
|
||||
var LIVEUI_START_PREFIX = "LIVEUI_START_";
|
||||
var LIVEUI_END_PREFIX = "LIVEUI_END_";
|
||||
var LIVEUI_MARKER_PREFIX = "LIVEUI_";
|
||||
var HTML_PARSE_REGEX = /<!--LIVEUI_(START|END)_(.*?)-->|<|>|[^<>]+/g;
|
||||
var RANGE_PARSE_REGEX = /^<!--LIVEUI_(START|END)_(.*?)-->$/;
|
||||
var MARKER_PARSE_REGEX = /^LIVEUI_(.*)$/;
|
||||
|
||||
Meteor.ui._TAG = "_liveui";
|
||||
|
||||
Meteor.ui._doc._newAnnotations = {}; // {id -> options} until range created
|
||||
Meteor.ui._doc._newRanges = []; // [LiveRange, ...] until flush time
|
||||
Meteor.ui._doc._nextId = 1;
|
||||
|
||||
// XXX to mention:
|
||||
// - turning ranges into frags separately helps deal with
|
||||
// mismatched tags
|
||||
// - the LIVEUI_START/END comments could be any strings, in theory,
|
||||
// since we pull them out -- for example, they could be fake
|
||||
// tags like <range> </range>. Html comments are invisible
|
||||
// if they go through, but not e.g. inside tag attributes.
|
||||
// - there is no "ignore annotations" mode, that has to be implemented
|
||||
// in liveui.
|
||||
|
||||
|
||||
Meteor.ui._doc.materialize = function (html) {
|
||||
var idToSubHtml = {};
|
||||
var inTag = false;
|
||||
var parts = [[]];
|
||||
var ids = [];
|
||||
_.each(html.match(HTML_PARSE_REGEX), function(tok) {
|
||||
var part = tok;
|
||||
if (tok === '<') {
|
||||
inTag = true;
|
||||
} else if (tok === '>') {
|
||||
inTag = false;
|
||||
} else if (tok.charAt(0) === '<') {
|
||||
// START or END comment
|
||||
if (inTag) {
|
||||
// can't have a "LiveRange" between tag angle brackets;
|
||||
// until we deal with this case somehow, ignore
|
||||
// the annotation
|
||||
part = "";
|
||||
} else {
|
||||
var match = tok.match(RANGE_PARSE_REGEX);
|
||||
var isStart = match[1] === 'START';
|
||||
var id = match[2];
|
||||
if (isStart) {
|
||||
ids.push(id); // push the id we're in
|
||||
parts.push([]); // start a new fragment
|
||||
part = ""; // don't emit anything
|
||||
} else {
|
||||
var curId = ids.pop(); // pop the id
|
||||
if (curId !== id)
|
||||
throw new Error("Range mismatch: "+curId+" / "+id);
|
||||
// record the HTML for this range
|
||||
var subHtml = parts.pop().join('');
|
||||
idToSubHtml[id] = subHtml;
|
||||
// emit a comment in the parent range
|
||||
part = "<!--" + LIVEUI_MARKER_PREFIX + id + "-->";
|
||||
}
|
||||
}
|
||||
}
|
||||
// append the current token to the current fragment
|
||||
parts[parts.length - 1].push(part);
|
||||
});
|
||||
|
||||
if (ids.length > 0)
|
||||
throw new Error("Unclosed ranges "+ids.join(','));
|
||||
|
||||
var topHtml = parts.pop().join('');
|
||||
|
||||
// Helper that invokes `f` on every comment node under `parent`.
|
||||
// If `f` returns a node, visit that node next.
|
||||
var eachComment = function(parent, f) {
|
||||
for (var n = parent.firstChild; n;) {
|
||||
if (n.nodeType === 8) { // COMMENT
|
||||
n = (f(n) || n.nextSibling);
|
||||
continue;
|
||||
}
|
||||
if (n.nodeType === 1) // ELEMENT
|
||||
eachComment(n, f); // recurse
|
||||
n = n.nextSibling;
|
||||
}
|
||||
};
|
||||
|
||||
var makeFrag = function(html) {
|
||||
var frag = Meteor.ui._htmlToFragment(html);
|
||||
// empty frag becomes HTML comment <!--empty-->
|
||||
if (! frag.firstChild)
|
||||
frag.appendChild(document.createComment("empty"));
|
||||
|
||||
eachComment(frag, function(comment) {
|
||||
var match = MARKER_PARSE_REGEX.exec(comment.nodeValue);
|
||||
if (match) {
|
||||
var id = match[1];
|
||||
var html = idToSubHtml[id];
|
||||
|
||||
// Look up annotation data for this id, to determine if it exists
|
||||
// and hasn't been used before during this or a previous
|
||||
// materialize (if the dev is not playing by the rules)
|
||||
var options = Meteor.ui._doc._newAnnotations[id];
|
||||
if (! options)
|
||||
throw new Error("Missing or duplicate annotation (on "+
|
||||
(html||'unknown html')+")");
|
||||
Meteor.ui._doc._newAnnotations[id] = null;
|
||||
|
||||
var subFrag = makeFrag(html);
|
||||
var range = new Meteor.ui._LiveRange(Meteor.ui._TAG, subFrag);
|
||||
// assign options to the LiveRange, including `id`
|
||||
_.extend(range, options);
|
||||
|
||||
var next = comment.nextSibling;
|
||||
|
||||
var container = comment.parentNode;
|
||||
if (container && container.nodeName === "TABLE" &&
|
||||
_.any(subFrag.childNodes,
|
||||
function(n) { return n.nodeName === "TR"; })) {
|
||||
// Avoid putting a TR directly in a TABLE without an
|
||||
// intervening TBODY, because it doesn't work in IE. We do
|
||||
// the same thing on all browsers for ease of testing
|
||||
// and debugging.
|
||||
var tbody = document.createElement("TBODY");
|
||||
tbody.appendChild(subFrag);
|
||||
comment.parentNode.replaceChild(tbody, comment);
|
||||
} else {
|
||||
comment.parentNode.replaceChild(subFrag, comment);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
});
|
||||
|
||||
return frag;
|
||||
};
|
||||
|
||||
return makeFrag(topHtml);
|
||||
};
|
||||
|
||||
Meteor.ui._doc.annotate = function(html, options) {
|
||||
options = options || {};
|
||||
|
||||
// Generate a unique id string, e.g. "a17"
|
||||
var id = "a"+(Meteor.ui._doc._nextId++);
|
||||
options.id = id;
|
||||
|
||||
// Save `options` object to attach to LiveRange later
|
||||
Meteor.ui._doc._newAnnotations[id] = options;
|
||||
|
||||
// Surround the HTML with comments
|
||||
return ("<!--" + LIVEUI_START_PREFIX + options.id + "-->" +
|
||||
html + "<!--" + LIVEUI_END_PREFIX + options.id + "-->");
|
||||
};
|
||||
|
||||
})();
|
||||
56
packages/liveui/livedocument_tests.js
Normal file
56
packages/liveui/livedocument_tests.js
Normal file
@@ -0,0 +1,56 @@
|
||||
Tinytest.add("livedocument - assembly", function(test) {
|
||||
|
||||
var doTest = function(calc) {
|
||||
var frag = Meteor.ui._doc.materialize(
|
||||
calc(function(str, expected) {
|
||||
return Meteor.ui._doc.annotate(str);
|
||||
}));
|
||||
var groups = [];
|
||||
var html = calc(function(str, expected, noRange) {
|
||||
if (arguments.length > 1)
|
||||
str = expected;
|
||||
if (! noRange)
|
||||
groups.push(str);
|
||||
return str;
|
||||
});
|
||||
test.equal(WrappedFrag(frag).html(), html);
|
||||
|
||||
var actualGroups = [];
|
||||
var tempRange = new Meteor.ui._LiveRange(Meteor.ui._TAG, frag);
|
||||
tempRange.visit(function(isStart, rng) {
|
||||
if (! isStart)
|
||||
actualGroups.push(Meteor.ui._rangeToHtml(rng));
|
||||
});
|
||||
test.equal(actualGroups.join(','), groups.join(','));
|
||||
};
|
||||
|
||||
doTest(function(A) { return "<p>Hello</p>"; });
|
||||
doTest(function(A) { return "<td>Hello</td><td>World</td>"; });
|
||||
doTest(function(A) { return "<td>"+A("Hello")+"</td>"; });
|
||||
doTest(function(A) { return A("<td>"+A("Hello")+"</td>"); });
|
||||
doTest(function(A) { return A(A(A(A(A(A("foo")))))); });
|
||||
doTest(
|
||||
function(A) { return "<div>Yo"+A("<p>Hello "+A(A("World")),"<p>Hello World</p>")+
|
||||
"</div>"; });
|
||||
doTest(function(A) {
|
||||
return A("<ul>"+A("<li>one","<li>one</li>")+
|
||||
A("<li>two","<li>two</li>")+
|
||||
A("<li>three","<li>three</li>"),
|
||||
"<ul><li>one</li><li>two</li><li>three</li></ul>"); });
|
||||
|
||||
doTest(function(A) {
|
||||
return A("<table>"+A("<tr>"+A("<td>"+A("Hi")+"</td>")+"</tr>")+"</table>",
|
||||
"<table><tbody><tr><td>Hi</td></tr></tbody></table>");
|
||||
});
|
||||
|
||||
test.throws(function() {
|
||||
doTest(function(A) {
|
||||
var z = A("Hello");
|
||||
return z+z;
|
||||
});
|
||||
});
|
||||
|
||||
doTest(function(A) {
|
||||
return '<div foo="'+A('bar', 'bar', true)+'">Hello</div>';
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,6 @@
|
||||
|
||||
(function() {
|
||||
|
||||
///// WrappedFrag /////
|
||||
|
||||
var WrappedFrag = function(frag) {
|
||||
if (! (this instanceof WrappedFrag))
|
||||
return new WrappedFrag(frag);
|
||||
|
||||
this.frag = frag;
|
||||
};
|
||||
WrappedFrag.prototype.rawHtml = function() {
|
||||
return Meteor.ui._fragmentToHtml(this.frag);
|
||||
};
|
||||
WrappedFrag.prototype.html = function() {
|
||||
return canonicalizeHtml(this.rawHtml());
|
||||
};
|
||||
WrappedFrag.prototype.hold = function() {
|
||||
return Meteor.ui._Sarge.holdFrag(this.frag), this;
|
||||
};
|
||||
WrappedFrag.prototype.release = function() {
|
||||
return Meteor.ui._Sarge.releaseFrag(this.frag), this;
|
||||
};
|
||||
WrappedFrag.prototype.node = function() {
|
||||
return this.frag;
|
||||
};
|
||||
|
||||
///// MISC /////
|
||||
|
||||
var legacyLabels = {
|
||||
'*[id], #[name]': function(n) {
|
||||
var label = null;
|
||||
|
||||
@@ -16,6 +16,7 @@ Package.on_use(function (api) {
|
||||
api.add_files(['domutils.js'], 'client');
|
||||
api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client');
|
||||
api.add_files(['liveevents.js'], 'client');
|
||||
api.add_files(['livedocument.js'], 'client');
|
||||
api.add_files(['liverange.js', 'liveui.js', 'innerhtml.js', 'patcher.js'],
|
||||
'client');
|
||||
});
|
||||
@@ -27,6 +28,7 @@ Package.on_test(function (api) {
|
||||
api.add_files('form_responder.js', 'server');
|
||||
|
||||
api.add_files([
|
||||
'livedocument_tests.js',
|
||||
'liverange_test_helpers.js',
|
||||
'liveui_tests.js',
|
||||
'liveui_tests.html',
|
||||
|
||||
@@ -13,6 +13,7 @@ Package.on_use(function (api, where) {
|
||||
api.add_files('canonicalize_html.js', where);
|
||||
api.add_files('stub_stream.js', where);
|
||||
api.add_files('onscreendiv.js', where);
|
||||
api.add_files('wrappedfrag.js', where);
|
||||
api.add_files('current_style.js', where);
|
||||
api.add_files('reactivevar.js', where);
|
||||
});
|
||||
|
||||
33
packages/test-helpers/wrappedfrag.js
Normal file
33
packages/test-helpers/wrappedfrag.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// A WrappedFrag provides utility methods pertaining to a given
|
||||
// DocumentFragment that are helpful in tests. For example,
|
||||
// WrappedFrag(frag).html() constructs a sort of cross-browser
|
||||
// innerHTML for the fragment.
|
||||
|
||||
// Constructor, with optional 'new':
|
||||
// var f = [new] WrappedFrag([frag])
|
||||
WrappedFrag = function(frag) {
|
||||
if (! (this instanceof WrappedFrag))
|
||||
return new WrappedFrag(frag);
|
||||
|
||||
this.frag = frag;
|
||||
};
|
||||
|
||||
WrappedFrag.prototype.rawHtml = function() {
|
||||
return Meteor.ui._fragmentToHtml(this.frag);
|
||||
};
|
||||
|
||||
WrappedFrag.prototype.html = function() {
|
||||
return canonicalizeHtml(this.rawHtml());
|
||||
};
|
||||
|
||||
WrappedFrag.prototype.hold = function() {
|
||||
return Meteor.ui._Sarge.holdFrag(this.frag), this;
|
||||
};
|
||||
|
||||
WrappedFrag.prototype.release = function() {
|
||||
return Meteor.ui._Sarge.releaseFrag(this.frag), this;
|
||||
};
|
||||
|
||||
WrappedFrag.prototype.node = function() {
|
||||
return this.frag;
|
||||
};
|
||||
Reference in New Issue
Block a user