mirror of
https://github.com/XFox111/TabsAsideExtension.git
synced 2026-04-22 07:58:01 +03:00
!feat: major 3.0 release candidate
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import { collectionCount, getCollections, saveCollections } from "@/features/collectionStorage";
|
||||
import { migrateStorage } from "@/features/migration";
|
||||
import { showWelcomeDialog } from "@/features/v3welcome/utils/showWelcomeDialog";
|
||||
import { SettingsValue } from "@/hooks/useSettings";
|
||||
import { CollectionItem, GraphicsStorage } from "@/models/CollectionModels";
|
||||
import getLogger from "@/utils/getLogger";
|
||||
import { onMessage, sendMessage } from "@/utils/messaging";
|
||||
import saveTabsToCollection from "@/utils/saveTabsToCollection";
|
||||
import sendNotification from "@/utils/sendNotification";
|
||||
import { settings } from "@/utils/settings";
|
||||
import watchTabSelection from "@/utils/watchTabSelection";
|
||||
import { Tabs, Windows } from "wxt/browser";
|
||||
import { Unwatch } from "wxt/storage";
|
||||
|
||||
export default defineBackground(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const logger = getLogger("background");
|
||||
const graphicsCache: GraphicsStorage = {};
|
||||
let listLocation: SettingsValue<"listLocation"> = "sidebar";
|
||||
|
||||
logger("Background script started");
|
||||
|
||||
// Little workaround for opening side panel
|
||||
// See: https://stackoverflow.com/questions/77213045/error-sidepanel-open-may-only-be-called-in-response-to-a-user-gesture-re
|
||||
settings.listLocation.getValue().then(location => listLocation = location);
|
||||
settings.listLocation.watch(newLocation => listLocation = newLocation);
|
||||
|
||||
browser.runtime.onInstalled.addListener(async ({ reason, previousVersion }) =>
|
||||
{
|
||||
logger("onInstalled", reason, previousVersion);
|
||||
|
||||
const previousMajor: number = previousVersion ? parseInt(previousVersion.split(".")[0]) : 0;
|
||||
|
||||
if (reason === "update" && previousMajor < 3)
|
||||
{
|
||||
await migrateStorage();
|
||||
await showWelcomeDialog.setValue(true);
|
||||
browser.runtime.reload();
|
||||
}
|
||||
});
|
||||
|
||||
browser.tabs.onUpdated.addListener((_, __, tab) =>
|
||||
{
|
||||
if (!tab.url)
|
||||
return;
|
||||
|
||||
graphicsCache[tab.url] = {
|
||||
preview: graphicsCache[tab.url]?.preview,
|
||||
icon: tab.favIconUrl ?? graphicsCache[tab.url]?.icon
|
||||
};
|
||||
});
|
||||
|
||||
browser.commands.onCommand.addListener(
|
||||
(command, tab) => performContextAction(command, tab!.windowId!)
|
||||
);
|
||||
|
||||
onMessage("getGraphicsCache", () => graphicsCache);
|
||||
onMessage("addThumbnail", ({ data }) =>
|
||||
{
|
||||
graphicsCache[data.url] = {
|
||||
preview: data.thumbnail,
|
||||
icon: graphicsCache[data.url]?.icon
|
||||
};
|
||||
});
|
||||
|
||||
setupContextMenu();
|
||||
async function setupContextMenu(): Promise<void>
|
||||
{
|
||||
await browser.contextMenus.removeAll();
|
||||
|
||||
const items: Record<string, string> =
|
||||
{
|
||||
"show_collections": i18n.t("actions.show_collections"),
|
||||
"set_aside": i18n.t("actions.set_aside.all"),
|
||||
"save": i18n.t("actions.save.all")
|
||||
};
|
||||
|
||||
Object.entries(items).forEach(([id, title]) => browser.contextMenus.create({
|
||||
id, title,
|
||||
visible: true,
|
||||
contexts: ["action", "page"]
|
||||
}));
|
||||
|
||||
watchTabSelection(async selection =>
|
||||
{
|
||||
await browser.contextMenus.update("set_aside", {
|
||||
title: i18n.t(`actions.set_aside.${selection}`)
|
||||
});
|
||||
await browser.contextMenus.update("save", {
|
||||
title: i18n.t(`actions.save.${selection}`)
|
||||
});
|
||||
});
|
||||
|
||||
browser.contextMenus.onClicked.addListener(
|
||||
({ menuItemId }, tab) => performContextAction((menuItemId as string), tab!.windowId!)
|
||||
);
|
||||
}
|
||||
|
||||
setupBadge();
|
||||
async function setupBadge(): Promise<void>
|
||||
{
|
||||
let unwatchBadge: Unwatch | null = null;
|
||||
const updateBadge = async (count: number | null) =>
|
||||
await browser.action.setBadgeText({ text: count && count > 0 ? count.toString() : "" });
|
||||
|
||||
if (await settings.showBadge.getValue())
|
||||
{
|
||||
updateBadge(await collectionCount.getValue());
|
||||
unwatchBadge = collectionCount.watch(updateBadge);
|
||||
}
|
||||
|
||||
if (import.meta.env.FIREFOX)
|
||||
{
|
||||
await browser.action.setBadgeBackgroundColor({ color: "0f6cbd" });
|
||||
await browser.action.setBadgeTextColor({ color: "white" });
|
||||
}
|
||||
|
||||
settings.showBadge.watch(async showBadge =>
|
||||
{
|
||||
if (showBadge)
|
||||
{
|
||||
updateBadge(await collectionCount.getValue());
|
||||
unwatchBadge = collectionCount.watch(updateBadge);
|
||||
}
|
||||
else
|
||||
{
|
||||
unwatchBadge?.();
|
||||
await browser.action.setBadgeText({ text: "" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupActionButton();
|
||||
async function setupActionButton(): Promise<void>
|
||||
{
|
||||
let unwatchActionTitle: Unwatch | null = null;
|
||||
|
||||
const onClickAction = async (): Promise<void> =>
|
||||
{
|
||||
logger("action.onClicked");
|
||||
const defaultAction = await settings.defaultSaveAction.getValue();
|
||||
await saveTabs(defaultAction === "set_aside");
|
||||
};
|
||||
|
||||
const updateTitle = async (selection: "all" | "selected"): Promise<void> =>
|
||||
{
|
||||
const defaultAction = await settings.defaultSaveAction.getValue();
|
||||
await browser.action.setTitle({ title: i18n.t(`actions.${defaultAction}.${selection}`) });
|
||||
};
|
||||
|
||||
const updateButton = async (action: SettingsValue<"contextAction">): Promise<void> =>
|
||||
{
|
||||
logger("updateButton", action);
|
||||
|
||||
// Cleanup any existing behavior
|
||||
browser.action.onClicked.removeListener(onClickAction);
|
||||
browser.action.onClicked.removeListener(browser?.sidebarAction?.toggle);
|
||||
browser.action.onClicked.removeListener(openCollectionsInTab);
|
||||
|
||||
await browser.action.disable();
|
||||
await browser.action.setTitle({ title: i18n.t("manifest.name") });
|
||||
unwatchActionTitle?.();
|
||||
|
||||
if (!import.meta.env.FIREFOX)
|
||||
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
|
||||
|
||||
// Setup new behavior
|
||||
if (action === "action")
|
||||
{
|
||||
browser.action.onClicked.addListener(onClickAction);
|
||||
unwatchActionTitle = watchTabSelection(updateTitle);
|
||||
await browser.action.enable();
|
||||
}
|
||||
else if (action === "open")
|
||||
{
|
||||
await browser.action.enable();
|
||||
const location = await settings.listLocation.getValue();
|
||||
|
||||
if (location === "sidebar")
|
||||
{
|
||||
if (import.meta.env.FIREFOX)
|
||||
browser.action.onClicked.addListener(browser.sidebarAction.toggle);
|
||||
else
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||
}
|
||||
else if (location !== "popup")
|
||||
browser.action.onClicked.addListener(openCollectionsInTab);
|
||||
}
|
||||
};
|
||||
|
||||
updateButton(await settings.contextAction.getValue());
|
||||
settings.contextAction.watch(updateButton);
|
||||
settings.listLocation.watch(async () => updateButton(await settings.contextAction.getValue()));
|
||||
}
|
||||
|
||||
setupCollectionView();
|
||||
async function setupCollectionView(): Promise<void>
|
||||
{
|
||||
const enforcePinnedTab = async (info: Tabs.OnHighlightedHighlightInfoType): Promise<void> =>
|
||||
{
|
||||
logger("enforcePinnedTab", info);
|
||||
|
||||
const activeWindow: Windows.Window = await browser.windows.getCurrent({ populate: true });
|
||||
|
||||
if (activeWindow.incognito)
|
||||
return;
|
||||
|
||||
if (!activeWindow.tabs!.some(tab =>
|
||||
[tab.url, tab.pendingUrl].includes(browser.runtime.getURL("/sidepanel.html")))
|
||||
)
|
||||
await browser.tabs.create({
|
||||
url: browser.runtime.getURL("/sidepanel.html"),
|
||||
windowId: activeWindow.id,
|
||||
active: false,
|
||||
pinned: true
|
||||
});
|
||||
};
|
||||
|
||||
const updateView = async (viewLocation: SettingsValue<"listLocation">): Promise<void> =>
|
||||
{
|
||||
logger("updateView", viewLocation);
|
||||
|
||||
browser.tabs.onHighlighted.removeListener(enforcePinnedTab);
|
||||
const tabs: Tabs.Tab[] = await browser.tabs.query({
|
||||
currentWindow: true,
|
||||
url: browser.runtime.getURL("/sidepanel.html")
|
||||
});
|
||||
await browser.tabs.remove(tabs.map(tab => tab.id!));
|
||||
|
||||
await browser.action.setPopup({
|
||||
popup: viewLocation === "popup" ? browser.runtime.getURL("/popup.html") : ""
|
||||
});
|
||||
|
||||
if (import.meta.env.FIREFOX)
|
||||
await browser.sidebarAction.setPanel({
|
||||
panel: viewLocation === "sidebar" ? browser.runtime.getURL("/sidepanel.html") : ""
|
||||
});
|
||||
else
|
||||
await chrome.sidePanel.setOptions({ enabled: viewLocation === "sidebar" });
|
||||
|
||||
if (viewLocation === "pinned")
|
||||
{
|
||||
await browser.tabs.create({
|
||||
url: browser.runtime.getURL("/sidepanel.html"),
|
||||
active: false,
|
||||
pinned: true
|
||||
});
|
||||
browser.tabs.onHighlighted.addListener(enforcePinnedTab);
|
||||
}
|
||||
};
|
||||
|
||||
updateView(await settings.listLocation.getValue());
|
||||
settings.listLocation.watch(updateView);
|
||||
}
|
||||
|
||||
function performContextAction(action: string, windowId: number): void
|
||||
{
|
||||
if (action === "show_collections")
|
||||
{
|
||||
if (listLocation === "sidebar" || listLocation === "popup")
|
||||
openCollectionsInView(listLocation, windowId);
|
||||
else
|
||||
openCollectionsInTab();
|
||||
}
|
||||
else
|
||||
saveTabs(action === "set_aside");
|
||||
}
|
||||
|
||||
function openCollectionsInView(view: "sidebar" | "popup", windowId: number): void
|
||||
{
|
||||
if (view === "sidebar")
|
||||
{
|
||||
if (import.meta.env.FIREFOX)
|
||||
browser.sidebarAction.open();
|
||||
else
|
||||
chrome.sidePanel.open({ windowId });
|
||||
}
|
||||
else
|
||||
browser.action.openPopup();
|
||||
}
|
||||
|
||||
async function openCollectionsInTab(): Promise<void>
|
||||
{
|
||||
logger("openCollectionsInTab");
|
||||
|
||||
const currentWindow: Windows.Window = await browser.windows.getCurrent({ populate: true });
|
||||
|
||||
if (currentWindow.incognito)
|
||||
{
|
||||
await browser.windows.create({
|
||||
url: browser.runtime.getURL("/sidepanel.html"),
|
||||
focused: true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
const collectionTab: Tabs.Tab | undefined = currentWindow.tabs!.find(
|
||||
tab => tab.url === browser.runtime.getURL("/sidepanel.html")
|
||||
);
|
||||
|
||||
if (collectionTab)
|
||||
await browser.tabs.update(collectionTab.id, { active: true });
|
||||
else
|
||||
await browser.tabs.create({
|
||||
url: browser.runtime.getURL("/sidepanel.html"),
|
||||
active: true,
|
||||
windowId: currentWindow.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTabs(closeAfterSave: boolean): Promise<void>
|
||||
{
|
||||
logger("saveTabs", closeAfterSave);
|
||||
|
||||
const collection: CollectionItem = await saveTabsToCollection(closeAfterSave);
|
||||
const [savedCollections, cloudIssue] = await getCollections();
|
||||
const newList = [collection, ...savedCollections];
|
||||
|
||||
await saveCollections(newList, cloudIssue === null, graphicsCache);
|
||||
|
||||
sendMessage("refreshCollections", undefined);
|
||||
|
||||
if (await settings.notifyOnSave.getValue())
|
||||
await sendNotification({
|
||||
title: i18n.t("notifications.tabs_saved.title"),
|
||||
message: i18n.t("notifications.tabs_saved.message"),
|
||||
icon: "/notification_icons/cloud_checkmark.png"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (ex)
|
||||
{
|
||||
console.error(ex);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import getLogger from "@/utils/getLogger";
|
||||
import { sendMessage } from "@/utils/messaging";
|
||||
|
||||
// This content script is injected into each browser tab.
|
||||
// It's purpose is to retrive an OpenGraph thumbnail URL from the metadata
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["<all_urls>"],
|
||||
runAt: "document_idle",
|
||||
main
|
||||
});
|
||||
|
||||
const logger = getLogger("contentScript");
|
||||
|
||||
async function main(): Promise<void>
|
||||
{
|
||||
logger("init");
|
||||
|
||||
// This method tries to sequentially retrieve thumbnails from all know meta tags.
|
||||
// It stops on the first thumbnail found.
|
||||
|
||||
// The order of search is:
|
||||
// 1. <meta property="og:image" content="https://example.com/image.jpg">
|
||||
// 2. <meta name="twitter:image" content="https://example.com/image.jpg">
|
||||
// 3. <link rel="thumbnail" href="https://example.com/thumbnail.jpg">
|
||||
// 4. <link rel="image_src" href="https://example.com/image.jpg">
|
||||
|
||||
const thumbnailUrl: string | undefined =
|
||||
document.querySelector<HTMLMetaElement>("head meta[property='og:image']")?.content ??
|
||||
document.querySelector<HTMLMetaElement>("head meta[name='twitter:image']")?.content ??
|
||||
document.querySelector<HTMLLinkElement>("head link[rel=thumbnail]")?.href ??
|
||||
document.querySelector<HTMLLinkElement>("head link[rel=image_src]")?.href;
|
||||
|
||||
if (thumbnailUrl)
|
||||
{
|
||||
logger(`Found thumbnail for "${document.location.href}"`, thumbnailUrl);
|
||||
await sendMessage("addThumbnail", {
|
||||
url: document.location.href,
|
||||
thumbnail: thumbnailUrl
|
||||
});
|
||||
}
|
||||
else
|
||||
logger(`No thumbnail found for "${document.location.href}"`);
|
||||
|
||||
logger("done");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useOptionsStyles = makeStyles({
|
||||
main:
|
||||
{
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto 1fr",
|
||||
height: "100%"
|
||||
},
|
||||
tabList:
|
||||
{
|
||||
flexWrap: "wrap"
|
||||
},
|
||||
article:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: tokens.spacingVerticalMNudge,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||
overflowY: "auto"
|
||||
},
|
||||
section:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "flex-start"
|
||||
},
|
||||
buttonFix:
|
||||
{
|
||||
minHeight: "32px"
|
||||
},
|
||||
horizontalButtons:
|
||||
{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tabs aside | Settings</title>
|
||||
|
||||
<meta name="manifest.open_in_tab" content="false" />
|
||||
|
||||
<style type="text/css">
|
||||
body
|
||||
{
|
||||
height: 500px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
|
||||
import { bskyLink, buyMeACoffeeLink, githubLinks, storeLink, websiteLink } from "@/data/links";
|
||||
import { useBmcStyles } from "@/hooks/useBmcStyles";
|
||||
import extLink from "@/utils/extLink";
|
||||
import { Body1, Button, Caption1, Link, Subtitle1, Text } from "@fluentui/react-components";
|
||||
import { PersonFeedback20Regular } from "@fluentui/react-icons";
|
||||
import { useOptionsStyles } from "../hooks/useOptionsStyles";
|
||||
import Package from "@/package.json";
|
||||
|
||||
export default function AboutSection(): React.ReactElement
|
||||
{
|
||||
const cls = useOptionsStyles();
|
||||
const bmcCls = useBmcStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text as="p">
|
||||
<Subtitle1>{ i18n.t("manifest.name") }</Subtitle1>
|
||||
<sup><Caption1> v{ Package.version }</Caption1></sup>
|
||||
</Text>
|
||||
|
||||
<Body1 as="p">
|
||||
{ i18n.t("options_page.about.developed_by") } (<Link { ...extLink(bskyLink) }>@xfox111.net</Link>)<br />
|
||||
{ i18n.t("options_page.about.licensed_under") } <Link { ...extLink(githubLinks.license) }>{ i18n.t("options_page.about.mit_license") }</Link>
|
||||
</Body1>
|
||||
|
||||
<Body1 as="p">
|
||||
{ i18n.t("options_page.about.translation_cta.text") }<br />
|
||||
<Link { ...extLink(githubLinks.translationGuide) }>
|
||||
{ i18n.t("options_page.about.translation_cta.button") }
|
||||
</Link>
|
||||
</Body1>
|
||||
|
||||
<Body1 as="p">
|
||||
<Link { ...extLink(websiteLink) }>{ i18n.t("options_page.about.links.website") }</Link><br />
|
||||
<Link { ...extLink(githubLinks.repo) }>{ i18n.t("options_page.about.links.source") }</Link><br />
|
||||
<Link { ...extLink(githubLinks.release) }>{ i18n.t("options_page.about.links.changelog") }</Link>
|
||||
</Body1>
|
||||
|
||||
<div className={ cls.horizontalButtons }>
|
||||
<Button
|
||||
as="a" { ...extLink(storeLink) }
|
||||
appearance="primary"
|
||||
icon={ <PersonFeedback20Regular /> }
|
||||
>
|
||||
{ i18n.t("common.cta.feedback") }
|
||||
</Button>
|
||||
<Button
|
||||
as="a" { ...extLink(buyMeACoffeeLink) }
|
||||
appearance="primary" className={ bmcCls.button }
|
||||
icon={ <BuyMeACoffee20Regular /> }
|
||||
>
|
||||
{ i18n.t("common.cta.sponsor") }
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import useSettings, { SettingsValue } from "@/hooks/useSettings";
|
||||
import { Dropdown, Field, Option } from "@fluentui/react-components";
|
||||
|
||||
export default function ActionsSection(): React.ReactElement
|
||||
{
|
||||
const [saveAction, setSaveAction] = useSettings("defaultSaveAction");
|
||||
const [restoreAction, setRestoreAction] = useSettings("defaultRestoreAction");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label={ i18n.t("options_page.actions.options.save_actions.title") }>
|
||||
<Dropdown
|
||||
value={ saveAction ? saveActionOptions[saveAction] : "" }
|
||||
selectedOptions={ [saveAction ?? ""] }
|
||||
onOptionSelect={ (_, e) => setSaveAction(e.optionValue as SaveActionType) }
|
||||
>
|
||||
{ Object.entries(saveActionOptions).map(([value, label]) =>
|
||||
<Option key={ value } value={ value }>
|
||||
{ label }
|
||||
</Option>
|
||||
) }
|
||||
</Dropdown>
|
||||
</Field>
|
||||
|
||||
<Field label={ i18n.t("options_page.actions.options.restore_actions.title") }>
|
||||
<Dropdown
|
||||
value={ restoreAction ? restoreActionOptions[restoreAction] : "" }
|
||||
selectedOptions={ [restoreAction ?? ""] }
|
||||
onOptionSelect={ (_, e) => setRestoreAction(e.optionValue as RestoreActionType) }
|
||||
>
|
||||
{ Object.entries(restoreActionOptions).map(([value, label]) =>
|
||||
<Option key={ value } value={ value }>
|
||||
{ label }
|
||||
</Option>
|
||||
) }
|
||||
</Dropdown>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SaveActionType = SettingsValue<"defaultSaveAction">;
|
||||
type RestoreActionType = SettingsValue<"defaultRestoreAction">;
|
||||
|
||||
const restoreActionOptions: Record<RestoreActionType, string> =
|
||||
{
|
||||
"open": i18n.t("options_page.actions.options.restore_actions.options.open"),
|
||||
"restore": i18n.t("options_page.actions.options.restore_actions.options.restore")
|
||||
};
|
||||
|
||||
const saveActionOptions: Record<SaveActionType, string> =
|
||||
{
|
||||
"set_aside": i18n.t("options_page.actions.options.save_actions.options.set_aside"),
|
||||
"save": i18n.t("options_page.actions.options.save_actions.options.save")
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import useSettings, { SettingsValue } from "@/hooks/useSettings";
|
||||
import { Button, Checkbox, Dropdown, Field, Option, OptionOnSelectData } from "@fluentui/react-components";
|
||||
import { KeyCommand20Regular } from "@fluentui/react-icons";
|
||||
import { useOptionsStyles } from "../hooks/useOptionsStyles";
|
||||
|
||||
export default function GeneralSection(): React.ReactElement
|
||||
{
|
||||
const [alwaysShowToolbars, setAlwaysShowToolbars] = useSettings("alwaysShowToolbars");
|
||||
const [ignorePinned, setIgnorePinned] = useSettings("ignorePinned");
|
||||
const [deletePrompt, setDeletePrompt] = useSettings("deletePrompt");
|
||||
const [showBadge, setShowBadge] = useSettings("showBadge");
|
||||
const [notifyOnSave, setNotifyOnSave] = useSettings("notifyOnSave");
|
||||
const [dismissOnLoad, setDismissOnLoad] = useSettings("dismissOnLoad");
|
||||
const [listLocation, setListLocation] = useSettings("listLocation");
|
||||
const [contextAction, setContextAction] = useSettings("contextAction");
|
||||
|
||||
const cls = useOptionsStyles();
|
||||
|
||||
const openShortcutsPage = (): Promise<any> =>
|
||||
browser.tabs.create({
|
||||
url: "chrome://extensions/shortcuts",
|
||||
active: true
|
||||
});
|
||||
|
||||
const handleListLocationChange = (_: any, e: OptionOnSelectData): void =>
|
||||
{
|
||||
if (e.optionValue === "popup" && contextAction !== "open")
|
||||
setContextAction("open");
|
||||
|
||||
setListLocation(e.optionValue as ListLocationType);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={ cls.section }>
|
||||
<Checkbox
|
||||
label={ i18n.t("options_page.general.options.always_show_toolbars") }
|
||||
checked={ alwaysShowToolbars ?? false }
|
||||
onChange={ (_, e) => setAlwaysShowToolbars(e.checked as boolean) } />
|
||||
<Checkbox
|
||||
label={ i18n.t("options_page.general.options.include_pinned") }
|
||||
checked={ !ignorePinned }
|
||||
onChange={ (_, e) => setIgnorePinned(!e.checked) } />
|
||||
<Checkbox
|
||||
label={ i18n.t("options_page.general.options.show_delete_prompt") }
|
||||
checked={ deletePrompt ?? false }
|
||||
onChange={ (_, e) => setDeletePrompt(e.checked as boolean) } />
|
||||
<Checkbox
|
||||
label={ i18n.t("options_page.general.options.show_badge") }
|
||||
checked={ showBadge ?? false }
|
||||
onChange={ (_, e) => setShowBadge(e.checked as boolean) } />
|
||||
<Checkbox
|
||||
label={ i18n.t("options_page.general.options.show_notification") }
|
||||
checked={ notifyOnSave ?? false }
|
||||
onChange={ (_, e) => setNotifyOnSave(e.checked as boolean) } />
|
||||
<Checkbox
|
||||
label={ i18n.t("options_page.general.options.unload_tabs") }
|
||||
checked={ dismissOnLoad ?? false }
|
||||
onChange={ (_, e) => setDismissOnLoad(e.checked as boolean) } />
|
||||
</section>
|
||||
|
||||
<Field label={ i18n.t("options_page.general.options.list_locations.title") }>
|
||||
<Dropdown
|
||||
value={ listLocation ? listLocationOptions[listLocation] : "" }
|
||||
selectedOptions={ [listLocation ?? ""] }
|
||||
onOptionSelect={ handleListLocationChange }
|
||||
>
|
||||
{ Object.entries(listLocationOptions).map(([key, value]) =>
|
||||
<Option key={ key } value={ key }>
|
||||
{ value }
|
||||
</Option>
|
||||
) }
|
||||
</Dropdown>
|
||||
</Field>
|
||||
|
||||
<Field label={ i18n.t("options_page.general.options.icon_action.title") }>
|
||||
<Dropdown
|
||||
value={ contextAction ? contextActionOptions[contextAction] : "" }
|
||||
selectedOptions={ [contextAction ?? ""] }
|
||||
onOptionSelect={ (_, e) => setContextAction(e.optionValue as ContextActionType) }
|
||||
disabled={ listLocation === "popup" }
|
||||
>
|
||||
{ Object.entries(contextActionOptions).map(([key, value]) =>
|
||||
key === "context" && import.meta.env.FIREFOX
|
||||
? <></> :
|
||||
<Option key={ key } value={ key }>
|
||||
{ value }
|
||||
</Option>
|
||||
) }
|
||||
</Dropdown>
|
||||
</Field>
|
||||
|
||||
{ !import.meta.env.FIREFOX &&
|
||||
<Button icon={ <KeyCommand20Regular /> } onClick={ openShortcutsPage } className={ cls.buttonFix }>
|
||||
{ i18n.t("options_page.general.options.change_shortcuts") }
|
||||
</Button>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ListLocationType = SettingsValue<"listLocation">;
|
||||
type ContextActionType = SettingsValue<"contextAction">;
|
||||
|
||||
const listLocationOptions: Record<ListLocationType, string> =
|
||||
{
|
||||
"sidebar": i18n.t("options_page.general.options.list_locations.options.sidebar"),
|
||||
"popup": i18n.t("options_page.general.options.list_locations.options.popup"),
|
||||
"tab": i18n.t("options_page.general.options.list_locations.options.tab"),
|
||||
"pinned": i18n.t("options_page.general.options.list_locations.options.pinned")
|
||||
};
|
||||
|
||||
const contextActionOptions: Record<ContextActionType, string> =
|
||||
{
|
||||
"action": i18n.t("options_page.general.options.icon_action.options.action"),
|
||||
"context": i18n.t("options_page.general.options.icon_action.options.context"),
|
||||
"open": i18n.t("options_page.general.options.icon_action.options.open")
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import useStorageInfo from "@/hooks/useStorageInfo";
|
||||
import { Button, Field, MessageBar, MessageBarBody, MessageBarTitle, ProgressBar } from "@fluentui/react-components";
|
||||
import { ArrowDownload20Regular, ArrowUpload20Regular } from "@fluentui/react-icons";
|
||||
import { useOptionsStyles } from "../hooks/useOptionsStyles";
|
||||
import exportData from "../utils/exportData";
|
||||
import importData from "../utils/importData";
|
||||
|
||||
export default function StorageSection(): React.ReactElement
|
||||
{
|
||||
const { bytesInUse, storageQuota, usedStorageRatio } = useStorageInfo();
|
||||
const [importResult, setImportResult] = useState<boolean | null>(null);
|
||||
|
||||
const dialog = useDialog();
|
||||
const cls = useOptionsStyles();
|
||||
|
||||
const handleImport = (): void =>
|
||||
dialog.pushPrompt({
|
||||
title: i18n.t("options_page.storage.import_prompt.title"),
|
||||
confirmText: i18n.t("options_page.storage.import_prompt.proceed"),
|
||||
onConfirm: () => importData().then(setImportResult),
|
||||
content: (
|
||||
<MessageBar intent="warning">
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>{ i18n.t("options_page.storage.import_prompt.warning_title") }</MessageBarTitle>
|
||||
|
||||
{ i18n.t("options_page.storage.import_prompt.warning_text") }
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
)
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
label={ i18n.t("options_page.storage.capacity.title") }
|
||||
hint={ i18n.t("options_page.storage.capacity.description", [(bytesInUse / 1024).toFixed(1), storageQuota / 1024]) }
|
||||
validationState={ usedStorageRatio >= 0.8 ? "error" : undefined }
|
||||
>
|
||||
<ProgressBar value={ usedStorageRatio } thickness="large" />
|
||||
</Field>
|
||||
|
||||
<div className={ cls.horizontalButtons }>
|
||||
<Button icon={ <ArrowDownload20Regular /> } onClick={ exportData }>
|
||||
{ i18n.t("options_page.storage.export") }
|
||||
</Button>
|
||||
<Button icon={ <ArrowUpload20Regular /> } onClick={ handleImport }>
|
||||
{ i18n.t("options_page.storage.import") }
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ importResult !== null &&
|
||||
<MessageBar intent={ importResult ? "success" : "error" }>
|
||||
<MessageBarBody>
|
||||
{ importResult === true ?
|
||||
i18n.t("options_page.storage.import_results.success") :
|
||||
i18n.t("options_page.storage.import_results.error")
|
||||
}
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import App from "@/App.tsx";
|
||||
import "@/assets/global.css";
|
||||
import { Tab, TabList } from "@fluentui/react-components";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { useOptionsStyles } from "./hooks/useOptionsStyles.ts";
|
||||
import AboutSection from "./layouts/AboutSection.tsx";
|
||||
import ActionsSection from "./layouts/ActionsSection.tsx";
|
||||
import GeneralSection from "./layouts/GeneralSection.tsx";
|
||||
import StorageSection from "./layouts/StorageSection.tsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<App>
|
||||
<OptionsPage />
|
||||
</App>
|
||||
);
|
||||
|
||||
function OptionsPage(): React.ReactElement
|
||||
{
|
||||
const [selection, setSelection] = useState<SelectionType>("general");
|
||||
const cls = useOptionsStyles();
|
||||
|
||||
return (
|
||||
<main className={ cls.main }>
|
||||
<TabList
|
||||
className={ cls.tabList }
|
||||
selectedValue={ selection }
|
||||
onTabSelect={ (_, data) => setSelection(data.value as SelectionType) }
|
||||
>
|
||||
<Tab value="general">{ i18n.t("options_page.general.title") }</Tab>
|
||||
<Tab value="actions">{ i18n.t("options_page.actions.title") }</Tab>
|
||||
<Tab value="storage">{ i18n.t("options_page.storage.title") }</Tab>
|
||||
<Tab value="about">{ i18n.t("options_page.about.title") }</Tab>
|
||||
</TabList>
|
||||
|
||||
<article className={ cls.article }>
|
||||
{ selection === "general" && <GeneralSection /> }
|
||||
{ selection === "actions" && <ActionsSection /> }
|
||||
{ selection === "storage" && <StorageSection /> }
|
||||
{ selection === "about" && <AboutSection /> }
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type SelectionType = "general" | "actions" | "storage" | "about";
|
||||
@@ -0,0 +1,16 @@
|
||||
export default async function exportData(): Promise<void>
|
||||
{
|
||||
const data: string = JSON.stringify({
|
||||
local: await browser.storage.local.get(null),
|
||||
sync: await browser.storage.sync.get(null)
|
||||
});
|
||||
|
||||
const element: HTMLAnchorElement = document.createElement("a");
|
||||
element.style.display = "none";
|
||||
element.href = `data:application/json;charset=utf-8,${data}`;
|
||||
element.setAttribute("download", "tabs-aside_data.json");
|
||||
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { sendMessage } from "@/utils/messaging";
|
||||
|
||||
export default async function importData(): Promise<boolean | null>
|
||||
{
|
||||
const element: HTMLInputElement = document.createElement("input");
|
||||
element.style.display = "none";
|
||||
element.hidden = true;
|
||||
element.type = "file";
|
||||
element.accept = ".json";
|
||||
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
|
||||
await new Promise(resolve =>
|
||||
{
|
||||
const listener = () =>
|
||||
{
|
||||
element.removeEventListener("input", listener);
|
||||
resolve(null);
|
||||
};
|
||||
element.addEventListener("input", listener);
|
||||
});
|
||||
|
||||
if (!element.files || element.files.length < 1)
|
||||
return null;
|
||||
|
||||
const file: File = element.files[0];
|
||||
const content: string = await file.text();
|
||||
|
||||
document.body.removeChild(element);
|
||||
|
||||
try
|
||||
{
|
||||
const data: any = JSON.parse(content);
|
||||
|
||||
if (data.local)
|
||||
await browser.storage.local.set(data.local);
|
||||
|
||||
if (data.sync)
|
||||
await browser.storage.sync.set(data.sync);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error("Failed to parse JSON", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
sendMessage("refreshCollections", undefined);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tabs aside</title>
|
||||
<style type="text/css">
|
||||
html,
|
||||
body {
|
||||
height: 600px;
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./sidepanel/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles_CollectionView = makeStyles({
|
||||
root:
|
||||
{
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`,
|
||||
borderRadius: tokens.borderRadiusLarge,
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
|
||||
"--border": tokens.colorNeutralForeground1,
|
||||
|
||||
"&:hover .CollectionView__toolbar, &:focus-within .CollectionView__toolbar":
|
||||
{
|
||||
display: "flex"
|
||||
},
|
||||
|
||||
"&:hover":
|
||||
{
|
||||
boxShadow: tokens.shadow4
|
||||
}
|
||||
},
|
||||
color:
|
||||
{
|
||||
border: `${tokens.strokeWidthThick} solid var(--border)`
|
||||
},
|
||||
verticalRoot:
|
||||
{
|
||||
height: "560px"
|
||||
},
|
||||
empty:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
flexGrow: 1,
|
||||
margin: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalSNudge}`,
|
||||
marginBottom: tokens.spacingVerticalSNudge,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: tokens.spacingVerticalS,
|
||||
padding: `${tokens.spacingVerticalXL} ${tokens.spacingHorizontalL}`,
|
||||
color: tokens.colorNeutralForeground3,
|
||||
height: "144px"
|
||||
},
|
||||
emptyText:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingVerticalXS
|
||||
},
|
||||
emptyCaption:
|
||||
{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
columnGap: tokens.spacingHorizontalXS
|
||||
},
|
||||
list:
|
||||
{
|
||||
display: "grid",
|
||||
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||
columnGap: tokens.spacingHorizontalS,
|
||||
rowGap: tokens.spacingHorizontalSNudge,
|
||||
overflowX: "auto",
|
||||
alignItems: "flex-end",
|
||||
alignSelf: "flex-start",
|
||||
maxWidth: "100%",
|
||||
gridAutoFlow: "column"
|
||||
},
|
||||
verticalList:
|
||||
{
|
||||
gridAutoFlow: "row",
|
||||
width: "100%",
|
||||
paddingBottom: tokens.spacingVerticalS
|
||||
},
|
||||
dragOverlay:
|
||||
{
|
||||
cursor: "grabbing !important",
|
||||
transform: "scale(1.05)",
|
||||
boxShadow: `${tokens.shadow16} !important`,
|
||||
"& > div":
|
||||
{
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
sorting:
|
||||
{
|
||||
pointerEvents: "none"
|
||||
},
|
||||
dragging:
|
||||
{
|
||||
visibility: "hidden"
|
||||
},
|
||||
draggingOver:
|
||||
{
|
||||
backgroundColor: tokens.colorBrandBackground2
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import CollectionHeader from "@/entrypoints/sidepanel/components/collections/CollectionHeader";
|
||||
import useDndItem from "@/entrypoints/sidepanel/hooks/useDndItem";
|
||||
import { useGroupColors } from "@/hooks/useGroupColors";
|
||||
import { CollectionItem } from "@/models/CollectionModels";
|
||||
import { horizontalListSortingStrategy, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { Body1Strong, mergeClasses } from "@fluentui/react-components";
|
||||
import { CollectionsRegular } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import CollectionContext from "../contexts/CollectionContext";
|
||||
import { useCollections } from "../contexts/CollectionsProvider";
|
||||
import { useStyles_CollectionView } from "./CollectionView.styles";
|
||||
import GroupView from "./GroupView";
|
||||
import TabView from "./TabView";
|
||||
|
||||
export default function CollectionView({ collection, index: collectionIndex, dragOverlay }: CollectionViewProps): ReactElement
|
||||
{
|
||||
const { tilesView } = useCollections();
|
||||
const {
|
||||
setNodeRef,
|
||||
nodeProps,
|
||||
setActivatorNodeRef,
|
||||
activatorProps,
|
||||
activeItem, isCurrentlySorting, isBeingDragged, isActiveOverThis: isOver
|
||||
} = useDndItem({ id: collectionIndex.toString(), data: { indices: [collectionIndex], item: collection } });
|
||||
|
||||
const isActiveOverThis: boolean = isOver && activeItem?.item.type !== "collection";
|
||||
|
||||
const tabCount: number = useMemo(() => collection.items.flatMap(i => i.type === "group" ? i.items : i).length, [collection.items]);
|
||||
const hasPinnedGroup: boolean = useMemo(() => collection.items.length > 0 &&
|
||||
(collection.items[0].type === "group" && collection.items[0].pinned === true), [collection.items]);
|
||||
|
||||
const cls = useStyles_CollectionView();
|
||||
const colorCls = useGroupColors();
|
||||
|
||||
return (
|
||||
<CollectionContext.Provider value={ { collection, collectionIndex, tabCount, hasPinnedGroup } }>
|
||||
<div
|
||||
ref={ setNodeRef } { ...nodeProps }
|
||||
className={ mergeClasses(
|
||||
cls.root,
|
||||
collection.color && colorCls[collection.color],
|
||||
collection.color && cls.color,
|
||||
!tilesView && cls.verticalRoot,
|
||||
dragOverlay && cls.dragOverlay,
|
||||
isBeingDragged && cls.dragging,
|
||||
isCurrentlySorting && cls.sorting,
|
||||
isActiveOverThis && cls.draggingOver
|
||||
) }
|
||||
>
|
||||
|
||||
<CollectionHeader dragHandleProps={ activatorProps } dragHandleRef={ setActivatorNodeRef } />
|
||||
|
||||
{ collection.items.length < 1 ?
|
||||
<div className={ cls.empty }>
|
||||
<CollectionsRegular fontSize={ 32 } />
|
||||
<Body1Strong>{ i18n.t("collections.empty") }</Body1Strong>
|
||||
</div>
|
||||
:
|
||||
<div className={ mergeClasses(cls.list, !tilesView && cls.verticalList) }>
|
||||
<SortableContext
|
||||
items={ collection.items.map((_, index) => [collectionIndex, index].join("/")) }
|
||||
strategy={ tilesView ? horizontalListSortingStrategy : verticalListSortingStrategy }
|
||||
>
|
||||
{ collection.items.map((i, index) =>
|
||||
i.type === "group" ?
|
||||
<GroupView
|
||||
key={ index } group={ i } indices={ [collectionIndex, index] } />
|
||||
:
|
||||
<TabView key={ index } tab={ i } indices={ [collectionIndex, index] } />
|
||||
) }
|
||||
</SortableContext>
|
||||
</div>
|
||||
}
|
||||
</div >
|
||||
</CollectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type CollectionViewProps =
|
||||
{
|
||||
collection: CollectionItem;
|
||||
index: number;
|
||||
dragOverlay?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { makeStyles, shorthands, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles_EditDialog = makeStyles({
|
||||
surface:
|
||||
{
|
||||
"--border": tokens.colorTransparentStroke,
|
||||
...shorthands.borderWidth(tokens.strokeWidthThick),
|
||||
...shorthands.borderColor("var(--border)")
|
||||
},
|
||||
content:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: tokens.spacingVerticalS
|
||||
},
|
||||
colorPicker:
|
||||
{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
rowGap: tokens.spacingVerticalS,
|
||||
columnGap: tokens.spacingVerticalS
|
||||
},
|
||||
colorButton:
|
||||
{
|
||||
"&[aria-pressed=true]":
|
||||
{
|
||||
color: "var(--text) !important",
|
||||
backgroundColor: "var(--border) !important",
|
||||
|
||||
"& .fui-Button__icon":
|
||||
{
|
||||
color: "var(--text)"
|
||||
}
|
||||
}
|
||||
},
|
||||
colorButton_icon:
|
||||
{
|
||||
color: "var(--border)"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||
import { useGroupColors } from "@/hooks/useGroupColors";
|
||||
import { CollectionItem, GroupItem } from "@/models/CollectionModels";
|
||||
import * as fui from "@fluentui/react-components";
|
||||
import { Circle20Filled, CircleOff20Regular, Pin20Filled, Rename20Regular } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import { useStyles_EditDialog } from "./EditDialog.styles";
|
||||
|
||||
export default function EditDialog(props: GroupEditDialogProps): ReactElement
|
||||
{
|
||||
const [title, setTitle] = useState<string>(
|
||||
(props.type === "collection"
|
||||
? props.collection?.title :
|
||||
(props.group?.pinned !== true ? props.group?.title : ""))
|
||||
?? ""
|
||||
);
|
||||
|
||||
const [color, setColor] = useState<chrome.tabGroups.ColorEnum | undefined | "pinned">(
|
||||
props.type === "collection"
|
||||
? props.collection?.color :
|
||||
props.group?.pinned === true ? "pinned" : (props.group?.color ?? "blue")
|
||||
);
|
||||
|
||||
const cls = useStyles_EditDialog();
|
||||
const colorCls = useGroupColors();
|
||||
|
||||
const handleSave = () =>
|
||||
{
|
||||
if (props.type === "collection")
|
||||
props.onSave({
|
||||
type: "collection",
|
||||
timestamp: props.collection?.timestamp ?? Date.now(),
|
||||
color: (color === "pinned") ? undefined : color!,
|
||||
title,
|
||||
items: props.collection?.items ?? []
|
||||
});
|
||||
else if (color === "pinned")
|
||||
props.onSave({
|
||||
type: "group",
|
||||
pinned: true,
|
||||
items: props.group?.items ?? []
|
||||
});
|
||||
else
|
||||
props.onSave({
|
||||
type: "group",
|
||||
pinned: false,
|
||||
color: color!,
|
||||
title,
|
||||
items: props.group?.items ?? []
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<fui.DialogSurface className={ fui.mergeClasses(cls.surface, (color && color !== "pinned") && colorCls[color]) }>
|
||||
<fui.DialogBody>
|
||||
<fui.DialogTitle>
|
||||
{
|
||||
props.type === "collection" ?
|
||||
i18n.t(`dialogs.edit.title.${props.collection ? "edit" : "new"}_collection`) :
|
||||
i18n.t(`dialogs.edit.title.${props.group ? "edit" : "new"}_group`)
|
||||
}
|
||||
</fui.DialogTitle>
|
||||
|
||||
<fui.DialogContent>
|
||||
<form className={ cls.content }>
|
||||
<fui.Field label={ i18n.t("dialogs.edit.collection_title") }>
|
||||
<fui.Input
|
||||
contentBefore={ <Rename20Regular /> }
|
||||
disabled={ color === "pinned" }
|
||||
placeholder={
|
||||
props.type === "collection" ? getCollectionTitle(props.collection) : ""
|
||||
}
|
||||
value={ color === "pinned" ? i18n.t("groups.pinned") : title }
|
||||
onChange={ (_, e) => setTitle(e.value) } />
|
||||
</fui.Field>
|
||||
<fui.Field label="Color">
|
||||
<div className={ cls.colorPicker }>
|
||||
{ (props.type === "group" && (!props.hidePinned || props.group?.pinned)) &&
|
||||
<fui.ToggleButton
|
||||
checked={ color === "pinned" }
|
||||
onClick={ () => setColor("pinned") }
|
||||
icon={ <Pin20Filled /> }
|
||||
shape="circular"
|
||||
>
|
||||
{ i18n.t("groups.pinned") }
|
||||
</fui.ToggleButton>
|
||||
}
|
||||
{ props.type === "collection" &&
|
||||
<fui.ToggleButton
|
||||
checked={ color === undefined }
|
||||
onClick={ () => setColor(undefined) }
|
||||
icon={ <CircleOff20Regular /> }
|
||||
shape="circular"
|
||||
>
|
||||
{ i18n.t("colors.none") }
|
||||
</fui.ToggleButton>
|
||||
}
|
||||
{ Object.keys(colorCls).map(i =>
|
||||
<fui.ToggleButton
|
||||
checked={ color === i }
|
||||
onClick={ () => setColor(i as chrome.tabGroups.ColorEnum) }
|
||||
className={ fui.mergeClasses(cls.colorButton, colorCls[i as chrome.tabGroups.ColorEnum]) }
|
||||
icon={ {
|
||||
className: cls.colorButton_icon,
|
||||
children: <Circle20Filled />
|
||||
} }
|
||||
key={ i }
|
||||
shape="circular"
|
||||
>
|
||||
{ i18n.t(`colors.${i as chrome.tabGroups.ColorEnum}`) }
|
||||
</fui.ToggleButton>
|
||||
) }
|
||||
</div>
|
||||
</fui.Field>
|
||||
</form>
|
||||
</fui.DialogContent>
|
||||
|
||||
<fui.DialogActions>
|
||||
<fui.DialogTrigger disableButtonEnhancement>
|
||||
<fui.Button appearance="primary" onClick={ handleSave }>{ i18n.t("common.actions.save") }</fui.Button>
|
||||
</fui.DialogTrigger>
|
||||
<fui.DialogTrigger disableButtonEnhancement>
|
||||
<fui.Button appearance="subtle">{ i18n.t("common.actions.cancel") }</fui.Button>
|
||||
</fui.DialogTrigger>
|
||||
</fui.DialogActions>
|
||||
</fui.DialogBody>
|
||||
</fui.DialogSurface>
|
||||
);
|
||||
}
|
||||
|
||||
export type GroupEditDialogProps =
|
||||
{
|
||||
type: "collection";
|
||||
collection?: CollectionItem;
|
||||
onSave: (item: CollectionItem) => void;
|
||||
} |
|
||||
{
|
||||
type: "group";
|
||||
hidePinned?: boolean;
|
||||
group?: GroupItem;
|
||||
onSave: (item: GroupItem) => void;
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles_GroupView = makeStyles({
|
||||
root:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignSelf: "normal",
|
||||
|
||||
padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS}`,
|
||||
paddingBottom: tokens.spacingVerticalNone,
|
||||
borderRadius: tokens.borderRadiusLarge,
|
||||
|
||||
"&:hover .GroupView-toolbar, &:focus-within .GroupView-toolbar":
|
||||
{
|
||||
visibility: "visible"
|
||||
},
|
||||
|
||||
"&:hover":
|
||||
{
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
header:
|
||||
{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
|
||||
borderBottom: `${tokens.strokeWidthThick} solid var(--border)`,
|
||||
borderBottomLeftRadius: tokens.borderRadiusLarge
|
||||
},
|
||||
verticalHeader:
|
||||
{
|
||||
borderBottomLeftRadius: tokens.borderRadiusNone
|
||||
},
|
||||
title:
|
||||
{
|
||||
display: "grid",
|
||||
gridAutoFlow: "column",
|
||||
alignItems: "center",
|
||||
minHeight: "12px",
|
||||
minWidth: "24px",
|
||||
gap: tokens.spacingHorizontalXS,
|
||||
width: "max-content",
|
||||
maxWidth: "160px",
|
||||
|
||||
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
|
||||
paddingBottom: tokens.spacingVerticalXS,
|
||||
marginBottom: "-2px",
|
||||
|
||||
border: `${tokens.strokeWidthThick} solid var(--border)`,
|
||||
borderRadius: `${tokens.borderRadiusLarge} ${tokens.borderRadiusLarge} ${tokens.borderRadiusNone} ${tokens.borderRadiusLarge}`,
|
||||
borderBottom: "none",
|
||||
backgroundColor: "var(--border)",
|
||||
color: "var(--text)"
|
||||
},
|
||||
verticalTitle:
|
||||
{
|
||||
borderBottomLeftRadius: tokens.borderRadiusNone
|
||||
},
|
||||
pinned:
|
||||
{
|
||||
backgroundColor: "transparent"
|
||||
},
|
||||
toolbar:
|
||||
{
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
visibility: "hidden",
|
||||
|
||||
"@media (pointer: coarse)":
|
||||
{
|
||||
visibility: "visible"
|
||||
}
|
||||
},
|
||||
showToolbar:
|
||||
{
|
||||
visibility: "visible"
|
||||
},
|
||||
openAllLink:
|
||||
{
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
empty:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: tokens.colorNeutralForeground3,
|
||||
minWidth: "160px",
|
||||
height: "120px",
|
||||
marginBottom: tokens.spacingVerticalSNudge
|
||||
},
|
||||
verticalEmpty:
|
||||
{
|
||||
height: "auto",
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`
|
||||
},
|
||||
list:
|
||||
{
|
||||
display: "flex",
|
||||
columnGap: tokens.spacingHorizontalS,
|
||||
rowGap: tokens.spacingHorizontalSNudge,
|
||||
height: "100%"
|
||||
},
|
||||
verticalList:
|
||||
{
|
||||
flexFlow: "column"
|
||||
},
|
||||
listContainer:
|
||||
{
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXS}`,
|
||||
paddingBottom: tokens.spacingVerticalNone,
|
||||
height: "100%"
|
||||
},
|
||||
verticalListContainer:
|
||||
{
|
||||
borderLeft: `${tokens.strokeWidthThick} solid var(--border)`,
|
||||
padding: tokens.spacingVerticalSNudge,
|
||||
marginBottom: tokens.spacingVerticalSNudge,
|
||||
borderTopLeftRadius: tokens.borderRadiusNone,
|
||||
borderBottomLeftRadius: tokens.borderRadiusNone,
|
||||
borderTop: "none"
|
||||
},
|
||||
pinnedColor:
|
||||
{
|
||||
"--border": tokens.colorNeutralStrokeAccessible,
|
||||
"--text": tokens.colorNeutralForeground1
|
||||
},
|
||||
dragOverlay:
|
||||
{
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
transform: "scale(1.05)",
|
||||
cursor: "grabbing !important",
|
||||
boxShadow: `${tokens.shadow16} !important`,
|
||||
"& > div":
|
||||
{
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
dragging:
|
||||
{
|
||||
visibility: "hidden"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import GroupContext from "@/entrypoints/sidepanel/contexts/GroupContext";
|
||||
import useDndItem from "@/entrypoints/sidepanel/hooks/useDndItem";
|
||||
import { openGroup } from "@/entrypoints/sidepanel/utils/opener";
|
||||
import { useGroupColors } from "@/hooks/useGroupColors";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import { GroupItem } from "@/models/CollectionModels";
|
||||
import { horizontalListSortingStrategy, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { Caption1Strong, Link, mergeClasses, Tooltip } from "@fluentui/react-components";
|
||||
import { Pin16Filled, WebAssetRegular } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import { useCollections } from "../contexts/CollectionsProvider";
|
||||
import GroupDropZone from "./collections/GroupDropZone";
|
||||
import GroupMoreMenu from "./collections/GroupMoreMenu";
|
||||
import { useStyles_GroupView } from "./GroupView.styles";
|
||||
import TabView from "./TabView";
|
||||
|
||||
export default function GroupView({ group, indices, dragOverlay }: GroupViewProps): ReactElement
|
||||
{
|
||||
const [alwaysShowToolbars] = useSettings("alwaysShowToolbars");
|
||||
const { tilesView } = useCollections();
|
||||
|
||||
const groupId: string = useMemo(() => indices.join("/"), [indices]);
|
||||
|
||||
const {
|
||||
setNodeRef, nodeProps,
|
||||
setActivatorNodeRef, activatorProps,
|
||||
activeItem: active, isBeingDragged
|
||||
} = useDndItem({ id: groupId, data: { indices, item: group }, disabled: group.pinned });
|
||||
|
||||
const disableDropZone: boolean = useMemo(
|
||||
() => active !== null &&
|
||||
(active.item.type !== "tab" || (active.indices[0] === indices[0] && active.indices[1] === indices[1])),
|
||||
[active, indices]);
|
||||
const disableSorting: boolean = useMemo(
|
||||
() => active !== null && (active.item.type !== "tab" || active.indices[0] !== indices[0]),
|
||||
[active, indices]);
|
||||
|
||||
const cls = useStyles_GroupView();
|
||||
const colorCls = useGroupColors();
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={ { group, indices } }>
|
||||
<div
|
||||
ref={ setNodeRef } { ...nodeProps }
|
||||
className={ mergeClasses(
|
||||
cls.root,
|
||||
group.pinned === true ? cls.pinnedColor : colorCls[group.color],
|
||||
isBeingDragged && cls.dragging,
|
||||
dragOverlay && cls.dragOverlay
|
||||
) }
|
||||
>
|
||||
<div className={ mergeClasses(cls.header, !tilesView && cls.verticalHeader) }>
|
||||
|
||||
<div
|
||||
ref={ setActivatorNodeRef } { ...activatorProps }
|
||||
className={ mergeClasses(cls.title, group.pinned && cls.pinned, !tilesView && cls.verticalTitle) }
|
||||
>
|
||||
{ group.pinned === true ?
|
||||
<>
|
||||
<Pin16Filled />
|
||||
<Caption1Strong truncate wrap={ false }>{ i18n.t("groups.pinned") }</Caption1Strong>
|
||||
</>
|
||||
:
|
||||
<Tooltip relationship="description" content={ group.title ?? "" }>
|
||||
<Caption1Strong truncate wrap={ false }>{ group.title }</Caption1Strong>
|
||||
</Tooltip>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={ mergeClasses(cls.toolbar, "GroupView-toolbar", alwaysShowToolbars === true && cls.showToolbar) }>
|
||||
{ group.items.length > 0 &&
|
||||
<Link className={ cls.openAllLink } onClick={ () => openGroup(group, false) }>
|
||||
{ i18n.t("groups.open") }
|
||||
</Link>
|
||||
}
|
||||
|
||||
<GroupMoreMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GroupDropZone
|
||||
disabled={ disableDropZone }
|
||||
className={ mergeClasses(cls.listContainer, !tilesView && cls.verticalListContainer) }
|
||||
>
|
||||
{ group.items.length < 1 ?
|
||||
<div className={ mergeClasses(cls.empty, !tilesView && cls.verticalEmpty) }>
|
||||
<WebAssetRegular fontSize={ 32 } />
|
||||
<Caption1Strong>{ i18n.t("groups.empty") }</Caption1Strong>
|
||||
</div>
|
||||
:
|
||||
<div className={ mergeClasses(cls.list, !tilesView && cls.verticalList) }>
|
||||
<SortableContext
|
||||
items={ group.items.map((_, index) => [...indices, index].join("/")) }
|
||||
disabled={ disableSorting }
|
||||
strategy={ !tilesView ? verticalListSortingStrategy : horizontalListSortingStrategy }
|
||||
>
|
||||
{ group.items.map((i, index) =>
|
||||
<TabView key={ index } tab={ i } indices={ [...indices, index] } />
|
||||
) }
|
||||
</SortableContext>
|
||||
</div>
|
||||
}
|
||||
</GroupDropZone>
|
||||
</div>
|
||||
</GroupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type GroupViewProps =
|
||||
{
|
||||
group: GroupItem;
|
||||
indices: number[];
|
||||
dragOverlay?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles_TabView = makeStyles({
|
||||
root:
|
||||
{
|
||||
display: "grid",
|
||||
position: "relative",
|
||||
|
||||
width: "160px",
|
||||
height: "120px",
|
||||
marginBottom: tokens.spacingVerticalSNudge,
|
||||
|
||||
border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke3}`,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
|
||||
cursor: "pointer",
|
||||
textDecoration: "none !important",
|
||||
userSelect: "none",
|
||||
|
||||
"&:hover button, &:focus-within button":
|
||||
{
|
||||
display: "inline-flex"
|
||||
},
|
||||
|
||||
"&:hover":
|
||||
{
|
||||
boxShadow: tokens.shadow4
|
||||
},
|
||||
|
||||
"&:focus-visible":
|
||||
{
|
||||
outline: `2px solid ${tokens.colorStrokeFocus2}`
|
||||
}
|
||||
},
|
||||
listView:
|
||||
{
|
||||
width: "100%",
|
||||
height: "min-content",
|
||||
marginBottom: tokens.spacingVerticalNone
|
||||
},
|
||||
image:
|
||||
{
|
||||
zIndex: 0,
|
||||
position: "absolute",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
objectFit: "cover"
|
||||
},
|
||||
header:
|
||||
{
|
||||
zIndex: 1,
|
||||
alignSelf: "end",
|
||||
minHeight: "32px",
|
||||
|
||||
display: "grid",
|
||||
gridTemplateColumns: "auto 1fr auto",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalSNudge,
|
||||
paddingLeft: tokens.spacingHorizontalS,
|
||||
|
||||
borderBottomLeftRadius: tokens.borderRadiusMedium,
|
||||
borderBottomRightRadius: tokens.borderRadiusMedium,
|
||||
|
||||
backgroundColor: tokens.colorSubtleBackgroundLightAlphaHover,
|
||||
color: tokens.colorNeutralForeground1,
|
||||
"-webkit-backdrop-filer": "blur(4px)",
|
||||
backdropFilter: "blur(4px)"
|
||||
},
|
||||
icon:
|
||||
{
|
||||
cursor: "grab",
|
||||
|
||||
"&:active":
|
||||
{
|
||||
cursor: "grabbing"
|
||||
}
|
||||
},
|
||||
title:
|
||||
{
|
||||
overflowX: "hidden",
|
||||
justifySelf: "start",
|
||||
maxWidth: "100%"
|
||||
},
|
||||
deleteButton:
|
||||
{
|
||||
display: "none",
|
||||
|
||||
"@media (pointer: coarse)":
|
||||
{
|
||||
display: "inline-flex"
|
||||
}
|
||||
},
|
||||
showDeleteButton:
|
||||
{
|
||||
display: "inline-flex"
|
||||
},
|
||||
dragOverlay:
|
||||
{
|
||||
cursor: "grabbing !important",
|
||||
transform: "scale(1.05)",
|
||||
boxShadow: `${tokens.shadow16} !important`,
|
||||
"& > div":
|
||||
{
|
||||
pointerEvents: "none"
|
||||
}
|
||||
},
|
||||
dragging:
|
||||
{
|
||||
visibility: "hidden"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import faviconPlaceholder from "@/assets/FaviconPlaceholder.svg";
|
||||
import pagePlaceholder from "@/assets/PagePlaceholder.svg";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useCollections } from "@/entrypoints/sidepanel/contexts/CollectionsProvider";
|
||||
import useDndItem from "@/entrypoints/sidepanel/hooks/useDndItem";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import { TabItem } from "@/models/CollectionModels";
|
||||
import { Button, Caption1, Link, mergeClasses, Tooltip } from "@fluentui/react-components";
|
||||
import { Dismiss20Regular } from "@fluentui/react-icons";
|
||||
import { MouseEventHandler, ReactElement } from "react";
|
||||
import { useStyles_TabView } from "./TabView.styles";
|
||||
|
||||
export default function TabView({ tab, indices, dragOverlay }: TabViewProps): ReactElement
|
||||
{
|
||||
const { removeItem, graphics, tilesView } = useCollections();
|
||||
const {
|
||||
setNodeRef, setActivatorNodeRef,
|
||||
nodeProps, activatorProps, isBeingDragged
|
||||
} = useDndItem({ id: indices.join("/"), data: { indices, item: tab } });
|
||||
const dialog = useDialog();
|
||||
|
||||
const [deletePrompt] = useSettings("deletePrompt");
|
||||
const [showToolbar] = useSettings("alwaysShowToolbars");
|
||||
|
||||
const cls = useStyles_TabView();
|
||||
|
||||
const handleDelete: MouseEventHandler<HTMLButtonElement> = (args) =>
|
||||
{
|
||||
args.preventDefault();
|
||||
args.stopPropagation();
|
||||
|
||||
if (deletePrompt)
|
||||
dialog.pushPrompt({
|
||||
title: i18n.t("tabs.delete"),
|
||||
content: i18n.t("common.delete_prompt"),
|
||||
destructive: true,
|
||||
confirmText: i18n.t("common.actions.delete"),
|
||||
onConfirm: () => removeItem(...indices)
|
||||
});
|
||||
else
|
||||
removeItem(...indices);
|
||||
};
|
||||
|
||||
const handleClick: MouseEventHandler<HTMLAnchorElement> = (args) =>
|
||||
{
|
||||
args.preventDefault();
|
||||
browser.tabs.create({ url: tab.url, active: true });
|
||||
};
|
||||
|
||||
const handleAuxClick: MouseEventHandler<HTMLAnchorElement> = (args) =>
|
||||
{
|
||||
args.preventDefault();
|
||||
|
||||
if (args.button === 1)
|
||||
browser.tabs.create({ url: tab.url, active: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ setNodeRef } { ...nodeProps }
|
||||
href={ tab.url }
|
||||
onClick={ handleClick } onAuxClick={ handleAuxClick }
|
||||
className={ mergeClasses(
|
||||
cls.root,
|
||||
!tilesView && cls.listView,
|
||||
isBeingDragged && cls.dragging,
|
||||
dragOverlay && cls.dragOverlay
|
||||
) }
|
||||
>
|
||||
{ tilesView &&
|
||||
<img
|
||||
src={ graphics[tab.url]?.preview ?? pagePlaceholder }
|
||||
onError={ e => e.currentTarget.src = pagePlaceholder }
|
||||
className={ cls.image } draggable={ false } />
|
||||
}
|
||||
|
||||
<div className={ cls.header }>
|
||||
<img
|
||||
ref={ setActivatorNodeRef } { ...activatorProps }
|
||||
src={ graphics[tab.url]?.icon ?? faviconPlaceholder }
|
||||
onError={ e => e.currentTarget.src = faviconPlaceholder }
|
||||
height={ 20 } width={ 20 }
|
||||
className={ cls.icon } draggable={ false } />
|
||||
|
||||
<Tooltip relationship="description" content={ tab.title ?? tab.url }>
|
||||
<Caption1 truncate wrap={ false } className={ cls.title }>
|
||||
{ tab.title ?? tab.url }
|
||||
</Caption1>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip relationship="label" content={ i18n.t("tabs.delete") }>
|
||||
<Button
|
||||
className={ mergeClasses(cls.deleteButton, showToolbar === true && cls.showDeleteButton) }
|
||||
appearance="subtle" icon={ <Dismiss20Regular /> }
|
||||
onClick={ handleDelete } />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export type TabViewProps =
|
||||
{
|
||||
tab: TabItem;
|
||||
indices: number[];
|
||||
dragOverlay?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||
import getSelectedTabs from "@/entrypoints/sidepanel/utils/getSelectedTabs";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import { TabItem } from "@/models/CollectionModels";
|
||||
import { Button, Caption1, makeStyles, mergeClasses, Subtitle2, tokens, Tooltip } from "@fluentui/react-components";
|
||||
import { Add20Filled, Add20Regular, bundleIcon } from "@fluentui/react-icons";
|
||||
import CollectionContext, { CollectionContextType } from "../../contexts/CollectionContext";
|
||||
import { useCollections } from "../../contexts/CollectionsProvider";
|
||||
import CollectionMoreButton from "./CollectionMoreButton";
|
||||
import OpenCollectionButton from "./OpenCollectionButton";
|
||||
|
||||
export default function CollectionHeader({ dragHandleRef, dragHandleProps }: CollectionHeaderProps): React.ReactElement
|
||||
{
|
||||
const { updateCollection } = useCollections();
|
||||
const { tabCount, collection, collectionIndex } = useContext<CollectionContextType>(CollectionContext);
|
||||
const [alwaysShowToolbars] = useSettings("alwaysShowToolbars");
|
||||
|
||||
const AddIcon = bundleIcon(Add20Filled, Add20Regular);
|
||||
|
||||
const handleAddSelected = async () =>
|
||||
{
|
||||
const newTabs: TabItem[] = await getSelectedTabs();
|
||||
updateCollection({ ...collection, items: [...collection.items, ...newTabs] }, collectionIndex);
|
||||
};
|
||||
|
||||
const cls = useStyles();
|
||||
|
||||
return (
|
||||
<div className={ cls.header }>
|
||||
<div className={ cls.title } ref={ dragHandleRef } { ...dragHandleProps }>
|
||||
<Tooltip
|
||||
relationship="description"
|
||||
content={ getCollectionTitle(collection) }
|
||||
positioning="above-start"
|
||||
>
|
||||
<Subtitle2 truncate wrap={ false } className={ cls.titleText }>
|
||||
{ getCollectionTitle(collection) }
|
||||
</Subtitle2>
|
||||
</Tooltip>
|
||||
|
||||
<Caption1>
|
||||
{ i18n.t("collections.tabs_count", [tabCount]) }
|
||||
</Caption1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
mergeClasses(
|
||||
cls.toolbar,
|
||||
"CollectionView__toolbar",
|
||||
alwaysShowToolbars === true && cls.showToolbar
|
||||
) }
|
||||
>
|
||||
{ tabCount < 1 ?
|
||||
<Button icon={ <AddIcon /> } appearance="subtle" onClick={ handleAddSelected }>
|
||||
{ i18n.t("collections.menu.add_selected") }
|
||||
</Button>
|
||||
:
|
||||
<OpenCollectionButton />
|
||||
}
|
||||
|
||||
<CollectionMoreButton onAddSelected={ handleAddSelected } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type CollectionHeaderProps =
|
||||
{
|
||||
dragHandleRef?: React.LegacyRef<HTMLDivElement>;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
header:
|
||||
{
|
||||
color: "var(--border)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`,
|
||||
paddingBottom: tokens.spacingVerticalS
|
||||
},
|
||||
title:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "flex-start",
|
||||
overflow: "hidden"
|
||||
},
|
||||
titleText:
|
||||
{
|
||||
maxWidth: "100%"
|
||||
},
|
||||
toolbar:
|
||||
{
|
||||
display: "none",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
alignItems: "flex-start",
|
||||
|
||||
"@media (pointer: coarse)":
|
||||
{
|
||||
display: "flex"
|
||||
}
|
||||
},
|
||||
showToolbar:
|
||||
{
|
||||
display: "flex"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useDangerStyles } from "@/hooks/useDangerStyles";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import { Button, Menu, MenuDivider, MenuItem, MenuList, MenuPopover, MenuTrigger, Tooltip } from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
import CollectionContext, { CollectionContextType } from "../../contexts/CollectionContext";
|
||||
import { useCollections } from "../../contexts/CollectionsProvider";
|
||||
import exportCollectionToBookmarks from "../../utils/exportCollectionToBookmarks";
|
||||
import EditDialog from "../EditDialog";
|
||||
|
||||
export default function CollectionMoreButton({ onAddSelected }: CollectionMoreButtonProps): React.ReactElement
|
||||
{
|
||||
const { removeItem, updateCollection } = useCollections();
|
||||
const { tabCount, hasPinnedGroup, collection, collectionIndex } = useContext<CollectionContextType>(CollectionContext);
|
||||
const dialog = useDialog();
|
||||
const [deletePrompt] = useSettings("deletePrompt");
|
||||
|
||||
const AddIcon = ic.bundleIcon(ic.Add20Filled, ic.Add20Regular);
|
||||
const GroupIcon = ic.bundleIcon(ic.GroupList20Filled, ic.GroupList20Regular);
|
||||
const EditIcon = ic.bundleIcon(ic.Edit20Filled, ic.Edit20Regular);
|
||||
const DeleteIcon = ic.bundleIcon(ic.Delete20Filled, ic.Delete20Regular);
|
||||
const PinnedIcon = ic.bundleIcon(ic.Pin20Filled, ic.Pin20Regular);
|
||||
const BookmarkIcon = ic.bundleIcon(ic.BookmarkAdd20Filled, ic.BookmarkAdd20Regular);
|
||||
|
||||
const dangerCls = useDangerStyles();
|
||||
|
||||
const handleDelete = () =>
|
||||
{
|
||||
if (deletePrompt)
|
||||
dialog.pushPrompt({
|
||||
title: i18n.t("collections.menu.delete"),
|
||||
content: i18n.t("common.delete_prompt"),
|
||||
destructive: true,
|
||||
confirmText: i18n.t("common.actions.delete"),
|
||||
onConfirm: () => removeItem(collectionIndex)
|
||||
});
|
||||
else
|
||||
removeItem(collectionIndex);
|
||||
};
|
||||
|
||||
const handleEdit = () =>
|
||||
dialog.pushCustom(
|
||||
<EditDialog
|
||||
type="collection"
|
||||
collection={ collection }
|
||||
onSave={ item => updateCollection(item, collectionIndex) } />
|
||||
);
|
||||
|
||||
const handleCreateGroup = () =>
|
||||
dialog.pushCustom(
|
||||
<EditDialog
|
||||
type="group"
|
||||
hidePinned={ hasPinnedGroup }
|
||||
onSave={ group => updateCollection({ ...collection, items: [...collection.items, group] }, collectionIndex) } />
|
||||
);
|
||||
|
||||
const handleAddPinnedGroup = () =>
|
||||
{
|
||||
updateCollection({
|
||||
...collection,
|
||||
items: [
|
||||
{ type: "group", pinned: true, items: [] },
|
||||
...collection.items
|
||||
]
|
||||
}, collectionIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Tooltip relationship="label" content={ i18n.t("common.tooltips.more") }>
|
||||
<MenuTrigger>
|
||||
<Button appearance="subtle" icon={ <ic.MoreVertical20Regular /> } />
|
||||
</MenuTrigger>
|
||||
</Tooltip>
|
||||
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
{ tabCount > 0 &&
|
||||
<MenuItem icon={ <AddIcon /> } onClick={ () => onAddSelected?.() }>
|
||||
{ i18n.t("collections.menu.add_selected") }
|
||||
</MenuItem>
|
||||
}
|
||||
{ !import.meta.env.FIREFOX &&
|
||||
<MenuItem icon={ <GroupIcon /> } onClick={ handleCreateGroup }>
|
||||
{ i18n.t("collections.menu.add_group") }
|
||||
</MenuItem>
|
||||
}
|
||||
{ (import.meta.env.FIREFOX && !hasPinnedGroup) &&
|
||||
<MenuItem icon={ <PinnedIcon /> } onClick={ handleAddPinnedGroup }>
|
||||
{ i18n.t("collections.menu.add_pinned") }
|
||||
</MenuItem>
|
||||
}
|
||||
{ tabCount > 0 &&
|
||||
<MenuItem icon={ <BookmarkIcon /> } onClick={ () => exportCollectionToBookmarks(collection) }>
|
||||
{ i18n.t("collections.menu.export_bookmarks") }
|
||||
</MenuItem>
|
||||
}
|
||||
<MenuDivider />
|
||||
<MenuItem icon={ <EditIcon /> } onClick={ handleEdit }>
|
||||
{ i18n.t("collections.menu.edit") }
|
||||
</MenuItem>
|
||||
<MenuItem icon={ <DeleteIcon /> } className={ dangerCls.menuItem } onClick={ handleDelete }>
|
||||
{ i18n.t("collections.menu.delete") }
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export type CollectionMoreButtonProps =
|
||||
{
|
||||
onAddSelected?: () => void;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { makeStyles, mergeClasses, tokens } from "@fluentui/react-components";
|
||||
import GroupContext, { GroupContextType } from "../../contexts/GroupContext";
|
||||
|
||||
export default function GroupDropZone({ disabled, ...props }: DropZoneProps): React.ReactElement
|
||||
{
|
||||
const { group, indices } = useContext<GroupContextType>(GroupContext);
|
||||
const id: string = indices.join("/") + "_dropzone";
|
||||
const { isOver, setNodeRef, active } = useDroppable({ id, data: { indices, item: group }, disabled });
|
||||
|
||||
const isDragging = !disabled && active !== null;
|
||||
const cls = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ isDragging ? setNodeRef : undefined } { ...props }
|
||||
className={ mergeClasses(cls.root, isDragging && cls.dragging, isOver && cls.over, props.className) }
|
||||
>
|
||||
{ props.children }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type DropZoneProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
||||
& {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root:
|
||||
{
|
||||
borderRadius: tokens.borderRadiusLarge,
|
||||
borderTopRightRadius: tokens.borderRadiusNone,
|
||||
border: `${tokens.strokeWidthThin} solid transparent`
|
||||
},
|
||||
over:
|
||||
{
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
border: `${tokens.strokeWidthThin} solid ${tokens.colorBrandStroke1}`
|
||||
},
|
||||
dragging:
|
||||
{
|
||||
border: `${tokens.strokeWidthThin} dashed ${tokens.colorNeutralStroke1}`
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import EditDialog from "@/entrypoints/sidepanel/components/EditDialog";
|
||||
import CollectionContext, { CollectionContextType } from "@/entrypoints/sidepanel/contexts/CollectionContext";
|
||||
import { useCollections } from "@/entrypoints/sidepanel/contexts/CollectionsProvider";
|
||||
import GroupContext, { GroupContextType } from "@/entrypoints/sidepanel/contexts/GroupContext";
|
||||
import getSelectedTabs from "@/entrypoints/sidepanel/utils/getSelectedTabs";
|
||||
import { useDangerStyles } from "@/hooks/useDangerStyles";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import { TabItem } from "@/models/CollectionModels";
|
||||
import { Button, Menu, MenuItem, MenuList, MenuPopover, MenuTrigger, Tooltip } from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import { openGroup } from "../../utils/opener";
|
||||
|
||||
export default function GroupMoreMenu(): ReactElement
|
||||
{
|
||||
const { group, indices } = useContext<GroupContextType>(GroupContext);
|
||||
const { hasPinnedGroup } = useContext<CollectionContextType>(CollectionContext);
|
||||
const [deletePrompt] = useSettings("deletePrompt");
|
||||
const dialog = useDialog();
|
||||
const { updateGroup, removeItem, ungroup } = useCollections();
|
||||
|
||||
const dangerCls = useDangerStyles();
|
||||
|
||||
const AddIcon = ic.bundleIcon(ic.Add20Filled, ic.Add20Regular);
|
||||
const UngroupIcon = ic.bundleIcon(ic.FullScreenMaximize20Filled, ic.FullScreenMaximize20Regular);
|
||||
const EditIcon = ic.bundleIcon(ic.Edit20Filled, ic.Edit20Regular);
|
||||
const NewWindowIcon = ic.bundleIcon(ic.WindowNew20Filled, ic.WindowNew20Regular);
|
||||
const DeleteIcon = ic.bundleIcon(ic.Delete20Filled, ic.Delete20Regular);
|
||||
|
||||
const handleDelete = () =>
|
||||
{
|
||||
if (deletePrompt)
|
||||
dialog.pushPrompt({
|
||||
title: i18n.t("groups.menu.delete"),
|
||||
content: i18n.t("common.delete_prompt"),
|
||||
confirmText: i18n.t("common.actions.delete"),
|
||||
destructive: true,
|
||||
onConfirm: () => removeItem(...indices)
|
||||
});
|
||||
else
|
||||
removeItem(...indices);
|
||||
};
|
||||
|
||||
const handleEdit = () =>
|
||||
dialog.pushCustom(
|
||||
<EditDialog
|
||||
type="group"
|
||||
group={ group }
|
||||
hidePinned={ hasPinnedGroup }
|
||||
onSave={ item => updateGroup(item, indices[0], indices[1]) } />
|
||||
);
|
||||
|
||||
const handleAddSelected = async () =>
|
||||
{
|
||||
const newTabs: TabItem[] = await getSelectedTabs();
|
||||
updateGroup({ ...group, items: [...group.items, ...newTabs] }, indices[0], indices[1]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Tooltip relationship="label" content={ i18n.t("common.tooltips.more") }>
|
||||
<MenuTrigger>
|
||||
<Button size="small" appearance="subtle" icon={ <ic.MoreVertical20Regular /> } />
|
||||
</MenuTrigger>
|
||||
</Tooltip>
|
||||
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
{ group.items.length > 0 &&
|
||||
<MenuItem icon={ <NewWindowIcon /> } onClick={ () => openGroup(group, true) }>
|
||||
{ i18n.t("groups.menu.new_window") }
|
||||
</MenuItem>
|
||||
}
|
||||
|
||||
<MenuItem icon={ <AddIcon /> } onClick={ handleAddSelected }>
|
||||
{ i18n.t("groups.menu.add_selected") }
|
||||
</MenuItem>
|
||||
|
||||
{ (!import.meta.env.FIREFOX || group.pinned !== true) &&
|
||||
<MenuItem icon={ <EditIcon /> } onClick={ handleEdit }>
|
||||
{ i18n.t("groups.menu.edit") }
|
||||
</MenuItem>
|
||||
}
|
||||
{ group.items.length > 0 &&
|
||||
<MenuItem
|
||||
className={ dangerCls.menuItem }
|
||||
icon={ <UngroupIcon /> }
|
||||
onClick={ () => ungroup(indices[0], indices[1]) }
|
||||
>
|
||||
{ i18n.t("groups.menu.ungroup") }
|
||||
</MenuItem>
|
||||
}
|
||||
<MenuItem className={ dangerCls.menuItem } icon={ <DeleteIcon /> } onClick={ handleDelete }>
|
||||
{ i18n.t("groups.menu.delete") }
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import browserLocaleKey from "@/utils/browserLocaleKey";
|
||||
import { Menu, MenuButtonProps, MenuItem, MenuList, MenuPopover, MenuTrigger, SplitButton } from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
import CollectionContext, { CollectionContextType } from "../../contexts/CollectionContext";
|
||||
import { useCollections } from "../../contexts/CollectionsProvider";
|
||||
import { openCollection } from "../../utils/opener";
|
||||
|
||||
export default function OpenCollectionButton(): React.ReactElement
|
||||
{
|
||||
const [defaultAction] = useSettings("defaultRestoreAction");
|
||||
const { removeItem } = useCollections();
|
||||
const dialog = useDialog();
|
||||
const { collection, collectionIndex } = useContext<CollectionContextType>(CollectionContext);
|
||||
|
||||
const OpenIcon = ic.bundleIcon(ic.Open20Filled, ic.Open20Regular);
|
||||
const RestoreIcon = ic.bundleIcon(ic.ArrowExportRtl20Filled, ic.ArrowExportRtl20Regular);
|
||||
const NewWindowIcon = ic.bundleIcon(ic.WindowNew20Filled, ic.WindowNew20Regular);
|
||||
const InPrivateIcon = ic.bundleIcon(ic.TabInPrivate20Filled, ic.TabInPrivate20Regular);
|
||||
|
||||
const handleIncognito = async () =>
|
||||
{
|
||||
if (await browser.extension.isAllowedIncognitoAccess())
|
||||
openCollection(collection, "incognito");
|
||||
else
|
||||
dialog.pushPrompt({
|
||||
title: i18n.t("collections.incognito_check.title"),
|
||||
content: (
|
||||
<>
|
||||
{ i18n.t(`collections.incognito_check.message.${browserLocaleKey}.p1`) }
|
||||
<br />
|
||||
<br />
|
||||
{ i18n.t(`collections.incognito_check.message.${browserLocaleKey}.p2`) }
|
||||
</>
|
||||
),
|
||||
confirmText: i18n.t("collections.incognito_check.action"),
|
||||
onConfirm: async () => import.meta.env.FIREFOX ?
|
||||
await browser.runtime.openOptionsPage() :
|
||||
await browser.tabs.create({
|
||||
url: `chrome://extensions/?id=${browser.runtime.id}`,
|
||||
active: true
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpen = (mode: "current" | "new") =>
|
||||
() => openCollection(collection, mode);
|
||||
|
||||
const handleRestore = async () =>
|
||||
{
|
||||
await openCollection(collection);
|
||||
removeItem(collectionIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
{ (triggerProps: MenuButtonProps) => defaultAction === "restore" ?
|
||||
<SplitButton
|
||||
appearance="subtle" icon={ <RestoreIcon /> } menuButton={ triggerProps }
|
||||
primaryActionButton={ { onClick: handleRestore } }
|
||||
>
|
||||
{ i18n.t("collections.actions.restore") }
|
||||
</SplitButton>
|
||||
:
|
||||
<SplitButton
|
||||
appearance="subtle" icon={ <OpenIcon /> } menuButton={ triggerProps }
|
||||
primaryActionButton={ { onClick: handleOpen("current") } }
|
||||
>
|
||||
{ i18n.t("collections.actions.open") }
|
||||
</SplitButton>
|
||||
}
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
{ defaultAction === "restore" ?
|
||||
<MenuItem icon={ <OpenIcon /> } onClick={ handleOpen("current") }>
|
||||
{ i18n.t("collections.actions.open") }
|
||||
</MenuItem>
|
||||
:
|
||||
<MenuItem icon={ <RestoreIcon /> } onClick={ handleRestore }>
|
||||
{ i18n.t("collections.actions.restore") }
|
||||
</MenuItem>
|
||||
}
|
||||
<MenuItem icon={ <NewWindowIcon /> } onClick={ () => handleOpen("new") }>
|
||||
{ i18n.t("collections.actions.new_window") }
|
||||
</MenuItem>
|
||||
<MenuItem icon={ <InPrivateIcon /> } onClick={ handleIncognito }>
|
||||
{ i18n.t(`collections.actions.incognito.${browserLocaleKey}`) }
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CollectionItem } from "@/models/CollectionModels";
|
||||
import { createContext } from "react";
|
||||
|
||||
const CollectionContext = createContext<CollectionContextType>(null!);
|
||||
|
||||
export default CollectionContext;
|
||||
|
||||
export type CollectionContextType =
|
||||
{
|
||||
collection: CollectionItem;
|
||||
collectionIndex: number;
|
||||
tabCount: number;
|
||||
hasPinnedGroup: boolean;
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CloudStorageIssueType, getCollections, graphics as graphicsStorage, saveCollections } from "@/features/collectionStorage";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import { CollectionItem, GraphicsStorage, GroupItem } from "@/models/CollectionModels";
|
||||
import getLogger from "@/utils/getLogger";
|
||||
import { onMessage } from "@/utils/messaging";
|
||||
import { createContext } from "react";
|
||||
import mergePinnedGroups from "../utils/mergePinnedGroups";
|
||||
|
||||
const logger = getLogger("CollectionsProvider");
|
||||
|
||||
const CollectionsContext = createContext<CollectionsContextType>(null!);
|
||||
|
||||
export const useCollections = () => useContext<CollectionsContextType>(CollectionsContext);
|
||||
|
||||
export default function CollectionsProvider({ children }: React.PropsWithChildren): React.ReactElement
|
||||
{
|
||||
const [collections, setCollections] = useState<CollectionItem[]>(null!);
|
||||
const [cloudIssue, setCloudIssue] = useState<CloudStorageIssueType | null>(null);
|
||||
const [graphics, setGraphics] = useState<GraphicsStorage>({});
|
||||
const [tilesView] = useSettings("tilesView");
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
refreshCollections();
|
||||
onMessage("refreshCollections", refreshCollections);
|
||||
}, []);
|
||||
|
||||
const refreshCollections = async (): Promise<void> =>
|
||||
{
|
||||
const [result, issues] = await getCollections();
|
||||
setCloudIssue(issues);
|
||||
setCollections(result);
|
||||
setGraphics(await graphicsStorage.getValue());
|
||||
};
|
||||
|
||||
const updateStorage = async (collectionList: CollectionItem[]): Promise<void> =>
|
||||
{
|
||||
logger("save");
|
||||
collectionList.forEach(mergePinnedGroups);
|
||||
setCollections([...collectionList]);
|
||||
await saveCollections(collectionList, cloudIssue === null);
|
||||
setGraphics(await graphicsStorage.getValue());
|
||||
};
|
||||
|
||||
const addCollection = (collection: CollectionItem): void =>
|
||||
{
|
||||
updateStorage([collection, ...collections]);
|
||||
};
|
||||
|
||||
const removeItem = (...indices: number[]): void =>
|
||||
{
|
||||
if (indices.length > 2)
|
||||
(collections[indices[0]].items[indices[1]] as GroupItem).items.splice(indices[2], 1);
|
||||
else if (indices.length > 1)
|
||||
collections[indices[0]].items.splice(indices[1], 1);
|
||||
else
|
||||
collections.splice(indices[0], 1);
|
||||
|
||||
updateStorage(collections);
|
||||
};
|
||||
|
||||
const updateCollections = (collectionList: CollectionItem[]): void =>
|
||||
{
|
||||
updateStorage(collectionList);
|
||||
};
|
||||
|
||||
const updateCollection = (collection: CollectionItem, index: number): void =>
|
||||
{
|
||||
collections[index] = collection;
|
||||
updateStorage(collections);
|
||||
};
|
||||
|
||||
const updateGroup = (group: GroupItem, collectionIndex: number, groupIndex: number): void =>
|
||||
{
|
||||
collections[collectionIndex].items[groupIndex] = group;
|
||||
updateStorage(collections);
|
||||
};
|
||||
|
||||
const ungroup = (collectionIndex: number, groupIndex: number): void =>
|
||||
{
|
||||
const group = collections[collectionIndex].items[groupIndex] as GroupItem;
|
||||
collections[collectionIndex].items.splice(groupIndex, 1, ...group.items);
|
||||
updateStorage(collections);
|
||||
};
|
||||
|
||||
return (
|
||||
<CollectionsContext.Provider
|
||||
value={ {
|
||||
collections, cloudIssue, graphics, tilesView: tilesView!,
|
||||
refreshCollections, removeItem, ungroup,
|
||||
updateCollections, updateCollection, updateGroup, addCollection
|
||||
} }
|
||||
>
|
||||
{ children }
|
||||
</CollectionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type CollectionsContextType =
|
||||
{
|
||||
collections: CollectionItem[] | null;
|
||||
cloudIssue: CloudStorageIssueType | null;
|
||||
graphics: GraphicsStorage;
|
||||
tilesView: boolean;
|
||||
|
||||
refreshCollections: () => Promise<void>;
|
||||
addCollection: (collection: CollectionItem) => void;
|
||||
|
||||
updateCollections: (collections: CollectionItem[]) => void;
|
||||
updateCollection: (collection: CollectionItem, index: number) => void;
|
||||
updateGroup: (group: GroupItem, collectionIndex: number, groupIndex: number) => void;
|
||||
ungroup: (collectionIndex: number, groupIndex: number) => void;
|
||||
|
||||
removeItem: (...indices: number[]) => void;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { GroupItem } from "@/models/CollectionModels";
|
||||
import { createContext } from "react";
|
||||
|
||||
const GroupContext = createContext<GroupContextType>(null!);
|
||||
|
||||
export default GroupContext;
|
||||
|
||||
export type GroupContextType =
|
||||
{
|
||||
group: GroupItem;
|
||||
indices: number[];
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CollectionItem, GroupItem, TabItem } from "@/models/CollectionModels";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { Arguments } from "@dnd-kit/sortable/dist/hooks/useSortable";
|
||||
|
||||
export default function useDndItem(args: Arguments): DndItemHook
|
||||
{
|
||||
const {
|
||||
setActivatorNodeRef, setNodeRef,
|
||||
transform, attributes, listeners,
|
||||
active, over,
|
||||
isDragging,
|
||||
isSorting,
|
||||
isOver
|
||||
} = useSortable({ transition: null, ...args });
|
||||
|
||||
return {
|
||||
setActivatorNodeRef,
|
||||
setNodeRef,
|
||||
nodeProps:
|
||||
{
|
||||
style:
|
||||
{
|
||||
transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined
|
||||
},
|
||||
...attributes
|
||||
},
|
||||
activatorProps:
|
||||
{
|
||||
...listeners,
|
||||
style:
|
||||
{
|
||||
cursor: args.disabled ? undefined : "grab"
|
||||
}
|
||||
},
|
||||
activeItem: active ? { ...active.data.current, id: active.id } as DndItem : null,
|
||||
overItem: over ? { ...over.data.current, id: over.id } as DndItem : null,
|
||||
isBeingDragged: isDragging,
|
||||
isCurrentlySorting: isSorting,
|
||||
isActiveOverThis: isOver
|
||||
};
|
||||
}
|
||||
|
||||
export type DndItem =
|
||||
{
|
||||
id: string;
|
||||
indices: number[];
|
||||
item: (TabItem | CollectionItem | GroupItem);
|
||||
};
|
||||
|
||||
export type DndItemHook =
|
||||
{
|
||||
setNodeRef: (element: HTMLElement | null) => void;
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
nodeProps: React.HTMLAttributes<HTMLElement>;
|
||||
activatorProps: React.HTMLAttributes<HTMLElement>;
|
||||
activeItem: DndItem | null;
|
||||
overItem: DndItem | null;
|
||||
isBeingDragged: boolean;
|
||||
isCurrentlySorting: boolean;
|
||||
isActiveOverThis: boolean;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tabs aside</title>
|
||||
|
||||
<meta name="manifest.open_at_install" content="true" />
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles_CollectionListView = makeStyles({
|
||||
root:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: tokens.spacingVerticalM,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`,
|
||||
overflowX: "hidden",
|
||||
overflowY: "auto"
|
||||
},
|
||||
collectionList:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: tokens.spacingVerticalM
|
||||
},
|
||||
searchBar:
|
||||
{
|
||||
boxShadow: tokens.shadow2
|
||||
},
|
||||
emptySearch:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: tokens.spacingVerticalS
|
||||
},
|
||||
empty:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: tokens.spacingVerticalS,
|
||||
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
|
||||
color: tokens.colorNeutralForeground2
|
||||
},
|
||||
msgBar:
|
||||
{
|
||||
flex: "none"
|
||||
},
|
||||
listView:
|
||||
{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import CollectionView from "@/entrypoints/sidepanel/components/CollectionView";
|
||||
import GroupView from "@/entrypoints/sidepanel/components/GroupView";
|
||||
import { DndItem } from "@/entrypoints/sidepanel/hooks/useDndItem";
|
||||
import CloudIssueMessages from "@/entrypoints/sidepanel/layouts/collections/messages/CloudIssueMessages";
|
||||
import CtaMessage from "@/entrypoints/sidepanel/layouts/collections/messages/CtaMessage";
|
||||
import filterCollections, { CollectionFilterType } from "@/entrypoints/sidepanel/utils/filterCollections";
|
||||
import sortCollections from "@/entrypoints/sidepanel/utils/sortCollections";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import { CollectionItem } from "@/models/CollectionModels";
|
||||
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { rectSortingStrategy, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { Body1, Button, Caption1, mergeClasses, Subtitle2 } from "@fluentui/react-components";
|
||||
import { ArrowUndo20Regular, SearchInfo24Regular, Sparkle48Regular } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import TabView from "../../components/TabView";
|
||||
import CollectionContext from "../../contexts/CollectionContext";
|
||||
import { useCollections } from "../../contexts/CollectionsProvider";
|
||||
import applyReorder from "../../utils/dnd/applyReorder";
|
||||
import { collisionDetector } from "../../utils/dnd/collisionDetector";
|
||||
import { useStyles_CollectionListView } from "./CollectionListView.styles";
|
||||
import SearchBar from "./SearchBar";
|
||||
import StorageCapacityIssueMessage from "./messages/StorageCapacityIssueMessage";
|
||||
|
||||
export default function CollectionListView(): ReactElement
|
||||
{
|
||||
const { tilesView, updateCollections, collections } = useCollections();
|
||||
|
||||
const [sortMode, setSortMode] = useSettings("sortMode");
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [colors, setColors] = useState<CollectionFilterType["colors"]>([]);
|
||||
|
||||
const [active, setActive] = useState<DndItem | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { delay: 100, tolerance: 0 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 300, tolerance: 20 } })
|
||||
);
|
||||
|
||||
const resultList = useMemo(
|
||||
() => sortCollections(filterCollections(collections, { query, colors }), sortMode),
|
||||
[query, colors, sortMode, collections]
|
||||
);
|
||||
|
||||
const cls = useStyles_CollectionListView();
|
||||
|
||||
const resetFilter = useCallback(() =>
|
||||
{
|
||||
setQuery("");
|
||||
setColors([]);
|
||||
}, []);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent): void =>
|
||||
{
|
||||
setActive(event.active.data.current as DndItem);
|
||||
};
|
||||
|
||||
const handleDragEnd = (args: DragEndEvent): void =>
|
||||
{
|
||||
setActive(null);
|
||||
const result: CollectionItem[] | null = applyReorder(resultList, args);
|
||||
|
||||
if (result !== null)
|
||||
{
|
||||
updateCollections(result);
|
||||
if (sortMode !== "custom")
|
||||
setSortMode("custom");
|
||||
}
|
||||
};
|
||||
|
||||
if (sortMode === null || collections === null)
|
||||
return <></>;
|
||||
|
||||
if (collections.length < 1)
|
||||
return (
|
||||
<article className={ cls.empty }>
|
||||
<Sparkle48Regular />
|
||||
<Subtitle2 align="center">{ i18n.t("main.list.empty.title") }</Subtitle2>
|
||||
<Caption1 align="center">{ i18n.t("main.list.empty.message") }</Caption1>
|
||||
</article>
|
||||
);
|
||||
|
||||
return (
|
||||
<article className={ cls.root }>
|
||||
<SearchBar
|
||||
query={ query } onQueryChange={ setQuery }
|
||||
filter={ colors } onFilterChange={ setColors }
|
||||
sort={ sortMode } onSortChange={ setSortMode }
|
||||
onReset={ resetFilter } />
|
||||
|
||||
<CtaMessage className={ cls.msgBar } />
|
||||
<StorageCapacityIssueMessage className={ cls.msgBar } />
|
||||
<CloudIssueMessages className={ cls.msgBar } />
|
||||
|
||||
{ resultList.length < 1 ?
|
||||
<div className={ cls.emptySearch }>
|
||||
<SearchInfo24Regular />
|
||||
<Subtitle2>{ i18n.t("main.list.empty_search.title") }</Subtitle2>
|
||||
<Body1>{ i18n.t("main.list.empty_search.message") }</Body1>
|
||||
<Button appearance="subtle" icon={ <ArrowUndo20Regular /> } onClick={ resetFilter }>
|
||||
{ i18n.t("common.actions.reset_filters") }
|
||||
</Button>
|
||||
</div>
|
||||
:
|
||||
<section className={ mergeClasses(cls.collectionList, !tilesView && cls.listView) }>
|
||||
<DndContext
|
||||
sensors={ sensors }
|
||||
collisionDetection={ collisionDetector(!tilesView) }
|
||||
onDragStart={ handleDragStart }
|
||||
onDragEnd={ handleDragEnd }
|
||||
>
|
||||
<SortableContext
|
||||
items={ resultList.map((_, index) => index.toString()) }
|
||||
strategy={ tilesView ? verticalListSortingStrategy : rectSortingStrategy }
|
||||
>
|
||||
{ resultList.map((collection, index) =>
|
||||
<CollectionView key={ index } collection={ collection } index={ index } />
|
||||
) }
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay dropAnimation={ null }>
|
||||
{ active &&
|
||||
<>
|
||||
{ active.item.type === "collection" &&
|
||||
<CollectionView collection={ active.item } index={ -1 } dragOverlay />
|
||||
}
|
||||
{ active.item.type === "group" &&
|
||||
<CollectionContext.Provider
|
||||
value={ {
|
||||
tabCount: 0,
|
||||
collectionIndex: active.indices[0],
|
||||
collection: resultList[active.indices[0]],
|
||||
hasPinnedGroup: true
|
||||
} }
|
||||
>
|
||||
<GroupView group={ active.item } indices={ [-1] } dragOverlay />
|
||||
</CollectionContext.Provider>
|
||||
}
|
||||
{ active.item.type === "tab" &&
|
||||
<TabView tab={ active.item } indices={ [-1] } dragOverlay />
|
||||
}
|
||||
</>
|
||||
}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useGroupColors } from "@/hooks/useGroupColors";
|
||||
import * as fui from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
import { CollectionFilterType } from "../../utils/filterCollections";
|
||||
|
||||
export default function FilterCollectionsButton({ value, onChange }: FilterCollectionsButtonProps): React.ReactElement
|
||||
{
|
||||
const cls = useStyles();
|
||||
const colorCls = useGroupColors();
|
||||
|
||||
const ColorFilterIcon = ic.bundleIcon(ic.Color20Filled, ic.Color20Regular);
|
||||
const ColorIcon = ic.bundleIcon(ic.Circle20Filled, ic.CircleHalfFill20Regular);
|
||||
const NoColorIcon = ic.bundleIcon(ic.CircleOffFilled, ic.CircleOffRegular);
|
||||
const AnyColorIcon = ic.bundleIcon(ic.PhotoFilter20Filled, ic.PhotoFilter20Regular);
|
||||
|
||||
return (
|
||||
<fui.Menu
|
||||
checkedValues={ !value || value.length < 1 ? { default: ["any"] } : { colors: value } }
|
||||
onCheckedValueChange={ (_, e) =>
|
||||
onChange?.(e.checkedItems.includes("any") ? [] : e.checkedItems as CollectionFilterType["colors"])
|
||||
}
|
||||
>
|
||||
|
||||
<fui.Tooltip relationship="label" content={ i18n.t("main.list.searchbar.filter") }>
|
||||
<fui.MenuTrigger disableButtonEnhancement>
|
||||
<fui.Button appearance="subtle" icon={ <ColorFilterIcon /> } />
|
||||
</fui.MenuTrigger>
|
||||
</fui.Tooltip>
|
||||
|
||||
<fui.MenuPopover>
|
||||
<fui.MenuList>
|
||||
<fui.MenuItemCheckbox name="default" value="any" icon={ <AnyColorIcon /> }>
|
||||
{ i18n.t("colors.any") }
|
||||
</fui.MenuItemCheckbox>
|
||||
<fui.MenuDivider />
|
||||
<fui.MenuItemCheckbox name="colors" value="none" icon={ <NoColorIcon /> }>
|
||||
{ i18n.t("colors.none") }
|
||||
</fui.MenuItemCheckbox>
|
||||
|
||||
{ Object.keys(colorCls).map(i =>
|
||||
<fui.MenuItemCheckbox
|
||||
key={ i } name="colors" value={ i }
|
||||
icon={
|
||||
<ColorIcon
|
||||
className={ fui.mergeClasses(
|
||||
cls.colorIcon,
|
||||
colorCls[i as chrome.tabGroups.ColorEnum]
|
||||
) } />
|
||||
}
|
||||
>
|
||||
{ i18n.t(`colors.${i as chrome.tabGroups.ColorEnum}`) }
|
||||
</fui.MenuItemCheckbox>
|
||||
) }
|
||||
</fui.MenuList>
|
||||
</fui.MenuPopover>
|
||||
</fui.Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export type FilterCollectionsButtonProps =
|
||||
{
|
||||
value?: CollectionFilterType["colors"];
|
||||
onChange?: (value: CollectionFilterType["colors"]) => void;
|
||||
};
|
||||
|
||||
const useStyles = fui.makeStyles({
|
||||
colorIcon:
|
||||
{
|
||||
color: "var(--border)"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Button, Input, makeStyles, tokens, Tooltip } from "@fluentui/react-components";
|
||||
import { ArrowUndo20Filled, ArrowUndo20Regular, bundleIcon, Search20Regular } from "@fluentui/react-icons";
|
||||
import { CollectionFilterType } from "../../utils/filterCollections";
|
||||
import { CollectionSortMode } from "../../utils/sortCollections";
|
||||
import FilterCollectionsButton from "./FilterCollectionsButton";
|
||||
import SortCollectionsButton from "./SortCollectionsButton";
|
||||
|
||||
export default function SearchBar(props: SearchBarProps): React.ReactElement
|
||||
{
|
||||
const cls = useStyles();
|
||||
|
||||
const ResetIcon = bundleIcon(ArrowUndo20Filled, ArrowUndo20Regular);
|
||||
|
||||
return (
|
||||
<Input
|
||||
className={ cls.root }
|
||||
appearance="filled-lighter"
|
||||
contentBefore={ <Search20Regular /> }
|
||||
placeholder={ i18n.t("main.list.searchbar.title") }
|
||||
value={ props.query } onChange={ (_, e) => props.onQueryChange?.(e.value) }
|
||||
contentAfter={
|
||||
<>
|
||||
{ (props.query || (props.filter && props.filter.length > 0)) &&
|
||||
<Tooltip relationship="label" content={ i18n.t("common.actions.reset_filters") }>
|
||||
<Button appearance="subtle" icon={ <ResetIcon /> } onClick={ props.onReset } />
|
||||
</Tooltip>
|
||||
}
|
||||
<FilterCollectionsButton value={ props.filter } onChange={ props.onFilterChange } />
|
||||
<SortCollectionsButton value={ props.sort } onChange={ props.onSortChange } />
|
||||
</>
|
||||
} />
|
||||
);
|
||||
}
|
||||
|
||||
export type SearchBarProps =
|
||||
{
|
||||
query?: string;
|
||||
onQueryChange?: (query: string) => void;
|
||||
filter?: CollectionFilterType["colors"];
|
||||
onFilterChange?: (filter: CollectionFilterType["colors"]) => void;
|
||||
sort?: CollectionSortMode;
|
||||
onSortChange?: (sort: CollectionSortMode) => void;
|
||||
onReset?: () => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root:
|
||||
{
|
||||
boxShadow: tokens.shadow2
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { CollectionSortMode } from "@/entrypoints/sidepanel/utils/sortCollections";
|
||||
import { Button, Menu, MenuItemRadio, MenuList, MenuPopover, MenuTrigger, Tooltip } from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
|
||||
export default function SortCollectionsButton({ value, onChange }: SortCollectionsButtonProps): React.ReactElement
|
||||
{
|
||||
const ColorSortIcon = ic.bundleIcon(ic.ArrowSort20Filled, ic.ArrowSort20Regular);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
checkedValues={ { sort: value ? [value] : [] } }
|
||||
onCheckedValueChange={ (_, e) => onChange?.(e.checkedItems[0] as CollectionSortMode) }
|
||||
>
|
||||
<Tooltip relationship="label" content={ i18n.t("main.list.searchbar.sort.title") }>
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
<Button appearance="subtle" icon={ <ColorSortIcon /> } />
|
||||
</MenuTrigger>
|
||||
</Tooltip>
|
||||
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
{ Object.entries(sortIcons).map(([key, Icon]) =>
|
||||
<MenuItemRadio key={ key } name="sort" value={ key } icon={ <Icon /> }>
|
||||
{ i18n.t(`main.list.searchbar.sort.options.${key as CollectionSortMode}`) }
|
||||
</MenuItemRadio>
|
||||
) }
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export type SortCollectionsButtonProps =
|
||||
{
|
||||
value?: CollectionSortMode | null;
|
||||
onChange?: (value: CollectionSortMode) => void;
|
||||
};
|
||||
|
||||
const sortIcons: Record<CollectionSortMode, ic.FluentIcon> =
|
||||
{
|
||||
newest: ic.bundleIcon(ic.Sparkle20Filled, ic.Sparkle20Regular),
|
||||
oldest: ic.bundleIcon(ic.History20Filled, ic.History20Regular),
|
||||
ascending: ic.bundleIcon(ic.TextSortAscending20Filled, ic.TextSortAscending20Regular),
|
||||
descending: ic.bundleIcon(ic.TextSortDescending20Filled, ic.TextSortDescending20Regular),
|
||||
custom: ic.bundleIcon(ic.Star20Filled, ic.Star20Regular)
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import resolveConflict from "@/features/collectionStorage/utils/resolveConflict";
|
||||
import { Button, MessageBar, MessageBarActions, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
|
||||
import { ArrowUpload20Regular, CloudArrowDown20Regular, Wrench20Regular } from "@fluentui/react-icons";
|
||||
import { useCollections } from "../../../contexts/CollectionsProvider";
|
||||
|
||||
export default function CloudIssueMessages(props: MessageBarProps): React.ReactElement
|
||||
{
|
||||
const { cloudIssue, refreshCollections } = useCollections();
|
||||
|
||||
const overrideStorageWith = async (source: "local" | "sync") =>
|
||||
{
|
||||
await resolveConflict(source);
|
||||
await refreshCollections();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ cloudIssue === "parse_error" &&
|
||||
<MessageBar intent="error" layout="multiline" { ...props }>
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>{ i18n.t("parse_error_message.title") }</MessageBarTitle>
|
||||
{ i18n.t("parse_error_message.message") }
|
||||
</MessageBarBody>
|
||||
<MessageBarActions>
|
||||
<Button icon={ <Wrench20Regular /> } onClick={ () => overrideStorageWith("local") }>
|
||||
{ i18n.t("parse_error_message.action") }
|
||||
</Button>
|
||||
</MessageBarActions>
|
||||
</MessageBar>
|
||||
}
|
||||
|
||||
{ cloudIssue === "merge_conflict" &&
|
||||
<MessageBar intent="warning" layout="multiline" { ...props }>
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>{ i18n.t("merge_conflict_message.title") }</MessageBarTitle>
|
||||
{ i18n.t("merge_conflict_message.message") }
|
||||
</MessageBarBody>
|
||||
<MessageBarActions>
|
||||
<Button icon={ <ArrowUpload20Regular /> } onClick={ () => overrideStorageWith("local") }>
|
||||
{ i18n.t("merge_conflict_message.accept_local") }
|
||||
</Button>
|
||||
<Button icon={ <CloudArrowDown20Regular /> } onClick={ () => overrideStorageWith("sync") }>
|
||||
{ i18n.t("merge_conflict_message.accept_cloud") }
|
||||
</Button>
|
||||
</MessageBarActions>
|
||||
</MessageBar>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
|
||||
import { buyMeACoffeeLink, storeLink } from "@/data/links";
|
||||
import { useBmcStyles } from "@/hooks/useBmcStyles";
|
||||
import extLink from "@/utils/extLink";
|
||||
import { Button, Link, MessageBar, MessageBarActions, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
|
||||
import { DismissRegular, HeartFilled } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
export default function CtaMessage(props: MessageBarProps): ReactElement
|
||||
{
|
||||
const [counter, setCounter] = useState<number>(0);
|
||||
const bmcCls = useBmcStyles();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
ctaCounter.getValue().then(c =>
|
||||
{
|
||||
if (c >= 0)
|
||||
{
|
||||
setCounter(c);
|
||||
ctaCounter.setValue(c + 1);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetCounter = async (counter: number) =>
|
||||
{
|
||||
await ctaCounter.setValue(counter);
|
||||
setCounter(counter);
|
||||
};
|
||||
|
||||
if (counter < 50)
|
||||
return <></>;
|
||||
|
||||
return (
|
||||
<MessageBar layout="multiline" icon={ <HeartFilled color="red" /> } { ...props }>
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>{ i18n.t("cta_message.title") }</MessageBarTitle>
|
||||
{ i18n.t("cta_message.message") } <Link { ...extLink(storeLink) }>{ i18n.t("cta_message.feedback") }</Link>
|
||||
</MessageBarBody>
|
||||
<MessageBarActions
|
||||
containerAction={
|
||||
<Button icon={ <DismissRegular /> } appearance="transparent" onClick={ () => resetCounter(0) } />
|
||||
}
|
||||
>
|
||||
<Button
|
||||
as="a" { ...extLink(buyMeACoffeeLink) }
|
||||
onClick={ () => resetCounter(-1) }
|
||||
appearance="primary"
|
||||
className={ bmcCls.button }
|
||||
icon={ <BuyMeACoffee20Regular /> }
|
||||
>
|
||||
{ i18n.t("common.cta.sponsor") }
|
||||
</Button>
|
||||
</MessageBarActions>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
const ctaCounter = storage.defineItem<number>("local:ctaCounter", { fallback: 0 });
|
||||
@@ -0,0 +1,21 @@
|
||||
import useStorageInfo from "@/hooks/useStorageInfo";
|
||||
import { MessageBar, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
|
||||
|
||||
export default function StorageCapacityIssueMessage(props: MessageBarProps): JSX.Element
|
||||
{
|
||||
const { usedStorageRatio } = useStorageInfo();
|
||||
|
||||
if (usedStorageRatio < 0.8)
|
||||
return <></>;
|
||||
|
||||
return (
|
||||
<MessageBar intent="warning" layout="multiline" { ...props }>
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>
|
||||
{ i18n.t("storage_full_message.title", [(usedStorageRatio * 100).toFixed(1)]) }
|
||||
</MessageBarTitle>
|
||||
{ i18n.t("storage_full_message.message") }
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCollections } from "@/entrypoints/sidepanel/contexts/CollectionsProvider";
|
||||
import useSettings, { SettingsValue } from "@/hooks/useSettings";
|
||||
import saveTabsToCollection from "@/utils/saveTabsToCollection";
|
||||
import watchTabSelection from "@/utils/watchTabSelection";
|
||||
import { Menu, MenuButtonProps, MenuItem, MenuList, MenuPopover, MenuTrigger, SplitButton } from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
export default function ActionButton(): ReactElement
|
||||
{
|
||||
const { addCollection } = useCollections();
|
||||
const [defaultAction] = useSettings("defaultSaveAction");
|
||||
const [selection, setSelection] = useState<"all" | "selected">("all");
|
||||
|
||||
const handleAction = async (primary: boolean) =>
|
||||
{
|
||||
const colection = await saveTabsToCollection(primary === (defaultAction === "set_aside"));
|
||||
addCollection(colection);
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return watchTabSelection(setSelection);
|
||||
}, []);
|
||||
|
||||
if (defaultAction === null)
|
||||
return <div />;
|
||||
|
||||
const primaryActionKey: ActionsKey = `${defaultAction}.${selection}`;
|
||||
const PrimaryIcon = actionIcons[primaryActionKey];
|
||||
const secondaryActionKey: ActionsKey = `${defaultAction === "save" ? "set_aside" : "save"}.${selection}`;
|
||||
const SecondaryIcon = actionIcons[secondaryActionKey];
|
||||
|
||||
return (
|
||||
<Menu positioning="below-end">
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
{ (triggerProps: MenuButtonProps) => (
|
||||
<SplitButton
|
||||
appearance="primary"
|
||||
icon={ <PrimaryIcon /> }
|
||||
menuButton={ triggerProps }
|
||||
primaryActionButton={ { onClick: () => handleAction(true) } }
|
||||
>
|
||||
{ i18n.t(`actions.${primaryActionKey}`) }
|
||||
</SplitButton>
|
||||
) }
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
<MenuItem icon={ <SecondaryIcon /> } onClick={ () => handleAction(false) }>
|
||||
{ i18n.t(`actions.${secondaryActionKey}`) }
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
const actionIcons: Record<ActionsKey, ic.FluentIcon> =
|
||||
{
|
||||
"save.all": ic.bundleIcon(ic.SaveArrowRight20Filled, ic.SaveArrowRight20Regular),
|
||||
"save.selected": ic.bundleIcon(ic.SaveCopy20Filled, ic.SaveCopy20Regular),
|
||||
"set_aside.all": ic.bundleIcon(ic.ArrowRight20Filled, ic.ArrowRight20Regular),
|
||||
"set_aside.selected": ic.bundleIcon(ic.CopyArrowRight20Filled, ic.CopyArrowRight20Regular)
|
||||
};
|
||||
|
||||
export type ActionsKey = `${SettingsValue<"defaultSaveAction">}.${"all" | "selected"}`;
|
||||
|
||||
export type ActionsValue =
|
||||
{
|
||||
label: string;
|
||||
icon: ic.FluentIcon;
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useCollections } from "@/entrypoints/sidepanel/contexts/CollectionsProvider";
|
||||
import { Button, makeStyles, tokens, Tooltip } from "@fluentui/react-components";
|
||||
import { CollectionsAddRegular } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import EditDialog from "../../components/EditDialog";
|
||||
import ActionButton from "./ActionButton";
|
||||
import MoreButton from "./MoreButton";
|
||||
|
||||
export default function Header(): ReactElement
|
||||
{
|
||||
const { addCollection } = useCollections();
|
||||
const dialog = useDialog();
|
||||
const cls = useStyles();
|
||||
|
||||
const handleCreateCollection = () =>
|
||||
dialog.pushCustom(
|
||||
<EditDialog
|
||||
type="collection"
|
||||
onSave={ addCollection } />
|
||||
);
|
||||
|
||||
return (
|
||||
<header className={ cls.header }>
|
||||
<ActionButton />
|
||||
|
||||
<div className={ cls.headerSecondary }>
|
||||
<MoreButton />
|
||||
<Tooltip relationship="label" content={ i18n.t("main.header.create_collection") }>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={ <CollectionsAddRegular /> }
|
||||
onClick={ handleCreateCollection } />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
header:
|
||||
{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`,
|
||||
gap: tokens.spacingHorizontalS
|
||||
},
|
||||
headerSecondary:
|
||||
{
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalXS
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BuyMeACoffee20Filled, BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
|
||||
import { buyMeACoffeeLink, githubLinks, storeLink } from "@/data/links";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import extLink from "@/utils/extLink";
|
||||
import sendNotification from "@/utils/sendNotification";
|
||||
import * as fui from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
export default function MoreButton(): ReactElement
|
||||
{
|
||||
const [tilesView, setTilesView] = useSettings("tilesView");
|
||||
|
||||
const SettingsIcon: ic.FluentIcon = ic.bundleIcon(ic.Settings20Filled, ic.Settings20Regular);
|
||||
const ViewIcon: ic.FluentIcon = ic.bundleIcon(ic.GridKanban20Filled, ic.GridKanban20Regular);
|
||||
const FeedbackIcon: ic.FluentIcon = ic.bundleIcon(ic.PersonFeedback20Filled, ic.PersonFeedback20Regular);
|
||||
const LearnIcon: ic.FluentIcon = ic.bundleIcon(ic.QuestionCircle20Filled, ic.QuestionCircle20Regular);
|
||||
const BmcIcon: ic.FluentIcon = ic.bundleIcon(BuyMeACoffee20Filled, BuyMeACoffee20Regular);
|
||||
|
||||
return (
|
||||
<fui.Menu
|
||||
hasIcons hasCheckmarks
|
||||
checkedValues={ { tilesView: tilesView ? ["true"] : [] } }
|
||||
onCheckedValueChange={ (_, e) => setTilesView(e.checkedItems.length > 0) }
|
||||
>
|
||||
<fui.Tooltip relationship="label" content={ i18n.t("common.tooltips.more") }>
|
||||
<fui.MenuTrigger disableButtonEnhancement>
|
||||
<fui.Button appearance="subtle" icon={ <ic.MoreVerticalRegular /> } />
|
||||
</fui.MenuTrigger>
|
||||
</fui.Tooltip>
|
||||
|
||||
<fui.MenuPopover>
|
||||
<fui.MenuList>
|
||||
|
||||
<fui.MenuItem icon={ <SettingsIcon /> } onClick={ () => browser.runtime.openOptionsPage() }>
|
||||
{ i18n.t("options_page.title") }
|
||||
</fui.MenuItem>
|
||||
<fui.MenuItemCheckbox name="tilesView" value="true" icon={ <ViewIcon /> }>
|
||||
{ i18n.t("main.header.menu.tiles_view") }
|
||||
</fui.MenuItemCheckbox>
|
||||
|
||||
<fui.MenuDivider />
|
||||
|
||||
<fui.MenuItemLink icon={ <BmcIcon /> } { ...extLink(buyMeACoffeeLink) }>
|
||||
{ i18n.t("common.cta.sponsor") }
|
||||
</fui.MenuItemLink>
|
||||
<fui.MenuItemLink icon={ <FeedbackIcon /> } { ...extLink(storeLink) } >
|
||||
{ i18n.t("common.cta.feedback") }
|
||||
</fui.MenuItemLink>
|
||||
<fui.MenuItemLink icon={ <LearnIcon /> } { ...extLink(githubLinks.release) } >
|
||||
{ i18n.t("main.header.menu.changelog") }
|
||||
</fui.MenuItemLink>
|
||||
|
||||
{ import.meta.env.DEV &&
|
||||
<fui.MenuGroup>
|
||||
<fui.MenuGroupHeader>Dev tools</fui.MenuGroupHeader>
|
||||
<fui.MenuItem
|
||||
icon={ <ic.ArrowClockwise20Regular /> }
|
||||
onClick={ () => document.location.reload() }
|
||||
>
|
||||
Reload page
|
||||
</fui.MenuItem>
|
||||
<fui.MenuItem
|
||||
icon={ <ic.Open20Regular /> }
|
||||
onClick={ () => browser.tabs.create({ url: browser.runtime.getURL("/sidepanel.html"), active: true }) }
|
||||
>
|
||||
Open in tab
|
||||
</fui.MenuItem>
|
||||
<fui.MenuItem
|
||||
icon={ <ic.Alert20Regular /> }
|
||||
onClick={ async () => await sendNotification({
|
||||
icon: "/notification_icons/cloud_error.png",
|
||||
message: "Notification message",
|
||||
title: "Notification title"
|
||||
}) }
|
||||
>
|
||||
Show test notification
|
||||
</fui.MenuItem>
|
||||
</fui.MenuGroup>
|
||||
}
|
||||
</fui.MenuList>
|
||||
</fui.MenuPopover>
|
||||
</fui.Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import App from "@/App.tsx";
|
||||
import "@/assets/global.css";
|
||||
import { useLocalMigration } from "@/features/migration";
|
||||
import useWelcomeDialog from "@/features/v3welcome/hooks/useWelcomeDialog";
|
||||
import { Divider, makeStyles } from "@fluentui/react-components";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import CollectionsProvider from "./contexts/CollectionsProvider";
|
||||
import CollectionListView from "./layouts/collections/CollectionListView";
|
||||
import Header from "./layouts/header/Header";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<App>
|
||||
<MainPage />
|
||||
</App>
|
||||
);
|
||||
|
||||
document.title = i18n.t("manifest.name");
|
||||
|
||||
function MainPage(): React.ReactElement
|
||||
{
|
||||
const cls = useStyles();
|
||||
|
||||
useLocalMigration();
|
||||
useWelcomeDialog();
|
||||
|
||||
return (
|
||||
<CollectionsProvider>
|
||||
<main className={ cls.main }>
|
||||
<Header />
|
||||
<Divider />
|
||||
<CollectionListView />
|
||||
</main>
|
||||
</CollectionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
main:
|
||||
{
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto auto 1fr",
|
||||
height: "100vh"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CollectionItem, GroupItem, TabItem } from "@/models/CollectionModels";
|
||||
import { DragEndEvent } from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { DndItem } from "../../hooks/useDndItem";
|
||||
|
||||
export default function applyReorder(collections: CollectionItem[], { over, active }: DragEndEvent): null | CollectionItem[]
|
||||
{
|
||||
if (!over || active.id === over.id)
|
||||
return null;
|
||||
|
||||
const activeItem: DndItem = active.data.current as DndItem;
|
||||
const overItem: DndItem = over.data.current as DndItem;
|
||||
|
||||
console.log("DragEnd", `active: ${active.id} ${activeItem.item.type}`, `over: ${over.id} ${overItem.item.type}`);
|
||||
|
||||
let newList: CollectionItem[] = [
|
||||
...collections.map(collection => ({
|
||||
...collection,
|
||||
items: collection.items.map<TabItem | GroupItem>(item =>
|
||||
item.type === "group" ?
|
||||
{ ...item, items: item.items.map(tab => ({ ...tab })) } :
|
||||
{ ...item }
|
||||
)
|
||||
}))
|
||||
];
|
||||
|
||||
if (activeItem.item.type === "collection")
|
||||
{
|
||||
newList = arrayMove(
|
||||
newList,
|
||||
activeItem.indices[0],
|
||||
overItem.indices[0]
|
||||
);
|
||||
|
||||
return newList;
|
||||
}
|
||||
|
||||
const sourceItem: GroupItem | CollectionItem = activeItem.indices.length > 2 ?
|
||||
(newList[activeItem.indices[0]].items[activeItem.indices[1]] as GroupItem) :
|
||||
newList[activeItem.indices[0]];
|
||||
|
||||
if ((over.id as string).endsWith("_dropzone") || overItem.item.type === "collection")
|
||||
{
|
||||
const destItem: GroupItem | CollectionItem = overItem.indices.length > 1 ?
|
||||
(newList[overItem.indices[0]].items[overItem.indices[1]] as GroupItem) :
|
||||
newList[overItem.indices[0]];
|
||||
|
||||
destItem.items.push(activeItem.item as any);
|
||||
sourceItem.items.splice(activeItem.indices[activeItem.indices.length - 1], 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
sourceItem.items = arrayMove(
|
||||
sourceItem.items,
|
||||
activeItem.indices[activeItem.indices.length - 1],
|
||||
overItem.indices[overItem.indices.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
return newList;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ClientRect, Collision, CollisionDescriptor, CollisionDetection } from "@dnd-kit/core";
|
||||
import { DndItem } from "../../hooks/useDndItem";
|
||||
import { centerOfRectangle, distanceBetween, getIntersectionRatio, getMaxIntersectionRatio, getRectSideCoordinates, sortCollisionsAsc } from "./dndUtils";
|
||||
|
||||
export function collisionDetector(vertical?: boolean): CollisionDetection
|
||||
{
|
||||
return (args): Collision[] =>
|
||||
{
|
||||
const { collisionRect, droppableContainers, droppableRects, active, pointerCoordinates } = args;
|
||||
const activeItem = active.data.current as DndItem;
|
||||
|
||||
if (!pointerCoordinates)
|
||||
return [];
|
||||
|
||||
const collisions: CollisionDescriptor[] = [];
|
||||
const centerRect = centerOfRectangle(
|
||||
collisionRect,
|
||||
collisionRect.left,
|
||||
collisionRect.top
|
||||
);
|
||||
|
||||
for (const droppableContainer of droppableContainers)
|
||||
{
|
||||
const { id, data } = droppableContainer;
|
||||
const rect = droppableRects.get(id);
|
||||
|
||||
const droppableItem: DndItem = data.current as DndItem;
|
||||
|
||||
if (!rect)
|
||||
continue;
|
||||
|
||||
let value: number = 0;
|
||||
|
||||
if (activeItem.item.type === "collection")
|
||||
{
|
||||
if (droppableItem.item.type !== "collection")
|
||||
continue;
|
||||
|
||||
value = distanceBetween(centerOfRectangle(rect), centerRect);
|
||||
collisions.push({ id, data: { droppableContainer, value } });
|
||||
continue;
|
||||
}
|
||||
|
||||
const intersectionRatio: number = getIntersectionRatio(rect, collisionRect);
|
||||
const intersectionCoefficient: number = intersectionRatio / getMaxIntersectionRatio(rect, collisionRect);
|
||||
|
||||
if (droppableItem.item.type === "collection")
|
||||
{
|
||||
if (activeItem.indices.length === 2 && activeItem.indices[0] === droppableItem.indices[0])
|
||||
continue;
|
||||
|
||||
if (intersectionCoefficient < 0.7 && activeItem.item.type === "tab")
|
||||
continue;
|
||||
|
||||
if (activeItem.indices.length === 3 && activeItem.indices[0] === droppableItem.indices[0])
|
||||
{
|
||||
const [collectionId, groupId] = activeItem.indices;
|
||||
const groupRect: ClientRect | undefined = droppableRects.get(`${collectionId}/${groupId}`);
|
||||
|
||||
if (!groupRect)
|
||||
continue;
|
||||
|
||||
value = 1 / (intersectionRatio - getIntersectionRatio(groupRect, collisionRect));
|
||||
}
|
||||
else
|
||||
{
|
||||
value = 1 / intersectionRatio;
|
||||
}
|
||||
}
|
||||
else if (droppableItem.item.type === "group" && (id as string).endsWith("_dropzone"))
|
||||
{
|
||||
if (activeItem.item.type === "group")
|
||||
continue;
|
||||
|
||||
if (
|
||||
activeItem.indices.length === 3 &&
|
||||
activeItem.indices[0] === droppableItem.indices[0] &&
|
||||
activeItem.indices[1] === droppableItem.indices[1]
|
||||
)
|
||||
continue;
|
||||
|
||||
if (intersectionCoefficient < 0.5)
|
||||
continue;
|
||||
|
||||
value = 1 / intersectionRatio;
|
||||
}
|
||||
else if (activeItem.indices.length === droppableItem.indices.length)
|
||||
{
|
||||
if (activeItem.indices[0] !== droppableItem.indices[0])
|
||||
continue;
|
||||
|
||||
if (activeItem.indices.length === 3 && activeItem.indices[1] !== droppableItem.indices[1])
|
||||
continue;
|
||||
|
||||
if (droppableItem.item.type === "group" && droppableItem.item.pinned === true)
|
||||
continue;
|
||||
|
||||
if (activeItem.item.type === "tab" && droppableItem.item.type === "tab")
|
||||
{
|
||||
value = distanceBetween(centerOfRectangle(rect), centerRect);
|
||||
}
|
||||
else
|
||||
{
|
||||
const activeIndex: number = activeItem.indices[activeItem.indices.length - 1];
|
||||
const droppableIndex: number = droppableItem.indices[droppableItem.indices.length - 1];
|
||||
const before: boolean = activeIndex < droppableIndex;
|
||||
|
||||
value = distanceBetween(
|
||||
getRectSideCoordinates(rect, before, vertical),
|
||||
getRectSideCoordinates(collisionRect, before, vertical)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((value > 0 && value < Number.POSITIVE_INFINITY) || active.id === id)
|
||||
collisions.push({ id, data: { droppableContainer, value } });
|
||||
};
|
||||
|
||||
return collisions.sort(sortCollisionsAsc);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ClientRect, CollisionDescriptor } from "@dnd-kit/core";
|
||||
import { Coordinates } from "@dnd-kit/utilities";
|
||||
|
||||
export function getRectSideCoordinates(rect: ClientRect, before: boolean, vertical?: boolean)
|
||||
{
|
||||
if (before)
|
||||
return vertical ? bottomsideOfRect(rect) : rightsideOfRect(rect);
|
||||
|
||||
return vertical ? topsideOfRect(rect) : leftsideOfRect(rect);
|
||||
}
|
||||
|
||||
export function getMaxIntersectionRatio(entry: ClientRect, target: ClientRect): number
|
||||
{
|
||||
const entrySize = entry.width * entry.height;
|
||||
const targetSize = target.width * target.height;
|
||||
|
||||
return Math.min(targetSize / entrySize, entrySize / targetSize);
|
||||
}
|
||||
|
||||
function topsideOfRect(rect: ClientRect): Coordinates
|
||||
{
|
||||
const { left, top } = rect;
|
||||
|
||||
return {
|
||||
x: left + rect.width * 0.5,
|
||||
y: top
|
||||
};
|
||||
}
|
||||
|
||||
function bottomsideOfRect(rect: ClientRect): Coordinates
|
||||
{
|
||||
const { left, bottom } = rect;
|
||||
return {
|
||||
x: left + rect.width * 0.5,
|
||||
y: bottom
|
||||
};
|
||||
}
|
||||
|
||||
function rightsideOfRect(rect: ClientRect): Coordinates
|
||||
{
|
||||
const { right, top } = rect;
|
||||
return {
|
||||
x: right,
|
||||
y: top + rect.height * 0.5
|
||||
};
|
||||
}
|
||||
|
||||
function leftsideOfRect(rect: ClientRect): Coordinates
|
||||
{
|
||||
const { left, top } = rect;
|
||||
return {
|
||||
x: left,
|
||||
y: top + rect.height * 0.5
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2021, Claudéric Demers
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export function distanceBetween(p1: Coordinates, p2: Coordinates)
|
||||
{
|
||||
return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
|
||||
}
|
||||
|
||||
export function sortCollisionsAsc(
|
||||
{ data: { value: a } }: CollisionDescriptor,
|
||||
{ data: { value: b } }: CollisionDescriptor
|
||||
)
|
||||
{
|
||||
return a - b;
|
||||
}
|
||||
|
||||
export function getIntersectionRatio(entry: ClientRect, target: ClientRect): number
|
||||
{
|
||||
const top = Math.max(target.top, entry.top);
|
||||
const left = Math.max(target.left, entry.left);
|
||||
const right = Math.min(target.left + target.width, entry.left + entry.width);
|
||||
const bottom = Math.min(target.top + target.height, entry.top + entry.height);
|
||||
const width = right - left;
|
||||
const height = bottom - top;
|
||||
|
||||
if (left < right && top < bottom)
|
||||
{
|
||||
const targetArea = target.width * target.height;
|
||||
const entryArea = entry.width * entry.height;
|
||||
const intersectionArea = width * height;
|
||||
const intersectionRatio =
|
||||
intersectionArea / (targetArea + entryArea - intersectionArea);
|
||||
|
||||
return Number(intersectionRatio.toFixed(4));
|
||||
}
|
||||
|
||||
// Rectangles do not overlap, or overlap has an area of zero (edge/corner overlap)
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function centerOfRectangle(
|
||||
rect: ClientRect,
|
||||
left = rect.left,
|
||||
top = rect.top
|
||||
): Coordinates
|
||||
{
|
||||
return {
|
||||
x: left + rect.width * 0.5,
|
||||
y: top + rect.height * 0.5
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CollectionItem, TabItem } from "@/models/CollectionModels";
|
||||
import sendNotification from "@/utils/sendNotification";
|
||||
import { Bookmarks } from "wxt/browser";
|
||||
import { getCollectionTitle } from "./getCollectionTitle";
|
||||
|
||||
export default async function exportCollectionToBookmarks(collection: CollectionItem)
|
||||
{
|
||||
const rootFolder: Bookmarks.BookmarkTreeNode = await browser.bookmarks.create({
|
||||
title: getCollectionTitle(collection)
|
||||
});
|
||||
|
||||
for (let i = 0; i < collection.items.length; i++)
|
||||
{
|
||||
const item = collection.items[i];
|
||||
|
||||
if (item.type === "tab")
|
||||
{
|
||||
await createTabBookmark(item, rootFolder.id);
|
||||
}
|
||||
else
|
||||
{
|
||||
const groupFolder = await browser.bookmarks.create({
|
||||
parentId: rootFolder.id,
|
||||
title: item.pinned
|
||||
? `📌 ${i18n.t("groups.pinned")}` :
|
||||
(item.title?.trim() || `${i18n.t("groups.title")} ${i}`)
|
||||
});
|
||||
|
||||
for (const tab of item.items)
|
||||
await createTabBookmark(tab, groupFolder.id);
|
||||
}
|
||||
}
|
||||
|
||||
await sendNotification({
|
||||
title: i18n.t("notifications.bookmark_saved.title"),
|
||||
message: i18n.t("notifications.bookmark_saved.message"),
|
||||
icon: "/notification_icons/bookmark_add.png"
|
||||
});
|
||||
}
|
||||
|
||||
async function createTabBookmark(tab: TabItem, parentId: string): Promise<void>
|
||||
{
|
||||
await browser.bookmarks.create({
|
||||
parentId,
|
||||
title: tab.title?.trim() || tab.url,
|
||||
url: tab.url
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||
import { CollectionItem, TabItem } from "@/models/CollectionModels";
|
||||
|
||||
export default function filterCollections(
|
||||
collections: CollectionItem[] | null,
|
||||
filter: CollectionFilterType
|
||||
): CollectionItem[]
|
||||
{
|
||||
if (!collections || collections.length < 1)
|
||||
return [];
|
||||
|
||||
if (!filter.query && filter.colors.length < 1)
|
||||
return collections;
|
||||
|
||||
const query: string = filter.query.toLocaleLowerCase();
|
||||
|
||||
return collections.filter(collection =>
|
||||
{
|
||||
let querySatisfied: boolean = query.length < 1 ||
|
||||
getCollectionTitle(collection).toLocaleLowerCase().includes(query);
|
||||
let colorSatisfied: boolean = filter.colors.length < 1 ||
|
||||
filter.colors.includes(collection.color ?? "none");
|
||||
|
||||
if (querySatisfied && colorSatisfied)
|
||||
return true;
|
||||
|
||||
function probeTab(tab: TabItem, query: string): boolean
|
||||
{
|
||||
return tab.title?.toLocaleLowerCase().includes(query) || tab.url.toLocaleLowerCase().includes(query);
|
||||
}
|
||||
|
||||
for (const item of collection.items)
|
||||
{
|
||||
if (item.type === "tab" && !querySatisfied)
|
||||
{
|
||||
querySatisfied = probeTab(item, query);
|
||||
}
|
||||
else if (item.type === "group")
|
||||
{
|
||||
if (item.pinned !== true)
|
||||
{
|
||||
if (!querySatisfied)
|
||||
querySatisfied = (item.title?.toLocaleLowerCase() ?? "").includes(query);
|
||||
|
||||
if (!colorSatisfied)
|
||||
colorSatisfied = filter.colors.includes(item.color);
|
||||
}
|
||||
|
||||
if (!querySatisfied)
|
||||
querySatisfied = item.items.some(i => probeTab(i, query));
|
||||
}
|
||||
|
||||
if (querySatisfied && colorSatisfied)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export type CollectionFilterType =
|
||||
{
|
||||
query: string;
|
||||
colors: (chrome.tabGroups.ColorEnum | "none")[];
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { CollectionItem } from "@/models/CollectionModels";
|
||||
|
||||
export function getCollectionTitle(collection?: CollectionItem): string
|
||||
{
|
||||
return collection?.title
|
||||
|| new Date(collection?.timestamp ?? Date.now())
|
||||
.toLocaleDateString(browser.i18n.getUILanguage(), { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TabItem } from "@/models/CollectionModels";
|
||||
import { Tabs } from "wxt/browser";
|
||||
|
||||
export default async function getSelectedTabs(): Promise<TabItem[]>
|
||||
{
|
||||
const tabs: Tabs.Tab[] = await browser.tabs.query({ currentWindow: true, highlighted: true });
|
||||
return tabs.filter(i => i.url).map(i => ({ type: "tab", url: i.url!, title: i.title }));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CollectionItem, TabItem } from "@/models/CollectionModels";
|
||||
|
||||
export default function mergePinnedGroups(collection: CollectionItem): void
|
||||
{
|
||||
const pinnedItems: TabItem[] = [];
|
||||
const otherItems: CollectionItem["items"] = [];
|
||||
let pinExists: boolean = false;
|
||||
|
||||
collection.items.forEach(item =>
|
||||
{
|
||||
if (item.type === "group" && item.pinned === true)
|
||||
{
|
||||
pinExists = true;
|
||||
pinnedItems.push(...item.items);
|
||||
}
|
||||
else
|
||||
otherItems.push(item);
|
||||
});
|
||||
|
||||
if (pinnedItems.length > 0 || pinExists)
|
||||
collection.items = [
|
||||
{ type: "group", pinned: true, items: pinnedItems },
|
||||
...otherItems
|
||||
];
|
||||
else
|
||||
collection.items = otherItems;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||
import { CollectionItem, GroupItem, TabItem } from "@/models/CollectionModels";
|
||||
import { settings } from "@/utils/settings";
|
||||
import { Tabs, Windows } from "wxt/browser";
|
||||
|
||||
export async function openCollection(collection: CollectionItem, targetWindow?: "current" | "new" | "incognito"): Promise<void>
|
||||
{
|
||||
if (targetWindow === "incognito" && !(await browser.extension.isAllowedIncognitoAccess()))
|
||||
throw new Error("The extension doesn't have incognito permission");
|
||||
|
||||
const discard: boolean = await settings.dismissOnLoad.getValue();
|
||||
|
||||
await manageWindow(
|
||||
async windowId =>
|
||||
{
|
||||
if (collection.items.some(i => i.type === "group"))
|
||||
// Open tabs as regular, open groups as groups
|
||||
await Promise.all(collection.items.map(async i =>
|
||||
{
|
||||
if (i.type === "tab")
|
||||
await createTab(i.url, windowId, discard);
|
||||
else
|
||||
await createGroup(i, windowId, discard);
|
||||
}));
|
||||
|
||||
else if (collection.color)
|
||||
// Open collection as one big group
|
||||
await createGroup({
|
||||
type: "group",
|
||||
color: collection.color,
|
||||
title: getCollectionTitle(collection),
|
||||
items: collection.items as TabItem[]
|
||||
}, windowId);
|
||||
|
||||
else
|
||||
// Open collection tabs as is
|
||||
await Promise.all(collection.items.map(async i =>
|
||||
await createTab((i as TabItem).url, windowId, discard)
|
||||
));
|
||||
},
|
||||
(!targetWindow || targetWindow === "current") ?
|
||||
undefined :
|
||||
{ incognito: targetWindow === "incognito" }
|
||||
);
|
||||
}
|
||||
|
||||
export async function openGroup(group: GroupItem, newWindow: boolean = false): Promise<void>
|
||||
{
|
||||
await manageWindow(
|
||||
windowId => createGroup(group, windowId),
|
||||
newWindow ? {} : undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function createGroup(group: GroupItem, windowId: number, discard?: boolean): Promise<void>
|
||||
{
|
||||
discard ??= await settings.dismissOnLoad.getValue();
|
||||
const tabIds: number[] = await Promise.all(group.items.map(async i =>
|
||||
(await createTab(i.url, windowId, discard, group.pinned)).id!
|
||||
));
|
||||
|
||||
// "Pinned" group is technically not a group, so not much else to do here
|
||||
// and Firefox doesn't even support tab groups
|
||||
if (group.pinned === true || import.meta.env.FIREFOX)
|
||||
return;
|
||||
|
||||
const groupId: number = await chrome.tabs.group({
|
||||
tabIds, createProperties: {
|
||||
windowId
|
||||
}
|
||||
});
|
||||
|
||||
await chrome.tabGroups.update(groupId, {
|
||||
title: group.title,
|
||||
color: group.color
|
||||
});
|
||||
}
|
||||
|
||||
async function manageWindow(handle: (windowId: number) => Promise<void>, windowProps?: Windows.CreateCreateDataType): Promise<void>
|
||||
{
|
||||
const currentWindow: Windows.Window = windowProps ?
|
||||
await browser.windows.create({ url: "about:blank", focused: true, ...windowProps }) :
|
||||
await browser.windows.getCurrent();
|
||||
const windowId: number = currentWindow.id!;
|
||||
|
||||
await handle(windowId);
|
||||
|
||||
if (windowProps)
|
||||
// Close "about:blank" tab
|
||||
await browser.tabs.remove(currentWindow.tabs![0].id!);
|
||||
}
|
||||
|
||||
async function createTab(url: string, windowId: number, discard: boolean, pinned?: boolean): Promise<Tabs.Tab>
|
||||
{
|
||||
const tab = await browser.tabs.create({ url, windowId: windowId, active: false, pinned });
|
||||
|
||||
if (discard)
|
||||
discardOnLoad(tab.id!);
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
function discardOnLoad(tabId: number): void
|
||||
{
|
||||
const handleTabUpdated = (id: number, _: any, tab: Tabs.Tab) =>
|
||||
{
|
||||
if (id !== tabId || !tab.url)
|
||||
return;
|
||||
|
||||
browser.tabs.onUpdated.removeListener(handleTabUpdated);
|
||||
|
||||
if (!tab.active)
|
||||
browser.tabs.discard(tabId);
|
||||
};
|
||||
|
||||
browser.tabs.onUpdated.addListener(handleTabUpdated);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||
import { CollectionItem } from "@/models/CollectionModels";
|
||||
|
||||
export default function sortCollections(
|
||||
collections: CollectionItem[],
|
||||
mode?: CollectionSortMode | null
|
||||
): CollectionItem[]
|
||||
{
|
||||
return sorters[mode ?? "custom"]([...collections]);
|
||||
}
|
||||
|
||||
export type CollectionSortMode = "ascending" | "descending" | "newest" | "oldest" | "custom";
|
||||
|
||||
const sorters: Record<CollectionSortMode, CollectionSorter> =
|
||||
{
|
||||
ascending: i => i.sort((a, b) => getCollectionTitle(a).localeCompare(getCollectionTitle(b))),
|
||||
descending: i => i.sort((a, b) => getCollectionTitle(b).localeCompare(getCollectionTitle(a))),
|
||||
newest: i => i.sort((a, b) => b.timestamp - a.timestamp),
|
||||
oldest: i => i.sort((a, b) => a.timestamp - b.timestamp),
|
||||
custom: i => i
|
||||
};
|
||||
|
||||
type CollectionSorter = (collections: CollectionItem[]) => CollectionItem[];
|
||||
Reference in New Issue
Block a user