Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions background_scripts/main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "../lib/utils.js";
import "../lib/settings.js";
import "../lib/themes.js";
import "../lib/url_utils.js";
import "../background_scripts/tab_recency.js";
import * as bgUtils from "../background_scripts/bg_utils.js";
Expand Down Expand Up @@ -91,13 +92,24 @@ chrome.webNavigation.onHistoryStateUpdated.addListener(onURLChange); // history.
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onURLChange); // Hash changed.

if (!globalThis.isUnitTests) {
// Cache "content_scripts/vimium.css" in chrome.storage.session for UI components.
// Cache the active theme's "content_scripts/vimium.css" in chrome.storage.session for UI
// components, which inject it into their shadow DOM.
(function () {
const url = chrome.runtime.getURL("content_scripts/vimium.css");
fetch(url).then(async (response) => {
const cacheVimiumCss = async () => {
const { theme } = await chrome.storage.sync.get("theme");
// The theme setting may not exist yet, or may be invalid; fall back to the default theme.
const themeName = Themes.isValidTheme(theme) ? theme : Themes.defaultTheme;
const url = chrome.runtime.getURL(
Themes.getThemePath(themeName, "content_scripts/vimium.css"),
);
const response = await fetch(url);
if (response.ok) {
chrome.storage.session.set({ vimiumCSSInChromeStorage: await response.text() });
}
};
cacheVimiumCss();
chrome.storage.onChanged.addListener((changes, area) => {
if (area == "sync" && changes.theme != null) cacheVimiumCss();
});
})();
}
Expand Down
15 changes: 11 additions & 4 deletions content_scripts/vimium.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
--vimium-foreground-color: white;
--vimium-foreground-text-color: black;
--vimium-link-color: blue;

/* These are consumed by the user-customizable userDefinedLinkHintCss setting, and can be
* overridden by themes. These are the values which Vimium's UI has always displayed. */
--vimium-hint-background: linear-gradient(to bottom, #fff785 0%, #ffc542 100%);
--vimium-hint-border-color: #e3be23;
--vimium-hint-text-color: black;
--vimium-hint-matching-character-color: #d4ac3a;
}

.vimium-reset,
Expand Down Expand Up @@ -112,23 +119,23 @@ div.internal-vimium-hint-marker {
overflow: hidden;
font-size: 11px;
padding: 1px 3px 0px 3px;
background: linear-gradient(to bottom, #fff785 0%, #ffc542 100%);
border: solid 1px #c38a22;
background: var(--vimium-hint-background);
border: solid 1px var(--vimium-hint-border-color);
border-radius: 3px;
box-shadow: 0px 3px 7px 0px rgba(0, 0, 0, 0.3);
z-index: 2147483647;
}

div.internal-vimium-hint-marker span {
color: #302505;
color: var(--vimium-hint-text-color);
font-family: Helvetica, Arial, sans-serif;
font-weight: bold;
font-size: 11px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
}

div.internal-vimium-hint-marker > .matchingCharacter {
color: #d4ac3a;
color: var(--vimium-hint-matching-character-color);
}

div > .vimiumActiveHintMarker span {
Expand Down
52 changes: 50 additions & 2 deletions content_scripts/vimium_frontend.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,43 @@ const onFocus = forTrusted(function (event) {
}
});

// Injects the active theme's stylesheet into this frame. The theme's styles are appended after the
// manifest-declared "content_scripts/vimium.css", so they take precedence in the cascade. The
// default theme requires no overlay, because it is identical to Vimium's built-in styles.
let injectedTheme = null;
function applyTheme() {
if (globalThis.document == null) return;
let theme;
try {
theme = Settings.get("theme");
} catch {
return; // Settings have not yet been loaded.
}
if (!Themes.isValidTheme(theme)) theme = Themes.defaultTheme;
if (theme === injectedTheme) return;
injectedTheme = theme;

for (const el of document.querySelectorAll("link[data-vimium-theme]")) {
el.remove();
}
if (theme === Themes.defaultTheme) return;

const parent = document.head || document.documentElement;
if (parent == null) {
// The document hasn't been created yet; try again once it has.
injectedTheme = null;
DomUtils.documentReady().then(applyTheme);
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = chrome.runtime.getURL(Themes.getThemePath(theme, "content_scripts/vimium.css"));
link.setAttribute("data-vimium-theme", theme);
parent.appendChild(link);
}

Settings.addEventListener("change", () => applyTheme());

// We install these listeners directly (that is, we don't use installListener) because we still need
// to receive events when Vimium is not enabled.
globalThis.addEventListener("focus", onFocus, true);
Expand Down Expand Up @@ -297,9 +334,18 @@ const flashFrame = (() => {
// Create a shadow DOM wrapping the frame so the page's styles don't interfere with ours.
const shadowDOM = highlightedFrameElement.attachShadow({ mode: "open" });

// Inject stylesheet.
// Inject stylesheet. Use the active theme's stylesheet when one is set.
const styleEl = DomUtils.createElement("style");
const vimiumCssUrl = chrome.runtime.getURL("content_scripts/vimium.css");
let theme;
try {
theme = Settings.get("theme");
} catch {
theme = Themes.defaultTheme;
}
if (!Themes.isValidTheme(theme)) theme = Themes.defaultTheme;
const vimiumCssUrl = chrome.runtime.getURL(
Themes.getThemePath(theme, "content_scripts/vimium.css"),
);
styleEl.textContent = `@import url("${vimiumCssUrl}");`;
shadowDOM.appendChild(styleEl);

Expand Down Expand Up @@ -443,6 +489,8 @@ async function checkIfEnabledForUrl() {
// This is the first time we learn what this frame's ID is.
globalThis.frameId = response.frameId;

applyTheme();

if (normalMode == null) installModes();
normalMode.setPassKeys(response.passKeys);
// Hide the HUD if we're not enabled.
Expand Down
11 changes: 7 additions & 4 deletions lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,20 @@ const defaultOptions = {
userDefinedLinkHintCss: `\
div > .vimiumHintMarker {
/* linkhint boxes */
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785),
color-stop(100%,#FFC542));
border: 1px solid #E3BE23;
/* These variables are defined by Vimium's active theme. */
background: var(--vimium-hint-background);
border: 1px solid var(--vimium-hint-border-color);
}

div > .vimiumHintMarker span {
/* linkhint text */
color: black;
color: var(--vimium-hint-text-color);
font-weight: bold;
font-size: 12px;
}

div > .vimiumHintMarker > .matchingCharacter {
color: var(--vimium-hint-matching-character-color);
}\
`,
// Default exclusion rules.
Expand All @@ -54,6 +55,8 @@ div > .vimiumHintMarker > .matchingCharacter {
nextPatterns: "next,more,newer,>,\u203a,\u2192,\xbb,\u226b,>>",
// default/fall back search engine
searchUrl: "https://www.google.com/search?q=",
// The name of the theme used to style Vimium's UI. See themes/ for the available themes.
theme: "default",
// put in an example search engine
searchEngines: `\
w: https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia
Expand Down
126 changes: 126 additions & 0 deletions lib/theme_picker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// A stylable replacement for native <select> elements, used by the theme pickers. The option popup
// of a native <select> cannot be styled with CSS, so this component renders its own list, which
// picks up the styling of the active theme. The container element should have the "theme-picker"
// class and is populated by this function.
export function createThemePicker(container, { options, value, onChange }) {
container.innerHTML = "";

const valueEl = document.createElement("span");
valueEl.className = "theme-picker-value";

const listEl = document.createElement("ul");
listEl.className = "theme-picker-options";
listEl.hidden = true;

container.appendChild(valueEl);
container.appendChild(listEl);

let currentValue = value;
let highlightedIndex = -1;

const optionEls = options.map((option) => {
const optionEl = document.createElement("li");
optionEl.setAttribute("role", "option");
optionEl.textContent = option.label;
optionEl.addEventListener("click", (event) => {
event.stopPropagation();
select(option.value);
});
listEl.appendChild(optionEl);
return optionEl;
});

const indexOfValue = (value) => Math.max(0, options.findIndex((option) => option.value == value));

function render() {
valueEl.textContent = options[indexOfValue(currentValue)].label;
optionEls.forEach((optionEl, i) => {
const isSelected = options[i].value == currentValue;
optionEl.classList.toggle("selected", isSelected);
optionEl.classList.toggle("highlighted", i === highlightedIndex);
optionEl.setAttribute("aria-selected", isSelected);
});
}

function select(value) {
currentValue = value;
close();
render();
onChange(value);
}

const isOpen = () => !listEl.hidden;

const onOutsideClick = (event) => {
if (!container.contains(event.target)) close();
};

function open() {
listEl.hidden = false;
container.setAttribute("aria-expanded", "true");
highlightedIndex = indexOfValue(currentValue);
render();
document.addEventListener("pointerdown", onOutsideClick, true);
}

function close() {
listEl.hidden = true;
container.setAttribute("aria-expanded", "false");
highlightedIndex = -1;
document.removeEventListener("pointerdown", onOutsideClick, true);
render();
}

container.addEventListener("click", () => {
if (isOpen()) {
close();
} else {
open();
}
});

container.addEventListener("keydown", (event) => {
if (!isOpen()) {
if (["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault();
open();
}
return;
}
switch (event.key) {
case "Escape":
event.preventDefault();
close();
break;
case "ArrowDown":
case "ArrowUp":
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
highlightedIndex = Math.min(
options.length - 1,
Math.max(0, highlightedIndex + delta),
);
render();
break;
case "Enter":
case " ":
event.preventDefault();
if (highlightedIndex >= 0) {
select(options[highlightedIndex].value);
}
break;
}
});

render();

return {
setValue(value) {
currentValue = value;
render();
},
getValue() {
return currentValue;
},
};
}
40 changes: 40 additions & 0 deletions lib/themes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Themes are directories containing a full set of Vimium's stylesheets, located in themes/<name>.
// The currently active theme is stored in the "theme" setting.
const Themes = {
defaultTheme: "default",

availableThemes: [
"default",
"ventura",
],

// Display names for the options page's theme picker, keyed by theme name.
displayNames: {
"default": "Default",
"ventura": "macOS Ventura",
},

// The set of files which each theme directory provides. Paths are relative to the repository
// root, and mirror the locations of the corresponding default stylesheets.
themedFilePaths: [
"content_scripts/vimium.css",
"pages/action.css",
"pages/command_listing.css",
"pages/help_dialog_page.css",
"pages/hud_page.css",
"pages/key_mappings.css",
"pages/options.css",
"pages/vomnibar_page.css",
],

// Returns the path of `filePath` (one of themedFilePaths) within the given theme.
getThemePath(theme, filePath) {
return `themes/${theme}/${filePath}`;
},

isValidTheme(theme) {
return this.availableThemes.includes(theme);
},
};

globalThis.Themes = Themes;
42 changes: 42 additions & 0 deletions lib/themes_page_loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Loads the user's selected theme into this extension page, by appending a themed copy of each of
// the page's own stylesheets to the end of <head>. The appended stylesheets override the defaults
// because they come later in the cascade. This file is included by every extension page which
// displays Vimium UI. It must be loaded after lib/themes.js.
(() => {
if (globalThis.chrome?.storage?.sync == null) return;
if (globalThis.Themes == null) {
throw new Error("lib/themes_page_loader.js requires lib/themes.js to be loaded first.");
}

const extensionOrigin = chrome.runtime.getURL("");
let appliedTheme = null;

const apply = async () => {
const values = await chrome.storage.sync.get("theme");
let theme = values.theme ?? Themes.defaultTheme;
if (!Themes.isValidTheme(theme)) theme = Themes.defaultTheme;
if (theme === appliedTheme) return;
appliedTheme = theme;

for (const el of document.querySelectorAll("link[data-vimium-theme]")) {
el.remove();
}
if (theme === Themes.defaultTheme) return;

for (const link of document.querySelectorAll("link[rel=stylesheet]")) {
if (!link.href.startsWith(extensionOrigin)) continue;
const filePath = decodeURI(link.href.slice(extensionOrigin.length));
if (!Themes.themedFilePaths.includes(filePath)) continue;
const themedLink = document.createElement("link");
themedLink.rel = "stylesheet";
themedLink.href = chrome.runtime.getURL(Themes.getThemePath(theme, filePath));
themedLink.setAttribute("data-vimium-theme", theme);
document.head.appendChild(themedLink);
}
};

apply();
chrome.storage.onChanged.addListener((changes, area) => {
if (area == "sync" && changes.theme != null) apply();
});
})();
Loading