blob: 6ec89e161e71c8476d61b4aefd86e47dd7984251 [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:convert';
import 'dart:html';
import 'package:path/path.dart' as _p;
import 'highlight_js.dart';
// TODO(devoncarew): Fix the issue where we can't load source maps.
// TODO(devoncarew): Include a favicon.
String get rootPath => querySelector('.root').text.trim();
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);
});
}
});
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(' ', null);
}
});
}
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'));
int editCount = editListData['editCount'];
if (editCount == 0) {
p.append(Text('$editCount proposed edits'));
} else {
p.append(Text('$editCount proposed ${pluralize(editCount, 'edit')}:'));
}
Element list = editList.append(document.createElement('ul'));
for (var edit in editListData['edits']) {
Element item = list.append(document.createElement('li'));
item.classes.add('edit');
AnchorElement anchor = item.append(document.createElement('a'));
anchor.classes.add('edit-link');
int offset = edit['offset'];
anchor.dataset['offset'] = '$offset';
int line = edit['line'];
anchor.dataset['line'] = '$line';
anchor.append(Text('line $line'));
anchor.onClick.listen((MouseEvent event) {
navigate(window.location.pathname, offset, line, callback: () {
pushState(window.location.pathname, offset, line);
});
loadRegionExplanation(anchor);
});
item.append(Text(': ${edit['explanation']}'));
}
}
/// 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.parent);
}
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.
// TODO(devoncarew): path might be a url; if it is, then use url manipulation
// to add additional args.
HttpRequest.request(
path.contains('?') ? '$path&inline=true' : '$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);
String filePathPart =
path.contains('?') ? path.substring(0, path.indexOf('?')) : path;
updatePage(filePathPart, offset);
if (callback != null) {
callback();
}
} else {
window.alert('Request failed; status of ${xhr.status}');
}
}).catchError((e, st) {
logError('loadFile: $e', st);
window.alert('Could not load $path ($e).');
});
}
void pushState(String path, int offset, int lineNumber) {
// TODO(devoncarew): Path might be a url; if it is, then use url manipulation
// to add additional args.
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('handlePostLinkClick: $e', st);
window.alert('Could not load $path ($e).');
});
}
void addClickHandlers(String selector) {
Element parentElement = document.querySelector(selector);
List<Element> navLinks = parentElement.querySelectorAll('.nav-link');
navLinks.forEach((link) {
link.onClick.listen(handleNavLinkClick);
});
// TODO(devoncarew): Move this code to where the elements are defined.
List<Element> regions = parentElement.querySelectorAll('.region');
regions.forEach((Element anchor) {
anchor.onClick.listen((event) {
loadRegionExplanation(anchor);
});
});
List<Element> postLinks = parentElement.querySelectorAll('.post-link');
postLinks.forEach((link) {
link.onClick.listen(handlePostLinkClick);
});
}
void writeNavigationSubtree(Element parentElement, dynamic tree) {
Element 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');
editsBadge.setAttribute(
'title', '$editCount ${pluralize(editCount, 'edit')}');
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('loadNavigationTree: $e', st);
window.alert('Could not load $path ($e).');
});
}
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 = '';
String filePath = response['path'];
String parentDirectory = _p.dirname(filePath);
// 'Changed ... at foo.dart:12.'
String explanationMessage = response['explanation'];
String relPath = _p.relative(filePath, from: rootPath);
int line = response['line'];
Element explanation = editPanel.append(document.createElement('p'));
explanation.append(Text('$explanationMessage at $relPath:$line.'));
int 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:".
} else {
editPanel.append(ParagraphElement()..text = 'Edit rationale:');
Element detailList = editPanel.append(document.createElement('ul'));
for (var detail in response['details']) {
var detailItem = detailList.append(document.createElement('li'));
detailItem.append(Text(detail['description']));
if (detail['link'] != null) {
int targetLine = detail['link']['line'];
detailItem.append(Text(' ('));
AnchorElement a = detailItem.append(document.createElement('a'));
a.append(Text("${detail['link']['text']}:$targetLine"));
String relLink = detail['link']['href'];
String fullPath = _p.normalize(_p.join(parentDirectory, relLink));
a.setAttribute('href', fullPath);
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(AnchorElement anchor) {
String path = window.location.pathname;
String offset = anchor.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('loadRegionExplanation: $e', st);
window.alert('Could not load $path ($e).');
});
}
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;
}
}
String pluralize(int count, String single, {String multiple}) {
return count == 1 ? single : (multiple ?? '${single}s');
}