Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Delta Between Two Patch Sets: include.preload.js

Issue 6393086494113792: Issue 154 - Added devtools panel showing blocked and blockable items (Closed)
Left Patch Set: Rebased Created March 3, 2015, 3:16 p.m.
Right Patch Set: Adapt for UI changes generating domain specific filters when necessary Created Feb. 3, 2016, 10:40 a.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
Left: Side by side diff | Download
Right: Side by side diff | Download
« no previous file with change/comment | « dependencies ('k') | lib/devtools.js » ('j') | no next file with change/comment »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
LEFTRIGHT
1 /* 1 /*
2 * This file is part of Adblock Plus <https://adblockplus.org/>, 2 * This file is part of Adblock Plus <https://adblockplus.org/>,
3 * Copyright (C) 2006-2015 Eyeo GmbH 3 * Copyright (C) 2006-2016 Eyeo GmbH
4 * 4 *
5 * Adblock Plus is free software: you can redistribute it and/or modify 5 * Adblock Plus is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License version 3 as 6 * it under the terms of the GNU General Public License version 3 as
7 * published by the Free Software Foundation. 7 * published by the Free Software Foundation.
8 * 8 *
9 * Adblock Plus is distributed in the hope that it will be useful, 9 * Adblock Plus is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details. 12 * GNU General Public License for more details.
13 * 13 *
14 * You should have received a copy of the GNU General Public License 14 * You should have received a copy of the GNU General Public License
15 * along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>. 15 * along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>.
16 */ 16 */
17 17
18 var SELECTOR_GROUP_SIZE = 20; 18 var SELECTOR_GROUP_SIZE = 20;
19 19
20 var typeMap = { 20 var typeMap = {
21 "img": "IMAGE", 21 "img": "IMAGE",
22 "input": "IMAGE", 22 "input": "IMAGE",
23 "picture": "IMAGE",
23 "audio": "MEDIA", 24 "audio": "MEDIA",
24 "video": "MEDIA", 25 "video": "MEDIA",
25 "frame": "SUBDOCUMENT", 26 "frame": "SUBDOCUMENT",
26 "iframe": "SUBDOCUMENT" 27 "iframe": "SUBDOCUMENT",
28 "object": "OBJECT",
29 "embed": "OBJECT"
27 }; 30 };
31
32 function getURLsFromObjectElement(element)
33 {
34 var url = element.getAttribute("data");
35 if (url)
36 return [url];
37
38 for (var i = 0; i < element.children.length; i++)
39 {
40 var child = element.children[i];
41 if (child.localName != "param")
42 continue;
43
44 var name = child.getAttribute("name");
45 if (name != "movie" && // Adobe Flash
46 name != "source" && // Silverlight
47 name != "src" && // Real Media + Quicktime
48 name != "FileName") // Windows Media
49 continue;
50
51 var value = child.getAttribute("value");
52 if (!value)
53 continue;
54
55 return [value];
56 }
57
58 return [];
59 }
60
61 function getURLsFromAttributes(element)
62 {
63 var urls = [];
64
65 if (element.src)
66 urls.push(element.src);
67
68 if (element.srcset)
69 {
70 var candidates = element.srcset.split(",");
71 for (var i = 0; i < candidates.length; i++)
72 {
73 var url = candidates[i].trim().replace(/\s+\S+$/, "");
74 if (url)
75 urls.push(url);
76 }
77 }
78
79 return urls;
80 }
81
82 function getURLsFromMediaElement(element)
83 {
84 var urls = getURLsFromAttributes(element);
85
86 for (var i = 0; i < element.children.length; i++)
87 {
88 var child = element.children[i];
89 if (child.localName == "source" || child.localName == "track")
90 urls.push.apply(urls, getURLsFromAttributes(child));
91 }
92
93 if (element.poster)
94 urls.push(element.poster);
95
96 return urls;
97 }
98
99 function getURLsFromElement(element)
100 {
101 var urls;
102 switch (element.localName)
103 {
104 case "object":
105 urls = getURLsFromObjectElement(element);
106 break;
107
108 case "video":
109 case "audio":
110 case "picture":
111 urls = getURLsFromMediaElement(element);
112 break;
113
114 default:
115 urls = getURLsFromAttributes(element);
116 break;
117 }
118
119 for (var i = 0; i < urls.length; i++)
120 {
121 if (/^(?!https?:)[\w-]+:/i.test(urls[i]))
122 urls.splice(i--, 1);
123 }
124
125 return urls;
126 }
28 127
29 function checkCollapse(element) 128 function checkCollapse(element)
30 { 129 {
31 var tag = element.localName; 130 var tag = element.localName;
32 if (tag in typeMap) 131 if (tag in typeMap)
33 { 132 {
34 // This element failed loading, did we block it? 133 // This element failed loading, did we block it?
35 var url = element.src; 134 var urls = getURLsFromElement(element);
36 if (!url || !/^https?:/i.test(url)) 135 if (urls.length == 0)
37 return; 136 return;
38 137
39 ext.backgroundPage.sendMessage( 138 ext.backgroundPage.sendMessage(
40 { 139 {
41 type: "should-collapse", 140 type: "should-collapse",
42 url: url, 141 urls: urls,
43 mediatype: typeMap[tag] 142 mediatype: typeMap[tag],
143 baseURL: document.location.href
44 }, 144 },
45 145
46 function(response) 146 function(response)
47 { 147 {
48 if (response && element.parentNode) 148 if (response && element.parentNode)
49 { 149 {
50 var property = "display"; 150 var property = "display";
51 var value = "none"; 151 var value = "none";
52 152
53 // <frame> cannot be removed, doing that will mess up the frameset 153 // <frame> cannot be removed, doing that will mess up the frameset
54 if (tag == "frame") 154 if (tag == "frame")
55 { 155 {
56 property = "visibility"; 156 property = "visibility";
57 value = "hidden"; 157 value = "hidden";
58 } 158 }
59 159
60 // <input type="image"> elements try to load their image again 160 // <input type="image"> elements try to load their image again
61 // when the "display" CSS property is set. So we have to check 161 // when the "display" CSS property is set. So we have to check
62 // that it isn't already collapsed to avoid an infinite recursion. 162 // that it isn't already collapsed to avoid an infinite recursion.
63 if (element.style.getPropertyValue(property) != value || 163 if (element.style.getPropertyValue(property) != value ||
64 element.style.getPropertyPriority(property) != "important") 164 element.style.getPropertyPriority(property) != "important")
65 element.style.setProperty(property, value, "important"); 165 element.style.setProperty(property, value, "important");
66 } 166 }
67 } 167 }
68 ); 168 );
69 } 169 }
170
171 window.collapsing = true;
70 } 172 }
71 173
72 function checkSitekey() 174 function checkSitekey()
73 { 175 {
74 var attr = document.documentElement.getAttribute("data-adblockkey"); 176 var attr = document.documentElement.getAttribute("data-adblockkey");
75 if (attr) 177 if (attr)
76 ext.backgroundPage.sendMessage({type: "add-sitekey", token: attr}); 178 ext.backgroundPage.sendMessage({type: "add-sitekey", token: attr});
77 } 179 }
78 180
79 function isInlineFrame(element) 181 function getContentDocument(element)
80 { 182 {
81 var contentDocument;
82 try 183 try
83 { 184 {
84 contentDocument = element.contentDocument; 185 return element.contentDocument;
85 } 186 }
86 catch (e) 187 catch (e)
87 { 188 {
88 return false; // third-party 189 return null;
89 } 190 }
90 191 }
91 if (!contentDocument) 192
92 return false; // not a frame 193 function ElementHidingTracer(document, selectors)
93 194 {
94 return contentDocument.location.protocol == "about:"; 195 this.document = document;
95 } 196 this.selectors = selectors;
96 197
97 function traceHiddenElements(document, selectors) 198 this.changedNodes = [];
98 { 199 this.timeout = null;
99 function check(element) 200
201 this.observer = new MutationObserver(this.observe.bind(this));
202 this.trace = this.trace.bind(this);
203
204 if (document.readyState == "loading")
205 document.addEventListener("DOMContentLoaded", this.trace);
206 else
207 this.trace();
208 }
209 ElementHidingTracer.prototype = {
210 checkNodes: function(nodes)
100 { 211 {
101 var matchedSelectors = []; 212 var matchedSelectors = [];
102 213
103 for (var i = 0; i < selectors.length; i++) 214 // Find all selectors that match any hidden element inside the given nodes.
104 { 215 for (var i = 0; i < this.selectors.length; i++)
105 var selector = selectors[i]; 216 {
106 var elements = document.querySelectorAll(selector); 217 var selector = this.selectors[i];
107 218
108 for (var j = 0; j < elements.length; j++) 219 for (var j = 0; j < nodes.length; j++)
109 { 220 {
110 if (getComputedStyle(elements[j]).display == "none") 221 var elements = nodes[j].querySelectorAll(selector);
222 var matched = false;
223
224 for (var k = 0; k < elements.length; k++)
111 { 225 {
112 matchedSelectors.push(selector); 226 // Only consider selectors that actually have an effect on the
227 // computed styles, and aren't overridden by rules with higher
228 // priority, or haven't been circumvented in a different way.
229 if (getComputedStyle(elements[k]).display == "none")
230 {
231 matchedSelectors.push(selector);
232 matched = true;
233 break;
234 }
235 }
236
237 if (matched)
238 break;
239 }
240 }
241
242 if (matchedSelectors.length > 0)
243 ext.backgroundPage.sendMessage({
244 type: "trace-elemhide",
245 selectors: matchedSelectors
246 });
247 },
248
249 onTimeout: function()
250 {
251 this.checkNodes(this.changedNodes);
252 this.changedNodes = [];
253 this.timeout = null;
254 },
255
256 observe: function(mutations)
257 {
258 // Forget previously changed nodes that are no longer in the DOM.
259 for (var i = 0; i < this.changedNodes.length; i++)
260 {
261 if (!this.document.contains(this.changedNodes[i]))
262 this.changedNodes.splice(i--, 1);
263 }
264
265 for (var j = 0; j < mutations.length; j++)
266 {
267 var mutation = mutations[j];
268 var node = mutation.target;
269
270 // Ignore mutations of nodes that aren't in the DOM anymore.
271 if (!this.document.contains(node))
272 continue;
273
274 // Since querySelectorAll() doesn't consider the root itself
275 // and since CSS selectors can also match siblings, we have
276 // to consider the parent node for attribute mutations.
277 if (mutation.type == "attributes")
278 node = node.parentNode;
279
280 var addNode = true;
281 for (var k = 0; k < this.changedNodes.length; k++)
282 {
283 var previouslyChangedNode = this.changedNodes[k];
284
285 // If we are already going to check an ancestor of this node,
286 // we can ignore this node, since it will be considered anyway
287 // when checking one of its ancestors.
288 if (previouslyChangedNode.contains(node))
289 {
290 addNode = false;
113 break; 291 break;
114 } 292 }
115 } 293
116 } 294 // If this node is an ancestor of a node that previously changed,
117 295 // we can ignore that node, since it will be considered anyway
118 if (matchedSelectors.length > 0) 296 // when checking one of its ancestors.
119 ext.backgroundPage.sendMessage({type: "trace-elemhide", selectors: matched Selectors}); 297 if (node.contains(previouslyChangedNode))
120 } 298 this.changedNodes.splice(k--, 1);
121 299 }
122 function trace() 300
123 { 301 if (addNode)
124 check(); 302 this.changedNodes.push(node);
125 303 }
126 new MutationObserver(check).observe( 304
127 document, 305 // Check only nodes whose descendants have changed, and not more often
306 // than once a second. Otherwise large pages with a lot of DOM mutations
307 // (like YouTube) freeze when the devtools panel is active.
308 if (this.timeout == null)
309 this.timeout = setTimeout(this.onTimeout.bind(this), 1000);
310 },
311
312 trace: function()
313 {
314 this.checkNodes([this.document]);
315
316 this.observer.observe(
317 this.document,
128 { 318 {
129 childList: true, 319 childList: true,
130 attributes: true, 320 attributes: true,
131 subtree: true 321 subtree: true
132 } 322 }
133 ); 323 );
134 } 324 },
135 325
136 if (document.readyState == "loading") 326 disconnect: function()
137 document.addEventListener("DOMContentLoaded", trace); 327 {
138 else 328 this.document.removeEventListener("DOMContentLoaded", this.trace);
139 trace(); 329 this.observer.disconnect();
140 } 330 clearTimeout(this.timeout);
331 }
332 };
141 333
142 function reinjectRulesWhenRemoved(document, style) 334 function reinjectRulesWhenRemoved(document, style)
143 { 335 {
144 var MutationObserver = window.MutationObserver || window.WebKitMutationObserve r; 336 var MutationObserver = window.MutationObserver || window.WebKitMutationObserve r;
145 if (!MutationObserver) 337 if (!MutationObserver)
146 return; 338 return;
147 339
148 var observer = new MutationObserver(function(mutations) 340 var observer = new MutationObserver(function(mutations)
149 { 341 {
150 var isStyleRemoved = false; 342 var isStyleRemoved = false;
(...skipping 11 matching lines...) Expand all
162 observer.disconnect(); 354 observer.disconnect();
163 355
164 var n = document.styleSheets.length; 356 var n = document.styleSheets.length;
165 if (n == 0) 357 if (n == 0)
166 return; 358 return;
167 359
168 var stylesheet = document.styleSheets[n - 1]; 360 var stylesheet = document.styleSheets[n - 1];
169 ext.backgroundPage.sendMessage( 361 ext.backgroundPage.sendMessage(
170 {type: "get-selectors"}, 362 {type: "get-selectors"},
171 363
172 function(selectors) 364 function(response)
173 { 365 {
366 var selectors = response.selectors;
174 while (selectors.length > 0) 367 while (selectors.length > 0)
175 { 368 {
176 var selector = selectors.splice(0, SELECTOR_GROUP_SIZE).join(", "); 369 var selector = selectors.splice(0, SELECTOR_GROUP_SIZE).join(", ");
177 370
178 // Using non-standard addRule() here. This is the only way 371 // Using non-standard addRule() here. This is the only way
179 // to add rules at the end of a cross-origin stylesheet 372 // to add rules at the end of a cross-origin stylesheet
180 // because we don't know how many rules are already in there 373 // because we don't know how many rules are already in there
181 stylesheet.addRule(selector, "display: none !important;"); 374 stylesheet.addRule(selector, "display: none !important;");
182 } 375 }
183 } 376 }
184 ); 377 );
185 }); 378 });
186 379
187 observer.observe(style.parentNode, {childList: true}); 380 observer.observe(style.parentNode, {childList: true});
381 return observer;
188 } 382 }
189 383
190 function convertSelectorsForShadowDOM(selectors) 384 function convertSelectorsForShadowDOM(selectors)
191 { 385 {
192 var result = []; 386 var result = [];
193 var prefix = "::content "; 387 var prefix = "::content ";
194 388
195 for (var i = 0; i < selectors.length; i++) 389 for (var i = 0; i < selectors.length; i++)
196 { 390 {
197 var selector = selectors[i]; 391 var selector = selectors[i];
392 if (selector.indexOf(",") == -1)
393 {
394 result.push(prefix + selector);
395 continue;
396 }
397
198 var start = 0; 398 var start = 0;
199 var sep = ""; 399 var sep = "";
200
201 for (var j = 0; j < selector.length; j++) 400 for (var j = 0; j < selector.length; j++)
202 { 401 {
203 var chr = selector[j]; 402 var chr = selector[j];
204 if (chr == "\\") 403 if (chr == "\\")
205 j++; 404 j++;
206 else if (chr == sep) 405 else if (chr == sep)
207 sep = ""; 406 sep = "";
208 else if (chr == '"' || chr == "'") 407 else if (sep == "")
209 sep = chr; 408 {
210 else if (chr == "," && sep == "") 409 if (chr == '"' || chr == "'")
211 { 410 sep = chr;
212 result.push(prefix + selector.substring(start, j)); 411 else if (chr == ",")
213 start = j + 1; 412 {
413 result.push(prefix + selector.substring(start, j));
414 start = j + 1;
415 }
214 } 416 }
215 } 417 }
216 418
217 result.push(prefix + selector.substring(start)); 419 result.push(prefix + selector.substring(start));
218 } 420 }
219 421
220 return result; 422 return result;
221 } 423 }
222 424
223 function init(document) 425 function init(document)
224 { 426 {
427 var shadow = null;
428 var style = null;
429 var observer = null;
430 var tracer = null;
431 var propertyFilters = new CSSPropertyFilters(window, addElemHideSelectors);
432
225 // Use Shadow DOM if available to don't mess with web pages that rely on 433 // Use Shadow DOM if available to don't mess with web pages that rely on
226 // the order of their own <style> tags (#309). 434 // the order of their own <style> tags (#309).
227 // 435 //
228 // However, creating a shadow root breaks running CSS transitions. So we 436 // However, creating a shadow root breaks running CSS transitions. So we
229 // have to create the shadow root before transistions might start (#452). 437 // have to create the shadow root before transistions might start (#452).
230 // 438 //
231 // Also, we can't use shadow DOM on Google Docs, since it breaks printing 439 // Also, using shadow DOM causes issues on some Google websites,
232 // there (#1770). 440 // including Google Docs and Gmail (#1770, #2602).
233 var shadow = null; 441 if ("createShadowRoot" in document.documentElement && !/\.google\.com$/.test(d ocument.domain))
234 if ("createShadowRoot" in document.documentElement && document.domain != "docs .google.com")
235 { 442 {
236 shadow = document.documentElement.createShadowRoot(); 443 shadow = document.documentElement.createShadowRoot();
237 shadow.appendChild(document.createElement("shadow")); 444 shadow.appendChild(document.createElement("shadow"));
238 } 445 }
239 446
240 // Sets the currently used CSS rules for elemhide filters 447 function addElemHideSelectors(selectors)
241 var setElemhideCSSRules = function(response) 448 {
242 { 449 if (selectors.length == 0)
243 if (response.selectors.length == 0)
244 return; 450 return;
245 451
246 var style = document.createElement("style"); 452 if (!style)
247 style.setAttribute("type", "text/css"); 453 {
248 454 // Create <style> element lazily, only if we add styles. Add it to
249 var selectors = response.selectors; 455 // the shadow DOM if possible. Otherwise fallback to the <head> or
456 // <html> element. If we have injected a style element before that
457 // has been removed (the sheet property is null), create a new one.
458 style = document.createElement("style");
459 (shadow || document.head || document.documentElement).appendChild(style);
460
461 // It can happen that the frame already navigated to a different
462 // document while we were waiting for the background page to respond.
463 // In that case the sheet property will stay null, after addind the
464 // <style> element to the shadow DOM.
465 if (!style.sheet)
466 return;
467
468 observer = reinjectRulesWhenRemoved(document, style);
469 }
470
471 // If using shadow DOM, we have to add the ::content pseudo-element
472 // before each selector, in order to match elements within the
473 // insertion point.
250 if (shadow) 474 if (shadow)
251 {
252 shadow.appendChild(style);
253 selectors = convertSelectorsForShadowDOM(selectors); 475 selectors = convertSelectorsForShadowDOM(selectors);
254 } 476
255 else 477 // WebKit (and Blink?) apparently chokes when the selector list in a
256 { 478 // CSS rule is huge. So we split the elemhide selectors into groups.
257 // Try to insert the style into the <head> tag, inserting directly under t he 479 while (selectors.length > 0)
258 // document root breaks dev tools functionality: 480 {
259 // http://code.google.com/p/chromium/issues/detail?id=178109 481 var selector = selectors.splice(0, SELECTOR_GROUP_SIZE).join(", ");
260 (document.head || document.documentElement).appendChild(style); 482 style.sheet.addRule(selector, "display: none !important;");
261 } 483 }
262 484 };
263 var setRules = function() 485
264 { 486 var updateStylesheet = function()
265 // The sheet property might not exist yet if the 487 {
266 // <style> element was created for a sub frame 488 var selectors = null;
267 if (!style.sheet) 489 var CSSPropertyFiltersLoaded = false;
268 { 490
269 setTimeout(setRules, 0); 491 var checkLoaded = function()
492 {
493 if (!selectors || !CSSPropertyFiltersLoaded)
270 return; 494 return;
271 } 495
272 496 if (observer)
273 // WebKit apparently chokes when the selector list in a CSS rule is huge. 497 observer.disconnect();
274 // So we split the elemhide selectors into groups. 498 observer = null;
275 for (var i = 0; selectors.length > 0; i++) 499
276 { 500 if (tracer)
277 var selector = selectors.splice(0, SELECTOR_GROUP_SIZE).join(", "); 501 tracer.disconnect();
278 style.sheet.insertRule(selector + " { display: none !important; }", i); 502 tracer = null;
279 } 503
280 504 if (style && style.parentElement)
281 if (response.trace) 505 style.parentElement.removeChild(style);
282 traceHiddenElements(document, response.selectors); 506 style = null;
507
508 addElemHideSelectors(selectors.selectors);
509 propertyFilters.apply();
510
511 if (selectors.trace)
512 tracer = new ElementHidingTracer(document, selectors.selectors);
283 }; 513 };
284 514
285 setRules(); 515 ext.backgroundPage.sendMessage({type: "get-selectors"}, function(response)
286 reinjectRulesWhenRemoved(document, style); 516 {
517 selectors = response;
518 checkLoaded();
519 });
520
521 propertyFilters.load(function()
522 {
523 CSSPropertyFiltersLoaded = true;
524 checkLoaded();
525 });
287 }; 526 };
527
528 updateStylesheet();
288 529
289 document.addEventListener("error", function(event) 530 document.addEventListener("error", function(event)
290 { 531 {
291 checkCollapse(event.target); 532 checkCollapse(event.target);
292 }, true); 533 }, true);
293 534
294 document.addEventListener("load", function(event) 535 document.addEventListener("load", function(event)
295 { 536 {
296 var element = event.target; 537 var element = event.target;
297 538
298 if (/^i?frame$/.test(element.localName)) 539 if (/^i?frame$/.test(element.localName))
299 checkCollapse(element); 540 checkCollapse(element);
300 541
301 // prior to Chrome 37, content scripts cannot run on about:blank, 542 if (/\bChrome\//.test(navigator.userAgent))
302 // about:srcdoc and javascript: URLs. Moreover, as of Chrome 40 543 {
303 // "load" and "error" events aren't dispatched there. So we have 544 var contentDocument = getContentDocument(element);
304 // to apply element hiding and collapsing from the parent frame. 545 if (contentDocument)
305 if (/\bChrome\//.test(navigator.userAgent) && isInlineFrame(element)) 546 {
306 { 547 var contentWindow = contentDocument.defaultView;
307 init(element.contentDocument); 548 if (contentDocument instanceof contentWindow.HTMLDocument)
308 549 {
309 for (var tagName in typeMap) 550 // Prior to Chrome 37, content scripts cannot run in
310 Array.prototype.forEach.call(element.contentDocument.getElementsByTagNam e(tagName), checkCollapse); 551 // dynamically created frames. Also on Chrome 37-40
552 // document_start content scripts (like this one) don't
553 // run either in those frames due to https://crbug.com/416907.
554 // So we have to apply element hiding from the parent frame.
555 if (!("init" in contentWindow))
556 init(contentDocument);
557
558 // Moreover, "load" and "error" events aren't dispatched for elements
559 // in dynamically created frames due to https://crbug.com/442107.
560 // So we also have to apply element collpasing from the parent frame.
561 if (!contentWindow.collapsing)
562 Array.prototype.forEach.call(
563 contentDocument.querySelectorAll(Object.keys(typeMap).join(",")),
564 checkCollapse
565 );
566 }
567 }
311 } 568 }
312 }, true); 569 }, true);
313 570
314 ext.backgroundPage.sendMessage({type: "get-selectors"}, setElemhideCSSRules); 571 return updateStylesheet;
315 } 572 }
316 573
317 if (document instanceof HTMLDocument) 574 if (document instanceof HTMLDocument)
318 { 575 {
319 checkSitekey(); 576 checkSitekey();
320 init(document); 577 window.updateStylesheet = init(document);
321 } 578 }
LEFTRIGHT

Powered by Google App Engine
This is Rietveld