blob: 8d5c9f5c107ff654809e90fb0ace3ad9ccdfbd95 [file] [log] [blame]
// Copyright (c) 2020, 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.
import 'dart:async';
import 'dart:convert';
import 'dart:html';
import 'dart:js';
// TODO(devoncarew): Remove this is a follow-up.
// ignore_for_file: prefer_single_quotes
// TODO(devoncarew): Fix the issue where we can't load source maps.
// TODO(devoncarew): Include a favicon.
String get rootPath => querySelector(".root").text.trim();
int getOffset(String location) {
String str = Uri.parse(location).queryParameters['offset'];
return str == null ? null : int.tryParse(str);
}
int getLine(String location) {
String str = Uri.parse(location).queryParameters['line'];
return str == null ? null : int.tryParse(str);
}
/// Remove highlighting from [offset].
void removeHighlight(int offset, int lineNumber) {
if (offset != null) {
var anchor = document.getElementById("o$offset");
if (anchor != null) {
anchor.classes.remove("target");
}
}
if (lineNumber != null) {
var line = document.querySelector(".line-$lineNumber");
if (line != null) {
line.parent.classes.remove("highlight");
}
}
}
/// Return the absolute path of [path], assuming [path] is relative to [root].
String absolutePath(String path) {
if (path[0] != "/") {
return '$rootPath/$path';
} else {
return path;
}
}
/// If [path] lies within [root], return the relative path of [path] from [root].
/// Otherwise, return [path].
String relativePath(String path) {
var root = querySelector(".root").text + "/";
if (path.startsWith(root)) {
return path.substring(root.length);
} else {
return path;
}
}
/// Write the contents of the Edit List, from JSON data [editListData].
void writeEditList(dynamic editListData) {
var editList = document.querySelector(".edit-list .panel-content");
editList.innerHtml = "";
var p = editList.append(document.createElement("p"));
var countElement = p.append(document.createElement("strong"));
int editCount = editListData["editCount"];
countElement.append(Text(editCount.toString()));
if (editCount == 1) {
p.append(
Text(" edit was made to this file. Click the edit's checkbox to toggle "
"its reviewed state."));
} else {
p.append(Text(
" edits were made to this file. Click an edit's checkbox to toggle "
"its reviewed state."));
}
for (var edit in editListData["edits"]) {
ParagraphElement editP = editList.append(document.createElement("p"));
editP.classes.add("edit");
Element checkbox = editP.append(document.createElement("input"));
checkbox.setAttribute("type", "checkbox");
checkbox.setAttribute("title", "Click to mark reviewed");
checkbox.setAttribute("disabled", "disabled");
editP.append(Text('line ${edit["line"]}: ${edit["explanation"]}.'));
AnchorElement a = editP.append(document.createElement("a"));
a.classes.add("edit-link");
int offset = edit["offset"];
a.dataset['offset'] = '$offset';
int line = edit["line"];
a.dataset['line'] = '$line';
a.append(Text("[view]"));
a.onClick.listen((MouseEvent event) {
navigate(window.location.pathname, offset, line, callback: () {
pushState(window.location.pathname, offset, line);
});
loadRegionExplanation(a);
});
}
}
/// Load data from [data] into the .code and the .regions divs.
void writeCodeAndRegions(dynamic data) {
var regions = document.querySelector(".regions");
var code = document.querySelector(".code");
PermissiveNodeValidator.setInnerHtml(regions, data["regions"]);
PermissiveNodeValidator.setInnerHtml(code, data["navContent"]);
writeEditList(data["editList"]);
highlightAllCode();
addClickHandlers(".code");
addClickHandlers(".regions");
}
/// Navigate to [path] and optionally scroll [offset] into view.
///
/// If [callback] is present, it will be called after the server response has
/// been processed, and the content has been updated on the page.
void navigate(
String path,
int offset,
int lineNumber, {
VoidCallback callback,
}) {
int currentOffset = getOffset(window.location.href);
int currentLineNumber = getLine(window.location.href);
removeHighlight(currentOffset, currentLineNumber);
if (path == window.location.pathname) {
// Navigating to same file; just scroll into view.
maybeScrollToAndHighlight(offset, lineNumber);
if (callback != null) {
callback();
}
} else {
loadFile(path, offset, lineNumber, callback: callback);
}
}
void maybeScrollIntoView(Element element) {
Rectangle rect = element.getBoundingClientRect();
if (rect.bottom > window.innerHeight) {
element.scrollIntoView();
} else if (rect.top < 0) {
element.scrollIntoView();
}
}
/// Scroll target with id [offset] into view if it is not currently in view.
///
/// If [offset] is null, instead scroll the "unit-name" header, at the top of the
/// page, into view.
///
/// Also add the "target" class, highlighting the target. Also add the
/// "highlight" class to the entire line on which the target lies.
void maybeScrollToAndHighlight(int offset, int lineNumber) {
Element target;
Element line;
if (offset != null) {
target = document.getElementById("o$offset");
line = document.querySelector(".line-$lineNumber");
if (target != null) {
maybeScrollIntoView(target);
target.classes.add("target");
} else if (line != null) {
// If the target doesn't exist, but the line does, scroll that into view
// instead.
maybeScrollIntoView(line);
}
if (line != null) {
(line.parentNode as Element).classes.add("highlight");
}
} else {
// If no offset is given, this is likely a navigation link, and we need to
// scroll back to the top of the page.
target = document.getElementById("unit-name");
maybeScrollIntoView(target);
}
}
/// Load the file at [path] from the server, optionally scrolling [offset] into
/// view.
void loadFile(
String path,
int offset,
int lineNumber, {
VoidCallback callback,
}) {
// Navigating to another file; request it, then do work with the response.
HttpRequest.request(
"$path?inline=true",
requestHeaders: {'Content-Type': 'application/json; charset=UTF-8'},
).then((HttpRequest xhr) {
if (xhr.status == 200) {
var response = jsonDecode(xhr.responseText);
writeCodeAndRegions(response);
maybeScrollToAndHighlight(offset, lineNumber);
updatePage(path, offset);
if (callback != null) {
callback();
}
} else {
window.alert("Request failed; status of ${xhr.status}");
}
}).catchError((e, st) {
logError(e, st);
window.alert('Could not load $path; preview server might be disconnected.');
});
}
void pushState(String path, int offset, int lineNumber) {
var newLocation = window.location.origin + path + "?";
if (offset != null) {
newLocation = newLocation + "offset=$offset&";
}
if (lineNumber != null) {
newLocation = newLocation + "line=$lineNumber";
}
window.history.pushState({}, "", newLocation);
}
/// Update the heading and navigation links.
///
/// Call this after updating page content on a navigation.
void updatePage(String path, int offset) {
path = relativePath(path);
// Update page heading.
Element unitName = document.querySelector("#unit-name");
unitName.text = path;
// Update navigation styles.
document.querySelectorAll(".nav-panel .nav-link").forEach((Element link) {
var name = link.dataset['name'];
if (name == path) {
link.classes.add("selected-file");
} else {
link.classes.remove("selected-file");
}
});
}
void highlightAllCode() {
document.querySelectorAll(".code").forEach((Element block) {
hljs.highlightBlock(block);
});
}
void addArrowClickHandler(Element arrow) {
Element childList =
(arrow.parentNode as Element).querySelector(":scope > ul");
// Animating height from "auto" to "0" is not supported by CSS [1], so all we
// have are hacks. The `* 2` allows for events in which the list grows in
// height when resized, with additional text wrapping.
// [1] https://css-tricks.com/using-css-transitions-auto-dimensions/
childList.style.maxHeight = "${childList.offsetHeight * 2}px";
arrow.onClick.listen((MouseEvent event) {
if (!childList.classes.contains("collapsed")) {
childList.classes.add("collapsed");
arrow.classes.add("collapsed");
} else {
childList.classes.remove("collapsed");
arrow.classes.remove("collapsed");
}
});
}
void handleNavLinkClick(MouseEvent event) {
Element target = event.currentTarget;
var path = absolutePath(target.getAttribute("href"));
int offset = getOffset(target.getAttribute("href"));
int lineNumber = getLine(target.getAttribute("href"));
if (offset != null) {
navigate(path, offset, lineNumber, callback: () {
pushState(path, offset, lineNumber);
});
} else {
navigate(path, null, null, callback: () {
pushState(path, null, null);
});
}
event.preventDefault();
}
void handlePostLinkClick(MouseEvent event) {
String path = (event.currentTarget as Element).getAttribute("href");
path = absolutePath(path);
// Directing the server to produce an edit; request it, then do work with the
// response.
HttpRequest.request(
path,
method: 'POST',
requestHeaders: {'Content-Type': 'application/json; charset=UTF-8'},
).then((HttpRequest xhr) {
if (xhr.status == 200) {
// Likely request new navigation and file content.
} else {
window.alert("Request failed; status of ${xhr.status}");
}
}).catchError((e, st) {
logError(e, st);
window.alert('Could not load $path; preview server might be disconnected.');
});
}
void addClickHandlers(String parentSelector) {
Element parentElement = document.querySelector(parentSelector);
var navLinks = parentElement.querySelectorAll(".nav-link");
navLinks.forEach((link) {
link.onClick.listen(handleNavLinkClick);
});
var regions = parentElement.querySelectorAll(".region");
regions.forEach((Element region) {
region.onClick.listen((event) {
loadRegionExplanation(region);
});
});
var postLinks = parentElement.querySelectorAll(".post-link");
postLinks.forEach((link) {
link.onClick.listen(handlePostLinkClick);
});
}
void writeNavigationSubtree(Element parentElement, dynamic tree) {
var ul = parentElement.append(document.createElement('ul'));
for (var entity in tree) {
Element li = ul.append(document.createElement('li'));
if (entity["type"] == "directory") {
li.classes.add("dir");
Element arrow = li.append(document.createElement('span'));
arrow.classes.add("arrow");
arrow.innerHtml = "&#x25BC;";
Element icon = li.append(document.createElement('span'));
icon.innerHtml = "&#x1F4C1;";
li.append(Text(entity["name"]));
writeNavigationSubtree(li, entity["subtree"]);
addArrowClickHandler(arrow);
} else {
li.innerHtml = "&#x1F4C4;";
Element a = li.append(document.createElement("a"));
a.classes.add("nav-link");
a.dataset['name'] = entity["path"];
a.setAttribute("href", entity["href"]);
a.append(Text(entity["name"]));
a.onClick.listen(handleNavLinkClick);
int editCount = entity["editCount"];
if (editCount > 0) {
Element editsBadge = li.append(document.createElement("span"));
editsBadge.classes.add("edit-count");
var edits = editCount == 1 ? 'edit' : 'edits';
editsBadge.setAttribute("title", '$editCount $edits');
editsBadge.append(Text(editCount.toString()));
}
}
}
}
/// Load the navigation tree into the ".nav-tree" div.
void loadNavigationTree() {
String path = "/_preview/navigationTree.json";
// Request the navigation tree, then do work with the response.
HttpRequest.request(
path,
requestHeaders: {'Content-Type': 'application/json; charset=UTF-8'},
).then((HttpRequest xhr) {
if (xhr.status == 200) {
dynamic response = jsonDecode(xhr.responseText);
var navTree = document.querySelector(".nav-tree");
navTree.innerHtml = "";
writeNavigationSubtree(navTree, response);
} else {
window.alert('Request failed; status of ${xhr.status}');
}
}).catchError((e, st) {
logError(e, st);
window.alert('Could not load $path; preview server might be disconnected.');
});
}
void logError(e, st) {
window.console.error('$e');
window.console.error('$st');
}
void writeRegionExplanation(dynamic response) {
var editPanel = document.querySelector(".edit-panel .panel-content");
editPanel.innerHtml = "";
var regionLocation = document.createElement("p");
regionLocation.classes.add("region-location");
// Insert a zero-width space after each "/", to allow lines to wrap after each
// directory name.
// TODO(devoncarew): Handle the following regex (to improve layout).
//var path = response["path"].replace(/\//g, "/\u200B");
var path = response["path"];
regionLocation.append(Text('$path '));
Element regionLine = regionLocation.append(document.createElement("span"));
regionLine.append(Text('line ${response["line"]}'));
regionLine.classes.add("nowrap");
editPanel.append(regionLocation);
var explanation = editPanel.append(document.createElement("p"));
explanation.append(Text(response["explanation"]));
var detailCount = response["details"].length;
if (detailCount == 0) {
// Having 0 details is not necessarily an expected possibility, but handling
// the possibility prevents awkward text, "for 0 reasons:".
explanation.append(Text("."));
} else {
explanation.append(Text(detailCount == 1
? ' for $detailCount reason:'
: ' for $detailCount reasons:'));
var detailList = editPanel.append(document.createElement("ol"));
for (var detail in response["details"]) {
var detailItem = detailList.append(document.createElement("li"));
detailItem.append(Text(detail["description"]));
if (detail["link"] != null) {
detailItem.append(Text(" ("));
AnchorElement a = detailItem.append(document.createElement("a"));
a.append(Text(detail["link"]["text"]));
a.setAttribute("href", detail["link"]["href"]);
a.classes.add("nav-link");
detailItem.append(Text(")"));
}
}
}
if (response["edits"] != null) {
for (var edit in response["edits"]) {
Element editParagraph = editPanel.append(document.createElement("p"));
Element a = editParagraph.append(document.createElement("a"));
a.append(Text(edit["text"]));
a.setAttribute("href", edit["href"]);
a.classes.add("post-link");
}
}
}
/// Load the explanation for [region], into the ".panel-content" div.
void loadRegionExplanation(Element region) {
String path = window.location.pathname;
String offset = region.dataset['offset'];
// Request the region, then do work with the response.
HttpRequest.request(
'$path?region=region&offset=$offset',
requestHeaders: {'Content-Type': 'application/json; charset=UTF-8'},
).then((HttpRequest xhr) {
if (xhr.status == 200) {
var response = jsonDecode(xhr.responseText);
writeRegionExplanation(response);
addClickHandlers(".edit-panel .panel-content");
} else {
window.alert('Request failed; status of ${xhr.status}');
}
}).catchError((e, st) {
logError(e, st);
window.alert('Could not load $path; preview server might be disconnected.');
});
}
/// Resize the fixed-size and fixed-position navigation and information panels.
void resizePanels() {
var navInner = document.querySelector(".nav-inner");
var height = window.innerHeight;
navInner.style.height = "${height}px";
var infoPanelHeight = height / 2 - 6;
var editPanel = document.querySelector(".edit-panel");
editPanel.style.height = "${infoPanelHeight}px";
var editListHeight = height / 2 - 6;
var editList = document.querySelector(".edit-list");
editList.style.height = "${editListHeight}px";
}
void main() {
document.addEventListener("DOMContentLoaded", (event) {
String path = window.location.pathname;
int offset = getOffset(window.location.href);
int lineNumber = getLine(window.location.href);
loadNavigationTree();
if (path != "/" && path != rootPath) {
// TODO(srawlins): replaceState?
loadFile(path, offset, lineNumber, callback: () {
pushState(path, offset, lineNumber);
});
}
resizePanels();
});
window.addEventListener("popstate", (event) {
var path = window.location.pathname;
int offset = getOffset(window.location.href);
var lineNumber = getLine(window.location.href);
if (path.length > 1) {
loadFile(path, offset, lineNumber);
} else {
// Blank out the page, for the index screen.
writeCodeAndRegions({"regions": "", "navContent": ""});
updatePage("&nbsp;", null);
}
});
final Debouncer resizeDebouncer =
Debouncer(const Duration(milliseconds: 200));
window.addEventListener("resize", (event) {
resizeDebouncer.run(resizePanels);
});
final Debouncer scrollDebouncer =
Debouncer(const Duration(milliseconds: 200));
// When scrolling the page, determine whether the navigation and information
// panels need to be fixed in place, or allowed to scroll.
window.addEventListener("scroll", (event) {
var navPanel = document.querySelector(".nav-panel");
var navInner = navPanel.querySelector(".nav-inner");
var infoPanel = document.querySelector(".info-panel");
var panelContainer = document.querySelector(".panel-container");
var innerTopOffset = navPanel.offsetTop;
if (window.pageYOffset > innerTopOffset) {
if (!navInner.classes.contains("fixed")) {
var navPanelWidth = navPanel.offsetWidth - 14;
navPanel.style.width = "${navPanelWidth}px";
// Subtract 7px for nav-inner's padding.
navInner.style.width = (navPanelWidth + 7).toString() + "px";
navInner.classes.add("fixed");
}
if (!panelContainer.classes.contains("fixed")) {
var infoPanelWidth = infoPanel.offsetWidth;
infoPanel.style.width = "${infoPanelWidth}px";
panelContainer.style.width = "${infoPanelWidth}px";
panelContainer.classes.add("fixed");
}
} else {
if (navInner.classes.contains("fixed")) {
navPanel.style.width = "";
navInner.style.width = "";
navInner.classes.remove("fixed");
}
if (panelContainer.classes.contains("fixed")) {
infoPanel.style.width = "";
panelContainer.style.width = "";
panelContainer.classes.remove("fixed");
}
}
scrollDebouncer.run(resizePanels);
});
}
final HighlightJs hljs = HighlightJs._();
/// A small wrapper around the JavaScript highlight.js APIs.
class HighlightJs {
static JsObject get _hljs => context['hljs'];
HighlightJs._();
void highlightBlock(Element block) {
_hljs.callMethod('highlightBlock', [block]);
}
}
/// A utility class to debounce an action at the given duration.
class Debouncer {
final Duration duration;
Timer _timer;
Debouncer(this.duration);
void run(VoidCallback action) {
if (_timer != null) {
_timer.cancel();
}
_timer = Timer(duration, action);
}
}
class PermissiveNodeValidator implements NodeValidator {
static PermissiveNodeValidator instance = PermissiveNodeValidator();
static void setInnerHtml(Element element, String html) {
element.setInnerHtml(html, validator: instance);
}
@override
bool allowsAttribute(Element element, String attributeName, String value) {
return true;
}
@override
bool allowsElement(Element element) {
return true;
}
}