From 18d57da7417bec03ac80afdf69c83ac8b00da2d5 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 23 Nov 2024 16:50:44 +0100 Subject: [PATCH 1/4] Implemented generic omnibar function. Since the API exposed by Surfingkeys is very limited (and it becomes more limited on every release), I've added a new Omnibar implementation that exposes callbacks such as `autocomplete`, `onEnter`, `onShiftEnter` and `onResultClick` to customize the behaviour (which e.g. allows POST requests, custom autocomplete hooks, close/keep open on input behaviour etc.). --- package-lock.json | 45 +++- package.json | 5 +- src/conf.priv.example.js | 1 + src/omnibar.js | 479 +++++++++++++++++++++++++++++++++++++++ src/plugins/openai.js | 108 +++++++++ src/plugins/searx.js | 127 +++++++++++ src/theme.js | 17 ++ 7 files changed, 778 insertions(+), 4 deletions(-) create mode 100644 src/omnibar.js create mode 100644 src/plugins/openai.js create mode 100644 src/plugins/searx.js diff --git a/package-lock.json b/package-lock.json index aea21b8..e813b13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,9 @@ "version": "1.1.0", "license": "MIT", "dependencies": { - "dompurify": "^2.4.1", - "github-reserved-names": "^2.0.4", + "dompurify": "^3.2.0", + "github-reserved-names": "^2.0.7", + "showdown": "^2.1.0", "uhtml": "^3.1.0" }, "devDependencies": { @@ -6692,6 +6693,31 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true }, + "node_modules/showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "license": "MIT", + "dependencies": { + "commander": "^9.0.0" + }, + "bin": { + "showdown": "bin/showdown.js" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/tiviesantos" + } + }, + "node_modules/showdown/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -13667,6 +13693,21 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true }, + "showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "requires": { + "commander": "^9.0.0" + }, + "dependencies": { + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==" + } + } + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", diff --git a/package.json b/package.json index 634aed7..5422d38 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "contributors": [], "license": "MIT", "dependencies": { - "dompurify": "^2.4.1", - "github-reserved-names": "^2.0.4", + "dompurify": "^3.2.0", + "github-reserved-names": "^2.0.7", + "showdown": "^2.1.0", "uhtml": "^3.1.0" }, "devDependencies": { diff --git a/src/conf.priv.example.js b/src/conf.priv.example.js index e31b824..e12a007 100644 --- a/src/conf.priv.example.js +++ b/src/conf.priv.example.js @@ -19,6 +19,7 @@ const keys = { wolframalpha: "", // https://products.wolframalpha.com/api/ domainr: "", // https://market.mashape.com/domainr/domainr alternativeTo: "", // Extract the x-algolia-api-key sent with requests when typing in search box on alternativeto.net + openai: "", // https://platform.openai.com/api-keys // ****** Google Custom Search Engines ****** // // Can be created at https://cse.google.com/cse diff --git a/src/omnibar.js b/src/omnibar.js new file mode 100644 index 0000000..2aeb0e0 --- /dev/null +++ b/src/omnibar.js @@ -0,0 +1,479 @@ +import actions from "./actions.js" + +const { Front } = api +const omnibar = {} + +/** + * Opens a custom Omnibar UI. + * + * @param {object} options - The options for the Omnibar. + * @param {function} options.onEnter - The function to call when an item is selected. + * It can return a string to display as a message, or a list of strings, or a list of objects + * having `text` and `url` properties. The default function opens the selected URL in a new tab + * if it has a URL property. Its arguments are the selected item (an object with `text` and `url`) + * and the options object, containing `newTab` and `active` properties. + * @param {function} options.onShiftEnter - The function to call when an item is selected with Shift+Enter. + * By default, it points to the same function as `onEnter`. + * @param {function} options.onResultClick - The function to call when a result is clicked. + * It takes two parameters: the click event and the result object. + * By default, it points to `null`, meaning that the `onEnter` function will be called for the clicked result. + * @param {function} options.autocomplete - The function to call to get autocomplete results. + * It can return a string to display as a message, or a list of strings, or a list of objects + * having `text` and `url` properties. Its argument is an object with `text` and `url` properties. + * @param {boolean} options.newTab - Whether to open the selected item in a new tab (default: false). + * @param {boolean} options.active - Whether the new tab should be active (default: true). + * @param {string} options.placeholder - The placeholder text for the input field (default: ""). + * @param {number} options.autocompleteStartTimeout - The timeout in milliseconds to wait before + * calling the autocomplete function (default: 1000). + */ +omnibar.open = ( + { + onEnter = (item, props) => { + if (!item.url) { + return "No URL provided" + } + + actions.openLink(item.url, props) + }, + onShiftEnter = onEnter, + onResultClick = null, + autocomplete = () => [], + newTab = false, + active = true, + placeholder = "", + autocompleteStartTimeout = 1000, + } = {} +) => { + let results = [], + focusedResult = -1, + listItems = [], + inputText = "", + inputUrl = null, + abortController = null, + autocompleteTimeout = null, + originalHandlers = {} + + var iframe, input, list, cachedPromise, omnibar + const _onResultClick = onResultClick || ((_, result) => onEnter(result, { newTab, active })) + + /** + * Creates the custom Omnibar UI. + */ + const init = () => { + iframe = ( + [...document.body.parentElement.children] + .filter((el) => !!el.shadowRoot) + .map((el) => el.shadowRoot.activeElement) + .filter((el) => el.classList?.contains('sk_ui')) + )?.[0] + + if (!iframe) { + console.error("Omnibar not found") + return + } + + const body = iframe.contentWindow.document.body + omnibar = body.querySelector('#sk_omnibar') + input = body.querySelector('#sk_omnibarSearchArea input') + list = body.querySelector('#sk_omnibarSearchResult') + + if (!(omnibar && input && list)) { + // XXX: This is a workaround for the Omnibar not being ready yet. + // It should probably be replaced by a reactive callback, but at + // the moment that `Front.openOmnibar` is called the Omnibar DOM + // is not yet ready. + setTimeout(init, 100) + return + } + + originalHandlers = { + onkeydown: input.onkeydown, + onkeyup: input.onkeyup, + oninput: input.oninput, + } + + input.placeholder = placeholder + input.onkeydown = _onKeyDown + input.onkeyup = _onKeyUp + input.oninput = _onInput + reset() + } + + /** + * Closes the Omnibar and resets the state. + */ + const close = () => { + if (omnibar) { + omnibar.style.display = "none" + } + + reset() + Object.entries(originalHandlers).forEach(([key, value]) => { + input[key] = value + }) + } + + /** + * Resets the state of the Omnibar. + */ + const reset = () => { + input.value = "" + inputText = "" + inputUrl = null + results = [] + focusedResult = -1 + listItems = [] + list.innerHTML = "" + cancel() + } + + /** + * Cancels the cached promise. + */ + const cancel = () => { + if (abortController) { + abortController.abort() + abortController = null + } + + if (autocompleteTimeout) { + clearTimeout(autocompleteTimeout) + autocompleteTimeout = null + } + } + + /** + * Selects a result. + * + * @param {object} result - The result to select. If not provided: + * - If a result is focused, selects that result. + * - Otherwise, selects the default result. + */ + const select = (result, { shiftKey = false } = {}) => { + if (!result) { + result = (focusedResult >= 0 && focusedResult < results.length) + ? results[focusedResult] : { text: inputText, url: inputUrl } + } + + const fun = shiftKey ? onShiftEnter : onEnter + const ret = fun(result, { newTab, active }) + const reset = () => { + results = [] + focusedResult = -1 + } + + if (ret == null) { + close() + } else if (ret instanceof Promise) { + reset() + ret.then((response) => { + if (response == null) { + close() + return + } + + if (!Array.isArray(response)) { + response = [response] + } + + results = response + _renderResults(response) + }) + } else { + reset() + results = ret + _renderResults(ret) + } + } + + /** + * Updates the Omnibar with the given input. + * + * @param {list} results - The list of results to display, + * each with a `text` and `url` property, or as a string. + */ + const _renderResults = (results) => { + list.innerHTML = "" + listItems = [] + + results.forEach((result) => { + const ul = document.createElement('ul') + const li = document.createElement('li') + const container = document.createElement('div') + const text = document.createElement('div') + + container.classList.add('text-container') + text.classList.add('text') + + if (result.html) { + text.innerHTML = result.html + } else { + text.innerText = result.text || result + } + + li.onclick = (e) => _onResultClick(e, result) + listItems.push(li) + container.appendChild(text) + + if (result.url?.length) { + const url = document.createElement('div') + url.classList.add('url') + url.innerText = result.url + container.appendChild(url) + } + + let iconSrc = result.icon + if (iconSrc?.length) { + const icon = document.createElement('img') + icon.classList.add('icon') + icon.src = iconSrc + li.appendChild(icon) + } + + li.appendChild(container) + ul.appendChild(li) + list.appendChild(ul) + }) + } + + /** + * Focuses the result at the given index. + * + * @param {number} index - The index of the result to focus. + */ + const _focusResult = (index) => { + if (index < 0 || index >= results.length) { + index = -1 + } + + focusedResult = index + listItems.forEach((li, i) => { + if (i === index) { + li.classList.add('focused') + } else { + li.classList.remove('focused') + } + }) + } + + /** + * Handles keydown events for the Omnibar. + * + * @param {KeyboardEvent} e - The keydown event. + */ + const _onKeyDown = (e) => { + if (e.key === "Escape") { + close() + } + } + + /** + * Handles keyup events for the Omnibar. + * + * @param {KeyboardEvent} e - The keyup event. + */ + const _onKeyUp = (e) => { + if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { + if (!results.length) { + return + } + + _focusResult((focusedResult + 1) % results.length) + } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { + if (!results.length) { + return + } + + _focusResult((focusedResult - 1 + results.length) % results.length) + } else if (e.key === "Enter") { + select(results[focusedResult], { shiftKey: e.shiftKey }) + } + } + + /** + * Handles input events for the Omnibar. + * + * @param {InputEvent} e - The input event. + */ + const _onInput = (e) => { + if (e.target.value === inputText) { + return + } + + inputText = e.target.value + try { + inputUrl = new URL(inputText).href + } catch (e) { + inputUrl = null + } + + cancel() + autocompleteTimeout = setTimeout(() => { + const text = inputText.trim() + if (!text) { + return + } + + cachedPromise = new Promise((resolve, reject) => { + const abortListener = () => reject(new Error("Aborted")) + abortController = new AbortController() + abortController.signal.addEventListener("abort", abortListener) + + const ret = autocomplete({ + text, + url: inputUrl, + }) + + if (ret instanceof Promise) { + ret.then(resolve).catch(reject) + } else { + resolve(ret) + } + }) + + cachedPromise.then((response) => { + if (response == null) { + response = [] + } + + if (!Array.isArray(response)) { + response = [response] + } + + results = response + _renderResults(results) + }) + }, autocompleteStartTimeout) + } + + Front.openOmnibar({ + type: "SearchEngine", + }) + + setTimeout(init, 100) +} + +export default omnibar + +/** + * Example implememtation: custom omnibar backed by a Searx instance. + * It supports the following features: + * + * - If a URL is provided, it opens it directly. + * - If a search query is provided, it autocompletes it using Searx and displays the results. + * - If is pressed, it opens either the selected autocompletion result or the search query. + * - If + is pressed, it fetches the search results using Searx and displays them. + * - `newTab` and `active` options are supported. + */ + +// const searxUrl = "https://searchx.example.com" +// +// const searxCallback = ({newTab = false} = {}) => { +// return () => { +// omnibar.open({ +// autocompleteStartTimeout: 1500, +// newTab, +// autocomplete: (query) => { +// if (query.url) { +// return [ +// { +// text: `🔗 ${query.url}`, +// url: query.url, +// }, +// ] +// } +// +// return new Promise((resolve, reject) => { +// fetch( +// `${searxUrl}/autocompleter`, +// { +// method: "POST", +// headers: { +// "Content-Type": "application/x-www-form-urlencoded", +// }, +// body: `q=${encodeURIComponent(query.text)}`, +// } +// ).then((response) => { +// response.json().then((data) => { +// resolve( +// data[1]?.map((item) => { +// const sanitizedText = item.replace('<', '<').replace('>', '>') +// return { +// html: `🔍 ${sanitizedText.substring(0, query.text.length)}${sanitizedText.substring(query.text.length)}`, +// url: `${searxUrl}/search?q=${encodeURIComponent(item)}`, +// } +// }) || [] +// ) +// }).catch((error) => { +// console.error('Could not parse autocompletion results', error) +// reject(error) +// }) +// }).catch((error) => { +// console.error('Could not fetch autocompletion results', error) +// reject(error) +// }) +// }) +// }, +// +// onEnter: (item) => { +// if (!item.url) { +// item.url = `${searxUrl}/search?q=${encodeURIComponent(item.text)}` +// } +// +// actions.openLink(item.url, { newTab }) +// }, +// +// onShiftEnter: (item) => { +// return new Promise((resolve, reject) => { +// fetch( +// `${searxUrl}/search?q=${encodeURIComponent(item.text)}&categories=general&pageno=1&safesearch=0&format=rss`, +// ).then((response) => { +// response.text().then((data) => { +// const parser = new DOMParser() +// const xml = parser.parseFromString(data, "text/xml") +// const items = Array.from(xml.querySelectorAll("item")) +// const results = items +// .filter((item) => item.querySelector("link") && item.querySelector("title")) +// .map((item) => ({ +// url: item.querySelector("link").textContent, +// html: `${item.querySelector("title").textContent}
${item.querySelector("description")?.textContent}`, +// })) +// +// resolve(results) +// }).catch((error) => { +// console.error('Could not parse RSS results', error) +// reject(error) +// }) +// }).catch((error) => { +// console.error('Could not fetch RSS results', error) +// reject(error) +// }) +// }) +// }, +// }) +// } +// } + +/* Then, in your maps.global (keys.js): */ + +// import omnibar from "./omnibar.js" +// +// maps.global = [ +// // ... +// // Open URL or search (current tab) +// { +// alias: "go", +// category: categories.pageNav, +// description: "Open URL or search (current tab)", +// callback: searxCallback({newTab: false}), +// }, +// +// // Open URL or search (new tab) +// { +// alias: "t", +// category: categories.pageNav, +// description: "Open URL or search (new tab)", +// callback: searxCallback({newTab: true}), +// }, +// // ... +// ] diff --git a/src/plugins/openai.js b/src/plugins/openai.js new file mode 100644 index 0000000..8561e1e --- /dev/null +++ b/src/plugins/openai.js @@ -0,0 +1,108 @@ +import api from "../api.js" +import omnibar from "../omnibar.js" +import priv from "../conf.priv.js" + +const { Clipboard } = api + +// ChatGPT requires the showdown library to convert Markdown to HTML: +// npm install showdown +import showdown from "showdown" + +/** + * Open the Omnibar with OpenAI results. + * + * It requires an API key from OpenAI, to be specified in the priv.js file. + * + * @param {Object} options - Options object. + * @param {string} options.model - The model to use (default: "gpt-3.5-turbo"). + * @param {number} options.maxTokens - The maximum number of tokens to generate (default: 500). + * @param {Object[]} options.context - The initial context (default: []). + * + * Example usage: Add the following in your keys.js file: + +import openaiCallback from "./plugins/openai.js" + +maps.global = [ + // ... + // OpenAI + { + alias: "ai", + category: categories.misc, + description: "Open ChatGPT", + callback: openaiCallback(), + }, + // ... +] + +*/ +export default function( + { + model = "gpt-3.5-turbo", + maxTokens = 500, + context = [ + { + role: "system", + content: "You are a helpful assistant that can answer questions and provide information.", + }, + { + role: "system", + content: "When extra formatting is needed, you can use Markdown.", + }, + ], + } = {} +) { + var _context = [...context] + + return () => { + omnibar.open({ + autocomplete: () => { + // No autocompletion, predictions are generated only when the user presses Enter + return [] + }, + + onEnter: (item) => { + // It requires the Showdown library to convert Markdown to HTML: + // npm install showdown + const markdown = new showdown.Converter() + _context.push({ role: "user", content: item.text }) + + return new Promise((resolve, reject) => { + fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + "Authorization": `Bearer ${priv.keys.openai}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + messages: _context, + max_tokens: maxTokens, + }), + } + ).then((response) => { + response.json().then((data) => { + const text = data.choices[0].message.content + resolve({ + text, + html: markdown.makeHtml(text), + }) + }).catch((error) => { + console.error('Could not parse response', error) + reject(error) + }) + }).catch((error) => { + console.error('Could not fetch response', error) + reject(error) + }) + }) + }, + + onResultClick: (e, item) => { + e.preventDefault() + Clipboard.write(item.text) + }, + }) + } +} diff --git a/src/plugins/searx.js b/src/plugins/searx.js new file mode 100644 index 0000000..bc96634 --- /dev/null +++ b/src/plugins/searx.js @@ -0,0 +1,127 @@ +import actions from "../actions.js" +import omnibar from "../omnibar.js" + +// Customize it to your liking +const searxUrl = "https://search.fabiomanganiello.com" + +/** + * Open the Omnibar with SearX autocompletion. + * + * @param {Object} options - Options object. + * @param {boolean} options.newTab - Open the result in a new tab (default: false). + * @param {boolean} options.active - Make the new tab active (default: true). + * + * Example usage: Add the following in your keys.js file: + +import searxCallback from "./plugins/searx.js" + +maps.global = [ + // ... + // Open URL or search (current tab) + { + alias: "go", + category: categories.pageNav, + description: "Open URL or search (current tab)", + callback: searxCallback({newTab: false}), + }, + + // Open URL or search (new tab) + { + alias: "t", + category: categories.pageNav, + description: "Open URL or search (new tab)", + callback: searxCallback({newTab: true}), + }, + // ... +] + + */ +export default function({ + newTab = false, + active = true, +} = {}) { + return () => { + omnibar.open({ + autocompleteStartTimeout: 1500, + newTab, + active, + autocomplete: (query) => { + if (query.url) { + return [ + { + text: `🔗 ${query.url}`, + url: query.url, + }, + ] + } + + return new Promise((resolve, reject) => { + fetch( + `${searxUrl}/autocompleter`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `q=${encodeURIComponent(query.text)}`, + } + ).then((response) => { + response.json().then((data) => { + resolve( + data[1]?.map((item) => { + const sanitizedText = item.replace('<', '<').replace('>', '>') + return { + html: `🔍 ${sanitizedText.substring(0, query.text.length)}${sanitizedText.substring(query.text.length)}`, + url: `${searxUrl}/search?q=${encodeURIComponent(item)}`, + } + }) || [] + ) + }).catch((error) => { + console.error('Could not parse autocompletion results', error) + reject(error) + }) + }).catch((error) => { + console.error('Could not fetch autocompletion results', error) + reject(error) + }) + }) + }, + + onEnter: (item) => { + if (!item.url) { + item.url = `${searxUrl}/search?q=${encodeURIComponent(item.text)}` + } + + actions.openLink(item.url, { newTab }) + }, + + onShiftEnter: (item) => { + return new Promise((resolve, reject) => { + fetch( + `${searxUrl}/search?q=${encodeURIComponent(item.text)}&categories=general&pageno=1&safesearch=0&format=rss`, + ).then((response) => { + response.text().then((data) => { + const parser = new DOMParser() + const xml = parser.parseFromString(data, "text/xml") + const items = Array.from(xml.querySelectorAll("item")) + const results = items + .filter((item) => item.querySelector("link") && item.querySelector("title")) + .map((item) => ({ + url: item.querySelector("link").textContent, + html: `${item.querySelector("title").textContent}
${item.querySelector("description")?.textContent}`, + })) + + resolve(results) + }).catch((error) => { + console.error('Could not parse RSS results', error) + reject(error) + }) + }).catch((error) => { + console.error('Could not fetch RSS results', error) + reject(error) + }) + }) + }, + }) + } +} diff --git a/src/theme.js b/src/theme.js index d5f1bb8..6c8a51b 100644 --- a/src/theme.js +++ b/src/theme.js @@ -21,11 +21,28 @@ const commonStyles = ` margin: 0 !important; } + #sk_omnibarSearchResult > ul { + /* + * Make the Omnibar search result list scrollable + * by making it as high as the parent container. + * display: flex would make things easier, but I + * don't want to change too much of the original + * styling. + */ + max-height: 60vh !important; + } + #sk_omnibar li { background: none !important; padding: 0.35rem 0.5rem !important; } + #sk_omnibar li, + #sk_omnibar li > .text-container { + max-height: 60vh !important; + overflow: auto !important; + } + #sk_omnibarSearchResult > ul:nth-child(1) { margin-bottom: 0px !important; padding: 0 !important; From e5abce3a154502dbc53c8d840f7c90bec8078173 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 24 Nov 2024 00:23:10 +0100 Subject: [PATCH 2/4] Removed duplicate comments. --- src/omnibar.js | 123 ------------------------------------------------- 1 file changed, 123 deletions(-) diff --git a/src/omnibar.js b/src/omnibar.js index 2aeb0e0..b6a9c27 100644 --- a/src/omnibar.js +++ b/src/omnibar.js @@ -354,126 +354,3 @@ omnibar.open = ( } export default omnibar - -/** - * Example implememtation: custom omnibar backed by a Searx instance. - * It supports the following features: - * - * - If a URL is provided, it opens it directly. - * - If a search query is provided, it autocompletes it using Searx and displays the results. - * - If is pressed, it opens either the selected autocompletion result or the search query. - * - If + is pressed, it fetches the search results using Searx and displays them. - * - `newTab` and `active` options are supported. - */ - -// const searxUrl = "https://searchx.example.com" -// -// const searxCallback = ({newTab = false} = {}) => { -// return () => { -// omnibar.open({ -// autocompleteStartTimeout: 1500, -// newTab, -// autocomplete: (query) => { -// if (query.url) { -// return [ -// { -// text: `🔗 ${query.url}`, -// url: query.url, -// }, -// ] -// } -// -// return new Promise((resolve, reject) => { -// fetch( -// `${searxUrl}/autocompleter`, -// { -// method: "POST", -// headers: { -// "Content-Type": "application/x-www-form-urlencoded", -// }, -// body: `q=${encodeURIComponent(query.text)}`, -// } -// ).then((response) => { -// response.json().then((data) => { -// resolve( -// data[1]?.map((item) => { -// const sanitizedText = item.replace('<', '<').replace('>', '>') -// return { -// html: `🔍 ${sanitizedText.substring(0, query.text.length)}${sanitizedText.substring(query.text.length)}`, -// url: `${searxUrl}/search?q=${encodeURIComponent(item)}`, -// } -// }) || [] -// ) -// }).catch((error) => { -// console.error('Could not parse autocompletion results', error) -// reject(error) -// }) -// }).catch((error) => { -// console.error('Could not fetch autocompletion results', error) -// reject(error) -// }) -// }) -// }, -// -// onEnter: (item) => { -// if (!item.url) { -// item.url = `${searxUrl}/search?q=${encodeURIComponent(item.text)}` -// } -// -// actions.openLink(item.url, { newTab }) -// }, -// -// onShiftEnter: (item) => { -// return new Promise((resolve, reject) => { -// fetch( -// `${searxUrl}/search?q=${encodeURIComponent(item.text)}&categories=general&pageno=1&safesearch=0&format=rss`, -// ).then((response) => { -// response.text().then((data) => { -// const parser = new DOMParser() -// const xml = parser.parseFromString(data, "text/xml") -// const items = Array.from(xml.querySelectorAll("item")) -// const results = items -// .filter((item) => item.querySelector("link") && item.querySelector("title")) -// .map((item) => ({ -// url: item.querySelector("link").textContent, -// html: `${item.querySelector("title").textContent}
${item.querySelector("description")?.textContent}`, -// })) -// -// resolve(results) -// }).catch((error) => { -// console.error('Could not parse RSS results', error) -// reject(error) -// }) -// }).catch((error) => { -// console.error('Could not fetch RSS results', error) -// reject(error) -// }) -// }) -// }, -// }) -// } -// } - -/* Then, in your maps.global (keys.js): */ - -// import omnibar from "./omnibar.js" -// -// maps.global = [ -// // ... -// // Open URL or search (current tab) -// { -// alias: "go", -// category: categories.pageNav, -// description: "Open URL or search (current tab)", -// callback: searxCallback({newTab: false}), -// }, -// -// // Open URL or search (new tab) -// { -// alias: "t", -// category: categories.pageNav, -// description: "Open URL or search (new tab)", -// callback: searxCallback({newTab: true}), -// }, -// // ... -// ] From 43fb53a116fb646113938dfa69dc309ad3901c95 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 25 Nov 2024 23:30:18 +0100 Subject: [PATCH 3/4] Support for multiline input and loading UI. --- src/omnibar.js | 73 +++++++++++++++++++++++++++++++++++++------ src/plugins/openai.js | 19 ++++++++--- src/plugins/searx.js | 58 +++++++++++++++++----------------- src/theme.js | 15 +++++++++ 4 files changed, 122 insertions(+), 43 deletions(-) diff --git a/src/omnibar.js b/src/omnibar.js index b6a9c27..cceacae 100644 --- a/src/omnibar.js +++ b/src/omnibar.js @@ -11,9 +11,9 @@ const omnibar = {} * It can return a string to display as a message, or a list of strings, or a list of objects * having `text` and `url` properties. The default function opens the selected URL in a new tab * if it has a URL property. Its arguments are the selected item (an object with `text` and `url`) - * and the options object, containing `newTab` and `active` properties. - * @param {function} options.onShiftEnter - The function to call when an item is selected with Shift+Enter. - * By default, it points to the same function as `onEnter`. + * and the options object, containing `newTab` and `active` properties and the `shiftKey` and `ctrlKey` + * modifier flags. It also includes a `listSelect` property to indicate if the item was clicked/selected + * from the list or it comes from the input field. * @param {function} options.onResultClick - The function to call when a result is clicked. * It takes two parameters: the click event and the result object. * By default, it points to `null`, meaning that the `onEnter` function will be called for the clicked result. @@ -22,6 +22,7 @@ const omnibar = {} * having `text` and `url` properties. Its argument is an object with `text` and `url` properties. * @param {boolean} options.newTab - Whether to open the selected item in a new tab (default: false). * @param {boolean} options.active - Whether the new tab should be active (default: true). + * @param {boolean} options.multiline - Whether the input field should be a textarea (default: false). * @param {string} options.placeholder - The placeholder text for the input field (default: ""). * @param {number} options.autocompleteStartTimeout - The timeout in milliseconds to wait before * calling the autocomplete function (default: 1000). @@ -35,11 +36,11 @@ omnibar.open = ( actions.openLink(item.url, props) }, - onShiftEnter = onEnter, onResultClick = null, autocomplete = () => [], newTab = false, active = true, + multiline = false, placeholder = "", autocompleteStartTimeout = 1000, } = {} @@ -54,7 +55,17 @@ omnibar.open = ( originalHandlers = {} var iframe, input, list, cachedPromise, omnibar - const _onResultClick = onResultClick || ((_, result) => onEnter(result, { newTab, active })) + const _onResultClick = onResultClick || ( + (e, result) => { + onEnter(result, { + newTab, + active, + shiftKey: e.shiftKey, + ctrlKey: e.ctrlKey, + listSelect: true, + }) + } + ) /** * Creates the custom Omnibar UI. @@ -74,7 +85,10 @@ omnibar.open = ( const body = iframe.contentWindow.document.body omnibar = body.querySelector('#sk_omnibar') - input = body.querySelector('#sk_omnibarSearchArea input') + input = ( + body.querySelector('#sk_omnibarSearchArea input') || + body.querySelector('#sk_omnibarSearchArea textarea') + ) list = body.querySelector('#sk_omnibarSearchResult') if (!(omnibar && input && list)) { @@ -86,6 +100,27 @@ omnibar.open = ( return } + // If it's a multiline input, but the Omnibar input element is not a textarea, + // we need to create a new textarea element and replace the input element. + if (multiline) { + let textarea = input + if (input.tagName !== "TEXTAREA") { + textarea = document.createElement("textarea") + input.parentNode.replaceChild(textarea, input) + } + + input = textarea + input.spellcheck = false + } else { + // Otherwise, if it's not a multiline input, make sure it's an input element. + if (input.tagName !== "INPUT") { + let newInput = document.createElement("input") + input.parentNode.replaceChild(newInput, input) + input = newInput + } + } + + setTimeout(() => input.focus(), 100) originalHandlers = { onkeydown: input.onkeydown, onkeyup: input.onkeyup, @@ -149,23 +184,24 @@ omnibar.open = ( * - If a result is focused, selects that result. * - Otherwise, selects the default result. */ - const select = (result, { shiftKey = false } = {}) => { + const select = (result, { shiftKey = false, ctrlKey = false, listSelect = false } = {}) => { if (!result) { result = (focusedResult >= 0 && focusedResult < results.length) ? results[focusedResult] : { text: inputText, url: inputUrl } } - const fun = shiftKey ? onShiftEnter : onEnter - const ret = fun(result, { newTab, active }) + const ret = onEnter(result, { newTab, active, shiftKey, ctrlKey, listSelect }) const reset = () => { results = [] focusedResult = -1 } if (ret == null) { + // If the onEnter function returns null, close the Omnibar. close() } else if (ret instanceof Promise) { reset() + input?.classList?.add("loading") ret.then((response) => { if (response == null) { close() @@ -178,6 +214,8 @@ omnibar.open = ( results = response _renderResults(response) + }).finally(() => { + input?.classList?.remove("loading") }) } else { reset() @@ -196,6 +234,10 @@ omnibar.open = ( list.innerHTML = "" listItems = [] + if (!results?.length) { + results = [] + } + results.forEach((result) => { const ul = document.createElement('ul') const li = document.createElement('li') @@ -264,6 +306,13 @@ omnibar.open = ( const _onKeyDown = (e) => { if (e.key === "Escape") { close() + return + } + + // Adjust the height of the textarea to fit the content. + if (multiline) { + e.target.style.height = "" + e.target.style.height = e.target.scrollHeight + "px" } } @@ -286,7 +335,11 @@ omnibar.open = ( _focusResult((focusedResult - 1 + results.length) % results.length) } else if (e.key === "Enter") { - select(results[focusedResult], { shiftKey: e.shiftKey }) + select(results[focusedResult], { + shiftKey: e.shiftKey, + ctrlKey: e.ctrlKey, + listSelect: focusedResult >= 0, + }) } } diff --git a/src/plugins/openai.js b/src/plugins/openai.js index 8561e1e..64bd26b 100644 --- a/src/plugins/openai.js +++ b/src/plugins/openai.js @@ -55,14 +55,25 @@ export default function( return () => { omnibar.open({ + multiline: true, autocomplete: () => { // No autocompletion, predictions are generated only when the user presses Enter return [] }, - onEnter: (item) => { - // It requires the Showdown library to convert Markdown to HTML: - // npm install showdown + onEnter: (item, { ctrlKey, listSelect }) => { + if (listSelect) { + // If the user selected a result, copy it to the clipboard + Clipboard.write(item.text) + return "" + } + + if (!ctrlKey) { + // If the user did not press Ctrl, do not open the result + // (multi-line input, the user may want to add more text) + return "" + } + const markdown = new showdown.Converter() _context.push({ role: "user", content: item.text }) @@ -100,7 +111,7 @@ export default function( }, onResultClick: (e, item) => { - e.preventDefault() + e?.preventDefault() Clipboard.write(item.text) }, }) diff --git a/src/plugins/searx.js b/src/plugins/searx.js index bc96634..1ad1943 100644 --- a/src/plugins/searx.js +++ b/src/plugins/searx.js @@ -87,40 +87,40 @@ export default function({ }) }, - onEnter: (item) => { - if (!item.url) { - item.url = `${searxUrl}/search?q=${encodeURIComponent(item.text)}` - } - - actions.openLink(item.url, { newTab }) - }, - - onShiftEnter: (item) => { - return new Promise((resolve, reject) => { - fetch( - `${searxUrl}/search?q=${encodeURIComponent(item.text)}&categories=general&pageno=1&safesearch=0&format=rss`, - ).then((response) => { - response.text().then((data) => { - const parser = new DOMParser() - const xml = parser.parseFromString(data, "text/xml") - const items = Array.from(xml.querySelectorAll("item")) - const results = items - .filter((item) => item.querySelector("link") && item.querySelector("title")) - .map((item) => ({ - url: item.querySelector("link").textContent, - html: `${item.querySelector("title").textContent}
${item.querySelector("description")?.textContent}`, - })) + onEnter: (item, { shiftKey }) => { + if (shiftKey) { + return new Promise((resolve, reject) => { + fetch( + `${searxUrl}/search?q=${encodeURIComponent(item.text)}&categories=general&pageno=1&safesearch=0&format=rss`, + ).then((response) => { + response.text().then((data) => { + const parser = new DOMParser() + const xml = parser.parseFromString(data, "text/xml") + const items = Array.from(xml.querySelectorAll("item")) + const results = items + .filter((item) => item.querySelector("link") && item.querySelector("title")) + .map((item) => ({ + url: item.querySelector("link").textContent, + html: `${item.querySelector("title").textContent}
${item.querySelector("description")?.textContent}`, + })) - resolve(results) + resolve(results) + }).catch((error) => { + console.error('Could not parse RSS results', error) + reject(error) + }) }).catch((error) => { - console.error('Could not parse RSS results', error) + console.error('Could not fetch RSS results', error) reject(error) }) - }).catch((error) => { - console.error('Could not fetch RSS results', error) - reject(error) }) - }) + } + + if (!item.url) { + item.url = `${searxUrl}/search?q=${encodeURIComponent(item.text)}` + } + + actions.openLink(item.url, { newTab }) }, }) } diff --git a/src/theme.js b/src/theme.js index 6c8a51b..3180278 100644 --- a/src/theme.js +++ b/src/theme.js @@ -53,6 +53,21 @@ const commonStyles = ` padding-left: 8px !important; } + #sk_omnibar textarea { + width: 100%; + max-height: 20vh; + overflow: auto; + border: none; + outline: none; + font-size: 1.1em; + tab-size: 2; + } + + #sk_omnibar input.loading, + #sk_omnibar textarea.loading { + opacity: 0.5; + } + /* Disable RichHints CSS animation */ .expandRichHints { animation: none; From 4cb800cb24344f39173a8569cd0deab3838769c4 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 1 Dec 2024 16:44:46 +0100 Subject: [PATCH 4/4] Parse HTML via `htmlPurify` before setting `innerHTML`. Addresses comment: https://github.com/b0o/surfingkeys-conf/pull/90#issuecomment-2499399187 --- src/omnibar.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/omnibar.js b/src/omnibar.js index cceacae..03b774b 100644 --- a/src/omnibar.js +++ b/src/omnibar.js @@ -1,6 +1,8 @@ import actions from "./actions.js" +import utils from "./util.js" const { Front } = api +const { htmlPurify } = utils const omnibar = {} /** @@ -248,7 +250,7 @@ omnibar.open = ( text.classList.add('text') if (result.html) { - text.innerHTML = result.html + text.innerHTML = htmlPurify(result.html) } else { text.innerText = result.text || result }