├── .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 | ![CI](https://github.com/tim-we/tabs-aside/workflows/CI/badge.svg) 2 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=tim-we_tabs-aside&metric=alert_status)](https://sonarcloud.io/dashboard?id=tim-we_tabs-aside) 3 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=tim-we_tabs-aside&metric=ncloc)](https://sonarcloud.io/dashboard?id=tim-we_tabs-aside) 4 | ![Mozilla Add-on](https://img.shields.io/amo/users/tabs-aside) 5 | ![Mozilla Add-on](https://img.shields.io/amo/rating/tabs-aside) 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 | [![addons.mozilla.org/](https://addons.cdn.mozilla.net/static/img/addons-buttons/AMO-button_2.png)](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 |
20 |

21 |

22 |

23 |
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 |
17 |

18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 |

Details

26 |

27 |         
28 |
29 |

30 | 31 |

32 |
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 | 8 | 9 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/img/browserAction/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 13 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/img/browserAction/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/img/browserMenu/active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/img/browserMenu/add.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/check-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/close-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/copy.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/img/folder-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/help-16.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/img/ionicons_svg_md-code.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/img/menu/aside.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 13 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/img/menu/options-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/menu/tabs.svg: -------------------------------------------------------------------------------- 1 | 8 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /src/img/new-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/report-issues.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/img/sidebar/Search.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/sidebar/arrowhead-down-12.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/sidebar/copy-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/sidebar/delete-light-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/img/sidebar/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/img/sidebar/info-light-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/img/sidebar/more-16-thin.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /src/img/sidebar/more-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/img/sidebar/open-in-new-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/img/sidebar/pin-12.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/img/sidebar/readermode.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/img/sidebar/restore-light-16.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/img/warning.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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 |
16 | 17 | 18 |
19 |
20 |
${i18n("session_restore")}
21 |
${i18n("session_aside")}
22 |
23 |
24 |
25 |
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 | --------------------------------------------------------------------------------