Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { html } from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
import {
createDialog,
cancelDialog,
} from "chrome://global/content/megalist/Dialog.mjs";
// eslint-disable-next-line import/no-unassigned-import
/**
* Map with limit on how many entries it can have.
* When over limit entries are added, oldest one are removed.
*/
class MostRecentMap {
constructor(maxSize) {
this.#maxSize = maxSize;
}
get(id) {
const data = this.#map.get(id);
if (data) {
this.#keepAlive(id, data);
}
return data;
}
has(id) {
this.#map.has(id);
}
set(id, data) {
this.#keepAlive(id, data);
this.#enforceLimits();
}
clear() {
this.#map.clear();
}
#maxSize;
#map = new Map();
#keepAlive(id, data) {
// Re-insert data to the map so it will be less likely to be evicted
this.#map.delete(id);
this.#map.set(id, data);
}
#enforceLimits() {
// Maps preserve order in which data was inserted,
// we use that fact to remove oldest data from it.
while (this.#map.size > this.#maxSize) {
this.#map.delete(this.#map.keys().next().value);
}
}
}
/**
* MegalistView presents data pushed to it by the MegalistViewModel and
* notify MegalistViewModel of user commands.
*/
export class MegalistView extends MozLitElement {
static keyToMessage = {
ArrowUp: "SelectPreviousSnapshot",
ArrowDown: "SelectNextSnapshot",
PageUp: "SelectPreviousGroup",
PageDown: "SelectNextGroup",
Escape: "UpdateFilter",
};
static LINE_HEIGHT = 64;
constructor() {
super();
this.selectedIndex = 0;
this.searchText = "";
this.layout = null;
window.addEventListener("MessageFromViewModel", ev =>
this.#onMessageFromViewModel(ev)
);
}
static get properties() {
return {
listLength: { type: Number },
selectedIndex: { type: Number },
searchText: { type: String },
layout: { type: Object },
};
}
/**
* View shows list of snapshots of lines stored in the View Model.
* View Model provides the first snapshot id in the list and list length.
* It's safe to combine firstSnapshotId+index to identify specific snapshot
* in the list. When the list changes, View Model will provide a new
* list with new first snapshot id (even if the content is the same).
*/
#firstSnapshotId = 0;
/**
* Cache 120 most recently used lines.
* View lives in child and View Model in parent processes.
* By caching a few lines we reduce the need to send data between processes.
* This improves performance in nearby scrolling scenarios.
* 7680 is 8K vertical screen resolution.
* Typical line is under 1/4KB long, making around 30KB cache requirement.
*/
#snapshotById = new MostRecentMap(7680 / MegalistView.LINE_HEIGHT);
#templates = {};
static queries = {
searchInput: ".search",
};
connectedCallback() {
super.connectedCallback();
this.ownerDocument.addEventListener("keydown", e => this.#handleKeydown(e));
for (const template of this.ownerDocument.getElementsByTagName(
"template"
)) {
this.#templates[template.id] = template.content.firstElementChild;
}
this.#messageToViewModel("Refresh");
}
createLineElement(index) {
if (index < 0 || index >= this.listLength) {
return null;
}
const snapshotId = this.#firstSnapshotId + index;
const lineElement = this.#templates.lineElement.cloneNode(true);
lineElement.dataset.id = snapshotId;
lineElement.addEventListener("dblclick", e => {
this.#messageToViewModel("Command");
e.preventDefault();
});
const data = this.#snapshotById.get(snapshotId);
if (data !== "Loading") {
if (data) {
this.#applyData(snapshotId, data, lineElement);
} else {
// Put placeholder for this snapshot data to avoid requesting it again
this.#snapshotById.set(snapshotId, "Loading");
// Ask for snapshot data from the View Model.
// Note: we could have optimized it further by asking for a range of
// indices because any scroll in virtualized list can only add
// a continuous range at the top or bottom of the visible area.
// However, this optimization is not necessary at the moment as
// we typically will request under a 100 of lines at a time.
// If we feel like making this improvement, we need to enhance
// VirtualizedList to request a range of new elements instead.
this.#messageToViewModel("RequestSnapshot", { snapshotId });
}
}
return lineElement;
}
/**
* Find snapshot element on screen and populate it with data
*/
receiveSnapshot({ snapshotId, snapshot }) {
this.#snapshotById.set(snapshotId, snapshot);
const lineElement = this.shadowRoot.querySelector(
`.line[data-id="${snapshotId}"]`
);
if (lineElement) {
this.#applyData(snapshotId, snapshot, lineElement);
}
}
#applyData(snapshotId, snapshotData, lineElement) {
let elementToFocus;
const template =
this.#templates[snapshotData.template] ?? this.#templates.lineTemplate;
const lineContent = template.cloneNode(true);
lineContent.querySelector(".label").textContent = snapshotData.label;
const toggleButton = lineContent.querySelector(".icon");
if (toggleButton) {
toggleButton.setAttribute("title", snapshotData.toggleTooltip);
}
const valueElement = lineContent.querySelector(".value");
if (valueElement) {
const valueText = lineContent.querySelector("span");
if (valueText) {
valueText.textContent = snapshotData.value;
} else {
const valueInput = lineContent.querySelector("input");
if (valueInput) {
valueInput.value = snapshotData.value;
valueInput.addEventListener("keydown", e => {
switch (e.code) {
case "Enter":
this.#messageToViewModel("Command", {
snapshotId,
commandId: "Save",
value: valueInput.value,
});
break;
case "Escape":
this.#messageToViewModel("Command", {
snapshotId,
commandId: "Cancel",
});
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
});
valueInput.addEventListener("input", () => {
// Update local cache so we don't override editing value
// while user scrolls up or down a little.
const snapshotDataInChild = this.#snapshotById.get(snapshotId);
if (snapshotDataInChild) {
snapshotDataInChild.value = valueInput.value;
}
this.#messageToViewModel("Command", {
snapshotId,
commandId: "EditInProgress",
value: valueInput.value,
});
});
elementToFocus = valueInput;
} else {
valueElement.textContent = snapshotData.value;
}
}
if (snapshotData.valueIcon) {
const valueIcon = valueElement.querySelector(".icon");
if (valueIcon) {
valueIcon.src = snapshotData.valueIcon;
}
}
if (snapshotData.href) {
const linkElement = this.ownerDocument.createElement("a");
linkElement.className = valueElement.className;
linkElement.href = snapshotData.href;
linkElement.replaceChildren(...valueElement.children);
valueElement.replaceWith(linkElement);
}
if (snapshotData.stickers?.length) {
const stickersElement = lineContent.querySelector(".stickers");
for (const sticker of snapshotData.stickers) {
const stickerElement = this.ownerDocument.createElement("span");
stickerElement.textContent = sticker.label;
stickerElement.className = sticker.type;
stickersElement.appendChild(stickerElement);
}
}
}
lineElement.querySelector(".content").replaceWith(lineContent);
lineElement.classList.toggle("start", !!snapshotData.start);
lineElement.classList.toggle("end", !!snapshotData.end);
elementToFocus?.focus();
}
#messageToViewModel(messageName, data) {
window.windowGlobalChild
.getActor("Megalist")
.sendAsyncMessage(messageName, data);
}
#onMessageFromViewModel({ detail }) {
const functionName = `receive${detail.name}`;
if (!(functionName in this)) {
throw new Error(`Received unknown message "${detail.name}"`);
}
this[functionName](detail.data);
}
receiveUpdateSelection({ selectedIndex }) {
this.selectedIndex = selectedIndex;
}
receiveShowSnapshots({ firstSnapshotId, count }) {
this.#firstSnapshotId = firstSnapshotId;
this.listLength = count;
// Each new display list starts with the new first snapshot id
// so we can forget previously known data.
this.#snapshotById.clear();
this.shadowRoot.querySelector("virtualized-list").requestRefresh();
this.requestUpdate();
}
receiveMegalistUpdateFilter({ searchText }) {
this.searchText = searchText;
this.requestUpdate();
}
receiveSetLayout({ layout }) {
if (layout) {
createDialog(layout, commandId =>
this.#messageToViewModel("Command", { commandId })
);
} else {
cancelDialog();
}
}
#handleInputChange(e) {
const searchText = e.target.value;
this.#messageToViewModel("UpdateFilter", { searchText });
}
#handleKeydown(e) {
const message = MegalistView.keyToMessage[e.code];
if (message) {
this.#messageToViewModel(message);
e.preventDefault();
} else if (e.code == "Enter") {
// Do not handle Enter at the virtualized list level when line menu is open
if (
this.shadowRoot.querySelector(
".line.selected > .menuButton > .menuPopup"
)
) {
return;
}
if (e.altKey) {
// Execute default command1
this.#messageToViewModel("Command");
} else {
// Show line level menu
this.shadowRoot
.querySelector(".line.selected > .menuButton > button")
?.click();
}
e.preventDefault();
} else if (e.ctrlKey && e.key == "c" && !this.searchText.length) {
this.#messageToViewModel("Command", { commandId: "Copy" });
e.preventDefault();
}
}
#handleClick(e) {
const elementWithCommand = e.composedTarget.closest("[data-command]");
if (elementWithCommand) {
const commandId = elementWithCommand.dataset.command;
if (commandId) {
this.#messageToViewModel("Command", { commandId });
return;
}
}
const lineElement = e.composedTarget.closest(".line");
if (!lineElement) {
return;
}
const snapshotId = Number(lineElement.dataset.id);
const snapshotData = this.#snapshotById.get(snapshotId);
if (!snapshotData) {
return;
}
this.#messageToViewModel("SelectSnapshot", { snapshotId });
const menuButton = e.composedTarget.closest(".menuButton");
if (menuButton) {
this.#handleMenuButtonClick(menuButton, snapshotId, snapshotData);
}
e.preventDefault();
}
#handleMenuButtonClick(menuButton, snapshotId, snapshotData) {
if (!snapshotData.commands?.length) {
return;
}
const button = menuButton.querySelector("button");
const popup = this.ownerDocument.createElement("div");
popup.setAttribute("role", "menu");
popup.setAttribute("data-l10n-id", "more-options-popup");
popup.className = "menuPopup";
let closeMenu = () => {
button.setAttribute("aria-expanded", "false");
popup.remove();
this.searchInput.focus();
};
popup.addEventListener(
"keydown",
e => {
function focusInternal(next, wrapSelector) {
let element = e.composedTarget;
do {
element = element[next];
} while (element && element.tagName != "BUTTON");
// If we can't find next/prev button, focus the first/last one
element ??=
e.composedTarget.parentElement.querySelector(wrapSelector);
element?.focus();
}
function focusNext() {
focusInternal("nextElementSibling", "button");
}
function focusPrev() {
focusInternal("previousElementSibling", "button:last-of-type");
}
switch (e.code) {
case "Escape":
closeMenu();
break;
case "Tab":
if (e.shiftKey) {
focusPrev();
} else {
focusNext();
}
break;
case "ArrowUp":
focusPrev();
break;
case "ArrowDown":
focusNext();
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
},
{ capture: true }
);
popup.addEventListener(
"blur",
e => {
if (
e.composedTarget?.closest(".menuPopup") !=
e.relatedTarget?.closest(".menuPopup")
) {
closeMenu();
}
},
{ capture: true }
);
popup.addEventListener("mousedown", e => {
e.preventDefault();
if (e.target.classList != "menuItem") {
closeMenu();
}
});
for (const command of snapshotData.commands) {
if (command == "-") {
const separator = this.ownerDocument.createElement("div");
separator.className = "separator";
popup.appendChild(separator);
continue;
}
const menuItem = this.ownerDocument.createElement("button");
menuItem.setAttribute("role", "menuitem");
menuItem.classList.add("menuItem");
menuItem.setAttribute("data-l10n-id", command.label);
menuItem.addEventListener("click", e => {
this.#messageToViewModel("Command", {
snapshotId,
commandId: command.id,
});
button.setAttribute("aria-expanded", "false");
popup.remove();
e.preventDefault();
});
popup.appendChild(menuItem);
}
button.setAttribute("aria-expanded", "true");
button.after(popup);
popup.querySelector("button")?.focus();
}
/**
* Renders data-source specific UI that should be displayed before the
* virtualized list. This is determined by the "SetLayout" message provided
* by the View Model. Defaults to displaying the search input.
*/
renderBeforeList() {
return html`
<div class="searchContainer" @click=${() => this.searchInput.select()}>
<div class="searchIcon"></div>
<input
class="search"
type="search"
data-l10n-id="filter-input"
.value=${this.searchText}
@input=${e => this.#handleInputChange(e)}
/>
</div>
`;
}
renderList() {
if (this.layout) {
return null;
}
return html` <virtualized-list
.lineCount=${this.listLength}
.lineHeight=${MegalistView.LINE_HEIGHT}
.selectedIndex=${this.selectedIndex}
.createLineElement=${index => this.createLineElement(index)}
>
</virtualized-list>`;
}
renderAfterList() {}
render() {
return html`
<link
rel="stylesheet"
href="chrome://global/content/megalist/megalist.css"
/>
<div @click=${this.#handleClick} class="container">
<div class="beforeList">${this.renderBeforeList()}</div>
${this.renderList()}
<div class="afterList">${this.renderAfterList()}</div>
</div>
`;
}
}
customElements.define("megalist-view", MegalistView);