summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel/megalist/content/MegalistView.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:50 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:50 +0000
commitdef92d1b8e9d373e2f6f27c366d578d97d8960c6 (patch)
tree2ef34b9ad8bb9a9220e05d60352558b15f513894 /toolkit/components/satchel/megalist/content/MegalistView.mjs
parentAdding debian version 125.0.3-1. (diff)
downloadfirefox-def92d1b8e9d373e2f6f27c366d578d97d8960c6.tar.xz
firefox-def92d1b8e9d373e2f6f27c366d578d97d8960c6.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/satchel/megalist/content/MegalistView.mjs')
-rw-r--r--toolkit/components/satchel/megalist/content/MegalistView.mjs477
1 files changed, 477 insertions, 0 deletions
diff --git a/toolkit/components/satchel/megalist/content/MegalistView.mjs b/toolkit/components/satchel/megalist/content/MegalistView.mjs
new file mode 100644
index 0000000000..44a0198692
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/MegalistView.mjs
@@ -0,0 +1,477 @@
+/* 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";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/megalist/VirtualizedList.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/megalist/search-input.mjs";
+
+/**
+ * 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 = "";
+
+ window.addEventListener("MessageFromViewModel", ev =>
+ this.#onMessageFromViewModel(ev)
+ );
+ }
+
+ static get properties() {
+ return {
+ listLength: { type: Number },
+ selectedIndex: { type: Number },
+ searchText: { type: String },
+ };
+ }
+
+ /**
+ * 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 = {};
+
+ 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 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();
+ }
+
+ #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 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 popup = this.ownerDocument.createElement("div");
+ popup.className = "menuPopup";
+ 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":
+ popup.remove();
+ 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")
+ ) {
+ // TODO: this triggers on macOS before "click" event. Due to this,
+ // we are not receiving the command.
+ popup.remove();
+ }
+ },
+ { capture: true }
+ );
+
+ 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.textContent = command.label;
+ menuItem.addEventListener("click", e => {
+ this.#messageToViewModel("Command", {
+ snapshotId,
+ commandId: command.id,
+ });
+ popup.remove();
+ e.preventDefault();
+ });
+ popup.appendChild(menuItem);
+ }
+
+ menuButton.querySelector("button").after(popup);
+ popup.querySelector("button")?.focus();
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://global/content/megalist/megalist.css"
+ />
+ <div class="container">
+ <search-input
+ .value=${this.searchText}
+ .change=${e => this.#handleInputChange(e)}
+ >
+ </search-input>
+ <virtualized-list
+ .lineCount=${this.listLength}
+ .lineHeight=${MegalistView.LINE_HEIGHT}
+ .selectedIndex=${this.selectedIndex}
+ .createLineElement=${index => this.createLineElement(index)}
+ @click=${e => this.#handleClick(e)}
+ >
+ </virtualized-list>
+ </div>
+ `;
+ }
+}
+
+customElements.define("megalist-view", MegalistView);