| // 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 = "▼"; |
| Element icon = li.append(document.createElement('span')); |
| icon.innerHtml = "📁"; |
| li.append(Text(entity["name"])); |
| writeNavigationSubtree(li, entity["subtree"]); |
| addArrowClickHandler(arrow); |
| } else { |
| li.innerHtml = "📄"; |
| 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(" ", 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; |
| } |
| } |