mirror of
https://github.com/nkcmr/HyperTab.git
synced 2026-03-20 16:44:07 +01:00
mvp working
This commit is contained in:
132
src/background/main.ts
Normal file
132
src/background/main.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
declare const browser: typeof chrome;
|
||||
|
||||
// tabSwitches is a stack of all tab activations. every time a tab becomes
|
||||
// active it is prepended (unshift) to the front of the array. this means that
|
||||
// there will be duplicate IDs in the array.
|
||||
//
|
||||
// this is handled in 2 ways:
|
||||
// 1. when the popup opens and asks for a list of tabs, we will only insert the tab
|
||||
// in the result when it is _first_ seen and ignored thereafter. however, over
|
||||
// time this will lead to a lot of wasted work since most of the iteration will
|
||||
// be just skipping elements in this array.
|
||||
//
|
||||
// 2. the array is periodically "compacted" by simply running it through a uniq()
|
||||
// operation, therefore reducing most wasted work most of the time.
|
||||
let tabSwitches: number[] = [];
|
||||
|
||||
function uniq<T = any>(array: T[]): T[] {
|
||||
if (array.length <= 1) {
|
||||
return array;
|
||||
}
|
||||
const seen = new Set<T>();
|
||||
const result = [];
|
||||
for (let ele of array) {
|
||||
if (seen.has(ele)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(ele);
|
||||
result.push(ele);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
// periodically compact tabSwitches
|
||||
tabSwitches = uniq(tabSwitches);
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
browser.runtime.onConnect.addListener((port) => {
|
||||
port.onMessage.addListener((message) => {
|
||||
switch (message.rpc) {
|
||||
case "listTabs":
|
||||
console.time(`rpc:listTabs:${message.id}`);
|
||||
browser.tabs
|
||||
.query({})
|
||||
.then((tabs) => {
|
||||
// filter out file:///... things, safari does not really have
|
||||
// safari://... things like chrome
|
||||
tabs = tabs.filter((t) => t.url || t.title);
|
||||
|
||||
const hit = new Map<number, boolean>(
|
||||
tabs.filter((t) => !!t.id).map((t) => [t.id!, false])
|
||||
);
|
||||
const tabsById = new Map<number, chrome.tabs.Tab>(
|
||||
tabs.filter((t) => !!t.id).map((t) => [t.id!, t])
|
||||
);
|
||||
const resultTabs = [];
|
||||
for (let tabId of tabSwitches.slice(1)) {
|
||||
if (hit.get(tabId)) {
|
||||
continue;
|
||||
}
|
||||
hit.set(tabId, true);
|
||||
const tab = tabsById.get(tabId);
|
||||
if (!tab) {
|
||||
continue;
|
||||
}
|
||||
resultTabs.push(tab);
|
||||
}
|
||||
for (let [tabId, didHit] of hit.entries()) {
|
||||
if (didHit) {
|
||||
continue;
|
||||
}
|
||||
const tab = tabsById.get(tabId);
|
||||
if (!tab) {
|
||||
continue;
|
||||
}
|
||||
resultTabs.push(tab);
|
||||
}
|
||||
port.postMessage({
|
||||
result: resultTabs,
|
||||
id: message.id,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
console.timeEnd(`rpc:listTabs:${message.id}`);
|
||||
});
|
||||
return;
|
||||
default:
|
||||
port.postMessage({
|
||||
error: `unknown rpc method: ${message.rpc}`,
|
||||
id: message.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
type Command = () => Promise<void> | void;
|
||||
|
||||
browser.tabs.onActivated.addListener((activeInfo) => {
|
||||
if (activeInfo.tabId) {
|
||||
tabSwitches.unshift(activeInfo.tabId);
|
||||
}
|
||||
});
|
||||
|
||||
const commands = new Map<string, Command>([
|
||||
[
|
||||
"openTabSwitcher",
|
||||
async () => {
|
||||
console.log("open that tab switcher!");
|
||||
await (browser as any).browserAction.openPopup();
|
||||
// await browser.action.openPopup();
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
browser.commands.onCommand.addListener(async (command) => {
|
||||
console.log(`received keyboard shortcut: ${command}`);
|
||||
const fn = commands.get(command);
|
||||
if (!fn) {
|
||||
throw new Error(`unmapped command: ${command}`);
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(fn());
|
||||
} catch (e) {
|
||||
console.error(`command function failed: ${e}`);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// wrapping everything in a try/catch because F-ING SAFARI refuses to
|
||||
// help you understand why background scripts fail. (https://developer.apple.com/forums/thread/705321)
|
||||
console.error(`background startup failure: ${e}`);
|
||||
}
|
||||
387
src/popup/main.tsx
Normal file
387
src/popup/main.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import Fuse, { FuseResult, FuseResultMatch, RangeTuple } from "fuse.js";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import "./scrollIntoViewIfNeededPolyfill";
|
||||
|
||||
function hostname(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const browser = chrome;
|
||||
|
||||
interface BackgroundPage {
|
||||
listTabs(): Promise<chrome.tabs.Tab[]>;
|
||||
}
|
||||
|
||||
function useBackgroundPage(): BackgroundPage {
|
||||
const msgId = useRef<number>(1);
|
||||
const port = useRef<chrome.runtime.Port>(browser.runtime.connect());
|
||||
type PromiseFinishers<T> = {
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
};
|
||||
const waiter = new Map<number, PromiseFinishers<unknown>>();
|
||||
useEffect(() => {
|
||||
const msgListener: Parameters<
|
||||
typeof port.current.onMessage.addListener
|
||||
>["0"] = (message) => {
|
||||
if (!("id" in message) || typeof message.id !== "number") {
|
||||
return;
|
||||
}
|
||||
const promfinishers = waiter.get(message.id);
|
||||
if (!promfinishers) {
|
||||
return;
|
||||
}
|
||||
waiter.delete(message.id);
|
||||
if (message.error) {
|
||||
promfinishers.reject(message.error);
|
||||
} else {
|
||||
promfinishers.resolve(message.result);
|
||||
}
|
||||
};
|
||||
|
||||
port.current.onMessage.addListener(msgListener);
|
||||
return () => {
|
||||
port.current.onMessage.removeListener(msgListener);
|
||||
port.current.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
listTabs() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++msgId.current;
|
||||
waiter.set(id, {
|
||||
reject,
|
||||
resolve(value) {
|
||||
console.timeEnd(`bgpage:rpc:listTabs:${id}`);
|
||||
resolve(value as chrome.tabs.Tab[]);
|
||||
},
|
||||
});
|
||||
console.time(`bgpage:rpc:listTabs:${id}`);
|
||||
port.current.postMessage({ rpc: "listTabs", id });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const focusTab = (tabId: number, windowId: number): void => {
|
||||
browser.tabs.update(tabId, { active: true });
|
||||
browser.windows.update(windowId, { focused: true });
|
||||
window.close();
|
||||
};
|
||||
|
||||
const HighlightMatches: FunctionComponent<{
|
||||
text: string;
|
||||
match?: FuseResultMatch;
|
||||
}> = ({ text, match }) => {
|
||||
if (!match) {
|
||||
return <>{text}</>;
|
||||
}
|
||||
const parts: JSX.Element[] = [];
|
||||
const indicies = structuredClone(match.indices) as RangeTuple[];
|
||||
let currentPart = "";
|
||||
let currentMatchIndicy: RangeTuple | undefined;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (indicies.length > 0 && indicies[0][0] === i) {
|
||||
currentMatchIndicy = indicies.shift();
|
||||
if (currentPart.length > 0) {
|
||||
parts.push(<>{currentPart}</>);
|
||||
currentPart = "";
|
||||
}
|
||||
}
|
||||
currentPart += text[i];
|
||||
if (
|
||||
currentMatchIndicy &&
|
||||
(currentMatchIndicy[1] === i || i === text.length - 1)
|
||||
) {
|
||||
currentMatchIndicy = undefined;
|
||||
parts.push(<b style={{ fontWeight: "bold" }}>{currentPart}</b>);
|
||||
currentPart = "";
|
||||
}
|
||||
}
|
||||
if (currentPart) {
|
||||
parts.push(<>{currentPart}</>);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) => (
|
||||
<React.Fragment key={i}>{p}</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// <big_sigh> ...
|
||||
function faviconsWork(tabURL: string, size: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const hiddenDiv = document.createElement("div", {});
|
||||
hiddenDiv.setAttribute("style", "display:none;");
|
||||
const testImg = document.createElement("img");
|
||||
testImg.src = faviconURL({ url: tabURL } as chrome.tabs.Tab, 32)!;
|
||||
testImg.onerror = () => {
|
||||
document.body.removeChild(hiddenDiv);
|
||||
resolve(false);
|
||||
};
|
||||
testImg.onload = () => {
|
||||
document.body.removeChild(hiddenDiv);
|
||||
resolve(true);
|
||||
};
|
||||
hiddenDiv.appendChild(testImg);
|
||||
document.body.appendChild(hiddenDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function faviconURL(t: chrome.tabs.Tab, size: number): string | undefined {
|
||||
if (t.favIconUrl) {
|
||||
return t.favIconUrl;
|
||||
}
|
||||
if (!t.url) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(browser.runtime.getURL("/_favicon/"));
|
||||
url.searchParams.set("pageUrl", t.url);
|
||||
url.searchParams.set("size", `${size}`);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const Popup: FunctionComponent = () => {
|
||||
const [tabSelector, setTabSelector] = useState(0);
|
||||
const [tabs, setTabs] = useState<chrome.tabs.Tab[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const FAVICON_NOT_SUPPORTED = 0;
|
||||
const FAVICON_SUPPORTED_VIA_EXT_URL = 1;
|
||||
const FAVICON_SUPPORTED_VIA_TAB_DATA = 2;
|
||||
const [enableFavicons, setEnabledFavicons] = useState(FAVICON_NOT_SUPPORTED);
|
||||
useEffect(() => {
|
||||
faviconsWork("https://www.google.com", 32).then((ok) => {
|
||||
if (enableFavicons === 0) {
|
||||
setEnabledFavicons(FAVICON_SUPPORTED_VIA_EXT_URL);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.log({ tabs });
|
||||
}, [tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
setTabSelector(0);
|
||||
}, [setTabSelector, searchQuery]);
|
||||
const searchIndex = useMemo(() => {
|
||||
const result = new Fuse(tabs, {
|
||||
keys: ["title", "url"],
|
||||
includeMatches: true,
|
||||
});
|
||||
return result;
|
||||
}, [tabs]);
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchQuery) {
|
||||
return tabs.map(
|
||||
(t, i): FuseResult<chrome.tabs.Tab> => ({
|
||||
item: t,
|
||||
refIndex: i,
|
||||
})
|
||||
);
|
||||
}
|
||||
return searchIndex.search(searchQuery);
|
||||
}, [tabs, searchIndex, searchQuery]);
|
||||
|
||||
const selectedTab = Math.max(
|
||||
0,
|
||||
Math.min(searchResults.length - 1, tabSelector)
|
||||
);
|
||||
|
||||
const selectNext = useCallback(() => {
|
||||
setTabSelector((n) => Math.min(searchResults.length - 1, n + 1));
|
||||
}, [searchResults]);
|
||||
const selectPrev = useCallback(() => {
|
||||
setTabSelector((n) => Math.max(0, n - 1));
|
||||
}, [setTabSelector]);
|
||||
|
||||
const goToTab = useCallback(() => {
|
||||
focusTab(
|
||||
searchResults[tabSelector].item.id!,
|
||||
searchResults[tabSelector].item.windowId
|
||||
);
|
||||
}, [searchResults, tabSelector]);
|
||||
useHotkeys(
|
||||
"Down",
|
||||
() => {
|
||||
selectNext();
|
||||
},
|
||||
[selectNext]
|
||||
);
|
||||
useHotkeys(
|
||||
"Up",
|
||||
() => {
|
||||
selectPrev();
|
||||
},
|
||||
[selectPrev]
|
||||
);
|
||||
useHotkeys(
|
||||
"Enter",
|
||||
() => {
|
||||
goToTab();
|
||||
},
|
||||
[goToTab]
|
||||
);
|
||||
|
||||
const bgpage = useBackgroundPage();
|
||||
useEffect(() => {
|
||||
console.time("queryTabs");
|
||||
bgpage
|
||||
.listTabs()
|
||||
.then((returnedTabs) => {
|
||||
if (returnedTabs.find((t) => !!t.favIconUrl)) {
|
||||
setEnabledFavicons(FAVICON_SUPPORTED_VIA_TAB_DATA);
|
||||
}
|
||||
setTabs(returnedTabs);
|
||||
})
|
||||
.finally(() => {
|
||||
console.timeEnd("queryTabs");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectedTabEle = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedTabEle.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// scrollIntoViewIfNeeded is non standard but for just safari it works
|
||||
// great!
|
||||
(selectedTabEle as any).current.scrollIntoViewIfNeeded(false);
|
||||
}, [selectedTabEle.current]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: "1em" }}>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
style={{
|
||||
width: "100%",
|
||||
outline: "none",
|
||||
border: "none",
|
||||
fontSize: "1.1em",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
selectNext();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
selectPrev();
|
||||
} else if (e.key === "Enter") {
|
||||
goToTab();
|
||||
}
|
||||
}}
|
||||
spellCheck="false"
|
||||
autoCorrect="false"
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<hr style={{ opacity: "0.3", marginTop: "0px" }} />
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "500px",
|
||||
overflow: "scroll",
|
||||
// minHeight: "300px"
|
||||
}}
|
||||
>
|
||||
{searchResults.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "1.5em",
|
||||
fontSize: "1.1em",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
No Results Found
|
||||
</div>
|
||||
) : null}
|
||||
{searchResults.map((t, i) => {
|
||||
const favicURL = enableFavicons !== 0 ? faviconURL(t.item, 32) : null;
|
||||
return (
|
||||
<div
|
||||
key={t.item.id}
|
||||
className="ht-tab"
|
||||
onClick={() => {
|
||||
focusTab(t.item.id!, t.item.windowId);
|
||||
}}
|
||||
ref={i === tabSelector ? selectedTabEle : undefined}
|
||||
style={{
|
||||
padding: "10px",
|
||||
backgroundColor: i === selectedTab ? "#e9e9e9" : undefined,
|
||||
|
||||
// favicon support
|
||||
...(enableFavicons
|
||||
? {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{favicURL && (
|
||||
<div className="ht-tab-favicon" style={{ marginRight: "1em" }}>
|
||||
<img width={16} height={16} src={favicURL} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
className="ht-tab-title"
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
fontSize: "1.1em",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
{
|
||||
<HighlightMatches
|
||||
text={t.item.title ?? ""}
|
||||
match={t.matches?.find((m) => m.key === "title")}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
className="ht-tab-location"
|
||||
style={{
|
||||
color: "#6e6e6e",
|
||||
}}
|
||||
>
|
||||
{t.item.url ? hostname(t.item.url) : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("main")!);
|
||||
root.render(<Popup />);
|
||||
54
src/popup/scrollIntoViewIfNeededPolyfill.ts
Normal file
54
src/popup/scrollIntoViewIfNeededPolyfill.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
if (!(Element as any).prototype.scrollIntoViewIfNeeded) {
|
||||
console.log("scrollIntoViewIfNeeded polyfill installing...");
|
||||
(Element.prototype as any).scrollIntoViewIfNeeded = function (
|
||||
centerIfNeeded: boolean
|
||||
) {
|
||||
centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded;
|
||||
/** @this {Element} */
|
||||
var parent = this.parentNode,
|
||||
parentComputedStyle = window.getComputedStyle(parent, null),
|
||||
parentBorderTopWidth = parseInt(
|
||||
parentComputedStyle.getPropertyValue("border-top-width")
|
||||
),
|
||||
parentBorderLeftWidth = parseInt(
|
||||
parentComputedStyle.getPropertyValue("border-left-width")
|
||||
),
|
||||
overTop = this.offsetTop - parent.offsetTop < parent.scrollTop,
|
||||
overBottom =
|
||||
this.offsetTop -
|
||||
parent.offsetTop +
|
||||
this.clientHeight -
|
||||
parentBorderTopWidth >
|
||||
parent.scrollTop + parent.clientHeight,
|
||||
overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft,
|
||||
overRight =
|
||||
this.offsetLeft -
|
||||
parent.offsetLeft +
|
||||
this.clientWidth -
|
||||
parentBorderLeftWidth >
|
||||
parent.scrollLeft + parent.clientWidth,
|
||||
alignWithTop = overTop && !overBottom;
|
||||
|
||||
if ((overTop || overBottom) && centerIfNeeded) {
|
||||
parent.scrollTop =
|
||||
this.offsetTop -
|
||||
parent.offsetTop -
|
||||
parent.clientHeight / 2 -
|
||||
parentBorderTopWidth +
|
||||
this.clientHeight / 2;
|
||||
}
|
||||
|
||||
if ((overLeft || overRight) && centerIfNeeded) {
|
||||
parent.scrollLeft =
|
||||
this.offsetLeft -
|
||||
parent.offsetLeft -
|
||||
parent.clientWidth / 2 -
|
||||
parentBorderLeftWidth +
|
||||
this.clientWidth / 2;
|
||||
}
|
||||
|
||||
if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) {
|
||||
this.scrollIntoView(alignWithTop);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user