1
0
mirror of https://github.com/XFox111/TabsAsideExtension.git synced 2026-04-22 07:58:01 +03:00

Major 3.0 (#118)

Co-authored-by: Maison da Silva <maisonmdsgreen@hotmail.com>
This commit is contained in:
2025-07-30 15:02:26 +03:00
committed by GitHub
parent d6996031b6
commit 2bd9337e63
200 changed files with 19452 additions and 3339 deletions
+19
View File
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/devcontainers/base:focal
RUN apt update && apt upgrade -y
RUN apt install -y software-properties-common apt-transport-https ca-certificates curl gnupg
RUN mkdir -p /etc/apt/keyrings
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
RUN apt update && apt install -y nodejs
RUN corepack enable
RUN echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list
RUN curl -fSsL https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor | sudo tee /usr/share/keyrings/google-chrome.gpg >> /dev/null
RUN echo deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main | sudo tee /etc/apt/sources.list.d/google-chrome.list
RUN apt update && apt install -y google-chrome-stable firefox
+26
View File
@@ -0,0 +1,26 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "TabsAsideExtension",
"build": {
"dockerfile": "Dockerfile"
},
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"zardoy.disable-ts-errors",
"github.vscode-github-actions",
"GitHub.vscode-pull-request-github",
"bierner.github-markdown-preview",
"mrmlnc.vscode-scss",
"Gruntfuggly.todo-tree",
"redhat.vscode-yaml"
]
}
},
"postCreateCommand": "yarn install"
}
+2
View File
@@ -0,0 +1,2 @@
* @XFox111
locales/pt_BR.yml @maisondasilva @XFox111
-12
View File
@@ -1,12 +0,0 @@
# These are supported funding model platforms
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
#patreon: # Replace with a single Patreon username
open_collective: TabsAside
#ko_fi: # Replace with a single Ko-fi username
#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
#liberapay: # Replace with a single Liberapay username
#issuehunt: # Replace with a single IssueHunt username
#otechie: # Replace with a single Otechie username
custom: [ "https://buymeacoffee.com/xfox111" ]
-33
View File
@@ -1,33 +0,0 @@
---
name: Bug report
about: Create a report to help us improve the extension
title: ''
labels: bug
assignees: ''
---
### Description
A clear and concise description of what the bug is.
### Reproduction steps
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
### Expected behavior
A clear and concise description of what you expected to happen.
### Screenshots
If applicable, add screenshots to help explain your problem.
### Environment
Please provide the following information:
- Operating System: [e.g. Windows 10 Pro 1909 (10.0.18363)]
- Browser: [e.g. Microsoft Edge 83.0.478.56]
- Extension version: [e.g. 1.5]
### Additional context
Add any other context about the problem here.
+107
View File
@@ -0,0 +1,107 @@
name: "🐞 Bug Report"
description: Create a report to help us improve the extension
title: "[Bug]: "
labels: ["bug", "needs-triage"]
assignees:
- xfox111
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: desc
attributes:
label: Description
description: A clear and concise description of what the bug is.
placeholder: e.g. Sometimes when generating a password not all character sets are included
validations:
required: true
- type: textarea
attributes:
label: Reproduction steps
description: Precisely describe minimal number of steps that make the bug to appear
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See '...'
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
placeholder: e.g. Generated password should include at least one character from every enabled character set
validations:
required: true
- type: textarea
attributes:
label: Screenshot
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: dropdown
id: os
attributes:
label: Operating system
options:
- "Windows 10 and newer"
- "Windows 8/8.1"
- "Windows 7 and older"
- "MacOS"
- "Debian or Debian-based"
- "Other"
validations:
required: true
- type: input
id: browser
attributes:
label: Browser name and version
placeholder: e.g. Microsoft Edge 119.0.2151.58
description: Put here your browser's name and version
validations:
required: true
- type: input
id: version
attributes:
label: Extension version
placeholder: e.g. 3.0.0
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false
- type: dropdown
id: requested-help
attributes:
label: Are you willing to submit a PR for this issue?
options:
- "yes"
- "no"
validations:
required: true
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
required: true
- label: The provided reproduction is a minimal reproducible example of the bug.
required: true
+7
View File
@@ -0,0 +1,7 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json
blank_issues_enabled: true
contact_links:
- name: Questions & Discussions
url: https://github.com/XFox111/TabsAsideExtension/discussions
about: Use GitHub discussions for message-board style questions and discussions.
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when '...'
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,62 @@
name: "🚀 New feature proposal"
description: Suggest a feature idea for this project
title: "[Feature]: "
labels: ["feature", "needs-triage"]
assignees:
- xfox111
body:
- type: markdown
attributes:
value: |
Thanks for your interest in the project and taking the time to fill out this feature report!
- type: textarea
id: proposition
attributes:
label: Proposed solution
description: Describe the solution you'd like
validations:
required: true
- type: textarea
id: justification
attributes:
label: Justification
description: Is your feature request related to a problem? Please describe.
validations:
required: true
- type: textarea
id: alts
attributes:
label: Alternatives
description: Describe alternatives you've considered.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: dropdown
id: requested-help
attributes:
label: Are you willing to submit a PR for this issue?
options:
- "yes"
- "no"
validations:
required: true
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
required: true
+39
View File
@@ -0,0 +1,39 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
target-branch: "next"
assignees:
- "XFox111"
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
- package-ecosystem: "github-actions"
directory: "/"
target-branch: "next"
assignees:
- "XFox111"
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
- package-ecosystem: "devcontainers"
directory: "/"
target-branch: "next"
assignees:
- "XFox111"
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
+29 -5
View File
@@ -1,9 +1,33 @@
Implements following issues: <!-- ⚠️ Make sure that you create this PR against `next` branch and not `main` -->
## Description
<!--Put short description of the pull request here-->
Resolves: #issue_number
<!-- ------------------------------------- -->
<!-- FOR REPOSITORY MAINTAINERS' PRS ONLY! -->
<!-- DO NOT INCLUDE FOLLOWING IN YOUR PR!! -->
<!-- ------------------------------------- -->
<!-- > ## 🚀 Patch Tuesday update
> This pull request is a part of our new initiative!
From now on we are starting to roll out updates on every first Tuesday of the month, which will include bugfixes, security and dependency updates to keep the project's security and stability up to date!
## Description
Dependencies update and security fixes
## Changelog ## Changelog
- Item 1 ### Dependency bumps
- Item 2 - #
- Item 3 ### Fixed security vulnerabilities
- [CWE-20](https://cwe.mitre.org/data/definitions/20.html) (#)
- CVE-2022-25883 (#)
## PR Checklist ## PR Checklist
- [ ] Change extension version in the manifest - [ ] Update version in `package.json`
- [ ] [Post-merge] Review and publish GitHub release
- [ ] Update Discussions
- [ ] [Post-deploy] Update changelog for Firefox webstore
- [ ] Reset `next` branch to be in sync with `main`
-->
+11 -10
View File
@@ -1,13 +1,14 @@
<!-- > ## 🚀 Patch Tuesday update
> This release is a part of our new initiative!
From now on we are starting to roll out updates on every first Tuesday of the month, which will include bugfixes, security and dependency updates to keep the project's security and stability up to date!
-->
## What's new ## What's new
<!-- - Dependency updates and security patches (#) -->
## How to install <!-- ### Fixed security issues in this update
1. Download attached archive and unpack it - [CWE-20](https://cwe.mitre.org/data/definitions/20.html)
2. Enable Developers mode on your browser extensions page - CVE-2022-25883
3. Click "Load unpacked" button and navigate to the extension root folder (contains `manifest.json`) -->
4. Done!
*On Firefox you should open manifest file instead of extension's folder Refer to [Download section of the README.md](https://github.com/XFox111/TabsAsideExtension#download) for sideloading instructions and download links
**Note:** If you delete extension folder it will disappear from your browser
_Sideloaded extensions don't replace officially installed ones_
+156
View File
@@ -0,0 +1,156 @@
name: Release pipeline
on:
release:
types: [ released ]
workflow_dispatch:
inputs:
bypass_audit:
description: Bypass npm audit
type: boolean
default: false
targets:
description: Targets
required: true
default: '["chrome","firefox"]'
type: choice
options:
- '["chrome","firefox"]'
- '["chrome"]'
- '["firefox"]'
firefox:
description: Deploy Firefox
type: boolean
default: true
chrome:
description: Deploy Chrome
type: boolean
default: true
edge:
description: Deploy Edge
type: boolean
default: true
gh-release:
description: Attach to GitHub release
type: boolean
default: true
jobs:
build:
runs-on: ubuntu-latest
container: node:24
strategy:
fail-fast: false
matrix:
target: ${{ fromJSON(github.event.inputs.targets || '["chrome","firefox"]') }}
steps:
- uses: actions/checkout@main
- run: |
echo "WXT_GA4_API_SECRET=${{ secrets.GA4_SECRET }}" >> .env
echo "WXT_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}" >> .env
- run: corepack enable
- run: yarn install
# Patch for firefox dnd popup (see https://github.com/clauderic/dnd-kit/issues/1043)
- run: grep -v "this.windowListeners.add(EventName.Resize, this.handleCancel);" core.esm.js > core.esm.js.tmp && mv core.esm.js.tmp core.esm.js
working-directory: ./node_modules/@dnd-kit/core/dist
if: ${{ matrix.target == 'firefox' }}
- run: yarn zip -b ${{ matrix.target }}
- name: Drop build artifacts (${{ matrix.target }})
uses: actions/upload-artifact@main
with:
name: ${{ matrix.target }}
path: ./.output/tabs-aside-*.zip
include-hidden-files: true
- name: web-ext lint
if: ${{ matrix.target == 'firefox' }}
uses: freaktechnik/web-ext-lint@main
with:
extension-root: ./.output/firefox-mv3
self-hosted: false
- run: yarn npm audit
continue-on-error: ${{ github.event_name != 'release' && github.event.inputs.bypass_audit == 'true' }}
publish-github:
needs: build
if: ${{ github.event_name == 'release' || github.event.inputs.gh-release == 'true' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: ${{ fromJSON(github.event.inputs.targets || '["chrome","firefox"]') }}
steps:
- uses: actions/download-artifact@main
with:
name: ${{ matrix.target }}
- name: Attach build to release
uses: xresloader/upload-to-github-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
file: tabs-aside-*-${{ matrix.target }}.zip
draft: false
overwrite: true
update_latest_release: true
publish-chrome:
needs: build
if: ${{ github.event_name == 'release' || (github.event.inputs.chrome == 'true' && contains(github.event.inputs.targets, 'chrome')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@main
with:
name: chrome
- uses: wdzeng/chrome-extension@v1.3.0
with:
extension-id: ${{ secrets.CHROME_EXT_ID }}
zip-path: tabs-aside-*-chrome.zip
client-id: ${{ secrets.CHROME_CLIENT_ID }}
client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
publish-edge:
needs: build
if: ${{ github.event_name == 'release' || (github.event.inputs.edge == 'true' && contains(github.event.inputs.targets, 'chrome')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@main
with:
name: chrome
- uses: wdzeng/edge-addon@v2.1.0
with:
product-id: ${{ secrets.EDGE_PRODUCT_ID }}
zip-path: tabs-aside-*-chrome.zip
client-id: ${{ secrets.EDGE_CLIENT_ID }}
api-key: ${{ secrets.EDGE_API_KEY }}
publish-firefox:
needs: build
if: ${{ github.event_name == 'release' || (github.event.inputs.firefox == 'true' && contains(github.event.inputs.targets, 'firefox')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@main
with:
name: firefox
- uses: wdzeng/firefox-addon@v1.2.0
with:
addon-guid: ${{ secrets.FIREFOX_EXT_UUID }}
xpi-path: tabs-aside-*-firefox.zip
source-file-path: tabs-aside-*-sources.zip
jwt-issuer: ${{ secrets.FIREFOX_API_KEY }}
jwt-secret: ${{ secrets.FIREFOX_CLIENT_SECRET }}
-70
View File
@@ -1,70 +0,0 @@
name: CI
on:
release:
types: [published]
jobs:
Firefox:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build Extension for Firefox
id: web-ext-build
uses: kewisch/action-web-ext@v1
with:
cmd: build
- name: 'Sign & publish'
id: web-ext-sign
uses: kewisch/action-web-ext@v1
with:
cmd: sign
channel: listed
source: ${{ steps.web-ext-build.outputs.target }}
apiKey: ${{ secrets.FIREFOX_API_KEY }}
apiSecret: ${{ secrets.FIREFOX_CLIENT_SECRET }}
- name: Drop artifacts
uses: actions/upload-artifact@v2
with:
name: 'Firefox Artefacts'
path: ${{ steps.web-ext-build.outputs.target }}
Chrome:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Pack extension
uses: TheDoctor0/zip-release@0.4.1
with:
filename: ./TabsAside.zip
exclusions: '.git/* .vscode/* .github/* *.md'
- name: Publish to Chrome Webstore
uses: trmcnvn/chrome-addon@v2
with:
extension: mgmjbodjgijnebfgohlnjkegdpbdjgin
zip: ./TabsAside.zip
client-id: ${{ secrets.CHROME_CLIENT_ID }}
client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
- name: Upload artifact
uses: xresloader/upload-to-github-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
file: ./TabsAside.zip
tags: true
draft: false
- name: Drop artifacts
uses: actions/upload-artifact@v2
with:
name: 'Chrome Artifacts'
path: ./TabsAside.zip
+86
View File
@@ -0,0 +1,86 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main", "next" ]
paths-ignore:
- '**.md'
- 'LICENSE'
- '**/cd_pipeline.yaml'
- '**/dependabot.yml'
- '**/pr_pipeline.yaml'
- '.vscode/*'
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main", "next" ]
paths-ignore:
- '**.md'
- 'LICENSE'
- '**/cd_pipeline.yaml'
- '**/dependabot.yml'
- '**/pr_pipeline.yaml'
- '.vscode/*'
schedule:
- cron: '24 7 * * 3'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'typescript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
+31
View File
@@ -0,0 +1,31 @@
name: PR next workflow
on:
push:
branches: [ main ]
paths:
- 'package.json'
workflow_dispatch:
permissions:
contents: write
jobs:
create-release-draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@main
- name: Get version from package.json
id: get_version
run: |
extver=`jq -r ".version" package.json`
echo "version=$extver" >> "$GITHUB_OUTPUT"
- uses: dev-build-deploy/release-me@v0.18.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
prefix: v
draft: true
version: v${{ steps.get_version.outputs.version }}
release-notes: .github/release_description_template.md
+70
View File
@@ -0,0 +1,70 @@
name: PR check pipeline
on:
pull_request:
branches: [ "main", "next" ]
paths-ignore:
- '**.md'
- '**.txt'
- "locales/*"
- 'LICENSE'
- 'PRIVACY'
- '**/cd_pipeline.yml'
- '**/dependabot.yml'
- '**/codeql-analysis.yml'
- '**/pr_next.yaml'
- '.vscode/*'
- '.devcontainer/*'
workflow_dispatch:
inputs:
targets:
description: Targets
required: true
default: '["chrome","firefox"]'
type: choice
options:
- '["chrome","firefox"]'
- '["chrome"]'
- '["firefox"]'
jobs:
build:
runs-on: ubuntu-latest
container: node:24
strategy:
fail-fast: false
matrix:
target: ${{ fromJSON(github.event.inputs.targets || '["chrome","firefox"]') }}
steps:
- uses: actions/checkout@main
- run: |
echo "WXT_GA4_API_SECRET=${{ secrets.GA4_SECRET }}" >> .env
echo "WXT_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}" >> .env
- run: corepack enable
- run: yarn install
# Patch for firefox dnd popup (see https://github.com/clauderic/dnd-kit/issues/1043)
- run: grep -v "this.windowListeners.add(EventName.Resize, this.handleCancel);" core.esm.js > core.esm.js.tmp && mv core.esm.js.tmp core.esm.js
working-directory: ./node_modules/@dnd-kit/core/dist
if: ${{ matrix.target == 'firefox' }}
- run: yarn zip -b ${{ matrix.target }}
- name: Drop artifacts (${{ matrix.target }})
uses: actions/upload-artifact@main
with:
name: ${{ matrix.target }}
path: ./.output/tabs-aside-*-${{ matrix.target }}.zip
include-hidden-files: true
- name: web-ext lint
if: ${{ matrix.target == 'firefox' }}
uses: freaktechnik/web-ext-lint@main
with:
extension-root: ./.output/firefox-mv3
self-hosted: false
- run: yarn npm audit
+31 -1
View File
@@ -1 +1,31 @@
\.vscode # Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.yarn/
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
web-ext.config.js
.env*
+12
View File
@@ -0,0 +1,12 @@
{
"recommendations": [
"bierner.github-markdown-preview",
"dbaeumer.vscode-eslint",
"github.vscode-github-actions",
"Gruntfuggly.todo-tree",
"jock.svg",
"ms-azuretools.vscode-docker",
"saeris.markdown-github-alerts",
"zardoy.disable-ts-errors"
]
}
-13
View File
@@ -1,13 +0,0 @@
{
"version": "0.2.0",
"configurations":
[
{
"name": "Debug",
"type": "firefox",
"request": "launch",
"reAttach": true,
"addonPath": "${workspaceFolder}"
}
]
}
-12
View File
@@ -1,12 +0,0 @@
{
"scanSettings": {
"baseBranches": []
},
"checkRunSettings": {
"vulnerableCheckRunConclusionLevel": "failure",
"displayMode": "diff"
},
"issueSettings": {
"minSeverityLevel": "LOW"
}
}
+117
View File
@@ -0,0 +1,117 @@
nodeLinker: node-modules
packageExtensions:
"@wxt-dev/module-react@*":
peerDependencies:
vite: "*"
"@fluentui/react-accordion@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-avatar@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-carousel@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-color-picker@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-combobox@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-dialog@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-field@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-list@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-menu@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-nav@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-overflow@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-popover@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-swatch-picker@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-table@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-tabs@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-tag-picker@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-teaching-popover@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-toolbar@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-tree@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-alert@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-checkbox@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-components@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-drawer@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-infobutton@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-infolabel@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-input@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-persona@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-progress@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-radio@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-select@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-skeleton@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-slider@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-spinbutton@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-switch@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-tags@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-textarea@*":
peerDependencies:
scheduler: "0.23.0"
"@fluentui/react-search@*":
peerDependencies:
scheduler: "0.23.0"
+11
View File
@@ -0,0 +1,11 @@
import DialogProvider from "@/contexts/DialogProvider";
import ThemeProvider from "@/contexts/ThemeProvider";
const App: React.FC<React.PropsWithChildren> = ({ children }: React.PropsWithChildren) =>
<ThemeProvider>
<DialogProvider>
{ children }
</DialogProvider>
</ThemeProvider>;
export default App;
+107 -49
View File
@@ -2,75 +2,133 @@
## Our Pledge ## Our Pledge
In the interest of fostering an open and welcoming environment, we as We as members, contributors, and leaders pledge to make participation in our
contributors and maintainers pledge to making participation in our project and community a harassment-free experience for everyone, regardless of age, body
our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender
size, disability, ethnicity, sex characteristics, gender identity and expression, identity and expression, level of experience, education, socio-economic status,
level of experience, education, socio-economic status, nationality, personal nationality, personal appearance, race, caste, color, religion, or sexual
appearance, race, religion, or sexual identity and orientation. identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards ## Our Standards
Examples of behavior that contributes to creating a positive environment Examples of behavior that contributes to a positive environment for our
include: community include:
- Using welcoming and inclusive language * Demonstrating empathy and kindness toward other people
- Being respectful of differing viewpoints and experiences * Being respectful of differing opinions, viewpoints, and experiences
- Gracefully accepting constructive criticism * Giving and gracefully accepting constructive feedback
- Focusing on what is best for the community * Accepting responsibility and apologizing to those affected by our mistakes,
- Showing empathy towards other community members and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior by participants include: Examples of unacceptable behavior include:
- The use of sexualized language or imagery and unwelcome sexual attention or * The use of sexualized language or imagery, and sexual attention or advances of
advances any kind
- Trolling, insulting/derogatory comments, and personal or political attacks * Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment * Public or private harassment
- Publishing others' private information, such as a physical or electronic * Publishing others' private information, such as a physical or email address,
address, without explicit permission without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a * Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Our Responsibilities ## Enforcement Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable Community leaders are responsible for clarifying and enforcing our standards of
behavior and are expected to take appropriate and fair corrective action in acceptable behavior and will take appropriate and fair corrective action in
response to any instances of unacceptable behavior. response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Project maintainers have the right and responsibility to remove, edit, or Community leaders have the right and responsibility to remove, edit, or reject
reject comments, commits, code, wiki edits, issues, and other contributions comments, commits, code, wiki edits, issues, and other contributions that are
that are not aligned to this Code of Conduct, or to ban temporarily or not aligned to this Code of Conduct, and will communicate reasons for moderation
permanently any contributor for other behaviors that they deem inappropriate, decisions when appropriate.
threatening, offensive, or harmful.
## Scope ## Scope
This Code of Conduct applies both within project spaces and in public spaces This Code of Conduct applies within all community spaces, and also applies when
when an individual is representing the project or its community. Examples of an individual is officially representing the community in public spaces.
representing a project or community include using an official project e-mail Examples of representing our community include using an official email address,
address, posting via an official social media account, or acting as an appointed posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be representative at an online or offline event.
further defined and clarified by project maintainers.
## Enforcement ## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at opensource@xfox111.net. All reported to the community leaders responsible for enforcement at
complaints will be reviewed and investigated and will result in a response that [opensource@xfox111.net](mailto:opensource@xfox111.net).
is deemed necessary and appropriate to the circumstances. The project team is All complaints will be reviewed and investigated promptly and fairly.
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good All community leaders are obligated to respect the privacy and security of the
faith may face temporary or permanent repercussions as determined by other reporter of any incident.
members of the project's leadership.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution ## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, This Code of Conduct is adapted from the [Contributor Covenant][homepage],
available at <https://www.contributor-covenant.org/version/1/4/code-of-conduct.html> version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
> Contributor Covenant is released under the [Creative Commons Attribution 4.0 International Public License](https://github.com/EthicalSource/contributor_covenant/blob/release/LICENSE.md).
[homepage]: https://www.contributor-covenant.org [homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
For answers to common questions about this code of conduct, see [Mozilla CoC]: https://github.com/mozilla/diversity
<https://www.contributor-covenant.org/faq> [FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+2 -212
View File
@@ -1,214 +1,4 @@
# Contribution Guidelines # Contribution Guidelines
Welcome, and thank you for your interest in contributing to my project!
There are many ways in which you can contribute, beyond writing code. The goal of this document is to provide a high-level overview of how you can get involved. > [!IMPORTANT]
> This article has been moved to the [project's Wiki section](https://github.com/XFox111/TabsAsideExtension/wiki/Contribution-Guidelines)
## Table of Contents
- [Contribution Guidelines](#contribution-guidelines)
- [Table of Contents](#table-of-contents)
- [Asking Questions](#asking-questions)
- [Providing Feedback](#providing-feedback)
- [Reporting Issues](#reporting-issues)
- [Look For an Existing Issue](#look-for-an-existing-issue)
- [Writing Good Bug Reports and Feature Requests](#writing-good-bug-reports-and-feature-requests)
- [Final Checklist](#final-checklist)
- [Follow Your Issue](#follow-your-issue)
- [Contributing to the codebase](#contributing-to-the-codebase)
- [Deploy test version on your browser](#deploy-test-version-on-your-browser)
- [Development workflow](#development-workflow)
- [Release](#release)
- [Coding guidelines](#coding-guidelines)
- [Indentation](#indentation)
- [Names](#names)
- [Comments](#comments)
- [Strings](#strings)
- [Style](#style)
- [Finding an issue to work on](#finding-an-issue-to-work-on)
- [Contributing to translations](#contributing-to-translations)
- [Submitting pull requests](#submitting-pull-requests)
- [Spell check errors](#spell-check-errors)
- [Thank You!](#thank-you)
- [Attribution](#attribution)
## Asking Questions
Have a question? Rather than opening an issue, please ask me directly on opensource@xfox111.net.
## Providing Feedback
Your comments and feedback are welcome.
You can leave your feedbak on feedback@xfox111.net or do it on [Microsoft Edge Add-ons Webstore](https://microsoftedge.microsoft.com/addons/detail/tabs-aside/kmnblllmalkiapkfknnlpobmjjdnlhnd), [Chrome Extensions Webstore](https://chrome.google.com/webstore/detail/tabs-aside/mgmjbodjgijnebfgohlnjkegdpbdjgin) or [Mozilla Add-ons Webstore](https://addons.mozilla.org/firefox/addon/ms-edge-tabs-aside/)
## Reporting Issues
Have you identified a reproducible problem in the extension? Have a feature request? I'd like to hear it! Here's how you can make reporting your issue as effective as possible.
### Look For an Existing Issue
Before you create a new issue, please do a search in [open issues](https://github.com/xfox111/TabsAsideExtension/issues) to see if the issue or feature request has already been filed.
Be sure to scan through the [feature requests](https://github.com/XFox111/TabsAsideExtension/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement).
If you find your issue already exists, make relevant comments and add your [reaction](https://github.com/blog/2119-add-reactions-to-pull-requests-issues-and-comments). Use a reaction in place of a "+1" comment:
- 👍 - upvote
- 👎 - downvote
If you cannot find an existing issue that describes your bug or feature, create a new issue using the guidelines below.
### Writing Good Bug Reports and Feature Requests
File a single issue per problem and feature request. Do not enumerate multiple bugs or feature requests in the same issue.
Do not add your issue as a comment to an existing issue unless they are the same ones. Many issues look similar, but have different causes.
The more information you can provide, the more likely someone will be successful at reproducing the issue and finding a solution.
Please include the following with each issue:
- Current version of the extension
- Your current browser and OS name
- Reproducible steps (1... 2... 3...) that cause the issue
- What you expected to see, versus what you actually saw
- Images, animations, or a link to a video showing the issue occurring
### Final Checklist
Please remember to do the following:
- [ ] Search the issue repository to ensure your report is a new issue
- [ ] Separate issues reports
- [ ] Include as much information as you can to your report
Don't feel bad if the developers can't reproduce the issue right away. They will simply ask for more information!
### Follow Your Issue
Once your report is submitted, be sure to stay in touch with the devs in case they need more help from you.
## Contributing to the codebase
If you are interested in writing code to fix issues or implement new awesome features you can follow these guidelines to get a better result
### Deploy test version on your browser
1. Clone repository to local storage using [Git](https://guides.github.com/introduction/git-handbook/)
```bash
git clone https://github.com/xfox111/TabsAsideExtension.git
```
2. Enable Developers mode on your browser extensions page
3. Click "Load unpacked" button and navigate to the extension root folder (contains `manifest.json`)
4. Done!
Next time you make any changes to the codebase, reload the extension by toggling it off and on or by pressing "Reload" button on the extensions list page
> **Note:** You can also check [this article](https://xfox111.net/46hsgv) to get more information about debugging web extensions
### Development workflow
This section represents how contributors should interact with codebase implementing features and fixing bugs
1. Getting assigned to the issue
2. Creating a repository fork
3. Making changes to the codebase
4. Creating a pull request to `master`
5. Reviewing & completing PR
6. Done
#### Release
The next stage is the release. Release performs on every push to master (which makes functional changes to the source code). Release performs manually by @XFox111 into: Chrome webstore, Edge webstore and GitHub releases
### Coding guidelines
#### Indentation
We use tabs, not spaces.
#### Names
The project naming rules inherit [.NET Naming Guidelines](https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/naming-guidelines). Nevertheless there'is some distinction with the guidelines as well as additions to the one:
- Use `camelCase` for variables instead of `CamelCase` stated in [Capitalization Conventions](https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/capitalization-conventions#capitalization-rules-for-identifiers)
- Use `snake_case` for file names
#### Comments
Leave as more comments as you can. Remember: the more detailed documentation your code has the less programmers will curse you in the future
#### Strings
Use "double quotes" wherever it's possible
#### Style
- Prefer to use lambda functions
- Put curly braces on new lines
- Wrong:
```javascript
if (condition) {
...
}
```
- Correct:
```javascript
if (condition)
{
...
}
```
- Put spaces between operators and before braces in methods declarations, conditionals and loops
- Wrong:
- `y=k*x+b`
- `function FunctionName()`
- Correct:
- `y = k * x + b`
- `function FunctionName ()`
- Use ternary conditionals wherever it's possible
- Wrong:
```javascript
var s;
if (condition)
s = "Life";
else
s = "Death";
```
- Correct:
```javascript
var s = condition ? "Life" : "Death";
```
- Do not surround loop and conditional bodies with curly braces if they can be avoided
- Wrong:
```javascript
if (condition)
{
console.log("Hello, World!");
}
else
{
return;
}
```
- Correct
```javascript
if (condition)
console.log("Hello, World!");
else
return;
```
### Finding an issue to work on
Check out the [full issues list](https://github.com/XFox111/TabsAsideExtension/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue) for a list of all potential areas for contributions. **Note** that just because an issue exists in the repository does not mean we will accept a contribution. There are several reasons we may not accept a pull request like:
- Performance - One of Tabs Aside core values is to deliver a lightweight extension, that means it should perform well in both real and test environments.
- User experience - Since we want to deliver a lightweight extension, the UX should feel lightweight as well and not be cluttered. Most changes to the UI should go through the issue owner and project owner (@XFox111).
- Architectural - Project owner needs to agree with any architectural impact a change may make. Such things must be discussed with and agreed upon by the project owner.
To improve the chances to get a pull request merged you should select an issue that is labelled with the `help-wanted` or `bug` labels. If the issue you want to work on is not labelled with `help-wanted` or `bug`, you can start a conversation with the project owner asking whether an external contribution will be considered.
To avoid multiple pull requests resolving the same issue, let others know you are working on it by saying so in a comment.
### Contributing to translations
If you want to help us to translate this extension into other languages, please read [this article](https://developer.chrome.com/extensions/i18n)
**Note** that whatever you want to contribute to the codebase, you should do it only after you got assigned on an issue
### Submitting pull requests
To enable us to quickly review and accept your pull requests, always create one pull request per issue and [link the issue in the pull request](https://github.com/blog/957-introducing-issue-mentions). Never merge multiple requests in one unless they have the same root cause. Be sure to follow our [Coding Guidelines](#coding-guidelines) and keep code changes as small as possible. Avoid pure formatting changes to code that has not been modified otherwise. Pull requests should contain tests whenever possible. Fill pull request content according to its template. Deviations from template are not recommended
#### Spell check errors
Pull requests that fix typos are welcomed but please make sure it doesn't touch multiple feature areas, otherwise it will be difficult to review. Pull requests only fixing spell check errors in source code are not recommended.
## Thank You!
Your contributions to open source, large or small, make great projects like this possible. Thank you for taking the time to contribute.
## Attribution
These Contribution Guidelines are adapted from the [Contributing to VS Code](https://github.com/microsoft/vscode/blob/master/CONTRIBUTING.md)
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2020 Michael "XFox" Gordeev Copyright (c) 2025 Eugene Fox
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
-7
View File
@@ -1,7 +0,0 @@
Tabs Aside Extension Privacy Policy
1. Developers of the extension don't affiliate with Google LLC. or Microsoft Corporation in any way
2. This extension doesn't transfer any personal data (data which may be used to track your location or reveal your identity) to any remote or local server
3. This extension doesn't share any personal data with third parties
4. This extension stores following personal data:
- Browser tabs which have been saved for later by user via this extension (if user click "Set current tabs aside" button). This includes titles, favicons and web links
5. User can delete all saved personal data by removing this extension from his browser
+20
View File
@@ -0,0 +1,20 @@
# Tabs aside extension Privacy policy
1. Developers of the extension don't affiliate with Google LLC, Mozilla Foundation or Microsoft Corporation in any way.
2. This extension stores user data only related to its core functionality. This includes:
- User settings
- User saved collections of tabs
- Thumbnails of saved tabs
3. This extension uses Google Analytics to collect usage statistics and improve the extension.
4. This extension uses analytics to collect following data:
- Random UUID to identify the user
- Browser name and version
- Operating system name and version
- System architecture
- Screen resolution
- Extension language
- User settings
- Number of saved collections
- Action identifiers (e.g. "page_view", "extension_installed", "item_created", etc.)
4. This extension does not collect or use any personally identifiable information (PII) or sensitive data for purposes other than its core functionality.
5. This extension uses cloud storage built into your browser to store its data.
6. Refer to your browser's developer regarding the privacy policy of the cloud storage used by your browser.
+79 -47
View File
@@ -1,68 +1,100 @@
# Tabs aside
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/xfox111/TabsAsideExtension)](https://github.com/xfox111/TabsAsideExtension/releases/latest) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/xfox111/TabsAsideExtension)](https://github.com/xfox111/TabsAsideExtension/releases/latest)
![CI](https://github.com/XFox111/TabsAsideExtension/workflows/CI/badge.svg) [![GitHub last commit](https://img.shields.io/github/last-commit/xfox111/TabsAsideExtension?label=Last+update)](https://github.com/XFox111/TabsAsideExtension/commits/main)
[![Mozilla Add-on](https://img.shields.io/amo/rating/ms-edge-tabs-aside?label=Firefox%20rating)](https://addons.mozilla.org/firefox/addon/ms-edge-tabs-aside/)
[![Chrome Web Store](https://img.shields.io/chrome-web-store/users/mgmjbodjgijnebfgohlnjkegdpbdjgin?label=Chrome%20Webstore%20downloads)](https://chrome.google.com/webstore/detail/tabs-aside/mgmjbodjgijnebfgohlnjkegdpbdjgin) <picture>
[![Mozilla Add-on](https://img.shields.io/amo/users/ms-edge-tabs-aside?label=Firefox%20Webstore%20downloads)](https://addons.mozilla.org/firefox/addon/ms-edge-tabs-aside/) <source media="(prefers-color-scheme: dark)" srcset="https://cdn.xfox111.net/projects/tabs-aside/dark.webp">
<source media="(prefers-color-scheme: light)" srcset="https://cdn.xfox111.net/projects/tabs-aside/light.webp">
<img alt="Password generator">
</picture>
[![GitHub issues](https://img.shields.io/github/issues/xfox111/TabsAsideExtension)](https://github.com/xfox111/TabsAsideExtension/issues) Stemming its roots from the original Microsoft Edge browser feature, this extension has grown much bigger than just a temporary storage for tabs.
[![GitHub last commit](https://img.shields.io/github/last-commit/xfox111/TabsAsideExtension)](https://github.com/xfox111/TabsAsideExtension/commits/master)
[![GitHub repo size](https://img.shields.io/github/repo-size/xfox111/TabsAsideExtension?label=repo%20size)](https://github.com/xfox111/TabsAsideExtension)
[![MIT License](https://img.shields.io/github/license/xfox111/TabsAsideExtension)](https://opensource.org/licenses/MIT)
[![Twitter Follow](https://img.shields.io/twitter/follow/xfox111?style=social)](https://twitter.com/xfox111) It allows you to save and manage your tabs in a convenient way, providing a range of features that make it easy to organize and access your saved tabs.
[![GitHub followers](https://img.shields.io/github/followers/xfox111?label=Follow%20@xfox111&style=social)](https://github.com/xfox111)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-%40xfox111-orange)](https://buymeacoffee.com/xfox111)
![Tabs aside](https://xfox111.net/y7xk3z)
If youre like me, you often find yourself with a bunch of open tabs. Youd like to get those tabs out of the way sometimes, but theyre maybe not worth saving as actual bookmarks.
In the Edge browser, Microsoft has introduced a new feature called "Tabs aside" (or Tab groups) which lets you set tabs aside in a sort of temporary workspace so that you can call them back up later.
![Tabs aside demo](https://xfox111.net/knrp7b)
Unfortunately, in new Chromium-based Microsoft Edge, the devs decided not to implement this must-have-feature. So I've decided to create a browser extension which replicates this awesome feature
## Features ## Features
- Familiar UI inherited from legacy Microsoft Edge with some improvements - **Save tabs**: Save all your open tabs in a single click, and restore them later
- Auto Dark mode - **Organize tabs**: Create collections and subgroups to organize your saved tabs
- Now you can restore one tab from collection without removing - **Search tabs**: Quickly find the tabs you need using the search feature
- Now you can choose if you want to load restored tabs only when you're navigating onto them - **Sync across devices**: Access your saved tabs from any device with your account
- Set tabs you've selected aside - **Go dark**: Dark mode support for a more comfortable browsing experience
- Sync your saved tabs between different PCs - **Personalize**: Change the appearance and behavior of the extension to suit your needs
- **Now available for Firefox!**
Check out our [latest blog post](https://at.xfox111.net/tabs-aside-3-0) regarding all the new features and improvements in Tabs aside 3.0
## Languages
- Chinese (Simplified)
- English
- Italian
- Polish
- Portuguese (Brazil) by [@maisondasilva](https://github.com/maisondasilva)
- Russian
- Spanish
- Ukrainian
## Download ## Download
- [Google Chrome Webstore](https://chrome.google.com/webstore/detail/tabs-aside/mgmjbodjgijnebfgohlnjkegdpbdjgin) [![Chrome Web Store](https://img.shields.io/chrome-web-store/users/mgmjbodjgijnebfgohlnjkegdpbdjgin?label=Chrome%20Webstore%20downloads)](https://chrome.google.com/webstore/detail/mgmjbodjgijnebfgohlnjkegdpbdjgin)
[![Mozilla Add-on](https://img.shields.io/amo/users/ms-edge-tabs-aside?label=Firefox%20Webstore%20downloads)](https://addons.mozilla.org/firefox/addon/ms-edge-tabs-aside/)
- [Google Chrome Webstore](https://chrome.google.com/webstore/detail/mgmjbodjgijnebfgohlnjkegdpbdjgin)
- [Microsoft Edge Add-ons Webstore](https://microsoftedge.microsoft.com/addons/detail/kmnblllmalkiapkfknnlpobmjjdnlhnd) - [Microsoft Edge Add-ons Webstore](https://microsoftedge.microsoft.com/addons/detail/kmnblllmalkiapkfknnlpobmjjdnlhnd)
- [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/ms-edge-tabs-aside/) - [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/ms-edge-tabs-aside/)
- [GitHub Releases](https://github.com/xfox111/TabsAsideExtension/releases/latest) - [GitHub Releases](https://github.com/xfox111/TabsAsideExtension/releases/latest)
## Project roadmap ### Sideloading (for testing purposes only)
You can go to the project's [roadmap kanban board](https://github.com/XFox111/TabsAsideExtension/projects/1) and see what have we planned and watch our progress in realtime
<details>
<summary>Click to expand</summary>
---
<details>
<summary><b>Chromium-based browsers (Edge, Chrome, etc.)</b></summary>
> 1. Go to [Releases](https://github.com/XFox111/TabsAsideExtension/releases) and select a release to download
> 2. Download attached archive for Chromium and unpack it
> 3. Go to `chrome://extensions`
> 4. Enable "Developer mode"
> 5. Click the "Load unpacked" button and navigate to the extension's root folder (contains `manifest.json`)
> 6. Done!
</details>
<details>
<summary><b>Firefox</b></summary>
> 1. Go to [Releases](https://github.com/XFox111/TabsAsideExtension/releases) and select a release to download
> 2. Download attached archive for Firefox and unpack it
> 3. Go to `about:debugging#/runtime/this-firefox`
> 4. Click the "Load Temporary Add-on..." button and select `manifest.json` file in the root folder
> 5. Done!
> **Important!**
This will _replace_ officialy installed version if you have one.
If you want to sideload it without replacing to run both versions at the same time - before loading add-on, open `manifest.json` in a text editor and change `id` key (it's `tabsaside@xfox111.net` by default) to something else
</details>
> **Note:** If you delete the extension folder it will disappear from your browser
---
</details>
## Contributing ## Contributing
[![GitHub issues](https://img.shields.io/github/issues/xfox111/TabsAsideExtension)](https://github.com/xfox111/TabsAsideExtension/issues)
[![CI](https://github.com/XFox111/TabsAsideExtension/actions/workflows/cd_pipeline.yaml/badge.svg)](https://github.com/XFox111/TabsAsideExtension/actions/workflows/cd_pipeline.yaml)
[![GitHub repo size](https://img.shields.io/github/repo-size/xfox111/TabsAsideExtension?label=repo%20size)](https://github.com/xfox111/TabsAsideExtension)
There are many ways in which you can participate in the project, for example: There are many ways in which you can participate in the project, for example:
- [Submit bugs and feature requests](https://github.com/xfox111/TabsAsideExtension/issues), and help us verify as they are checked in - [Submit bugs and feature requests](https://github.com/xfox111/TabsAsideExtension/issues), and help us verify as they are checked in
- Review [source code changes](https://github.com/xfox111/TabsAsideExtension/pulls) - Review [source code changes](https://github.com/xfox111/TabsAsideExtension/pulls)
- Review documentation and make pull requests for anything from typos to new content - Review documentation and make pull requests for anything from typos to new content
If you are interested in fixing issues and contributing directly to the code base, please see the [Contribution Guidelines](https://github.com/XFox111/TabsAsideExtension/blob/master/CONTRIBUTING.md), which covers the following: If you are interested in fixing issues and contributing directly to the code base, please refer to the [Contribution Guidelines](https://github.com/XFox111/TabsAsideExtension/wiki/Contribution-Guidelines)
- [How to deploy the extension on your browser](https://github.com/XFox111/TabsAsideExtension/blob/master/CONTRIBUTING.md#deploy-test-version-on-your-browser)
- [The development workflow](https://github.com/XFox111/TabsAsideExtension/blob/master/CONTRIBUTING.md#development-workflow), including debugging and running tests
- [Coding guidelines](https://github.com/XFox111/TabsAsideExtension/blob/master/CONTRIBUTING.md#coding-guidelines)
- [Submitting pull requests](https://github.com/XFox111/TabsAsideExtension/blob/master/CONTRIBUTING.md#submitting-pull-requests)
- [Finding an issue to work on](https://github.com/XFox111/TabsAsideExtension/blob/master/CONTRIBUTING.md#finding-an-issue-to-work-on)
- [Contributing to translations](https://github.com/XFox111/TabsAsideExtension/blob/master/CONTRIBUTING.md#contributing-to-translations)
## Code of Conduct ---
This project has adopted the Contributor Covenant. For more information see the [Code of Conduct](https://github.com/XFox111/TabsAsideExtension/blob/master/CODE_OF_CONDUCT.md)
## Copyrights [![Bluesky](https://img.shields.io/badge/%40xfox111.net-BSky?logo=bluesky&logoColor=%230285FF&label=Bluesky&labelColor=white&color=%230285FF)](https://bsky.app/profile/xfox111.net)
> ©2021 Michael "XFox" Gordeev [![GitHub](https://img.shields.io/badge/%40xfox111-GitHub?logo=github&logoColor=%23181717&label=GitHub&labelColor=white&color=%23181717)](https://github.com/xfox111)
[![Buy Me a Coffee](https://img.shields.io/badge/%40xfox111-BMC?logo=buymeacoffee&logoColor=black&label=Buy%20me%20a%20coffee&labelColor=white&color=%23FFDD00)](https://buymeacoffee.com/xfox111)
Font copyrights: Microsoft Corportation ©2021 (Additional ELUA applied) > ©2025 Eugene Fox. Licensed under [MIT license](https://github.com/XFox111/TabsAsideExtension/blob/main/LICENSE)
Licensed under [MIT License](https://opensource.org/licenses/MIT)
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
Tabs aside extension has a linear versioning system. The latest version is always the most secure one. This is applied to major versions as well. If you are using an older version, please update it to the latest one.
We regularly run security audits and fix any security issues that are found. If you find a security issue, please report it to us as described below.
If you are willing to continue using an older version, you can sideload it from the [releases page](https://github.com/XFox111/TabsAsideExtension/releases). Use outdated versions at your own risk.
## Reporting a Vulnerability
You can report a security issue by clicking "Report a vulnerability" button at the top-right of this page, or by going through [this link](https://github.com/XFox111/TabsAsideExtension/security/advisories/new)
-96
View File
@@ -1,96 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title loc="name">Tabs aside</title>
<link id="icon" rel="shortcut icon" type="image/png" href="icons/light/empty/16.png" />
<link rel="stylesheet" type="text/css" href="css/style.css" />
<link rel="stylesheet" type="text/css" href="css/style.generic.css" />
<link rel="stylesheet" type="text/css" href="css/style.dark.css" />
<link rel="stylesheet" type="text/css" href="css/style.listview.css" />
<meta charset="utf-8" />
</head>
<body>
<div class="tabsAside background">
<div class="tabsAside closeArea"></div>
<aside class="tabsAside pane">
<header>
<h1 loc="name">Tabs aside</h1>
<button loc_alt="options" class="btn more" title="Options">&#xE712;<span class="updateBadge"></span></button>
<nav>
<label class="checkbox control">
<input id="loadOnRestore" type="checkbox" />
<span class="mark"></span>
<span loc="loadOnRestore">Load tabs on restore</span>
</label>
<label class="checkbox control">
<input id="swapIconAction" type="checkbox" />
<span class="mark"></span>
<span loc="swapIconAction">Set tabs aside on extension icon click (Alt+P or right-click to open the pane)</span>
</label>
<label class="checkbox control">
<input id="showDeleteDialog" type="checkbox" />
<span class="mark"></span>
<span loc="showDeleteDialog">Show confirmation dialog before deleting an item</span>
</label>
<hr />
<div>
<button value="https://github.com/xfox111/TabsAsideExtension">
<img src="icons/github.svg"/>
<span loc="github">Visit GitHub page</span>
</button>
<button value="https://github.com/XFox111/TabsAsideExtension/releases/latest">
<img src="icons/list.svg"/>
<span loc="changelog">Changelog</span>
<span class="updateBadge"></span>
</button>
<button feedback-button>
<img src="icons/feedback.svg"/>
<span loc="feedback">Leave feedback</span>
</button>
<button value="https://buymeacoffee.com/xfox111">
<img style="filter: none !important;" src="icons/bmc.svg"/>
<span loc="buyMeACoffee">Buy me a coffee!</span>
</button>
</div>
<hr />
<p>
<small>v1.0</small><br />
<span loc="credits">Developed by Michael Gordeev</span> (<a href="https://twitter.com/xfox111" target="_blank">@xfox111</a>)
</p>
</nav>
<button loc_alt="closePanel" class="btn remove" title="Close panel">&#xE711;</button>
<a class="saveTabs">
<span class="iconArrowRight">&#xE72A;</span>
<span loc="setAside">Set current tabs aside</span>
<span loc_alt="setMultipleTabsAsideTooltip" class="iconQuestionCircle" title="Tip : You can set aside specific tabs by selecting them (by holding CTRL or SHIFT and clicking on the tabs) before clicking on the TabsAside extension">&#xE9CE;</span>
</a>
<div class="listviewSwitch tile">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div class="listviewSwitch list">
<div></div>
<div></div>
<div></div>
</div>
</header>
<section>
<h2 loc="nothingSaved">You have no aside tabs</h2>
</section>
</aside>
</div>
<script type="text/javascript" src="js/aside-script.js"></script>
</body>
</html>
-142
View File
@@ -1,142 +0,0 @@
{
"name":
{
"message": "Tabs aside",
"description": "Extension name. Displayed in the manifest and pane header"
},
"description":
{
"message": "Classic Microsoft Edge \"Tabs Aside\" feature",
"description": "Extension description"
},
"author":
{
"message": "Michael \"XFox\" Gordeev",
"description": "Author name"
},
"options":
{
"message": "Options",
"description": "Alternative text for options button in the pane"
},
"closePanel":
{
"message": "Close",
"description": "Alternative text for close panel button"
},
"loadOnRestore":
{
"message": "Load tabs on restore",
"description": "Label for option"
},
"showDeleteDialog":
{
"message": "Show confirmation dialog before deleting an item",
"description": "Label for option"
},
"swapIconAction":
{
"message": "Set tabs aside on extension icon click (%TOGGLE_SHORTCUT% or right-click to open the pane)",
"description": "Label for option"
},
"github":
{
"message": "Visit GitHub page",
"description": "Link title"
},
"changelog":
{
"message": "Changelog",
"description": "Link title"
},
"feedback":
{
"message": "Leave feedback",
"description": "Link title"
},
"buyMeACoffee":
{
"message": "Buy me a coffee!",
"description": "Link title"
},
"credits":
{
"message": "Developed by Michael 'XFox' Gordeev",
"description": "Options menu credits"
},
"setAside":
{
"message": "Set current tabs aside",
"description": "Save collection action name. Used in the pane, extension context menu and manifest shortcuts"
},
"setMultipleTabsAsideTooltip":
{
"message": "Tip : You can set aside specific tabs by selecting them (by holding CTRL or SHIFT and clicking on the tabs) before clicking on the TabsAside extension",
"description": "Tooltip displayed on hover in the pane to explain how to set specific tabs aside"
},
"nothingSaved":
{
"message": "You have no aside tabs",
"description": "Placeholder for empty pane"
},
"removeTab":
{
"message": "Remove tab from collection",
"description": "Button hint on a tab card"
},
"restoreTabs":
{
"message": "Restore tabs",
"description": "Collection restore action link name"
},
"more":
{
"message": "More...",
"description": "Collections' more button title"
},
"restoreNoRemove":
{
"message": "Restore without removing",
"description": "Context action item name"
},
"removeCollection":
{
"message": "Remove collection",
"description": "Collection remove action name"
},
"removeCollectionConfirm":
{
"message": "Are you sure you want to delete this collection?",
"description": "Prompt dialog content on collection deletion"
},
"removeTabConfirm":
{
"message": "Are you sure you want to delete this tab?",
"description": "Prompt dialog content on one tab deletion"
},
"togglePaneContext":
{
"message": "Toggle tabs aside pane",
"description": "Context action name. Used in extension context menu and manifest shortcuts"
},
"noTabsToSave":
{
"message": "No tabs available to save",
"description": "Alert dialog message when there's no tabs to save"
},
"errorSavingTabs":
{
"message": "Tabs could not be set aside. You may be trying to set too many tabs aside at once, or have too many tabs already set aside.",
"description": "Alert dialog message when there is an issue saving tabs"
},
"olderDataMigrationFailed":
{
"message": "Some tabs set aside from a previous version could not be migrated. They have been backed up as browser bookmarks.",
"description": "Alert dialog message when there is an issue migrating previous versions data"
},
"tabs":
{
"message": "items",
"description": "Collection tabs counter label (e.g. 8 items)"
}
}
-142
View File
@@ -1,142 +0,0 @@
{
"name":
{
"message": "Pestañas que has reservado",
"description": "Extension name. Displayed in the manifest and pane header"
},
"description":
{
"message": "Función \"Reservar pestañas\" de Microsoft Edge clásico",
"description": "Extension description"
},
"author":
{
"message": "Michael \"XFox\" Gordeev",
"description": "Author name"
},
"options":
{
"message": "Opciones",
"description": "Alternative text for options button in the pane"
},
"closePanel":
{
"message": "Cerrar",
"description": "Alternative text for close panel button"
},
"loadOnRestore":
{
"message": "Cargar pestañas al restaurar",
"description": "Label for option"
},
"showDeleteDialog":
{
"message": "Mostrar diálogo de confirmacion antes de eliminar un item",
"description": "Label for option"
},
"swapIconAction":
{
"message": "Reservar pestañas al apretar el icono de la extensión (%TOGGLE_SHORTCUT% o click-derecho para abrir el panel)",
"description": "Label for option"
},
"github":
{
"message": "Visitar la pagina de GitHub",
"description": "Link title"
},
"changelog":
{
"message": "Registro de cambios",
"description": "Link title"
},
"feedback":
{
"message": "Deja un comentario",
"description": "Link title"
},
"buyMeACoffee":
{
"message": "¡Comprame un cafe!",
"description": "Link title"
},
"credits":
{
"message": "Desarrollado por Michael 'XFox' Gordeev",
"description": "Options menu credits"
},
"setAside":
{
"message": "Reservar las pestañas actuales",
"description": "Save collection action name. Used in the pane, extension context menu and manifest shortcuts"
},
"setMultipleTabsAsideTooltip":
{
"message": "Consejo: puede dejar de lado pestañas específicas seleccionándolas (manteniendo presionada la tecla CTRL o SHIFT y haciendo clic en las pestañas) antes de hacer clic en la extensión TabsAside",
"description": "Tooltip displayed on hover in the pane to explain how to set specific tabs aside"
},
"nothingSaved":
{
"message": "No tienes pestañas reservadas",
"description": "Placeholder for empty pane"
},
"removeTab":
{
"message": "Eliminar pestañas de la colección",
"description": "Button hint on a tab card"
},
"restoreTabs":
{
"message": "Restaurar pestañas",
"description": "Collection restore action link name"
},
"more":
{
"message": "Más...",
"description": "Collections' more button title"
},
"restoreNoRemove":
{
"message": "Restaurar sin eliminar",
"description": "Context action item name"
},
"removeCollection":
{
"message": "Eliminar la colección",
"description": "Collection remove action name"
},
"removeCollectionConfirm":
{
"message": "¿Está seguro que quiere eliminar esta colección?",
"description": "Prompt dialog content on collection deletion"
},
"removeTabConfirm":
{
"message": "¿Está seguro que quiere eliminar esta pestaña?",
"description": "Prompt dialog content on one tab deletion"
},
"togglePaneContext":
{
"message": "Abrir panel de pestañas",
"description": "Context action name. Used in extension context menu and manifest shortcuts"
},
"noTabsToSave":
{
"message": "No hay pestañas disponibles para reservar",
"description": "Alert dialog message when there's no tabs to save"
},
"errorSavingTabs":
{
"message": "Las pestañas no se pueden dejar de lado. Es posible que esté intentando dejar demasiadas pestañas a un lado a la vez o que ya haya reservado demasiadas pestañas.",
"description": "Alert dialog message when there is an issue saving tabs"
},
"olderDataMigrationFailed":
{
"message": "Algunas pestañas apartadas de una versión anterior no se pudieron migrar. Se han respaldado como marcadores del navegador.",
"description": "Alert dialog message when there is an issue migrating previous versions data"
},
"tabs":
{
"message": "items",
"description": "Collection tabs counter label (e.g. 8 items)"
}
}
-142
View File
@@ -1,142 +0,0 @@
{
"name":
{
"message": "Schede a parte",
"description": "name of the extension. Displayed in the manifest and pane header"
},
"description":
{
"message": "La funzione del classico Microsoft Edge \"Tabs Aside\"",
"description": "Extension description"
},
"author":
{
"message": "Michael \"XFox\" Gordeev",
"description": "Author name"
},
"options":
{
"message": "Opzioni",
"description": "Alternative text for options button in the pane"
},
"closePanel":
{
"message": "Chiudi",
"description": "Alternative text for close panel button"
},
"loadOnRestore":
{
"message": "Riapri le schede al ripristino",
"description": "Label for option"
},
"showDeleteDialog":
{
"message": "Mostra una finestra di conferma prima di eliminare qualcosa",
"description": "Label for option"
},
"swapIconAction":
{
"message": "Metti da parte le schede in Tabs Aside al click sull'estensione (%TOGGLE_SHORTCUT% o click con il tasto destro sull'icona dell'estensione per aprire il pannello)",
"description": "Label for option"
},
"github":
{
"message": "Visita la pagina di GitHub",
"description": "Link title"
},
"changelog":
{
"message": "Registro dei cambiamenti",
"description": "Link title"
},
"feedback":
{
"message": "Lascia un feedback",
"description": "Link title"
},
"buyMeACoffee":
{
"message": "Comprami un caffè!",
"description": "Link title"
},
"credits":
{
"message": "Sviluppato da Michael 'XFox' Gordeev",
"description": "Options menu credits"
},
"setAside":
{
"message": "Sposta le schede correnti in Tabs Aside",
"description": "Save collection action name. Used in the pane, extension context menu and manifest shortcuts"
},
"setMultipleTabsAsideTooltip":
{
"message": "Suggerimento: puoi mettere da parte schede specifiche selezionandole (tenendo premuto CTRL o MAIUSC e facendo clic sulle schede) e cliccando sull'estensione Tabs Aside.",
"description": "Tooltip displayed on hover in the pane to explain how to set specific tabs aside"
},
"nothingSaved":
{
"message": "Non hai schede da parte qua",
"description": "Placeholder for empty pane"
},
"removeTab":
{
"message": "Rimuovi la scheda da questo gruppo di schede",
"description": "Button hint on a tab card"
},
"restoreTabs":
{
"message": "Ripristina schede",
"description": "Collection restore action link name"
},
"more":
{
"message": "Altro...",
"description": "Collections' more button title"
},
"restoreNoRemove":
{
"message": "Ripristina senza rimuovere le schede da da Tabs Aside",
"description": "Context action item name"
},
"removeCollection":
{
"message": "Rimuovi gruppo di schede",
"description": "Collection remove action name"
},
"removeCollectionConfirm":
{
"message": "Sei sicuro di voler eliminare questo gruppo di schede?",
"description": "Prompt dialog content on collection deletion"
},
"removeTabConfirm":
{
"message": "Sei sucuro di voler rimuovere questa scheda?",
"description": "Prompt dialog content on one tab deletion"
},
"togglePaneContext":
{
"message": "Mostra/nascondi il pannello di Tabs Aside",
"description": "Context action name. Used in extension context menu and manifest shortcuts"
},
"noTabsToSave":
{
"message": "Nessuna scheda da salvare",
"description": "Alert dialog message when there's no tabs to save"
},
"errorSavingTabs":
{
"message": "Queste schede non possono essere messe da parte. Forse perché stai cercando di mettere da parte troppe schede o perché ce ne sono già troppe messe da parte.",
"description": "Alert dialog message when there is an issue saving tabs"
},
"olderDataMigrationFailed":
{
"message": "Alcune schede messe da parte in una versione precedente non potevano essere mantenute. Sono state salvate come preferiti del browser.",
"description": "Alert dialog message when there is an issue migrating previous versions data"
},
"tabs":
{
"message": "schede",
"description": "Collection tabs counter label (e.g. 8 items)"
}
}
-142
View File
@@ -1,142 +0,0 @@
{
"name":
{
"message": "Kolekcje zakładek",
"description": "Extension name. Displayed in the manifest and pane header"
},
"description":
{
"message": "Klasyczna funkcjonalność Microsoft Edge \"Kolekcje zakładek\"",
"description": "Extension description"
},
"author":
{
"message": "Michael \"XFox\" Gordeev",
"description": "Author name"
},
"options":
{
"message": "Opcje",
"description": "Alternative text for options button in the pane"
},
"closePanel":
{
"message": "Zamknij",
"description": "Alternative text for close panel button"
},
"loadOnRestore":
{
"message": "Przeładuj zawartość kart podczas przywracania",
"description": "Label for option"
},
"showDeleteDialog":
{
"message": "Pokaż potwierdzenie podczas kasowania elementu",
"description": "Label for option"
},
"swapIconAction":
{
"message": "Kliknięcie na ikonę zapisuje aktualne karty (%TOGGLE_SHORTCUT% lub prawy przycisk myszki, aby otworzyć panel)",
"description": "Label for option"
},
"github":
{
"message": "Strona GitHub",
"description": "Link title"
},
"changelog":
{
"message": "Historia zmian",
"description": "Link title"
},
"feedback":
{
"message": "Wyślij opinię",
"description": "Link title"
},
"buyMeACoffee":
{
"message": "Kup mi kawę!",
"description": "Link title"
},
"credits":
{
"message": "Opracowane przez Michael 'XFox' Gordeev",
"description": "Options menu credits"
},
"setAside":
{
"message": "Zapisz aktualne karty",
"description": "Save collection action name. Used in the pane, extension context menu and manifest shortcuts"
},
"setMultipleTabsAsideTooltip":
{
"message": "Wskazówka : możesz zaznaczyć interesujące karty używając CTRL lub SHIFT na karcie, aby tylko te później zapisać",
"description": "Tooltip displayed on hover in the pane to explain how to set specific tabs aside"
},
"nothingSaved":
{
"message": "Nie posiadasz żadnej kolekcji zakładek",
"description": "Placeholder for empty pane"
},
"removeTab":
{
"message": "Usunięcie karty z kolekcji",
"description": "Button hint on a tab card"
},
"restoreTabs":
{
"message": "Przywrócenie kart",
"description": "Collection restore action link name"
},
"more":
{
"message": "Więcej...",
"description": "Collections' more button title"
},
"restoreNoRemove":
{
"message": "Przywrócenie kart bez usunięcia kolekcji",
"description": "Context action item name"
},
"removeCollection":
{
"message": "Usunięcie kolekcji",
"description": "Collection remove action name"
},
"removeCollectionConfirm":
{
"message": "Jesteś pewny, że chcesz skasować kolekcję?",
"description": "Prompt dialog content on collection deletion"
},
"removeTabConfirm":
{
"message": "Jesteś pewny, że chcesz skasować zakładkę?",
"description": "Prompt dialog content on one tab deletion"
},
"togglePaneContext":
{
"message": "Przełącz panel kolekcji",
"description": "Context action name. Used in extension context menu and manifest shortcuts"
},
"noTabsToSave":
{
"message": "Brak zakładek do zapisu",
"description": "Alert dialog message when there's no tabs to save"
},
"errorSavingTabs":
{
"message": "Zakładki nie mogą zostać zapisane. Przypuszczalnie zbyt wiele zakładek lub aktualnie posiadanych kolekcji.",
"description": "Alert dialog message when there is an issue saving tabs"
},
"olderDataMigrationFailed":
{
"message": "Niektóre kolekcje z poprzedniej wersji nie mogły zostać przekonwertowane, w związku z czym zostały zapisane jako zwykłe zakładki w przeglądarce",
"description": "Alert dialog message when there is an issue migrating previous versions data"
},
"tabs":
{
"message": "elementów",
"description": "Collection tabs counter label (e.g. 8 items)"
}
}
-142
View File
@@ -1,142 +0,0 @@
{
"name":
{
"message": "Reservar abas",
"description": "Extension name. Displayed in the manifest and pane header"
},
"description":
{
"message": "Função \"Reservar abas\" do Microsoft Edge Clássico",
"description": "Extension description"
},
"author":
{
"message": "Michael \"XFox\" Gordeev",
"description": "Author name"
},
"options":
{
"message": "Opções",
"description": "Alternative text for options button in the pane"
},
"closePanel":
{
"message": "Fechar",
"description": "Alternative text for close panel button"
},
"loadOnRestore":
{
"message": "Carregar abas ao restaurar",
"description": "Label for option"
},
"showDeleteDialog":
{
"message": "Mostrar diálogo de confirmação antes de deletar um item",
"description": "Label for option"
},
"swapIconAction":
{
"message": "Reservar abas ao apertar o ícone da extensão (%TOGGLE_SHORTCUT% ou clique-direito para abrir o painel)",
"description": "Label for option"
},
"github":
{
"message": "Visite a página do GitHub",
"description": "Link title"
},
"changelog":
{
"message": "Registro de mudanças",
"description": "Link title"
},
"feedback":
{
"message": "Deixe um comentário",
"description": "Link title"
},
"buyMeACoffee":
{
"message": "Me compre um café!",
"description": "Link title"
},
"credits":
{
"message": "Desenvolvido por Michael 'XFox' Gordeev",
"description": "Options menu credits"
},
"setAside":
{
"message": "Reservas as abas atuais",
"description": "Save collection action name. Used in the pane, extension context menu and manifest shortcuts"
},
"setMultipleTabsAsideTooltip":
{
"message": "Dica: Você pode reservar abas específicas ao selecioná-las (ao apertar CTRL ou SHIFT e clicando nas abas) antes de clicar na extensão Reservar Abas",
"description": "Tooltip displayed on hover in the pane to explain how to set specific tabs aside"
},
"nothingSaved":
{
"message": "Você não tem abas reservadas",
"description": "Placeholder for empty pane"
},
"removeTab":
{
"message": "Remover aba da coleção",
"description": "Button hint on a tab card"
},
"restoreTabs":
{
"message": "Restaurar abas",
"description": "Collection restore action link name"
},
"more":
{
"message": "Mais...",
"description": "Collections' more button title"
},
"restoreNoRemove":
{
"message": "Restaurar sem remover",
"description": "Context action item name"
},
"removeCollection":
{
"message": "Remover coleção",
"description": "Collection remove action name"
},
"removeCollectionConfirm":
{
"message": "Você tem certeza que quer deletar esta coleção?",
"description": "Prompt dialog content on collection deletion"
},
"removeTabConfirm":
{
"message": "Você tem certeza que quer deletar esta aba?",
"description": "Prompt dialog content on one tab deletion"
},
"togglePaneContext":
{
"message": "Alternar painel de abas",
"description": "Context action name. Used in extension context menu and manifest shortcuts"
},
"noTabsToSave":
{
"message": "Não há abas disponíveis para salvar",
"description": "Alert dialog message when there's no tabs to save"
},
"errorSavingTabs":
{
"message": "As abas não puderam ser reservadas. Você pode ter tentado reservar muitas abas de uma vez só, ou pode ter abas demais já reservadas.",
"description": "Alert dialog message when there is an issue saving tabs"
},
"olderDataMigrationFailed":
{
"message": "Algumas abas reservadas de uma versão antiga não puderam ser migradas. Elas foram salvas como marcadores do navegador.",
"description": "Alert dialog message when there is an issue migrating previous versions data"
},
"tabs":
{
"message": "itens",
"description": "Collection tabs counter label (e.g. 8 items)"
}
}
-142
View File
@@ -1,142 +0,0 @@
{
"name":
{
"message": "Отложенные вкладки",
"description": "Extension name. Displayed in the manifest and pane header"
},
"description":
{
"message": "Функиця отложенных вкладок классического Microsoft Edge",
"description": "Extension description"
},
"author":
{
"message": "Михаил \"XFox\" Гордеев",
"description": "Author name"
},
"options":
{
"message": "Настройки",
"description": "Alternative text for options button in the pane"
},
"closePanel":
{
"message": "Закрыть",
"description": "Alternative text for close panel button"
},
"loadOnRestore":
{
"message": "Загружать вкладки после открытия",
"description": "Label for option"
},
"showDeleteDialog":
{
"message": "Показывать окно подтверждения перед удалением элемента",
"description": "Label for option"
},
"swapIconAction":
{
"message": "Откладывать вкладки при нажатии на иконку расширения (%TOGGLE_SHORTCUT% или правая кнопка мыши для открытия панели)",
"description": "Label for option"
},
"github":
{
"message": "Страница GitHub",
"description": "Link title"
},
"changelog":
{
"message": "Список изменений",
"description": "Link title"
},
"feedback":
{
"message": "Оставить отзыв",
"description": "Link title"
},
"buyMeACoffee":
{
"message": "Купить мне кофе!",
"description": "Link title"
},
"credits":
{
"message": "Разработано: Михаил 'XFox' Гордеев",
"description": "Options menu credits"
},
"setAside":
{
"message": "Отложить открытые вкладки",
"description": "Save collection action name. Used in the pane, extension context menu and manifest shortcuts"
},
"setMultipleTabsAsideTooltip":
{
"message": "Подсказка: теперь вы можете откладывать только некоторые вкладки. Просто выделите нужные вкладки с помощью Shift или Ctrl перед использованием расширения",
"description": "Tooltip displayed on hover in the pane to explain how to set specific tabs aside"
},
"nothingSaved":
{
"message": "У вас нет отложенных вкладок",
"description": "Placeholder for empty pane"
},
"removeTab":
{
"message": "Удалить вкладку из коллекции",
"description": "Button hint on a tab card"
},
"restoreTabs":
{
"message": "Восстановить вкладки",
"description": "Collection restore action link name"
},
"more":
{
"message": "Больше...",
"description": "Collections' more button title"
},
"restoreNoRemove":
{
"message": "Восстановить без удаления",
"description": "Context action item name"
},
"removeCollection":
{
"message": "Удалить коллекцию",
"description": "Collection remove action name"
},
"removeCollectionConfirm":
{
"message": "Вы уверены, что хотите удалить эту коллекцию?",
"description": "Prompt dialog content on collection deletion"
},
"removeTabConfirm":
{
"message": "Вы уверены, что хотите удалить эту вкладку из коллекции?",
"description": "Prompt dialog content on one tab deletion"
},
"togglePaneContext":
{
"message": "Открыть/закрыть панель",
"description": "Context action name. Used in extension context menu and manifest shortcuts"
},
"noTabsToSave":
{
"message": "Нет доступных для сохранения вкладок",
"description": "Alert dialog message when there's no tabs to save"
},
"errorSavingTabs":
{
"message": "Не удается отложить вкладки. Возможно, вы пытаетесь отложить слишком много вкладок за раз или уже отложили слишком много вкладок",
"description": "Alert dialog message when there is an issue saving tabs"
},
"olderDataMigrationFailed":
{
"message": "Не удалось перенести отложенные вкладки, сохраненные в старой версии расширения. Они были сохранены в закладках",
"description": "Alert dialog message when there is an issue migrating previous versions data"
},
"tabs":
{
"message": "вкладок",
"description": "Collection tabs counter label (e.g. 8 items)"
}
}
-142
View File
@@ -1,142 +0,0 @@
{
"name":
{
"message": "搁置的标签页",
"description": "Extension name. Displayed in the manifest and pane header"
},
"description":
{
"message": "为Chromium浏览器提供旧版 Microsoft Edge 中的\"搁置标签页\" 功能",
"description": "Extension description"
},
"author":
{
"message": "Michael \"XFox\" Gordeev",
"description": "Author name"
},
"options":
{
"message": "设置",
"description": "Alternative text for options button in the pane"
},
"closePanel":
{
"message": "关闭",
"description": "Alternative text for close panel button"
},
"loadOnRestore":
{
"message": "在重新打开时加载页面",
"description": "Label for option"
},
"showDeleteDialog":
{
"message": "在删除项目之前显示确认对话框",
"description": "Label for option"
},
"swapIconAction":
{
"message": "点击拓展图标来搁置所有标签页 (按%TOGGLE_SHORTCUT%或右键来打开侧栏)",
"description": "Label for option"
},
"github":
{
"message": "查看GitHub页面",
"description": "Link title"
},
"changelog":
{
"message": "变更日志",
"description": "Link title"
},
"feedback":
{
"message": "给我们反馈",
"description": "Link title"
},
"buyMeACoffee":
{
"message": "给我买杯咖啡!",
"description": "Link title"
},
"credits":
{
"message": "由Michael 'XFox' Gordeev开发",
"description": "Options menu credits"
},
"setAside":
{
"message": "搁置当前的所有标签页",
"description": "Save collection action name. Used in the pane, extension context menu and manifest shortcuts"
},
"setMultipleTabsAsideTooltip":
{
"message": "提示:在单击TabsAside扩展名之前,可以通过选择特定选项卡(按住CTRL或SHIFT并单击选项卡)来搁置它们",
"description": "Tooltip displayed on hover in the pane to explain how to set specific tabs aside"
},
"nothingSaved":
{
"message": "你目前没有搁置的标签页",
"description": "Placeholder for empty pane"
},
"removeTab":
{
"message": "从标签页集中移除标签页",
"description": "Button hint on a tab card"
},
"restoreTabs":
{
"message": "恢复标签页",
"description": "Collection restore action link name"
},
"more":
{
"message": "更多...",
"description": "Collections' more button title"
},
"restoreNoRemove":
{
"message": "恢复但不移除标签页",
"description": "Context action item name"
},
"removeCollection":
{
"message": "移除标签页集",
"description": "Collection remove action name"
},
"removeCollectionConfirm":
{
"message": "你确定要移除这个标签页集吗?",
"description": "Prompt dialog content on collection deletion"
},
"removeTabConfirm":
{
"message": "你确定要移除这个标签页吗?",
"description": "Prompt dialog content on one tab deletion"
},
"togglePaneContext":
{
"message": "打开或关闭侧栏",
"description": "Context action name. Used in extension context menu and manifest shortcuts"
},
"noTabsToSave":
{
"message": "没有可以搁置的标签页",
"description": "Alert dialog message when there's no tabs to save"
},
"errorSavingTabs":
{
"message": "标签页无法被搁置. 可能是因为您尝试一次性搁置过多的标签页, 或是先前搁置的标签页过多.",
"description": "Alert dialog message when there is an issue saving tabs"
},
"olderDataMigrationFailed":
{
"message": "一部分搁置的标签页无法从上一个版本中迁移. 这些标签页已经作为浏览器书签被备份.",
"description": "Alert dialog message when there is an issue migrating previous versions data"
},
"tabs":
{
"message": "项",
"description": "Collection tabs counter label (e.g. 8 items)"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { googleAnalytics4 } from "@wxt-dev/analytics/providers/google-analytics-4";
import { WxtAppConfig } from "wxt/sandbox";
import { userPropertiesStorage } from "./features/analytics";
export default defineAppConfig({
analytics:
{
debug: import.meta.env.DEV,
enabled: storage.defineItem("local:analytics", {
fallback: true
}),
userId: storage.defineItem("local:userId", {
init: () => crypto.randomUUID()
}),
userProperties: userPropertiesStorage,
providers:
[
googleAnalytics4({
apiSecret: import.meta.env.WXT_GA4_API_SECRET,
measurementId: import.meta.env.WXT_GA4_MEASUREMENT_ID
})
]
}
} as WxtAppConfig);
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<style type="text/css">
.icon {
fill: #242424;
}
@media (prefers-color-scheme: dark) {
.icon {
fill: #ffffff;
}
}
</style>
</defs>
<path class="icon"
d="M10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18ZM10 3C10.6568 3 11.4068 3.59025 12.0218 4.90814C12.2393 5.37419 12.4283 5.90978 12.5806 6.5H7.41936C7.57172 5.90978 7.76073 5.37419 7.97822 4.90814C8.59323 3.59025 9.34315 3 10 3ZM7.07203 4.48526C6.79564 5.07753 6.56498 5.75696 6.38931 6.5H3.93648C4.77295 5.05399 6.11182 3.93497 7.71442 3.38163C7.47297 3.71222 7.25828 4.08617 7.07203 4.48526ZM6.19265 7.5C6.06723 8.28832 6 9.12934 6 10C6 10.8707 6.06723 11.7117 6.19265 12.5H3.45963C3.16268 11.7236 3 10.8808 3 10C3 9.1192 3.16268 8.2764 3.45963 7.5H6.19265ZM6.38931 13.5C6.56498 14.243 6.79564 14.9225 7.07203 15.5147C7.25828 15.9138 7.47297 16.2878 7.71442 16.6184C6.11182 16.065 4.77295 14.946 3.93648 13.5H6.38931ZM7.41936 13.5H12.5806C12.4283 14.0902 12.2393 14.6258 12.0218 15.0919C11.4068 16.4097 10.6568 17 10 17C9.34315 17 8.59323 16.4097 7.97822 15.0919C7.76073 14.6258 7.57172 14.0902 7.41936 13.5ZM12.7938 12.5H7.20617C7.07345 11.7253 7 10.8833 7 10C7 9.11669 7.07345 8.27472 7.20617 7.5H12.7938C12.9266 8.27472 13 9.11669 13 10C13 10.8833 12.9266 11.7253 12.7938 12.5ZM13.6107 13.5H16.0635C15.2271 14.946 13.8882 16.065 12.2856 16.6184C12.527 16.2878 12.7417 15.9138 12.928 15.5147C13.2044 14.9225 13.435 14.243 13.6107 13.5ZM16.5404 12.5H13.8074C13.9328 11.7117 14 10.8707 14 10C14 9.12934 13.9328 8.28832 13.8074 7.5H16.5404C16.8373 8.2764 17 9.1192 17 10C17 10.8808 16.8373 11.7236 16.5404 12.5ZM12.2856 3.38163C13.8882 3.93497 15.2271 5.05399 16.0635 6.5H13.6107C13.435 5.75696 13.2044 5.07753 12.928 4.48526C12.7417 4.08617 12.527 3.71222 12.2856 3.38163Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg SYSTEM "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-flat-20110816.dtd">
<svg id="PagePlaceholder" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">
<defs>
<style type="text/css">
.background {
fill: #ffffff;
}
.pageElements {
fill: #c2c2c2;
}
@media (prefers-color-scheme: dark) {
.background {
fill: #141414;
}
.pageElements {
fill: #666666;
}
}
</style>
</defs>
<rect class="background" width="800" height="600" rx="10" ry="10" />
<g class="pageElements">
<rect x="50" y="50" width="700" height="300" rx="10" ry="10" />
<rect x="50" y="370" width="220" height="180" rx="10" ry="10" />
<rect x="530" y="370" width="220" height="180" rx="10" ry="10" />
<rect x="290" y="370" width="220" height="180" rx="10" ry="10" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 899 B

+57
View File
@@ -0,0 +1,57 @@
html,
body
{
padding: 0;
margin: 0;
-moz-user-select: none;
-webkit-user-select: none;
user-select: none;
overflow: hidden;
}
*
{
margin: 0;
box-sizing: border-box;
}
#root > .fui-FluentProvider
{
height: 100vh;
overflow: hidden;
}
/* width */
::-webkit-scrollbar
{
width: 8px;
height: 8px;
}
/* Track */
::-webkit-scrollbar-track
{
background: transparent;
}
/* Handle */
::-webkit-scrollbar-thumb
{
/* eslint-disable-next-line css/no-invalid-properties */
background-color: var(--colorNeutralStroke1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover
{
/* eslint-disable-next-line css/no-invalid-properties */
background-color: var(--colorNeutralStroke1Hover);
}
::-webkit-scrollbar-thumb:hover:active
{
/* eslint-disable-next-line css/no-invalid-properties */
background-color: var(--colorNeutralStroke1Pressed);
}
+48
View File
@@ -0,0 +1,48 @@
import { useDangerStyles } from "@/hooks/useDangerStyles";
import { Button, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger } from "@fluentui/react-components";
export default function PromptDialog(props: PromptDialogProps): React.ReactElement
{
const dangerCls = useDangerStyles();
return (
<DialogSurface>
<DialogBody>
<DialogTitle>{ props.title }</DialogTitle>
<DialogContent>
{ props.content }
</DialogContent>
<DialogActions>
<DialogTrigger disableButtonEnhancement>
<Button
appearance="primary"
className={ props.destructive ? dangerCls.buttonPrimary : undefined }
onClick={ props.onConfirm }
>
{ props.confirmText }
</Button>
</DialogTrigger>
<DialogTrigger disableButtonEnhancement>
<Button appearance="subtle">
{ props.cancelText ?? i18n.t("common.actions.cancel") }
</Button>
</DialogTrigger>
</DialogActions>
</DialogBody>
</DialogSurface>
);
}
export type PromptDialogProps =
{
title: string;
content: React.ReactNode;
confirmText: string;
cancelText?: string;
onConfirm: () => void;
destructive?: boolean;
};
+51
View File
@@ -0,0 +1,51 @@
import { Dialog, DialogModalType } from "@fluentui/react-components";
import { createContext, PropsWithChildren, ReactElement } from "react";
import PromptDialog, { PromptDialogProps } from "@/components/PromptDialog";
const DialogContext = createContext<DialogContextType>(null!);
export default function DialogProvider(props: PropsWithChildren): ReactElement
{
const [dialog, setDialog] = useState<ReactElement | null>(null);
const [modalType, setModalType] = useState<DialogModalType | undefined>(undefined);
const [onDismiss, setOnDismiss] = useState<(() => void) | undefined>(undefined);
const pushPrompt = (props: PromptDialogProps): void =>
setDialog(
<PromptDialog { ...props } />
);
const pushCustom = (dialogSurface: ReactElement, modalType?: DialogModalType, onDismiss?: () => void): void =>
{
setDialog(dialogSurface);
setModalType(modalType);
setOnDismiss(() => onDismiss);
};
const handleOpenChange = () =>
{
onDismiss?.();
setOnDismiss(undefined);
setTimeout(() => setDialog(null), 200);
};
return (
<DialogContext.Provider value={ { pushPrompt, pushCustom } }>
{ props.children }
{ dialog &&
<Dialog defaultOpen onOpenChange={ handleOpenChange } modalType={ modalType }>
{ dialog }
</Dialog>
}
</DialogContext.Provider>
);
}
export const useDialog = () => useContext<DialogContextType>(DialogContext);
export type DialogContextType =
{
pushPrompt(props: PromptDialogProps): void;
pushCustom(dialogSurface: ReactElement, modalType?: DialogModalType, onDismiss?: () => void): void;
};
+35
View File
@@ -0,0 +1,35 @@
import { FluentProvider, Theme, webDarkTheme, webLightTheme } from "@fluentui/react-components";
import { createContext } from "react";
const ThemeContext = createContext<ThemeContextType>({ theme: webLightTheme, isDark: false });
const media: MediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
export default function ThemeProvider(props: React.PropsWithChildren): React.ReactElement
{
const [isDark, setIsDark] = useState<boolean>(media.matches);
const theme = useMemo(() => isDark ? webDarkTheme : webLightTheme, [isDark]);
useEffect(() =>
{
const updateTheme = (args: MediaQueryListEvent) => setIsDark(args.matches);
media.addEventListener("change", updateTheme);
return () => media.removeEventListener("change", updateTheme);
}, []);
return (
<ThemeContext.Provider value={ { theme, isDark } }>
<FluentProvider theme={ theme }>
{ props.children }
</FluentProvider>
</ThemeContext.Provider>
);
}
export const useTheme = (): ThemeContextType => useContext(ThemeContext);
export type ThemeContextType =
{
theme: Theme;
isDark: boolean;
};
-310
View File
@@ -1,310 +0,0 @@
.tabsAside.background
{
background-color: rgba(255, 255, 255, .5);
position: fixed;
top: 0;
bottom: 0;
right: 0;
left: 0;
transition: .2s;
color: black;
}
.tabsAside.closeArea
{
position: fixed;
top: 0;
bottom: 0;
right: 0;
left: 0;
background-color: transparent;
}
.tabsAside.pane
{
user-select: none;
position: fixed;
right: 0px;
top: 0px;
bottom: 0px;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
width: 100%;
min-width: 500px;
background-color: #f7f7f7;
border: 1px solid rgba(100, 100, 100, .5);
border-width: 0px 0px 0px 1px;
box-shadow: 6px 0px 12px black;
transform: translateX(110%); /* pane is hidded */
transition: .2s;
}
aside[embedded]
{
width: 500px !important;
}
.tabsAside.pane[opened]
{
transform: translateX(0px);
}
/* Pane header*/
.tabsAside.pane > header
{
z-index: 1;
padding: 14px 20px 16px 20px;
box-shadow: 0px 0px 5px rgba(0,0,0,.5);
background-color: white;
display: grid;
grid-template-columns: 1fr auto auto;
grid-column-gap: 10px;
grid-row-gap: 20px;
}
.tabsAside.pane > header > h1
{
margin: 0px 5px;
font-weight: 400;
font-size: 24px;
}
.tabsAside.pane > header > button .updateBadge
{
position: absolute;
bottom: 2px;
right: 2px;
}
.tabsAside.pane > header > nav
{
top: 45px;
right: 55px;
}
.tabsAside.pane > header nav > p
{
margin: 10px;
}
.tabsAside.pane > header nav > p > a
{
text-decoration: none;
}
.tabsAside.pane > header nav > p > a:hover
{
text-decoration: underline;
}
.saveTabs
{
display: inline-grid;
grid-template-columns: 16px auto 16px;
grid-column-gap: 15px;
margin-right: auto;
}
.saveTabs:hover
{
text-decoration: none !important;
}
.saveTabs:hover span:nth-child(2)
{
text-decoration: underline;
}
.iconArrowRight,.iconQuestionCircle
{
width: 16px;
height: 16px;
display: inline-block;
font-family: "SegoeMDL2Assets";
margin: 2px;
}
.iconQuestionCircle
{
font-size: small;
padding-top: 2px;
margin-bottom: 0;
}
.tabsAside.pane section
{
overflow: auto;
}
.tabsAside.pane > section > h2
{
margin: 20px;
font-weight: normal;
}
/* Collection header */
.tabsAside.pane > section > div
{
transition: .2s;
}
.collectionSet
{
background-color: white;
margin: 10px;
border-radius: 5px;
border: 1px solid #eee;
}
.collectionSet:hover
{
box-shadow: 0px 0px 5px rgba(0, 0, 0, .25);
}
.collectionSet .header > *
{
visibility: hidden;
}
.collectionSet:hover .header > *
{
visibility: visible;
}
.collectionSet > .header
{
margin: 10px 10px 0px 20px;
display: grid;
grid-template-columns: 1fr auto auto auto;
grid-column-gap: 10px;
align-items: center;
}
.collectionSet > .header > small
{
color: gray;
visibility: visible !important;
}
.collectionSet > .header > input
{
margin: 0px;
visibility: visible !important;
font-weight: 600;
border: none;
background: transparent;
}
.collectionSet > .header > input:hover
{
border: 1px solid black;
}
.collectionSet > .header > div > nav
{
width: 250px;
right: 0px;
top: 35px;
}
/* Tabs collection */
.collectionSet > .set
{
padding: 5px 10px;
white-space: nowrap;
overflow: auto;
}
.collectionSet > .set::-webkit-scrollbar-thumb
{
visibility: hidden;
}
.collectionSet > .set:hover::-webkit-scrollbar-thumb
{
visibility: visible;
}
.collectionSet > .set > div
{
width: 175px;
height: 148px;
margin: 5px;
background-color: #c2c2c2;
background-image: url("chrome-extension://__MSG_@@extension_id__/images/tab_thumbnail.png"),
url("moz-extension://__MSG_@@extension_id__/images/tab_thumbnail.png");
background-size: cover;
background-position-x: center;
display: inline-grid;
grid-template-rows: 1fr auto;
transition: .25s;
cursor: pointer;
border: 1px solid #eee;
border-radius: 5px;
}
.collectionSet > .set > div:hover
{
box-shadow: 0px 0px 5px rgba(100, 100, 100, .5);
}
.collectionSet > .set > div > div
{
background-color: rgba(233, 233, 233, .75);
grid-row: 2;
display: grid;
grid-template-columns: auto 1fr auto;
}
.collectionSet > .set > div > div > button
{
margin: auto;
margin-right: 5px;
display: none;
}
.collectionSet > .set > div:hover > div > button
{
display: initial;
}
.collectionSet > .set > div > div > div
{
width: 20px;
height: 20px;
margin: 8px;
background-image: url("chrome-extension://__MSG_@@extension_id__/images/tab_icon.png"),
url("moz-extension://__MSG_@@extension_id__/images/tab_icon.png");
background-size: 20px;
}
.collectionSet > .set > div > div > span
{
overflow: hidden;
margin: auto 0px;
margin-right: 10px;
font-size: 12px;
}
.collectionSet > .set > div:hover > div > span
{
margin-right: 5px;
}
@media only screen and (max-width: 500px)
{
.tabsAside.pane
{
width: 100% !important;
min-width: initial;
}
}
-136
View File
@@ -1,136 +0,0 @@
.tabsAside[darkmode].background
{
background-color: rgba(0, 0, 0, .5);
}
.tabsAside[darkmode] .pane
{
background-color: #333333;
color: white;
}
.tabsAside[darkmode] .pane header
{
background-color: #3b3b3b;
}
.tabsAside[darkmode] nav hr
{
filter: invert(1);
}
.tabsAside[darkmode] .saveTabs > div
{
filter: invert();
}
/* Button style */
.tabsAside[darkmode] button
{
color: white;
}
.tabsAside[darkmode] .pane button:hover,
.tabsAside[darkmode] .pane .control.checkbox:hover
{
background-color: gray;
}
.tabsAside[darkmode] .pane button img,
.tabsAside[darkmode] .pane label > input + span
{
filter: invert();
}
.tabsAside[darkmode] .pane button:active
{
background-color: dimgray;
}
.tabsAside[darkmode] .collectionSet > .header > input
{
color: white;
}
.tabsAside[darkmode] .collectionSet > .header > input:hover
{
border: 1px solid dimgray;
}
.tabsAside[darkmode] .collectionSet > .header > input:focus
{
background: white;
color: black;
}
.tabsAside[darkmode] a
{
color: #48adff;
}
/* Timestamp label */
.tabsAside[darkmode] > .pane > section small
{
color: lightgray !important;
}
/* Scrollbar style */
.tabsAside[darkmode] .pane ::-webkit-scrollbar-thumb
{
background: gray;
border-radius: 3px;
}
.tabsAside[darkmode] .pane ::-webkit-scrollbar-thumb:hover
{
background: dimgray;
}
.tabsAside[darkmode] .pane .collectionSet
{
background-color: #3b3b3b;
border-color: #444;
}
/* Tab style */
.tabsAside[darkmode] .pane .collectionSet > .set > div
{
background-color: #0c0c0c;
background-image: url("chrome-extension://__MSG_@@extension_id__/images/tab_thumbnail_dark.png"),
url("moz-extension://__MSG_@@extension_id__/images/tab_thumbnail_dark.png");
border-color: #444;
}
.tabsAside[darkmode] .pane .collectionSet > .set > div > div
{
background-color: rgba(50, 50, 50, .75);
}
.tabsAside[darkmode] .pane .collectionSet > .set > div > div > div
{
background-image: url("chrome-extension://__MSG_@@extension_id__/images/tab_icon_dark.png"),
url("moz-extension://__MSG_@@extension_id__/images/tab_icon_dark.png");
}
/* Context menu style */
.tabsAside[darkmode] .pane nav
{
background-color: #4a4a4a;
}
.tabsAside[darkmode] .listviewSwitch > div
{
border-radius: 1px;
background-color: gray;
}
.tabsAside[darkmode] .tabsAside .listviewSwitch.tile > div
{
background-color: #c2c2c2;
}
.tabsAside[darkmode] .tabsAside[listview] .listviewSwitch.tile > div
{
background-color: gray;
}
.tabsAside[darkmode] .tabsAside[listview] .listviewSwitch.list > div
{
background-color: #c2c2c2;
}
-231
View File
@@ -1,231 +0,0 @@
.updateBadge
{
display: none;
}
.tabsAside[updated] .updateBadge
{
width: 10px;
height: 10px;
background-color: #0078d7;
border-radius: 5px;
display: block;
}
/* Custom scrollbar */
.tabsAside ::-webkit-scrollbar
{
height: 6px;
width: 6px;
}
.tabsAside ::-webkit-scrollbar-thumb
{
background: darkgray;
border-radius: 3px;
}
.tabsAside ::-webkit-scrollbar-thumb:hover
{
background: gray;
}
.tabsAside
{
font-family: "SegoeUI", "SegoeMDL2Assets" !important;
font-size: 14px;
user-select: none;
}
/* Links style */
.tabsAside a
{
color: #0078d7;
}
.tabsAside a:hover
{
text-decoration: underline;
cursor: pointer;
}
.tabsAside a:visited
{
color: #0078d7;
}
/* Buttons style */
.tabsAside button
{
width: 28px;
height: 28px;
font-size: 16px;
background-color: transparent;
border: none;
cursor: pointer;
outline: none !important;
position: relative;
}
.tabsAside button:hover
{
background-color: #f2f2f2;
}
.tabsAside button:active
{
background-color: gray;
}
/* Context menus style */
.tabsAside .contextContainer
{
position: relative;
}
.tabsAside nav
{
user-select: none;
position: absolute;
width: 390px;
font-size: 12px;
box-shadow: 0px 4px 10px rgba(0,0,0,.25);
background-color: white;
border-radius: 5px;
z-index: 10;
visibility: hidden;
padding: 4px 0px;
}
.tabsAside nav hr
{
border: none;
height: 1px;
background-color: rgba(0, 0, 0, .1);
margin: 4px 0px
}
.tabsAside nav button
{
align-content: center;
text-align: start;
padding: 0px 12px;
width: 100%;
height: 32px;
font-family: "SegoeUI";
font-size: 12px;
display: grid;
grid-template-columns: auto 1fr auto;
grid-column-gap: 14px;
}
.tabsAside nav button:hover
{
background-color: #eeee;
}
.tabsAside nav button img:first-child
{
width: 16px;
height: 16px;
}
.tabsAside nav button img:nth-child(3)
{
width: 12px;
height: 12px;
}
.tabsAside nav button *:nth-child(3)
{
align-self: center;
}
.tabsAside button + nav:active,
.tabsAside button:focus + nav
{
visibility: visible;
}
/* Icon buttons style */
.btn
{
background-repeat: no-repeat;
background-size: 12px;
background-position: center;
font-family: "SegoeUI", "SegoeMDL2Assets";
}
.control.checkbox
{
display: block;
position: relative;
padding: 8px 12px 8px 42px;
box-sizing: border-box;
cursor: pointer;
min-height: 32px;
}
.control.checkbox:hover
{
background-color: #eeeeee;
}
.control.checkbox input
{
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.control.checkbox input + span
{
position: absolute;
left: 12px;
height: 16px;
width: 16px;
transition-property: background, border, border-color;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.23, 1);
}
.control.checkbox input + span::after
{
position: absolute;
color: rgba(0, 0, 0, .4);
font-family: "SegoeMDL2Assets";
content: "\E73E";
width: 16px;
height: 16px;
font-size: 16px;
text-align: center;
display: none;
}
.control.checkbox input:checked + span::after
{
display: block;
color: black;
}
.control.checkbox:hover input + span::after
{
display: block;
}
@font-face
{
font-family: "SegoeUI";
src: local("Segoe UI"),
url("../fonts/segoeui.ttf") format("truetype"),
url("../fonts/segoeui.woff") format("woff")
}
@font-face
{
font-family: "SegoeMDL2Assets";
src: local("Segoe MDL2 Assets"),
url("../fonts/segoemdl2.ttf") format("truetype"),
url("../fonts/segoemdl2.woff") format("woff")
}
-59
View File
@@ -1,59 +0,0 @@
.tabsAside[listview] .collectionSet > .header
{
margin-bottom: 5px;
}
.tabsAside[listview] .collectionSet > .set
{
max-height: 250px;
}
.tabsAside[listview] .collectionSet > .set > div
{
width: initial;
height: initial;
background-image: none !important;
display: block;
}
.listviewSwitch
{
width: 20px;
height: 20px;
display: grid;
grid-row-gap: 2px;
grid-column-gap: 2px;
cursor: pointer;
margin: 0px auto;
}
.listviewSwitch.tile
{
grid-template-columns: 1fr 1fr;
}
.listviewSwitch > div
{
border-radius: 1px;
background-color: #c2c2c2;
}
.listviewSwitch:hover > div
{
background-color: #a0a0aa;
}
.tabsAside .listviewSwitch.tile > div
{
background-color: gray;
}
.tabsAside[listview] .listviewSwitch.tile > div
{
background-color: #c2c2c2;
}
.tabsAside[listview] .listviewSwitch.list > div
{
background-color: gray;
}
+24
View File
@@ -0,0 +1,24 @@
import Package from "@/package.json";
export const buyMeACoffeeLink: string = "https://buymeacoffee.com/xfox111";
export const bskyLink: string = "https://bsky.app/profile/xfox111.net";
export const websiteLink: string = "https://xfox111.net";
export const v3blogPost: string = "https://at.xfox111.net/tabs-aside-3-0";
const githubLink = (path: string = "."): string =>
new URL(path, browser.runtime.getManifest().homepage_url).href;
export const githubLinks =
{
repo: githubLink(),
release: githubLink(`releases/tag/v${Package.version}`),
license: githubLink("blob/main/LICENSE"),
translationGuide: githubLink("wiki/Contribution-Guidelines#contributing-to-translations")
};
export const storeLink: string =
import.meta.env.FIREFOX
? "https://addons.mozilla.org/en-US/firefox/addon/ms-edge-tabs-aside/" :
chrome.runtime.getManifest().update_url?.startsWith("https://edge.microsoft.com/") ?
"https://microsoftedge.microsoft.com/addons/detail/tabs-aside/kmnblllmalkiapkfknnlpobmjjdnlhnd" :
"https://chromewebstore.google.com/detail/tabs-aside/mgmjbodjgijnebfgohlnjkegdpbdjgin";
+422
View File
@@ -0,0 +1,422 @@
import { track, trackError } from "@/features/analytics";
import { collectionCount, getCollections, saveCollections } from "@/features/collectionStorage";
import { migrateStorage } from "@/features/migration";
import { showWelcomeDialog } from "@/features/v3welcome/utils/showWelcomeDialog";
import { SettingsValue } from "@/hooks/useSettings";
import { CollectionItem, GraphicsStorage } from "@/models/CollectionModels";
import getLogger from "@/utils/getLogger";
import { onMessage, sendMessage } from "@/utils/messaging";
import saveTabsToCollection from "@/utils/saveTabsToCollection";
import sendNotification from "@/utils/sendNotification";
import { settings } from "@/utils/settings";
import watchTabSelection from "@/utils/watchTabSelection";
import { Tabs, Windows } from "wxt/browser";
import { Unwatch } from "wxt/storage";
import { openCollection, openGroup } from "./sidepanel/utils/opener";
export default defineBackground(() =>
{
try
{
const logger = getLogger("background");
const graphicsCache: GraphicsStorage = {};
let listLocation: SettingsValue<"listLocation"> = "sidebar";
logger("Background script started");
// Little workaround for opening side panel
// See: https://stackoverflow.com/questions/77213045/error-sidepanel-open-may-only-be-called-in-response-to-a-user-gesture-re
settings.listLocation.getValue().then(location => listLocation = location);
settings.listLocation.watch(newLocation => listLocation = newLocation);
browser.runtime.onInstalled.addListener(async ({ reason, previousVersion }) =>
{
logger("onInstalled", reason, previousVersion);
track("extension_installed", { reason, previousVersion: previousVersion ?? "none" });
const previousMajor: number = previousVersion ? parseInt(previousVersion.split(".")[0]) : 0;
if (reason === "update" && previousMajor < 3)
{
await migrateStorage();
await showWelcomeDialog.setValue(true);
browser.runtime.reload();
}
});
browser.tabs.onUpdated.addListener((_, __, tab) =>
{
if (!tab.url)
return;
graphicsCache[tab.url] = {
preview: graphicsCache[tab.url]?.preview,
capture: graphicsCache[tab.url]?.capture,
icon: tab.favIconUrl ?? graphicsCache[tab.url]?.icon
};
});
browser.commands.onCommand.addListener(
(command, tab) => performContextAction(command, tab!.windowId!)
);
onMessage("getGraphicsCache", () => graphicsCache);
onMessage("addThumbnail", ({ data }) =>
{
graphicsCache[data.url] = {
preview: data.thumbnail,
capture: graphicsCache[data.url]?.capture,
icon: graphicsCache[data.url]?.icon
};
});
onMessage("refreshCollections", () => { });
if (import.meta.env.FIREFOX)
{
onMessage("openCollection", ({ data }) => openCollection(data.collection, data.targetWindow));
onMessage("openGroup", ({ data }) => openGroup(data.group, data.newWindow));
}
setupTabCaputre();
async function setupTabCaputre(): Promise<void>
{
const tryCaptureTab = async (tab: Tabs.Tab): Promise<void> =>
{
if (!tab.url || tab.status !== "complete" || !tab.active)
return;
if (graphicsCache[tab.url]?.capture || graphicsCache[tab.url]?.capture === null)
return;
try
{
// We use chrome here because polyfill throws uncatchable errors for some reason
// It's a compatible API anyway
const capture: string = await chrome.tabs.captureVisibleTab(tab.windowId!, { format: "jpeg", quality: 1 });
if (capture)
{
graphicsCache[tab.url] = {
capture,
preview: graphicsCache[tab.url]?.preview,
icon: graphicsCache[tab.url]?.icon
};
}
}
catch
{
graphicsCache[tab.url] = {
capture: null!,
preview: graphicsCache[tab.url]?.preview,
icon: graphicsCache[tab.url]?.icon
};
}
};
setInterval(() =>
{
browser.tabs.query({ active: true })
.then(tabs => tabs.forEach(tab => tryCaptureTab(tab)));
}, 1000);
}
setupContextMenu();
async function setupContextMenu(): Promise<void>
{
await browser.contextMenus.removeAll();
const items: Record<string, string> =
{
"show_collections": i18n.t("actions.show_collections"),
"set_aside": i18n.t("actions.set_aside.all"),
"save": i18n.t("actions.save.all")
};
Object.entries(items).forEach(([id, title]) => browser.contextMenus.create({
id, title,
visible: true,
contexts: ["action"]
}));
watchTabSelection(async selection =>
{
await browser.contextMenus.update("set_aside", {
title: i18n.t(`actions.set_aside.${selection}`)
});
await browser.contextMenus.update("save", {
title: i18n.t(`actions.save.${selection}`)
});
});
browser.contextMenus.onClicked.addListener(
({ menuItemId }, tab) => performContextAction((menuItemId as string), tab!.windowId!)
);
}
setupBadge();
async function setupBadge(): Promise<void>
{
let unwatchBadge: Unwatch | null = null;
const updateBadge = async (count: number | null) =>
await browser.action.setBadgeText({ text: count && count > 0 ? count.toString() : "" });
if (await settings.showBadge.getValue())
{
updateBadge(await collectionCount.getValue());
unwatchBadge = collectionCount.watch(updateBadge);
}
if (import.meta.env.FIREFOX)
{
await browser.action.setBadgeBackgroundColor({ color: "#0f6cbd" });
await browser.action.setBadgeTextColor({ color: "white" });
}
settings.showBadge.watch(async showBadge =>
{
if (showBadge)
{
updateBadge(await collectionCount.getValue());
unwatchBadge = collectionCount.watch(updateBadge);
}
else
{
unwatchBadge?.();
await browser.action.setBadgeText({ text: "" });
}
});
}
setupActionButton();
async function setupActionButton(): Promise<void>
{
let unwatchActionTitle: Unwatch | null = null;
const onClickAction = async (): Promise<void> =>
{
logger("action.onClicked");
const defaultAction = await settings.defaultSaveAction.getValue();
await saveTabs(defaultAction === "set_aside");
};
const updateTitle = async (selection: "all" | "selected"): Promise<void> =>
{
const defaultAction = await settings.defaultSaveAction.getValue();
await browser.action.setTitle({ title: i18n.t(`actions.${defaultAction}.${selection}`) });
};
const toggleSidebarFirefox = async (): Promise<void> =>
await browser.sidebarAction.toggle();
const updateButton = async (action: SettingsValue<"contextAction">): Promise<void> =>
{
logger("updateButton", action);
// Cleanup any existing behavior
browser.action.onClicked.removeListener(onClickAction);
browser.action.onClicked.removeListener(toggleSidebarFirefox);
browser.action.onClicked.removeListener(openCollectionsInTab);
await browser.action.disable();
await browser.action.setTitle({ title: i18n.t("manifest.name") });
unwatchActionTitle?.();
if (!import.meta.env.FIREFOX)
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
// Setup new behavior
if (action === "action")
{
browser.action.onClicked.addListener(onClickAction);
unwatchActionTitle = watchTabSelection(updateTitle);
await browser.action.enable();
}
else if (action === "open")
{
await browser.action.enable();
const location = await settings.listLocation.getValue();
if (location === "sidebar")
{
if (import.meta.env.FIREFOX)
browser.action.onClicked.addListener(toggleSidebarFirefox);
else
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
}
else if (location !== "popup")
browser.action.onClicked.addListener(openCollectionsInTab);
}
};
updateButton(await settings.contextAction.getValue());
settings.contextAction.watch(updateButton);
settings.listLocation.watch(async () => updateButton(await settings.contextAction.getValue()));
}
setupCollectionView();
async function setupCollectionView(): Promise<void>
{
const enforcePinnedTab = async (): Promise<void> =>
{
logger("enforcePinnedTab");
const openWindows: Windows.Window[] = await browser.windows.getAll({ populate: true });
for (const openWindow of openWindows)
{
if (openWindow.incognito || openWindow.type !== "normal")
continue;
const activeTabs: Tabs.Tab[] = openWindow.tabs!.filter(tab =>
tab.url === browser.runtime.getURL("/sidepanel.html"));
const targetTab: Tabs.Tab | undefined = activeTabs.find(tab => tab.pinned);
if (!targetTab)
await browser.tabs.create({
url: browser.runtime.getURL("/sidepanel.html"),
windowId: openWindow.id,
active: false,
pinned: true
});
const tabsToClose: Tabs.Tab[] = activeTabs.filter(tab => tab.id !== targetTab?.id);
if (tabsToClose.length > 0)
await browser.tabs.remove(tabsToClose.map(tab => tab.id!));
}
};
const updateView = async (viewLocation: SettingsValue<"listLocation">): Promise<void> =>
{
logger("updateView", viewLocation);
browser.tabs.onHighlighted.removeListener(enforcePinnedTab);
const tabs: Tabs.Tab[] = await browser.tabs.query({
url: browser.runtime.getURL("/sidepanel.html")
});
await browser.tabs.remove(tabs.map(tab => tab.id!));
await browser.action.setPopup({
popup: viewLocation === "popup" ? browser.runtime.getURL("/popup.html") : ""
});
if (import.meta.env.FIREFOX)
await browser.sidebarAction.setPanel({
panel: viewLocation === "sidebar" ? browser.runtime.getURL("/sidepanel.html") : ""
});
else
await chrome.sidePanel.setOptions({ enabled: viewLocation === "sidebar" });
if (viewLocation === "pinned")
{
enforcePinnedTab();
browser.tabs.onHighlighted.addListener(enforcePinnedTab);
}
};
updateView(await settings.listLocation.getValue());
settings.listLocation.watch(updateView);
}
function performContextAction(action: string, windowId: number): void
{
if (action === "show_collections")
{
if (listLocation === "sidebar" || listLocation === "popup")
openCollectionsInView(listLocation, windowId);
else
openCollectionsInTab();
}
else
saveTabs(action === "set_aside");
}
function openCollectionsInView(view: "sidebar" | "popup", windowId: number): void
{
if (view === "sidebar")
{
if (import.meta.env.FIREFOX)
browser.sidebarAction.open();
else
chrome.sidePanel.open({ windowId });
}
else
browser.action.openPopup();
}
async function openCollectionsInTab(): Promise<void>
{
logger("openCollectionsInTab");
const currentWindow: Windows.Window = await browser.windows.getCurrent({ populate: true });
if (currentWindow.incognito)
{
let availableWindows: Windows.Window[] = await browser.windows.getAll({ populate: true });
availableWindows = availableWindows.filter(window =>
!window.incognito &&
window.tabs?.some(i => i.url === browser.runtime.getURL("/sidepanel.html"))
);
if (availableWindows.length > 0)
{
const availableTab: Tabs.Tab = availableWindows[0].tabs!.find(
tab => tab.url === browser.runtime.getURL("/sidepanel.html")
)!;
await browser.tabs.update(availableTab.id, { active: true });
await browser.windows.update(availableWindows[0].id!, { focused: true });
return;
}
await browser.windows.create({
url: browser.runtime.getURL("/sidepanel.html"),
focused: true
});
}
else
{
const collectionTab: Tabs.Tab | undefined = currentWindow.tabs!.find(
tab => tab.url === browser.runtime.getURL("/sidepanel.html")
);
if (collectionTab)
await browser.tabs.update(collectionTab.id, { active: true });
else
await browser.tabs.create({
url: browser.runtime.getURL("/sidepanel.html"),
active: true,
windowId: currentWindow.id
});
}
}
async function saveTabs(closeAfterSave: boolean): Promise<void>
{
logger("saveTabs", closeAfterSave);
const collection: CollectionItem = await saveTabsToCollection(closeAfterSave);
const [savedCollections, cloudIssue] = await getCollections();
const newList = [collection, ...savedCollections];
await saveCollections(newList, cloudIssue === null, graphicsCache);
sendMessage("refreshCollections", undefined);
if (await settings.notifyOnSave.getValue())
await sendNotification({
title: i18n.t("notifications.tabs_saved.title"),
message: i18n.t("notifications.tabs_saved.message"),
icon: "/notification_icons/cloud_checkmark.png"
});
}
}
catch (ex)
{
console.error(ex);
trackError("background_error", ex as Error);
}
});
+46
View File
@@ -0,0 +1,46 @@
import getLogger from "@/utils/getLogger";
import { sendMessage } from "@/utils/messaging";
// This content script is injected into each browser tab.
// It's purpose is to retrive an OpenGraph thumbnail URL from the metadata
export default defineContentScript({
matches: ["<all_urls>"],
runAt: "document_idle",
main
});
const logger = getLogger("contentScript");
async function main(): Promise<void>
{
logger("init");
// This method tries to sequentially retrieve thumbnails from all know meta tags.
// It stops on the first thumbnail found.
// The order of search is:
// 1. <meta property="og:image" content="https://example.com/image.jpg">
// 2. <meta name="twitter:image" content="https://example.com/image.jpg">
// 3. <link rel="thumbnail" href="https://example.com/thumbnail.jpg">
// 4. <link rel="image_src" href="https://example.com/image.jpg">
const thumbnailUrl: string | undefined =
document.querySelector<HTMLMetaElement>("head meta[property='og:image']")?.content ??
document.querySelector<HTMLMetaElement>("head meta[name='twitter:image']")?.content ??
document.querySelector<HTMLLinkElement>("head link[rel=thumbnail]")?.href ??
document.querySelector<HTMLLinkElement>("head link[rel=image_src]")?.href;
if (thumbnailUrl)
{
logger(`Found thumbnail for "${document.location.href}"`, thumbnailUrl);
await sendMessage("addThumbnail", {
url: document.location.href,
thumbnail: thumbnailUrl
});
}
else
logger(`No thumbnail found for "${document.location.href}"`);
logger("done");
}
@@ -0,0 +1,38 @@
import { makeStyles, tokens } from "@fluentui/react-components";
export const useOptionsStyles = makeStyles({
main:
{
display: "grid",
gridTemplateRows: "auto 1fr",
height: "100%"
},
tabList:
{
flexWrap: "wrap"
},
article:
{
display: "flex",
flexFlow: "column",
gap: tokens.spacingVerticalMNudge,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
overflowY: "auto"
},
section:
{
display: "flex",
flexFlow: "column",
alignItems: "flex-start"
},
buttonFix:
{
minHeight: "32px"
},
horizontalButtons:
{
display: "flex",
flexWrap: "wrap",
gap: tokens.spacingHorizontalS
}
});
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tabs aside | Settings</title>
<meta name="manifest.open_in_tab" content="false" />
<style type="text/css">
body
{
height: 500px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
@@ -0,0 +1,58 @@
import { BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
import { bskyLink, buyMeACoffeeLink, githubLinks, storeLink, websiteLink } from "@/data/links";
import { useBmcStyles } from "@/hooks/useBmcStyles";
import extLink from "@/utils/extLink";
import { Body1, Button, Caption1, Link, Subtitle1, Text } from "@fluentui/react-components";
import { PersonFeedback20Regular } from "@fluentui/react-icons";
import { useOptionsStyles } from "../hooks/useOptionsStyles";
import Package from "@/package.json";
export default function AboutSection(): React.ReactElement
{
const cls = useOptionsStyles();
const bmcCls = useBmcStyles();
return (
<>
<Text as="p">
<Subtitle1>{ i18n.t("manifest.name") }</Subtitle1>
<sup><Caption1> v{ Package.version }</Caption1></sup>
</Text>
<Body1 as="p">
{ i18n.t("options_page.about.developed_by") } (<Link { ...extLink(bskyLink) }>@xfox111.net</Link>)<br />
{ i18n.t("options_page.about.licensed_under") } <Link { ...extLink(githubLinks.license) }>{ i18n.t("options_page.about.mit_license") }</Link>
</Body1>
<Body1 as="p">
{ i18n.t("options_page.about.translation_cta.text") }<br />
<Link { ...extLink(githubLinks.translationGuide) }>
{ i18n.t("options_page.about.translation_cta.button") }
</Link>
</Body1>
<Body1 as="p">
<Link { ...extLink(websiteLink) }>{ i18n.t("options_page.about.links.website") }</Link><br />
<Link { ...extLink(githubLinks.repo) }>{ i18n.t("options_page.about.links.source") }</Link><br />
<Link { ...extLink(githubLinks.release) }>{ i18n.t("options_page.about.links.changelog") }</Link>
</Body1>
<div className={ cls.horizontalButtons }>
<Button
as="a" { ...extLink(storeLink) }
appearance="primary"
icon={ <PersonFeedback20Regular /> }
>
{ i18n.t("common.cta.feedback") }
</Button>
<Button
as="a" { ...extLink(buyMeACoffeeLink) }
appearance="primary" className={ bmcCls.button }
icon={ <BuyMeACoffee20Regular /> }
>
{ i18n.t("common.cta.sponsor") }
</Button>
</div>
</>
);
}
@@ -0,0 +1,55 @@
import useSettings, { SettingsValue } from "@/hooks/useSettings";
import { Dropdown, Field, Option } from "@fluentui/react-components";
export default function ActionsSection(): React.ReactElement
{
const [saveAction, setSaveAction] = useSettings("defaultSaveAction");
const [restoreAction, setRestoreAction] = useSettings("defaultRestoreAction");
return (
<>
<Field label={ i18n.t("options_page.actions.options.save_actions.title") }>
<Dropdown
value={ saveAction ? saveActionOptions[saveAction] : "" }
selectedOptions={ [saveAction ?? ""] }
onOptionSelect={ (_, e) => setSaveAction(e.optionValue as SaveActionType) }
>
{ Object.entries(saveActionOptions).map(([value, label]) =>
<Option key={ value } value={ value }>
{ label }
</Option>
) }
</Dropdown>
</Field>
<Field label={ i18n.t("options_page.actions.options.restore_actions.title") }>
<Dropdown
value={ restoreAction ? restoreActionOptions[restoreAction] : "" }
selectedOptions={ [restoreAction ?? ""] }
onOptionSelect={ (_, e) => setRestoreAction(e.optionValue as RestoreActionType) }
>
{ Object.entries(restoreActionOptions).map(([value, label]) =>
<Option key={ value } value={ value }>
{ label }
</Option>
) }
</Dropdown>
</Field>
</>
);
}
type SaveActionType = SettingsValue<"defaultSaveAction">;
type RestoreActionType = SettingsValue<"defaultRestoreAction">;
const restoreActionOptions: Record<RestoreActionType, string> =
{
"open": i18n.t("options_page.actions.options.restore_actions.options.open"),
"restore": i18n.t("options_page.actions.options.restore_actions.options.restore")
};
const saveActionOptions: Record<SaveActionType, string> =
{
"set_aside": i18n.t("options_page.actions.options.save_actions.options.set_aside"),
"save": i18n.t("options_page.actions.options.save_actions.options.save")
};
@@ -0,0 +1,121 @@
import useSettings, { SettingsValue } from "@/hooks/useSettings";
import { Button, Checkbox, Dropdown, Field, Option, OptionOnSelectData } from "@fluentui/react-components";
import { KeyCommand20Regular } from "@fluentui/react-icons";
import { useOptionsStyles } from "../hooks/useOptionsStyles";
export default function GeneralSection(): React.ReactElement
{
const [alwaysShowToolbars, setAlwaysShowToolbars] = useSettings("alwaysShowToolbars");
const [ignorePinned, setIgnorePinned] = useSettings("ignorePinned");
const [deletePrompt, setDeletePrompt] = useSettings("deletePrompt");
const [showBadge, setShowBadge] = useSettings("showBadge");
const [notifyOnSave, setNotifyOnSave] = useSettings("notifyOnSave");
const [dismissOnLoad, setDismissOnLoad] = useSettings("dismissOnLoad");
const [listLocation, setListLocation] = useSettings("listLocation");
const [contextAction, setContextAction] = useSettings("contextAction");
const cls = useOptionsStyles();
const openShortcutsPage = (): Promise<any> =>
browser.tabs.create({
url: "chrome://extensions/shortcuts",
active: true
});
const handleListLocationChange = (_: any, e: OptionOnSelectData): void =>
{
if (e.optionValue === "popup" && contextAction !== "open")
setContextAction("open");
if (import.meta.env.FIREFOX && e.optionValue !== "sidebar")
browser.sidebarAction.close();
setListLocation(e.optionValue as ListLocationType);
};
return (
<>
<section className={ cls.section }>
<Checkbox
label={ i18n.t("options_page.general.options.always_show_toolbars") }
checked={ alwaysShowToolbars ?? false }
onChange={ (_, e) => setAlwaysShowToolbars(e.checked as boolean) } />
<Checkbox
label={ i18n.t("options_page.general.options.include_pinned") }
checked={ !ignorePinned }
onChange={ (_, e) => setIgnorePinned(!e.checked) } />
<Checkbox
label={ i18n.t("options_page.general.options.show_delete_prompt") }
checked={ deletePrompt ?? false }
onChange={ (_, e) => setDeletePrompt(e.checked as boolean) } />
<Checkbox
label={ i18n.t("options_page.general.options.show_badge") }
checked={ showBadge ?? false }
onChange={ (_, e) => setShowBadge(e.checked as boolean) } />
<Checkbox
label={ i18n.t("options_page.general.options.show_notification") }
checked={ notifyOnSave ?? false }
onChange={ (_, e) => setNotifyOnSave(e.checked as boolean) } />
<Checkbox
label={ i18n.t("options_page.general.options.unload_tabs") }
checked={ dismissOnLoad ?? false }
onChange={ (_, e) => setDismissOnLoad(e.checked as boolean) } />
</section>
<Field label={ i18n.t("options_page.general.options.list_locations.title") }>
<Dropdown
value={ listLocation ? listLocationOptions[listLocation] : "" }
selectedOptions={ [listLocation ?? ""] }
onOptionSelect={ handleListLocationChange }
>
{ Object.entries(listLocationOptions).map(([key, value]) =>
<Option key={ key } value={ key }>
{ value }
</Option>
) }
</Dropdown>
</Field>
<Field label={ i18n.t("options_page.general.options.icon_action.title") }>
<Dropdown
value={ contextAction ? contextActionOptions[contextAction] : "" }
selectedOptions={ [contextAction ?? ""] }
onOptionSelect={ (_, e) => setContextAction(e.optionValue as ContextActionType) }
disabled={ listLocation === "popup" }
>
{ Object.entries(contextActionOptions).map(([key, value]) =>
key === "context" && import.meta.env.FIREFOX
? <></> :
<Option key={ key } value={ key }>
{ value }
</Option>
) }
</Dropdown>
</Field>
{ !import.meta.env.FIREFOX &&
<Button icon={ <KeyCommand20Regular /> } onClick={ openShortcutsPage } className={ cls.buttonFix }>
{ i18n.t("options_page.general.options.change_shortcuts") }
</Button>
}
</>
);
}
type ListLocationType = SettingsValue<"listLocation">;
type ContextActionType = SettingsValue<"contextAction">;
const listLocationOptions: Record<ListLocationType, string> =
{
"sidebar": i18n.t("options_page.general.options.list_locations.options.sidebar"),
"popup": i18n.t("options_page.general.options.list_locations.options.popup"),
"tab": i18n.t("options_page.general.options.list_locations.options.tab"),
"pinned": i18n.t("options_page.general.options.list_locations.options.pinned")
};
const contextActionOptions: Record<ContextActionType, string> =
{
"action": i18n.t("options_page.general.options.icon_action.options.action"),
"context": i18n.t("options_page.general.options.icon_action.options.context"),
"open": i18n.t("options_page.general.options.icon_action.options.open")
};
@@ -0,0 +1,102 @@
import { useDialog } from "@/contexts/DialogProvider";
import { cloudDisabled, setCloudStorage } from "@/features/collectionStorage";
import { useDangerStyles } from "@/hooks/useDangerStyles";
import useStorageInfo from "@/hooks/useStorageInfo";
import { Button, Field, MessageBar, MessageBarBody, MessageBarTitle, ProgressBar } from "@fluentui/react-components";
import { ArrowDownload20Regular, ArrowUpload20Regular } from "@fluentui/react-icons";
import { useOptionsStyles } from "../hooks/useOptionsStyles";
import exportData from "../utils/exportData";
import importData from "../utils/importData";
export default function StorageSection(): React.ReactElement
{
const { bytesInUse, storageQuota, usedStorageRatio } = useStorageInfo();
const [importResult, setImportResult] = useState<boolean | null>(null);
const [isCloudDisabled, setCloudDisabled] = useState<boolean>(null!);
const dialog = useDialog();
const cls = useOptionsStyles();
const dangerCls = useDangerStyles();
useEffect(() =>
{
cloudDisabled.getValue().then(setCloudDisabled);
return cloudDisabled.watch(setCloudDisabled);
}, []);
const handleImport = (): void =>
dialog.pushPrompt({
title: i18n.t("options_page.storage.import_prompt.title"),
confirmText: i18n.t("options_page.storage.import_prompt.proceed"),
onConfirm: () => importData().then(setImportResult),
content: (
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>{ i18n.t("options_page.storage.import_prompt.warning_title") }</MessageBarTitle>
{ i18n.t("options_page.storage.import_prompt.warning_text") }
</MessageBarBody>
</MessageBar>
)
});
const handleDisableCloud = (): void =>
dialog.pushPrompt({
title: i18n.t("options_page.storage.disable"),
content: i18n.t("options_page.storage.disable_prompt.text"),
confirmText: i18n.t("options_page.storage.disable_prompt.action"),
destructive: true,
onConfirm: () => setCloudStorage(false)
});
return (
<>
{ isCloudDisabled === false &&
<Field
label={ i18n.t("options_page.storage.capacity.title") }
hint={ i18n.t("options_page.storage.capacity.description", [(bytesInUse / 1024).toFixed(1), storageQuota / 1024]) }
validationState={ usedStorageRatio >= 0.8 ? "error" : undefined }
>
<ProgressBar value={ usedStorageRatio } thickness="large" />
</Field>
}
{ isCloudDisabled === true &&
<Button appearance="primary" onClick={ () => setCloudStorage(true) }>
{ i18n.t("options_page.storage.enable") }
</Button>
}
<div className={ cls.horizontalButtons }>
<Button icon={ <ArrowDownload20Regular /> } onClick={ exportData }>
{ i18n.t("options_page.storage.export") }
</Button>
<Button icon={ <ArrowUpload20Regular /> } onClick={ handleImport }>
{ i18n.t("options_page.storage.import") }
</Button>
</div>
{ importResult !== null &&
<MessageBar intent={ importResult ? "success" : "error" }>
<MessageBarBody>
{ importResult === true ?
i18n.t("options_page.storage.import_results.success") :
i18n.t("options_page.storage.import_results.error")
}
</MessageBarBody>
</MessageBar>
}
{ isCloudDisabled === false &&
<div className={ cls.horizontalButtons }>
<Button
appearance="subtle" className={ dangerCls.buttonSubtle }
onClick={ handleDisableCloud }
>
{ i18n.t("options_page.storage.disable") }
</Button>
</div>
}
</>
);
}
+47
View File
@@ -0,0 +1,47 @@
import App from "@/App.tsx";
import "@/assets/global.css";
import { Tab, TabList } from "@fluentui/react-components";
import ReactDOM from "react-dom/client";
import { useOptionsStyles } from "./hooks/useOptionsStyles.ts";
import AboutSection from "./layouts/AboutSection.tsx";
import ActionsSection from "./layouts/ActionsSection.tsx";
import GeneralSection from "./layouts/GeneralSection.tsx";
import StorageSection from "./layouts/StorageSection.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<App>
<OptionsPage />
</App>
);
analytics.page("options_page");
function OptionsPage(): React.ReactElement
{
const [selection, setSelection] = useState<SelectionType>("general");
const cls = useOptionsStyles();
return (
<main className={ cls.main }>
<TabList
className={ cls.tabList }
selectedValue={ selection }
onTabSelect={ (_, data) => setSelection(data.value as SelectionType) }
>
<Tab value="general">{ i18n.t("options_page.general.title") }</Tab>
<Tab value="actions">{ i18n.t("options_page.actions.title") }</Tab>
<Tab value="storage">{ i18n.t("options_page.storage.title") }</Tab>
<Tab value="about">{ i18n.t("options_page.about.title") }</Tab>
</TabList>
<article className={ cls.article }>
{ selection === "general" && <GeneralSection /> }
{ selection === "actions" && <ActionsSection /> }
{ selection === "storage" && <StorageSection /> }
{ selection === "about" && <AboutSection /> }
</article>
</main>
);
}
type SelectionType = "general" | "actions" | "storage" | "about";
+19
View File
@@ -0,0 +1,19 @@
export default async function exportData(): Promise<void>
{
const data: string = JSON.stringify({
local: await browser.storage.local.get(null),
sync: await browser.storage.sync.get(null)
});
const blob: Blob = new Blob([data], { type: "application/json" });
const element: HTMLAnchorElement = document.createElement("a");
element.style.display = "none";
element.href = URL.createObjectURL(blob);
element.setAttribute("download", "tabs-aside_data.json");
document.body.appendChild(element);
element.click();
URL.revokeObjectURL(element.href);
document.body.removeChild(element);
};
+56
View File
@@ -0,0 +1,56 @@
import { sendMessage } from "@/utils/messaging";
export default async function importData(): Promise<boolean | null>
{
const element: HTMLInputElement = document.createElement("input");
element.style.display = "none";
element.hidden = true;
element.type = "file";
element.accept = ".json";
document.body.appendChild(element);
element.click();
await new Promise(resolve =>
{
const listener = () =>
{
element.removeEventListener("input", listener);
resolve(null);
};
element.addEventListener("input", listener);
});
if (!element.files || element.files.length < 1)
return null;
const file: File = element.files[0];
const content: string = await file.text();
document.body.removeChild(element);
try
{
const data: any = JSON.parse(content);
if (data.local)
await browser.storage.local.set(data.local);
if (data.sync)
{
if (import.meta.env.FIREFOX && data.sync.contextAction === "context")
data.sync.contextAction = "open";
await browser.storage.sync.set(data.sync);
}
}
catch (error)
{
console.error("Failed to parse JSON", error);
return false;
}
sendMessage("refreshCollections", undefined);
return true;
}
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tabs aside</title>
<style type="text/css">
html,
body {
height: 600px;
width: 400px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./sidepanel/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,102 @@
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:
{
maxHeight: "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,
gridAutoRows: import.meta.env.FIREFOX ? "min-content" : undefined
},
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,88 @@
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, 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 } />
{ (!activeItem || activeItem.item.type !== "collection") && !dragOverlay &&
<>
{ 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,157 @@
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
import { track } from "@/features/analytics";
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 horizontalNavigationAttributes = fui.useArrowNavigationGroup({ axis: "horizontal" });
const onSubmit = (e: React.FormEvent<HTMLFormElement>) =>
{
e.preventDefault();
handleSave();
};
const handleSave = () =>
{
if (props.type === "collection" ? props.collection !== null : props.group !== null)
track("item_edited", { type: props.type });
else
track("item_created", { type: props.type });
if (props.type === "collection")
props.onSave({
type: "collection",
timestamp: props.collection?.timestamp ?? Date.now(),
color: (color === "pinned") ? undefined : color!,
title: title ? title : undefined,
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: title ? title : undefined,
items: props.group?.items ?? []
});
};
return (
<fui.DialogSurface className={ fui.mergeClasses(cls.surface, (color && color !== "pinned") && colorCls[color]) }>
<form onSubmit={ onSubmit }>
<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>
<div 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, true) : ""
}
value={ color === "pinned" ? i18n.t("groups.pinned") : title }
onChange={ (_, e) => setTitle(e.value) } />
</fui.Field>
<fui.Field label="Color">
<div className={ cls.colorPicker } { ...horizontalNavigationAttributes }>
{ (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>
</div>
</fui.DialogContent>
<fui.DialogActions>
<fui.DialogTrigger disableButtonEnhancement>
<fui.Button appearance="primary" as="button" type="submit">{ 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>
</form>
</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,154 @@
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"
},
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%",
position: "relative"
},
verticalList:
{
flexFlow: "column"
},
verticalListCollapsed:
{
maxHeight: "136px",
overflow: "clip"
},
horizontalListCollapsed:
{
maxWidth: "400px",
overflow: "clip"
},
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,120 @@
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,
((active?.item.type === "group" && active?.indices[0] === indices[0]) || dragOverlay) && (tilesView ? cls.horizontalListCollapsed : cls.verticalListCollapsed)
) }
>
<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,111 @@
import { makeStyles, tokens } from "@fluentui/react-components";
export const useStyles_TabView = makeStyles({
root:
{
display: "grid",
position: "relative",
width: "160px",
height: "120px",
flexShrink: 0,
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",
borderBottomLeftRadius: tokens.borderRadiusMedium,
borderBottomRightRadius: tokens.borderRadiusMedium,
backgroundColor: tokens.colorSubtleBackgroundLightAlphaHover,
color: tokens.colorNeutralForeground1,
"-webkit-backdrop-filer": "blur(4px)",
backdropFilter: "blur(4px)"
},
icon:
{
cursor: "grab",
padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalSNudge}`,
height: "32px",
boxSizing: "border-box",
"&:active":
{
cursor: "grabbing"
}
},
title:
{
overflowX: "hidden",
justifySelf: "start",
maxWidth: "100%"
},
deleteButton:
{
display: "none"
},
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,110 @@
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";
import CollectionContext, { CollectionContextType } from "../contexts/CollectionContext";
export default function TabView({ tab, indices, dragOverlay }: TabViewProps): ReactElement
{
const { removeItem, graphics, tilesView } = useCollections();
const { collection } = useContext<CollectionContextType>(CollectionContext);
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();
const removeIndex: number[] = [collection.timestamp, ...indices.slice(1)];
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(...removeIndex)
});
else
removeItem(...removeIndex);
};
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 ?? graphics[tab.url]?.capture ?? 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 }
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,110 @@
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
import getSelectedTabs from "@/entrypoints/sidepanel/utils/getSelectedTabs";
import useSettings from "@/hooks/useSettings";
import { GroupItem, 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";
import saveTabsToCollection from "@/utils/saveTabsToCollection";
export default function CollectionHeader({ dragHandleRef, dragHandleProps }: CollectionHeaderProps): React.ReactElement
{
const [contextOpen, setContextOpen] = useState<boolean>(false);
const [listLocation] = useSettings("listLocation");
const isTab: boolean = listLocation === "tab" || listLocation === "pinned";
const { updateCollection } = useCollections();
const { tabCount, collection } = useContext<CollectionContextType>(CollectionContext);
const [alwaysShowToolbars] = useSettings("alwaysShowToolbars");
const AddIcon = bundleIcon(Add20Filled, Add20Regular);
const handleAddSelected = async () =>
{
const newTabs: (TabItem | GroupItem)[] = isTab ?
(await saveTabsToCollection(false)).items :
await getSelectedTabs();
updateCollection({ ...collection, items: [...collection.items, ...newTabs] }, collection.timestamp);
};
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 || contextOpen) && cls.showToolbar
) }
>
{ tabCount < 1 ?
<Button icon={ <AddIcon /> } appearance="subtle" onClick={ handleAddSelected }>
{ isTab ? i18n.t("collections.menu.add_all") : i18n.t("collections.menu.add_selected") }
</Button>
:
<OpenCollectionButton onOpenChange={ (_, e) => setContextOpen(e.open) } />
}
<CollectionMoreButton onAddSelected={ handleAddSelected } onOpenChange={ (_, e) => setContextOpen(e.open) } />
</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"
},
showToolbar:
{
display: "flex"
}
});
@@ -0,0 +1,98 @@
import { useDialog } from "@/contexts/DialogProvider";
import { useDangerStyles } from "@/hooks/useDangerStyles";
import useSettings from "@/hooks/useSettings";
import { Button, Menu, MenuDivider, MenuItem, MenuList, MenuOpenChangeData, MenuOpenEvent, 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, onOpenChange }: CollectionMoreButtonProps): React.ReactElement
{
const [listLocation] = useSettings("listLocation");
const isTab: boolean = listLocation === "tab" || listLocation === "pinned";
const { removeItem, updateCollection } = useCollections();
const { tabCount, hasPinnedGroup, collection } = 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 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(collection.timestamp)
});
else
removeItem(collection.timestamp);
};
const handleEdit = () =>
dialog.pushCustom(
<EditDialog
type="collection"
collection={ collection }
onSave={ item => updateCollection(item, collection.timestamp) } />
);
const handleCreateGroup = () =>
dialog.pushCustom(
<EditDialog
type="group"
hidePinned={ hasPinnedGroup }
onSave={ group => updateCollection({ ...collection, items: [...collection.items, group] }, collection.timestamp) } />
);
return (
<Menu onOpenChange={ onOpenChange }>
<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?.() }>
{ isTab ? i18n.t("collections.menu.add_all") : i18n.t("collections.menu.add_selected") }
</MenuItem>
}
<MenuItem icon={ <GroupIcon /> } onClick={ handleCreateGroup }>
{ i18n.t("collections.menu.add_group") }
</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;
onOpenChange?: (e: MenuOpenEvent, data: MenuOpenChangeData) => 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,115 @@
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 { sendMessage } from "@/utils/messaging";
import saveTabsToCollection from "@/utils/saveTabsToCollection";
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 [listLocation] = useSettings("listLocation");
const isTab: boolean = listLocation === "tab" || listLocation === "pinned";
const { group, indices } = useContext<GroupContextType>(GroupContext);
const { hasPinnedGroup, collection } = 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 = () =>
{
const removeIndex: number[] = [collection.timestamp, ...indices.slice(1)];
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(...removeIndex)
});
else
removeItem(...removeIndex);
};
const handleEdit = () =>
dialog.pushCustom(
<EditDialog
type="group"
group={ group }
hidePinned={ hasPinnedGroup }
onSave={ item => updateGroup(item, collection.timestamp, indices[1]) } />
);
const openGroupInNewWindow = () =>
{
if (import.meta.env.FIREFOX && listLocation === "popup")
sendMessage("openGroup", { group, newWindow: true });
else
openGroup(group, true);
};
const handleAddSelected = async () =>
{
const newTabs: TabItem[] = isTab ?
(await saveTabsToCollection(false)).items.flatMap(i => i.type === "tab" ? i : i.items) :
await getSelectedTabs();
updateGroup({ ...group, items: [...group.items, ...newTabs] }, collection.timestamp, 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={ openGroupInNewWindow }>
{ i18n.t("groups.menu.new_window") }
</MenuItem>
}
<MenuItem icon={ <AddIcon /> } onClick={ handleAddSelected }>
{ isTab ? i18n.t("groups.menu.add_all") : i18n.t("groups.menu.add_selected") }
</MenuItem>
<MenuItem icon={ <EditIcon /> } onClick={ handleEdit }>
{ i18n.t("groups.menu.edit") }
</MenuItem>
{ group.items.length > 0 &&
<MenuItem
className={ dangerCls.menuItem }
icon={ <UngroupIcon /> }
onClick={ () => ungroup(collection.timestamp, 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,111 @@
import { useDialog } from "@/contexts/DialogProvider";
import useSettings from "@/hooks/useSettings";
import browserLocaleKey from "@/utils/browserLocaleKey";
import { sendMessage } from "@/utils/messaging";
import { Menu, MenuButtonProps, MenuItem, MenuList, MenuOpenChangeData, MenuOpenEvent, 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({ onOpenChange }: OpenCollectionButtonProps): React.ReactElement
{
const [defaultAction] = useSettings("defaultRestoreAction");
const [listLocation] = useSettings("listLocation");
const { removeItem } = useCollections();
const dialog = useDialog();
const { collection } = 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())
{
if (import.meta.env.FIREFOX && listLocation === "popup")
sendMessage("openCollection", { collection, targetWindow: "incognito" });
else
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") =>
import.meta.env.FIREFOX && listLocation === "popup" && mode === "new" ?
() => sendMessage("openCollection", { collection, targetWindow: "new" }) :
() => openCollection(collection, mode);
const handleRestore = async () =>
{
await openCollection(collection);
removeItem(collection.timestamp);
};
return (
<Menu onOpenChange={ onOpenChange }>
<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>
);
}
export type OpenCollectionButtonProps =
{
onOpenChange?: (e: MenuOpenEvent, data: MenuOpenChangeData) => void;
};
@@ -0,0 +1,13 @@
import { CollectionItem } from "@/models/CollectionModels";
import { createContext } from "react";
const CollectionContext = createContext<CollectionContextType>(null!);
export default CollectionContext;
export type CollectionContextType =
{
collection: CollectionItem;
tabCount: number;
hasPinnedGroup: boolean;
};
@@ -0,0 +1,121 @@
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, sendMessage } 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());
sendMessage("refreshCollections", undefined);
};
const addCollection = (collection: CollectionItem): void =>
{
updateStorage([collection, ...collections]);
};
const removeItem = (...indices: number[]): void =>
{
const collectionIndex: number = collections.findIndex(i => i.timestamp === indices[0]);
if (indices.length > 2)
(collections[collectionIndex].items[indices[1]] as GroupItem).items.splice(indices[2], 1);
else if (indices.length > 1)
collections[collectionIndex].items.splice(indices[1], 1);
else
collections.splice(collectionIndex, 1);
updateStorage(collections);
};
const updateCollections = (collectionList: CollectionItem[]): void =>
{
updateStorage(collectionList);
};
const updateCollection = (collection: CollectionItem, id: number): void =>
{
const index: number = collections.findIndex(i => i.timestamp === id);
collections[index] = collection;
updateStorage(collections);
};
const updateGroup = (group: GroupItem, collectionId: number, groupIndex: number): void =>
{
const collectionIndex: number = collections.findIndex(i => i.timestamp === collectionId);
collections[collectionIndex].items[groupIndex] = group;
updateStorage(collections);
};
const ungroup = (collectionId: number, groupIndex: number): void =>
{
const collectionIndex: number = collections.findIndex(i => i.timestamp === collectionId);
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, id: number) => void;
updateGroup: (group: GroupItem, collectionId: number, groupIndex: number) => void;
ungroup: (collectionId: 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,55 @@
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",
"@media screen and (min-width: 360px)":
{
gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))"
}
}
});
@@ -0,0 +1,152 @@
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 { track } from "@/features/analytics";
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";
import { snapHandleToCursor } from "../../utils/dnd/snapHandleToCursor";
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: 10, tolerance: 20 } }),
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");
track("used_drag_and_drop");
}
};
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 }
modifiers={ [snapHandleToCursor] }
>
<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 !== null ?
active.item.type === "collection" ?
<CollectionView collection={ active.item } index={ -1 } dragOverlay />
:
<CollectionContext.Provider
value={ {
tabCount: 0,
collection: resultList[active.indices[0]],
hasPinnedGroup: true
} }
>
{ active.item.type === "group" ?
<GroupView group={ active.item } indices={ [-1] } dragOverlay />
:
<TabView tab={ active.item } indices={ [-1] } dragOverlay />
}
</CollectionContext.Provider>
:
<></>
}
</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,66 @@
import { BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
import { buyMeACoffeeLink, storeLink } from "@/data/links";
import { track } from "@/features/analytics";
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 === -1)
track("bmc_clicked");
else
track("cta_dismissed");
};
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) } onClick={ () => track("feedback_clicked") }>{ 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,86 @@
import { BuyMeACoffee20Filled, BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
import { buyMeACoffeeLink, githubLinks, storeLink } from "@/data/links";
import { track } from "@/features/analytics";
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) } onClick={ () => track("feedback_clicked") }>
{ i18n.t("common.cta.sponsor") }
</fui.MenuItemLink>
<fui.MenuItemLink icon={ <FeedbackIcon /> } { ...extLink(storeLink) } onClick={ () => track("bmc_clicked") }>
{ 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>
);
}
+45
View File
@@ -0,0 +1,45 @@
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");
analytics.page("collection_list");
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,153 @@
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 we drag a collection, we should ignore other items, like tabs or groups
if (droppableItem.item.type !== "collection")
continue;
// Using distance between centers
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);
// Dragging a tab or a group over a collection
if (droppableItem.item.type === "collection")
{
// Ignoring collection, if the tab or the group is inside that collection
if (activeItem.indices.length === 2 && activeItem.indices[0] === droppableItem.indices[0])
continue;
// Ignoring collection if we're dragging a tab or a group that doesn't belong to the collection,
// but intersection ratio is less than 0.7
if (intersectionCoefficient < 0.7)
continue;
// If we're dragging a tab, that's inside a group that belongs to the collection,
// we substract the group's intersection from the collection's one
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));
}
// Otherwise, use intersection ratio
// At this point we're dragging either:
// - a group, that doesn't belong to the collection
// - a tab, that either belongs to the collection's group, or has intersection coefficient >= .7
else
{
value = 2 / intersectionRatio;
}
}
// If we're dragging a tab or a group over another group's dropzone
else if (droppableItem.item.type === "group" && (id as string).endsWith("_dropzone"))
{
// Ignore, if we're dragging a group
if (activeItem.item.type === "group")
continue;
// Ignore, if we're dragging a tab, that's inside the group
if (
activeItem.indices.length === 3 &&
activeItem.indices[0] === droppableItem.indices[0] &&
activeItem.indices[1] === droppableItem.indices[1]
)
continue;
// Ignore, if coefficient is less than .5
// (at this point we're dragging a tab, that's outside of the group's dropzone)
if (intersectionCoefficient < 0.5)
continue;
// Use intersection between the tab and the group's dropzone
value = 1 / intersectionRatio;
}
// We're dragging a group or a tab over its sibling
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;
// Ignore pinned groups
if (droppableItem.item.type === "group" && droppableItem.item.pinned === true)
continue;
const collectionRect: ClientRect | undefined = droppableRects.get(activeItem.indices[0].toString());
if (!collectionRect)
continue;
const collectionIntersectionRatio: number = getIntersectionRatio(collectionRect, collisionRect);
const collectionIntersectionCoefficient: number = collectionIntersectionRatio / getMaxIntersectionRatio(collectionRect, collisionRect);
// Ignore if we are outside of the home collection
if (collectionIntersectionCoefficient < 0.7)
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,34 @@
import { Modifier } from "@dnd-kit/core";
import { Coordinates, getEventCoordinates } from "@dnd-kit/utilities";
import { DndItem } from "../../hooks/useDndItem";
export const snapHandleToCursor: Modifier = ({
activatorEvent,
draggingNodeRect,
transform,
active
}) =>
{
if (draggingNodeRect && activatorEvent)
{
const activeItem: DndItem | undefined = active?.data.current as DndItem;
const activatorCoordinates: Coordinates | null = getEventCoordinates(activatorEvent);
if (!activatorCoordinates)
return transform;
const initX: number = activatorCoordinates.x - draggingNodeRect.left;
const initY: number = activatorCoordinates.y - draggingNodeRect.top;
const offsetX: number = activeItem?.item.type === "group" ? 24 : draggingNodeRect.height / 2;
const offsetY: number = activeItem?.item.type === "group" ? 20 : draggingNodeRect.height / 2;
return {
...transform,
x: transform.x + initX - offsetX,
y: transform.y + initY - offsetY
};
}
return transform;
};
@@ -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,10 @@
import { CollectionItem } from "@/models/CollectionModels";
export function getCollectionTitle(collection?: CollectionItem, useTimestamp?: boolean): string
{
if (collection?.title !== undefined && useTimestamp !== true)
return collection.title;
return new Date(collection?.timestamp ?? Date.now())
.toLocaleDateString(browser.i18n.getUILanguage(), { year: "numeric", month: "short", day: "numeric" });
}

Some files were not shown because too many files have changed in this diff Show More