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`
+ this.handleCommandClick(e, result)} + @mouseenter=${this.mouseenterResultHit} + class="hit-block-heading ${result.action ? "hit" : ""}" + href="${result.href}" + > +

${result.title}

+
+ ${result.sections.map( + (section, bindex) => + html`${this.renderSectionResult( + section, + `${section.id}-${rindex}-${bindex}`, + result, + )}`, + )} +
`, + )} +
+ `; + } + + 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` + +
+

${title}

+
+
+ `; + } + + 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)); }), );