├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── .sonarcloud.properties
├── .stylelintrc
├── LICENSE
├── README.md
├── gulp
├── config.js
├── index.js
├── lib
│ └── log.js
└── tasks
│ ├── copy.js
│ ├── css.js
│ └── js.js
├── gulpfile.js
├── media
├── old-icons
│ ├── aside2.pdn
│ ├── aside2.png
│ └── tab-dark-16.svg
├── screenshot1.png
├── screenshot10.png
├── screenshot2.png
├── screenshot3.png
├── screenshot4.png
├── screenshot5.png
├── screenshot6.png
├── screenshot7.png
├── screenshot8.png
└── screenshot9.png
├── package-lock.json
├── package.json
├── src
├── _locales
│ ├── en
│ │ └── messages.json
│ ├── fr
│ │ └── messages.json
│ └── it
│ │ └── messages.json
├── fonts
│ ├── open-sans-condensed-v12-latin-300.woff2
│ └── open-sans-v15-latin-regular.woff2
├── html
│ ├── background.html
│ ├── bookmark-selector.html
│ ├── menu
│ │ ├── main.html
│ │ └── setup.html
│ ├── options.html
│ ├── sidebar.html
│ ├── tab-error.html
│ └── user-setup.html
├── img
│ ├── arrow-left-48.png
│ ├── arrowhead-right-16.svg
│ ├── aside-24.png
│ ├── browserAction
│ │ ├── context.svg
│ │ ├── dark.svg
│ │ └── light.svg
│ ├── browserMenu
│ │ ├── active.svg
│ │ └── add.svg
│ ├── check-16.svg
│ ├── close-16.svg
│ ├── copy.svg
│ ├── folder-16.svg
│ ├── help-16.svg
│ ├── ionicons_svg_md-code.svg
│ ├── menu
│ │ ├── aside.svg
│ │ ├── options-16.svg
│ │ └── tabs.svg
│ ├── new-16.svg
│ ├── report-issues.svg
│ ├── sidebar
│ │ ├── Search.svg
│ │ ├── arrowhead-down-12.svg
│ │ ├── copy-16.svg
│ │ ├── delete-light-16.svg
│ │ ├── icon.svg
│ │ ├── info-light-16.svg
│ │ ├── more-16-thin.svg
│ │ ├── more-16.svg
│ │ ├── open-in-new-16.svg
│ │ ├── pin-12.svg
│ │ ├── readermode.svg
│ │ └── restore-light-16.svg
│ └── warning.svg
├── manifest.json
├── scss
│ ├── base
│ │ └── _fonts.scss
│ ├── bookmark-selector.scss
│ ├── menu-setup.scss
│ ├── menu.scss
│ ├── modal-windows.scss
│ ├── options.scss
│ ├── overlay-menu.scss
│ ├── sidebar.scss
│ ├── sidebar
│ │ ├── _menu-items.scss
│ │ ├── _search.scss
│ │ └── _session.scss
│ ├── tab-error.scss
│ ├── tab-view-simple-list.scss
│ └── user-setup.scss
├── tab-loader
│ ├── load.html
│ └── load.js
└── ts
│ ├── background
│ ├── BrowserTabContextMenu.ts
│ ├── KeyboardCommands.ts
│ ├── Migration.ts
│ ├── WindowFocusHistory.ts
│ └── background.ts
│ ├── bookmark-selector
│ ├── Controller.ts
│ ├── Model.ts
│ └── View.ts
│ ├── browserAction
│ ├── BrowserActionManager.ts
│ ├── Menu.ts
│ ├── MenuItemType.d.ts
│ ├── MenuItems.ts
│ ├── SetupLauncher.ts
│ └── TabSelector.ts
│ ├── core
│ ├── ActiveSession.ts
│ ├── ActiveSessionManager.ts
│ ├── ClassicSessionManager.ts
│ ├── SessionManager.ts
│ ├── SessionTitleGenerator.ts
│ └── TabData.ts
│ ├── extension-pages
│ └── tab-error.ts
│ ├── messages
│ ├── MessageListener.ts
│ └── Messages.ts
│ ├── options
│ ├── Controls
│ │ ├── BookmarkControl.ts
│ │ ├── BooleanControl.ts
│ │ ├── SelectControl.ts
│ │ └── StringControl.ts
│ ├── OptionTypeDefinition.d.ts
│ ├── Options.ts
│ ├── OptionsManager.ts
│ └── OptionsPage.ts
│ ├── sidebar
│ ├── Search.ts
│ ├── SessionOptionsMenu.ts
│ ├── SessionView.ts
│ ├── TabContextMenu.ts
│ ├── TabViewFactory.ts
│ ├── TabViews
│ │ ├── SimpleList.ts
│ │ └── TabView.ts
│ ├── sidebar.ts
│ └── user-setup
│ │ ├── OptionsPresets.ts
│ │ ├── SetupStep.ts
│ │ └── user-setup.ts
│ └── util
│ ├── Clipboard.ts
│ ├── EditText.ts
│ ├── Errors.ts
│ ├── FuncIterator.ts
│ ├── HTMLUtilities.ts
│ ├── ModalWindow.ts
│ ├── OverlayMenu.ts
│ ├── PromiseUtils.ts
│ ├── StringUtils.ts
│ ├── Types.d.ts
│ └── WebExtAPIHelpers.ts
└── tsconfig.json
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 |
16 | - name: Setup Node.js environment
17 | uses: actions/setup-node@v3.3.0
18 | with:
19 | node-version: 14.x
20 |
21 | - name: NPM install
22 | run: npm ci
23 |
24 | - name: Build extension
25 | run: npm run build
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | releases/
4 | gulp/cache/
5 |
--------------------------------------------------------------------------------
/.sonarcloud.properties:
--------------------------------------------------------------------------------
1 | sonar.sources=./src
2 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "ignoreFiles": [
3 | "dist/**"
4 | ],
5 | "extends": [
6 | "stylelint-config-sass-guidelines",
7 | "stylelint-config-rational-order"
8 | ],
9 | "plugins": [
10 | "stylelint-no-unsupported-browser-features"
11 | ],
12 | "rules": {
13 | "number-leading-zero": "never",
14 | "max-nesting-depth": [2, {
15 | "ignore": ["blockless-at-rules"]
16 | }],
17 | "selector-no-qualifying-type": [true, {
18 | "ignore": ["class", "attribute"]
19 | }],
20 | "block-no-empty": null,
21 | "plugin/no-unsupported-browser-features": [true, {
22 | "ignore": [
23 | 'css-gradients',
24 | 'outline'
25 | ]
26 | }],
27 | "order/properties-alphabetical-order": null
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | [](https://sonarcloud.io/dashboard?id=tim-we_tabs-aside)
3 | [](https://sonarcloud.io/dashboard?id=tim-we_tabs-aside)
4 | 
5 | 
6 |
7 | # Tabs Aside 3
8 |
9 | An extension for Mozilla Firefox based on the Microsoft Edge feature Tabs Aside.
10 | Set your tabs aside as sessions for later. Tabs are stored as bookmarks in a folder of your choice.
11 | Supports Firefox containers.
12 |
13 | [](https://addons.mozilla.org/firefox/addon/tabs-aside)
14 |
15 | ---
16 |
17 | ### Translations
18 |
19 | Want to contribute translations? [Translating Tabs Aside](https://github.com/tim-we/tabs-aside/wiki/Translations)
20 |
--------------------------------------------------------------------------------
/gulp/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: process.argv.includes('--prod') ? 'prod' : 'dev',
3 |
4 | srcPath: './src',
5 | destPath: './dist',
6 |
7 | copy: {
8 | files: [
9 | 'manifest.json',
10 | 'img/**/*',
11 | 'html/**/*',
12 | 'tab-loader/*',
13 | 'fonts/**/*',
14 | '_locales/**/*'
15 | ]
16 | },
17 |
18 | css: {
19 | src: 'scss/*.scss',
20 | dest: 'css',
21 | watch: 'scss/**/*.scss'
22 | },
23 |
24 | js: {
25 | src: 'ts/**/*.ts',
26 | dest: 'js'
27 | }
28 |
29 | };
30 |
--------------------------------------------------------------------------------
/gulp/index.js:
--------------------------------------------------------------------------------
1 | const gulp = require('gulp');
2 |
3 | module.exports = (tasks) => {
4 | tasks.forEach((name) => {
5 | const module = require('./tasks/' + name);
6 |
7 | for (let taskname in module.task) {
8 | if (Array.isArray(module.task[taskname])) {
9 | gulp.task(taskname, gulp.series(...module.task[taskname]));
10 | } else {
11 | gulp.task(taskname, module.task[taskname]);
12 | }
13 | }
14 | });
15 |
16 | return gulp;
17 | };
18 |
--------------------------------------------------------------------------------
/gulp/lib/log.js:
--------------------------------------------------------------------------------
1 | const fancyLog = require('fancy-log');
2 | const chalk = require('chalk');
3 |
4 | function ucFirst(s){
5 | return s[0].toUpperCase() + s.slice(1);
6 | }
7 |
8 | function log(type, id, msg) {
9 | var status = '';
10 |
11 | switch(type) {
12 | case 'error':
13 | status = chalk.red;
14 | break;
15 | case 'warning':
16 | status = chalk.yellow;
17 | type = 'warn';
18 | break;
19 | default:
20 | status = chalk.green;
21 | break;
22 | }
23 | status = status(`[${ucFirst(type)}]`);
24 |
25 | let quotes = msg.match(/"(.*?)"/g);
26 | if (quotes != null) {
27 | quotes.forEach(quote => {
28 | msg = msg.replace(quote, chalk.green(quote));
29 | });
30 | }
31 |
32 | id = chalk.blue(`[${id}]`);
33 |
34 | fancyLog[type](`${status} ${id} ${msg}`);
35 | };
36 |
37 | log.pathsToString = (paths) => {
38 | const projectPath = process.cwd();
39 | paths = paths.map(path => {
40 | return `"${path.replace(projectPath, '')}"`;
41 | });
42 | return paths.join(', ');
43 | };
44 |
45 | module.exports = log;
46 |
--------------------------------------------------------------------------------
/gulp/tasks/copy.js:
--------------------------------------------------------------------------------
1 | const del = require('del');
2 | const gulp = require('gulp');
3 | const tap = require('gulp-tap');
4 |
5 | const path = require('path');
6 |
7 | const log = require('../lib/log');
8 | const config = require('../config.js');
9 |
10 | const task = {};
11 |
12 | const copyPaths = folder => {
13 | return config.copy.files ? config.copy.files.map(file => {
14 | return path.resolve(folder, file);
15 | }) : [];
16 | };
17 |
18 | const copy = paths => {
19 | return gulp.src(paths, { base: config.srcPath})
20 | .pipe(tap(file => {
21 | log('info', 'copy:build', `Copy "${file.basename}".`);
22 | }))
23 | .pipe(gulp.dest(config.destPath));
24 | };
25 |
26 | task['copy:clean'] = () => {
27 | return del(copyPaths(config.destPath)).then(files => {
28 | if (files.length > 0) {
29 | log('info', 'copy:clean', `Deleted ${log.pathsToString(files)}.`);
30 | }
31 | });
32 | };
33 |
34 | task['copy:build'] = () => {
35 | return copy(copyPaths(config.srcPath));
36 | };
37 |
38 | task['copy:watch:init'] = (done) => {
39 | gulp.watch(copyPaths(config.srcPath)).on('change', path => {
40 | log('info', 'copy:watch', `File "${path}" has changed.`);
41 | copy(path);
42 | });
43 | done();
44 | };
45 |
46 | task['copy'] = ['copy:clean', 'copy:build'];
47 | task['copy:watch'] = ['copy', 'copy:watch:init'];
48 |
49 | module.exports.task = task;
50 |
--------------------------------------------------------------------------------
/gulp/tasks/css.js:
--------------------------------------------------------------------------------
1 | const del = require('del');
2 | const gulp = require('gulp');
3 | const sass = require('gulp-sass');
4 | const gulpif = require('gulp-if');
5 | const sourcemaps = require('gulp-sourcemaps');
6 | const tap = require('gulp-tap');
7 |
8 | const path = require('path');
9 |
10 | const log = require('../lib/log');
11 | const config = require('../config.js');
12 |
13 | const task = {};
14 | const srcPath = path.resolve(config.srcPath, config.css.src);
15 | const destPath = path.resolve(config.destPath, config.css.dest);
16 | const watchPath = path.resolve(config.srcPath, config.css.watch);
17 |
18 | task['css:clean'] = () => {
19 | return del(destPath).then(files => {
20 | if (files.length > 0) {
21 | log('info', 'css:clean', `Deleted ${log.pathsToString(files)}.`);
22 | }
23 | });
24 | };
25 |
26 | task['css:build'] = () => {
27 | return gulp.src(srcPath)
28 | .pipe(gulpif(config.env === 'dev', sourcemaps.init({ loadMaps: true })))
29 | .pipe(sass({
30 | outputStyle: config.env === 'dev' ? 'expanded' : 'compressed'
31 | }).on('error', error => {
32 | let errMsg = error.formatted.split("\n");
33 | log('error', 'css:build', `Sass: ${errMsg[0] + errMsg[1]}`);
34 | }))
35 | .pipe(tap(file => {
36 | log('info', 'css:build', `Build "${file.basename}".`);
37 | }))
38 | .pipe(gulpif(config.env === 'dev', sourcemaps.write('.')))
39 | .pipe(gulp.dest(destPath));
40 | };
41 |
42 | // task csswatch
43 | task['css:watch:init'] = (done) => {
44 | gulp.watch(watchPath, gulp.series('css'))
45 | .on('change', path => {
46 | log('info', 'css:watch', `File "${path}" has changed.`)
47 | });
48 | done();
49 | };
50 |
51 | task['css'] = ['css:clean', 'css:build'];
52 | task['css:watch'] = ['css', 'css:watch:init'];
53 |
54 | module.exports.task = task;
55 |
--------------------------------------------------------------------------------
/gulp/tasks/js.js:
--------------------------------------------------------------------------------
1 | const del = require('del');
2 | const gulp = require('gulp');
3 | const ts = require('gulp-typescript');
4 | const gulpif = require('gulp-if');
5 | const sourcemaps = require('gulp-sourcemaps');
6 |
7 | const path = require('path');
8 |
9 | const log = require('../lib/log');
10 | const config = require('../config.js');
11 |
12 | const task = {};
13 | const srcPath = path.resolve(config.srcPath, config.js.src);
14 | const destPath = path.resolve(config.destPath, config.js.dest);
15 | const tsOptions = process.argv.includes('--disable-typecheck') ? {} : { isolatedModules: false };
16 | const tsProject = ts.createProject('tsconfig.json', tsOptions);
17 |
18 | task['js:clean'] = () => {
19 | return del(destPath).then(paths => {
20 | if (paths.length > 0) {
21 | log('info', 'js:clean', `Deleted ${log.pathsToString(paths)}.`);
22 | }
23 | });
24 | };
25 |
26 | task['js:build'] = () => {
27 | const tsResult = gulp.src(srcPath)
28 | .pipe(gulpif(config.env === 'dev', sourcemaps.init()))
29 | .pipe(tsProject())
30 | .on('error', error => {});
31 | log('info', 'js:build', `Build "${config.js.src}".`);
32 | return tsResult.js
33 | .pipe(gulpif(config.env === 'dev', sourcemaps.write()))
34 | .pipe(gulp.dest(destPath));
35 | };
36 |
37 | task['js:watch:init'] = (done) => {
38 | gulp.watch(srcPath, gulp.series('js:build'))
39 | .on('change', path => {
40 | log('info', 'js:build', `File "${path}" has changed.`)
41 | });
42 | done();
43 | };
44 |
45 | task['js'] = ['js:clean', 'js:build'];
46 | task['js:watch'] = ['js', 'js:watch:init'];
47 |
48 | module.exports.task = task;
49 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | const gulp = require('./gulp')([
2 | 'copy',
3 | 'css',
4 | 'js'
5 | ]);
6 |
7 | gulp.task('default', gulp.series(
8 | 'copy',
9 | 'css',
10 | 'js'
11 | ));
12 |
13 | gulp.task('watch', gulp.series(
14 | 'copy:watch',
15 | 'css:watch',
16 | 'js:watch'
17 | ));
18 |
--------------------------------------------------------------------------------
/media/old-icons/aside2.pdn:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/old-icons/aside2.pdn
--------------------------------------------------------------------------------
/media/old-icons/aside2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/old-icons/aside2.png
--------------------------------------------------------------------------------
/media/old-icons/tab-dark-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/media/screenshot1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot1.png
--------------------------------------------------------------------------------
/media/screenshot10.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot10.png
--------------------------------------------------------------------------------
/media/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot2.png
--------------------------------------------------------------------------------
/media/screenshot3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot3.png
--------------------------------------------------------------------------------
/media/screenshot4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot4.png
--------------------------------------------------------------------------------
/media/screenshot5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot5.png
--------------------------------------------------------------------------------
/media/screenshot6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot6.png
--------------------------------------------------------------------------------
/media/screenshot7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot7.png
--------------------------------------------------------------------------------
/media/screenshot8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot8.png
--------------------------------------------------------------------------------
/media/screenshot9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/media/screenshot9.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tabs-aside",
3 | "version": "3.6.0",
4 | "description": "tab/session manager",
5 | "scripts": {
6 | "build": "gulp --prod",
7 | "dev": "gulp watch",
8 | "dev-fastbuild": "gulp watch --disable-typecheck",
9 | "typecheck": "tsc --watch --noEmit",
10 | "firefox": "web-ext build -s dist -a releases -i=\"**/*.pdn\"",
11 | "run-firefox": "npm run build && web-ext run -s dist",
12 | "test": "echo \"Error: no test specified\" && exit 1"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/tim-we/tabs-aside.git"
17 | },
18 | "author": "tim-we",
19 | "license": "GPL-3.0",
20 | "bugs": {
21 | "url": "https://github.com/tim-we/tabs-aside/issues"
22 | },
23 | "homepage": "https://github.com/tim-we/tabs-aside#readme",
24 | "devDependencies": {
25 | "@types/firefox-webext-browser": "^67.0.2",
26 | "del": "^6.0.0",
27 | "gulp": "^4.0.2",
28 | "gulp-concat": "^2.6.1",
29 | "gulp-if": "^3.0.0",
30 | "gulp-sass": "^4.1.0",
31 | "gulp-sourcemaps": "^2.6.5",
32 | "gulp-tap": "^2.0.0",
33 | "gulp-typescript": "^6.0.0-alpha.1",
34 | "stylelint": "^15.10.1",
35 | "stylelint-config-sass-guidelines": "^10.0.0",
36 | "stylelint-no-unsupported-browser-features": "^4.1.4",
37 | "stylelint-order": "^6.0.4",
38 | "stylelint-scss": "^3.19.0",
39 | "typescript": "^4.2.4",
40 | "web-ext": "^7.11.0"
41 | },
42 | "browserslist": "Firefox 64"
43 | }
44 |
--------------------------------------------------------------------------------
/src/fonts/open-sans-condensed-v12-latin-300.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/src/fonts/open-sans-condensed-v12-latin-300.woff2
--------------------------------------------------------------------------------
/src/fonts/open-sans-v15-latin-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/src/fonts/open-sans-v15-latin-regular.woff2
--------------------------------------------------------------------------------
/src/html/background.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Tabs Aside background page
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/html/bookmark-selector.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Bookmark Selector
8 |
9 |
10 |
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/html/menu/main.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Tabs Aside Menu
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/html/menu/setup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | BrowserAction Setup Page
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/html/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Tabs Aside Options
8 |
9 |
10 |
11 |
12 |
13 |
14 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/html/sidebar.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Tabs Aside Sidebar
8 |
9 |
10 |
11 |
12 |
13 |
14 |
19 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/html/tab-error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Tabs Aside Tab Error
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Tabs Aside Tab Error
15 |
16 |
19 |
20 |
21 |
22 |
23 |
24 |
28 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/html/user-setup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Tabs Aside Setup
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
25 |
26 |
--------------------------------------------------------------------------------
/src/img/arrow-left-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/src/img/arrow-left-48.png
--------------------------------------------------------------------------------
/src/img/arrowhead-right-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/img/aside-24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tim-we/tabs-aside/f552b4bab59d7abd01fd0e87f01faaf5f196c5c6/src/img/aside-24.png
--------------------------------------------------------------------------------
/src/img/browserAction/context.svg:
--------------------------------------------------------------------------------
1 |
2 |
17 |
--------------------------------------------------------------------------------
/src/img/browserAction/dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
20 |
--------------------------------------------------------------------------------
/src/img/browserAction/light.svg:
--------------------------------------------------------------------------------
1 |
2 |
18 |
--------------------------------------------------------------------------------
/src/img/browserMenu/active.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/img/browserMenu/add.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/check-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/close-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/copy.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/img/folder-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/help-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/img/ionicons_svg_md-code.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/img/menu/aside.svg:
--------------------------------------------------------------------------------
1 |
2 |
20 |
--------------------------------------------------------------------------------
/src/img/menu/options-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/menu/tabs.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/src/img/new-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/report-issues.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/img/sidebar/Search.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/sidebar/arrowhead-down-12.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/sidebar/copy-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/sidebar/delete-light-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
8 |
--------------------------------------------------------------------------------
/src/img/sidebar/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
18 |
--------------------------------------------------------------------------------
/src/img/sidebar/info-light-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/img/sidebar/more-16-thin.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/img/sidebar/more-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/img/sidebar/open-in-new-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
8 |
--------------------------------------------------------------------------------
/src/img/sidebar/pin-12.svg:
--------------------------------------------------------------------------------
1 |
4 |
7 |
--------------------------------------------------------------------------------
/src/img/sidebar/readermode.svg:
--------------------------------------------------------------------------------
1 |
4 |
8 |
--------------------------------------------------------------------------------
/src/img/sidebar/restore-light-16.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/img/warning.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "name": "Tabs Aside",
4 | "version": "3.6.0",
5 | "description": "__MSG_extension_description__",
6 | "author": "Tim Weißenfels",
7 |
8 | "default_locale": "en",
9 |
10 | "icons": {
11 | "48": "img/browserAction/dark.svg"
12 | },
13 |
14 | "applications": {
15 | "gecko": {
16 | "id": "{644e8eb0-c710-47e9-b81c-5dd69bfcf86b}",
17 | "strict_min_version": "63.0"
18 | }
19 | },
20 |
21 | "permissions": [
22 | "bookmarks",
23 | "tabs",
24 | "storage",
25 | "menus",
26 | "sessions",
27 | "cookies"
28 | ],
29 |
30 | "browser_action": {
31 | "default_icon": {
32 | "16": "img/browserAction/dark.svg",
33 | "32": "img/browserAction/dark.svg"
34 | },
35 | "default_title": "Tabs Aside!",
36 | "default_popup": "html/menu/main.html",
37 | "browser_style": true,
38 | "theme_icons": [{
39 | "light": "img/browserAction/light.svg",
40 | "dark": "img/browserAction/dark.svg",
41 | "size": 16
42 | }, {
43 | "light": "img/browserAction/light.svg",
44 | "dark": "img/browserAction/dark.svg",
45 | "size": 32
46 | }]
47 | },
48 |
49 | "sidebar_action": {
50 | "default_title": "__MSG_sidebar_default_title__",
51 | "default_panel": "html/sidebar.html",
52 | "default_icon": "img/sidebar/icon.svg",
53 | "open_at_install": false
54 | },
55 |
56 | "background": {
57 | "page": "html/background.html"
58 | },
59 |
60 | "options_ui": {
61 | "page": "html/options.html",
62 | "browser_style": true
63 | },
64 |
65 | "commands": {
66 | "tabs-aside": {
67 | "suggested_key": {
68 | "default": "Alt+Shift+Q"
69 | },
70 | "description": "__MSG_command_tabs_aside_description__"
71 | },
72 |
73 | "_execute_sidebar_action": {
74 | "suggested_key": {
75 | "default": "Alt+Q"
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/scss/base/_fonts.scss:
--------------------------------------------------------------------------------
1 | /* https://google-webfonts-helper.herokuapp.com */
2 |
3 | /* open-sans-regular - latin */
4 | @font-face {
5 | font-family: 'Open Sans';
6 | font-style: normal;
7 | font-weight: 400;
8 | src:
9 | local('Open Sans Regular'),
10 | local('OpenSans-Regular'),
11 | url('../fonts/open-sans-v15-latin-regular.woff2') format('woff2'); /* Chrome 26+, Opera 23+, Firefox 39+ */
12 | }
13 |
14 | /* open-sans-condensed-300 - latin */
15 | @font-face {
16 | font-family: 'Open Sans Condensed';
17 | font-style: normal;
18 | font-weight: 300;
19 | src:
20 | local('Open Sans Condensed Light'),
21 | local('OpenSansCondensed-Light'),
22 | url('../fonts/open-sans-condensed-v12-latin-300.woff2') format('woff2'); /* Chrome 26+, Opera 23+, Firefox 39+ */
23 | }
24 |
--------------------------------------------------------------------------------
/src/scss/bookmark-selector.scss:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: 'Segoe UI', 'Arial', 'Ubuntu', sans-serif;
3 | }
4 |
5 | button {
6 | border: none;
7 | background-color: rgba(0,0,0,0.25);
8 | cursor: pointer;
9 | }
10 |
11 | #breadcrumbs {
12 | position: absolute;
13 | top: 0px;
14 | left: 0px;
15 | right: 0px;
16 | height: 25px;
17 | background-color: rgb(202, 202, 202);
18 | font-size: 0px;
19 | white-space: nowrap;
20 | }
21 |
22 | .breadcrumb {
23 | position: relative;
24 | display: inline-block;
25 | height: 25px;
26 | font-size:16px;
27 | line-height: 22px;
28 | padding-left: 4px;
29 | padding-right: 4px;
30 | margin-right:17px;
31 | cursor: pointer;
32 | user-select: none;
33 | -moz-user-select: -moz-none;
34 | }
35 |
36 | .breadcrumb:hover {
37 | background-color: rgba(16,16,16,0.3);
38 | }
39 |
40 | .breadcrumb:not(:nth-last-of-type(1))::after{
41 | position: absolute;
42 | right: -16px;
43 | display: inline-block;
44 | content:" ";
45 | width:16px;
46 | height:25px;
47 | background-image: url("../img/arrowhead-right-16.svg");
48 | background-position: center;
49 | background-repeat: no-repeat;
50 | cursor: default;
51 | }
52 |
53 | #controls {
54 | position: absolute;
55 | left: 0px;
56 | right: 0px;
57 | bottom: 0px;
58 | height: 30px;
59 | background-color: rgb(230,230,230);
60 | }
61 |
62 | #content-view {
63 | position: absolute;
64 | left: 0px;
65 | right:0px;
66 | top: 25px;
67 | bottom: 30px;
68 | overflow-x: hidden;
69 | overflow-y: scroll;
70 | padding-top: 10px;
71 | padding-bottom: 10px;
72 | }
73 |
74 | #select {
75 | position: absolute;
76 | bottom: 0px;
77 | right: 0px;
78 | height: 100%;
79 | padding-left: 10px;
80 | padding-right: 10px;
81 | }
82 |
83 | #newfolder {
84 | position: absolute;
85 | bottom: 0px;
86 | left: 0px;
87 | height: 100%;
88 | padding-left: 2px;
89 | }
90 |
91 | #newfolder > img {
92 | position: relative;
93 | top: 2px;
94 | }
95 |
96 | .folder {
97 | position: relative;
98 | cursor: pointer;
99 | margin-bottom: 8px;
100 | white-space: nowrap;
101 | user-select: none;
102 | -moz-user-select: -moz-none;
103 | }
104 |
105 | .folder::before {
106 | content: " ";
107 | display: inline-block;
108 | position: relative;
109 | top: 2px;
110 | margin-right: 6px;
111 | margin-left: 8px;
112 | background-image: url("../img/folder-16.svg");
113 | width: 16px;
114 | height: 16px;
115 | }
116 |
117 | .folder.selected {
118 | background-color: rgba(0, 150, 255, 0.15);
119 | box-shadow: 0px 0px 0px 1px rgba(0,150, 255, 0.9);
120 | }
121 |
122 | .folder.oldselection::after {
123 | content: " ";
124 | display: block;
125 | position: absolute;
126 | top: 4px;
127 | right: 2px;
128 | width: 16px;
129 | height: 16px;
130 | background-image: url("../img/check-16.svg");
131 | opacity: 0.6;
132 | }
--------------------------------------------------------------------------------
/src/scss/menu-setup.scss:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 20px;
3 | width: 350px;
4 | background-color: white;
5 | }
6 |
7 | body > div {
8 | text-align: center;
9 | }
10 |
11 | body > div:not(:last-child){
12 | margin-bottom: 15px;
13 | }
14 |
15 | button {
16 | width: 120px;
17 | min-height: 42px;
18 | margin: 5px;
19 |
20 | text-align: center;
21 |
22 | background-color: #0996f8;
23 | border: none;
24 | box-shadow: 0 1px 0 #0670cc inset;
25 | color: white;
26 |
27 | cursor: pointer;
28 | }
29 |
30 | button:hover {
31 | background-color: #0670cc;
32 | box-shadow: 0 1px 0 #005bab inset;
33 | }
--------------------------------------------------------------------------------
/src/scss/menu.scss:
--------------------------------------------------------------------------------
1 | body {
2 | width: 268px;
3 | --button-height: 25px;
4 | --icon-size: 16px;
5 | --accent-color: rgb(10,132,255);
6 | min-height: calc(3 * var(--button-height));
7 | }
8 |
9 | body.active-session {
10 | background-color: var(--accent-color);
11 | }
12 |
13 | #active-session-indicator {
14 | position: relative;
15 | display: none;
16 | background-color: var(--accent-color);
17 | color: white;
18 | font-size: 12px;
19 | height: 16px;
20 | line-height: 16px;
21 | padding: 0px 2px;
22 | padding-left: 4px;
23 | margin-bottom: 1px;
24 | white-space: nowrap;
25 | overflow: hidden;
26 | }
27 |
28 | #active-session-indicator::after {
29 | content: " ";
30 | display: block;
31 | position: absolute;
32 | top: 0px;
33 | right: -15px;
34 | bottom: 0px;
35 | width: 16px;
36 | box-shadow: 0px 0px 10px 3px var(--accent-color);
37 | }
38 |
39 | body.active-session div#active-session-indicator {
40 | display: block;
41 | }
42 |
43 | .button {
44 | position: relative;
45 | display: block;
46 | height: var(--button-height);
47 | line-height: var(--button-height);
48 | color: black;
49 | padding-left: 36px;
50 | padding-right: 12px;
51 | text-decoration: none;
52 | white-space: nowrap;
53 |
54 | background-color: white;
55 | }
56 |
57 | .button.disabled {
58 | color: rgb(110,110,110);
59 | }
60 |
61 | .mini-button {
62 | width: 24px;
63 | height: 24px;
64 | background-color: inherit;
65 | background-position: center;
66 | background-repeat: no-repeat;
67 | }
68 |
69 | .button.icon::before {
70 | content: " ";
71 | display: block;
72 | position: absolute;
73 | left: calc(36px - var(--icon-size) - 8px);
74 | top: calc(0.5 * (var(--button-height) - var(--icon-size)));
75 | height: var(--icon-size);
76 | width: var(--icon-size);
77 |
78 | background-color: inherit;
79 | background-image: var(--iconURL, none);
80 | background-repeat: no-repeat;
81 | background-position: center;
82 | }
83 |
84 | .button.disabled.icon::before {
85 | opacity: 0.5;
86 | }
87 |
88 | .button.icon.wide::before {
89 | left: calc(36px - 24px - 8px);
90 | width: 24px;
91 | background-position: right;
92 | }
93 |
94 | .button:not(.disabled):hover, .mini-button:hover {
95 | background-color: #F0F0F0;
96 | }
97 |
98 | .button:not(.disabled):active, .mini-button:active {
99 | background-color: #E8E8E8;
100 | }
101 |
102 | .button:not(.disabled)[data-shortcut]::after {
103 | content: attr(data-shortcut);
104 | position:absolute;
105 | color: #6D6D6D;
106 | right: 12px;
107 | text-align: right;
108 | }
--------------------------------------------------------------------------------
/src/scss/modal-windows.scss:
--------------------------------------------------------------------------------
1 | #modal-background {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 |
6 | position: fixed;
7 | top: 0px;
8 | left: 0px;
9 | right: 0px;
10 | bottom: 0px;
11 | margin: 0px;
12 | padding: 0px;
13 |
14 | background-color: rgba(64,64,64,0.25);
15 | z-index: 9999;
16 | opacity: 0;
17 |
18 | transition: opacity 0.25s;
19 | }
20 |
21 | #modal-background > div.modal-window {
22 | --modal-background: rgb(64,64,64);
23 | --modal-text-color: rgb(255,255,255);
24 | --modal-button-bg-color: rgb(255,255,255);
25 | --modal-button-text-color: rgb(16,16,16);
26 |
27 | position: relative;
28 | width: 100%;
29 |
30 | background-color: var(--modal-background);
31 | color: var(--modal-text-color);
32 | box-shadow: 0px 0px 10px 0px rgba(16,16,16,.125);
33 |
34 | text-align: center;
35 |
36 | transition: opacity .25s;
37 | }
38 |
39 | #modal-background > div.modal-window > div.content {
40 | position: relative;
41 | display: inline-block;
42 | width: 100%;
43 | max-width: 500px;
44 | text-align: left;
45 | }
46 |
47 | #modal-background > div.modal-window > div.content > div.custom-content {
48 | width: 100%;
49 | padding: 10px;
50 | max-height: 800px;
51 | overflow: auto;
52 | }
53 |
54 | #modal-background > div.modal-window > div.content > div.custom-content > h2:first-child {
55 | margin-top: 0px;
56 | margin-bottom: 7px;
57 | }
58 |
59 | #modal-background > div.modal-window > div.content > div.custom-content > table {
60 | border: none;
61 | width: 100%;
62 | }
63 |
64 | #modal-background > div.modal-window > div.content > div.custom-content > table tr:nth-of-type(even) {
65 | background-color: rgba(200,200,200,0.1);
66 | }
67 |
68 | #modal-background > div.modal-window > div.content > div.custom-content > table td {
69 | padding: 0px 2px;
70 | }
71 |
72 | #modal-background > div.modal-window > div.content > div.buttons {
73 | position: relative;
74 | width: 100%;
75 | text-align: right;
76 | background-color: rgba(16,16,16,0.1);
77 | padding: 4px;
78 | }
79 |
80 | #modal-background > div.modal-window > div.content > div.buttons > button {
81 | background-color: var(--modal-button-bg-color);
82 | color: var(--modal-button-text-color);
83 | min-width: 50px;
84 | border: none;
85 | text-align: center;
86 | margin-bottom: 0px;
87 | margin-left: 4px;
88 | margin-right: 0px;
89 | }
--------------------------------------------------------------------------------
/src/scss/options.scss:
--------------------------------------------------------------------------------
1 | a:link, a:visited { color: #0000FF; }
2 |
3 | .section {
4 | display: grid;
5 | grid-template-columns: 1fr;
6 | grid-column-start: 6px;
7 | grid-column-end: 5px;
8 | grid-row-gap: 6px;
9 | }
10 |
11 | .row {
12 | position: relative;
13 | padding-top: 6px;
14 | padding-left: 6px;
15 | padding-right: 5px;
16 | padding-bottom: 2px;
17 | }
18 |
19 | .row.browser-style {
20 | padding-bottom: 0px;
21 | margin-bottom: 1px;
22 | }
23 |
24 | .row:not(:nth-of-type(1)) {
25 | border-top: 1px solid rgba(12, 12, 13, 0.4);
26 | }
27 |
28 | label {
29 | position: relative;
30 | top: 3px;
31 | font-size: 1.25rem;
32 | }
33 |
34 | .row.select > label, .row.bookmark > label {
35 | margin-right: 8px;
36 | }
37 |
38 | .row.select > select {
39 | min-width: 110px;
40 | min-height: 20px;
41 | }
42 |
43 | .info {
44 | margin-top: 5px;
45 | font-size: 1rem;
46 | color: rgba(16,16,16,0.75);
47 | }
48 |
49 | .info:last-child {
50 | margin-bottom: 0px;
51 | }
52 |
53 | div.bookmarkFolderView {
54 | display: inline-block;
55 | border: 1px solid rgb(100, 100, 100);
56 | background-color: rgba(200,200,200,0.3);
57 | background-image: url("../img/folder-16.svg");
58 | background-position: 3px center;
59 | background-repeat: no-repeat;
60 | color: black;
61 | padding: 2px;
62 | padding-left: 23px;
63 | padding-right: 5px;
64 | margin-left: 3px;
65 | min-width: 135px;
66 | min-height: 20px;
67 | opacity: 0.65;
68 | cursor: pointer;
69 | }
70 |
71 | div.bookmarkFolderView:hover {
72 | opacity: 0.75;
73 | }
74 |
75 | .row.string > label {
76 | margin-right: 8px;
77 | }
78 |
79 | body:not(.active-sessions) .row.active-only::after {
80 | content:"";
81 | position: absolute;
82 | top: 0;
83 | left: 0;
84 | width: 100%;
85 | height: 100%;
86 | background-color: rgba(200,200,200,0.42);
87 | padding-bottom: 2px;
88 | padding-top: 6px;
89 | }
90 |
91 | footer {
92 | margin-top: 10px;
93 | padding-left: 6px;
94 | padding-right: 5px;
95 | text-align: right;
96 | }
97 |
98 | footer > a::before {
99 | font-size: 0;
100 | display: inline-block;
101 | position: relative;
102 | width: 16px;
103 | height: 16px;
104 | }
105 |
106 | footer > a:not(:last-of-type) {
107 | margin-right: 3px;
108 | }
109 |
110 | #source-code::before {
111 | content: " ";
112 | top: 4px;
113 | margin-right: 1px;
114 | background-image: url('../img/ionicons_svg_md-code.svg');
115 | }
116 |
117 | #report-issues::before {
118 | content: " ";
119 | top: 3px;
120 | background-image: url('../img/report-issues.svg');
121 | }
122 |
123 | @media (prefers-color-scheme: dark) {
124 | body {
125 | color: rgb(249, 249, 250);
126 | background-color: rgb(32, 32, 35);
127 | }
128 |
129 | a:link, a:visited {
130 | color: rgb(69, 161, 255);
131 | }
132 |
133 | .info {
134 | color: rgb(177, 177, 179);
135 | }
136 |
137 | div.bookmarkFolderView {
138 | border: 1px solid rgb(177, 177, 177);
139 | background-color: rgb(251,251,251);
140 | color: rgb(32, 32, 35);
141 | opacity: 1;
142 | }
143 |
144 | div.bookmarkFolderView:hover {
145 | opacity: 0.9;
146 | }
147 |
148 | .row:not(:nth-of-type(1)) {
149 | border-top: 1px solid rgba(249, 249, 250, 0.2);
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src/scss/overlay-menu.scss:
--------------------------------------------------------------------------------
1 | #overlay-menu-bg {
2 | position: absolute;
3 | top: 0px;
4 | left: 0px;
5 | right: 0px;
6 | bottom: 0px;
7 | overflow: hidden;
8 | z-index: 1000;
9 | }
10 |
11 | .overlay-menu {
12 | position: absolute;
13 | box-sizing: content-box;
14 | min-width: 150px;
15 | max-width: 100vw;
16 | padding-top: 5px;
17 | padding-bottom: 5px;
18 | right: 5px;
19 | background-color: rgb(50,50,52);
20 | box-shadow: 0px 0px 0px 1px rgb(71,71,73);
21 | color: white;
22 | animation: .25s overlay-menu-in;
23 | transform-origin: top right;
24 |
25 | --item-height: 25px;
26 | }
27 |
28 | .overlay-menu.origin-left {
29 | transform-origin: top left;
30 | }
31 |
32 | .overlay-menu > .overlay-menu-item {
33 | position: relative;
34 | height: var(--item-height);
35 | line-height: var(--item-height);
36 | cursor: default;
37 | padding-left: 36px;
38 | padding-right: 12px;
39 | white-space: nowrap;
40 | }
41 |
42 | .overlay-menu > .overlay-menu-item:hover {
43 | background-color: rgb(71,71,73);
44 | }
45 |
46 | /* this pseudo element is the icon */
47 | .overlay-menu > .overlay-menu-item::before {
48 | content: " ";
49 | display: block;
50 | position: absolute;
51 | width: 16px;
52 | height: 16px;
53 | left: 12px;
54 | top: 5px;
55 | background-repeat: no-repeat;
56 | background-position: center;
57 | }
58 |
59 | @media (max-width: 180px) {
60 | .overlay-menu {
61 | min-width: auto;
62 | right: 0px !important;
63 | left: 0px;
64 | }
65 | }
66 |
67 | @keyframes overlay-menu-in {
68 | from {
69 | transform: scale(0.75,0.1);
70 | opacity: 0;
71 | }
72 |
73 | to {
74 | transform: scale(1,1);
75 | opacity: 1;
76 | }
77 | }
--------------------------------------------------------------------------------
/src/scss/sidebar.scss:
--------------------------------------------------------------------------------
1 | @import 'base/fonts';
2 | @import 'overlay-menu';
3 | @import 'sidebar/session';
4 | @import 'sidebar/search';
5 | @import 'sidebar/menu-items';
6 | @import 'modal-windows';
7 |
8 | :root {
9 | --search-height: 32px;
10 | --bgcolor: white;
11 | }
12 |
13 | body {
14 | font-family: 'Open Sans', Segoe UI, sans-serif;
15 | background-color: var(--bgcolor);
16 | }
17 |
18 | a, a:visited {
19 | cursor: pointer;
20 | color: rgb(0, 0, 238);
21 | }
22 |
23 | .state-info {
24 | position: absolute;
25 | top: 0px;
26 | left: 0px;
27 | right: 0px;
28 | padding: 5px;
29 | font-size: 1.2rem;
30 | }
31 |
32 | .state-info:not(.show) {
33 | display: none;
34 | }
35 |
36 | #no-sessions {
37 | bottom: 0px;
38 | background-color: white;
39 | }
40 |
41 | #sessions {
42 | position: absolute;
43 | top: 5px;
44 | bottom: calc(var(--search-height) + 2px);
45 | left: 0px;
46 | right: 0px;
47 |
48 | display: flex;
49 | flex-direction: column;
50 | flex-wrap: nowrap;
51 | overflow-x: hidden;
52 | }
53 |
--------------------------------------------------------------------------------
/src/scss/sidebar/_menu-items.scss:
--------------------------------------------------------------------------------
1 | #options-menu-restore-keep::before {
2 | background-image: url("../img/sidebar/restore-light-16.svg");
3 | }
4 |
5 | #options-menu-remove-session::before {
6 | background-image: url("../img/sidebar/delete-light-16.svg");
7 | }
8 |
9 | #options-menu-session-details::before {
10 | background-image: url("../img/sidebar/info-light-16.svg");
11 | }
12 |
13 | #options-menu-tab-copy::before {
14 | background-image: url("../img/sidebar/copy-16.svg");
15 | }
16 |
17 | #options-menu-tab-remove::before {
18 | background-image: url("../img/sidebar/delete-light-16.svg");
19 | }
--------------------------------------------------------------------------------
/src/scss/sidebar/_search.scss:
--------------------------------------------------------------------------------
1 | #search {
2 | position: absolute;
3 | bottom: 1px;
4 | left: 0px;
5 | right: 0px;
6 | height: var(--search-height);
7 | border-top: 1px solid #E3E3E3;
8 | --search-clear-width: 24px;
9 | }
10 |
11 | #search-input {
12 | box-sizing: border-box;
13 | border: none;
14 | position: absolute;
15 | top: 0px;
16 | left: var(--search-height);
17 | height: var(--search-height);
18 | width: calc(100% - var(--search-height) - var(--search-clear-width) - 1px);
19 | }
20 |
21 | #search-icon {
22 | position: absolute;
23 | top: 0px;
24 | left: 0px;
25 | height: var(--search-height);
26 | width: var(--search-height);
27 | padding-left: 2px;
28 |
29 | background-repeat: no-repeat;
30 | background-position: center;
31 | background-image: url("../img/sidebar/Search.svg");
32 | }
33 |
34 | #search-clear {
35 | position: absolute;
36 | top: 0;
37 | right: 0;
38 |
39 | height: var(--search-height);
40 | width: var(--search-clear-width);
41 | max-width: var(--search-height);
42 |
43 | cursor: pointer;
44 | border: none;
45 | background-color: var(--bgcolor);
46 | background-position: center;
47 | background-repeat: no-repeat;
48 | background-image: url("../img/close-16.svg");
49 | background-size: 50%;
50 |
51 | opacity: 0;
52 | transition: opacity .25s, background-size .25s;
53 | }
54 |
55 | #search-clear.show {
56 | opacity: 1;
57 | background-size: 100%;
58 | }
59 |
60 | #search-clear:hover {
61 | background-size: 110%;
62 | }
63 |
--------------------------------------------------------------------------------
/src/scss/sidebar/_session.scss:
--------------------------------------------------------------------------------
1 | .session {
2 | position: relative;
3 | margin-bottom: 5px;
4 | --header-height: 32px;
5 | --bg-color: rgb(240,240,240);
6 | --hover-trans-dur: .25s;
7 | transition: background var(--hover-trans-dur), box-shadow var(--hover-trans-dur);
8 | background-color: var(--bg-color);
9 | z-index: 1;
10 | animation: session-in .25s ease-out;
11 | }
12 |
13 | .session:hover {
14 | --bg-color: rgb(245,245,245);
15 | box-shadow: 0px 1px 1px rgba(8,8,8,0.1);
16 | }
17 |
18 | .session.expanded:hover {
19 | box-shadow: 0px 1px 1px 0px rgba(16,16,16,0.1);
20 | }
21 |
22 | @keyframes session-in {
23 | 0% {
24 | opacity: 0;
25 | transform: translateX(5px);
26 | }
27 | 100% {
28 | opacity: 1;
29 | transform: translateX(0px);
30 | }
31 | }
32 |
33 | .session > .header {
34 | position: relative;
35 | padding-left: 14px;
36 | height: var(--header-height);
37 | z-index: 3;
38 | transition: box-shadow 0.5s;
39 | }
40 |
41 | .session:not(.expanded):hover > .header:active {
42 | box-shadow: 0px 1px 0px 0px rgba(16,16,16,0.1);
43 | }
44 |
45 | .session > .header::before {
46 | content: " ";
47 | position: absolute;
48 | top: calc(0.5 * (var(--header-height) - 12px));
49 | left: 0px;
50 | height: 12px;
51 | background-repeat: no-repeat;
52 | background-position-y: center;
53 | background-image: url("../img/sidebar/arrowhead-down-12.svg");
54 | width: 12px;
55 |
56 | transition: transform 0.25s;
57 | }
58 |
59 | .session:not(.expanded) > .header::before {
60 | transform: rotate(-90deg);
61 | }
62 |
63 | .session > .header .title {
64 | margin-right: 3px;
65 | line-height: var(--header-height);
66 | white-space: nowrap;
67 | font-size: 1.2rem;
68 | word-spacing: -.075rem;
69 | cursor: text;
70 | transition: background .2s;
71 | }
72 |
73 | .session.active > .header .title:before {
74 | content: " ";
75 | display: inline-block;
76 | background-color: #0A84FF;
77 | width: 11px;
78 | height: 11px;
79 | border-radius: 50%;
80 | margin-left: -1px;
81 | margin-right: 3px;
82 | position: relative;
83 | top: -1px;
84 | }
85 |
86 | .session > .header .title.editmode > input {
87 | border: none;
88 | padding: 0px;
89 | font-family: inherit;
90 | font-size: inherit;
91 | word-spacing: inherit;
92 | min-width: 80px;
93 | box-shadow: 0px 0px 0px 1px rgba(32, 32, 32, 0.1);
94 | }
95 |
96 | .session:not(:hover) > .header .title.editmode > input:focus {
97 | box-shadow: 0px 0px 0px 1px rgba(16,16,16,0.1);
98 | }
99 |
100 | .session > .header .title:hover {
101 | background-color: rgba(255,255,255,0.75);
102 | }
103 |
104 | .session > .header .number-of-tabs {
105 | color: #6D6D6D;
106 | line-height: var(--header-height);
107 | white-space: nowrap;
108 | font-family: 'Open Sans Condensed', sans-serif;
109 | cursor: default;
110 | -moz-user-select: none;
111 | }
112 |
113 | .session > .header > .align-right {
114 | position: absolute;
115 | right: 0px;
116 | top: 0px;
117 | height: var(--header-height);
118 | }
119 |
120 | .session > .header .controls {
121 | display: flex;
122 | flex-direction: row;
123 | flex-wrap: nowrap;
124 | background-color: var(--bg-color);
125 | line-height: var(--header-height);
126 | height: var(--header-height);
127 | padding-left: 1px;
128 | padding-right: 3px;
129 | -moz-user-select: none;
130 | transition: background var(--hover-trans-dur);
131 | }
132 |
133 | .session > .header .textbutton {
134 | color: rgb(0,0,238);
135 | cursor: pointer;
136 | }
137 |
138 | .session > .header .textbutton:hover {
139 | text-decoration: underline;
140 | }
141 |
142 | .session > .header .more {
143 | width: 20px;
144 | height: var(--header-height);
145 | background-repeat: no-repeat;
146 | background-position: center 9px;
147 | background-image: url("../img/sidebar/more-16.svg");
148 | margin-left: 2px;
149 | cursor: pointer;
150 | }
151 |
152 | @media (max-width: 300px) {
153 | .session > .header .controls {
154 | padding-right: 1px;
155 | }
156 |
157 | .session > .header .more {
158 | width: 14px;
159 | background-position: center 8px;
160 | background-image: url("../img/sidebar/more-16-thin.svg");
161 | margin-left: 1px;
162 | }
163 |
164 | .session.active > .header::before {
165 | left: -12px;
166 | opacity: 0;
167 | }
168 |
169 | .session.active > .header .title:before {
170 | margin-left: 0px;
171 | }
172 |
173 | .session.active > .header {
174 | padding-left: 3px;
175 | }
176 | }
177 |
178 | .session > .header .more:hover {
179 | background-color: rgba(0,0,0,0.1);
180 | transition: .2s;
181 | }
182 |
183 | .session > .tab-view {
184 | z-index: 2;
185 | }
186 |
187 | .session:not(.expanded) > .tab-view {
188 | display: none;
189 | }
190 |
191 | .session.active > .header .restore {
192 | display: none;
193 | }
194 |
195 | .session:not(.active) > .header .aside {
196 | display: none;
197 | }
198 |
199 | .session.hidden {
200 | display: none;
201 | }
202 |
--------------------------------------------------------------------------------
/src/scss/tab-error.scss:
--------------------------------------------------------------------------------
1 | @import 'base/fonts';
2 |
3 | :root {
4 | background-color: #f9f9fa;
5 | color: #0c0c0d;
6 | }
7 |
8 | a:link, a:visited { color: #0000FF; }
9 |
10 | @media (prefers-color-scheme: dark) {
11 | :root {
12 | background-color: #2A2A2E;
13 | color: rgb(249, 249, 250);
14 | }
15 |
16 | a:link, a:visited { color: rgb(69, 161, 255); }
17 | }
18 |
19 | body {
20 | font-family: 'Open Sans', Segoe UI, sans-serif;
21 | font-size: 15px;
22 | font-weight: normal;
23 |
24 | display: flex;
25 | flex-direction: column;
26 | box-sizing: border-box;
27 | min-height: 100vh;
28 | padding: 40px 48px;
29 | align-items: center;
30 | justify-content: center;
31 | margin: 0;
32 | }
33 |
34 | .container {
35 | min-width: 13em;
36 | max-width: 52em;
37 | }
38 |
39 | .title {
40 | background-position: left 0;
41 | background-repeat: no-repeat;
42 | background-size: 1.6em;
43 | background-image: url(../img/warning.svg);
44 |
45 | margin-inline-start: -2.3em;
46 | padding-inline-start: 2.3em;
47 | padding-top: 2px;
48 | font-size: 2.2em;
49 | }
50 |
51 | .title-text {
52 | font-size: inherit;
53 | padding-bottom: 0.4em;
54 | }
55 |
56 | h1 {
57 | font-weight: lighter;
58 | line-height: 1.2;
59 | margin: 0;
60 | margin-bottom: .5em;
61 | }
62 |
63 | .row-section.optional {
64 | display: none;
65 | }
66 |
67 | .button-container > button:first-child {
68 | margin-inline-start: 0;
69 | }
70 |
71 | button {
72 | padding: 0 1.5em;
73 | min-width: 6.3em;
74 | margin-left: 0px;
75 | margin-right: 4px;
76 | min-height: 32px;
77 |
78 | background-color: rgb(0, 96, 223);
79 | color: white;
80 | font-size: 1em;
81 | text-decoration: none;
82 |
83 | border-radius: 2px;
84 | border: 1px solid transparent;
85 | box-shadow: rgba(10, 132, 255, 0.3) 0px 0px 0px 4px;
86 | outline-offset: -1px;
87 | outline: 2px solid #0a84ff;
88 | -moz-outline-radius: 3px;
89 | }
90 |
91 | button:hover {
92 | background-color: rgb(0, 62, 170);
93 | }
94 |
95 | pre {
96 | background-color: rgba(200,200,200,0.2);
97 | padding: 4px 7px;
98 | border-radius: 2px;
99 | margin-top: 10px;
100 | }
101 |
102 | #description p:not(:first-of-type) {
103 | margin-top: 10px;
104 | }
105 |
106 | #url {
107 | width: calc(100% - 30px - 15px);
108 | min-width: 420px;
109 | max-width: 1000px;
110 | border: none;
111 | background-color: rgb(235,235,235);
112 | font-size: 20px;
113 | padding: 3px 3px;
114 | height: 25px;
115 | border-radius: 2px;
116 | }
117 |
118 | #copy {
119 | position: relative;
120 | top: -3px;
121 |
122 | width: 25px;
123 | height: 25px;
124 | padding: 3px 3px;
125 | box-sizing: content-box;
126 |
127 | border: 0px;
128 | border-radius: 2px;
129 | background-color: rgb(235,235,235);
130 |
131 | background-image: url('../img/copy.svg');
132 | background-repeat: no-repeat;
133 | background-position: center;
134 |
135 | cursor: pointer;
136 | }
137 |
138 | #copy:hover {
139 | background-color: rgb(200,200,200);
140 | }
141 |
142 | #open {
143 | margin-top: 1em;
144 | margin-bottom: 1em;
145 | display: none;
146 | }
147 |
--------------------------------------------------------------------------------
/src/scss/tab-view-simple-list.scss:
--------------------------------------------------------------------------------
1 | .tab-view > ol {
2 | padding-left: 1.2em;
3 | font-family: 'Open Sans Condensed', sans-serif;
4 | font-size: 1rem;
5 | margin-top: 4px;
6 | margin-bottom: 2px;
7 | animation: simpleListExpand 0.05s;
8 | animation-timing-function: ease-out;
9 | }
10 |
11 | .tab-view > ol.geq10 {
12 | padding-left: 1.45em;
13 | }
14 |
15 | .tab-view > ol.geq100 {
16 | padding-left: 1.8em;
17 | }
18 |
19 | .tab-view > ol > li {
20 | white-space: nowrap;
21 | transition: background .42s;
22 | }
23 |
24 | .tab-view > ol a {
25 | font-family: 'Open Sans', Segoe UI, sans-serif;
26 | font-size: 1rem;
27 | }
28 |
29 | .tab-view > ol a.tab:not(:hover) {
30 | color: #222426;
31 | text-decoration: none;
32 | }
33 |
34 | .tab-view .state-icon {
35 | display: inline-block;
36 | width: 12px;
37 | height: 12px;
38 | margin-right: 1px;
39 | background-repeat: no-repeat;
40 | background-position: center;
41 | background-size: contain;
42 | transform: translateY(2px);
43 | }
44 |
45 | .tab-view .state-icon:last-of-type {
46 | margin-right: 2px;
47 | }
48 |
49 | .tab-view .state-icon.pinned {
50 | background-image: url("../img/sidebar/pin-12.svg");
51 | }
52 |
53 | .tab-view .state-icon.rm {
54 | background-image: url("../img/sidebar/readermode.svg");
55 | }
56 |
57 | @keyframes simpleListExpand {
58 | 0% {
59 | transform: translateY(-10px);
60 | opacity: 0;
61 | overflow: hidden;
62 | max-height: 10px;
63 | }
64 |
65 | 100% {
66 | transform: translateY(0px);
67 | max-height: 500px;
68 | overflow: hidden;
69 | opacity: 1;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/scss/user-setup.scss:
--------------------------------------------------------------------------------
1 | @import 'base/fonts';
2 |
3 | body {
4 | --bg-color: rgb(249,249,250);
5 |
6 | font-family: 'Open Sans', Segoe UI, sans-serif;
7 | background-color: var(--bg-color);
8 | }
9 |
10 | a, a:visited {
11 | cursor: pointer;
12 | color: rgb(0, 0, 238);
13 | }
14 |
15 | #header {
16 | padding: 5px;
17 | }
18 |
19 | #title {
20 | font-size: 2em;
21 | }
22 |
23 | #version {
24 | opacity: .75;
25 | margin-left: 1px;
26 | }
27 |
28 | #setup-content {
29 | padding: 5px;
30 | }
31 |
32 | .content-box {
33 | position: relative;
34 | background-color: white;
35 | box-shadow: rgba(12, 12, 13, 0.1) 0px 1px 4px 0px;
36 | border-radius: 4px;
37 | margin: 0 0 8px;
38 | padding: 5px;
39 | font-size: 14px;
40 | }
41 |
42 | .options {
43 | border-radius: 4px;
44 | background-color: inherit;
45 | overflow:hidden;
46 | }
47 |
48 | .options > a {
49 | display: block;
50 | background-color: rgba(12, 12, 13, 0.1);
51 | text-align: center;
52 | padding: 2px;
53 | color: rgb(12, 12, 13);
54 | min-height: 32px;
55 | line-height: 32px;
56 | }
57 |
58 | .options > a.recommended {
59 | background-color: rgb(10,132,255);
60 | color: white;
61 |
62 | animation: linear .2s recommended_fadein;
63 | }
64 |
65 | .options > a:hover {
66 | background-color: rgba(12, 12, 13, 0.2);
67 | }
68 |
69 | .options > a.recommended:hover {
70 | background-color: rgb(14, 118, 230);
71 | }
72 |
73 | .options > a:hover:active {
74 | background-color: rgba(12, 12, 13, 0.3);
75 | }
76 |
77 | .options > a.recommended:hover:active {
78 | background-color: rgb(15, 115, 212);
79 | }
80 |
81 | .options > a ul {
82 | font-size: 0.8em;
83 | opacity: 0.7;
84 | margin: 0px;
85 | margin-bottom: 5px;
86 | line-height: normal;
87 | max-width: 90vw;
88 | padding-left: 35vw;
89 | }
90 |
91 | @media (max-width: 270px) {
92 | .options > a ul {
93 | padding-left: 25vw;
94 | }
95 | }
96 |
97 | @media (max-width: 240px) {
98 | .options > a ul {
99 | padding-left: 10vw;
100 | }
101 | }
102 |
103 | .help-text {
104 | position: relative;
105 | color: rgb(12,12,13);
106 | padding: 1px 4px;
107 | padding-left: 25px;
108 | }
109 |
110 | .help-text::before {
111 | content: " ";
112 | display: inline-block;
113 | position: absolute;
114 | top: 1px;
115 | left: 4px;
116 | width: 16px;
117 | height: 16px;
118 | background-image: url("../img/help-16.svg");
119 | background-repeat: no-repeat;
120 | background-position: center;
121 | }
122 |
123 | #footer {
124 | position: absolute;
125 | left: 0px;
126 | right: 0px;
127 | bottom: 0px;
128 | background-color: var(--bg-color);
129 | box-shadow: 0px 0px 4px 0px var(--bg-color);
130 | padding: 5px 1px;
131 | }
132 |
133 | #footer a {
134 | display: inline-block;
135 | position: relative;
136 | padding: 1px 4px;
137 | border-radius: 2px;
138 | color: rgb(12,12,13);
139 | margin-bottom: 4px;
140 | }
141 |
142 | #footer a:last-child {
143 | margin-bottom: 0px;
144 | }
145 |
146 | #footer a > img:first-child {
147 | position: relative;
148 | top: 3px;
149 | margin-right: 2px;
150 | }
151 |
152 | #footer a:hover {
153 | background-color: rgba(12,12,13,0.1);
154 | }
155 |
156 | @keyframes recommended_fadein {
157 | 0% {
158 | background-color: rgba(12, 12, 13, 0.1);
159 | }
160 |
161 | 100% {
162 | background-color: rgb(10,132,255);
163 | }
164 | }
--------------------------------------------------------------------------------
/src/tab-loader/load.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
10 |
11 | Tabs Aside Legacy Tab Loader
12 |
13 |
14 |
15 | Tabs Aside Legacy Tab Loader
16 | This tab was created by an old version of Tabs Aside.
17 | This page should be replaced with the website automatically so unfortunately something must have gone wrong.
18 | Sorry.
19 |
20 | URL:
21 | load tab manually
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/tab-loader/load.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | const params = new URLSearchParams(document.location.search.substring(1));
3 | const url = new URL(params.get("url"));
4 |
5 | console.log("[TA] Found an unloaded tab from a previous version.", url);
6 |
7 | const a = document.getElementById("load-manually");
8 | a.href = url.href;
9 | document.getElementById("url").textContent = url.href;
10 |
11 | // try to load this URL
12 | window.location.href = url.href;
13 | })();
14 |
--------------------------------------------------------------------------------
/src/ts/background/BrowserTabContextMenu.ts:
--------------------------------------------------------------------------------
1 | import * as SessionManager from "../core/SessionManager.js";
2 | import * as ActiveSessionManager from "../core/ActiveSessionManager.js";
3 | import * as ClassicSessionManager from "../core/ClassicSessionManager.js";
4 | import { Tab, ContextMenuId, Bookmark, SessionId } from "../util/Types.js";
5 | import ActiveSession from "../core/ActiveSession.js";
6 | import TabData from "../core/TabData.js";
7 | import { SessionContentUpdate } from "../messages/Messages.js";
8 | import { createTab } from "../util/WebExtAPIHelpers.js";
9 |
10 | let shown:boolean = false;
11 | let dynamicMenus:ContextMenuId[] = [];
12 |
13 | const parentOptions = {
14 | id: "parent",
15 | contexts: ["tab"] as "tab"[],
16 | icons: {
17 | "16": "img/browserAction/dark.svg",
18 | "32": "img/browserAction/dark.svg",
19 | },
20 | title: browser.i18n.getMessage("tab_contextmenu_title")
21 | };
22 |
23 | export async function init() {
24 | browser.menus.create(parentOptions);
25 |
26 | browser.menus.onShown.addListener(async (info, tab) => {
27 | if(info.contexts.includes("tab")) {
28 | shown = true;
29 |
30 | // get selected/highlighted tabs
31 | let selection = await browser.tabs.query({
32 | currentWindow: true,
33 | highlighted: true
34 | });
35 |
36 | console.assert(selection.length >= 1);
37 |
38 | if(selection.find(t => t.id === tab.id)) {
39 | createMenuForTabs(selection);
40 | } else {
41 | createMenuForTabs([tab]);
42 | }
43 | }
44 | });
45 |
46 | browser.menus.onHidden.addListener(() => {
47 | if(shown) {
48 | shown = false;
49 |
50 | // remove all dynamically added menus
51 | dynamicMenus.forEach(menuId => browser.menus.remove(menuId));
52 | }
53 | });
54 | }
55 |
56 | async function createMenuForTabs(tabs:Tab[]) {
57 | // collect active sessions of selected tabs
58 | let currentSessions = new Set();
59 | let currentSessionIds = new Set();
60 | tabs.forEach(tab => {
61 | let activeSession = ActiveSessionManager.getSessionFromTab(tab);
62 | if(activeSession) {
63 | currentSessions.add(activeSession);
64 | currentSessionIds.add(activeSession.bookmarkId);
65 | }
66 | });
67 |
68 | // get list of sessions (active + non-active)
69 | let sessions:Bookmark[] = await SessionManager.getSessionBookmarks();
70 | let activeSessions:Set = new Set(
71 | ActiveSessionManager.getActiveSessions().map(data => data.bookmarkId)
72 | );
73 |
74 | addToSessionMenu(sessions, currentSessionIds, activeSessions, tabs);
75 |
76 | if(currentSessions.size === 1) {
77 | dynamicMenus.push(browser.menus.create({
78 | parentId: "parent",
79 | id: "set-aside",
80 | title: browser.i18n.getMessage("tab_contextmenu_set_aside"),
81 | onclick: async (info) => {
82 | let currentSession:ActiveSession = currentSessions.values().next().value;
83 |
84 | // set aside all selected/highlighted tabs
85 | for(let tab of tabs) {
86 | await currentSession.setTabAside(tab.id);
87 | }
88 | }
89 | }));
90 | } else if(currentSessions.size === 0) {
91 | addAndSetAsideMenu(sessions, activeSessions, tabs);
92 | }
93 |
94 | if(shown) {
95 | // rebuilding a shown menu is an expensive operation, only invoke this method when necessary
96 | browser.menus.refresh();
97 | }
98 | }
99 |
100 | async function addToSessionMenu(
101 | sessions:Bookmark[],
102 | currentSessionIds:Set,
103 | activeSessions:Set,
104 | tabs:Tab[]
105 | ) {
106 | dynamicMenus.push(
107 | browser.menus.create({
108 | parentId: "parent",
109 | id: "add",
110 | title: browser.i18n.getMessage(tabs.length > 1 ?
111 | "tab_contextmenu_add_multiple" :
112 | "tab_contextmenu_add"
113 | ),
114 | icons: {
115 | "16": "img/browserMenu/add.svg",
116 | "32": "img/browserMenu/add.svg"
117 | }
118 | })
119 | );
120 |
121 | // create new session
122 | browser.menus.create({
123 | parentId: "add",
124 | title: "&create new session",
125 | icons: {
126 | "16": "img/browserMenu/add.svg",
127 | "32": "img/browserMenu/add.svg"
128 | },
129 | onclick: () => ClassicSessionManager.createSession(tabs, false)
130 | });
131 |
132 | if(sessions.length > 0) {
133 | browser.menus.create({
134 | parentId: "add",
135 | type: "separator"
136 | });
137 | }
138 |
139 | // add to existing session
140 | sessions.forEach(session => browser.menus.create({
141 | parentId: "add",
142 | title: "&" + session.title.replace(/&/ig, "&&").trim(),
143 | icons: activeSessions.has(session.id) ? {
144 | "16": "img/browserMenu/active.svg",
145 | "32": "img/browserMenu/active.svg"
146 | } : undefined,
147 | enabled: !currentSessionIds.has(session.id),
148 | onclick: async (info) => {
149 | let added = false;
150 |
151 | // move tabs to active session
152 | if(activeSessions.has(session.id)) {
153 | let as = ActiveSessionManager.getActiveSession(session.id);
154 | console.assert(as, "ActiveSession instance not found.");
155 |
156 | // only if the target session has its own window
157 | if(as.getWindowId() !== null) {
158 | // move or copy tabs to new session
159 | if(currentSessionIds.size === 0) {
160 | for(let tab of tabs) {
161 | await browser.tabs.move(tab.id, {
162 | windowId: as.getWindowId(),
163 | index: tab.pinned ? 0 : -1
164 | });
165 | }
166 | } else {
167 | // duplicate tabs
168 | for(let tab of tabs) {
169 | let details = TabData.createFromTab(tab).getTabCreateProperties(true);
170 | details.windowId = as.getWindowId();
171 | delete details.index;
172 | await createTab(details);
173 | }
174 | }
175 | added = true;
176 | }
177 | }
178 |
179 | // otherwise just create the bookmark
180 | if(!added) {
181 | for(let tab of tabs) {
182 | let createDetails = TabData.createFromTab(tab).getBookmarkCreateDetails(session.id);
183 | delete createDetails.index;
184 | await browser.bookmarks.create(createDetails);
185 | }
186 | }
187 |
188 | // update sidebar
189 | SessionContentUpdate.send(session.id);
190 | }
191 | }));
192 | }
193 |
194 | async function addAndSetAsideMenu(
195 | sessions:Bookmark[],
196 | activeSessions:Set,
197 | tabs:Tab[]
198 | ) {
199 | dynamicMenus.push(
200 | browser.menus.create({
201 | parentId: "parent",
202 | id: "add-n-close",
203 | title: browser.i18n.getMessage("tab_contextmenu_add_and_set_aside"),
204 | })
205 | );
206 |
207 | // ignore active sessions as this operation does not make sense
208 | sessions = sessions.filter(session => !activeSessions.has(session.id));
209 |
210 | // create new session
211 | browser.menus.create({
212 | parentId: "add-n-close",
213 | title: browser.i18n.getMessage("tab_contextmenu_create_new"),
214 | icons: {
215 | "16": "img/browserMenu/add.svg",
216 | "32": "img/browserMenu/add.svg"
217 | },
218 | onclick: () => ClassicSessionManager.createSession(tabs, true)
219 | });
220 |
221 | if(sessions.length > 0) {
222 | browser.menus.create({
223 | parentId: "add-n-close",
224 | type: "separator"
225 | });
226 | }
227 |
228 | sessions.forEach(session => browser.menus.create({
229 | parentId: "add-n-close",
230 | title: "&" + session.title.replace(/&/ig, "&&").trim(),
231 | onclick: async (info) => {
232 | for(let tab of tabs) {
233 | const data = TabData.createFromTab(tab);
234 | let createDetails = data.getBookmarkCreateDetails(session.id);
235 | delete createDetails.index;
236 | await browser.bookmarks.create(createDetails);
237 |
238 | browser.tabs.remove(tab.id);
239 | }
240 |
241 | // update sidebar
242 | SessionContentUpdate.send(session.id);
243 | }
244 | }));
245 | }
246 |
--------------------------------------------------------------------------------
/src/ts/background/KeyboardCommands.ts:
--------------------------------------------------------------------------------
1 | import * as ActiveSessionManager from "../core/ActiveSessionManager.js";
2 | import * as SessionManager from "../core/SessionManager.js";
3 |
4 | export function init() {
5 | browser.commands.onCommand.addListener(async command => {
6 | if (command === "tabs-aside") {
7 |
8 | let [tab] = await browser.tabs.query({
9 | active: true,
10 | currentWindow: true
11 | });
12 |
13 | if(tab === undefined) { return; }
14 |
15 | let session = ActiveSessionManager.getSessionFromTab(tab);
16 |
17 | if(session) {
18 | // tab is part of an active session -> set that session aside
19 | ActiveSessionManager.setAside(session.bookmarkId);
20 | } else {
21 | // set non-active tabs of this window aside (create session)
22 | SessionManager.createSessionFromWindow(true, tab.windowId);
23 | }
24 |
25 | //does not currently work in Firefox:
26 | //browser.sidebarAction.open();
27 | }
28 | });
29 | }
--------------------------------------------------------------------------------
/src/ts/background/Migration.ts:
--------------------------------------------------------------------------------
1 | import { attempt } from "../util/PromiseUtils.js";
2 | import * as OptionsManager from "../options/OptionsManager.js";
3 |
4 | let setupRequired:boolean = false;
5 | let restartRequired:boolean = false;
6 |
7 | export function isSetupRequired():boolean {
8 | return setupRequired;
9 | }
10 |
11 | type StoredData = {
12 | "version"?:number,
13 | "options"?:any, // user options
14 | "setup"?:boolean, // (user) setup completed flag
15 | "bookmarkFolderID"?:string // legacy
16 | "ba-icon"?:string // legacy
17 | };
18 |
19 | const CURRENT_DATA_VERSION = 3;
20 |
21 | export async function run() {
22 | let data:StoredData = await browser.storage.local.get() || {};
23 |
24 | if(data.version === CURRENT_DATA_VERSION && data.setup === true) {
25 | // everything is up to date, nothing has to be done here
26 | return;
27 | }
28 |
29 | setupRequired = data.setup !== true;
30 |
31 | if(data.version && data.version < CURRENT_DATA_VERSION) {
32 | if(data.version === 2) {
33 | await migrateFromVersion3_3_(data);
34 | } else if(data.version == 1) {
35 | setupRequired = true;
36 | await migrateFromOldVersion(data);
37 | }
38 | } else {
39 | setupRequired = true;
40 |
41 | // removing the extension will also remove all the stored data
42 | // lets check if there exists a "Tabs Aside" folder from a previous installation
43 | let folders = (await browser.bookmarks.search({title:"Tabs Aside"}))
44 | .filter(bm => bm.type === "folder");
45 |
46 | if(folders.length > 0) {
47 | console.log("[TA] Found a folder named 'Tabs Aside', probably from a previous installation.");
48 | await OptionsManager.setValue("rootFolder", folders[0].id);
49 | }
50 | }
51 |
52 | await browser.storage.local.set({"version": CURRENT_DATA_VERSION});
53 |
54 | if(restartRequired) {
55 | browser.runtime.reload();
56 | }
57 | }
58 |
59 | async function migrateFromOldVersion(data:StoredData){
60 | console.assert(data.version === 1 || data.version === undefined);
61 |
62 | if(data["bookmarkFolderID"]) {
63 | // this will not be removed to keep old versions working
64 | await attempt(browser.bookmarks.get(data["bookmarkFolderID"]).then(
65 | async (bms) => {
66 | if(bms[0].type === "folder") {
67 | console.log("[TA] Found 'Tabs Aside' folder from a previous installation.");
68 | await OptionsManager.setValue("rootFolder", bms[0].id);
69 | }
70 | }
71 | ));
72 | }
73 |
74 | if(data["ba-icon"]) {
75 | // use old browser action icon setting
76 | if(data["ba-icon"] === "dynamic") {
77 | await OptionsManager.setValue("browserActionContextIcon", true);
78 | }
79 |
80 | browser.storage.local.remove("ba-icon");
81 | }
82 | }
83 |
84 | /**
85 | * Migrate extension data from v3.3.* to {current}
86 | * Changes: option ignorePinned -> asidePinnedTabs (issue #74)
87 | * @param data
88 | */
89 | async function migrateFromVersion3_3_(data:StoredData) {
90 | console.assert(data.version === 2);
91 |
92 | if(data.options.ignorePinned !== undefined) {
93 | console.log("[TA] Switching from ignorePinned to asidePinnedTabs");
94 | restartRequired = true;
95 | await OptionsManager.setValue("asidePinnedTabs", !data.options.ignorePinned);
96 | await OptionsManager.removeUnused();
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/ts/background/WindowFocusHistory.ts:
--------------------------------------------------------------------------------
1 | let focusHistory:number[] = [];
2 |
3 | export async function init():Promise {
4 | let windows = await browser.windows.getAll();
5 | let current = await browser.windows.getLastFocused({
6 | populate: false
7 | });
8 |
9 | if(current.type !== "normal") {
10 | throw new Error("[TA] Unexpected window type");
11 | }
12 |
13 | focusHistory = windows.filter(wnd => wnd.type === "normal")
14 | .filter(wnd => wnd.id !== current.id)
15 | .map(wnd => wnd.id);
16 | focusHistory.unshift(current.id);
17 |
18 | // event listeners
19 |
20 | browser.windows.onRemoved.addListener(removedWndId => {
21 | focusHistory = focusHistory.filter(wndId => wndId !== removedWndId);
22 | });
23 |
24 | browser.windows.onCreated.addListener(wnd => {
25 | if(wnd.type === "normal") {
26 | focusHistory.push(wnd.id);
27 | }
28 | });
29 |
30 | browser.windows.onFocusChanged.addListener(async (focusedWndId) => {
31 | let wnd = await browser.windows.get(focusedWndId);
32 |
33 | if(wnd.type === "normal") {
34 | focusHistory = focusHistory.filter(wndId => focusedWndId);
35 | focusHistory.unshift(focusedWndId);
36 | }
37 | });
38 | }
39 |
40 | type WindowId = number;
41 |
42 | export function get(index:number):WindowId {
43 | if(focusHistory.length > index) {
44 | return focusHistory[index];
45 | } else {
46 | return browser.windows.WINDOW_ID_NONE;
47 | }
48 | }
49 |
50 | export function getPreviousWindow():WindowId {
51 | return get(1);
52 | }
53 |
--------------------------------------------------------------------------------
/src/ts/background/background.ts:
--------------------------------------------------------------------------------
1 | import * as SessionManager from "../core/SessionManager.js";
2 | import { Message, SessionCommand, DataRequest, BackgroundPing, ExtensionCommand } from "../messages/Messages.js";
3 | import * as BrowserActionManager from "../browserAction/BrowserActionManager.js";
4 | import * as KeyboardCommands from "./KeyboardCommands.js";
5 | import * as MessageListener from "../messages/MessageListener.js";
6 | import * as BrowserTabContextMenu from "./BrowserTabContextMenu.js";
7 | import * as Migration from "./Migration.js";
8 |
9 | MessageListener.setDestination("background");
10 |
11 | (async function(){
12 | await Migration.run();
13 | BrowserActionManager.init();
14 |
15 | if(Migration.isSetupRequired()) {
16 | BrowserActionManager.showSetup();
17 | browser.sidebarAction.setPanel({
18 | panel: browser.runtime.getURL("html/user-setup.html")
19 | });
20 | return;
21 | }
22 |
23 | KeyboardCommands.init();
24 |
25 | SessionManager.init().then(() => {
26 | MessageListener.add("*", (message:Message) => {
27 | if(message.type === "SessionCommand") {
28 | let cmd:SessionCommand = message as SessionCommand;
29 | return SessionManager.execCommand(cmd);
30 | }
31 |
32 | return Promise.resolve();
33 | });
34 |
35 | browser.runtime.onMessage.addListener((message:Message) => {
36 | if(message.type === "DataRequest") {
37 | let req:DataRequest = message as DataRequest;
38 |
39 | return SessionManager.dataRequest(req);
40 | } else if(message.type === "Ping") {
41 | return Promise.resolve(BackgroundPing.RESPONSE);
42 | }
43 |
44 | return Promise.resolve();
45 | });
46 | }, e => {
47 | console.error("[TA] Failed to initialize SessionManager.", e);
48 | });
49 |
50 | // if sidebar was already open it has to be reloaded
51 | // because the background page was unable to send any data
52 | if(await browser.sidebarAction.isOpen({})) {
53 | let m = new ExtensionCommand("sidebar", "reload");
54 | browser.runtime.sendMessage(m).catch(e => {
55 | console.error("[TA] Failed to send reload command to sidebar.", e);
56 | });
57 | }
58 |
59 | BrowserTabContextMenu.init();
60 | })();
61 |
--------------------------------------------------------------------------------
/src/ts/bookmark-selector/Controller.ts:
--------------------------------------------------------------------------------
1 | import * as Model from "./Model.js";
2 | import * as View from "./View.js";
3 | import * as OptionsManager from "../options/OptionsManager.js";
4 |
5 | const params = new URLSearchParams(document.location.search.substring(1));
6 |
7 | // is there a selected folder?
8 | var initPromise = Model.init(params.get("option"));
9 |
10 | Promise.all([
11 | initPromise,
12 | new Promise(resolve => {
13 | window.addEventListener("load", () => {
14 | View.init();
15 |
16 | resolve();
17 | });
18 | })
19 | ]).then(_ => View.update());
20 |
21 | export async function select() {
22 | if (Model.selectedFolderID) {
23 | await OptionsManager.setValue(
24 | Model.OptionId,
25 | Model.selectedFolderID
26 | );
27 |
28 | // close this window
29 | let wnd:browser.windows.Window = await browser.windows.getCurrent();
30 | browser.windows.remove(wnd.id);
31 | } else {
32 | alert(browser.i18n.getMessage("bookmarkFolderSelector_noSelection"));
33 | }
34 | }
--------------------------------------------------------------------------------
/src/ts/bookmark-selector/Model.ts:
--------------------------------------------------------------------------------
1 | import * as View from "./View.js";
2 | import * as OptionsManager from "../options/OptionsManager.js";
3 | import { attempt } from "../util/PromiseUtils.js";
4 |
5 | type BMTreeNode = browser.bookmarks.BookmarkTreeNode;
6 |
7 | export var selectedFolderID:string = "";
8 | export var oldSelectedFolderID:string = "";
9 | // folders in breadcrumbs:
10 | var bcrumbs:BMTreeNode[] = [];
11 | // folders in current view / folder
12 | var folders:BMTreeNode[] = [];
13 |
14 | var rootId:string;
15 |
16 | export var OptionId:string;
17 |
18 | export var FolderNamePreset:string = "Tabs Aside";
19 |
20 | function makeBreadcrumbs(folder:BMTreeNode|BMTreeNode[]):Promise {
21 | if (folder instanceof Array) { folder = folder[0]; }
22 |
23 | bcrumbs.unshift(folder);
24 |
25 | if (folder.parentId) {
26 | return browser.bookmarks.get(folder.parentId).then(makeBreadcrumbs);
27 | } else {
28 | // this is the root node
29 | rootId = folder.id;
30 | }
31 |
32 | return Promise.resolve();
33 | }
34 |
35 | export async function init(option:string):Promise {
36 | OptionId = option;
37 |
38 | let id = await OptionsManager.getValue(option);
39 | let folder:BMTreeNode;
40 |
41 | if (id) {
42 | await attempt(browser.bookmarks.get(id).then(res => {
43 | folder = res[0];
44 | selectedFolderID = id;
45 | oldSelectedFolderID = id;
46 | }));
47 | }
48 |
49 | if (!folder) {
50 | console.log("[TA] Bookmark selector: root folder fallback");
51 | folder = (await browser.bookmarks.getTree())[0];
52 | }
53 |
54 | console.assert(folder);
55 |
56 | if (folder.parentId) {
57 | await browser.bookmarks.get(folder.parentId).then(makeBreadcrumbs);
58 | } else {
59 | // it's the root folder
60 | bcrumbs.push(folder);
61 | }
62 |
63 | console.assert(bcrumbs.length > 0, "[TA] No folder!");
64 |
65 | folder = bcrumbs[bcrumbs.length - 1];
66 | folders = await getSubFolders(folder);
67 | }
68 |
69 | export function select(folderId:string, updateView:boolean = true):void {
70 | selectedFolderID = folderId;
71 |
72 | if(updateView) {
73 | View.update();
74 | }
75 | }
76 |
77 | export function clearSelection(updateView:boolean = true) {
78 | selectedFolderID = "";
79 |
80 | if(updateView) {
81 | View.update();
82 | }
83 | }
84 |
85 | export async function getSubFolders(bmFolderNode:BMTreeNode):Promise {
86 | if (bmFolderNode.children === undefined) {
87 | bmFolderNode.children = await browser.bookmarks.getChildren(bmFolderNode.id);
88 | }
89 |
90 | return Promise.resolve(
91 | bmFolderNode.children.filter(bm => bm.type === "folder")
92 | );
93 | }
94 |
95 | export function refreshChildren():Promise {
96 | return new Promise(async (resolve, reject) => {
97 | if (bcrumbs.length == 0) { reject("Invalid state"); }
98 |
99 | let f = bcrumbs[bcrumbs.length - 1];
100 |
101 | f.children = undefined;
102 |
103 | folders = await getSubFolders(f);
104 |
105 | resolve();
106 | });
107 | }
108 |
109 | export function navOpenFolder(fIndex:number):Promise {
110 | return new Promise(async (resolve,reject) => {
111 | if (folders[fIndex]) {
112 | let f = folders[fIndex];
113 | bcrumbs.push(f);
114 | folders = await getSubFolders(f);
115 |
116 | View.update();
117 | resolve();
118 | } else {
119 | reject("Invalid folder index!");
120 | }
121 | });
122 | }
123 |
124 | export function navUp():Promise {
125 | return new Promise(async (resolve, reject) => {
126 | if (bcrumbs.length > 1) {
127 | bcrumbs.pop();
128 | let f = bcrumbs[bcrumbs.length - 1];
129 | folders = await getSubFolders(f);
130 |
131 | View.update();
132 | resolve();
133 | } else {
134 | reject("Calm down. Can't go up that high!");
135 | }
136 | });
137 | }
138 |
139 | export function navBreadcrumb(bcIndex:number):Promise {
140 | return new Promise(async (resolve, reject) => {
141 | if (bcrumbs[bcIndex]) {
142 | bcrumbs.length = bcIndex + 1;
143 | folders = await getSubFolders(bcrumbs[bcIndex]);
144 |
145 | View.update();
146 | resolve();
147 | } else {
148 | reject("No such breadcrumb :/");
149 | }
150 | });
151 | }
152 |
153 | export function createFolder(name:string):Promise {
154 | return browser.bookmarks.create({
155 | title: name,
156 | type: "folder",
157 | parentId: bcrumbs[bcrumbs.length - 1].id
158 | }).then(bmFolder => {
159 | // auto-select new folder
160 | select(bmFolder.id, false);
161 |
162 | return refreshChildren();
163 | }, e => {
164 | alert("Error: Folder was not created.");
165 | console.error(e+"");
166 | }).then(() => {
167 | View.update();
168 | });
169 | }
170 |
171 | export function getFolders():browser.bookmarks.BookmarkTreeNode[] {
172 | return folders;
173 | }
174 |
175 | export function getBreadcrumbs():browser.bookmarks.BookmarkTreeNode[] {
176 | return bcrumbs;
177 | }
178 |
179 | export function getCurrentFolder():browser.bookmarks.BookmarkTreeNode {
180 | console.assert(bcrumbs.length > 0);
181 |
182 | return bcrumbs[bcrumbs.length - 1];
183 | }
184 |
185 | export function isRoot(folder:browser.bookmarks.BookmarkTreeNode):boolean {
186 | return folder && folder.id === rootId;
187 | }
188 |
--------------------------------------------------------------------------------
/src/ts/bookmark-selector/View.ts:
--------------------------------------------------------------------------------
1 | import * as Model from "./Model.js";
2 | import * as Controller from "./Controller.js";
3 |
4 | // DOM references
5 | var folderView:HTMLElement;
6 | var breadcrumbsView:HTMLElement;
7 | var selectButton:HTMLElement;
8 | var newFolderButton:HTMLElement;
9 |
10 | var selectedFolder = null;
11 |
12 | export function init():void {
13 | // get DOM references
14 | folderView = document.getElementById("content-view");
15 | breadcrumbsView = document.getElementById("breadcrumbs");
16 | selectButton = document.getElementById("select");
17 | newFolderButton = document.getElementById("newfolder");
18 |
19 | // i18n
20 | selectButton.innerText = browser.i18n.getMessage("bookmarkFolderSelector_select");
21 | newFolderButton.innerText = browser.i18n.getMessage("bookmarkFolderSelector_newFolderTooltip");
22 |
23 | // set up event listeners
24 | selectButton.addEventListener("click", Controller.select);
25 |
26 | newFolderButton.addEventListener("click", () => {
27 | if(Model.isRoot(Model.getCurrentFolder())) {
28 | alert(browser.i18n.getMessage("bookmarkFolderSelector_rootError"));
29 | return;
30 | }
31 |
32 | // prompt does not work on mobile browsers
33 | let folderName = window.prompt(
34 | browser.i18n.getMessage("bookmarkFolderSelector_newFolderDialog"),
35 | Model.FolderNamePreset
36 | );
37 |
38 | // check if prompt was aborted
39 | if (folderName === null) { return; }
40 |
41 | Model.clearSelection(false);
42 |
43 | Model.createFolder(folderName || Model.FolderNamePreset);
44 | });
45 | }
46 |
47 | export function unselect():void {
48 | if (selectedFolder) {
49 | selectedFolder.classList.remove("selected");
50 | }
51 |
52 | selectedFolder = null;
53 | }
54 |
55 | export function update():void {
56 | // update folder view
57 | folderView.innerHTML = "";
58 |
59 | Model.getFolders().forEach((folder,i) => {
60 | let folderDIV = document.createElement("div");
61 | folderDIV.classList.add("folder");
62 |
63 | folderDIV.textContent = folder.title;
64 | folderDIV.title = folder.title + ` (id: ${folder.id})`;
65 |
66 | if (folder.id === Model.selectedFolderID) {
67 | folderDIV.classList.add("selected");
68 | selectedFolder = folderDIV;
69 | }
70 |
71 | if (folder.id === Model.oldSelectedFolderID) {
72 | folderDIV.classList.add("oldselection");
73 | }
74 |
75 | folderDIV.addEventListener("click", () => {
76 | unselect();
77 | Model.select(folder.id, false);
78 | selectedFolder = folderDIV;
79 | folderDIV.classList.add("selected");
80 | });
81 |
82 | folderDIV.addEventListener("dblclick", e => {
83 | e.stopPropagation();
84 |
85 | Model.clearSelection(false);
86 | Model.navOpenFolder(i);
87 |
88 | return false;
89 | });
90 |
91 | folderView.appendChild(folderDIV);
92 | });
93 |
94 | // update breadcrumbs view
95 | breadcrumbsView.innerHTML = "";
96 |
97 | let bcrumbs = Model.getBreadcrumbs();
98 |
99 | bcrumbs.forEach((bc,i) => {
100 | let bcDIV = document.createElement("div");
101 | bcDIV.classList.add("breadcrumb");
102 |
103 | let name = (i === 0) ? "root" : bc.title;
104 | bcDIV.textContent = name;
105 |
106 | if (i < bcrumbs.length - 1) {
107 | bcDIV.addEventListener("click", () => {
108 | Model.clearSelection(false);
109 |
110 | Model.navBreadcrumb(i);
111 | });
112 | }
113 |
114 | breadcrumbsView.appendChild(bcDIV);
115 | });
116 | }
--------------------------------------------------------------------------------
/src/ts/browserAction/BrowserActionManager.ts:
--------------------------------------------------------------------------------
1 | import * as OptionManager from "../options/OptionsManager.js";
2 | import { OptionUpdateEvent } from "../messages/Messages.js";
3 | import * as MessageListener from "../messages/MessageListener.js";
4 | import * as ActiveSessionManager from "../core/ActiveSessionManager.js";
5 |
6 | let badgeColor:string = "#0A84FF";
7 |
8 | export async function init() {
9 | if(await OptionManager.getValue("browserActionContextIcon")) {
10 | updateIcon("context.svg");
11 | }
12 |
13 | browser.browserAction.setTitle({
14 | title: `Tabs Aside ${browser.runtime.getManifest().version}`
15 | });
16 |
17 | updateBadge();
18 |
19 | MessageListener.setDestination("background");
20 | MessageListener.add("OptionUpdate", (msg:OptionUpdateEvent) => {
21 | if(msg.key === "browserActionContextIcon") {
22 | if(msg.newValue as boolean) {
23 | updateIcon("context.svg");
24 | } else {
25 | updateIcon();
26 | }
27 | }
28 | });
29 | }
30 |
31 | export async function updateBadge() {
32 | let sessions = ActiveSessionManager.getActiveSessions();
33 | let n:number = sessions.filter(session => !session.windowId).length;
34 |
35 | let text:string = (n>0) ? n+"" : "";
36 |
37 | // set global badge color
38 | browser.browserAction.setBadgeBackgroundColor({
39 | color: badgeColor
40 | });
41 |
42 | await browser.browserAction.setBadgeText({ text: text });
43 |
44 | // get window ids of active sessions
45 | let windows = sessions.reduce(
46 | (wnds, session) => {
47 | if(session.windowId) {
48 | wnds.add(session.windowId);
49 | }
50 | return wnds;
51 | },
52 | new Set()
53 | );
54 |
55 | // hide badge on active session windows
56 | windows.forEach(windowId => browser.browserAction.setBadgeText({
57 | text: "",
58 | windowId: windowId
59 | }));
60 | }
61 |
62 | function updateIcon(newIcon?:string):Promise {
63 | let p:Promise;
64 |
65 | if(newIcon) {
66 | let iconPath:string = "../img/browserAction/" + newIcon;
67 |
68 | p = browser.browserAction.setIcon({
69 | path: {
70 | "16": iconPath,
71 | "32": iconPath
72 | }
73 | });
74 | } else {
75 | p = browser.browserAction.setIcon({});
76 | }
77 |
78 | return p.catch(e => console.error("[TA] Error updating icon:\n" + e));
79 | }
80 |
81 | export function showSetup():Promise {
82 | return browser.browserAction.setPopup({
83 | popup: browser.runtime.getURL("html/menu/setup.html")
84 | }).then(
85 | () => {
86 | // delay badge text update because otherwise it sometimes does not do anything
87 | window.setTimeout(
88 | () => browser.browserAction.setBadgeText({text:"!"}),
89 | 750
90 | );
91 | }
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/ts/browserAction/Menu.ts:
--------------------------------------------------------------------------------
1 | import MenuItems from "./MenuItems.js";
2 | import { MenuItem } from "./MenuItemType.js";
3 | import { StateInfoData, DataRequest } from "../messages/Messages.js";
4 | import { $$ } from "../util/HTMLUtilities.js";
5 |
6 | document.addEventListener("DOMContentLoaded", async () => {
7 |
8 | // get DOM references
9 | const activeSessionIndicator = $$("active-session-indicator");
10 | const buttonsContainer = $$("buttons");
11 |
12 | const stateInfo:StateInfoData = await DataRequest.send("state-info");
13 |
14 | if(stateInfo.currentSession) {
15 | // current tab is part of an active session
16 | document.body.classList.add("active-session");
17 | activeSessionIndicator.innerText = browser.i18n.getMessage(
18 | "menu_active-session-indicator",
19 | [stateInfo.currentSession.title]
20 | );
21 | activeSessionIndicator.title = browser.i18n.getMessage("menu_active-session-indicator_tooltip");
22 | }
23 |
24 | // create buttons
25 | MenuItems.forEach(
26 | item => buttonsContainer.appendChild(createButton(item, stateInfo))
27 | );
28 | });
29 |
30 | function createButton(item:MenuItem, state:StateInfoData):HTMLAnchorElement {
31 | let button:HTMLAnchorElement = document.createElement("a");
32 |
33 | if(item.hide && item.hide(state)) {
34 | button.style.display = "none";
35 | return button;
36 | }
37 |
38 | button.classList.add("button");
39 | button.innerText = browser.i18n.getMessage("menu_" + item.id + "_label") || item.id;
40 |
41 | if(item.icon) {
42 | let iconURL:string = "../../img/menu/" + item.icon;
43 | button.style.setProperty("--iconURL", `url('${iconURL}')`);
44 | button.classList.add("icon");
45 |
46 | if(item.wideIcon) {
47 | button.classList.add("wide");
48 | }
49 | }
50 |
51 | if(item.shortcut) {
52 | button.dataset.shortcut = item.shortcut;
53 | }
54 |
55 | if(item.tooltip) {
56 | button.title = browser.i18n.getMessage("menu_" + item.id + "_tooltip");
57 | }
58 |
59 | let enabled:boolean = item.isApplicable ? item.isApplicable(state) : true;
60 |
61 | if(!enabled) {
62 | button.classList.add("disabled");
63 | button.title = browser.i18n.getMessage("menu_action_not_applicable");
64 | }
65 |
66 | if(enabled && item.href) {
67 | button.href = item.href;
68 | }
69 |
70 | if(enabled && item.onclick) {
71 | button.addEventListener("click", e => {
72 | item.onclick(state);
73 |
74 | if(item.closeMenu !== false) {
75 | window.close();
76 | }
77 | });
78 | }
79 |
80 | return button;
81 | }
82 |
--------------------------------------------------------------------------------
/src/ts/browserAction/MenuItemType.d.ts:
--------------------------------------------------------------------------------
1 | import { StateInfoData } from "../messages/Messages.js";
2 |
3 | export interface MenuItem {
4 | id: string;
5 | optional?:boolean; // default: false
6 | shortcut?:string;
7 | icon?:string;
8 | wideIcon?:boolean; // default: false
9 | onclick?: (state:StateInfoData) => void;
10 | closeMenu?:boolean; // default: true
11 | tooltip?:boolean; // default: false
12 | href?:string;
13 | isApplicable?:(state:StateInfoData) => boolean;
14 | hide?:(state:StateInfoData) => boolean;
15 | }
--------------------------------------------------------------------------------
/src/ts/browserAction/MenuItems.ts:
--------------------------------------------------------------------------------
1 | import { MenuItem } from "./MenuItemType.js";
2 | import { SessionCommand } from "../messages/Messages.js";
3 | import { SessionId } from "../util/Types.js";
4 | import { getCurrentWindowId } from "../util/WebExtAPIHelpers.js";
5 | import { getCommandByName } from "../util/WebExtAPIHelpers.js";
6 | import * as OptionsManager from "../options/OptionsManager.js";
7 |
8 | let autoOpenSidebar = false;
9 | let showSessions:MenuItem, tabsAside:MenuItem, setAside:MenuItem;
10 |
11 | let menuItems:MenuItem[] = [
12 | showSessions = {
13 | id: "show-sessions",
14 | icon: "tabs.svg",
15 | wideIcon: true,
16 | onclick: () => browser.sidebarAction.open()
17 | },
18 | tabsAside = {
19 | id: "tabs-aside",
20 | icon: "aside.svg",
21 | tooltip: true,
22 | onclick: async () => {
23 | if(autoOpenSidebar) {
24 | // the sidebar can only be opened as
25 | // a direct response to a user event
26 | browser.sidebarAction.open();
27 | }
28 |
29 | SessionCommand.send("create", {
30 | windowId: await getCurrentWindowId(),
31 | setAside: true
32 | });
33 | },
34 | isApplicable: (state) => state.availableTabs > 0,
35 | hide: (state) => state.currentSession !== undefined
36 | },
37 | setAside = {
38 | id: "set-aside",
39 | icon: "aside.svg",
40 | wideIcon: true,
41 | tooltip: true,
42 | onclick: (state) => {
43 | let sessionId:SessionId = state.currentSession.bookmarkId;
44 |
45 | if(state.currentSession.windowId && state.previousWindowId !== browser.windows.WINDOW_ID_NONE) {
46 | browser.windows.update(state.previousWindowId, {focused:true});
47 |
48 | /* If we wait for the update promise we are not allowed
49 | * to call sidebarAction.open() anymore :(
50 | * So lets waste some time and hope it works...
51 | */
52 | var n=0;
53 | for(let i=0; i<4200; i++) {
54 | if(Math.random()<0.5) {
55 | n++;
56 | }
57 | }
58 |
59 | if(n>4200 /*false*/) {
60 | throw new Error();
61 | }
62 | }
63 |
64 | browser.sidebarAction.open();
65 | SessionCommand.send("set-aside", {sessionId: sessionId});
66 | },
67 | hide: (state) => state.currentSession === undefined
68 | },
69 | {
70 | id: "create-session",
71 | tooltip: true,
72 | onclick: async () => {
73 | SessionCommand.send("create", {
74 | windowId: await getCurrentWindowId(),
75 | setAside: false
76 | });
77 | },
78 | isApplicable: (state) => state.availableTabs > 0,
79 | hide: (state) => state.availableTabs === 0
80 | },
81 | {
82 | id: "options",
83 | icon: "options-16.svg",
84 | optional: true,
85 | onclick: () => browser.runtime.openOptionsPage()
86 | }
87 | ];
88 |
89 | (async () => {
90 | // load auto-open config
91 | autoOpenSidebar = await OptionsManager.getValue("sidebarAutoOpen");
92 |
93 | // load keyboard shortcuts
94 | let sidebarCmd = await getCommandByName("_execute_sidebar_action");
95 | let asideCmd = await getCommandByName("tabs-aside");
96 |
97 | showSessions.shortcut = sidebarCmd.shortcut;
98 | tabsAside.shortcut = asideCmd.shortcut;
99 | setAside.shortcut = tabsAside.shortcut;
100 | })();
101 |
102 | export default menuItems;
--------------------------------------------------------------------------------
/src/ts/browserAction/SetupLauncher.ts:
--------------------------------------------------------------------------------
1 | import {i18n, DOMReady} from "../util/HTMLUtilities.js";
2 |
3 | (async function(){
4 | await DOMReady();
5 | i18n();
6 |
7 | let button = document.getElementById("launch-setup");
8 |
9 | button.addEventListener("click", () => {
10 | /*await*/ browser.sidebarAction.setPanel({panel:browser.runtime.getURL("html/user-setup.html")});
11 | browser.sidebarAction.open();
12 | window.close();
13 | });
14 |
15 | browser.browserAction.setBadgeText({text:null});
16 | })();
17 |
--------------------------------------------------------------------------------
/src/ts/browserAction/TabSelector.ts:
--------------------------------------------------------------------------------
1 | import * as HTMLUtils from "../util/HTMLUtilities.js";
2 | import { Tab } from "../util/Types.js";
3 |
4 | var selectionHTML:HTMLElement;
5 |
6 | function getTabs(selected?:boolean):Promise {
7 | return browser.tabs.query({
8 | currentWindow: true,
9 | highlighted: selected
10 | });
11 | }
12 |
13 | async function invertSelection() {
14 | let tabs = await getTabs();
15 |
16 | await Promise.all(
17 | tabs.map(tab =>
18 | browser.tabs.update(tab.id, {
19 | active: tab.active,
20 | highlighted: tab.active || !tab.highlighted
21 | })
22 | )
23 | );
24 |
25 | updateView();
26 | }
27 |
28 | async function unSelectAll() {
29 | let tabs = await getTabs(true);
30 |
31 | if(tabs.length > 0) {
32 | await Promise.all(
33 | tabs.map(tab => browser.tabs.update(tab.id, {highlighted:false}))
34 | );
35 |
36 | updateView();
37 | }
38 | }
39 |
40 | async function selectAll() {
41 | let tabs = await getTabs(false);
42 |
43 | if(tabs.length > 0) {
44 | await Promise.all(
45 | tabs.map(tab => browser.tabs.update(tab.id, {
46 | active: tab.active,
47 | highlighted: true
48 | }))
49 | );
50 |
51 | updateView();
52 | }
53 | }
54 |
55 | async function updateView() {
56 | let tabs = await getTabs();
57 |
58 | let n:number = tabs.reduce(
59 | (acc:number, tab:Tab) => tab.highlighted ? acc+1 : acc,
60 | 0
61 | );
62 |
63 | let text:string = browser.i18n.getMessage("tab_selector_selection", [n, tabs.length]);
64 |
65 | selectionHTML.textContent = text;
66 | }
67 |
68 | async function getSelectedIds():Promise {
69 | let tabs = await getTabs(true);
70 |
71 | return tabs.map(tab => tab.id);
72 | }
73 |
74 | function showMultiSelectWarning() {
75 | document.getElementById("ms_error").classList.add("show");
76 | }
77 |
78 | (async function() {
79 | await HTMLUtils.DOMReady();
80 |
81 | // apply localization
82 | HTMLUtils.i18n();
83 |
84 | selectionHTML = document.getElementById("selection");
85 | updateView();
86 |
87 | // selection control buttons
88 | let selectControls:HTMLElement = document.getElementById("select-controls");
89 | selectControls.querySelector("#select-all").addEventListener("click", selectAll);
90 | selectControls.querySelector("#clear").addEventListener("click", unSelectAll);
91 | selectControls.querySelector("#invert").addEventListener("click", invertSelection);
92 |
93 | // keyboard input listener
94 | window.addEventListener("keydown", async (e) => {
95 | if(e.keyCode == 65 && e.ctrlKey) { // CTRL + A
96 | e.preventDefault();
97 | e.stopPropagation();
98 |
99 | if((await getTabs(false)).length) {
100 | selectAll();
101 | } else {
102 | unSelectAll();
103 | }
104 | } else if(e.keyCode == 73 && e.ctrlKey) { // CTRL + I
105 | e.preventDefault();
106 | e.stopPropagation();
107 |
108 | invertSelection();
109 | }
110 | });
111 |
112 | // test if multiselect is available
113 | let tabs = await getTabs(false);
114 | if(tabs.length > 0) {
115 | let testTab = tabs[0];
116 | browser.tabs.update(testTab.id, {
117 | active:false, highlighted:true
118 | }).then(() => {
119 | // undo
120 | browser.tabs.update(testTab.id, {highlighted:false});
121 | }, e => {
122 | showMultiSelectWarning();
123 | });
124 | }
125 |
126 | })();
--------------------------------------------------------------------------------
/src/ts/core/ClassicSessionManager.ts:
--------------------------------------------------------------------------------
1 | import * as OptionsManager from "../options/OptionsManager.js";
2 | import TabData from "./TabData.js";
3 | import { Tab, Window, Bookmark, SessionId } from "../util/Types.js";
4 | import { SessionEvent, SessionContentUpdate } from "../messages/Messages.js";
5 | import { createTab } from "../util/WebExtAPIHelpers.js";
6 | import { generateSessionTitle } from "./SessionTitleGenerator.js";
7 |
8 | export async function createSession(
9 | tabs:Tab[],
10 | setAside:boolean,
11 | sessionName?:string
12 | ):Promise {
13 | const rootFolderId:string = await OptionsManager.getValue("rootFolder");
14 |
15 | if(!sessionName) {
16 | sessionName = await generateSessionTitle();
17 | }
18 |
19 | const sessionBookmark:Bookmark = await browser.bookmarks.create({
20 | title: sessionName||"session",//TODO
21 | type: "folder",
22 | parentId: rootFolderId,
23 | index: 0
24 | });
25 |
26 | const sessionId = sessionBookmark.id;
27 |
28 | for(let i=0; i {
52 | // let the browser handle these requests simultaneously
53 | let [[tabBookmark], openInNewWindow, lazyLoading] = await Promise.all([
54 | browser.bookmarks.getSubTree(sessionId),
55 | OptionsManager.getValue("windowedSession"),
56 | OptionsManager.getValue("lazyLoading")
57 | ]);
58 |
59 | let tabBookmarks:Bookmark[] = tabBookmark.children;
60 | let newTabId:number;
61 |
62 | if(openInNewWindow) {
63 | // create window for the tabs
64 | let wnd:Window = await browser.windows.create();
65 | newTabId = wnd.tabs[0].id;
66 | // avoid conflicts with pinned tabs
67 | await browser.tabs.update(newTabId, {pinned: true});
68 |
69 | if(keepBookmarks) {
70 | browser.sessions.setWindowValue(wnd.id, "sessionID", this.bookmarkId);
71 | } else {
72 | browser.sessions.setWindowValue(wnd.id, "sessionTitle", tabBookmark.title);
73 | }
74 | }
75 |
76 | // create tabs
77 | await Promise.all(
78 | tabBookmarks.map(bm => {
79 | let data:TabData = TabData.createFromBookmark(bm);
80 | let createProperties = data.getTabCreateProperties();
81 |
82 | if(!lazyLoading && createProperties.discarded) {
83 | createProperties.discarded = false;
84 | }
85 |
86 | if(newTabId !== undefined) {
87 | createProperties.index += 1;
88 | }
89 |
90 | return createTab(createProperties);
91 | })
92 | );
93 |
94 | // remove "new tab" tab that gets created automatically when creating a new window
95 | if(newTabId) {
96 | browser.tabs.remove(newTabId);
97 | }
98 |
99 | // (optional) remove bookmarks
100 | if(!keepBookmarks) {
101 | await browser.bookmarks.removeTree(sessionId);
102 | SessionEvent.send(sessionId, "removed");
103 | }
104 | }
105 |
106 | export async function removeSession(sessionId:SessionId):Promise {
107 | // remove bookmarks
108 | await browser.bookmarks.removeTree(sessionId);
109 |
110 | // update views
111 | SessionEvent.send(sessionId, "removed");
112 | }
113 |
114 | export async function removeTabFromSession(tabBookmark:Bookmark):Promise {
115 | let sessionId:string = tabBookmark.parentId;
116 |
117 | await browser.bookmarks.remove(tabBookmark.id);
118 |
119 | let tabs:Bookmark[] = await browser.bookmarks.getChildren(sessionId);
120 |
121 | if(tabs.length === 0) {
122 | removeSession(sessionId);
123 | } else {
124 | // update views
125 | SessionContentUpdate.send(sessionId);
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/ts/core/SessionTitleGenerator.ts:
--------------------------------------------------------------------------------
1 | import * as OptionsManager from "../options/OptionsManager.js";
2 | import { formatDate } from "../util/StringUtils.js";
3 |
4 | export async function generateSessionTitle():Promise {
5 | let title = await OptionsManager.getValue("sessionTitleTemplate");
6 |
7 | if(title.includes("$")) {
8 | title = formatDate(title, new Date());
9 | }
10 |
11 | return title;
12 | }
13 |
--------------------------------------------------------------------------------
/src/ts/core/TabData.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Tab,
3 | Bookmark,
4 | TabCreateProperties,
5 | BookmarkCreateDetails,
6 | BookmarkChanges
7 | } from "../util/Types";
8 |
9 | type TitleData = {
10 | title:string,
11 | flags:Set
12 | variables:Map
13 | };
14 |
15 | /**
16 | * Regular expression that parses tab options and title from a bookmark title.
17 | * Example bookmark title: "[pinned,reading] Actual title"
18 | *
19 | * possible flags:
20 | * reading: reading mode
21 | * pinned: whether the tab is pinned or not
22 | * src: source code view
23 | *
24 | * possible variables:
25 | * cs: (string) cookieStoreId, for containers
26 | */
27 | const bmTitleParser = /^(\[(reading,|pinned,|src,|cs=[-\w]+?,)*(reading|pinned|src|cs=[-\w]+?)?\]\s)?(.*)$/;
28 | const validURL = /^(https?|moz-extension):\/\//i;
29 |
30 | const readerPrefix = "about:reader?url=";
31 | const viewSourcePrefix = "view-source:";
32 | const defaultCookieStoreId = "firefox-default";
33 |
34 | interface TabDetails {
35 | pinned:boolean;
36 | isInReaderMode:boolean;
37 | title:string;
38 | url:string;
39 | favIconUrl:string|undefined;
40 | viewSource:boolean;
41 | cookieStoreId:string|undefined;
42 | index:number;
43 | }
44 |
45 | export default class TabData {
46 | public pinned:boolean;
47 | public isInReaderMode:boolean;
48 | public title:string;
49 | public url:string;
50 | public favIconUrl:string|undefined;
51 | public viewSource:boolean;
52 | public cookieStoreId:string|undefined;
53 | public index:number;
54 |
55 | private constructor(details:TabDetails) {
56 | this.pinned = details.pinned;
57 | this.isInReaderMode = details.isInReaderMode;
58 | this.title = details.title;
59 | this.url = details.url;
60 | this.favIconUrl = details.favIconUrl;
61 | this.viewSource = details.viewSource;
62 | this.cookieStoreId = details.cookieStoreId;
63 | this.index = details.index;
64 |
65 | let title = details.title.trim();
66 | this.title = title === "" ? this.getHostname() : title;
67 |
68 | // TabData instances should be immutable
69 | Object.freeze(this);
70 | }
71 |
72 | public static createFromTab(tab:Tab):TabData {
73 | let details = {
74 | pinned: tab.pinned,
75 | title: tab.title,
76 | url: tab.url,
77 | favIconUrl: tab.favIconUrl,
78 | viewSource: false,
79 | index: tab.index,
80 | cookieStoreId: undefined,
81 | isInReaderMode: false
82 | };
83 |
84 |
85 | if(tab.cookieStoreId !== defaultCookieStoreId) {
86 | details.cookieStoreId = tab.cookieStoreId;
87 | }
88 |
89 | if(tab.isInReaderMode) {
90 | details.isInReaderMode = true;
91 | // URL format
92 | // "about:reader?url=https%3A%2F%2Fexample.com%2Freader-compatible-page"
93 | details.url = decodeURIComponent(
94 | tab.url.substr(readerPrefix.length)
95 | );
96 | }
97 |
98 | if(tab.url.startsWith(viewSourcePrefix)) {
99 | details.url = tab.url.substr(viewSourcePrefix.length);
100 | details.viewSource = true;
101 | }
102 |
103 | return new TabData(details);
104 | }
105 |
106 | public static createFromBookmark(bookmark:Bookmark):TabData {
107 | let data:TitleData = this.decodeTitle(bookmark.title);
108 |
109 | let details = {
110 | url: bookmark.url,
111 | title: data.title,
112 | pinned: data.flags.has("pinned"),
113 | isInReaderMode: data.flags.has("reading"),
114 | viewSource: false,
115 | index: bookmark.index,
116 | favIconUrl: this.getFavIconURL(bookmark.url),
117 | cookieStoreId: undefined
118 | };
119 |
120 | if(data.flags.has("src")) {
121 | details.url = viewSourcePrefix + bookmark.url;
122 | details.viewSource = true;
123 | }
124 |
125 | if(data.variables.has("cs")) {
126 | details.cookieStoreId = data.variables.get("cs");
127 | }
128 |
129 | return new TabData(details);
130 | }
131 |
132 | public getTabCreateProperties(active:boolean = false):TabCreateProperties {
133 | // active, pinned and reader mode tabs, 'new tab' and "about" urls cannot be created and discarded
134 | let discardTab:boolean = !active
135 | && !this.pinned
136 | && !this.isInReaderMode
137 | && !(!this.url || this.url.startsWith("about:"));
138 |
139 | let url:string = this.url;
140 |
141 | if(url === "about:newtab") {
142 | url = undefined;
143 | }
144 |
145 | let createProperties:TabCreateProperties = {
146 | active: active,
147 | url: url,
148 | openInReaderMode: this.isInReaderMode,
149 | pinned: this.pinned,
150 | discarded: discardTab,
151 | index: this.index
152 | };
153 |
154 | if(this.cookieStoreId) {
155 | createProperties.cookieStoreId = this.cookieStoreId;
156 | }
157 |
158 | if(discardTab) {
159 | createProperties.title = this.title;
160 | }
161 |
162 | return createProperties;
163 | }
164 |
165 | public getBookmarkCreateDetails(parentId:string):BookmarkCreateDetails {
166 | return {
167 | parentId: parentId,
168 | title: this.encodeTitle(),
169 | url: this.url,
170 | index: this.index
171 | };
172 | }
173 |
174 | public getBookmarkUpdate():BookmarkChanges {
175 | return {
176 | title: this.encodeTitle(),
177 | url: this.url
178 | };
179 | }
180 |
181 | public isPrivileged():boolean {
182 | return !(validURL.test(this.url) || this.url === "about:newtab");
183 | }
184 |
185 | public getHostname():string {
186 | return (new URL(this.url)).hostname;
187 | }
188 |
189 | private encodeTitle():string {
190 | let tabOptions:string[] = [];
191 |
192 | if(this.isInReaderMode) {
193 | tabOptions.push("reading");
194 | }
195 |
196 | if(this.pinned) {
197 | tabOptions.push("pinned");
198 | }
199 |
200 | if(this.viewSource) {
201 | tabOptions.push("src");
202 | }
203 |
204 | if(this.cookieStoreId && defaultCookieStoreId !== this.cookieStoreId) {
205 | tabOptions.push("cs=" + this.cookieStoreId);
206 | }
207 |
208 | let prefix:string = (tabOptions.length > 0) ?
209 | `[${tabOptions.join(",")}] ` : "";
210 |
211 | return prefix + this.title;
212 | }
213 |
214 | private static decodeTitle(title:string):TitleData {
215 | let matches:string[] = title.match(bmTitleParser);
216 |
217 | let data:string = matches[1] || "";
218 | let options:Set = new Set();
219 | let variables:Map = new Map();
220 |
221 | data.substring(1, data.length - 2).split(",").forEach(s => {
222 | if(s.includes("=", 2)) {
223 | let tmp = s.split("=");
224 | if(tmp.length === 2 && tmp[1].length > 0) {
225 | variables.set(tmp[0], tmp[1]);
226 | }
227 | } else {
228 | options.add(s);
229 | }
230 | });
231 |
232 | return {
233 | title: matches[matches.length - 1].trim(),
234 | flags: options,
235 | variables: variables
236 | };
237 | }
238 |
239 | private static getFavIconURL(url:string):string {
240 | // guess the favicon path
241 | return (new URL(url)).origin + "/favicon.ico";
242 |
243 | // alternative:
244 | // link.href = "http://s2.googleusercontent.com/s2/favicons?domain=" + url.hostname;
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/src/ts/extension-pages/tab-error.ts:
--------------------------------------------------------------------------------
1 | import * as HTMLUtils from "../util/HTMLUtilities.js";
2 | import { $$ } from "../util/HTMLUtilities.js";
3 | import * as Clipboard from "../util/Clipboard.js";
4 |
5 | (async function() {
6 | await HTMLUtils.DOMReady();
7 |
8 | // apply localization
9 | HTMLUtils.i18n();
10 |
11 | const urlBox:HTMLInputElement = document.getElementById("url");
12 |
13 | // expected URL parameters: url, error [, details]
14 | const params:URLSearchParams = new URL(window.location.href).searchParams;
15 | const url = params.get("url");
16 | const details = params.get("details") || "";
17 |
18 | // avoid linking to this page
19 | if(url.startsWith(browser.runtime.getURL(window.location.pathname))) {
20 | if(new URL(url).searchParams.has("url")) {
21 | setTimeout(() => {
22 | window.location.href = url;
23 | }, 250);
24 |
25 | return;
26 | }
27 | }
28 |
29 | if(details !== "") {
30 | const e = details.trim().toLowerCase();
31 | let description:string;
32 |
33 | if(e.includes("cookie store") || e.includes("contextual identities")) {
34 | description = browser.i18n.getMessage("tab_error_description_container");
35 |
36 | // open in default container button
37 | const button = $$("open");
38 | button.style.display = "block";
39 | button.addEventListener("click", async () => {
40 | const tab = await browser.tabs.getCurrent();
41 | browser.tabs.update(tab.id, {
42 | url: url,
43 | loadReplace: true
44 | });
45 | });
46 | } else if(e.includes("illegal url")) {
47 | if(url.startsWith("file:///")) {
48 | description = browser.i18n.getMessage("tab_error_description_files");
49 | } else {
50 | description = browser.i18n.getMessage("tab_error_description_privileged");
51 | }
52 | }
53 |
54 | if(description) {
55 | // show custom error description
56 | const d = $$("description");
57 | HTMLUtils.stringToParagraphs(description).forEach(p => d.appendChild(p));
58 | }
59 | }
60 |
61 | // URL UI
62 | urlBox.value = url;
63 | urlBox.focus();
64 | urlBox.select();
65 |
66 | // URL-copy button
67 | const copyButton:HTMLElement = document.getElementById("copy");
68 | copyButton.onclick = () => {
69 | Clipboard.copyTextFromInput(urlBox);
70 | };
71 |
72 | // (optional) details section
73 | if(params.has("details")) {
74 | $$("code").innerText = params.get("details");
75 | $$("details-section").style.display = "block";
76 | }
77 |
78 | })();
79 |
--------------------------------------------------------------------------------
/src/ts/messages/MessageListener.ts:
--------------------------------------------------------------------------------
1 | import { Message, MessageDestination, MessageType } from "./Messages.js";
2 |
3 | export type MessageListener = (m:Message) => void;
4 | type MessageTypeSelector = MessageType | "*";
5 |
6 | let destination:MessageDestination = null;
7 | let listeners:Map> = new Map();
8 |
9 | export function setDestination(dest:MessageDestination):void {
10 | console.assert(dest !== "all");
11 | destination = dest;
12 | }
13 |
14 | export function add(type:MessageTypeSelector, listener:MessageListener):void {
15 | console.assert(destination !== null);
16 |
17 | let typeListeners:Set = listeners.get(type) || new Set();
18 | typeListeners.add(listener);
19 | listeners.set(type, typeListeners);
20 | }
21 |
22 | export function remove(type:MessageTypeSelector, listener:MessageListener):void {
23 | let typeListeners:Set = listeners.get(type);
24 |
25 | console.assert(typeListeners, "No message listeners for " + type);
26 |
27 | typeListeners.delete(listener);
28 | }
29 |
30 | browser.runtime.onMessage.addListener((message:Message) => {
31 | if(message.destination !== "all" && message.destination !== destination) {
32 | return;
33 | }
34 |
35 | let typeListeners:Set = listeners.get(message.type) || new Set();
36 | typeListeners.forEach(f => f(message));
37 |
38 | let all:Set = listeners.get("*") || new Set();
39 | all.forEach(f => f(message));
40 | });
--------------------------------------------------------------------------------
/src/ts/messages/Messages.ts:
--------------------------------------------------------------------------------
1 | import { ActiveSessionData } from "../core/ActiveSession.js";
2 | import { attempt } from "../util/PromiseUtils.js";
3 |
4 | export type MessageType =
5 | "Ping"
6 | | "ExtensionCommand"
7 | | "SessionCommand"
8 | | "SessionEvent"
9 | | "DataRequest"
10 | | "OptionUpdate";
11 |
12 | export type MessageDestination =
13 | "all"
14 | | "sidebar"
15 | | "background"
16 | | "menu"
17 | | "options-page"
18 | | "tab-selector";
19 |
20 | export class Message {
21 | public readonly type: MessageType;
22 |
23 | public readonly destination: MessageDestination;
24 |
25 | protected constructor(type:MessageType, dest:MessageDestination) {
26 | this.type = type;
27 | this.destination = dest;
28 | }
29 | }
30 |
31 | export type SessionCMD = "restore" | "restore-single" | "set-aside" | "create" | "remove" | "remove-tab" | "rename";
32 |
33 | export type CreateSessionArguments = {
34 | title?:string;
35 | windowId?:number;
36 | setAside:boolean;
37 | tabs?:number[];
38 | };
39 |
40 | export type ModifySessionArguments = {
41 | sessionId:string;
42 | tabBookmarkId?:string;
43 | keepBookmarks?:boolean;
44 | keepTabs?:boolean;
45 | }
46 |
47 | export type ModifySessionMetaArguments = {
48 | sessionId:string;
49 | title:string;
50 | }
51 |
52 | type ArgumentData = CreateSessionArguments | ModifySessionArguments | ModifySessionMetaArguments;
53 |
54 | export class SessionCommand extends Message {
55 | public readonly cmd:SessionCMD;
56 | public readonly argData:ArgumentData;
57 |
58 | constructor(cmd: SessionCMD, argData:ArgumentData) {
59 | super("SessionCommand", "background");
60 |
61 | this.cmd = cmd;
62 | this.argData = argData;
63 | }
64 |
65 | public static async send(cmd: SessionCMD, args:ArgumentData) {
66 | let m:Message = new SessionCommand(cmd, args);
67 | await attempt(browser.runtime.sendMessage(m));
68 | }
69 | }
70 |
71 | type DataDescriptor = "active-sessions" | "state-info" | "previous-window-id";
72 |
73 | export class DataRequest extends Message {
74 | public readonly data: DataDescriptor;
75 |
76 | public constructor(data:DataDescriptor) {
77 | super("DataRequest", "background");
78 |
79 | this.data = data;
80 | }
81 |
82 | public static async send(data:DataDescriptor):Promise {
83 | let m:Message = new DataRequest(data);
84 | return browser.runtime.sendMessage(m);
85 | }
86 | }
87 |
88 | export interface StateInfoData {
89 | availableTabs:number; // number of tabs that would be part of the new session
90 | sessions:ActiveSessionData[];
91 | currentWindowSessions:ActiveSessionData[];
92 | currentSession:ActiveSessionData;
93 | previousWindowId:number;
94 | }
95 |
96 | type SessionEventType = "activated" | "set-aside" | "meta-update" | "content-update" | "removed" | "created" | "moved";
97 |
98 | export class SessionEvent extends Message {
99 | public readonly sessionId:string;
100 | public readonly event: SessionEventType;
101 |
102 | public constructor(sessionId:string, event:SessionEventType) {
103 | super("SessionEvent", "all");
104 |
105 | this.sessionId = sessionId;
106 | this.event = event;
107 | }
108 |
109 | public static async send(sessionId:string, event:SessionEventType) {
110 | let m:Message = new SessionEvent(sessionId, event);
111 | await attempt(browser.runtime.sendMessage(m));
112 | }
113 | }
114 |
115 | export class SessionContentUpdate extends SessionEvent {
116 | public readonly changedTabs:string[] = [];
117 | public readonly addedTabs:string[] = [];
118 | public readonly removedTabs:string[] = [];
119 |
120 | public constructor(sessionId:string) {
121 | super(sessionId, "content-update");
122 | }
123 |
124 | public static async send(sessionId:string) {
125 | let m:Message = new SessionContentUpdate(sessionId);
126 | await attempt(browser.runtime.sendMessage(m));
127 | }
128 | }
129 |
130 | export class OptionUpdateEvent extends Message {
131 | public readonly key: string;
132 | public readonly newValue: any;
133 |
134 | public constructor(key:string, newValue:any) {
135 | super("OptionUpdate", "all");
136 |
137 | this.key = key;
138 | this.newValue = newValue;
139 | }
140 |
141 | public static async send(key:string, newValue:any) {
142 | let m:Message = new OptionUpdateEvent(key, newValue);
143 | await attempt(browser.runtime.sendMessage(m));
144 | }
145 | }
146 |
147 | export class BackgroundPing extends Message {
148 | public static readonly RESPONSE = "Pong";
149 |
150 | public constructor() {
151 | super("Ping", "background");
152 | }
153 |
154 | public static async send():Promise {
155 | let m:Message = new BackgroundPing();
156 | let response:string|undefined = await browser.runtime.sendMessage(m);
157 |
158 | return (response === BackgroundPing.RESPONSE) ?
159 | Promise.resolve() :
160 | Promise.reject("Unexpected Ping response: '" + response + "'");
161 | }
162 | }
163 |
164 | export class ExtensionCommand extends Message {
165 | public readonly command:"reload" = "reload";
166 |
167 | public constructor(destination:MessageDestination, command:"reload") {
168 | super("ExtensionCommand", destination);
169 | this.command = command;
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/ts/options/Controls/BookmarkControl.ts:
--------------------------------------------------------------------------------
1 | import { Option } from "../OptionTypeDefinition.js";
2 | import { OptionUpdateEvent, Message } from "../../messages/Messages.js";
3 | import * as OptionsManager from "../OptionsManager.js";
4 |
5 | let optionIdFolderViewMap:Map = new Map();
6 | let instanceCounter = 0;
7 |
8 | browser.runtime.onMessage.addListener((message:Message) => {
9 | if(message.type === "OptionUpdate") {
10 | let msg:OptionUpdateEvent = message as OptionUpdateEvent;
11 |
12 | if(optionIdFolderViewMap.has(msg.key)) {
13 | let view:HTMLDivElement = optionIdFolderViewMap.get(msg.key);
14 |
15 | updateFolderView(view, msg.newValue);
16 | }
17 | }
18 | });
19 |
20 | export async function create(row:HTMLDivElement, option:Option):Promise {
21 | instanceCounter++;
22 | const i18nMessageName = "option_" + option.id;
23 | const bookmarkId = await OptionsManager.getValue(option.id);
24 |
25 | let folderView:HTMLDivElement = document.createElement("div");
26 | folderView.title = browser.i18n.getMessage("bookmarkFolderSelector_tooltip");
27 | folderView.id = "bmBox" + instanceCounter;
28 | folderView.setAttribute("data-bmId", "");
29 | folderView.classList.add("bookmarkFolderView");
30 |
31 | updateFolderView(folderView, bookmarkId);
32 |
33 | folderView.addEventListener("click", () => selectBookmark(option.id));
34 |
35 | let label:HTMLLabelElement = document.createElement("label");
36 | label.setAttribute("for", folderView.id);
37 | label.innerText = browser.i18n.getMessage(i18nMessageName);
38 |
39 | row.appendChild(label);
40 | row.appendChild(folderView);
41 |
42 | optionIdFolderViewMap.set(option.id, folderView);
43 | }
44 |
45 | async function updateFolderView(view:HTMLDivElement, bookmarkId:string) {
46 | if(bookmarkId) {
47 | browser.bookmarks.get(bookmarkId).then(res => {
48 | let title:string = res[0].title;
49 | view.innerText = title;
50 | }, () => {
51 | console.error(`[TA] Bookmark folder (id: ${bookmarkId}) not found.`);
52 | view.innerText = browser.i18n.getMessage("bookmarkFolderSelector_missing");
53 | });
54 | } else {
55 | view.innerText = "-";
56 | }
57 |
58 | view.setAttribute("data-bmId", bookmarkId);
59 | }
60 |
61 | /**
62 | * Opens the bookmark selector and returns a promise that resolves when the BMS is closed
63 | * @param optionId
64 | */
65 | export async function selectBookmark(optionId:string):Promise {
66 | let url = browser.runtime.getURL("html/bookmark-selector.html");
67 | url += "?option=" + encodeURIComponent(optionId);
68 |
69 | let bmsWindow:browser.windows.Window = await browser.windows.create({
70 | //focused: true, // not supported by FF
71 | allowScriptsToClose: true,
72 | width: 500,
73 | height: 300,
74 | titlePreface: "Tabs Aside! ",
75 | type: "popup",
76 | url: url
77 | });
78 |
79 | return new Promise(resolve => {
80 | function onWindowClosed(windowId:number) {
81 | if(windowId === bmsWindow.id) {
82 | browser.windows.onRemoved.removeListener(onWindowClosed);
83 | resolve();
84 | }
85 | }
86 |
87 | browser.windows.onRemoved.addListener(onWindowClosed);
88 | });
89 | }
90 |
--------------------------------------------------------------------------------
/src/ts/options/Controls/BooleanControl.ts:
--------------------------------------------------------------------------------
1 | import { Option } from "../OptionTypeDefinition.js";
2 | import * as OptionsManager from "../OptionsManager.js";
3 |
4 | let instanceCounter = 0;
5 |
6 | export async function create(row:HTMLDivElement, option:Option):Promise {
7 | instanceCounter++;
8 |
9 | let checkbox:HTMLInputElement = document.createElement("input");
10 | checkbox.id = "checkbox" + instanceCounter;
11 | checkbox.type = "checkbox";
12 | checkbox.classList.add("browser-style");
13 | checkbox.checked = await OptionsManager.getValue(option.id);
14 |
15 | let label = document.createElement("label");
16 | label.setAttribute("for", checkbox.id);
17 | label.innerText = browser.i18n.getMessage("option_" + option.id);
18 |
19 | checkbox.addEventListener("change", () => {
20 | OptionsManager.setValue(option.id, checkbox.checked);
21 | });
22 |
23 | row.appendChild(checkbox);
24 | row.appendChild(label);
25 | }
26 |
--------------------------------------------------------------------------------
/src/ts/options/Controls/SelectControl.ts:
--------------------------------------------------------------------------------
1 | import { SelectOption } from "../OptionTypeDefinition.js";
2 | import * as OptionsManager from "../OptionsManager.js";
3 |
4 | let instanceCounter = 0;
5 |
6 | export async function create(row:HTMLDivElement, option:SelectOption):Promise {
7 | instanceCounter++;
8 | const value = await OptionsManager.getValue(option.id);
9 | const i18nMessageName = "option_" + option.id;
10 |
11 | let select:HTMLSelectElement = document.createElement("select");
12 | select.id = "select" + instanceCounter;
13 | select.classList.add("browser-style");
14 |
15 | let selectOptions = option.options;
16 |
17 | selectOptions.forEach(o => {
18 | let selectOption:HTMLOptionElement = document.createElement("option");
19 | selectOption.value = o;
20 | selectOption.innerText = browser.i18n.getMessage(i18nMessageName + "__" + o) || "@ERROR";
21 |
22 | if(value === o) {
23 | selectOption.selected = true;
24 | }
25 |
26 | select.appendChild(selectOption);
27 | });
28 |
29 | let label:HTMLLabelElement = document.createElement("label");
30 | label.setAttribute("for", select.id);
31 | label.innerText = browser.i18n.getMessage(i18nMessageName) || "empty";
32 |
33 | select.addEventListener("change", () => {
34 | let x = select.options[select.selectedIndex].value;
35 | OptionsManager.setValue(option.id, x);
36 | });
37 |
38 | row.appendChild(label);
39 | row.appendChild(select);
40 | }
41 |
--------------------------------------------------------------------------------
/src/ts/options/Controls/StringControl.ts:
--------------------------------------------------------------------------------
1 | import { StringOption } from "../OptionTypeDefinition.js";
2 | import * as OptionsManager from "../OptionsManager.js";
3 |
4 | let instanceCounter = 0;
5 |
6 | export async function create(row:HTMLDivElement, option:StringOption) {
7 | instanceCounter++;
8 |
9 | let input = document.createElement("input");
10 | input.type = "text";
11 | input.id = "str-input-" + instanceCounter;
12 | input.classList.add("browser-style");
13 | input.value = await OptionsManager.getValue(option.id);
14 |
15 | let timeoutId:number;
16 |
17 | async function update() {
18 | timeoutId = undefined;
19 |
20 | const newValue = input.value || option.default;
21 | await OptionsManager.setValue(option.id, newValue);
22 | input.value = newValue;
23 | }
24 |
25 | input.addEventListener("input", () => {
26 | if(timeoutId) {
27 | window.clearTimeout(timeoutId);
28 | }
29 |
30 | timeoutId = window.setTimeout(update, 1500);
31 | });
32 |
33 | input.addEventListener("blur", () => {
34 | if(timeoutId) {
35 | window.clearTimeout(timeoutId);
36 | }
37 |
38 | input.value = input.value.trim();
39 | update();
40 | });
41 |
42 | let label = document.createElement("label");
43 | label.setAttribute("for", input.id);
44 | label.innerText = browser.i18n.getMessage("option_" + option.id);
45 |
46 | row.appendChild(label);
47 | row.appendChild(input);
48 | }
49 |
--------------------------------------------------------------------------------
/src/ts/options/OptionTypeDefinition.d.ts:
--------------------------------------------------------------------------------
1 | export interface GenericOption {
2 | id: string;
3 | type: S;
4 | default: T;
5 | }
6 |
7 | export interface SelectOption extends GenericOption<"select", string> {
8 | options: string[];
9 | }
10 |
11 | type OptionsGroup = "appearance" | "core";
12 |
13 | interface DisplayOptions {
14 | hint?:boolean; // tooltip
15 | info?:boolean; // html
16 | hidden?:boolean;
17 | group?:OptionsGroup;
18 | activeOnly?:boolean; // requires active sessions to be on/off (true/false)
19 | }
20 |
21 | export type BooleanOption = GenericOption<"boolean", boolean>;
22 | export type BookmarkOption = GenericOption<"bookmark", string>;
23 | export type StringOption = GenericOption<"string", string>;
24 |
25 | export type Option = (SelectOption
26 | | BooleanOption
27 | | BookmarkOption
28 | | StringOption)
29 | & DisplayOptions;
30 |
--------------------------------------------------------------------------------
/src/ts/options/Options.ts:
--------------------------------------------------------------------------------
1 | import { Option } from "./OptionTypeDefinition.js";
2 |
3 | let optionsMap:Map = new Map();
4 |
5 | (() => {
6 | let options:Option[] = [
7 | {
8 | id: "rootFolder",
9 | type: "bookmark",
10 | default: null,
11 | group: "core"
12 | },
13 | {
14 | id: "activeSessions",
15 | type: "boolean",
16 | default: true,
17 | info: true
18 | },
19 | {
20 | id: "windowedSession",
21 | type: "boolean",
22 | default: true
23 | },
24 | {
25 | id: "tabClosingBehavior",
26 | type: "select",
27 | options: ["remove-tab", "set-aside"],
28 | default: "remove-tab",
29 | info: true,
30 | activeOnly: true
31 | },
32 | {
33 | id: "lazyLoading",
34 | type: "boolean",
35 | default: true,
36 | info: true
37 | },
38 | {
39 | id: "asidePinnedTabs",
40 | type: "boolean",
41 | default: true,
42 | hint: true
43 | },
44 | {
45 | id: "sidebarTabLayout",
46 | type: "select",
47 | options: ["simple-list"],
48 | default: "simple-list",
49 | hidden: true,
50 | group: "appearance"
51 | },
52 | {
53 | id: "sidebarAutoOpen",
54 | type: "boolean",
55 | default: true
56 | },
57 | {
58 | id: "browserActionContextIcon",
59 | type: "boolean",
60 | default: false,
61 | info: true,
62 | group: "appearance"
63 | },
64 | {
65 | id: "confirmSessionRemoval",
66 | type: "boolean",
67 | default: true,
68 | info: true
69 | },
70 | {
71 | id: "sessionTitleTemplate",
72 | type: "string",
73 | default: browser.i18n.getMessage("session_title_default"),
74 | info: true
75 | }
76 | ];
77 |
78 | optionsMap = options.reduce((m:Map, o:Option) => m.set(o.id, o), optionsMap);
79 | })();
80 |
81 | export default optionsMap;
82 |
--------------------------------------------------------------------------------
/src/ts/options/OptionsManager.ts:
--------------------------------------------------------------------------------
1 | import Options from "./Options.js";
2 | import { Option } from "./OptionTypeDefinition.js";
3 | import { OptionUpdateEvent } from "../messages/Messages.js";
4 |
5 | let storage:browser.storage.StorageArea = browser.storage.local;
6 |
7 | type ChangeListener = (v:any) => void;
8 | let changeListeners:Map> = new Map();
9 |
10 | export async function getValue(key:string):Promise {
11 | // receive stored options from the storage API
12 | let storedOptions = (await storage.get("options"))["options"] as {[s:string]: any} || {};
13 |
14 | // get option definition
15 | let option:Option = Options.get(key);
16 |
17 | let value = (storedOptions[key] !== undefined) ? storedOptions[key] : option.default;
18 |
19 | return value as T;
20 | }
21 |
22 | export async function setValue(key:string, value:T) {
23 | // receive stored options from the storage API
24 | let storedOptions = (await storage.get("options"))["options"] as {[s:string]: any} || {};
25 |
26 | // get option definition
27 | let option:Option = Options.get(key);
28 |
29 | let oldValue:T = (storedOptions[key] !== undefined) ?
30 | storedOptions[key] : option.default;
31 |
32 | // if value has changed -> update options
33 | if(value !== oldValue) {
34 | storedOptions[key] = value;
35 |
36 | // update options (Storage API)
37 | await storage.set({"options": storedOptions});
38 | console.log(`[TA] Option ${key} updated.`);
39 |
40 | // call change listeners and pass new value as argument
41 | (changeListeners.get(key) || new Set()).forEach(f => f(value));
42 |
43 | // notify other scripts about the update && ignore no receiver error
44 | OptionUpdateEvent.send(key, value).catch(() => {});
45 | }
46 | }
47 |
48 | export function addChangeListener(key:string, callback:ChangeListener):void {
49 | let listeners:Set = changeListeners.get(key) || new Set();
50 | listeners.add(callback);
51 | changeListeners.set(key, listeners);
52 | }
53 |
54 | export async function removeUnused():Promise {
55 | // receive stored options from the storage API
56 | let storedOptions = (await storage.get("options"))["options"] as {[s:string]: any} || {};
57 | let cleanedOptions = {};
58 |
59 | // filter
60 | for(const id in storedOptions) {
61 | if(storedOptions.hasOwnProperty(id)) {
62 | if(Options.has(id)) {
63 | cleanedOptions[id] = storedOptions[id];
64 | } else {
65 | console.log("[TA] Dropping unused option " + id);
66 | }
67 | }
68 | }
69 |
70 | // store
71 | await storage.set({"options": cleanedOptions});
72 | }
73 |
--------------------------------------------------------------------------------
/src/ts/options/OptionsPage.ts:
--------------------------------------------------------------------------------
1 | import * as OptionsManager from "./OptionsManager.js";
2 | import Options from "./Options.js";
3 | import * as HTMLUtilities from "../util/HTMLUtilities.js";
4 | import * as MessageListener from "../messages/MessageListener.js";
5 |
6 | import * as BooleanControl from "./Controls/BooleanControl.js";
7 | import * as BookmarkControl from "./Controls/BookmarkControl.js";
8 | import * as SelectControl from "./Controls/SelectControl.js";
9 | import * as StringControl from "./Controls/StringControl.js";
10 |
11 | MessageListener.setDestination("options-page");
12 | MessageListener.add("OptionUpdate", () => {
13 | // this will only be triggered by option updates from other pages
14 | window.location.reload();
15 | })
16 |
17 | document.addEventListener("DOMContentLoaded", async () => {
18 | HTMLUtilities.i18n();
19 |
20 | // Multiple options depend on the active session option
21 | if(await OptionsManager.getValue("activeSessions")) {
22 | document.body.classList.add("active-sessions");
23 | }
24 | // react to changes
25 | OptionsManager.addChangeListener("activeSessions", (newValue:boolean) => {
26 | if(newValue) {
27 | document.body.classList.add("active-sessions");
28 | } else {
29 | document.body.classList.remove("active-sessions");
30 | }
31 | });
32 |
33 | let section = document.getElementById("main-section");
34 |
35 | // iterate over options
36 | Options.forEach(async option => {
37 | const i18nMessageName = "option_" + option.id;
38 |
39 | // skip hidden options
40 | if(option.hidden) { return; }
41 |
42 | // create row
43 | let row:HTMLDivElement = document.createElement("div");
44 | row.classList.add("row", "browser-style", option.type);
45 |
46 | if(option.type === "boolean") {
47 | await BooleanControl.create(row, option);
48 | } else if(option.type === "select") {
49 | await SelectControl.create(row, option);
50 | } else if(option.type === "bookmark") {
51 | await BookmarkControl.create(row, option);
52 | } else if(option.type === "string") {
53 | await StringControl.create(row, option);
54 | } else {
55 | console.warn("[TA] Unknown option type.", option);
56 | return;
57 | }
58 |
59 | if(option.hint) {
60 | row.title = browser.i18n.getMessage(i18nMessageName + "_hint");
61 | }
62 |
63 | if(option.info) {
64 | row.appendChild(document.createElement("br"));
65 |
66 | let info:HTMLParagraphElement = document.createElement("p");
67 | info.innerHTML = browser.i18n.getMessage(i18nMessageName + "_info");
68 | info.classList.add("info");
69 | row.appendChild(info);
70 | }
71 |
72 | if(option.activeOnly) {
73 | row.classList.add("active-only");
74 | }
75 |
76 | // append row
77 | section.appendChild(row);
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/ts/sidebar/Search.ts:
--------------------------------------------------------------------------------
1 | import { SessionEvent } from "../messages/Messages.js";
2 | import * as MessageListener from "../messages/MessageListener.js";
3 | import { $$ } from "../util/HTMLUtilities.js";
4 |
5 | MessageListener.setDestination("sidebar");
6 |
7 | document.addEventListener("DOMContentLoaded", () => {
8 | // find HTML elements
9 | const searchInput = $$("search-input") as HTMLInputElement;
10 | const searchClear = $$("search-clear") as HTMLButtonElement;
11 | const noResultsInfo:HTMLElement = $$("search-no-results");
12 |
13 | // set up attributes
14 | noResultsInfo.textContent = browser.i18n.getMessage("sidebar_search_noresults");
15 | searchClear.title = browser.i18n.getMessage("sidebar_search_clear");
16 | searchClear.setAttribute("aria-label", searchClear.title);
17 |
18 | searchInput.placeholder = browser.i18n.getMessage("sidebar_search_placeholder");
19 | searchInput.setAttribute("aria-label", searchInput.placeholder);
20 | searchInput.value = "";
21 | searchInput.focus();
22 |
23 | function clear() {
24 | searchInput.value = "";
25 | searchClear.classList.remove("show");
26 | showAll();
27 | }
28 |
29 | searchInput.addEventListener("input", async () => {
30 | let query:string = searchInput.value.trim();
31 |
32 | if(query === "") {
33 | searchClear.classList.remove("show");
34 | showAll();
35 | } else {
36 | searchClear.classList.add("show");
37 | let results:Set = await search(query);
38 |
39 | filterSessions(results);
40 |
41 | if(results.size === 0) {
42 | noResultsInfo.classList.add("show");
43 | } else {
44 | noResultsInfo.classList.remove("show");
45 | }
46 | }
47 | });
48 |
49 | searchClear.addEventListener("click", clear);
50 |
51 | $$("search-icon").addEventListener("click", () => {
52 | searchInput.focus();
53 | });
54 |
55 | window.addEventListener("keydown", e => {
56 | if(e.key === "f" && e.ctrlKey) { // CTRL + F
57 | e.preventDefault();
58 |
59 | searchInput.focus();
60 | }
61 | });
62 |
63 | searchInput.addEventListener("keydown", e => {
64 | if(e.key === "Escape") {
65 | clear();
66 | }
67 | });
68 | });
69 |
70 | let rootId:string;
71 |
72 | // this set of bookmark ids is used to filter search results
73 | let sessionIds:Set = new Set();
74 |
75 | let sessionContainer:HTMLElement;
76 |
77 | export async function init(sessionRootId:string, container:HTMLElement) {
78 | rootId = sessionRootId;
79 | sessionContainer = container;
80 |
81 | // initialize sessionIds
82 | let sessionBookmarks = await browser.bookmarks.getChildren(rootId);
83 | sessionBookmarks.forEach(
84 | session => sessionIds.add(session.id)
85 | );
86 | }
87 |
88 | /**
89 | * searches the bookmarks for sessions matching the given query
90 | * @param query
91 | * @returns returns the results as a set of ids as a promise
92 | */
93 | async function search(query:string):Promise> {
94 | let results:Set = new Set();
95 |
96 | let browserResults = await browser.bookmarks.search(query);
97 |
98 | browserResults.forEach(bm => {
99 | // ignore root node
100 | if(!bm.parentId) { return; }
101 |
102 | // is this bookmark a session or part of a session ?
103 | if(sessionIds.has(bm.id)) {
104 | results.add(bm.id);
105 | } else if(sessionIds.has(bm.parentId)) {
106 | results.add(bm.parentId);
107 | }
108 | });
109 |
110 | return results;
111 | }
112 |
113 | function filterSessions(filter:Set):void {
114 | let sessionViews = sessionContainer.querySelectorAll(".session");
115 |
116 | sessionViews.forEach(session => {
117 | let id:string = session.dataset.id || "";
118 |
119 | if(filter.has(id)) {
120 | session.classList.remove("hidden");
121 | } else {
122 | session.classList.add("hidden");
123 | }
124 | });
125 | }
126 |
127 | function showAll() {
128 | let sessionViews = sessionContainer.querySelectorAll(".session");
129 |
130 | sessionViews.forEach(session => {
131 | session.classList.remove("hidden");
132 | });
133 | }
134 |
135 | MessageListener.add("SessionEvent", (e:SessionEvent) => {
136 | if(e.event === "created") {
137 | sessionIds.add(e.sessionId);
138 | } else if(e.event === "removed") {
139 | sessionIds.delete(e.sessionId);
140 | }
141 | });
142 |
--------------------------------------------------------------------------------
/src/ts/sidebar/SessionOptionsMenu.ts:
--------------------------------------------------------------------------------
1 | import OverlayMenu from "../util/OverlayMenu.js";
2 | import SessionView from "./SessionView.js";
3 | import * as OptionsManager from "../options/OptionsManager.js";
4 | import { SessionCommand } from "../messages/Messages.js";
5 | import { Bookmark } from "../util/Types.js";
6 | import ModalWindow from "../util/ModalWindow.js";
7 |
8 | let _i18n = browser.i18n.getMessage;
9 |
10 | function date2str(date:Date|number):string {
11 | return date instanceof Date ?
12 | date.toISOString() :
13 | (new Date(date)).toISOString();
14 | }
15 |
16 | let activeSessions:boolean = true;
17 |
18 | // needs to be loaded just once because sidebar will reload if this is changed
19 | OptionsManager.getValue("activeSessions").then(value => activeSessions = value);
20 |
21 | export default class SessionOptionsMenu extends OverlayMenu {
22 | constructor(session:SessionView) {
23 | super();
24 |
25 | if(!activeSessions) {
26 | this.addItem("sidebar_session_restore_keep", () => {
27 | SessionCommand.send("restore", {
28 | sessionId: session.bookmarkId,
29 | keepBookmarks: true
30 | });
31 | }, "options-menu-restore-keep");
32 | }
33 |
34 | this.addItem("sidebar_session_rename", () => {
35 | session.editTitle();
36 | });
37 |
38 | this.addItem("sidebar_session_remove", async () => {
39 | const confirmationRequired:boolean = await OptionsManager.getValue("confirmSessionRemoval");
40 |
41 | if(confirmationRequired) {
42 | const confirmation:boolean = await ModalWindow.confirm(_i18n("sidebar_session_remove_confirm"));
43 | if(!confirmation) {
44 | return;
45 | }
46 | }
47 |
48 | if(activeSessions && session.isActive()) {
49 | let keep:boolean = await ModalWindow.confirm(_i18n("sidebar_session_remove_keep_tabs"));
50 |
51 | SessionCommand.send("remove", {
52 | sessionId: session.bookmarkId,
53 | keepTabs: keep
54 | });
55 | } else {
56 | SessionCommand.send("remove", {
57 | sessionId: session.bookmarkId,
58 | keepTabs: false
59 | });
60 | }
61 | }, "options-menu-remove-session");
62 |
63 | this.addItem("sidebar_session_details", async () => {
64 | let bookmark:Bookmark = (await browser.bookmarks.get(session.bookmarkId))[0];
65 |
66 | let modal = new ModalWindow();
67 | modal.addHeading(_i18n("sidebar_session_details_modal_title"));
68 | modal.addTable([
69 | [_i18n("sidebar_session_details_name"), bookmark.title],
70 | [_i18n("sidebar_session_details_id"), bookmark.id],
71 | [_i18n("sidebar_session_details_index"), ""+bookmark.index],
72 | [_i18n("sidebar_session_details_created"), date2str(bookmark.dateAdded)],
73 | [_i18n("sidebar_session_details_last_change"), date2str(bookmark.dateGroupModified)]
74 | ]);
75 | modal.setButtons(["close"]);
76 | await modal.show();
77 | }, "options-menu-session-details");
78 | }
79 | }
--------------------------------------------------------------------------------
/src/ts/sidebar/SessionView.ts:
--------------------------------------------------------------------------------
1 | import * as TabViewFactory from "./TabViewFactory.js";
2 | import {clean} from "../util/HTMLUtilities.js";
3 | import TabView from "./TabViews/TabView.js";
4 | import { SessionCommand } from "../messages/Messages.js";
5 | import * as EditText from "../util/EditText.js";
6 | import SessionOptionsMenu from "./SessionOptionsMenu.js";
7 | import { Bookmark } from "../util/Types.js";
8 |
9 | function i18n(messageName:string):string {
10 | return browser.i18n.getMessage("sidebar_"+messageName);
11 | }
12 |
13 | let template:HTMLTemplateElement = document.createElement("template");
14 | template.innerHTML = clean(`
15 |
26 |
27 | `);
28 |
29 | export default class SessionView {
30 | public bookmarkId:string;
31 |
32 | private html:HTMLElement;
33 | private titleElement:HTMLElement;
34 | private tabCounter:HTMLElement;
35 | private tabViewContainer:HTMLElement;
36 |
37 | private tabView:TabView = null;
38 |
39 | constructor(bookmark:Bookmark) {
40 | this.bookmarkId = bookmark.id;
41 |
42 | this.createHTML(bookmark);
43 | this.updateMeta();
44 | this.updateTabs();
45 | }
46 |
47 | public getHTML() {
48 | return this.html;
49 | }
50 |
51 | public async updateMeta() {
52 | // cancel title editmode
53 | EditText.cancel(this.titleElement);
54 |
55 | let sessionBookmark:Bookmark = (await browser.bookmarks.get(this.bookmarkId))[0];
56 |
57 | this.titleElement.textContent = sessionBookmark.title;
58 | }
59 |
60 | public async updateTabs() {
61 | let tabs:Bookmark[] = await browser.bookmarks.getChildren(this.bookmarkId);
62 |
63 | this.tabCounter.textContent = browser.i18n.getMessage(
64 | "sidebar_session_number_of_tabs",
65 | tabs.length+""
66 | );
67 |
68 | if(this.tabView) {
69 | this.tabView.update(tabs);
70 | }
71 | }
72 |
73 | private createHTML(bookmark:Bookmark) {
74 | this.html = document.createElement("section");
75 | this.html.classList.add("session");
76 | this.html.dataset.id = bookmark.id;
77 | this.html.appendChild(document.importNode(template.content, true));
78 |
79 | this.titleElement = this.html.querySelector(".title");
80 | this.tabCounter = this.html.querySelector(".number-of-tabs");
81 | this.tabViewContainer = this.html.querySelector(".tab-view");
82 |
83 | let header:HTMLElement = this.html.querySelector(".header");
84 | let controls:HTMLElement = header.querySelector(".controls");
85 | let moreButton:HTMLElement = controls.querySelector(".more");
86 |
87 | // click on session header -> toggle tab visibility
88 | header.addEventListener("click", () => this.toggle());
89 |
90 | // do not toggle tab visibility when clicking controls
91 | controls.addEventListener("click", e => e.stopPropagation());
92 |
93 | header.querySelector(".restore").addEventListener(
94 | "click", () => SessionCommand.send("restore", {sessionId: bookmark.id})
95 | );
96 |
97 | header.querySelector(".aside").addEventListener(
98 | "click", () => SessionCommand.send("set-aside", {sessionId: bookmark.id})
99 | );
100 |
101 | this.titleElement.addEventListener("click", e => {
102 | e.stopImmediatePropagation();
103 | e.stopPropagation();
104 |
105 | this.editTitle();
106 | });
107 |
108 | moreButton.addEventListener("click", () => {
109 | let menu = new SessionOptionsMenu(this);
110 | menu.showOn(moreButton);
111 | });
112 | }
113 |
114 | public toggle() {
115 | if(this.isExpanded()) {
116 | this.collapse();
117 | } else {
118 | this.expand();
119 | }
120 | }
121 |
122 | public isExpanded() {
123 | return this.html.classList.contains("expanded");
124 | }
125 |
126 | public async expand(data?:Bookmark[]) {
127 | // create TabView
128 | let tabView:TabView = TabViewFactory.createTabView(this);
129 | this.tabView = tabView;
130 |
131 | // optimization: if data is already available do not hit API again
132 | let tabBMs:Bookmark[] = (data instanceof Array) ?
133 | data : (await browser.bookmarks.getChildren(this.bookmarkId));
134 |
135 | this.tabViewContainer.appendChild(
136 | tabView.createHTML(tabBMs)
137 | );
138 |
139 | this.html.classList.add("expanded");
140 | }
141 |
142 | public collapse() {
143 | this.html.classList.remove("expanded");
144 |
145 | // remove tab view
146 | this.tabViewContainer.innerHTML = "";
147 | this.tabView = null;
148 | }
149 |
150 | public setActiveState(active:boolean):void {
151 | if(active) {
152 | this.html.classList.add("active");
153 | } else {
154 | this.html.classList.remove("active");
155 | }
156 | }
157 |
158 | public isActive():boolean {
159 | return this.html.classList.contains("active");
160 | }
161 |
162 | public editTitle() {
163 | EditText.edit(
164 | this.titleElement,
165 | browser.i18n.getMessage("sidebar_session_title_edit_placeholder"),
166 | 1
167 | ).then((newTitle:string) =>
168 | SessionCommand.send("rename", {
169 | sessionId: this.bookmarkId,
170 | title: newTitle
171 | })
172 | ).catch(error => console.log("[TA] Error", error));
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/src/ts/sidebar/TabContextMenu.ts:
--------------------------------------------------------------------------------
1 | import OverlayMenu from "../util/OverlayMenu.js";
2 | import { Bookmark, Tab } from "../util/Types.js";
3 | import * as Clipboard from "../util/Clipboard.js";
4 | import SessionView from "./SessionView.js";
5 | import { SessionCommand } from "../messages/Messages.js";
6 | import TabView from "./TabViews/TabView.js";
7 | import TabData from "../core/TabData.js";
8 | import * as OptionsManager from "../options/OptionsManager.js";
9 | import { createTab } from "../util/WebExtAPIHelpers.js";
10 |
11 | let _i18n = browser.i18n.getMessage;
12 |
13 | export default class TabContextMenu extends OverlayMenu {
14 | constructor(session:SessionView, tabView:TabView, tabBookmark:Bookmark) {
15 | super();
16 |
17 | this.addItem("sidebar_tab_copy_url", () => {
18 | Clipboard.copy(tabBookmark.url);
19 | }, "options-menu-tab-copy");
20 |
21 | if(!session.isActive()) {
22 | this.addItem("sidebar_tab_open_remove", async () => {
23 | let createProps = TabData.createFromBookmark(tabBookmark).getTabCreateProperties(true);
24 | let emptyTab:Tab|null = null;
25 |
26 | if(await OptionsManager.getValue("windowedSession")) {
27 | let wnd = await browser.windows.create();
28 | createProps.windowId = wnd.id;
29 | emptyTab = wnd.tabs[0];
30 | }
31 |
32 | await createTab(createProps);
33 |
34 | if(emptyTab) {
35 | browser.tabs.remove(emptyTab.id);
36 | }
37 |
38 | SessionCommand.send("remove-tab", {
39 | sessionId: tabBookmark.parentId,
40 | tabBookmarkId: tabBookmark.id
41 | });
42 | });
43 |
44 | this.addItem("sidebar_tab_remove_from_session", () => {
45 | SessionCommand.send("remove-tab", {
46 | sessionId: tabBookmark.parentId,
47 | tabBookmarkId: tabBookmark.id
48 | });
49 | }, "options-menu-tab-remove");
50 | }
51 | }
52 | }
--------------------------------------------------------------------------------
/src/ts/sidebar/TabViewFactory.ts:
--------------------------------------------------------------------------------
1 | import TabView from "./TabViews/TabView.js";
2 | import * as OptionsManager from "../options/OptionsManager.js";
3 |
4 | import SimpleList from "./TabViews/SimpleList.js";
5 | import SessionView from "./SessionView.js";
6 |
7 | let tabLayout:string = "simple-list";
8 |
9 | export async function init() {
10 | tabLayout = await OptionsManager.getValue("sidebarTabLayout");
11 |
12 | // load tab layout css
13 |
14 | let css:HTMLLinkElement = document.createElement("link");
15 | css.rel = "stylesheet";
16 | css.type = "text/css";
17 | css.href = "../css/tab-view-" + tabLayout + ".css";
18 |
19 | document.head.appendChild(css);
20 | }
21 |
22 | export function createTabView(sessionView:SessionView):TabView {
23 | if(tabLayout === "simple-list") {
24 | return new SimpleList(sessionView);
25 | }
26 |
27 | return null;
28 | }
29 |
--------------------------------------------------------------------------------
/src/ts/sidebar/TabViews/SimpleList.ts:
--------------------------------------------------------------------------------
1 | import TabView from "./TabView.js";
2 | import TabData from "../../core/TabData.js";
3 | import * as StringUtils from "../../util/StringUtils.js";
4 | import { SessionCommand } from "../../messages/Messages.js";
5 | import TabContextMenu from "../TabContextMenu.js";
6 | import SessionView from "../SessionView.js";
7 |
8 | type Bookmark = browser.bookmarks.BookmarkTreeNode;
9 |
10 | export default class SimpleList extends TabView {
11 |
12 | private list:HTMLOListElement = document.createElement("ol");
13 |
14 | constructor(session:SessionView) {
15 | super(session);
16 | }
17 |
18 | public createHTML(tabBookmarks:Bookmark[]): HTMLOListElement {
19 | this.populateList(tabBookmarks);
20 | return this.list;
21 | }
22 |
23 | private populateList(tabBookmarks:Bookmark[]) {
24 | this.setTabCountClass(tabBookmarks.length);
25 |
26 | let ol = this.list;
27 | ol.innerHTML = "";
28 |
29 | tabBookmarks.forEach(
30 | bm => ol.appendChild(this.createTabView(bm))
31 | );
32 | }
33 |
34 | private createTabView(tabBookmark:Bookmark):HTMLLIElement {
35 | let data:TabData = TabData.createFromBookmark(tabBookmark);
36 |
37 | let li:HTMLLIElement = document.createElement("li");
38 | li.id = "tab" + tabBookmark.id;
39 |
40 | let a:HTMLAnchorElement = document.createElement("a");
41 | a.classList.add("tab");
42 | a.textContent = StringUtils.limit(data.title, 80);
43 | a.dataset.id = tabBookmark.id;
44 | a.href = data.url;
45 | a.title = data.getHostname();
46 | a.onclick = e => {
47 | e.preventDefault();
48 |
49 | SessionCommand.send("restore-single", {
50 | sessionId: tabBookmark.parentId,
51 | tabBookmarkId: tabBookmark.id
52 | });
53 | };
54 | a.addEventListener("contextmenu", e => {
55 | e.stopImmediatePropagation();
56 | e.preventDefault();
57 |
58 | let menu = new TabContextMenu(this.sessionView, this, tabBookmark);
59 | menu.showAt(e.clientX, e.clientY);
60 | });
61 |
62 | if(data.isInReaderMode) {
63 | li.appendChild(this.createStateIcon("rm"));
64 | }
65 |
66 | if(data.pinned) {
67 | li.appendChild(this.createStateIcon("pinned"));
68 | }
69 |
70 | li.appendChild(a);
71 |
72 | return li;
73 | }
74 |
75 | private setTabCountClass(n:number):void {
76 | if(n >= 10 && n < 100) {
77 | this.list.classList.add("geq10");
78 | this.list.classList.remove("geq100");
79 | } else if(n >= 100) {
80 | this.list.classList.add("geq100");
81 | } else {
82 | this.list.classList.remove("geq10");
83 | this.list.classList.remove("geq100");
84 | }
85 | }
86 |
87 | public update(tabBookmarks:Bookmark[]) {
88 | // temporary solution
89 | this.populateList(tabBookmarks);
90 | }
91 |
92 | private createStateIcon(type:"pinned"|"rm"):HTMLElement {
93 | let icon = document.createElement("span");
94 | icon.classList.add("state-icon");
95 | icon.classList.add(type);
96 |
97 | return icon;
98 | }
99 | }
--------------------------------------------------------------------------------
/src/ts/sidebar/TabViews/TabView.ts:
--------------------------------------------------------------------------------
1 | import SessionView from "../SessionView.js";
2 |
3 | type Bookmark = browser.bookmarks.BookmarkTreeNode;
4 |
5 | export default abstract class TabView {
6 | protected sessionView:SessionView;
7 |
8 | constructor(session:SessionView) {
9 | this.sessionView = session;
10 | }
11 |
12 | public getSessionId():string {
13 | return this.sessionView.bookmarkId;
14 | }
15 |
16 | public abstract createHTML(tabBookmarks:Bookmark[]):HTMLElement;
17 |
18 | public abstract update(tabBookmarks:Bookmark[]):void;
19 |
20 | }
--------------------------------------------------------------------------------
/src/ts/sidebar/sidebar.ts:
--------------------------------------------------------------------------------
1 | import * as TabViewFactory from "./TabViewFactory.js";
2 | import * as OptionsManager from "../options/OptionsManager.js";
3 | import { OptionUpdateEvent, Message, SessionEvent, DataRequest, BackgroundPing, ExtensionCommand } from "../messages/Messages.js";
4 | import SessionView from "./SessionView.js";
5 | import * as Search from "./Search.js";
6 | import { ActiveSessionData } from "../core/ActiveSession.js";
7 | import * as MessageListener from "../messages/MessageListener.js";
8 | import { SolvableError, TabsAsideError } from "../util/Errors.js";
9 | import * as HTMLUtilities from "../util/HTMLUtilities.js";
10 |
11 | type Bookmark = browser.bookmarks.BookmarkTreeNode;
12 |
13 | // if one of these options changes reload the window
14 | let optionsThatRequireReload:Set = new Set([
15 | "activeSessions",
16 | "rootFolder",
17 | "sidebarTabLayout"
18 | ]);
19 |
20 | let rootId:string;
21 |
22 | let sessionViews:Map = new Map();
23 | let activeSessions:Map = new Map();
24 | let sessionContainer:HTMLElement;
25 | let noSessionsInfo:HTMLElement;
26 |
27 | // initialize...
28 | MessageListener.setDestination("sidebar");
29 | Promise.all([
30 | OptionsManager.getValue("rootFolder").then(v => {
31 | rootId = v;
32 | }),
33 |
34 | TabViewFactory.init(),
35 |
36 | HTMLUtilities.DOMReady().then(() => {
37 | sessionContainer = document.getElementById("sessions");
38 | noSessionsInfo = document.getElementById("no-sessions");
39 |
40 | // apply localization
41 | HTMLUtilities.i18n();
42 | })
43 |
44 | ]).then(() => {
45 | // check if the background page is responding
46 | // this can happen when the browser starts with the sidebar open
47 | return BackgroundPing.send().catch(e => {
48 | let error = new SolvableError("error_bgpNotResponding");
49 | error.setSolution(() => {
50 | window.location.reload();
51 | });
52 |
53 | return Promise.reject(error);
54 | });
55 | }).then(async () => {
56 | let sessions:Bookmark[];
57 |
58 | // request session data
59 | try {
60 | sessions = await browser.bookmarks.getChildren(rootId);
61 | } catch(e) {
62 | let error = new SolvableError("error_noRootFolder");
63 | error.setSolution(() => browser.runtime.openOptionsPage());
64 |
65 | return Promise.reject(error);
66 | }
67 |
68 | await getActiveSessions();
69 |
70 | // creating views
71 | sessions.forEach(sessionBookmark => addView(sessionBookmark));
72 |
73 | noSessionsCheck();
74 |
75 | }).then(() => {
76 | MessageListener.add("*", messageHandler);
77 |
78 | Search.init(rootId, sessionContainer);
79 | }).catch(e => {
80 | if(e instanceof TabsAsideError) {
81 | document.body.innerHTML = "";
82 | document.body.appendChild(e.createHTML());
83 | } else {
84 | console.error("[TA] " + e);
85 | document.body.innerHTML = "Error";
86 | }
87 |
88 | MessageListener.add("*", () => window.location.reload());
89 | });
90 |
91 | function addView(sessionBookmark:Bookmark, prepend:boolean = false):void {
92 | if(sessionViews.has(sessionBookmark.id)) {
93 | return updateView(sessionBookmark.id, sessionBookmark);
94 | }
95 |
96 | // create new session view
97 | let view = new SessionView(sessionBookmark);
98 |
99 | // by default session are not active
100 | if(activeSessions.has(view.bookmarkId)) {
101 | view.setActiveState(true);
102 | }
103 |
104 | // add to document and internal DS
105 | sessionViews.set(sessionBookmark.id, view);
106 |
107 | if(prepend && sessionContainer.firstChild) {
108 | sessionContainer.insertBefore(view.getHTML(), sessionContainer.firstChild);
109 | } else {
110 | sessionContainer.appendChild(view.getHTML());
111 | }
112 |
113 | noSessionsCheck();
114 | }
115 |
116 | function updateView(sessionId:string, sessionBookmark:Bookmark):void {
117 | let view = sessionViews.get(sessionId);
118 |
119 | if(view) {
120 | view.updateMeta();
121 | }
122 | }
123 |
124 | function noSessionsCheck():void {
125 | if(sessionViews.size === 0) {
126 | noSessionsInfo.classList.add("show");
127 | } else {
128 | noSessionsInfo.classList.remove("show");
129 | }
130 | }
131 |
132 | /**
133 | * Populates the activeSessions Map
134 | */
135 | async function getActiveSessions() {
136 | let response:ActiveSessionData[] = await DataRequest.send("active-sessions");
137 |
138 | activeSessions.clear();
139 | response.forEach(data => activeSessions.set(data.bookmarkId, data));
140 | }
141 |
142 | async function messageHandler(message:Message) {
143 | if(message.type === "OptionUpdate") {
144 | let msg:OptionUpdateEvent = message as OptionUpdateEvent;
145 |
146 | if(optionsThatRequireReload.has(msg.key)) {
147 | window.location.reload();
148 | }
149 | } else if(message.type === "SessionEvent") {
150 | let msg:SessionEvent = message as SessionEvent;
151 |
152 | let sessionView:SessionView = sessionViews.get(msg.sessionId);
153 |
154 | if(!sessionView) {
155 | if(msg.event === "created") {
156 | let sessionBookmark:Bookmark = (await browser.bookmarks.get(msg.sessionId))[0];
157 | addView(sessionBookmark, true);
158 | } else {
159 | // we can't modify a non-existing view so...
160 | return;
161 | }
162 | }
163 |
164 | if(msg.event === "content-update") {
165 | sessionView.updateTabs();
166 | } else if(msg.event === "activated") {
167 | sessionView.setActiveState(true);
168 | } else if(msg.event === "set-aside") {
169 | sessionView.setActiveState(false);
170 | } else if(msg.event === "meta-update") {
171 | sessionView.updateMeta();
172 | } else if(msg.event === "removed") {
173 | sessionView.getHTML().remove();
174 | sessionViews.delete(msg.sessionId);
175 | noSessionsCheck();
176 | } else if(msg.event === "moved") {
177 | //TODO: support other moves
178 | sessionView.getHTML().remove();
179 | sessionContainer.prepend(sessionView.getHTML());
180 | }
181 | } else if(message.type === "ExtensionCommand") {
182 | let ecm = message as ExtensionCommand;
183 |
184 | if(ecm.command === "reload") {
185 | window.location.reload();
186 | }
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/src/ts/sidebar/user-setup/OptionsPresets.ts:
--------------------------------------------------------------------------------
1 | import * as OptionsManager from "../../options/OptionsManager.js";
2 |
3 | interface OptionsPreset {
4 | options:{[id:string]: boolean|string};
5 | }
6 |
7 | export let Edge:OptionsPreset = {
8 | options: {
9 | "activeSessions": false,
10 | "windowedSession": false
11 | }
12 | };
13 |
14 | export let Classic:OptionsPreset = {
15 | options: {
16 | "activeSessions": true,
17 | "windowedSession": false,
18 | "asidePinnedTabs": false
19 | }
20 | };
21 |
22 | export async function apply(preset:OptionsPreset):Promise {
23 | for(let key of Object.keys(preset.options)) {
24 | console.log("setting " + key + " to " + preset.options[key]);
25 | await OptionsManager.setValue(key, preset.options[key]);
26 | }
27 | }
--------------------------------------------------------------------------------
/src/ts/sidebar/user-setup/SetupStep.ts:
--------------------------------------------------------------------------------
1 | import * as HTMLUtils from "../../util/HTMLUtilities.js";
2 |
3 | type CompletionListener = () => any;
4 |
5 | type OptionDetails = {
6 | text: string;
7 | action: ()=>Promise;
8 | recommended?:boolean;
9 | detailList?:string[]
10 | };
11 |
12 | export default class SetupStep {
13 | private static parent:HTMLElement;
14 | private static current:SetupStep = null;
15 | private readonly html:HTMLElement;
16 | private options:HTMLElement = null;
17 | private readonly completionListeners:Set = new Set();
18 | private recommended:HTMLAnchorElement = null;
19 |
20 | public constructor(messageName:string) {
21 | this.html = document.createElement("div");
22 | this.html.classList.add("content-box");
23 |
24 | let text = browser.i18n.getMessage(messageName);
25 | let ps = HTMLUtils.stringToParagraphs(text);
26 | ps.forEach(p => this.html.appendChild(p));
27 | }
28 |
29 | public static setParent(parent:HTMLElement):void {
30 | SetupStep.parent = parent;
31 | }
32 |
33 | public show():void {
34 | SetupStep.parent.prepend(this.html);
35 | if(SetupStep.current) {
36 | SetupStep.current.html.remove();
37 | }
38 | SetupStep.current = this;
39 | }
40 |
41 | public addOption(details:OptionDetails):void {
42 | if(!this.options) {
43 | this.options = document.createElement("div");
44 | this.options.classList.add("options");
45 | this.html.appendChild(this.options);
46 | }
47 |
48 | let prepend:boolean = false;
49 |
50 | let a:HTMLAnchorElement = document.createElement("a");
51 | a.innerText = browser.i18n.getMessage(details.text) || details.text;
52 | a.addEventListener("click", async () => {
53 | details.action().then(
54 | () => this.complete(),
55 | () => {}
56 | );
57 | });
58 |
59 | if(details.recommended) {
60 | // there can only be one recommended option
61 | if(this.recommended) {
62 | this.recommended.classList.remove("recommended");
63 | this.recommended.removeAttribute("title");
64 | prepend = true;
65 | }
66 |
67 | a.classList.add("recommended");
68 | a.title = browser.i18n.getMessage("setup_recommended_option");
69 | this.recommended = a;
70 | }
71 |
72 | if(details.detailList) {
73 | let ul = document.createElement("ul");
74 |
75 | details.detailList.forEach(
76 | msg => {
77 | let text = browser.i18n.getMessage(msg);
78 |
79 | if(text) {
80 | let li = document.createElement("li");
81 | li.innerText = text;
82 | ul.appendChild(li);
83 | }
84 | }
85 | );
86 |
87 | a.appendChild(ul);
88 | }
89 |
90 | if(prepend) {
91 | this.options.prepend(a);
92 | } else {
93 | this.options.appendChild(a);
94 | }
95 | }
96 |
97 | public completion():Promise {
98 | return new Promise(resolve => {
99 | this.completionListeners.add(resolve);
100 | });
101 | }
102 |
103 | private complete() {
104 | this.completionListeners.forEach(f => f());
105 | this.completionListeners.clear();
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/ts/sidebar/user-setup/user-setup.ts:
--------------------------------------------------------------------------------
1 | import * as HTMLUtils from "../../util/HTMLUtilities.js";
2 | import SetupStep from "./SetupStep.js";
3 | import { Bookmark } from "../../util/Types.js";
4 | import * as OptionsManager from "../../options/OptionsManager.js";
5 | import { selectBookmark } from "../../options/Controls/BookmarkControl.js";
6 | import { apply, Edge, Classic } from "./OptionsPresets.js";
7 |
8 | let step1 = new SetupStep("setup_root_folder_text");
9 | let step2 = new SetupStep("setup_preset_selection_text");
10 | let step3 = new SetupStep("setup_completed_text");
11 |
12 | let setupContainer:HTMLElement;
13 | let skipButton:HTMLAnchorElement;
14 |
15 | browser.sidebarAction.setTitle({title:"Tabs Aside"});
16 |
17 | HTMLUtils.DOMReady().then(() => {
18 | HTMLUtils.i18n();
19 |
20 | setupContainer = document.getElementById("setup-content");
21 | SetupStep.setParent(setupContainer);
22 |
23 | let version = document.getElementById("version");
24 | version.innerText = browser.i18n.getMessage(
25 | "setup_version",
26 | [browser.runtime.getManifest().version]
27 | );
28 |
29 | skipButton = document.getElementById("skip") as HTMLAnchorElement;
30 | skipButton.addEventListener("click", async e => {
31 | e.preventDefault();
32 | browser.runtime.openOptionsPage();
33 | close();
34 | });
35 |
36 | // hide skip button
37 | skipButton.style.display = "none";
38 |
39 | setup().catch(e => {
40 | console.error("[TA] Setup failed: " + e);
41 | });
42 | });
43 |
44 | async function setup() {
45 | let rootFolder:string|null = await OptionsManager.getValue("rootFolder");
46 |
47 | // add a button to keep the root folder from a previous installation
48 | if(rootFolder) {
49 | step1.addOption({
50 | text: "setup_root_folder_keep",
51 | action: () => Promise.resolve(),
52 | recommended: true
53 | });
54 | }
55 |
56 | // changelog box
57 | {
58 | let box = createBox();
59 | let a = document.createElement("a");
60 | a.href = "https://github.com/tim-we/tabs-aside/wiki/Tabs-Aside-3-::-Whats-new%3F";
61 | a.innerText = browser.i18n.getMessage("setup_changelog");
62 | let text = document.createElement("p");
63 | text.innerText = browser.i18n.getMessage("setup_changelog_text");
64 | text.appendChild(document.createElement("br"));
65 | text.appendChild(a);
66 | box.appendChild(text);
67 | setupContainer.appendChild(box);
68 | }
69 |
70 | step1.show();
71 | await step1.completion();
72 | // show skip button
73 | skipButton.style.display = "inline";
74 |
75 | step2.show();
76 | await step2.completion();
77 |
78 | step3.show();
79 | await step3.completion();
80 |
81 | close();
82 | }
83 |
84 | async function close() {
85 | await browser.storage.local.set({"setup": true});
86 | console.log("[TA] Setup completed.");
87 |
88 | browser.runtime.reload();
89 | }
90 |
91 | function createBox(text?:string):HTMLDivElement {
92 | let box = document.createElement("div");
93 | box.classList.add("content-box");
94 |
95 | if(text) {
96 | let ps = HTMLUtils.stringToParagraphs(text);
97 | ps.forEach(p => box.appendChild(p));
98 | }
99 |
100 | return box;
101 | }
102 |
103 | // ------ setup details -------
104 |
105 | step1.addOption({
106 | text: "setup_root_folder_auto_create",
107 | action: async () => {
108 | let folder:Bookmark = await browser.bookmarks.create({title: "Tabs Aside"});
109 | console.log("[TA] Created bookmark folder 'Tabs Aside'.");
110 | OptionsManager.setValue("rootFolder", folder.id);
111 | },
112 | recommended: true
113 | });
114 |
115 | step1.addOption({
116 | text: "setup_root_folder_select",
117 | action: async () => {
118 | await selectBookmark("rootFolder");
119 | let folderId = await OptionsManager.getValue("rootFolder");
120 |
121 | return folderId ? Promise.resolve() : Promise.reject();
122 | }
123 | });
124 |
125 | step2.addOption({
126 | text: "setup_preset_default",
127 | action: () => Promise.resolve(),
128 | recommended: true
129 | });
130 |
131 | step2.addOption({
132 | text: "setup_preset_edge",
133 | action: () => apply(Edge),
134 | detailList: [
135 | "setup_preset_active_sessions_disabled",
136 | "setup_preset_windowed_sessions_disabled"
137 | ]
138 | });
139 |
140 | step2.addOption({
141 | text: "setup_preset_classic",
142 | action: () => apply(Classic),
143 | detailList: [
144 | "setup_preset_windowed_sessions_disabled",
145 | "setup_preset_pinned_tabs_disabled"
146 | ]
147 | });
148 |
149 | step3.addOption({
150 | text: "setup_completed_close",
151 | action: () => Promise.resolve(),
152 | recommended: true
153 | });
154 |
155 | step3.addOption({
156 | text: "setup_completed_options_page",
157 | action: () => browser.runtime.openOptionsPage()
158 | });
159 |
--------------------------------------------------------------------------------
/src/ts/util/Clipboard.ts:
--------------------------------------------------------------------------------
1 | export function copy(text:string):void {
2 | // create a temporary invisible input element
3 | let input = document.createElement("input");
4 | input.type = "text";
5 | input.style.opacity = "0";
6 | input.value = text;
7 | document.body.appendChild(input);
8 |
9 | // copy
10 | copyTextFromInput(input);
11 |
12 | // remove the input element
13 | input.remove();
14 | }
15 |
16 | export function copyTextFromInput(input:HTMLInputElement):void {
17 | input.focus();
18 | input.select();
19 | document.execCommand("copy");
20 | }
--------------------------------------------------------------------------------
/src/ts/util/EditText.ts:
--------------------------------------------------------------------------------
1 | let previousText:Map = new Map();
2 | let rejectors:Map void> = new Map();
3 |
4 | export function edit(element:HTMLElement, placeholder:string = "", minLength:number = 1):Promise {
5 | if(previousText.has(element)) {
6 | return Promise.reject("Element is already in edit mode.");
7 | }
8 |
9 | // store current text content
10 | previousText.set(element, element.textContent);
11 |
12 | // create input element
13 | let input:HTMLInputElement = document.createElement("input");
14 | input.type = "text";
15 | input.placeholder = placeholder;
16 | input.value = element.textContent;
17 | input.title = "";
18 | input.style.width = (element.offsetWidth + 1) + "px";
19 | // catch clicks
20 | input.addEventListener("click", e => e.stopPropagation());
21 |
22 | // replace text with input element
23 | element.textContent = "";
24 | element.appendChild(input);
25 | element.classList.add("editmode");
26 | input.focus();
27 |
28 | // a promise that resolves when user input is "completed"
29 | let editComplete = new Promise((resolve,reject) => {
30 | rejectors.set(element, reject);
31 |
32 | // set up user input events
33 | input.addEventListener("blur", () => resolve());
34 |
35 | input.addEventListener("keydown", e => {
36 | e.stopPropagation();
37 |
38 | if (e.code === "Enter") {
39 | resolve();
40 | } else if (e.code === "Escape") {
41 | reject();
42 | }
43 | });
44 |
45 | input.select();
46 | }).catch(() => {
47 | closeEditMode(element, previousText.get(element));
48 |
49 | return Promise.reject();
50 | });
51 |
52 | return editComplete.then(() => {
53 | let text:string = input.value.trim();
54 |
55 | if(text.length >= minLength) {
56 | closeEditMode(element, text);
57 | return Promise.resolve(text);
58 | } else {
59 | closeEditMode(element, previousText.get(element));
60 | return Promise.reject();
61 | }
62 | });
63 | }
64 |
65 | function closeEditMode(element:HTMLElement, text:string) {
66 | // remove input & show text
67 | element.innerHTML = "";
68 | element.classList.remove("editmode");
69 | element.textContent = text;
70 |
71 | // clean up bookkeeping
72 | previousText.delete(element);
73 | rejectors.delete(element);
74 | }
75 |
76 | export function cancel(element:HTMLElement):void {
77 | let reject = rejectors.get(element);
78 |
79 | if(reject) {
80 | reject();
81 | }
82 | }
83 |
84 | export function cancelAll():void {
85 | Array.from(rejectors.values()).forEach(reject => reject());
86 | }
87 |
--------------------------------------------------------------------------------
/src/ts/util/Errors.ts:
--------------------------------------------------------------------------------
1 | export abstract class TabsAsideError {
2 | protected message:string;
3 |
4 | constructor(i18n:string) {
5 | this.message = browser.i18n.getMessage(i18n);
6 | }
7 |
8 | createHTML(): HTMLElement {
9 | let error:HTMLDivElement = document.createElement("div");
10 | error.classList.add("error");
11 |
12 | let text:HTMLSpanElement = document.createElement("span");
13 | text.textContent = this.message;
14 | error.appendChild(text);
15 |
16 | return error;
17 | }
18 | }
19 |
20 | export class CriticalError extends TabsAsideError {
21 | createHTML():HTMLElement {
22 | let error = super.createHTML();
23 | error.classList.add("critical-error");
24 | return error;
25 | }
26 | }
27 |
28 | export class SolvableError extends TabsAsideError {
29 | private i18n:string;
30 | private solution:Solution = null;
31 |
32 | constructor(i18n:string) {
33 | super(i18n);
34 | this.i18n = i18n;
35 | }
36 |
37 | public setSolution(solveAction:ErrorSolver):void {
38 | this.solution = new Solution(
39 | browser.i18n.getMessage(this.i18n + "_solution"),
40 | solveAction
41 | );
42 | }
43 |
44 | public createHTML():HTMLElement {
45 | let error:HTMLElement = super.createHTML();
46 |
47 | if(this.solution) {
48 | let fix:HTMLButtonElement = document.createElement("button");
49 | fix.dataset.i18n = this.i18n;
50 | fix.textContent = this.solution.text;
51 | fix.addEventListener("click", () => this.solution.solveAction());
52 | error.appendChild(fix);
53 | }
54 |
55 | return error;
56 | }
57 | }
58 |
59 | type ErrorSolver = () => any;
60 |
61 | class Solution {
62 | public text:string;
63 | public solveAction:ErrorSolver;
64 |
65 | constructor(text:string, solveAction:ErrorSolver) {
66 | this.text = text;
67 | this.solveAction = solveAction;
68 | }
69 | }
--------------------------------------------------------------------------------
/src/ts/util/FuncIterator.ts:
--------------------------------------------------------------------------------
1 | export default class FuncIterator {
2 | private iterator:IterableIterator;
3 |
4 | constructor(iterator:IterableIterator) {
5 | this.iterator = iterator;
6 | }
7 |
8 | public map(f: (value:T, index:number) => S):FuncIterator {
9 | return new FuncIterator(_map(this.iterator, f));
10 | }
11 |
12 | public filter(p: (value:T, index:number) => boolean):FuncIterator {
13 | return new FuncIterator(_filter(this.iterator, p));
14 | }
15 |
16 | public append(i:FuncIterator|IterableIterator):FuncIterator {
17 | let iterator2:IterableIterator = (i instanceof FuncIterator) ? i.iterator : i;
18 |
19 | return new FuncIterator(_append(this.iterator, iterator2));
20 | }
21 |
22 | public toArray():T[] {
23 | return Array.from(this.iterator);
24 | }
25 |
26 | public mapToArray(f: (value:T, index:number) => S):S[] {
27 | return Array.from(this.iterator, f);
28 | }
29 | }
30 |
31 | function * _map(iterable:IterableIterator, f: (value:T, index:number) => S) {
32 | let i = 0;
33 |
34 | for (let x of iterable) {
35 | yield f(x, i++);
36 | }
37 | }
38 |
39 | function * _filter(iterable:IterableIterator, p: (value:T, index:number) => boolean) {
40 | let i = 0;
41 |
42 | for(let x of iterable) {
43 | if(p(x, i++)) {
44 | yield x;
45 | }
46 | }
47 | }
48 |
49 | function * _append(iterable1:IterableIterator, iterable2:IterableIterator) {
50 | for(let x of iterable1) {
51 | yield x;
52 | }
53 |
54 | for(let x of iterable2) {
55 | yield x;
56 | }
57 | }
--------------------------------------------------------------------------------
/src/ts/util/HTMLUtilities.ts:
--------------------------------------------------------------------------------
1 | const whitespaceBetweenTags:RegExp = /\>\s+\<").trim();
5 | }
6 |
7 | /**
8 | * Localizes HTML that have the `.i18n` class
9 | * @param container Container that will be searched for `.i18n` class members
10 | */
11 | export function i18n(container:HTMLElement = document.body):void {
12 | container.querySelectorAll("[data-i18n]")
13 | .forEach((elem:HTMLElement) => {
14 | let messageName:string = elem.dataset.i18n;
15 |
16 | elem.textContent = browser.i18n.getMessage(messageName);
17 | });
18 | }
19 |
20 | export function DOMReady():Promise {
21 | if(document.readyState === "loading") {
22 | return new Promise(resolve =>
23 | document.addEventListener("DOMContentLoaded", () => {resolve()})
24 | );
25 | } else {
26 | return Promise.resolve();
27 | }
28 | }
29 |
30 | export function stringToParagraphs(str:string):HTMLParagraphElement[] {
31 | return str.split("\n").map(line => {
32 | let p = document.createElement("p");
33 | p.innerText = line;
34 | return p;
35 | });
36 | }
37 |
38 | export function $(query:string):HTMLElement {
39 | return document.querySelector(query);
40 | }
41 |
42 | export function $$(id:string):HTMLElement {
43 | return document.getElementById(id);
44 | }
45 |
--------------------------------------------------------------------------------
/src/ts/util/ModalWindow.ts:
--------------------------------------------------------------------------------
1 | let background:HTMLDivElement = document.createElement("div");
2 | background.id = "modal-background";
3 |
4 | let modals:ModalWindow[] = [];
5 |
6 | export default class ModalWindow {
7 | private windowHTML:HTMLDivElement;
8 | private customContent:HTMLDivElement;
9 | private buttons:HTMLDivElement;
10 | private buttonPressed:string = null;
11 | private onClosed:()=>void;
12 | public cancelable:boolean = true;
13 |
14 | public constructor() {
15 | this.windowHTML = document.createElement("div");
16 | this.windowHTML.classList.add("modal-window");
17 | this.windowHTML.addEventListener("click", e => e.stopPropagation());
18 |
19 | let content:HTMLDivElement = document.createElement("div");
20 | content.classList.add("content");
21 | this.windowHTML.appendChild(content);
22 |
23 | this.customContent = document.createElement("div");
24 | this.customContent.classList.add("custom-content");
25 | content.appendChild(this.customContent);
26 |
27 | this.buttons = document.createElement("div");
28 | this.buttons.classList.add("buttons");
29 | content.appendChild(this.buttons);
30 | }
31 |
32 | public addContent(elem:HTMLElement):void {
33 | this.customContent.appendChild(elem);
34 | }
35 |
36 | public addHeading(title:string):void {
37 | let h:HTMLHeadingElement = document.createElement("h2");
38 | h.innerText = title;
39 | this.addContent(h);
40 | }
41 |
42 | public addText(text:string):void {
43 | let p = document.createElement("p");
44 | p.innerText = text;
45 | this.addContent(p);
46 | }
47 |
48 | public addTable(rows:string[][]):void {
49 | let table:HTMLTableElement = document.createElement("table");
50 | rows.forEach(columns => {
51 | let tr:HTMLTableRowElement = document.createElement("tr");
52 | columns.forEach(value => {
53 | let td:HTMLTableCellElement = document.createElement("td");
54 | td.innerText = value;
55 | tr.appendChild(td);
56 | })
57 | table.appendChild(tr);
58 | });
59 | this.addContent(table);
60 | }
61 |
62 | public show():Promise {
63 | this.windowHTML.style.opacity = "0";
64 | background.appendChild(this.windowHTML);
65 |
66 | if(modals.length === 0) {
67 | background.style.opacity = "0";
68 | document.body.appendChild(background);
69 | background.style.opacity = "1";
70 | }
71 |
72 | this.windowHTML.style.opacity = "1";
73 |
74 | modals.push(this);
75 |
76 | return new Promise(resolve => this.onClosed = resolve);
77 | }
78 |
79 | public close():void {
80 | this.windowHTML.remove();
81 | this.windowHTML.style.marginTop = null;
82 |
83 | let i = modals.indexOf(this);
84 | modals.splice(i, 1);
85 |
86 | if(modals.length === 0) {
87 | background.remove();
88 | }
89 |
90 | this.onClosed();
91 | }
92 |
93 | public setButtons(buttonIds:string[]):void {
94 | this.buttons.innerHTML = "";
95 | buttonIds.forEach(buttonId => {
96 | let modal = this;
97 | let button:HTMLButtonElement = document.createElement("button");
98 | button.innerText = browser.i18n.getMessage("modal_window_button_" + buttonId) || buttonId;
99 | button.classList.add("browser-style");
100 | this.buttons.appendChild(button);
101 |
102 | button.addEventListener("click", e => {
103 | e.stopPropagation();
104 | modal.buttonPressed = buttonId;
105 | modal.close();
106 | });
107 | });
108 | }
109 |
110 | public static alert(text:string):Promise {
111 | let modal = new ModalWindow();
112 | modal.addText(text);
113 | modal.setButtons(["ok"]);
114 |
115 | return modal.show();
116 | }
117 |
118 | public static async confirm(text:string):Promise {
119 | let modal = new ModalWindow();
120 | modal.addText(text);
121 | modal.setButtons(["ok", "cancel"]);
122 | modal.cancelable = false;
123 |
124 | await modal.show();
125 |
126 | return modal.buttonPressed === "ok";
127 | }
128 | }
129 |
130 | background.addEventListener("click", () => {
131 | let currentModal = modals[modals.length - 1];
132 |
133 | if(currentModal.cancelable) {
134 | currentModal.close();
135 | }
136 | });
--------------------------------------------------------------------------------
/src/ts/util/OverlayMenu.ts:
--------------------------------------------------------------------------------
1 | let background:HTMLElement = document.createElement("div");
2 | background.id = "overlay-menu-bg";
3 |
4 | let isActive:boolean = false;
5 |
6 | export default abstract class OverlayMenu {
7 | private htmlMenu:HTMLElement;
8 |
9 | constructor() {
10 | this.htmlMenu = document.createElement("div");
11 | this.htmlMenu.classList.add("overlay-menu");
12 | }
13 |
14 | public showOn(element:HTMLElement) {
15 | let box = element.getBoundingClientRect();
16 |
17 | let posX = box.left + box.width;
18 | let posY = box.top + box.height;
19 |
20 | this.showAt(posX, posY);
21 | }
22 |
23 | public showAt(posX:number, posY:number) {
24 | // compute & set menu position
25 | let x = Math.max(0, window.innerWidth - Math.max(150, posX));
26 | let y = posY;
27 |
28 | if(posX < 160 && posX < x) {
29 | x = Math.max(0, posX);
30 | this.htmlMenu.style.left = x + "px";
31 | this.htmlMenu.classList.add("origin-left");
32 | } else {
33 | this.htmlMenu.style.right = x + "px";
34 | }
35 |
36 | this.htmlMenu.style.top = y + "px";
37 |
38 | // add to background element
39 | background.appendChild(this.htmlMenu);
40 |
41 | // show
42 | showBG();
43 | }
44 |
45 | public hide() {
46 | hideBG();
47 | }
48 |
49 | protected addItem(i18n:string, onclick:(e:MouseEvent) => void, id?:string) {
50 | let item = document.createElement("div");
51 | item.classList.add("overlay-menu-item");
52 | item.textContent = browser.i18n.getMessage(i18n);
53 |
54 | if(id) {
55 | item.id = id;
56 | }
57 |
58 | item.addEventListener("click", onclick);
59 |
60 | this.htmlMenu.appendChild(item);
61 | }
62 | }
63 |
64 | function showBG() {
65 | document.body.appendChild(background);
66 |
67 | isActive = true;
68 | }
69 |
70 | function clearBG() {
71 | while(background.firstChild) {
72 | background.removeChild(background.firstChild);
73 | }
74 | }
75 |
76 | function hideBG() {
77 | clearBG();
78 |
79 | isActive = false;
80 | background.remove();
81 | }
82 |
83 | // event listeners
84 | window.addEventListener("keydown", e => {
85 | if(isActive) {
86 | e.stopPropagation();
87 | e.stopImmediatePropagation();
88 | hideBG();
89 | }
90 | });
91 |
92 | background.addEventListener("click", e => {
93 | e.stopPropagation();
94 | hideBG();
95 | });
96 |
97 | background.addEventListener("contextmenu", e => {
98 | e.stopPropagation();
99 | e.preventDefault();
100 | });
--------------------------------------------------------------------------------
/src/ts/util/PromiseUtils.ts:
--------------------------------------------------------------------------------
1 | export function attempt(promise:Promise):Promise {
2 | return promise.catch(() => {});
3 | }
4 |
5 | export function resolves(promise:Promise):Promise {
6 | return promise.then(
7 | () => Promise.resolve(true),
8 | () => Promise.resolve(false)
9 | );
10 | }
11 |
12 | export function rejects(promise:Promise):Promise {
13 | return promise.then(
14 | () => Promise.resolve(false),
15 | () => Promise.resolve(true)
16 | );
17 | }
18 |
19 | export function wait(time:number):Promise {
20 | return new Promise(
21 | resolve => window.setTimeout(resolve, time)
22 | );
23 | }
--------------------------------------------------------------------------------
/src/ts/util/StringUtils.ts:
--------------------------------------------------------------------------------
1 | export function limit(str:string, maxLength:number):string {
2 | let n = maxLength - 3;
3 |
4 | return (str.length > n) ? str.substr(0,n).trim() + "..." : str;
5 | }
6 |
7 | // from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
8 | function escapeRegExp(str:string):string {
9 | return str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
10 | }
11 |
12 | /**
13 | * Replaces all instances of `find` in `str` with `replace`
14 | */
15 | export function replaceAll(str:string, find:string, replace:string):string {
16 | const re = new RegExp(escapeRegExp(find), 'g');
17 | return str.replace(re, replace);
18 | }
19 |
20 | /**
21 | * Replaces date&time format specifiers in a template string
22 | * @param template Template string that contains format specifiers (starting with `$`)
23 | */
24 | export function formatDate(template:string, date:Date = new Date()):string {
25 | let str = template;
26 |
27 | // date
28 | str = replaceAll(str, "$dd", nDigitNum(2, date.getDate()));
29 | str = replaceAll(str, "$MM", nDigitNum(2, date.getMonth()+1));
30 | str = replaceAll(str, "$d", date.getDate()+"");
31 | str = replaceAll(str, "$M", (date.getMonth()+1)+"");
32 | str = replaceAll(str, "$y", date.getFullYear()+"");
33 |
34 | // time
35 | str = replaceAll(str, "$H", date.getHours()+"");
36 | str = replaceAll(str, "$h", ((date.getHours() + 24) % 12 || 12)+"");
37 | str = replaceAll(str, "$m", nDigitNum(2, date.getMinutes()));
38 | str = replaceAll(str, "$s", nDigitNum(2, date.getSeconds()));
39 | str = replaceAll(str, "$p", date.getHours() < 12 ? "am" : "pm");
40 |
41 | return str;
42 | }
43 |
44 | function nDigitNum(digits:number, num:number):string {
45 | let str = ""+num;
46 | return str.length < digits ? "0".repeat(digits-str.length)+str : str;
47 | }
48 |
--------------------------------------------------------------------------------
/src/ts/util/Types.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @types/firefox-webext-browser does not expose all types (e.g. listener types).
3 | */
4 |
5 | export type SessionId = string;
6 | export type Tab = browser.tabs.Tab;
7 | export type Window = browser.windows.Window;
8 | export type Bookmark = browser.bookmarks.BookmarkTreeNode;
9 |
10 | export type TabCreateProperties = {
11 | active?:boolean;
12 | url?: string;
13 | pinned?:boolean;
14 | openInReaderMode?:boolean;
15 | windowId?:number;
16 | discarded?: boolean;
17 | cookieStoreId?: string;
18 | index?:number;
19 | title?:string;
20 | };
21 |
22 | export type BookmarkCreateDetails = browser.bookmarks.CreateDetails;
23 |
24 | export type BookmarkChanges = {title?:string; url?:string};
25 |
26 | export type TabCreatedListener = (tab:Tab) => void;
27 |
28 | export type TabRemoveListener = (
29 | tabId:number, removeInfo:{
30 | windowId:number,
31 | isWindowClosing:boolean
32 | }) => void;
33 |
34 | export type TabUpdatedListener = (
35 | tabId:number,
36 | changeInfo:TabChangeInfo,
37 | tab:Tab
38 | ) => void;
39 |
40 | type TabChangeInfo = {
41 | isArticle?:boolean,
42 | mutedInfo?:browser.tabs.MutedInfo,
43 | pinned?:boolean,
44 | status?: "loading"|"complete",
45 | title?:string,
46 | url?:string;
47 | };
48 |
49 | export type TabAttachedListener = (
50 | tabId:number,
51 | attachInfo:{
52 | newWindowId:number,
53 | newPosition:number
54 | }
55 | ) => void;
56 |
57 | export type TabDetachedListener = (
58 | tabId:number,
59 | detachInfo:{
60 | oldWindowId:number,
61 | oldPosition:number
62 | }
63 | ) => void;
64 |
65 | export type TabMovedListener = (
66 | tabId:number,
67 | moveInfo:{
68 | windowId:number,
69 | fromIndex:number,
70 | toIndex:number
71 | }
72 | ) => void;
73 |
74 | export type WindowRemovedListener = (windowId:number) => void;
75 |
76 | export type ContextMenuId = string | number;
--------------------------------------------------------------------------------
/src/ts/util/WebExtAPIHelpers.ts:
--------------------------------------------------------------------------------
1 | import { Tab, TabCreateProperties, Window } from "./Types";
2 |
3 | export async function getCurrentWindowId():Promise {
4 | let wnd = await browser.windows.getLastFocused({populate: false});
5 |
6 | return wnd ? wnd.id : browser.windows.WINDOW_ID_NONE;
7 | }
8 |
9 | export async function getCommandByName(name:string):Promise {
10 | let commands = await browser.commands.getAll();
11 | return commands.find(c => c.name === name);
12 | }
13 |
14 | const tabErrorUrl = browser.runtime.getURL("html/tab-error.html");
15 |
16 | /**
17 | * Creates a new tab (just like `tabs.create`) but catches errors.
18 | * @param createProperties Same as `tabs.create`.
19 | */
20 | export function createTab(createProperties:TabCreateProperties):Promise {
21 | return browser.tabs.create(createProperties).then(tab => tab, error => {
22 | console.error("[TA] Failed to create tab: " + error, error);
23 |
24 | // create a tab that displays the error
25 | let params = new URLSearchParams();
26 | params.append("url", createProperties.url);
27 | params.append("details", error+"");
28 |
29 | return browser.tabs.create({
30 | active: createProperties.active,
31 | pinned: createProperties.pinned,
32 | windowId: createProperties.windowId,
33 | discarded: false,
34 | url: tabErrorUrl + "?" + params
35 | });
36 | });
37 | }
38 |
39 | /**
40 | * Returns a window object of a window with different windowId.
41 | * If no such window exists, a new one is created.
42 | * @param windowId
43 | */
44 | export async function getAnotherWindow(windowId:number):Promise {
45 | console.assert(windowId !== browser.windows.WINDOW_ID_NONE);
46 |
47 | const allWindows = await browser.windows.getAll({windowTypes:["normal"]});
48 | const otherWindows = allWindows.filter(wnd => wnd.id !== windowId);
49 |
50 | if(otherWindows.length === 0) {
51 | console.log("[TA] Creating a new window to prevent the browser from closing...");
52 | let newWindow:Promise = browser.windows.create({});
53 |
54 | // hide the new window for now
55 | browser.windows.update(windowId, {focused: true});
56 |
57 | return newWindow;
58 | } else {
59 | return otherWindows[0];
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "moduleResolution": "node",
5 | "strict": false,
6 | "isolatedModules": true,
7 | "removeComments": true,
8 | "typeRoots": [
9 | "node_modules/@types"
10 | ]
11 | },
12 | "include": [
13 | "src/ts/**/*.ts"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------