notes/.config/chromium/Default/Extensions/dbepggeogbaibhgnhhndojpepiihcmeb/1.67_0/pages/vomnibar.js

456 lines
18 KiB
JavaScript

//
// This controls the contents of the Vomnibar iframe. We use an iframe to avoid changing the selection on the
// page (useful for bookmarklets), ensure that the Vomnibar style is unaffected by the page, and simplify key
// handling in vimium_frontend.js
//
const Vomnibar = {
vomnibarUI: null, // the dialog instance for this window
getUI() { return this.vomnibarUI; },
completers: {},
getCompleter(name) {
if (!this.completers[name])
this.completers[name] = new BackgroundCompleter(name);
return this.completers[name];
},
activate(userOptions) {
const options = {
completer: "omni",
query: "",
newTab: false,
selectFirst: false,
keyword: null
};
Object.assign(options, userOptions);
Object.assign(options, {refreshInterval: options.completer === "omni" ? 150 : 0});
const completer = this.getCompleter(options.completer);
if (this.vomnibarUI == null)
this.vomnibarUI = new VomnibarUI();
completer.refresh(this.vomnibarUI);
this.vomnibarUI.setInitialSelectionValue(options.selectFirst ? 0 : -1);
this.vomnibarUI.setCompleter(completer);
this.vomnibarUI.setRefreshInterval(options.refreshInterval);
this.vomnibarUI.setForceNewTab(options.newTab);
this.vomnibarUI.setQuery(options.query);
this.vomnibarUI.setKeyword(options.keyword);
this.vomnibarUI.update(true);
},
hide() {
if (this.vomnibarUI)
this.vomnibarUI.hide();
},
onHidden() {
if (this.vomnibarUI)
this.vomnibarUI.onHidden()
}
};
class VomnibarUI {
constructor() {
this.onKeyEvent = this.onKeyEvent.bind(this);
this.onInput = this.onInput.bind(this);
this.update = this.update.bind(this);
this.refreshInterval = 0;
this.onHiddenCallback = null;
this.initDom();
}
setQuery(query) { this.input.value = query; }
setKeyword(keyword) { this.customSearchMode = keyword; }
setInitialSelectionValue(initialSelectionValue) {
this.initialSelectionValue = initialSelectionValue;
}
setRefreshInterval(refreshInterval) {
this.refreshInterval = refreshInterval;
}
setForceNewTab(forceNewTab) {
this.forceNewTab = forceNewTab;
}
setCompleter(completer) {
this.completer = completer;
this.reset();
}
setKeywords(keywords) {
this.keywords = keywords;
}
// The sequence of events when the vomnibar is hidden is as follows:
// 1. Post a "hide" message to the host page.
// 2. The host page hides the vomnibar.
// 3. When that page receives the focus, and it posts back a "hidden" message.
// 3. Only once the "hidden" message is received here is any required action invoked (in onHidden).
// This ensures that the vomnibar is actually hidden before any new tab is created, and avoids flicker after
// opening a link in a new tab then returning to the original tab (see #1485).
hide(onHiddenCallback = null) {
this.onHiddenCallback = onHiddenCallback;
this.input.blur();
UIComponentServer.postMessage("hide");
this.reset();
}
onHidden() {
if (typeof this.onHiddenCallback === 'function') {
this.onHiddenCallback();
}
this.onHiddenCallback = null;
return this.reset();
}
reset() {
this.clearUpdateTimer();
this.completionList.style.display = "";
this.input.value = "";
this.completions = [];
this.previousInputValue = null;
this.customSearchMode = null;
this.selection = this.initialSelectionValue;
this.keywords = [];
this.seenTabToOpenCompletionList = false;
if (this.completer != null) {
this.completer.reset();
}
}
updateSelection() {
// For custom search engines, we suppress the leading term (e.g. the "w" of "w query terms") within the
// vomnibar input.
if (this.lastResponse.isCustomSearch && (this.customSearchMode == null)) {
const queryTerms = this.input.value.trim().split(/\s+/);
this.customSearchMode = queryTerms[0];
this.input.value = queryTerms.slice(1).join(" ");
}
// For suggestions for custom search engines, we copy the suggested text into the input when the item is
// selected, and revert when it is not. This allows the user to select a suggestion and then continue
// typing.
if ((0 <= this.selection) && (this.completions[this.selection].insertText != null)) {
if (this.previousInputValue == null) { this.previousInputValue = this.input.value; }
this.input.value = this.completions[this.selection].insertText;
} else if (this.previousInputValue != null) {
this.input.value = this.previousInputValue;
this.previousInputValue = null;
}
// Highlight the selected entry, and only the selected entry.
for (let i = 0, end = this.completionList.children.length; i < end; i++) {
this.completionList.children[i].className = (i === this.selection ? "vomnibarSelected" : "");
}
}
// Returns the user's action ("up", "down", "tab", etc, or null) based on their keypress. We support the
// arrow keys and various other shortcuts, and this function hides the event-decoding complexity.
actionFromKeyEvent(event) {
const key = KeyboardUtils.getKeyChar(event);
// Handle <Enter> on "keypress", and other events on "keydown"; this avoids interence with CJK translation
// (see #2915 and #2934).
if ((event.type === "keypress") && (key !== "enter")) { return null; }
if ((event.type === "keydown") && (key === "enter")) { return null; }
if (KeyboardUtils.isEscape(event)) {
return "dismiss";
} else if ((key === "up") ||
(event.shiftKey && (event.key === "Tab")) ||
(event.ctrlKey && ((key === "k") || (key === "p")))) {
return "up";
} else if ((event.key === "Tab") && !event.shiftKey) {
return "tab";
} else if ((key === "down") ||
(event.ctrlKey && ((key === "j") || (key === "n")))) {
return "down";
} else if (event.ctrlKey && (event.key === "Enter")) {
return "ctrl-enter";
} else if (event.key === "Enter") {
return "enter";
} else if ((event.key === "Delete") && event.shiftKey && !event.ctrlKey && !event.altKey) {
return "remove";
} else if (KeyboardUtils.isBackspace(event)) {
return "delete";
}
return null;
}
onKeyEvent(event) {
let action, completion;
this.lastAction = (action = this.actionFromKeyEvent(event));
if (!action)
return true; // pass through
const openInNewTab = this.forceNewTab || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
if (action === "dismiss") {
this.hide();
} else if ([ "tab", "down" ].includes(action)) {
if ((action === "tab") &&
(this.completer.name === "omni") &&
!this.seenTabToOpenCompletionList &&
(this.input.value.trim().length === 0)) {
this.seenTabToOpenCompletionList = true;
this.update(true);
} else if (this.completions.length > 0) {
this.selection += 1;
if (this.selection === this.completions.length)
this.selection = this.initialSelectionValue;
this.updateSelection();
}
} else if (action === "up") {
this.selection -= 1;
if (this.selection < this.initialSelectionValue)
this.selection = this.completions.length - 1;
this.updateSelection();
} else if (action === "enter") {
const c = this.completions[this.selection];
const isCustomSearchPrimarySuggestion = c && c.isPrimarySuggestion &&
this.lastResponse.engine && this.lastResponse.engine.searchUrl;
if ((this.selection === -1) || isCustomSearchPrimarySuggestion) {
let query = this.input.value.trim();
// <Enter> on an empty query is a no-op.
if (!(query.length > 0))
return;
// First case (@selection == -1).
// If the user types something and hits enter without selecting a completion from the list, then:
// - If a search URL has been provided, then use it. This is custom search engine request.
// - Otherwise, send the query to the background page, which will open it as a URL or create a
// default search, as appropriate.
//
// Second case (isCustomSearchPrimarySuggestion).
// Alternatively, the selected completion could be the primary selection for a custom search engine.
// Because the the suggestions are updated asynchronously in omni mode, the user may have typed more
// text than that which is included in the URL associated with the primary suggestion. Therefore, to
// avoid a race condition, we construct the query from the actual contents of the input (query).
if (isCustomSearchPrimarySuggestion)
query = Utils.createSearchUrl(query, this.lastResponse.engine.searchUrl);
this.hide(() => Vomnibar.getCompleter().launchUrl(query, openInNewTab));
} else {
completion = this.completions[this.selection];
this.hide(() => completion.performAction(openInNewTab));
}
} else if (action === "ctrl-enter") {
// Populate the vomnibar with the current selection's URL.
if (!this.customSearchMode && (this.selection >= 0)) {
if (this.previousInputValue == null) { this.previousInputValue = this.input.value; }
this.input.value = this.completions[this.selection] != null ? this.completions[this.selection].url : undefined;
this.input.scrollLeft = this.input.scrollWidth;
}
} else if (action === "delete") {
if (this.customSearchMode && (this.input.selectionEnd === 0)) {
// Normally, with custom search engines, the keyword (e,g, the "w" of "w query terms") is suppressed.
// If the cursor is at the start of the input, then reinstate the keyword (the "w").
this.input.value = this.customSearchMode + this.input.value.trimStart();
this.input.selectionStart = (this.input.selectionEnd = this.customSearchMode.length);
this.customSearchMode = null;
this.update(true);
} else if (this.seenTabToOpenCompletionList && (this.input.value.trim().length === 0)) {
this.seenTabToOpenCompletionList = false;
this.update(true);
} else {
return true; // Do not suppress event.
}
} else if ((action === "remove") && (0 <= this.selection)) {
completion = this.completions[this.selection];
console.log(completion);
}
// It seems like we have to manually suppress the event here and still return true.
event.stopImmediatePropagation();
event.preventDefault();
return true;
}
// Return the background-page query corresponding to the current input state. In other words, reinstate any
// search engine keyword which is currently being suppressed, and strip any prompted text.
getInputValueAsQuery() {
return ((this.customSearchMode != null) ? this.customSearchMode + " " : "") + this.input.value;
}
updateCompletions(callback = null) {
return this.completer.filter({
query: this.getInputValueAsQuery(),
seenTabToOpenCompletionList: this.seenTabToOpenCompletionList,
callback: lastResponse => {
this.lastResponse = lastResponse;
const { results } = this.lastResponse;
this.completions = results;
this.selection = (this.completions[0] != null ? this.completions[0].autoSelect : undefined) ? 0 : this.initialSelectionValue;
// Update completion list with the new suggestions.
this.completionList.innerHTML = this.completions.map(completion => `<li>${completion.html}</li>`).join("");
this.completionList.style.display = this.completions.length > 0 ? "block" : "";
this.selection = Math.min(this.completions.length - 1, Math.max(this.initialSelectionValue, this.selection));
this.updateSelection();
if (callback)
return callack();
}
});
}
onInput() {
let updateSynchronously;
this.seenTabToOpenCompletionList = false;
this.completer.cancel();
if ((0 <= this.selection) && this.completions[this.selection].customSearchMode && !this.customSearchMode) {
this.customSearchMode = this.completions[this.selection].customSearchMode;
updateSynchronously = true;
}
// If the user types, then don't reset any previous text, and reset the selection.
if (this.previousInputValue != null) {
this.previousInputValue = null;
this.selection = -1;
}
return this.update(updateSynchronously);
}
clearUpdateTimer() {
if (this.updateTimer != null) {
window.clearTimeout(this.updateTimer);
this.updateTimer = null;
}
}
shouldActivateCustomSearchMode() {
const queryTerms = this.input.value.trimStart().split(/\s+/);
return (1 < queryTerms.length) && Array.from(this.keywords).includes(queryTerms[0]) && !this.customSearchMode;
}
update(updateSynchronously, callback = null) {
// If the query text becomes a custom search (the user enters a search keyword), then we need to force a
// synchronous update (so that the state is updated immediately).
if (updateSynchronously == null) { updateSynchronously = false; }
if (!updateSynchronously) { updateSynchronously = this.shouldActivateCustomSearchMode(); }
if (updateSynchronously) {
this.clearUpdateTimer();
this.updateCompletions(callback);
} else if ((this.updateTimer == null)) {
// Update asynchronously for a better user experience, and to take some load off the CPU (not every
// keystroke will cause a dedicated update).
this.updateTimer = Utils.setTimeout(this.refreshInterval, () => {
this.updateTimer = null;
return this.updateCompletions(callback);
});
}
this.input.focus();
}
initDom() {
this.box = document.getElementById("vomnibar");
this.input = this.box.querySelector("input");
this.input.addEventListener("input", this.onInput);
this.input.addEventListener("keydown", this.onKeyEvent);
this.input.addEventListener("keypress", this.onKeyEvent);
this.completionList = this.box.querySelector("ul");
this.completionList.style.display = "";
window.addEventListener("focus", () => this.input.focus());
// A click in the vomnibar itself refocuses the input.
this.box.addEventListener("click", event => {
this.input.focus();
return event.stopImmediatePropagation();
});
// A click anywhere else hides the vomnibar.
document.addEventListener("click", () => this.hide());
}
}
//
// Sends requests to a Vomnibox completer on the background page.
//
class BackgroundCompleter {
// The "name" is the background-page completer to connect to: "omni", "tabs", or "bookmarks".
constructor(name) {
// These are the actions we can perform when the user selects a result.
this.name = name;
this.completionActions = {
navigateToUrl(url) { return openInNewTab => Vomnibar.getCompleter().launchUrl(url, openInNewTab); },
switchToTab(tabId) { return () => chrome.runtime.sendMessage({handler: "selectSpecificTab", id: tabId}); }
};
this.port = chrome.runtime.connect({name: "completions"});
this.messageId = null;
this.reset();
this.port.onMessage.addListener(msg => {
switch (msg.handler) {
case "keywords":
this.keywords = msg.keywords;
return this.lastUI.setKeywords(this.keywords);
case "completions":
if (msg.id === this.messageId) {
// The result objects coming from the background page will be of the form:
// { html: "", type: "", url: "", ... }
// Type will be one of [tab, bookmark, history, domain, search], or a custom search engine description.
for (let result of msg.results) {
Object.assign(result, {
performAction:
result.type === "tab" ?
this.completionActions.switchToTab(result.tabId) :
this.completionActions.navigateToUrl(result.url)
});
}
// Handle the message, but only if it hasn't arrived too late.
return this.mostRecentCallback(msg);
}
break;
}
});
}
filter(request) {
const { query, callback } = request;
this.mostRecentCallback = callback;
this.port.postMessage(Object.assign(request, {
handler: "filter",
name: this.name,
id: (this.messageId = Utils.createUniqueId()),
queryTerms: query.trim().split(/\s+/).filter(s => 0 < s.length),
// We don't send these keys.
callback: null
}));
}
reset() {
this.keywords = [];
}
refresh(lastUI) {
this.lastUI = lastUI;
this.reset();
return this.port.postMessage({name: this.name, handler: "refresh"});
}
cancel() {
// Inform the background completer that it may (should it choose to do so) abandon any pending query
// (because the user is typing, and there will be another query along soon).
this.port.postMessage({name: this.name, handler: "cancel"});
}
launchUrl(url, openInNewTab) {
// If the URL is a bookmarklet (so, prefixed with "javascript:"), then we always open it in the current
// tab.
if (openInNewTab)
openInNewTab = !Utils.hasJavascriptPrefix(url);
chrome.runtime.sendMessage({
handler: openInNewTab ? "openUrlInNewTab" : "openUrlInCurrentTab",
url
});
}
}
UIComponentServer.registerHandler(function(event) {
switch (event.data.name != null ? event.data.name : event.data) {
case "hide": Vomnibar.hide(); break;
case "hidden": Vomnibar.onHidden(); break;
case "activate": Vomnibar.activate(event.data); break;
}
});
document.addEventListener("DOMContentLoaded", function() {
DomUtils.injectUserCss(); // Manually inject custom user styles.
});
global.Vomnibar = Vomnibar;