diff --git a/src/commands.js b/src/commands.js
new file mode 100644
index 00000000..6b0bbfca
--- /dev/null
+++ b/src/commands.js
@@ -0,0 +1,566 @@
+import { library, icon } from "@fortawesome/fontawesome-svg-core";
+import {
+ faCircleXmark,
+ faMagnifyingGlass,
+ faCircleNotch,
+ faBinoculars,
+ faBarsStaggered,
+} from "@fortawesome/free-solid-svg-icons";
+import READTHEDOCS_LOGO from "./images/logo-wordmark-dark.svg";
+
+import styleSheet from "./search.css";
+import { AddonBase, addUtmParameters } from "./utils";
+import {
+ EVENT_READTHEDOCS_COMMANDS_SHOW,
+ EVENT_READTHEDOCS_COMMANDS_HIDE,
+ EVENT_READTHEDOCS_DOCDIFF_ADDED_REMOVED_SHOW,
+ EVENT_READTHEDOCS_DOCDIFF_HIDE,
+ EVENT_READTHEDOCS_SEARCH_SHOW,
+ EVENT_READTHEDOCS_SEARCH_HIDE,
+ EVENT_READTHEDOCS_FLYOUT_SHOW,
+ EVENT_READTHEDOCS_FLYOUT_HIDE,
+} from "./events";
+import { html, nothing, LitElement } from "lit";
+import { classMap } from "lit/directives/class-map.js";
+
+const MIN_CHARACTERS_QUERY = 3;
+
+export class CommandPaletteElement extends LitElement {
+ static elementName = "readthedocs-commands";
+
+ static properties = {
+ config: {
+ state: true,
+ },
+ show: { state: true },
+ inputIcon: { state: true },
+ commands: { state: true },
+ cssFormFocusClasses: { state: true },
+ triggerKeycode: { type: Number, attribute: "trigger-keycode" },
+ triggerSelector: { type: String, attribute: "trigger-selector" },
+ triggerEvent: { type: String, attribute: "trigger-event" },
+ htmlResults: { state: true },
+ };
+
+ static styles = styleSheet;
+
+ constructor() {
+ super();
+
+ library.add(faMagnifyingGlass);
+ library.add(faCircleNotch);
+ library.add(faBarsStaggered);
+ library.add(faCircleXmark);
+
+ this.config = null;
+ this.show = false;
+ this.cssFormFocusClasses = {};
+ this.commands = [];
+ this.inputIcon = icon(faMagnifyingGlass, { title: "Search commands" });
+ this.currentQueryRequest = null;
+ this.triggerKeycode = 191;
+ this.triggerSelector = null;
+ this.triggerEvent = "focusin";
+ this.htmlResults = null;
+ }
+
+ loadConfig(config) {
+ if (!CommandsAddon.isEnabled(config)) {
+ return;
+ }
+
+ this.config = config;
+ this.commands = this.createCommands(config);
+ console.log("Command palette config loaded", this.commands);
+ }
+
+ /**
+ * Create commands based on the provided configuration.
+ *
+ * Each command object has the following structure:
+ * {
+ * title: string, // The title of the command
+ * href: string, // The URL to navigate to when the command is executed
+ * action: function, // (Optional) A function to execute when the command is selected
+ * sections: {
+ * id: string, // The unique identifier of the section
+ * title: string, // The title of the section
+ * }
+ * }
+ *
+ * @param {Object} config - The configuration object
+ * @returns {Array} - An array of command objects
+ */
+ createCommands(config) {
+ const commands = [
+ {
+ title: "Show DocDiff",
+ href: "#",
+ sections: [],
+ action: () => {
+ const event = new CustomEvent(
+ EVENT_READTHEDOCS_DOCDIFF_ADDED_REMOVED_SHOW,
+ );
+ document.dispatchEvent(event);
+ },
+ },
+ {
+ title: "Hide DocDiff",
+ href: "#",
+ sections: [],
+ action: () => {
+ const event = new CustomEvent(EVENT_READTHEDOCS_DOCDIFF_HIDE);
+ document.dispatchEvent(event);
+ },
+ },
+ {
+ title: "Show Search",
+ href: "#",
+ sections: [],
+ action: () => {
+ const event = new CustomEvent(EVENT_READTHEDOCS_SEARCH_SHOW);
+ document.dispatchEvent(event);
+ },
+ },
+ {
+ title: "Hide Search",
+ href: "#",
+ sections: [],
+ action: () => {
+ const event = new CustomEvent(EVENT_READTHEDOCS_SEARCH_HIDE);
+ document.dispatchEvent(event);
+ },
+ },
+ {
+ title: "Show Flyout",
+ href: "#",
+ sections: [],
+ action: () => {
+ const event = new CustomEvent(EVENT_READTHEDOCS_FLYOUT_SHOW);
+ document.dispatchEvent(event);
+ },
+ },
+ {
+ title: "Hide Flyout",
+ href: "#",
+ sections: [],
+ action: () => {
+ const event = new CustomEvent(EVENT_READTHEDOCS_FLYOUT_HIDE);
+ document.dispatchEvent(event);
+ },
+ },
+ ];
+
+ if (config.projects && config.projects.current) {
+ commands.push(
+ {
+ title: "Project Home",
+ href: config.projects.current.urls.home,
+ sections: [],
+ },
+ {
+ title: "Project Builds",
+ href: config.projects.current.urls.builds,
+ sections: [],
+ },
+ {
+ title: "GitHub",
+ href: config.projects.current.repository.url,
+ sections: [],
+ },
+ );
+ }
+
+ if (config.builds && config.builds.current) {
+ commands.push({
+ title: "Current Build Log",
+ href: config.builds.current.urls.build,
+ sections: [],
+ });
+ }
+
+ if (
+ config.addons &&
+ config.addons.filesections &&
+ config.addons.filesections.pages
+ ) {
+ commands.push(
+ ...config.addons.filesections.pages.map((page) => ({
+ title: page.path,
+ href: page.path,
+ sections: page.sections,
+ })),
+ );
+ }
+
+ return commands;
+ }
+
+ firstUpdated() {
+ this.className = this.className || "raised floating";
+ }
+
+ render() {
+ if (this.config === null) {
+ console.log("Config is null, rendering nothing");
+ return nothing;
+ }
+ console.log("Rendering command palette");
+ return this.renderCommandPalette();
+ }
+
+ renderCommandPalette() {
+ console.log("Rendering command palette modal");
+ return html`
+
+
+
+
+
${this.htmlResults || nothing}
+
+
+
+ `;
+ }
+
+ renderNoResultsFound() {
+ console.log("Rendering no results found");
+ const binoculars = icon(faBinoculars, {
+ title: "Not found",
+ });
+ const query = this.getUserQuery();
+ this.htmlResults = html`
+
+ ${binoculars.node[0]}
+
No results for "${query}"
+
+ `;
+ }
+
+ renderResults(data) {
+ console.log("Rendering results", data);
+ this.htmlResults = html`
+
+ ${data.results.map(
+ (result, rindex) =>
+ html`
`,
+ )}
+
+ `;
+ }
+
+ handleCommandClick(e, result) {
+ e.preventDefault();
+ if (result.action) {
+ result.action(this.config);
+ } else {
+ window.location.href = result.href;
+ }
+ this.triggerClosePalette();
+ }
+
+ renderSectionResult(section, index, result) {
+ console.log("Rendering section result", section, result);
+ let title = section.title;
+
+ return html`
+
+
+
+ `;
+ }
+
+ getUserQuery() {
+ const query = this.renderRoot.querySelector("input[type=search]").value;
+ console.log("User query:", query);
+ return query;
+ }
+
+ showSpinIcon() {
+ console.log("Showing spin icon");
+ if (this.inputIcon.iconName !== "circle-notch") {
+ this.inputIcon = icon(faCircleNotch, {
+ title: "Spinner",
+ classes: ["spinner", "fa-spin"],
+ });
+ }
+ }
+
+ showMagnifierIcon() {
+ console.log("Showing magnifier icon");
+ this.inputIcon = icon(faMagnifyingGlass, { title: "Search" });
+ }
+
+ removeAllResults() {
+ console.log("Removing all results");
+ this.htmlResults = null;
+ }
+
+ fetchResults(query) {
+ console.log("Fetching results for query:", query);
+ this.htmlResults = null;
+ this.showSpinIcon();
+
+ // Ensure results is an array
+ const results = this.commands || [];
+
+ // Convert query to lowercase for case insensitive comparison
+ const lowerCaseQuery = query.toLowerCase();
+
+ // Simulate fetching results from in-memory data with fuzzy searching
+ const filteredResults = results.filter((result) => {
+ const pathMatch = result.title.toLowerCase().includes(lowerCaseQuery);
+ const sectionMatch = result.sections.some((section) =>
+ section.title.toLowerCase().includes(lowerCaseQuery),
+ );
+ return pathMatch || sectionMatch;
+ });
+
+ if (filteredResults.length > 0) {
+ this.renderResults({ results: filteredResults });
+ } else {
+ this.renderNoResultsFound();
+ }
+ this.showMagnifierIcon();
+ }
+
+ queryInput(e) {
+ const query = this.getUserQuery();
+ console.log("Query input:", query);
+ if (query.length >= MIN_CHARACTERS_QUERY) {
+ this.fetchResults(query);
+ } else {
+ this.removeAllResults();
+ }
+ }
+
+ queryInputFocus(e) {
+ if (e.type === "focusin") {
+ this.cssFormFocusClasses = {
+ focus: true,
+ };
+ } else if (e.type === "focusout") {
+ this.cssFormFocusClasses = {
+ focus: false,
+ };
+ }
+ }
+
+ selectResultKeyboard(e) {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ this.selectNextResult(true);
+ }
+
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ this.selectNextResult(false);
+ }
+
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const selected = this.renderRoot.querySelector("a.hit.active");
+ // if an item is selected, then redirect to its link or execute its action
+ if (selected !== null) {
+ const command = this.commands.find(
+ (cmd) => cmd.title === selected.textContent.trim(),
+ );
+ if (command && command.action) {
+ command.action();
+ } else {
+ window.location.href = selected.href;
+ }
+ }
+ }
+
+ if (e.key === "Escape") {
+ e.preventDefault();
+ this.triggerClosePalette();
+ }
+ }
+
+ selectNextResult(forward) {
+ const all = this.renderRoot.querySelectorAll("a.hit");
+ let selected;
+ let selectedIndex;
+ for (const [index, element] of all.entries()) {
+ if (element.classList.contains("active")) {
+ selected = element;
+ selectedIndex = index;
+ break;
+ }
+ }
+
+ const lastIndex = all.length > 0 ? all.length - 1 : 0;
+
+ let nextIndex = 0;
+ if (selectedIndex !== undefined) {
+ nextIndex = forward ? selectedIndex + 1 : selectedIndex - 1;
+ }
+
+ // Check if we're at the end/start of the list, and adjust accordingly
+ if (nextIndex > lastIndex) {
+ nextIndex = 0;
+ } else if (nextIndex < 0) {
+ nextIndex = lastIndex;
+ }
+
+ // Remove all active elements
+ for (const active of this.renderRoot.querySelectorAll("a.hit.active")) {
+ active.classList.remove("active");
+ }
+
+ // Add class for active element and scroll to it
+ const newActive = all[nextIndex];
+ newActive.classList.add("active");
+ newActive.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ inline: "start",
+ });
+ }
+
+ mouseenterResultHit(e) {
+ console.log("Mouse enter result hit");
+ const activeElements = this.renderRoot.querySelectorAll("a.hit.active");
+ for (const element of activeElements) {
+ element.classList.remove("active");
+ }
+ e.currentTarget.classList.add("active");
+ }
+
+ triggerClosePalette() {
+ console.log("Triggering close palette");
+ const event = new CustomEvent(EVENT_READTHEDOCS_COMMANDS_HIDE);
+ document.dispatchEvent(event);
+ }
+
+ showPalette() {
+ console.log("Showing palette");
+ this.show = true;
+ this.updateComplete.then(() => {
+ const input = this.renderRoot.querySelector("input[type=search]");
+ if (input) {
+ input.focus();
+ }
+ });
+ }
+
+ closePalette() {
+ console.log("Closing palette");
+ this.show = false;
+ }
+
+ _handleClosePalette = (e) => {
+ e.preventDefault();
+ this.closePalette();
+ };
+
+ _handleShowPalette = (e) => {
+ e.preventDefault();
+ this.showPalette();
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+
+ document.addEventListener(
+ EVENT_READTHEDOCS_COMMANDS_SHOW,
+ this._handleShowPalette,
+ );
+ document.addEventListener(
+ EVENT_READTHEDOCS_COMMANDS_HIDE,
+ this._handleClosePalette,
+ );
+ }
+
+ disconnectedCallback() {
+ document.removeEventListener(
+ EVENT_READTHEDOCS_COMMANDS_SHOW,
+ this._handleShowPalette,
+ );
+ document.removeEventListener(
+ EVENT_READTHEDOCS_COMMANDS_HIDE,
+ this._handleClosePalette,
+ );
+
+ super.disconnectedCallback();
+ }
+}
+
+export class CommandsAddon extends AddonBase {
+ static jsonValidationURI =
+ "http://v1.schemas.readthedocs.org/addons.commands.json";
+ static addonEnabledPath = "addons.filesections.enabled";
+ static addonName = "Commands";
+ static enabledOnHttpStatus = [200, 404];
+
+ constructor(config) {
+ super();
+
+ let elems = document.querySelectorAll("readthedocs-commands");
+ if (!elems.length) {
+ elems = [new CommandPaletteElement()];
+
+ document.body.append(elems[0]);
+ elems[0].requestUpdate();
+ }
+
+ for (const elem of elems) {
+ elem.loadConfig(config);
+ }
+ }
+}
+
+customElements.define("readthedocs-commands", CommandPaletteElement);
diff --git a/src/data-validation.js b/src/data-validation.js
index 97925c4b..0b3c5487 100644
--- a/src/data-validation.js
+++ b/src/data-validation.js
@@ -274,6 +274,48 @@ const addons_filetreediff = {
},
},
};
+// Validator for FileSections Addon
+const addons_filesections = {
+ $id: "http://v1.schemas.readthedocs.org/addons.commands.json",
+ type: "object",
+ required: ["addons"],
+ properties: {
+ addons: {
+ type: "object",
+ required: ["filesections"],
+ properties: {
+ filesections: {
+ type: "object",
+ required: ["enabled", "pages"],
+ properties: {
+ enabled: { type: "boolean" },
+ sections: {
+ type: "array",
+ items: {
+ type: "object",
+ required: ["path", "sections"],
+ properties: {
+ path: { type: "string" },
+ sections: {
+ type: "array",
+ items: {
+ type: "object",
+ required: ["id", "title"],
+ properties: {
+ id: { type: "string" },
+ title: { type: "string" },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
// Validator for Hotkeys Addon
const addons_hotkeys = {
@@ -534,6 +576,7 @@ export const ajv = new Ajv({
addons_linkpreviews,
addons_filetreediff,
addons_customscript,
+ addons_filesections,
],
});
diff --git a/src/events.js b/src/events.js
index e739e9c1..f383c87a 100644
--- a/src/events.js
+++ b/src/events.js
@@ -9,6 +9,8 @@ export const EVENT_READTHEDOCS_FLYOUT_SHOW = "readthedocs-flyout-show";
export const EVENT_READTHEDOCS_FLYOUT_HIDE = "readthedocs-flyout-hide";
export const EVENT_READTHEDOCS_ADDONS_DATA_READY =
"readthedocs-addons-data-ready";
+export const EVENT_READTHEDOCS_COMMANDS_SHOW = "readthedocs-commands-show";
+export const EVENT_READTHEDOCS_COMMANDS_HIDE = "readthedocs-commands-hide";
/**
* Object to pass to user subscribing to `EVENT_READTHEDOCS_ADDONS_DATA_READY`.
diff --git a/src/hotkeys.js b/src/hotkeys.js
index 49597a11..f7e3b1f4 100644
--- a/src/hotkeys.js
+++ b/src/hotkeys.js
@@ -8,6 +8,7 @@ import {
EVENT_READTHEDOCS_SEARCH_HIDE,
EVENT_READTHEDOCS_DOCDIFF_ADDED_REMOVED_SHOW,
EVENT_READTHEDOCS_DOCDIFF_HIDE,
+ EVENT_READTHEDOCS_COMMANDS_SHOW,
} from "./events";
export class HotKeysElement extends LitElement {
@@ -43,6 +44,7 @@ export class HotKeysElement extends LitElement {
this.docDiffShowed = false;
this.searchHotKeyEnabled = this.config.addons.hotkeys.search.enabled;
+ this.commandPaletteHotKeyEnabled = true;
}
_handleKeydown = (e) => {
@@ -58,7 +60,8 @@ export class HotKeysElement extends LitElement {
this.config.addons.hotkeys.doc_diff.trigger &&
document.activeElement.tagName !== "INPUT" &&
document.activeElement.tagName !== "TEXTAREA" &&
- document.activeElement.tagName !== "READTHEDOCS-SEARCH"
+ document.activeElement.tagName !== "READTHEDOCS-SEARCH" &&
+ document.activeElement.tagName !== "READTHEDOCS-COMMANDS"
) {
if (this.docDiffShowed) {
event = new CustomEvent(EVENT_READTHEDOCS_DOCDIFF_HIDE);
@@ -75,11 +78,25 @@ export class HotKeysElement extends LitElement {
keyboardEventToString(e) === this.config.addons.hotkeys.search.trigger &&
document.activeElement.tagName !== "INPUT" &&
document.activeElement.tagName !== "TEXTAREA" &&
- document.activeElement.tagName !== "READTHEDOCS-SEARCH"
+ document.activeElement.tagName !== "READTHEDOCS-SEARCH" &&
+ document.activeElement.tagName !== "READTHEDOCS-COMMANDS"
) {
event = new CustomEvent(EVENT_READTHEDOCS_SEARCH_SHOW);
}
+ // Command Palette
+ if (
+ this.commandPaletteHotKeyEnabled &&
+ (e.metaKey || e.ctrlKey) &&
+ e.key === "k" &&
+ document.activeElement.tagName !== "INPUT" &&
+ document.activeElement.tagName !== "TEXTAREA" &&
+ document.activeElement.tagName !== "READTHEDOCS-SEARCH" &&
+ document.activeElement.tagName !== "READTHEDOCS-COMMANDS"
+ ) {
+ event = new CustomEvent(EVENT_READTHEDOCS_COMMANDS_SHOW);
+ }
+
if (event !== undefined) {
document.dispatchEvent(event);
e.preventDefault();
diff --git a/src/index.js b/src/index.js
index ad761db6..ab621f37 100644
--- a/src/index.js
+++ b/src/index.js
@@ -9,6 +9,7 @@ import * as hotkeys from "./hotkeys";
import * as linkpreviews from "./linkpreviews";
import * as filetreediff from "./filetreediff";
import * as customscript from "./customscript";
+import * as commands from "./commands";
import { default as objectPath } from "object-path";
import {
domReady,
@@ -30,6 +31,7 @@ export function setup() {
linkpreviews.LinkPreviewsAddon,
filetreediff.FileTreeDiffAddon,
customscript.CustomScriptAddon,
+ commands.CommandsAddon,
];
return new Promise((resolve) => {
@@ -69,6 +71,7 @@ export function setup() {
if (addon.isEnabled(config, httpStatus)) {
promises.push(
new Promise((resolve) => {
+ console.debug("Enabling addon:", addon.addonName);
return resolve(new addon(config));
}),
);