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>
);
}