├── .browserslistrc ├── .circleci └── config.yml ├── .commitlintrc.json ├── .editorconfig ├── .gitignore ├── .gitignore-sync ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .lintstagedrc.mjs ├── .node-version ├── .prettierrc.mjs ├── .remarkrc.mjs ├── .renovaterc.json ├── .size-limit.json ├── .stylelintrc.cjs ├── .swcrc ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── PRIVACY.md ├── README.md ├── __mocks__ ├── browserExtension │ ├── bookmarks.test.ts │ ├── bookmarks.ts │ ├── i18n.ts │ ├── index.ts │ ├── runtime.ts │ ├── storage.test.ts │ ├── storage.ts │ └── utils │ │ ├── WebExtEventEmitter.ts │ │ └── object.ts ├── fileMock.ts └── webextension-polyfill.ts ├── eslint.config.js ├── jest-setup.ts ├── jest.config.mjs ├── markdown ├── contributing.md ├── description.md ├── developer_guide.md ├── legacy_version.md ├── title.md └── todo.md ├── package.json ├── scripts └── generateLocalesFromTransifex.ts ├── src ├── core │ ├── _locales │ │ ├── de │ │ │ └── messages.json │ │ ├── en │ │ │ └── messages.json │ │ ├── es │ │ │ └── messages.json │ │ ├── fr │ │ │ └── messages.json │ │ ├── it │ │ │ └── messages.json │ │ ├── ko │ │ │ └── messages.json │ │ ├── nb │ │ │ └── messages.json │ │ ├── nl │ │ │ └── messages.json │ │ ├── pt │ │ │ └── messages.json │ │ ├── ru │ │ │ └── messages.json │ │ ├── sv │ │ │ └── messages.json │ │ ├── vi │ │ │ └── messages.json │ │ ├── zh_CN │ │ │ └── messages.json │ │ └── zh_TW │ │ │ └── messages.json │ ├── components │ │ └── baseItems │ │ │ ├── ActionlessForm │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ │ ├── Button │ │ │ ├── index.test.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ │ ├── Input │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ │ ├── PlainList │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ │ ├── Select │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ │ └── StylelessButton │ │ │ ├── index.test.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ ├── constants │ │ └── index.ts │ ├── hooks │ │ └── useLatestRef.ts │ ├── images │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon38.png │ │ └── icon48.png │ ├── styles │ │ ├── composes.module.css │ │ └── globals.module.css │ ├── types │ │ ├── assets.d.ts │ │ ├── options.ts │ │ └── webextension-polyfill.d.ts │ └── utils │ │ ├── array.ts │ │ ├── createAndRenderRoot.ts │ │ ├── getOptionsConfig.ts │ │ ├── isMac.ts │ │ ├── queryClient.tsx │ │ └── withProviders.tsx ├── manifest.yml ├── options │ ├── components │ │ ├── App │ │ │ ├── globals.module.css │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Contributors.tsx │ │ ├── Donate.tsx │ │ ├── ExternalLink │ │ │ ├── index.test.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── NavBar │ │ │ ├── index.test.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── OptionForm │ │ │ ├── OptionForm.tsx │ │ │ ├── OptionItem │ │ │ │ ├── InputNumber │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.module.css │ │ │ │ ├── InputSelect │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.module.css │ │ │ │ ├── SelectButton │ │ │ │ │ ├── Option │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.module.css │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.module.css │ │ │ │ ├── SelectMultiple │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.module.css │ │ │ │ ├── SelectString │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.module.css │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── option-form.module.css │ │ ├── Router.tsx │ │ ├── contributors.module.css │ │ ├── donate.module.css │ │ └── navigationContext.ts │ ├── constants │ │ └── index.ts │ ├── hooks │ │ ├── __tests__ │ │ │ └── options.test.ts │ │ └── options.ts │ ├── images │ │ └── btn_donateCC_LG.webp │ └── index.tsx ├── popup │ ├── components │ │ ├── App │ │ │ ├── globals.module.css │ │ │ ├── index.tsx │ │ │ └── useGlobalEvents.ts │ │ ├── Backdrop │ │ │ ├── backdrop.module.css │ │ │ └── index.tsx │ │ ├── BookmarkTree │ │ │ ├── BookmarkRow │ │ │ │ ├── BookmarkRow.tsx │ │ │ │ ├── bookmark-row.module.css │ │ │ │ ├── index.tsx │ │ │ │ └── useTooltip.ts │ │ │ ├── BookmarkTree.tsx │ │ │ ├── NoSearchResult.tsx │ │ │ ├── TreeHeader.tsx │ │ │ ├── bookmark-tree.module.css │ │ │ ├── index.tsx │ │ │ ├── no-search-result.module.css │ │ │ ├── tree-header.module.css │ │ │ ├── useRowClickEvents.ts │ │ │ ├── useRowDragEvents.ts │ │ │ └── useRowHoverEvents.ts │ │ ├── BookmarkTrees │ │ │ ├── BookmarkTrees.tsx │ │ │ ├── bookmark-trees.module.css │ │ │ ├── index.tsx │ │ │ ├── withDragAndDropEvents.tsx │ │ │ └── withKeyboardNav.tsx │ │ ├── Search │ │ │ ├── SearchInput.tsx │ │ │ ├── index.tsx │ │ │ └── search-input.module.css │ │ ├── dragAndDrop │ │ │ ├── DragAndDropConsumer.tsx │ │ │ ├── DragAndDropContext.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── useDragZoneEvents.ts │ │ ├── editor │ │ │ ├── Editor.tsx │ │ │ ├── EditorContext.ts │ │ │ ├── EditorForm.tsx │ │ │ ├── editor-form.module.css │ │ │ └── index.ts │ │ ├── floatingWindow │ │ │ ├── FloatingWindow.tsx │ │ │ ├── FloatingWindowContext.ts │ │ │ ├── index.ts │ │ │ └── useGlobalBodySize.ts │ │ ├── keyBindings │ │ │ ├── KeyBindingsContext.ts │ │ │ ├── KeyBindingsWindow.module.css │ │ │ ├── KeyBindingsWindow.tsx │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── useKeyBindingsEvent.ts │ │ ├── listNavigation │ │ │ ├── ListNavigationContext.ts │ │ │ ├── index.ts │ │ │ └── useKeyboardNav.ts │ │ └── menu │ │ │ ├── Menu.tsx │ │ │ ├── MenuContainer.tsx │ │ │ ├── MenuRow.tsx │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── menu-row.module.css │ │ │ ├── menu.module.css │ │ │ ├── types.ts │ │ │ ├── useMenu.ts │ │ │ └── utils.ts │ ├── constants │ │ ├── menu.ts │ │ ├── ui.ts │ │ └── windows.ts │ ├── images │ │ ├── cross.svg │ │ ├── folder.svg │ │ └── search.svg │ ├── index.tsx │ ├── modules │ │ ├── bookmarks │ │ │ ├── constants.ts │ │ │ ├── contexts │ │ │ │ └── bookmarkTrees.ts │ │ │ ├── hooks │ │ │ │ ├── constants │ │ │ │ │ └── reactQuery.ts │ │ │ │ └── useGetBookmarkInfo.ts │ │ │ ├── methods │ │ │ │ ├── copyBookmark.ts │ │ │ │ ├── createBookmark.ts │ │ │ │ ├── getBookmark.ts │ │ │ │ ├── openBookmark.ts │ │ │ │ └── sortBookmarksByName.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── clickBookmark.ts │ │ │ │ ├── faviconUrl.ts │ │ │ │ ├── generators.ts │ │ │ │ ├── sortByTitle.ts │ │ │ │ └── transformers.ts │ │ ├── clipboard.ts │ │ ├── lastPositions │ │ │ ├── index.test.tsx │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── localStorage.tsx │ │ └── options.tsx │ └── utils │ │ ├── cycle.ts │ │ ├── deleteFromMap.ts │ │ └── getLastMapKey.ts └── template.html ├── svgo.config.mjs ├── tsconfig.json ├── webpack.config.mts └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | chrome >= 111 2 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | docker: 5 | - image: 'cimg/node:22.11' 6 | steps: 7 | - 'checkout' 8 | # require `sudo`: https://github.com/CircleCI-Public/cimg-node/issues/277 9 | - run: 'sudo corepack enable' 10 | - restore_cache: 11 | key: 'yarn-packages-{{ checksum "yarn.lock" }}' 12 | - run: 'yarn install --immutable' 13 | - save_cache: 14 | key: 'yarn-packages-{{ checksum "yarn.lock" }}' 15 | paths: 16 | - '.yarn/cache' 17 | - run: 'make ci' 18 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/commitlintrc", 3 | "extends": ["@commitlint/config-conventional"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################################ 2 | # GENERATED BY IGNORE-SYNC, DO NOT EDIT!!! # 3 | # https://github.com/foray1010/ignore-sync # 4 | ############################################ 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional stylelint cache 63 | .stylelintcache 64 | 65 | # Microbundle cache 66 | .rpt2_cache/ 67 | .rts2_cache_cjs/ 68 | .rts2_cache_es/ 69 | .rts2_cache_umd/ 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variable files 81 | .env 82 | .env.development.local 83 | .env.test.local 84 | .env.production.local 85 | .env.local 86 | 87 | # parcel-bundler cache (https://parceljs.org/) 88 | .cache 89 | .parcel-cache 90 | 91 | # Next.js build output 92 | .next 93 | out 94 | 95 | # Nuxt.js build / generate output 96 | .nuxt 97 | dist 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and not Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # vuepress v2.x temp and cache directory 109 | .temp 110 | .cache 111 | 112 | # Docusaurus cache and generated files 113 | .docusaurus 114 | 115 | # Serverless directories 116 | .serverless/ 117 | 118 | # FuseBox cache 119 | .fusebox/ 120 | 121 | # DynamoDB Local files 122 | .dynamodb/ 123 | 124 | # TernJS port file 125 | .tern-port 126 | 127 | # Stores VSCode versions used for testing VSCode extensions 128 | .vscode-test 129 | 130 | # yarn v2 131 | .yarn/cache 132 | .yarn/unplugged 133 | .yarn/build-state.yml 134 | .yarn/install-state.gz 135 | .pnp.* 136 | 137 | # General 138 | .DS_Store 139 | .AppleDouble 140 | .LSOverride 141 | 142 | # Icon must end with two \r 143 | Icon 144 | 145 | # Thumbnails 146 | ._* 147 | 148 | # Files that might appear in the root of a volume 149 | .DocumentRevisions-V100 150 | .fseventsd 151 | .Spotlight-V100 152 | .TemporaryItems 153 | .Trashes 154 | .VolumeIcon.icns 155 | .com.apple.timemachine.donotpresent 156 | 157 | # Directories potentially created on remote AFP share 158 | .AppleDB 159 | .AppleDesktop 160 | Network Trash Folder 161 | Temporary Items 162 | .apdisk 163 | 164 | *~ 165 | 166 | # temporary files which can be created if a process still has a handle open of a deleted file 167 | .fuse_hidden* 168 | 169 | # KDE directory preferences 170 | .directory 171 | 172 | # Linux trash folder which might appear on any partition or disk 173 | .Trash-* 174 | 175 | # .nfs files are created when an open file is removed but is still being accessed 176 | .nfs* 177 | 178 | # Windows thumbnail cache files 179 | Thumbs.db 180 | Thumbs.db:encryptable 181 | ehthumbs.db 182 | ehthumbs_vista.db 183 | 184 | # Dump file 185 | *.stackdump 186 | 187 | # Folder config file 188 | [Dd]esktop.ini 189 | 190 | # Recycle Bin used on file shares 191 | $RECYCLE.BIN/ 192 | 193 | # Windows Installer files 194 | *.cab 195 | *.msi 196 | *.msix 197 | *.msm 198 | *.msp 199 | 200 | # Windows shortcuts 201 | *.lnk 202 | 203 | build/ 204 | *.css.d.ts 205 | -------------------------------------------------------------------------------- /.gitignore-sync: -------------------------------------------------------------------------------- 1 | [github/gitignore] 2 | Node.gitignore 3 | Global/macOS.gitignore 4 | Global/Linux.gitignore 5 | Global/Windows.gitignore 6 | 7 | [inline] 8 | build/ 9 | *.css.d.ts 10 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | make build-css-types 2 | lint-staged 3 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | CI=true make ci 2 | -------------------------------------------------------------------------------- /.lintstagedrc.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | '*.{cjs,cts,js,mjs,mts,ts,tsx}': [ 3 | 'yarn prettier --write', 4 | 'eslint --fix', 5 | 'jest --findRelatedTests --passWithNoTests', 6 | ], 7 | '*.css': ['yarn prettier --write', 'yarn stylelint --fix'], 8 | '*.{json,yaml,yml}': 'yarn prettier --write', 9 | '*.{markdown,md}': ['yarn prettier --write', 'yarn remark'], 10 | '*.svg': 'svgo', 11 | '*ignore-sync': 'ignore-sync', 12 | } 13 | export default config 14 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.16.0 2 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | export { default } from '@foray1010/prettier-config' 2 | -------------------------------------------------------------------------------- /.remarkrc.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ['@foray1010/remark-preset'], 3 | } 4 | export default config 5 | -------------------------------------------------------------------------------- /.renovaterc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["@foray1010/renovate-config"], 4 | "packageRules": [ 5 | { 6 | "matchPackageNames": ["eslint"], 7 | "enabled": false 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "build/production/js/common.js", 4 | "limit": "73.08 kB" 5 | }, 6 | { 7 | "path": "build/production/js/options.js", 8 | "limit": "4.51 kB" 9 | }, 10 | { 11 | "path": "build/production/js/popup.js", 12 | "limit": "12.01 kB" 13 | }, 14 | { 15 | "path": "build/production/options.html", 16 | "limit": "1.72 kB" 17 | }, 18 | { 19 | "path": "build/production/popup.html", 20 | "limit": "1.96 kB" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /.stylelintrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | extends: ['@foray1010/stylelint-config'], 5 | rules: { 6 | 'plugin/no-unsupported-browser-features': [ 7 | true, 8 | { 9 | ignore: [ 10 | // We are not using button with `display: contents` 11 | 'css-display-contents', 12 | // Handled by lightningcss 13 | 'css-nesting', 14 | ], 15 | severity: 'error', 16 | }, 17 | ], 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc.json", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": true 7 | }, 8 | "transform": { 9 | "react": { 10 | "runtime": "automatic", 11 | "useBuiltins": true 12 | } 13 | } 14 | }, 15 | "env": { 16 | // `usage` mode is less efficient than `entry` 17 | "mode": "entry", 18 | "coreJs": "3.41", 19 | "shippedProposals": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | defaultSemverRangePrefix: '' 2 | 3 | enableGlobalCache: false 4 | 5 | enableTelemetry: false 6 | 7 | nodeLinker: node-modules 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2023 Yeung Yiu For 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifndef NPROC 2 | NPROC=$(shell nproc) 3 | endif 4 | MAKEFLAGS += --jobs=$(NPROC) --silent 5 | 6 | help: # get all command options 7 | @grep -E '^[a-zA-Z0-9 -]+:.*#' $(MAKEFILE_LIST) \ 8 | | sort \ 9 | | while read -r l; do \ 10 | printf "\033[1;32m$$(echo $$l | cut -f 1 -d':')\033[00m:$$(echo $$l | cut -f 2- -d'#')\n"; \ 11 | done 12 | .DEFAULT_GOAL := help 13 | .PHONY: help 14 | 15 | bin_dir := node_modules/.bin 16 | 17 | ts-node := node --experimental-strip-types 18 | 19 | src_dir := src 20 | 21 | locales: # download the latest locale files from transifex 22 | $(ts-node) scripts/generateLocalesFromTransifex.ts 23 | .PHONY: locales 24 | 25 | size-limit: build-prod # limit build size 26 | $(bin_dir)/size-limit 27 | .PHONY: size-limit 28 | 29 | tcm := $(bin_dir)/tcm 30 | webpack := $(ts-node) $(bin_dir)/webpack-cli 31 | 32 | build-css-types: 33 | $(tcm) $(src_dir) 34 | build-prod: build-css-types 35 | NODE_ENV=production $(webpack) 36 | build: build-prod size-limit # build production extension 37 | .PHONY: build-prod build 38 | 39 | dev: # build development extension in watch mode 40 | $(tcm) $(src_dir) --watch & 41 | NODE_ENV=development $(webpack) 42 | .PHONY: dev 43 | 44 | markdown_dir := markdown 45 | 46 | define buildmd 47 | mkdir -p $(dir $(2)) 48 | find $(1) | xargs -I{} sh -c "cat {}; echo" | sed '$$d' > $(2) 49 | endef 50 | build/store.md: $(markdown_dir)/description.md $(markdown_dir)/todo.md $(markdown_dir)/contributing.md 51 | $(call buildmd,$^,$@) 52 | README.md: $(markdown_dir)/title.md $(markdown_dir)/description.md $(markdown_dir)/legacy_version.md $(markdown_dir)/developer_guide.md $(markdown_dir)/todo.md $(markdown_dir)/contributing.md 53 | $(call buildmd,$^,$@) 54 | md: build/store.md README.md # generate markdown files 55 | .PHONY: md 56 | 57 | lint-css: # lint by stylelint 58 | yarn stylelint '**/*.css' 59 | lint-format: md # check if files follow prettier formatting 60 | yarn prettier --check . 61 | lint-md: md # lint by remark 62 | yarn remark . 63 | lint-js: build-css-types # lint by eslint 64 | $(bin_dir)/eslint . 65 | lint: lint-css lint-format lint-md lint-js # run all lint tasks 66 | .PHONY: lint-css lint-format lint-md lint-js lint 67 | 68 | test: # run tests 69 | $(bin_dir)/jest 70 | .PHONY: test 71 | 72 | type-check: build-css-types # type check by tsc 73 | $(bin_dir)/tsc 74 | type-coverage: build-css-types # check type coverage 75 | $(bin_dir)/type-coverage --strict --at-least 99 --detail --ignore-catch -- $(src_dir)/** 76 | type: type-check type-coverage # run all type tasks 77 | .PHONY: type-check type-coverage type 78 | 79 | ci: build md lint test type # run all checkings on CI 80 | .PHONY: ci 81 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | We will not collect nor sell any of your personal data neither on your side nor our side, we use them just for necessary features: 4 | 5 | - `Listing bookmarks` and `Edit bookmarks` require permission - `Read and change your bookmarks` 6 | 7 | - `Add current page` and `Open bookmark in tab` require permission - `Read your browsing history` 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Popup my Bookmarks 2 | 3 | [![Version On Chrome Web Store](https://img.shields.io/chrome-web-store/v/mppflflkbbafeopeoeigkbbdjdbeifni.svg?maxAge=3600)](https://chromewebstore.google.com/detail/popup-my-bookmarks/mppflflkbbafeopeoeigkbbdjdbeifni) 4 | [![Download Count On Chrome Web Store](https://img.shields.io/chrome-web-store/users/mppflflkbbafeopeoeigkbbdjdbeifni.svg?maxAge=3600)](https://chromewebstore.google.com/detail/popup-my-bookmarks/mppflflkbbafeopeoeigkbbdjdbeifni) 5 | [![Build Status](https://img.shields.io/circleci/build/gh/foray1010/Popup-my-Bookmarks/master.svg?maxAge=3600)](https://app.circleci.com/pipelines/github/foray1010/Popup-my-Bookmarks?branch=master) 6 | 7 | [Popup my Bookmarks](https://chromewebstore.google.com/detail/popup-my-bookmarks/mppflflkbbafeopeoeigkbbdjdbeifni) is a Chrome extension aims at providing a more efficient way to view and manage your bookmarks menu: 8 | 9 | - Firefox / IE-like bookmarks menu 10 | 11 | - Place mouse over folders to open it 12 | 13 | - Search bookmarks when you type 14 | 15 | - Do what Bookmark manager can do and more (e.g., Sort bookmarks by name, Add separator) 16 | 17 | - Highly configurable 18 | 19 | - Save 24px of your vertical workspace (Rock on Chromebook!) 20 | 21 | - Take as few permissions as possible, we never put your privacy at risk 22 | 23 | - No background running application, save computer memory and your privacy! 24 | 25 | Changelog: 26 | 27 | ## Legacy version 28 | 29 | Please visit following branches for the legacy versions that support older version of Chrome 30 | 31 | - [>= Chrome 64](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_64) 32 | - [>= Chrome 55](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_55) 33 | - [>= Chrome 34](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_34) 34 | - [>= Chrome 26](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_26) 35 | - [>= Chrome 20](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_20) 36 | 37 | ## Developer guide 38 | 39 | ### Before you start 40 | 41 | 1. We are using [corepack](https://nodejs.org/api/corepack.html) to manage the `yarn` version 42 | 43 | ```sh 44 | corepack enable 45 | ``` 46 | 47 | 1. `cd` to your workspace and install all dependencies 48 | 49 | ```sh 50 | yarn install 51 | ``` 52 | 53 | ### Commands 54 | 55 | 1. build 56 | 57 | ```sh 58 | make build 59 | ``` 60 | 61 | To build the whole extension and output a zip file (./build/production/{version_in_package.json}.zip) for uploading to Chrome Web Store 62 | 63 | 1. dev 64 | 65 | ```sh 66 | make dev 67 | ``` 68 | 69 | To build a temporary folder `build/development` for loading unpacked extension 70 | 71 | 1. lint 72 | 73 | ```sh 74 | make lint 75 | ``` 76 | 77 | To lint if all files follow our linter config 78 | 79 | 1. locales 80 | 81 | ```sh 82 | make locales 83 | ``` 84 | 85 | To download the latest locale files from transifex 86 | 87 | - `build/store.md` - Description for Chrome Web Store 88 | - `README.md` - Description for GitHub 89 | 90 | 1. md 91 | 92 | ```sh 93 | make md 94 | ``` 95 | 96 | To generate markdown files 97 | 98 | - `build/store.md` - Description for Chrome Web Store 99 | - `README.md` - Description for GitHub 100 | 101 | ## Todo & Working Progress 102 | 103 | See 104 | 105 | ## Contributing 106 | 107 | - Translate to other languages. It's all depended on volunteers as I am not a linguist. ;-) 108 | 109 | Please join our translation team on 110 | 111 | - Fork me on GitHub, join our development! 112 | 113 | Repo: 114 | -------------------------------------------------------------------------------- /__mocks__/browserExtension/i18n.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/require-await */ 2 | 3 | import messages from '@/core/_locales/en/messages.json' 4 | 5 | class I18n implements Readonly { 6 | public async detectLanguage(): Promise { 7 | throw new Error('Not implemented') 8 | } 9 | 10 | public async getAcceptLanguages() { 11 | return Array.from(navigator.languages) 12 | } 13 | 14 | public getMessage(messageName: string) { 15 | if (!Object.hasOwn(messages, messageName)) return '' 16 | return messages[messageName as keyof typeof messages].message 17 | } 18 | 19 | public getUILanguage() { 20 | return navigator.language 21 | } 22 | } 23 | 24 | const i18n = new I18n() 25 | export default i18n 26 | -------------------------------------------------------------------------------- /__mocks__/browserExtension/index.ts: -------------------------------------------------------------------------------- 1 | import bookmarks from './bookmarks.js' 2 | import i18n from './i18n.js' 3 | import runtime from './runtime.js' 4 | import storage from './storage.js' 5 | 6 | const browserMock = { 7 | bookmarks, 8 | i18n, 9 | runtime, 10 | storage, 11 | } as const satisfies Partial 12 | 13 | export default browserMock 14 | -------------------------------------------------------------------------------- /__mocks__/browserExtension/runtime.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/require-await */ 2 | 3 | import path from 'node:path' 4 | 5 | class Runtime implements Readonly> { 6 | public getURL(subPath: string) { 7 | return path.join( 8 | 'chrome-extension://apidhijjdkkimhbifblnemkcnmhellkf', 9 | subPath, 10 | ) 11 | } 12 | 13 | public async openOptionsPage() { 14 | window.close() 15 | } 16 | } 17 | 18 | const runtime = new Runtime() as typeof browser.runtime 19 | export default runtime 20 | -------------------------------------------------------------------------------- /__mocks__/browserExtension/storage.test.ts: -------------------------------------------------------------------------------- 1 | import storage from './storage.js' 2 | 3 | describe('browser.storage', () => { 4 | it('should fire callback for listeners', async () => { 5 | const testCallback = jest.fn() 6 | 7 | storage.onChanged.addListener(testCallback) 8 | expect(storage.onChanged.hasListener(testCallback)).toBe(true) 9 | 10 | await storage.local.set({ where: 'local' }) 11 | await storage.local.set({ where: undefined }) // Should be ignored 12 | expect(testCallback.mock.calls).toHaveLength(1) 13 | expect(testCallback).toHaveBeenLastCalledWith( 14 | { where: { newValue: 'local' } }, 15 | 'local', 16 | ) 17 | 18 | await storage.local.remove('where') 19 | expect(testCallback.mock.calls).toHaveLength(2) 20 | expect(testCallback).toHaveBeenLastCalledWith( 21 | { where: { oldValue: 'local' } }, 22 | 'local', 23 | ) 24 | 25 | await storage.sync.set({ where: 'sync' }) 26 | expect(testCallback.mock.calls).toHaveLength(3) 27 | expect(testCallback).toHaveBeenLastCalledWith( 28 | { where: { newValue: 'sync' } }, 29 | 'sync', 30 | ) 31 | 32 | await storage.sync.set({ where: 'sync2' }) 33 | expect(testCallback.mock.calls).toHaveLength(4) 34 | expect(testCallback).toHaveBeenLastCalledWith( 35 | { where: { oldValue: 'sync', newValue: 'sync2' } }, 36 | 'sync', 37 | ) 38 | 39 | await storage.sync.set({ secondNewField: 'secondNewField' }) 40 | expect(testCallback.mock.calls).toHaveLength(5) 41 | expect(testCallback).toHaveBeenLastCalledWith( 42 | { secondNewField: { newValue: 'secondNewField' } }, 43 | 'sync', 44 | ) 45 | 46 | await storage.sync.clear() 47 | expect(testCallback.mock.calls).toHaveLength(6) 48 | expect(testCallback).toHaveBeenLastCalledWith( 49 | { 50 | where: { oldValue: 'sync2' }, 51 | secondNewField: { oldValue: 'secondNewField' }, 52 | }, 53 | 'sync', 54 | ) 55 | 56 | await storage.managed.set({ where: 'managed' }) 57 | expect(testCallback.mock.calls).toHaveLength(7) 58 | expect(testCallback).toHaveBeenLastCalledWith( 59 | { where: { newValue: 'managed' } }, 60 | 'managed', 61 | ) 62 | }) 63 | 64 | it('should not fire callback after removing listeners', async () => { 65 | const testCallback = jest.fn() 66 | 67 | storage.onChanged.addListener(testCallback) 68 | expect(storage.onChanged.hasListener(testCallback)).toBe(true) 69 | 70 | storage.onChanged.removeListener(testCallback) 71 | expect(storage.onChanged.hasListener(testCallback)).toBe(false) 72 | 73 | await storage.local.set({ where: 'local' }) 74 | await storage.local.set({ where: undefined }) 75 | await storage.local.remove('where') 76 | await storage.sync.set({ where: 'sync' }) 77 | await storage.sync.set({ where: 'sync2' }) 78 | await storage.sync.set({ secondNewField: 'secondNewField' }) 79 | await storage.sync.clear() 80 | await storage.managed.set({ where: 'managed' }) 81 | 82 | expect(testCallback).not.toHaveBeenCalled() 83 | }) 84 | 85 | it('should fire callback for all listeners', async () => { 86 | const testCallback1 = jest.fn() 87 | storage.onChanged.addListener(testCallback1) 88 | 89 | const testCallback2 = jest.fn() 90 | storage.onChanged.addListener(testCallback2) 91 | 92 | await storage.local.set({ where: 'local' }) 93 | 94 | expect(testCallback1).toHaveBeenLastCalledWith( 95 | { where: { newValue: 'local' } }, 96 | 'local', 97 | ) 98 | 99 | expect(testCallback2).toHaveBeenLastCalledWith( 100 | { where: { newValue: 'local' } }, 101 | 'local', 102 | ) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /__mocks__/browserExtension/utils/WebExtEventEmitter.ts: -------------------------------------------------------------------------------- 1 | export class WebExtEventEmitter< 2 | EventArgs extends readonly unknown[], 3 | WebExtEventEventListener extends (...args: EventArgs) => void = ( 4 | ...args: EventArgs 5 | ) => void, 6 | > { 7 | readonly #eventTarget = new EventTarget() 8 | readonly #eventType = 'message' 9 | readonly #listenerMap = new WeakMap() 10 | 11 | public addListener(callback: WebExtEventEventListener): void { 12 | const listener: EventListener = (evt) => { 13 | callback(...(evt as CustomEvent).detail) 14 | } 15 | 16 | this.#listenerMap.set(callback, listener) 17 | this.#eventTarget.addEventListener(this.#eventType, listener) 18 | } 19 | 20 | public hasListener(callback: WebExtEventEventListener): boolean { 21 | return this.#listenerMap.has(callback) 22 | } 23 | 24 | public removeListener(callback: WebExtEventEventListener): void { 25 | const listener = this.#listenerMap.get(callback) 26 | if (!listener) return 27 | 28 | this.#listenerMap.delete(callback) 29 | this.#eventTarget.removeEventListener(this.#eventType, listener) 30 | } 31 | 32 | public dispatchEvent(eventData: EventArgs) { 33 | this.#eventTarget.dispatchEvent( 34 | new CustomEvent(this.#eventType, { detail: eventData }), 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /__mocks__/browserExtension/utils/object.ts: -------------------------------------------------------------------------------- 1 | export function pick, U extends keyof T>( 2 | object: T, 3 | keys: readonly U[], 4 | ): Pick { 5 | const acc = {} as Pick 6 | for (const key of keys) { 7 | if (Object.hasOwn(object, key)) { 8 | acc[key] = object[key] 9 | } 10 | } 11 | return acc 12 | } 13 | -------------------------------------------------------------------------------- /__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | const file = 'test-file-stub' 2 | export default file 3 | -------------------------------------------------------------------------------- /__mocks__/webextension-polyfill.ts: -------------------------------------------------------------------------------- 1 | export { default } from './browserExtension/index.js' 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { 2 | eslintIgnoresConfig, 3 | eslintNodeConfig, 4 | eslintReactConfig, 5 | } from '@foray1010/eslint-config' 6 | import eslintPluginTanstackQuery from '@tanstack/eslint-plugin-query' 7 | // eslint-disable-next-line import-x/extensions, import-x/no-unresolved 8 | import { defineConfig, globalIgnores } from 'eslint/config' 9 | 10 | const reactFiles = ['__mocks__/**', 'src/**'] 11 | 12 | const config = defineConfig( 13 | eslintIgnoresConfig, 14 | globalIgnores(['**/*.css.d.ts']), 15 | { 16 | ignores: reactFiles, 17 | extends: [eslintNodeConfig], 18 | }, 19 | { 20 | files: reactFiles, 21 | extends: [ 22 | eslintReactConfig, 23 | eslintPluginTanstackQuery.configs['flat/recommended'], 24 | ], 25 | rules: { 26 | // https://github.com/import-js/eslint-plugin-import/issues/1739 27 | 'import-x/no-unresolved': ['error', { ignore: [String.raw`\?`] }], 28 | }, 29 | }, 30 | ) 31 | export default config 32 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | const config = { 3 | preset: '@foray1010', 4 | moduleNameMapper: { 5 | '\\.css$': '/__mocks__/fileMock.ts', 6 | '\\.(png|webp)$': '/__mocks__/fileMock.ts', 7 | '\\.svg(\\?svgUse)*$': '/__mocks__/fileMock.ts', 8 | '^@/(.*)$': '/src/$1', 9 | }, 10 | setupFilesAfterEnv: ['/jest-setup.ts'], 11 | testEnvironment: 'jest-environment-jsdom', 12 | transform: { 13 | '\\.tsx?$': '@swc/jest', 14 | }, 15 | } 16 | export default config 17 | -------------------------------------------------------------------------------- /markdown/contributing.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | - Translate to other languages. It's all depended on volunteers as I am not a linguist. ;-) 4 | 5 | Please join our translation team on 6 | 7 | - Fork me on GitHub, join our development! 8 | 9 | Repo: 10 | -------------------------------------------------------------------------------- /markdown/description.md: -------------------------------------------------------------------------------- 1 | - Firefox / IE-like bookmarks menu 2 | 3 | - Place mouse over folders to open it 4 | 5 | - Search bookmarks when you type 6 | 7 | - Do what Bookmark manager can do and more (e.g., Sort bookmarks by name, Add separator) 8 | 9 | - Highly configurable 10 | 11 | - Save 24px of your vertical workspace (Rock on Chromebook!) 12 | 13 | - Take as few permissions as possible, we never put your privacy at risk 14 | 15 | - No background running application, save computer memory and your privacy! 16 | 17 | Changelog: 18 | -------------------------------------------------------------------------------- /markdown/developer_guide.md: -------------------------------------------------------------------------------- 1 | ## Developer guide 2 | 3 | ### Before you start 4 | 5 | 1. We are using [corepack](https://nodejs.org/api/corepack.html) to manage the `yarn` version 6 | 7 | ```sh 8 | corepack enable 9 | ``` 10 | 11 | 1. `cd` to your workspace and install all dependencies 12 | 13 | ```sh 14 | yarn install 15 | ``` 16 | 17 | ### Commands 18 | 19 | 1. build 20 | 21 | ```sh 22 | make build 23 | ``` 24 | 25 | To build the whole extension and output a zip file (./build/production/{version_in_package.json}.zip) for uploading to Chrome Web Store 26 | 27 | 1. dev 28 | 29 | ```sh 30 | make dev 31 | ``` 32 | 33 | To build a temporary folder `build/development` for loading unpacked extension 34 | 35 | 1. lint 36 | 37 | ```sh 38 | make lint 39 | ``` 40 | 41 | To lint if all files follow our linter config 42 | 43 | 1. locales 44 | 45 | ```sh 46 | make locales 47 | ``` 48 | 49 | To download the latest locale files from transifex 50 | 51 | - `build/store.md` - Description for Chrome Web Store 52 | - `README.md` - Description for GitHub 53 | 54 | 1. md 55 | 56 | ```sh 57 | make md 58 | ``` 59 | 60 | To generate markdown files 61 | 62 | - `build/store.md` - Description for Chrome Web Store 63 | - `README.md` - Description for GitHub 64 | -------------------------------------------------------------------------------- /markdown/legacy_version.md: -------------------------------------------------------------------------------- 1 | ## Legacy version 2 | 3 | Please visit following branches for the legacy versions that support older version of Chrome 4 | 5 | - [>= Chrome 64](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_64) 6 | - [>= Chrome 55](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_55) 7 | - [>= Chrome 34](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_34) 8 | - [>= Chrome 26](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_26) 9 | - [>= Chrome 20](https://github.com/foray1010/Popup-my-Bookmarks/tree/minimum_chrome_version_20) 10 | -------------------------------------------------------------------------------- /markdown/title.md: -------------------------------------------------------------------------------- 1 | # Popup my Bookmarks 2 | 3 | [![Version On Chrome Web Store](https://img.shields.io/chrome-web-store/v/mppflflkbbafeopeoeigkbbdjdbeifni.svg?maxAge=3600)](https://chromewebstore.google.com/detail/popup-my-bookmarks/mppflflkbbafeopeoeigkbbdjdbeifni) 4 | [![Download Count On Chrome Web Store](https://img.shields.io/chrome-web-store/users/mppflflkbbafeopeoeigkbbdjdbeifni.svg?maxAge=3600)](https://chromewebstore.google.com/detail/popup-my-bookmarks/mppflflkbbafeopeoeigkbbdjdbeifni) 5 | [![Build Status](https://img.shields.io/circleci/build/gh/foray1010/Popup-my-Bookmarks/master.svg?maxAge=3600)](https://app.circleci.com/pipelines/github/foray1010/Popup-my-Bookmarks?branch=master) 6 | 7 | [Popup my Bookmarks](https://chromewebstore.google.com/detail/popup-my-bookmarks/mppflflkbbafeopeoeigkbbdjdbeifni) is a Chrome extension aims at providing a more efficient way to view and manage your bookmarks menu: 8 | -------------------------------------------------------------------------------- /markdown/todo.md: -------------------------------------------------------------------------------- 1 | ## Todo & Working Progress 2 | 3 | See 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/package", 3 | "name": "Popup-my-Bookmarks", 4 | "version": "8.1.1", 5 | "private": true, 6 | "description": "A more efficient way to view and manage your bookmarks menu.", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "postinstall": "husky", 11 | "prettier": "prettier --ignore-path=node_modules/@foray1010/prettier-config/prettierignore", 12 | "remark": "remark --frail --ignore-path=node_modules/@foray1010/remark-preset/remarkignore --ignore-path-resolve-from=cwd --silently-ignore", 13 | "stylelint": "stylelint --ignore-path=node_modules/@foray1010/stylelint-config/stylelintignore" 14 | }, 15 | "dependencies": { 16 | "@fontsource/archivo-narrow": "5.2.5", 17 | "@tanstack/react-form": "1.9.0", 18 | "@tanstack/react-query": "5.77.2", 19 | "@tanstack/react-query-devtools": "5.77.2", 20 | "@tanstack/react-virtual": "3.13.9", 21 | "classix": "2.2.2", 22 | "constate": "3.3.3", 23 | "core-js": "3.42.0", 24 | "react": "19.1.0", 25 | "react-dom": "19.1.0", 26 | "use-debounce": "10.0.4", 27 | "use-deep-compare": "1.3.0", 28 | "use-resize-observer": "9.1.0", 29 | "use-typed-event-listener": "4.0.2", 30 | "webextension-polyfill": "0.12.0" 31 | }, 32 | "devDependencies": { 33 | "@commitlint/cli": "19.8.1", 34 | "@commitlint/config-conventional": "19.8.1", 35 | "@foray1010/eslint-config": "15.1.0", 36 | "@foray1010/jest-preset": "5.1.0", 37 | "@foray1010/prettier-config": "11.0.1", 38 | "@foray1010/remark-preset": "10.0.4", 39 | "@foray1010/stylelint-config": "16.0.0", 40 | "@foray1010/tsconfig": "14.0.0", 41 | "@size-limit/file": "11.2.0", 42 | "@svg-use/react": "1.0.0", 43 | "@svg-use/webpack": "1.0.0", 44 | "@swc/core": "1.11.29", 45 | "@swc/jest": "0.2.38", 46 | "@tanstack/eslint-plugin-query": "5.78.0", 47 | "@testing-library/dom": "10.4.0", 48 | "@testing-library/jest-dom": "6.6.3", 49 | "@testing-library/react": "16.3.0", 50 | "@testing-library/user-event": "14.6.1", 51 | "@transifex/api": "7.1.4", 52 | "@types/duplicate-package-checker-webpack-plugin": "2.1.5", 53 | "@types/firefox-webext-browser": "120.0.4", 54 | "@types/jest": "29.5.14", 55 | "@types/node": "22.15.21", 56 | "@types/react": "19.1.6", 57 | "@types/react-dom": "19.1.5", 58 | "@types/script-ext-html-webpack-plugin": "2.1.6", 59 | "@types/webpack-bundle-analyzer": "4.7.0", 60 | "@types/zip-webpack-plugin": "3.0.6", 61 | "chrome-manifest-loader": "0.3.0", 62 | "clean-webpack-plugin": "4.0.0", 63 | "copy-webpack-plugin": "13.0.0", 64 | "css-loader": "7.1.2", 65 | "csstype": "3.1.3", 66 | "duplicate-package-checker-webpack-plugin": "3.0.0", 67 | "eslint": "9.25.1", 68 | "extract-loader": "5.1.0", 69 | "file-loader": "6.2.0", 70 | "html-inline-css-webpack-plugin": "1.11.2", 71 | "html-webpack-plugin": "5.6.3", 72 | "husky": "9.1.7", 73 | "ignore-sync": "8.0.0", 74 | "image-process-loader": "1.1.1", 75 | "jest": "29.7.0", 76 | "jest-environment-jsdom": "29.7.0", 77 | "lightningcss": "1.29.3", 78 | "lightningcss-loader": "3.0.0", 79 | "lint-staged": "15.5.2", 80 | "mini-css-extract-plugin": "2.9.2", 81 | "node-notifier": "10.0.1", 82 | "postcss": "8.5.3", 83 | "postcss-loader": "8.1.1", 84 | "postcss-normalize": "13.0.1", 85 | "prettier": "3.5.3", 86 | "remark-cli": "12.0.1", 87 | "script-ext-html-webpack-plugin": "2.1.5", 88 | "sharp": "0.34.2", 89 | "size-limit": "11.2.0", 90 | "stylelint": "16.19.1", 91 | "svgo": "3.3.2", 92 | "swc-loader": "0.2.6", 93 | "terser-webpack-plugin": "5.3.14", 94 | "type-coverage": "2.29.7", 95 | "type-fest": "4.41.0", 96 | "typed-css-modules": "0.9.1", 97 | "typescript": "5.8.3", 98 | "webpack": "5.99.6", 99 | "webpack-bundle-analyzer": "4.10.2", 100 | "webpack-cli": "6.0.1", 101 | "yaml-loader": "0.8.1", 102 | "zip-webpack-plugin": "4.0.3" 103 | }, 104 | "packageManager": "yarn@4.9.1", 105 | "engines": { 106 | "node": ">=22.11.0" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /scripts/generateLocalesFromTransifex.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | 3 | import { promises as fsPromises } from 'node:fs' 4 | import path from 'node:path' 5 | import process from 'node:process' 6 | // eslint-disable-next-line n/no-unsupported-features/node-builtins 7 | import * as readline from 'node:readline/promises' 8 | 9 | import { type Collection, transifexApi } from '@transifex/api' 10 | 11 | const rl = readline.createInterface({ 12 | input: process.stdin, 13 | output: process.stdout, 14 | }) 15 | 16 | const organizationSlug = 'foray1010' 17 | const projectSlug = 'popup-my-bookmarks' 18 | const resourceSlug = 'messagesjson-1' 19 | 20 | const localesPath = path.join('src', 'core', '_locales') 21 | 22 | async function main(): Promise { 23 | const transifexApiKey: string = await rl.question( 24 | 'transifex api key (get from https://www.transifex.com/user/settings/api/): ', 25 | ) 26 | if (!transifexApiKey) throw new Error('transifexApiKey is required') 27 | 28 | transifexApi.setup({ 29 | auth: transifexApiKey, 30 | }) 31 | 32 | const organization = await transifexApi.Organization.get({ 33 | slug: organizationSlug, 34 | }) 35 | 36 | const project = await transifexApi.Project.get({ 37 | organization, 38 | slug: projectSlug, 39 | }) 40 | 41 | const resource = await transifexApi.Resource.get({ 42 | project, 43 | slug: resourceSlug, 44 | }) 45 | 46 | const languages = (await project.fetch('languages', false)) as Collection & 47 | Readonly<{ 48 | data: ReadonlyArray< 49 | Readonly<{ 50 | attributes: Readonly<{ 51 | code: string 52 | }> 53 | }> 54 | > 55 | }> 56 | await languages.fetch() 57 | 58 | await Promise.all( 59 | languages.data.map(async (language) => { 60 | let mappedLanguage: string 61 | switch (language.attributes.code) { 62 | case 'nb_NO': 63 | mappedLanguage = 'nb' 64 | break 65 | 66 | case 'es_ES': 67 | mappedLanguage = 'es' 68 | break 69 | 70 | default: 71 | mappedLanguage = language.attributes.code 72 | } 73 | 74 | console.log(`processing "${mappedLanguage}"...`) 75 | 76 | const url: string = 77 | await transifexApi.ResourceTranslationsAsyncDownload.download({ 78 | resource, 79 | language, 80 | mode: 'onlytranslated', 81 | }) 82 | const messagesJson: Record< 83 | string, 84 | Readonly<{ 85 | message: string 86 | description?: string 87 | }> 88 | > = await (await fetch(url)).json() 89 | 90 | const sortedMessagesJson = Object.fromEntries( 91 | Object.entries(messagesJson) 92 | .map(([k, v]) => { 93 | const trimmedMessage = v.message.trim() 94 | if (!trimmedMessage) return 95 | 96 | return [k, { ...v, message: trimmedMessage }] as const 97 | }) 98 | .filter((x) => x != null) 99 | .sort(([a], [b]) => a.localeCompare(b)), 100 | ) satisfies typeof messagesJson 101 | 102 | await fsPromises.mkdir(path.join(localesPath, mappedLanguage), { 103 | recursive: true, 104 | }) 105 | 106 | await fsPromises.writeFile( 107 | path.join(localesPath, mappedLanguage, 'messages.json'), 108 | JSON.stringify(sortedMessagesJson, null, 2) + '\n', 109 | ) 110 | 111 | console.log(`"${mappedLanguage}" is generated`) 112 | }), 113 | ) 114 | } 115 | 116 | main() 117 | .catch((err: Readonly) => { 118 | console.error(err) 119 | process.exitCode = 1 120 | }) 121 | .finally(() => { 122 | rl.close() 123 | }) 124 | -------------------------------------------------------------------------------- /src/core/components/baseItems/ActionlessForm/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import type { FormEvent } from 'react' 4 | 5 | import ActionlessForm from './index.js' 6 | 7 | describe('ActionlessForm', () => { 8 | it('should prevent default form submit action', async () => { 9 | const user = userEvent.setup() 10 | const handleSubmit = jest.fn], void>( 11 | (evt) => { 12 | evt.persist() 13 | }, 14 | ) 15 | const name = 'click me' 16 | 17 | render( 18 | 19 | 20 | , 21 | ) 22 | 23 | await user.click(screen.getByRole('button', { name })) 24 | 25 | expect(handleSubmit).toHaveBeenCalledWith( 26 | expect.objectContaining({ 27 | defaultPrevented: true, 28 | }), 29 | ) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/core/components/baseItems/ActionlessForm/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, JSX } from 'react' 2 | 3 | type Props = Readonly< 4 | Omit< 5 | JSX.IntrinsicElements['form'], 6 | 'action' | 'enctype' | 'method' | 'target' 7 | > 8 | > 9 | const ActionlessForm: FC = ({ onSubmit, ref, ...props }) => { 10 | return ( 11 |
{ 15 | evt.preventDefault() 16 | 17 | onSubmit?.(evt) 18 | }} 19 | /> 20 | ) 21 | } 22 | 23 | export default ActionlessForm 24 | -------------------------------------------------------------------------------- /src/core/components/baseItems/Button/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import type { FormEvent } from 'react' 4 | 5 | import Button from './index.js' 6 | 7 | describe('Button', () => { 8 | it('should not fire form submit when clicked in form', async () => { 9 | const user = userEvent.setup() 10 | const handleSubmit = jest.fn], void>() 11 | const name = 'click me' 12 | 13 | render( 14 | 15 | 16 | , 17 | ) 18 | 19 | await user.click(screen.getByRole('button', { name })) 20 | 21 | expect(handleSubmit).not.toHaveBeenCalled() 22 | }) 23 | 24 | it('should fire form submit when type="submit"', async () => { 25 | const user = userEvent.setup() 26 | const handleSubmit = jest.fn], void>( 27 | (evt) => { 28 | evt.preventDefault() 29 | }, 30 | ) 31 | const name = 'click me' 32 | 33 | render( 34 |
35 | 36 |
, 37 | ) 38 | 39 | await user.click(screen.getByRole('button', { name })) 40 | 41 | expect(handleSubmit).toHaveBeenCalled() 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/core/components/baseItems/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import type { FC, JSX } from 'react' 3 | 4 | import StylelessButton from '../StylelessButton' 5 | import * as classes from './styles.module.css' 6 | 7 | type Props = Readonly 8 | 9 | const Button: FC = ({ className, ref, ...props }) => { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | export default Button 20 | -------------------------------------------------------------------------------- /src/core/components/baseItems/Button/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | border: 0; 3 | background-color: var(--highlight-level1); 4 | padding-block: var(--gap-rem-2x); 5 | padding-inline: var(--gap-rem-4x); 6 | color: inherit; 7 | font-family: inherit; 8 | font-size: inherit; 9 | composes: focus from '@/core/styles/composes.module.css'; 10 | 11 | &:is(:hover, :active) { 12 | background-color: var(--highlight-level2); 13 | } 14 | 15 | &:disabled { 16 | opacity: 0.6; 17 | cursor: not-allowed; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/components/baseItems/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import type { FC, JSX } from 'react' 3 | 4 | import * as classes from './styles.module.css' 5 | 6 | type Props = Readonly 7 | const Input: FC = ({ className, ref, type = 'text', ...props }) => { 8 | return ( 9 | 19 | ) 20 | } 21 | 22 | export default Input 23 | -------------------------------------------------------------------------------- /src/core/components/baseItems/Input/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | composes: focus from '@/core/styles/composes.module.css'; 3 | } 4 | 5 | .text-input { 6 | border: 1px solid var(--highlight-level2); 7 | background-color: transparent; 8 | padding-block: var(--gap-rem-2x); 9 | padding-inline: var(--gap-rem-4x); 10 | color: inherit; 11 | font-family: inherit; 12 | font-size: inherit; 13 | } 14 | -------------------------------------------------------------------------------- /src/core/components/baseItems/PlainList/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import type { FC, JSX } from 'react' 3 | 4 | import * as classes from './styles.module.css' 5 | 6 | type Props = Readonly 7 | 8 | const PlainList: FC = ({ className, ref, ...props }) => { 9 | return ( 10 |
    11 | ) 12 | } 13 | 14 | export default PlainList 15 | -------------------------------------------------------------------------------- /src/core/components/baseItems/PlainList/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | margin: 0; 3 | padding: 0; 4 | list-style: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/core/components/baseItems/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import type { FC, JSX } from 'react' 3 | 4 | import * as classes from './styles.module.css' 5 | 6 | type Props = Readonly 7 | const Select: FC = ({ className, ref, ...props }) => { 8 | return ( 9 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/InputNumber/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | border-color: var(--main-color1); 3 | } 4 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/InputSelect/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ChangeEventHandler, 3 | type ComponentProps, 4 | useCallback, 5 | useRef, 6 | } from 'react' 7 | 8 | import Input from '@/core/components/baseItems/Input/index.js' 9 | import Select from '@/core/components/baseItems/Select/index.js' 10 | 11 | import * as classes from './styles.module.css' 12 | 13 | type RestInputProps = Readonly< 14 | Omit, 'className' | 'onChange' | 'value'> 15 | > 16 | 17 | type Props = Readonly< 18 | RestInputProps & { 19 | choices: ReadonlyArray 20 | onChange: ChangeEventHandler 21 | value: string 22 | } 23 | > 24 | export default function InputSelect({ 25 | choices, 26 | value, 27 | onChange, 28 | ...restProps 29 | }: Props) { 30 | const inputRef = useRef(null) 31 | const selectRef = useRef(null) 32 | 33 | const handleChange: ChangeEventHandler = 34 | useCallback( 35 | (evt) => { 36 | if (evt.currentTarget === selectRef.current) { 37 | inputRef.current?.focus() 38 | } 39 | 40 | onChange(evt) 41 | }, 42 | [onChange], 43 | ) 44 | 45 | return ( 46 |
    47 | 54 | 64 |
    65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/InputSelect/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: inline flow-root; 3 | position: relative; 4 | border: 1px solid var(--main-color1); 5 | } 6 | 7 | .input, 8 | .select { 9 | border: 0 !important; 10 | height: 100%; 11 | } 12 | 13 | .input { 14 | box-sizing: border-box; 15 | position: absolute; 16 | top: 0; 17 | left: 0; 18 | background-color: var(--bg-color); 19 | width: 85%; 20 | } 21 | 22 | .select { 23 | outline: 0; 24 | background-color: transparent; 25 | } 26 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectButton/Option/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import { type JSX, useRef } from 'react' 3 | 4 | import Button from '@/core/components/baseItems/Button/index.js' 5 | 6 | import * as classes from './styles.module.css' 7 | 8 | type Props = Readonly< 9 | Omit 10 | > 11 | export default function Option({ children, ...props }: Props) { 12 | const inputRef = useRef(null) 13 | 14 | return ( 15 | 16 | 17 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectButton/Option/styles.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | transition: color var(--transition-duration); 3 | border: 0; 4 | background-color: transparent; 5 | color: var(--frontground-color); 6 | composes: no-text-overflow from '@/core/styles/composes.module.css'; 7 | } 8 | 9 | .itemActive { 10 | cursor: default; 11 | color: var(--background-color); 12 | } 13 | 14 | .main { 15 | display: contents; 16 | } 17 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectButton/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'react' 2 | 3 | import Option from './Option/index.js' 4 | import * as classes from './styles.module.css' 5 | 6 | type RestOptionProps = Readonly< 7 | Omit< 8 | ComponentProps, 9 | 'defaultChecked' | 'defaultValue' | 'checked' | 'value' 10 | > 11 | > 12 | 13 | type Choice = Readonly<{ 14 | label: string 15 | value: T 16 | }> 17 | 18 | type Props = Readonly< 19 | RestOptionProps & { 20 | choices: readonly Choice[] 21 | value: T 22 | } 23 | > 24 | export default function SelectButton({ 25 | choices, 26 | value, 27 | ...restProps 28 | }: Props) { 29 | const coverInlineSizePercentage = 100 / choices.length 30 | const coverInsetInlineStartPercentage = 31 | choices.findIndex((choice) => choice.value === value) * 32 | coverInlineSizePercentage 33 | 34 | return ( 35 | 36 | 43 | 44 | {choices.map((choice) => ( 45 | 53 | ))} 54 | 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .cover { 2 | position: absolute; 3 | transition: inset-inline-start var(--transition-duration); 4 | background-color: var(--frontground-color); 5 | block-size: 100%; 6 | } 7 | 8 | .main { 9 | --frontground-color: var(--main-color1); 10 | --background-color: var(--bg-color); 11 | --transition-duration: 0.4s; 12 | 13 | display: inline flow-root; 14 | position: relative; 15 | border: 1px solid var(--frontground-color); 16 | background-color: var(--background-color); 17 | } 18 | 19 | .options { 20 | display: inline grid; 21 | grid-auto-columns: 1fr; 22 | grid-auto-flow: column; 23 | justify-content: stretch; 24 | isolation: isolate; 25 | } 26 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectMultiple/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'react' 2 | 3 | import Input from '@/core/components/baseItems/Input/index.js' 4 | import PlainList from '@/core/components/baseItems/PlainList/index.js' 5 | 6 | import * as classes from './styles.module.css' 7 | 8 | type RestInputProps = Readonly< 9 | Omit, 'checked' | 'className' | 'type' | 'value'> 10 | > 11 | 12 | type Props = Readonly< 13 | RestInputProps & { 14 | choices: ReadonlyArray 15 | value: ReadonlyArray 16 | } 17 | > 18 | export default function SelectMultiple({ 19 | choices, 20 | value, 21 | ...restProps 22 | }: Props) { 23 | return ( 24 | 25 | {choices.map((optionChoice, optionChoiceIndex) => { 26 | if (optionChoice === undefined) return null 27 | return ( 28 |
  • 29 | 39 |
  • 40 | ) 41 | })} 42 |
    43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectMultiple/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: inline flow-root; 3 | border: 1px solid var(--main-color1); 4 | padding-inline: var(--gap-rem-4x); 5 | } 6 | 7 | .checkbox { 8 | margin: 0; 9 | margin-inline-end: var(--gap-rem-2x); 10 | vertical-align: middle; 11 | accent-color: var(--main-color1); 12 | } 13 | 14 | .list-item { 15 | margin-block: var(--gap-rem-4x); 16 | line-height: 1; 17 | } 18 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectString/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import type { ComponentProps } from 'react' 3 | 4 | import Select from '@/core/components/baseItems/Select/index.js' 5 | 6 | import * as classes from './styles.module.css' 7 | 8 | type Props = Readonly< 9 | ComponentProps & { 10 | choices: ReadonlyArray 11 | } 12 | > 13 | export default function SelectString({ 14 | choices, 15 | className, 16 | ...restProps 17 | }: Props) { 18 | return ( 19 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectString/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | border-color: var(--main-color1); 3 | } 4 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEventHandler, FocusEventHandler } from 'react' 2 | import webExtension from 'webextension-polyfill' 3 | 4 | import type { 5 | ArrayOptionConfig, 6 | BooleanOptionConfig, 7 | IntegerOptionConfig, 8 | SelectOptionConfig, 9 | StringOptionConfig, 10 | } from '@/core/types/options.js' 11 | 12 | import InputNumber from './InputNumber/index.js' 13 | import InputSelect from './InputSelect/index.js' 14 | import SelectButton from './SelectButton/index.js' 15 | import SelectMultiple from './SelectMultiple/index.js' 16 | import SelectString from './SelectString/index.js' 17 | 18 | type PropsFromOptionConfig> = Readonly< 19 | OC & { 20 | onChange: (value: OC['default']) => void 21 | value: OC['default'] 22 | } 23 | > 24 | 25 | type Props = Readonly< 26 | { 27 | name: string 28 | onBlur: FocusEventHandler 29 | } & ( 30 | | PropsFromOptionConfig 31 | | PropsFromOptionConfig 32 | | PropsFromOptionConfig 33 | | PropsFromOptionConfig 34 | | PropsFromOptionConfig 35 | ) 36 | > 37 | 38 | export default function OptionItem(props: Props) { 39 | switch (props.type) { 40 | case 'array': 41 | return ( 42 | { 48 | const checkboxValue = Number.parseInt(evt.currentTarget.value, 10) 49 | 50 | const newValue = evt.currentTarget.checked 51 | ? [checkboxValue, ...props.value].sort() 52 | : props.value.filter((x) => x !== checkboxValue) 53 | props.onChange(newValue) 54 | }} 55 | /> 56 | ) 57 | 58 | case 'boolean': 59 | return ( 60 | { 75 | props.onChange(evt.currentTarget.value === 'true') 76 | }} 77 | /> 78 | ) 79 | 80 | case 'integer': 81 | return ( 82 | { 90 | const newValue = evt.currentTarget.valueAsNumber 91 | // @ts-expect-error empty string is valid for UI 92 | props.onChange(!Number.isNaN(newValue) ? newValue : '') 93 | }} 94 | /> 95 | ) 96 | 97 | case 'select': 98 | return ( 99 | { 106 | props.onChange(Number.parseInt(evt.currentTarget.value, 10)) 107 | }} 108 | /> 109 | ) 110 | 111 | case 'string': { 112 | const onChange: ChangeEventHandler< 113 | HTMLInputElement | HTMLSelectElement 114 | > = (evt) => { 115 | props.onChange( 116 | evt.currentTarget.value 117 | .split(',') 118 | .map((x) => x.trim()) 119 | .filter(Boolean) 120 | .join(','), 121 | ) 122 | } 123 | return ( 124 | { 130 | onChange(evt) 131 | props.onBlur(evt) 132 | }} 133 | onChange={onChange} 134 | /> 135 | ) 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import type { ValueOf } from 'type-fest' 3 | 4 | import type { OPTIONS } from '@/core/constants/index.js' 5 | import type { OptionsConfig } from '@/core/types/options.js' 6 | import getOptionsConfig from '@/core/utils/getOptionsConfig.js' 7 | import { OPTION_TABLE_MAP } from '@/options/constants/index.js' 8 | import { 9 | useDeleteOptions, 10 | useGetOptions, 11 | useUpdateOptions, 12 | } from '@/options/hooks/options.js' 13 | 14 | import { useNavigationContext } from '../navigationContext.js' 15 | import OptionForm from './OptionForm.js' 16 | 17 | function useGetOptionsWithDefaultValues({ 18 | optionsConfig, 19 | }: Readonly<{ optionsConfig: OptionsConfig | undefined }>) { 20 | const [isFilledDefaultValues, setIsFilledDefaultValues] = useState(false) 21 | 22 | const { data: options } = useGetOptions() 23 | const { mutateAsync: setOptions } = useUpdateOptions() 24 | 25 | useEffect(() => { 26 | if (!options || !optionsConfig) return 27 | 28 | const missingOptionNames = ( 29 | Object.keys(optionsConfig) as readonly ValueOf[] 30 | ).filter((optionName) => options[optionName] === undefined) 31 | 32 | if (missingOptionNames.length > 0) { 33 | const missingOptions = Object.fromEntries( 34 | missingOptionNames.map((optionName) => [ 35 | optionName, 36 | optionsConfig[optionName].default, 37 | ]), 38 | ) 39 | setOptions(missingOptions).catch(console.error) 40 | } else { 41 | setIsFilledDefaultValues(true) 42 | } 43 | 44 | return () => { 45 | setIsFilledDefaultValues(false) 46 | } 47 | }, [options, optionsConfig, setOptions]) 48 | 49 | return isFilledDefaultValues ? options : null 50 | } 51 | 52 | export default function OptionFormContainer() { 53 | const { currentPath } = useNavigationContext() 54 | 55 | const [optionsConfig, setOptionsConfig] = useState() 56 | useEffect(() => { 57 | getOptionsConfig().then(setOptionsConfig).catch(console.error) 58 | }, []) 59 | 60 | const options = useGetOptionsWithDefaultValues({ optionsConfig }) 61 | 62 | const { mutateAsync: deleteOptions } = useDeleteOptions() 63 | const { mutateAsync: updateOptions } = useUpdateOptions() 64 | 65 | if (!options || !optionsConfig) return null 66 | 67 | return ( 68 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/option-form.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flow-root; 3 | margin-block: var(--gap-rem-4x); 4 | } 5 | 6 | .table { 7 | /* unsetting the top and bottom spacing added by border-spacing as it does not support margin collapse */ 8 | margin-block: calc(var(--gap-rem-4x) * -1); 9 | width: 100%; 10 | table-layout: fixed; 11 | border-spacing: var(--gap-rem-4x); 12 | 13 | & > tbody, 14 | & > tfoot { 15 | & > tr > td:first-child { 16 | text-align: right; 17 | } 18 | } 19 | } 20 | 21 | .item-input { 22 | & > input[type='number'], 23 | & > input[type='text'], 24 | & > select { 25 | box-sizing: border-box; 26 | border-color: var(--main-color1); 27 | padding-block: var(--gap-rem-2x); 28 | padding-inline: var(--gap-rem-4x); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/options/components/Router.tsx: -------------------------------------------------------------------------------- 1 | import { RoutePath } from '../constants/index.js' 2 | import Contributors from './Contributors.js' 3 | import { useNavigationContext } from './navigationContext.js' 4 | import OptionForm from './OptionForm/index.js' 5 | 6 | export default function Router() { 7 | const { currentPath } = useNavigationContext() 8 | 9 | switch (currentPath) { 10 | case RoutePath.Contributors: 11 | return 12 | 13 | case RoutePath.Control: 14 | case RoutePath.General: 15 | case RoutePath.UserInterface: 16 | return 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/options/components/contributors.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | line-height: 2; 3 | 4 | &, 5 | & > dd { 6 | margin-inline-start: 4ch; 7 | } 8 | 9 | & > dt { 10 | font-weight: bold; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/options/components/donate.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: inline flex; 3 | gap: var(--gap-rem-4x); 4 | align-items: center; 5 | background-color: var(--highlight-level1); 6 | padding: var(--gap-rem-4x); 7 | } 8 | 9 | .desc { 10 | line-height: 1.5; 11 | 12 | & > ol { 13 | display: inline; 14 | padding: 0; 15 | list-style-position: inside; 16 | } 17 | } 18 | 19 | .main { 20 | display: flex; 21 | justify-content: center; 22 | margin: var(--gap-rem-4x); 23 | } 24 | -------------------------------------------------------------------------------- /src/options/components/navigationContext.ts: -------------------------------------------------------------------------------- 1 | import constate from 'constate' 2 | import { useState } from 'react' 3 | import type { ValueOf } from 'type-fest' 4 | 5 | import { RoutePath } from '../constants/index.js' 6 | 7 | function useNavigation() { 8 | const [currentPath, setCurrentPath] = useState>( 9 | RoutePath.General, 10 | ) 11 | return { 12 | currentPath, 13 | setCurrentPath, 14 | } 15 | } 16 | 17 | export const [NavigationProvider, useNavigationContext] = 18 | constate(useNavigation) 19 | -------------------------------------------------------------------------------- /src/options/constants/index.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from 'type-fest' 2 | 3 | import { OPTIONS } from '@/core/constants/index.js' 4 | 5 | export const RoutePath = { 6 | Contributors: 'contributors', 7 | Control: 'control', 8 | General: 'general', 9 | UserInterface: 'user-interface', 10 | } as const 11 | 12 | export const OPTION_TABLE_MAP = { 13 | [RoutePath.Contributors]: [], 14 | [RoutePath.Control]: [ 15 | OPTIONS.CLICK_BY_LEFT, 16 | OPTIONS.CLICK_BY_LEFT_CTRL, 17 | OPTIONS.CLICK_BY_LEFT_SHIFT, 18 | OPTIONS.CLICK_BY_MIDDLE, 19 | OPTIONS.OP_FOLDER_BY, 20 | ], 21 | [RoutePath.General]: [ 22 | OPTIONS.DEF_EXPAND, 23 | OPTIONS.HIDE_ROOT_FOLDER, 24 | OPTIONS.SEARCH_TARGET, 25 | OPTIONS.MAX_RESULTS, 26 | OPTIONS.TOOLTIP, 27 | OPTIONS.WARN_OPEN_MANY, 28 | OPTIONS.REMEMBER_POS, 29 | ], 30 | [RoutePath.UserInterface]: [ 31 | OPTIONS.SET_WIDTH, 32 | OPTIONS.FONT_SIZE, 33 | OPTIONS.FONT_FAMILY, 34 | ], 35 | } as const satisfies Record< 36 | ValueOf, 37 | readonly ValueOf[] 38 | > 39 | -------------------------------------------------------------------------------- /src/options/hooks/__tests__/options.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook, waitFor } from '@testing-library/react' 2 | 3 | import { ReactQueryClientProvider } from '@/core/utils/queryClient.js' 4 | 5 | import { 6 | useDeleteOptions, 7 | useGetOptions, 8 | useUpdateOptions, 9 | } from '../options.js' 10 | 11 | describe('options hooks', () => { 12 | async function initTestData() { 13 | const { result: useDeleteOptionsResult } = renderHook(useDeleteOptions, { 14 | wrapper: ReactQueryClientProvider, 15 | }) 16 | const { mutateAsync: deleteOptions } = useDeleteOptionsResult.current 17 | await act(async () => { 18 | await deleteOptions() 19 | }) 20 | 21 | const { result: useUpdateOptionsResult } = renderHook(useUpdateOptions, { 22 | wrapper: ReactQueryClientProvider, 23 | }) 24 | const { mutateAsync: updateOptions } = useUpdateOptionsResult.current 25 | await act(async () => { 26 | await updateOptions({ rememberPos: true }) 27 | }) 28 | } 29 | 30 | describe('useGetOptions', () => { 31 | it('should get options', async () => { 32 | await initTestData() 33 | 34 | const { result } = renderHook(useGetOptions, { 35 | wrapper: ReactQueryClientProvider, 36 | }) 37 | 38 | await waitFor(() => expect(result.current.isFetching).toBe(false)) 39 | 40 | expect(result.current.data).toStrictEqual({ rememberPos: true }) 41 | }) 42 | }) 43 | 44 | describe('useDeleteOptions', () => { 45 | it('should delete all options and refetch in useGetOptions', async () => { 46 | await initTestData() 47 | 48 | const { result: useGetOptionsResult } = renderHook(useGetOptions, { 49 | wrapper: ReactQueryClientProvider, 50 | }) 51 | 52 | await waitFor(() => { 53 | expect(useGetOptionsResult.current.isFetching).toBe(false) 54 | }) 55 | 56 | expect(useGetOptionsResult.current.data).toHaveProperty('rememberPos') 57 | 58 | const { result: useDeleteOptionsResult } = renderHook(useDeleteOptions, { 59 | wrapper: ReactQueryClientProvider, 60 | }) 61 | const { mutateAsync: deleteOptions } = useDeleteOptionsResult.current 62 | await act(async () => { 63 | await deleteOptions() 64 | }) 65 | 66 | await waitFor(() => { 67 | expect(useGetOptionsResult.current.data).toStrictEqual({}) 68 | }) 69 | }) 70 | }) 71 | 72 | describe('useUpdateOptions', () => { 73 | it('should insert option and refetch in useGetOptions', async () => { 74 | await initTestData() 75 | 76 | const { result: useGetOptionsResult } = renderHook(useGetOptions, { 77 | wrapper: ReactQueryClientProvider, 78 | }) 79 | 80 | await waitFor(() => { 81 | expect(useGetOptionsResult.current.isFetching).toBe(false) 82 | }) 83 | 84 | expect(useGetOptionsResult.current.data).toHaveProperty('rememberPos') 85 | expect(useGetOptionsResult.current.data).not.toHaveProperty('tooltip') 86 | 87 | const { result: useUpdateOptionsResult } = renderHook(useUpdateOptions, { 88 | wrapper: ReactQueryClientProvider, 89 | }) 90 | const { mutateAsync: updateOptions } = useUpdateOptionsResult.current 91 | await act(async () => { 92 | await updateOptions({ tooltip: true }) 93 | }) 94 | 95 | await waitFor(() => { 96 | expect(useGetOptionsResult.current.data).toStrictEqual({ 97 | rememberPos: true, 98 | tooltip: true, 99 | }) 100 | }) 101 | }) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /src/options/hooks/options.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 2 | import webExtension from 'webextension-polyfill' 3 | 4 | import type { Options } from '@/core/types/options.js' 5 | 6 | const queryKey = 'options' 7 | 8 | export function useGetOptions() { 9 | return useQuery({ 10 | queryKey: [queryKey], 11 | async queryFn(): Promise> { 12 | return webExtension.storage.sync.get() 13 | }, 14 | }) 15 | } 16 | 17 | export function useDeleteOptions() { 18 | const queryClient = useQueryClient() 19 | 20 | return useMutation({ 21 | async mutationFn() { 22 | return webExtension.storage.sync.clear() 23 | }, 24 | async onSuccess() { 25 | await queryClient.invalidateQueries({ 26 | queryKey: [queryKey], 27 | }) 28 | }, 29 | }) 30 | } 31 | 32 | export function useUpdateOptions() { 33 | const queryClient = useQueryClient() 34 | 35 | return useMutation({ 36 | async mutationFn(options: Partial) { 37 | return webExtension.storage.sync.set(options) 38 | }, 39 | async onSuccess() { 40 | await queryClient.invalidateQueries({ 41 | queryKey: [queryKey], 42 | }) 43 | }, 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /src/options/images/btn_donateCC_LG.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foray1010/Popup-my-Bookmarks/8d84394f92768b9d6a953bfccc616ab4e8871a9e/src/options/images/btn_donateCC_LG.webp -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import '../manifest.yml' 2 | 3 | import { StrictMode } from 'react' 4 | 5 | import createAndRenderRoot from '@/core/utils/createAndRenderRoot.js' 6 | 7 | import App from './components/App/index.js' 8 | 9 | createAndRenderRoot( 10 | 11 | 12 | , 13 | ) 14 | -------------------------------------------------------------------------------- /src/popup/components/App/globals.module.css: -------------------------------------------------------------------------------- 1 | @import url('@/core/styles/globals.module.css'); 2 | 3 | :root { 4 | user-select: none; 5 | } 6 | 7 | ::-webkit-scrollbar { 8 | background-color: transparent; 9 | width: var(--gap-rem-4x); 10 | } 11 | 12 | ::-webkit-scrollbar-thumb { 13 | border-left: var(--gap-rem) solid var(--bg-color); 14 | background-color: var(--highlight-level3); 15 | 16 | &:hover { 17 | border: 0; 18 | background-color: var(--highlight-level4); 19 | } 20 | 21 | &:active { 22 | background-color: var(--highlight-level5); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/popup/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import './globals.module.css' 2 | 3 | import type { PropertiesHyphen } from 'csstype' 4 | import { useEffect } from 'react' 5 | 6 | import { OPTIONS } from '@/core/constants/index.js' 7 | import { ReactQueryClientProvider } from '@/core/utils/queryClient.js' 8 | import withProviders from '@/core/utils/withProviders.js' 9 | import { BookmarkTreesProvider } from '@/popup/modules/bookmarks/contexts/bookmarkTrees.js' 10 | import { ClipboardProvider } from '@/popup/modules/clipboard.js' 11 | import { useOptions, withOptions } from '@/popup/modules/options.js' 12 | 13 | import BookmarkTrees from '../BookmarkTrees/index.js' 14 | import { Editor, EditorProvider } from '../editor/index.js' 15 | import { 16 | FloatingWindowProvider, 17 | useGlobalBodySize, 18 | } from '../floatingWindow/index.js' 19 | import { KeyBindingsProvider } from '../keyBindings/index.js' 20 | import { Menu, MenuProvider } from '../menu/index.js' 21 | import Search from '../Search/index.js' 22 | import useGlobalEvents from './useGlobalEvents.js' 23 | 24 | function useRootCss(key: keyof PropertiesHyphen, value: string | null) { 25 | useEffect(() => { 26 | document.documentElement.style.setProperty(key, value) 27 | 28 | return () => { 29 | document.documentElement.style.removeProperty(key) 30 | } 31 | }, [key, value]) 32 | } 33 | 34 | const AppWithOptions = withOptions(function InnerApp() { 35 | useGlobalEvents() 36 | 37 | const options = useOptions() 38 | 39 | const { globalBodySize } = useGlobalBodySize() 40 | 41 | useRootCss( 42 | 'font-family', 43 | Array.from( 44 | new Set([ 45 | ...options[OPTIONS.FONT_FAMILY] 46 | .split(',') 47 | .map((x) => x.trim()) 48 | .filter(Boolean), 49 | 'system-ui', 50 | 'sans-serif', 51 | ]), 52 | ).join(','), 53 | ) 54 | useRootCss( 55 | 'font-size', 56 | // revert the 75% font size in body 57 | `${options[OPTIONS.FONT_SIZE] / 0.75}px`, 58 | ) 59 | 60 | useRootCss( 61 | 'height', 62 | globalBodySize?.height !== undefined ? `${globalBodySize.height}px` : null, 63 | ) 64 | useRootCss( 65 | 'width', 66 | globalBodySize?.width !== undefined ? `${globalBodySize.width}px` : null, 67 | ) 68 | 69 | return ( 70 | 71 | } /> 72 | 73 | 74 | 75 | ) 76 | }) 77 | 78 | const App = withProviders(AppWithOptions, [ 79 | ReactQueryClientProvider, 80 | ClipboardProvider, 81 | KeyBindingsProvider, 82 | FloatingWindowProvider, 83 | EditorProvider, 84 | MenuProvider, 85 | ]) 86 | export default App 87 | -------------------------------------------------------------------------------- /src/popup/components/App/useGlobalEvents.ts: -------------------------------------------------------------------------------- 1 | import useEventListener from 'use-typed-event-listener' 2 | 3 | export default function useGlobalEvents(): void { 4 | useEventListener(document, 'contextmenu', (evt) => { 5 | // allow native context menu if it is an input element 6 | if (evt.target instanceof HTMLInputElement) { 7 | return 8 | } 9 | 10 | // disable native context menu 11 | evt.preventDefault() 12 | }) 13 | 14 | useEventListener(document, 'keydown', (evt) => { 15 | const isFocusedOnInputWithoutValue = 16 | document.activeElement instanceof HTMLInputElement && 17 | document.activeElement.value === '' 18 | if (evt.key === 'Escape' && isFocusedOnInputWithoutValue) { 19 | window.close() 20 | } 21 | }) 22 | 23 | useEventListener(document, 'mousedown', (evt) => { 24 | // disable the scrolling arrows after middle click 25 | if (evt.button === 1) evt.preventDefault() 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/popup/components/Backdrop/backdrop.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | position: absolute; 3 | inset: 0; 4 | backdrop-filter: blur(1px); 5 | background-color: color-mix( 6 | in srgb, 7 | var(--bg-color), 8 | transparent calc(100% - var(--opacity)) 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/popup/components/Backdrop/index.tsx: -------------------------------------------------------------------------------- 1 | import type { MouseEventHandler } from 'react' 2 | 3 | import * as classes from './backdrop.module.css' 4 | 5 | type Props = Readonly<{ 6 | opacity: number 7 | onClick: MouseEventHandler 8 | }> 9 | export default function Backdrop({ opacity, onClick }: Props) { 10 | return ( 11 |