codemirror
This commit is contained in:
471
apps/server/internal/httpserver/static/keyboard-nav.js
Normal file
471
apps/server/internal/httpserver/static/keyboard-nav.js
Normal file
@@ -0,0 +1,471 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.querySelector("[data-editor-form]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
var browser = document.querySelector(".miller-browser");
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
var documentShell = document.querySelector(".document-shell[data-document-path]");
|
||||
var markdownBody = document.querySelector(".markdown-body");
|
||||
var mode = "browser";
|
||||
var selectedColumnIndex = 0;
|
||||
var selectedLink = null;
|
||||
var selectedParagraphIndex = -1;
|
||||
|
||||
function getColumns() {
|
||||
return Array.prototype.slice.call(browser.querySelectorAll(".miller-column"));
|
||||
}
|
||||
|
||||
function getColumnLinks(columnIndex) {
|
||||
var columns = getColumns();
|
||||
if (!columns[columnIndex]) {
|
||||
return [];
|
||||
}
|
||||
return Array.prototype.slice.call(columns[columnIndex].querySelectorAll("a[href]"));
|
||||
}
|
||||
|
||||
function getParagraphTargets() {
|
||||
if (!markdownBody) {
|
||||
return [];
|
||||
}
|
||||
return Array.prototype.slice.call(
|
||||
markdownBody.querySelectorAll(".commentable, p, h1, h2, h3, h4, h5, h6, li, blockquote, pre")
|
||||
).filter(function (el) {
|
||||
return (el.textContent || "").trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest(
|
||||
"input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox'], .monaco-editor"
|
||||
));
|
||||
}
|
||||
|
||||
function isKeyboardNavigationTarget(target) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest(".miller-browser a[href], .markdown-body .is-keyboard-selected"));
|
||||
}
|
||||
|
||||
function shouldIgnoreTarget(target) {
|
||||
if (isEditableTarget(target)) {
|
||||
return true;
|
||||
}
|
||||
if (isKeyboardNavigationTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target && target.closest("a[href], button, summary, details, [role='button'], [role='menuitem']"));
|
||||
}
|
||||
|
||||
function showKeyboardUI() {
|
||||
document.body.classList.add("keyboard-navigation-active");
|
||||
}
|
||||
|
||||
function clearBrowserSelection() {
|
||||
browser.querySelectorAll(".is-keyboard-selected").forEach(function (node) {
|
||||
node.classList.remove("is-keyboard-selected");
|
||||
node.removeAttribute("aria-selected");
|
||||
});
|
||||
}
|
||||
|
||||
function clearParagraphSelection() {
|
||||
getParagraphTargets().forEach(function (node) {
|
||||
node.classList.remove("is-keyboard-selected");
|
||||
node.removeAttribute("tabindex");
|
||||
node.removeAttribute("aria-selected");
|
||||
});
|
||||
}
|
||||
|
||||
function scrollBrowserColumnIntoView(column) {
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
var leftEdge = column.offsetLeft;
|
||||
var rightEdge = leftEdge + column.offsetWidth;
|
||||
var targetLeft = browser.scrollLeft;
|
||||
|
||||
if (leftEdge < browser.scrollLeft) {
|
||||
targetLeft = leftEdge - 8;
|
||||
} else if (rightEdge > browser.scrollLeft + browser.clientWidth) {
|
||||
targetLeft = rightEdge - browser.clientWidth + 8;
|
||||
}
|
||||
|
||||
if (targetLeft !== browser.scrollLeft) {
|
||||
browser.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
function expandColumnForKeyboard(column) {
|
||||
getColumns().forEach(function (candidate) {
|
||||
if (candidate !== column) {
|
||||
candidate.classList.remove("is-preview-expanded");
|
||||
candidate.style.removeProperty("--expanded-column-width");
|
||||
}
|
||||
});
|
||||
|
||||
if (!column || !column.matches(".miller-column:not(:first-child):not(:last-child)")) {
|
||||
return;
|
||||
}
|
||||
|
||||
var expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384);
|
||||
column.style.setProperty("--expanded-column-width", expandedWidth + "px");
|
||||
column.classList.add("is-preview-expanded");
|
||||
}
|
||||
|
||||
function updateBrowserSelection(focusSelected) {
|
||||
clearBrowserSelection();
|
||||
var columns = getColumns();
|
||||
var column = columns[selectedColumnIndex];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandColumnForKeyboard(column);
|
||||
scrollBrowserColumnIntoView(column);
|
||||
|
||||
if (!selectedLink || !column.contains(selectedLink)) {
|
||||
selectedLink = column.querySelector("a.is-active[href]") || column.querySelector("a[href]");
|
||||
}
|
||||
if (!selectedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLink.classList.add("is-keyboard-selected");
|
||||
selectedLink.setAttribute("aria-selected", "true");
|
||||
if (focusSelected) {
|
||||
selectedLink.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function selectBrowserColumn(columnIndex, focusSelected) {
|
||||
var columns = getColumns();
|
||||
if (!columns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
mode = "browser";
|
||||
selectedColumnIndex = clamp(columnIndex, 0, columns.length - 1);
|
||||
var links = getColumnLinks(selectedColumnIndex);
|
||||
selectedLink = links.find(function (link) {
|
||||
return link.classList.contains("is-active");
|
||||
}) || links[0] || null;
|
||||
clearParagraphSelection();
|
||||
updateBrowserSelection(focusSelected);
|
||||
}
|
||||
|
||||
function moveBrowserVertical(delta) {
|
||||
var links = getColumnLinks(selectedColumnIndex);
|
||||
if (!links.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentIndex = links.indexOf(selectedLink);
|
||||
if (currentIndex < 0) {
|
||||
currentIndex = links.findIndex(function (link) {
|
||||
return link.classList.contains("is-active");
|
||||
});
|
||||
}
|
||||
if (currentIndex < 0) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
selectedLink = links[clamp(currentIndex + delta, 0, links.length - 1)];
|
||||
updateBrowserSelection(true);
|
||||
}
|
||||
|
||||
function navigateTo(link) {
|
||||
if (!link || !link.href) {
|
||||
return;
|
||||
}
|
||||
window.location.href = link.href;
|
||||
}
|
||||
|
||||
function moveBrowserRight() {
|
||||
var columns = getColumns();
|
||||
if (!columns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedColumnIndex < columns.length - 1 && selectedLink && selectedLink.classList.contains("is-active")) {
|
||||
selectBrowserColumn(selectedColumnIndex + 1, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedColumnIndex < columns.length - 1 && !selectedLink) {
|
||||
selectBrowserColumn(selectedColumnIndex + 1, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedLink && !selectedLink.classList.contains("is-active")) {
|
||||
navigateTo(selectedLink);
|
||||
return;
|
||||
}
|
||||
|
||||
if (markdownBody && getParagraphTargets().length) {
|
||||
enterParagraphMode(0);
|
||||
} else if (selectedLink) {
|
||||
navigateTo(selectedLink);
|
||||
}
|
||||
}
|
||||
|
||||
function nearestParagraphIndex() {
|
||||
var targets = getParagraphTargets();
|
||||
if (!targets.length) {
|
||||
return -1;
|
||||
}
|
||||
if (selectedParagraphIndex >= 0 && selectedParagraphIndex < targets.length) {
|
||||
return selectedParagraphIndex;
|
||||
}
|
||||
|
||||
var shellRect = documentShell ? documentShell.getBoundingClientRect() : null;
|
||||
var targetY = shellRect ? shellRect.top + shellRect.height * 0.35 : window.innerHeight * 0.35;
|
||||
var bestIndex = 0;
|
||||
var bestDistance = Number.POSITIVE_INFINITY;
|
||||
targets.forEach(function (el, index) {
|
||||
var distance = Math.abs(el.getBoundingClientRect().top - targetY);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestIndex = index;
|
||||
}
|
||||
});
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
function selectParagraph(index) {
|
||||
var targets = getParagraphTargets();
|
||||
if (!targets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearBrowserSelection();
|
||||
clearParagraphSelection();
|
||||
mode = "paragraph";
|
||||
selectedParagraphIndex = clamp(index, 0, targets.length - 1);
|
||||
|
||||
var target = targets[selectedParagraphIndex];
|
||||
target.classList.add("is-keyboard-selected");
|
||||
target.setAttribute("aria-selected", "true");
|
||||
target.setAttribute("tabindex", "-1");
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function enterParagraphMode(offset) {
|
||||
var index = nearestParagraphIndex();
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
selectParagraph(index + offset);
|
||||
}
|
||||
|
||||
function moveParagraphVertical(delta) {
|
||||
var targets = getParagraphTargets();
|
||||
if (!targets.length) {
|
||||
return;
|
||||
}
|
||||
var currentIndex = selectedParagraphIndex;
|
||||
if (currentIndex < 0 || currentIndex >= targets.length) {
|
||||
currentIndex = nearestParagraphIndex();
|
||||
}
|
||||
selectParagraph(currentIndex + delta);
|
||||
}
|
||||
|
||||
function openSelectedParagraphInEditor() {
|
||||
var editLink = document.querySelector(".document-actions-dropdown__item[href$='/edit']");
|
||||
if (editLink) {
|
||||
navigateTo(editLink);
|
||||
return;
|
||||
}
|
||||
var path = documentShell ? documentShell.getAttribute("data-document-path") : "";
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.location.href = "/docs/" + path.replace(/\.md$/, "") + "/edit";
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
var search = document.querySelector(".site-search input[type='search'], .site-search input[name='q']");
|
||||
if (!search) {
|
||||
return;
|
||||
}
|
||||
clearBrowserSelection();
|
||||
clearParagraphSelection();
|
||||
search.focus();
|
||||
search.select();
|
||||
}
|
||||
|
||||
function focusCommentsSidebar() {
|
||||
var desktopToggle = document.querySelector("[data-comment-toggle]");
|
||||
if (desktopToggle && desktopToggle.getAttribute("aria-pressed") === "false") {
|
||||
desktopToggle.click();
|
||||
}
|
||||
|
||||
var mobileToggle = document.querySelector("[data-toggle-comments]");
|
||||
if (mobileToggle && window.matchMedia("(max-width: 760px)").matches && mobileToggle.getAttribute("aria-expanded") === "false") {
|
||||
mobileToggle.click();
|
||||
}
|
||||
|
||||
var focusTarget = document.querySelector("[data-comments-aside-list] .comments-aside-item") ||
|
||||
document.querySelector("[data-comments-aside-list]") ||
|
||||
document.querySelector("[data-comments-aside]");
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearBrowserSelection();
|
||||
clearParagraphSelection();
|
||||
focusTarget.setAttribute("tabindex", "-1");
|
||||
focusTarget.focus({ preventScroll: false });
|
||||
}
|
||||
|
||||
function focusDocumentActions() {
|
||||
var dropdown = document.querySelector(".document-actions-dropdown");
|
||||
if (!dropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.open = true;
|
||||
var focusTarget = dropdown.querySelector(".document-actions-dropdown__item[href], .document-actions-dropdown__item:not([disabled])") ||
|
||||
dropdown.querySelector("summary");
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearBrowserSelection();
|
||||
clearParagraphSelection();
|
||||
focusTarget.focus();
|
||||
}
|
||||
|
||||
function closeDocumentActions() {
|
||||
var dropdown = document.querySelector(".document-actions-dropdown[open]");
|
||||
if (dropdown) {
|
||||
dropdown.open = false;
|
||||
var summary = dropdown.querySelector("summary");
|
||||
if (summary) {
|
||||
summary.focus();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
if (mode === "paragraph") {
|
||||
openSelectedParagraphInEditor();
|
||||
return;
|
||||
}
|
||||
navigateTo(selectedLink);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || shouldIgnoreTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var key = event.key;
|
||||
var normalized = key.length === 1 ? key.toLowerCase() : key;
|
||||
|
||||
if (event.code === "Backslash" || key === "\\") {
|
||||
event.preventDefault();
|
||||
focusSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized === "c") {
|
||||
event.preventDefault();
|
||||
focusCommentsSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized === "e") {
|
||||
event.preventDefault();
|
||||
focusDocumentActions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Escape" && closeDocumentActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowUp" || normalized === "k") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
if (mode === "paragraph") {
|
||||
moveParagraphVertical(-1);
|
||||
} else {
|
||||
moveBrowserVertical(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || normalized === "j") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
if (mode === "paragraph") {
|
||||
moveParagraphVertical(1);
|
||||
} else {
|
||||
moveBrowserVertical(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowLeft" || normalized === "h") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
if (mode === "paragraph") {
|
||||
selectBrowserColumn(getColumns().length - 1, true);
|
||||
} else {
|
||||
selectBrowserColumn(selectedColumnIndex - 1, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowRight" || normalized === "l") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
if (mode === "paragraph") {
|
||||
return;
|
||||
}
|
||||
moveBrowserRight();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
handleEnter();
|
||||
}
|
||||
}
|
||||
|
||||
function initSelection() {
|
||||
var columns = getColumns();
|
||||
if (!columns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var activeLinks = Array.prototype.slice.call(browser.querySelectorAll("a.is-active[href]"));
|
||||
var activeLink = activeLinks[activeLinks.length - 1] || browser.querySelector("a[href]");
|
||||
selectedLink = activeLink;
|
||||
if (activeLink) {
|
||||
var activeColumn = activeLink.closest(".miller-column");
|
||||
selectedColumnIndex = Math.max(0, columns.indexOf(activeColumn));
|
||||
}
|
||||
}
|
||||
|
||||
initSelection();
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
})();
|
||||
Reference in New Issue
Block a user