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,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[];
};
+61
View File
@@ -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;
};
+18
View File
@@ -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>
);
}
+44
View File
@@ -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);
};
}
+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[];