1
0
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:
2025-05-03 23:59:43 +03:00
parent dbc8c7fd4d
commit 39793a38c3
143 changed files with 14277 additions and 0 deletions
@@ -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);
};
}
+128
View File
@@ -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;
}
+117
View File
@@ -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[];