├── .node-version ├── .browserslistrc ├── .husky ├── pre-push ├── commit-msg └── pre-commit ├── jest-setup.ts ├── src ├── popup │ ├── constants │ │ ├── ui.ts │ │ ├── windows.ts │ │ └── menu.ts │ ├── modules │ │ ├── bookmarks │ │ │ ├── hooks │ │ │ │ ├── constants │ │ │ │ │ └── reactQuery.ts │ │ │ │ └── useGetBookmarkInfo.ts │ │ │ ├── utils │ │ │ │ ├── faviconUrl.ts │ │ │ │ ├── sortByTitle.ts │ │ │ │ ├── generators.ts │ │ │ │ ├── clickBookmark.ts │ │ │ │ └── transformers.ts │ │ │ ├── constants.ts │ │ │ ├── methods │ │ │ │ ├── copyBookmark.ts │ │ │ │ ├── createBookmark.ts │ │ │ │ ├── sortBookmarksByName.ts │ │ │ │ └── openBookmark.ts │ │ │ └── types.ts │ │ ├── lastPositions │ │ │ ├── types.ts │ │ │ └── index.ts │ │ ├── localStorage.tsx │ │ ├── clipboard.ts │ │ └── options.tsx │ ├── components │ │ ├── keyBindings │ │ │ ├── KeyBindingsWindow.module.css │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── useKeyBindingsEvent.ts │ │ │ ├── KeyBindingsWindow.tsx │ │ │ └── KeyBindingsContext.ts │ │ ├── editor │ │ │ ├── index.ts │ │ │ ├── editor-form.module.css │ │ │ ├── EditorContext.ts │ │ │ ├── EditorForm.tsx │ │ │ └── Editor.tsx │ │ ├── menu │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── menu-row.module.css │ │ │ ├── constants.ts │ │ │ ├── menu.module.css │ │ │ ├── useMenu.ts │ │ │ ├── MenuRow.tsx │ │ │ ├── Menu.tsx │ │ │ └── utils.ts │ │ ├── dragAndDrop │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ ├── DragAndDropContext.ts │ │ │ ├── useDragZoneEvents.ts │ │ │ └── DragAndDropConsumer.tsx │ │ ├── listNavigation │ │ │ ├── index.ts │ │ │ ├── ListNavigationContext.ts │ │ │ └── useKeyboardNav.ts │ │ ├── floatingWindow │ │ │ ├── index.ts │ │ │ ├── FloatingWindowContext.ts │ │ │ ├── useGlobalBodySize.ts │ │ │ └── FloatingWindow.tsx │ │ ├── Backdrop │ │ │ ├── backdrop.module.css │ │ │ └── index.tsx │ │ ├── BookmarkTree │ │ │ ├── no-search-result.module.css │ │ │ ├── NoSearchResult.tsx │ │ │ ├── bookmark-tree.module.css │ │ │ ├── TreeHeader.tsx │ │ │ ├── tree-header.module.css │ │ │ ├── BookmarkRow │ │ │ │ ├── bookmark-row.module.css │ │ │ │ ├── BookmarkRow.tsx │ │ │ │ ├── useTooltip.ts │ │ │ │ └── index.tsx │ │ │ ├── useRowDragEvents.ts │ │ │ ├── useRowHoverEvents.ts │ │ │ └── useRowClickEvents.ts │ │ ├── BookmarkTrees │ │ │ ├── bookmark-trees.module.css │ │ │ ├── withDragAndDropEvents.tsx │ │ │ ├── BookmarkTrees.tsx │ │ │ └── index.tsx │ │ ├── App │ │ │ ├── globals.module.css │ │ │ ├── useGlobalEvents.ts │ │ │ └── index.tsx │ │ └── Search │ │ │ ├── search-input.module.css │ │ │ ├── SearchInput.tsx │ │ │ └── index.tsx │ ├── utils │ │ ├── cycle.ts │ │ ├── deleteFromMap.ts │ │ └── getLastMapKey.ts │ ├── images │ │ ├── cross.svg │ │ ├── search.svg │ │ └── folder.svg │ └── index.tsx ├── options │ ├── components │ │ ├── App │ │ │ ├── styles.module.css │ │ │ ├── globals.module.css │ │ │ └── index.tsx │ │ ├── OptionForm │ │ │ ├── OptionItem │ │ │ │ ├── InputNumber │ │ │ │ │ ├── styles.module.css │ │ │ │ │ └── index.tsx │ │ │ │ ├── SelectString │ │ │ │ │ ├── styles.module.css │ │ │ │ │ └── index.tsx │ │ │ │ ├── SelectButton │ │ │ │ │ ├── Option │ │ │ │ │ │ ├── styles.module.css │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── styles.module.css │ │ │ │ │ └── index.tsx │ │ │ │ ├── SelectMultiple │ │ │ │ │ ├── styles.module.css │ │ │ │ │ └── index.tsx │ │ │ │ ├── InputSelect │ │ │ │ │ ├── styles.module.css │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── option-form.module.css │ │ │ ├── index.tsx │ │ │ └── OptionForm.tsx │ │ ├── contributors.module.css │ │ ├── ExternalLink │ │ │ ├── styles.module.css │ │ │ ├── index.tsx │ │ │ └── index.test.tsx │ │ ├── donate.module.css │ │ ├── navigationContext.ts │ │ ├── Router.tsx │ │ ├── NavBar │ │ │ ├── index.test.tsx │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ ├── Contributors.tsx │ │ └── Donate.tsx │ ├── images │ │ └── btn_donateCC_LG.webp │ ├── index.tsx │ ├── constants │ │ └── index.ts │ └── hooks │ │ ├── options.ts │ │ └── __tests__ │ │ └── options.test.ts ├── core │ ├── types │ │ ├── webextension-polyfill.d.ts │ │ ├── assets.d.ts │ │ └── options.ts │ ├── components │ │ └── baseItems │ │ │ ├── PlainList │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ │ ├── StylelessButton │ │ │ ├── styles.module.css │ │ │ ├── index.tsx │ │ │ └── index.test.tsx │ │ │ ├── Select │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ │ ├── Input │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ │ ├── Button │ │ │ ├── styles.module.css │ │ │ ├── index.tsx │ │ │ └── index.test.tsx │ │ │ └── ActionlessForm │ │ │ ├── index.tsx │ │ │ └── index.test.tsx │ ├── utils │ │ ├── array.ts │ │ ├── isMac.ts │ │ ├── createAndRenderRoot.ts │ │ ├── withProviders.tsx │ │ ├── queryClient.tsx │ │ └── getOptionsConfig.ts │ ├── hooks │ │ └── useLatestRef.ts │ ├── styles │ │ ├── composes.module.css │ │ └── globals.module.css │ └── constants │ │ └── index.ts ├── public │ └── images │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon38.png │ │ └── icon48.png └── template.html ├── .prettierrc.mjs ├── __mocks__ ├── fileMock.ts ├── webextension-polyfill.ts └── browserExtension │ ├── index.ts │ ├── utils │ ├── object.ts │ └── WebExtEventEmitter.ts │ ├── runtime.ts │ ├── i18n.ts │ └── storage.test.ts ├── .remarkrc.mjs ├── markdown ├── todo.md ├── contributing.md ├── description.md ├── legacy_version.md ├── title.md └── developer_guide.md ├── .commitlintrc.json ├── .yarnrc.yml ├── .gitignore-sync ├── .renovaterc.json ├── tsconfig.json ├── .editorconfig ├── PRIVACY.md ├── .lintstagedrc.mjs ├── .stylelintrc.cjs ├── .swcrc ├── .circleci └── config.yml ├── .size-limit.json ├── jest.config.ts ├── plugins └── GenerateJsonPlugin.ts ├── eslint.config.js ├── LICENSE ├── manifest.ts ├── Makefile ├── package.json ├── scripts └── generateLocalesFromTransifex.ts ├── README.md └── .gitignore /.node-version: -------------------------------------------------------------------------------- 1 | 24.11.0 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | chrome >= 111 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | CI=true make ci 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | make build-css-types 2 | lint-staged 3 | -------------------------------------------------------------------------------- /src/popup/constants/ui.ts: -------------------------------------------------------------------------------- 1 | export const MAX_POPUP_HEIGHT = 598 2 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | export { default } from '@foray1010/prettier-config' 2 | -------------------------------------------------------------------------------- /__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | const file = 'test-file-stub' 2 | export default file 3 | -------------------------------------------------------------------------------- /src/options/components/App/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | width: 100ch; 3 | } 4 | -------------------------------------------------------------------------------- /__mocks__/webextension-polyfill.ts: -------------------------------------------------------------------------------- 1 | export { default } from './browserExtension/index.js' 2 | -------------------------------------------------------------------------------- /src/options/components/App/globals.module.css: -------------------------------------------------------------------------------- 1 | @import url('@/core/styles/globals.module.css'); 2 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/hooks/constants/reactQuery.ts: -------------------------------------------------------------------------------- 1 | export const queryKey = 'bookmarks' 2 | -------------------------------------------------------------------------------- /src/popup/components/keyBindings/KeyBindingsWindow.module.css: -------------------------------------------------------------------------------- 1 | .window { 2 | outline: 0; 3 | } 4 | -------------------------------------------------------------------------------- /.remarkrc.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ['@foray1010/remark-preset'], 3 | } 4 | export default config 5 | -------------------------------------------------------------------------------- /markdown/todo.md: -------------------------------------------------------------------------------- 1 | ## Todo & Working Progress 2 | 3 | See 4 | -------------------------------------------------------------------------------- /src/core/types/webextension-polyfill.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'webextension-polyfill' { 2 | export default browser 3 | } 4 | -------------------------------------------------------------------------------- /src/public/images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foray1010/Popup-my-Bookmarks/HEAD/src/public/images/icon128.png -------------------------------------------------------------------------------- /src/public/images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foray1010/Popup-my-Bookmarks/HEAD/src/public/images/icon16.png -------------------------------------------------------------------------------- /src/public/images/icon38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foray1010/Popup-my-Bookmarks/HEAD/src/public/images/icon38.png -------------------------------------------------------------------------------- /src/public/images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foray1010/Popup-my-Bookmarks/HEAD/src/public/images/icon48.png -------------------------------------------------------------------------------- /src/popup/components/editor/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Editor } from './Editor.js' 2 | export * from './EditorContext.js' 3 | -------------------------------------------------------------------------------- /src/popup/components/menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Menu } from './MenuContainer.js' 2 | export * from './useMenu.js' 3 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/InputNumber/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | border-color: var(--main-color1); 3 | } 4 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionItem/SelectString/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | border-color: var(--main-color1); 3 | } 4 | -------------------------------------------------------------------------------- /src/popup/modules/lastPositions/types.ts: -------------------------------------------------------------------------------- 1 | export type LastPosition = Readonly<{ 2 | id: string 3 | scrollTop: number 4 | }> 5 | -------------------------------------------------------------------------------- /src/core/components/baseItems/PlainList/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | margin: 0; 3 | padding: 0; 4 | list-style: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/options/images/btn_donateCC_LG.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foray1010/Popup-my-Bookmarks/HEAD/src/options/images/btn_donateCC_LG.webp -------------------------------------------------------------------------------- /src/popup/constants/windows.ts: -------------------------------------------------------------------------------- 1 | export const WindowId = { 2 | Base: 'base', 3 | Editor: 'editor', 4 | Menu: 'menu', 5 | } as const 6 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/commitlintrc", 3 | "extends": ["@commitlint/config-conventional"] 4 | } 5 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | defaultSemverRangePrefix: '' 2 | 3 | enableGlobalCache: false 4 | 5 | enableTelemetry: false 6 | 7 | nodeLinker: node-modules 8 | -------------------------------------------------------------------------------- /src/core/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const notNullish = (x: T | null | undefined): x is T => { 2 | return x !== null && x !== undefined 3 | } 4 | -------------------------------------------------------------------------------- /src/popup/components/dragAndDrop/types.ts: -------------------------------------------------------------------------------- 1 | export type ResponseEvent = Readonly<{ 2 | activeKey: string | null 3 | itemKey: string 4 | }> 5 | -------------------------------------------------------------------------------- /src/popup/components/listNavigation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ListNavigationContext.js' 2 | export { default as useKeyboardNav } from './useKeyboardNav.js' 3 | -------------------------------------------------------------------------------- /src/popup/constants/menu.ts: -------------------------------------------------------------------------------- 1 | export const OPEN_IN_TYPES = { 2 | BACKGROUND_TAB: 0, 3 | CURRENT_TAB: 1, 4 | INCOGNITO_WINDOW: 2, 5 | NEW_TAB: 3, 6 | NEW_WINDOW: 4, 7 | } as const 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/popup/utils/cycle.ts: -------------------------------------------------------------------------------- 1 | export default function cycle(start: number, end: number, value: number) { 2 | if (value < start) return end 3 | if (value > end) return start 4 | return value 5 | } 6 | -------------------------------------------------------------------------------- /src/popup/images/cross.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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/popup/components/menu/types.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from 'type-fest' 2 | 3 | import type { MenuItem } from './constants.js' 4 | 5 | export type MenuPattern = ReadonlyArray>> 6 | -------------------------------------------------------------------------------- /src/popup/images/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/popup/utils/deleteFromMap.ts: -------------------------------------------------------------------------------- 1 | export default function deleteFromMap( 2 | map: ReadonlyMap, 3 | key: K, 4 | ): ReadonlyMap { 5 | const clonedMap = new Map(map) 6 | clonedMap.delete(key) 7 | return clonedMap 8 | } 9 | -------------------------------------------------------------------------------- /src/popup/utils/getLastMapKey.ts: -------------------------------------------------------------------------------- 1 | export default function getLastMapKey( 2 | map: ReadonlyMap, 3 | ): K | undefined { 4 | const indices = Array.from(map.keys()).sort() 5 | return indices.at(-1) 6 | } 7 | -------------------------------------------------------------------------------- /src/popup/components/floatingWindow/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FloatingWindow } from './FloatingWindow.js' 2 | export { FloatingWindowProvider } from './FloatingWindowContext.js' 3 | export { default as useGlobalBodySize } from './useGlobalBodySize.js' 4 | -------------------------------------------------------------------------------- /src/popup/components/keyBindings/index.ts: -------------------------------------------------------------------------------- 1 | export { KeyBindingsProvider } from './KeyBindingsContext.js' 2 | export { default as KeyBindingsWindow } from './KeyBindingsWindow.js' 3 | export { default as useKeyBindingsEvent } from './useKeyBindingsEvent.js' 4 | -------------------------------------------------------------------------------- /src/core/components/baseItems/StylelessButton/styles.module.css: -------------------------------------------------------------------------------- 1 | :where(.unset-all) { 2 | all: unset; 3 | -webkit-user-drag: element; /* all: unset will disable user drag */ 4 | } 5 | 6 | .main { 7 | composes: focus from '@/core/styles/composes.module.css'; 8 | } 9 | -------------------------------------------------------------------------------- /src/options/components/ExternalLink/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | text-decoration: none; 3 | color: var(--main-color1); 4 | composes: focus from '@/core/styles/composes.module.css'; 5 | 6 | &:hover { 7 | color: var(--main-color2); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/popup/components/dragAndDrop/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DragAndDropConsumer } from './DragAndDropConsumer.js' 2 | export * from './DragAndDropContext.js' 3 | export type * from './types.js' 4 | export { default as useDragZoneEvents } from './useDragZoneEvents.js' 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 | -------------------------------------------------------------------------------- /src/core/utils/isMac.ts: -------------------------------------------------------------------------------- 1 | export default function isMac() { 2 | // use `navigator.userAgentData.platform` in the future when all browsers support it 3 | // eslint-disable-next-line @typescript-eslint/no-deprecated 4 | return globalThis.navigator.platform.startsWith('Mac') 5 | } 6 | -------------------------------------------------------------------------------- /src/popup/images/folder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 oklch, 7 | var(--bg-color), 8 | transparent calc(100% - var(--opacity)) 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "@foray1010/tsconfig/tsconfig.react.json", 4 | "compilerOptions": { 5 | "paths": { 6 | "@/*": ["./src/*"] 7 | }, 8 | "allowImportingTsExtensions": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | 3 | import createAndRenderRoot from '@/core/utils/createAndRenderRoot.js' 4 | 5 | import App from './components/App/index.js' 6 | 7 | createAndRenderRoot( 8 | 9 | 10 | , 11 | ) 12 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTree/no-search-result.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | box-sizing: border-box; 3 | margin: var(--gap-rem-2x); 4 | cursor: default; 5 | padding: var(--gap-rem); 6 | text-align: center; 7 | composes: no-text-overflow from '@/core/styles/composes.module.css'; 8 | } 9 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/core/hooks/useLatestRef.ts: -------------------------------------------------------------------------------- 1 | import { type RefObject, useLayoutEffect, useRef } from 'react' 2 | 3 | export function useLatestRef(value: T): Readonly> { 4 | const ref = useRef(value) 5 | 6 | useLayoutEffect(() => { 7 | ref.current = value 8 | }, [value]) 9 | 10 | return ref 11 | } 12 | -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= htmlRspackPlugin.options.title %> 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTree/NoSearchResult.tsx: -------------------------------------------------------------------------------- 1 | import webExtension from 'webextension-polyfill' 2 | 3 | import * as classes from './no-search-result.module.css' 4 | 5 | export default function NoSearchResult() { 6 | return ( 7 |

{webExtension.i18n.getMessage('noResult')}

8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/utils/faviconUrl.ts: -------------------------------------------------------------------------------- 1 | import webExtension from 'webextension-polyfill' 2 | 3 | export function faviconUrl(pageUrl: string) { 4 | const url = new URL(webExtension.runtime.getURL('/_favicon/')) 5 | url.searchParams.set('pageUrl', pageUrl) 6 | url.searchParams.set('size', '32') 7 | return url.toString() 8 | } 9 | -------------------------------------------------------------------------------- /__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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/core/components/baseItems/Select/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | border: 1px solid var(--highlight-level2); 3 | background-color: transparent; 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 | -------------------------------------------------------------------------------- /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/popup/components/BookmarkTree/bookmark-tree.module.css: -------------------------------------------------------------------------------- 1 | .last-list-item { 2 | border-block-end: var(--section-color) solid var(--gap-rem); 3 | padding-block-end: var(--gap-rem); 4 | } 5 | 6 | .main { 7 | position: absolute; 8 | background-color: var(--bg-color); 9 | width: 100%; 10 | } 11 | 12 | .react-virtual-row { 13 | position: absolute; 14 | top: 0; 15 | left: 0; 16 | width: 100%; 17 | } 18 | -------------------------------------------------------------------------------- /src/core/styles/composes.module.css: -------------------------------------------------------------------------------- 1 | .focus { 2 | outline-width: 1px; 3 | outline-color: var(--highlight-level5); 4 | 5 | &:focus-visible { 6 | outline-style: solid; 7 | } 8 | } 9 | 10 | .list-item { 11 | border: 1px solid transparent; 12 | cursor: default; 13 | line-height: 1; 14 | } 15 | 16 | .no-text-overflow { 17 | overflow: hidden; 18 | text-overflow: ellipsis; 19 | white-space: nowrap; 20 | } 21 | -------------------------------------------------------------------------------- /src/popup/components/keyBindings/types.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from 'type-fest' 2 | 3 | import type { WindowId } from '@/popup/constants/windows.js' 4 | 5 | export type KeyDefinition = string | Readonly 6 | 7 | export type KeyBindingMeta = Readonly<{ 8 | key: KeyDefinition 9 | windowId: ValueOf 10 | }> 11 | 12 | export type KeyBindingEventCallback = (evt: Readonly) => void 13 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/constants.ts: -------------------------------------------------------------------------------- 1 | export const NO_BOOKMARK_ID_PREFIX = 'NO_BOOKMARK_ID_' 2 | export const DRAG_INDICATOR_ID = 'DRAG_INDICATOR_ID' 3 | export const SEARCH_RESULT_ID = 'SEARCH_RESULT_ID' 4 | export const SEPARATE_THIS_URL = 'http://separatethis.com/' 5 | 6 | export const BOOKMARK_TYPES = { 7 | BOOKMARK: 0, 8 | DRAG_INDICATOR: 1, 9 | FOLDER: 2, 10 | NO_BOOKMARK: 3, 11 | SEPARATOR: 4, 12 | } as const 13 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTrees/bookmark-trees.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: row-reverse; 4 | /* do not use --gap-rem because the max-width is fixed (800px) */ 5 | gap: var(--gap-px); 6 | } 7 | 8 | .first-section { 9 | --section-color: var(--main-color2); 10 | 11 | position: relative; 12 | } 13 | 14 | .second-section { 15 | --section-color: var(--main-color1); 16 | 17 | position: relative; 18 | } 19 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/utils/sortByTitle.ts: -------------------------------------------------------------------------------- 1 | import type { BookmarkInfo } from '../types.js' 2 | 3 | export default function sortByTitle( 4 | bookmarkInfos: readonly BookmarkInfo[], 5 | ): readonly BookmarkInfo[] { 6 | const collator = new Intl.Collator(undefined, { 7 | numeric: true, 8 | sensitivity: 'base', 9 | }) 10 | return Array.from(bookmarkInfos).sort((a, b) => 11 | collator.compare(a.title, b.title), 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/core/utils/createAndRenderRoot.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react' 2 | import * as ReactDom from 'react-dom/client' 3 | 4 | export default function createAndRenderRoot( 5 | app: Readonly, 6 | ): ReactDom.Root { 7 | const rootEl = document.getElementById('root') 8 | if (!rootEl) throw new TypeError('#root not found') 9 | 10 | const root = ReactDom.createRoot(rootEl) 11 | root.render(app) 12 | 13 | return root 14 | } 15 | -------------------------------------------------------------------------------- /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/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/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/popup/modules/localStorage.tsx: -------------------------------------------------------------------------------- 1 | import webExtension from 'webextension-polyfill' 2 | 3 | import type { LastPosition } from './lastPositions/types.js' 4 | 5 | export type LocalStorage = Readonly<{ 6 | lastPositions: readonly LastPosition[] 7 | }> 8 | 9 | export async function getLocalStorage(): Promise { 10 | return { 11 | lastPositions: [], 12 | ...((await webExtension.storage.local.get()) as Partial), 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/core/types/assets.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.png' { 4 | const imagePath: string 5 | export default imagePath 6 | } 7 | 8 | declare module '*.svg' { 9 | const imagePath: string 10 | export default imagePath 11 | } 12 | 13 | declare module '*.webp' { 14 | const imagePath: string 15 | export default imagePath 16 | } 17 | 18 | declare module '*.yml' { 19 | const data: never 20 | export default data 21 | } 22 | -------------------------------------------------------------------------------- /.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.46", 19 | "shippedProposals": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /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 | 19 | ) 20 | } 21 | 22 | export default Input 23 | -------------------------------------------------------------------------------- /src/core/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const OPTIONS = { 2 | CLICK_BY_LEFT: 'clickByLeft', 3 | CLICK_BY_LEFT_CTRL: 'clickByLeftCtrl', 4 | CLICK_BY_LEFT_SHIFT: 'clickByLeftShift', 5 | CLICK_BY_MIDDLE: 'clickByMiddle', 6 | DEF_EXPAND: 'defExpand', 7 | FONT_FAMILY: 'fontFamily', 8 | FONT_SIZE: 'fontSize', 9 | HIDE_ROOT_FOLDER: 'hideRootFolder', 10 | MAX_RESULTS: 'maxResults', 11 | OP_FOLDER_BY: 'opFolderBy', 12 | REMEMBER_POS: 'rememberPos', 13 | SEARCH_TARGET: 'searchTarget', 14 | SET_WIDTH: 'setWidth', 15 | TOOLTIP: 'tooltip', 16 | WARN_OPEN_MANY: 'warnOpenMany', 17 | } as const 18 | 19 | export const ROOT_ID = '0' 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "build/production/common.js", 4 | "limit": "77.04 kB" 5 | }, 6 | { 7 | "path": "build/production/options.css", 8 | "limit": "1.65 kB" 9 | }, 10 | { 11 | "path": "build/production/options.html", 12 | "limit": "112 B" 13 | }, 14 | { 15 | "path": "build/production/options.js", 16 | "limit": "4.54 kB" 17 | }, 18 | { 19 | "path": "build/production/popup.css", 20 | "limit": "1.89 kB" 21 | }, 22 | { 23 | "path": "build/production/popup.html", 24 | "limit": "113 B" 25 | }, 26 | { 27 | "path": "build/production/popup.js", 28 | "limit": "11.6 kB" 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /src/core/components/baseItems/StylelessButton/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 StylelessButton: FC = ({ 9 | className, 10 | ref, 11 | type = 'button', 12 | ...props 13 | }) => { 14 | return ( 15 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /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 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 | -------------------------------------------------------------------------------- /src/popup/modules/clipboard.ts: -------------------------------------------------------------------------------- 1 | import constate from 'constate' 2 | import { useCallback, useState } from 'react' 3 | 4 | export const ClipboardAction = { 5 | Copy: 'copy', 6 | Cut: 'cut', 7 | None: 'none', 8 | } as const 9 | 10 | type ClipboardState = Readonly< 11 | | { 12 | action: (typeof ClipboardAction)['None'] 13 | } 14 | | { 15 | action: (typeof ClipboardAction)['Copy'] | (typeof ClipboardAction)['Cut'] 16 | items: ReadonlySet 17 | } 18 | > 19 | 20 | const initialState = { 21 | action: ClipboardAction.None, 22 | } as const satisfies ClipboardState 23 | 24 | function useClipboard() { 25 | const [state, setState] = useState(initialState) 26 | 27 | const reset = useCallback(() => setState(initialState), []) 28 | 29 | return { 30 | state, 31 | reset, 32 | set: setState, 33 | } 34 | } 35 | 36 | export const [ClipboardProvider, useClipboardContext] = constate(useClipboard) 37 | -------------------------------------------------------------------------------- /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/modules/bookmarks/methods/copyBookmark.ts: -------------------------------------------------------------------------------- 1 | import webExtension from 'webextension-polyfill' 2 | 3 | import { BOOKMARK_TYPES } from '../constants.js' 4 | import { getBookmarkInfo, getBookmarkTreeInfo } from './getBookmark.js' 5 | 6 | export async function recursiveCopyBookmarks( 7 | id: string, 8 | destination: Readonly<{ parentId: string | undefined; index: number }>, 9 | ): Promise { 10 | const bookmarkInfo = await getBookmarkInfo(id) 11 | 12 | const createdBookmarkNode = await webExtension.bookmarks.create({ 13 | ...destination, 14 | title: bookmarkInfo.title, 15 | url: bookmarkInfo.url, 16 | }) 17 | 18 | if (bookmarkInfo.type === BOOKMARK_TYPES.FOLDER) { 19 | const bookmarkTree = await getBookmarkTreeInfo(id) 20 | 21 | for (const [index, child] of bookmarkTree.children.entries()) { 22 | await recursiveCopyBookmarks(child.id, { 23 | parentId: createdBookmarkNode.id, 24 | index, 25 | }) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /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/popup/components/menu/useMenu.ts: -------------------------------------------------------------------------------- 1 | import constate from 'constate' 2 | import { useCallback, useState } from 'react' 3 | 4 | type OpenState = Readonly<{ 5 | targetId: string 6 | displayPositions: Readonly<{ top: number; left: number }> 7 | targetPositions: Readonly<{ top: number; left: number }> 8 | }> 9 | 10 | type State = Readonly< 11 | | { 12 | isOpen: false 13 | } 14 | | ({ 15 | isOpen: true 16 | } & OpenState) 17 | > 18 | 19 | const initialState: State = { 20 | isOpen: false, 21 | } 22 | 23 | function useMenu() { 24 | const [state, setState] = useState(initialState) 25 | 26 | const open = useCallback((openState: OpenState) => { 27 | setState({ 28 | ...openState, 29 | isOpen: true, 30 | }) 31 | }, []) 32 | 33 | const close = useCallback(() => { 34 | setState(initialState) 35 | }, []) 36 | 37 | return { 38 | close, 39 | open, 40 | state, 41 | } 42 | } 43 | 44 | export const [MenuProvider, useMenuContext] = constate(useMenu) 45 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTrees/withDragAndDropEvents.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentType, useCallback } from 'react' 2 | 3 | import { useBookmarkTreesContext } from '@/popup/modules/bookmarks/contexts/bookmarkTrees.js' 4 | 5 | import { DragAndDropProvider } from '../dragAndDrop/index.js' 6 | 7 | export default function withDragAndDropEvents

    ( 8 | WrappedComponent: ComponentType

    , 9 | ) { 10 | return function ComponentWithDragAndDropEvents(props: P) { 11 | const { moveBookmarkToDragIndicator, removeDragIndicator } = 12 | useBookmarkTreesContext() 13 | 14 | return ( 15 | , activeKey: string) => { 19 | moveBookmarkToDragIndicator(activeKey) 20 | }, 21 | [moveBookmarkToDragIndicator], 22 | )} 23 | > 24 | 25 | 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/options/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import './globals.module.css' 2 | 3 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 4 | 5 | import { ReactQueryClientProvider } from '@/core/utils/queryClient.js' 6 | import withProviders from '@/core/utils/withProviders.js' 7 | 8 | import Donate from '../Donate.js' 9 | import NavBar from '../NavBar/index.js' 10 | import { NavigationProvider } from '../navigationContext.js' 11 | import Router from '../Router.js' 12 | import * as classes from './styles.module.css' 13 | 14 | function InnerApp() { 15 | return ( 16 |

    17 | 18 | 19 |
    20 | 21 |
    22 | 23 | 24 | 25 | {process.env['NODE_ENV'] === 'development' ? ( 26 | 27 | ) : null} 28 |
    29 | ) 30 | } 31 | 32 | const App = withProviders(InnerApp, [ 33 | ReactQueryClientProvider, 34 | NavigationProvider, 35 | ]) 36 | export default App 37 | -------------------------------------------------------------------------------- /src/options/components/NavBar/styles.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: grid; 3 | grid-auto-columns: 1fr; 4 | grid-auto-flow: column; 5 | background-color: var(--main-color5); 6 | } 7 | 8 | .button { 9 | border: 0; 10 | border-block-end: var(--gap-rem-2x) solid transparent; 11 | background-color: transparent; 12 | cursor: default; 13 | padding-block-start: var(--gap-rem-2x); 14 | text-align: center; 15 | color: var(--bg-color); 16 | font: 17 | 1.125rem 'Archivo Narrow', 18 | sans-serif; 19 | composes: focus no-text-overflow from '@/core/styles/composes.module.css'; 20 | 21 | &:is(:hover, .active) { 22 | &:nth-of-type(1) { 23 | border-block-end-color: var(--main-color1); 24 | } 25 | 26 | &:nth-of-type(2) { 27 | border-block-end-color: var(--main-color2); 28 | } 29 | 30 | &:nth-of-type(3) { 31 | border-block-end-color: var(--main-color3); 32 | } 33 | 34 | &:nth-of-type(4) { 35 | border-block-end-color: var(--main-color4); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/popup/components/keyBindings/useKeyBindingsEvent.ts: -------------------------------------------------------------------------------- 1 | import { useDeepCompareEffect } from 'use-deep-compare' 2 | 3 | import { useLatestRef } from '@/core/hooks/useLatestRef.js' 4 | 5 | import { useKeyBindingsContext } from './KeyBindingsContext.js' 6 | import type { KeyBindingMeta } from './types.js' 7 | 8 | export default function useKeyBindingsEvent( 9 | meta: KeyBindingMeta, 10 | callback: (evt: Readonly) => void | Promise, 11 | ) { 12 | const { addListener, removeListener } = useKeyBindingsContext() 13 | 14 | const callbackRef = useLatestRef(callback) 15 | 16 | useDeepCompareEffect(() => { 17 | function wrappedCallback(evt: Readonly) { 18 | const maybePromise = callbackRef.current(evt) 19 | if (maybePromise !== undefined) { 20 | maybePromise.catch(console.error) 21 | } 22 | } 23 | 24 | addListener(meta, wrappedCallback) 25 | 26 | return () => { 27 | removeListener(meta, wrappedCallback) 28 | } 29 | }, [addListener, callbackRef, meta, removeListener]) 30 | } 31 | -------------------------------------------------------------------------------- /src/popup/components/dragAndDrop/DragAndDropContext.ts: -------------------------------------------------------------------------------- 1 | import constate from 'constate' 2 | import { useState } from 'react' 3 | import useEventListener from 'use-typed-event-listener' 4 | 5 | function useDragAndDrop({ 6 | onDragEnd, 7 | onDrop, 8 | }: Readonly<{ 9 | onDragEnd: (evt: Readonly) => void 10 | onDrop: (evt: Readonly, activeKey: string) => void 11 | }>) { 12 | const [activeKey, setActiveKey] = useState(null) 13 | 14 | // use document.mouseup to handle drop events because we are not using native drag, and it can support drop outside of the document.body 15 | useEventListener(document, 'mouseup', (evt) => { 16 | // ignore as user is not dragging 17 | if (activeKey === null) return 18 | 19 | if (evt.buttons === 0) { 20 | onDrop(evt, activeKey) 21 | onDragEnd(evt) 22 | 23 | setActiveKey(null) 24 | } 25 | }) 26 | 27 | return { 28 | activeKey, 29 | setActiveKey, 30 | } 31 | } 32 | 33 | export const [DragAndDropProvider, useDragAndDropContext] = 34 | constate(useDragAndDrop) 35 | -------------------------------------------------------------------------------- /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/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import type { ValueOf } from 'type-fest' 3 | import webExtension from 'webextension-polyfill' 4 | 5 | import type { OPTIONS } from '@/core/constants/index.js' 6 | import createAndRenderRoot from '@/core/utils/createAndRenderRoot.js' 7 | import getOptionsConfig from '@/core/utils/getOptionsConfig.js' 8 | 9 | import App from './components/App/index.js' 10 | import { getOptions } from './modules/options.js' 11 | 12 | async function main(): Promise { 13 | const [options, optionsConfig] = await Promise.all([ 14 | getOptions(), 15 | getOptionsConfig(), 16 | ]) 17 | 18 | // if missing option, open options page to init options 19 | const missingOptionNames = ( 20 | Object.keys(optionsConfig) as readonly ValueOf[] 21 | ).filter((optionName) => options[optionName] === undefined) 22 | if (missingOptionNames.length > 0) { 23 | await webExtension.runtime.openOptionsPage() 24 | return 25 | } 26 | 27 | createAndRenderRoot( 28 | 29 | 30 | , 31 | ) 32 | } 33 | 34 | main().catch(console.error) 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/types.ts: -------------------------------------------------------------------------------- 1 | import type { BOOKMARK_TYPES } from './constants.js' 2 | 3 | export type BookmarkInfo = Readonly< 4 | { 5 | id: string 6 | isRoot: boolean 7 | isSimulated: boolean 8 | isUnmodifiable: boolean 9 | parentId?: string | undefined 10 | storageIndex: number 11 | title: string 12 | } & ( 13 | | { 14 | type: (typeof BOOKMARK_TYPES)['BOOKMARK'] 15 | iconUrl: string 16 | url: string 17 | } 18 | | { 19 | type: (typeof BOOKMARK_TYPES)['DRAG_INDICATOR'] 20 | iconUrl?: never 21 | url?: never 22 | } 23 | | { 24 | type: (typeof BOOKMARK_TYPES)['FOLDER'] 25 | iconUrl: string 26 | url?: never 27 | } 28 | | { 29 | type: (typeof BOOKMARK_TYPES)['NO_BOOKMARK'] 30 | iconUrl?: never 31 | url?: never 32 | } 33 | | { 34 | type: (typeof BOOKMARK_TYPES)['SEPARATOR'] 35 | iconUrl?: never 36 | url: string 37 | } 38 | ) 39 | > 40 | 41 | export type BookmarkTreeInfo = Readonly<{ 42 | children: ReadonlyArray 43 | parent: BookmarkInfo & { type: (typeof BOOKMARK_TYPES)['FOLDER'] } 44 | }> 45 | -------------------------------------------------------------------------------- /src/popup/components/editor/EditorContext.ts: -------------------------------------------------------------------------------- 1 | import constate from 'constate' 2 | import { useCallback, useState } from 'react' 3 | 4 | type OpenParams = Readonly< 5 | ( 6 | | { 7 | isCreating: true 8 | createAfterId: string 9 | } 10 | | { 11 | isCreating: false 12 | editTargetId: string 13 | } 14 | ) & { 15 | isAllowedToEditUrl: boolean 16 | positions?: { top: number; left: number } 17 | } 18 | > 19 | 20 | type State = Readonly< 21 | | { 22 | isOpen: false 23 | } 24 | | ({ 25 | isOpen: true 26 | } & Required) 27 | > 28 | 29 | const initialState: State = { 30 | isOpen: false, 31 | } 32 | 33 | function useEditor() { 34 | const [state, setState] = useState(initialState) 35 | 36 | const open = useCallback((openParams: OpenParams) => { 37 | setState({ 38 | positions: { top: 0, left: 0 }, 39 | ...openParams, 40 | isOpen: true, 41 | }) 42 | }, []) 43 | 44 | const close = useCallback(() => { 45 | setState(initialState) 46 | }, []) 47 | 48 | return { 49 | close, 50 | open, 51 | state, 52 | } 53 | } 54 | 55 | export const [EditorProvider, useEditorContext] = constate(useEditor) 56 | -------------------------------------------------------------------------------- /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/popup/components/BookmarkTrees/BookmarkTrees.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode, useMemo } from 'react' 2 | 3 | import BookmarkTree from '../BookmarkTree/index.js' 4 | import * as classes from './bookmark-trees.module.css' 5 | 6 | type Props = Readonly<{ 7 | firstTreeHeader: ReactNode 8 | treeIds: ReadonlyArray 9 | width: number 10 | }> 11 | export default function BookmarkTrees(props: Props) { 12 | const trees = props.treeIds.map((treeId) => ( 13 | 14 | )) 15 | 16 | const firstSectionItems = trees.filter((_, index) => index % 2 === 0) 17 | const secondSectionItems = trees.filter((_, index) => index % 2 !== 0) 18 | 19 | const widthStyle = useMemo( 20 | () => ({ 21 | width: `${props.width}px`, 22 | }), 23 | [props.width], 24 | ) 25 | 26 | return ( 27 |
    28 |
    29 | {props.firstTreeHeader} 30 | {firstSectionItems} 31 |
    32 | {secondSectionItems.length > 0 && ( 33 |
    34 | {secondSectionItems} 35 |
    36 | )} 37 |
    38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTree/BookmarkRow/bookmark-row.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | gap: var(--gap-rem-2x); 4 | align-items: center; 5 | padding-inline: var(--gap-rem); 6 | width: stretch; 7 | composes: list-item from '@/core/styles/composes.module.css'; 8 | 9 | &.drag-indicator { 10 | border: 1px dashed var(--highlight-level5); 11 | padding-block: var(--gap-rem-2x); 12 | block-size: 1em; 13 | } 14 | 15 | &.root-folder { 16 | color: var(--section-color); 17 | } 18 | 19 | &.highlighted { 20 | outline: 0; 21 | border-color: var(--highlight-level2); 22 | background-color: var(--highlight-level1); 23 | } 24 | 25 | &.separator { 26 | color: var(--highlight-level4); 27 | 28 | & > .title { 29 | padding-block: var(--gap-rem); 30 | text-overflow: clip; 31 | line-height: 0.5; 32 | } 33 | } 34 | 35 | &.unclickable { 36 | color: var(--highlight-level4); 37 | 38 | & > img { 39 | opacity: 0.4; 40 | } 41 | } 42 | } 43 | 44 | .icon { 45 | flex-shrink: 0; 46 | aspect-ratio: 1; 47 | width: auto; 48 | height: 1em; 49 | min-height: 1rem; 50 | } 51 | 52 | .title { 53 | padding-block: var(--gap-rem-2x); 54 | composes: no-text-overflow from '@/core/styles/composes.module.css'; 55 | } 56 | -------------------------------------------------------------------------------- /src/popup/components/menu/MenuRow.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import type { MouseEventHandler } from 'react' 3 | import type { ValueOf } from 'type-fest' 4 | import webExtension from 'webextension-polyfill' 5 | 6 | import StylelessButton from '@/core/components/baseItems/StylelessButton/index.js' 7 | 8 | import type { MenuItem } from './constants.js' 9 | import * as classes from './menu-row.module.css' 10 | 11 | type Props = Readonly<{ 12 | isFocused: boolean 13 | isUnclickable: boolean 14 | onClick: MouseEventHandler 15 | onMouseEnter: MouseEventHandler 16 | onMouseLeave: MouseEventHandler 17 | rowName: ValueOf 18 | }> 19 | export default function MenuRow({ 20 | isFocused, 21 | isUnclickable, 22 | onClick, 23 | onMouseEnter, 24 | onMouseLeave, 25 | rowName, 26 | }: Props) { 27 | return ( 28 |
  • 29 | 36 | {webExtension.i18n.getMessage(rowName)} 37 | 38 |
  • 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/popup/modules/options.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import constate from 'constate' 3 | import type { ComponentType } from 'react' 4 | import webExtension from 'webextension-polyfill' 5 | 6 | import type { Options } from '@/core/types/options.js' 7 | import withProviders from '@/core/utils/withProviders.js' 8 | 9 | const queryKey = 'options' 10 | 11 | export async function getOptions() { 12 | return (await webExtension.storage.sync.get()) as unknown as Options 13 | } 14 | 15 | const [OptionsProvider, useInternalOptions] = constate( 16 | function useOptionsState() { 17 | const { data: options } = useQuery({ 18 | queryKey: [queryKey], 19 | queryFn: getOptions, 20 | }) 21 | 22 | return options 23 | }, 24 | ) 25 | 26 | export function withOptions

    (WrappedComponent: ComponentType

    ) { 27 | function InnerComponent(props: P) { 28 | const options = useInternalOptions() 29 | if (!options) return null 30 | 31 | return 32 | } 33 | 34 | return withProviders

    (InnerComponent, [OptionsProvider]) 35 | } 36 | 37 | export function useOptions() { 38 | const options = useInternalOptions() 39 | if (!options) { 40 | throw new Error('options not found') 41 | } 42 | 43 | return options 44 | } 45 | -------------------------------------------------------------------------------- /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 | - `build/store.md` - Description for Chrome Web Store 51 | - `README.md` - Description for GitHub 52 | 53 | 1. md 54 | 55 | ```sh 56 | make md 57 | ``` 58 | 59 | To generate markdown files 60 | - `build/store.md` - Description for Chrome Web Store 61 | - `README.md` - Description for GitHub 62 | -------------------------------------------------------------------------------- /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/popup/components/BookmarkTree/BookmarkRow/BookmarkRow.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import type { JSX } from 'react' 3 | 4 | import StylelessButton from '@/core/components/baseItems/StylelessButton/index.js' 5 | 6 | import * as classes from './bookmark-row.module.css' 7 | 8 | type Props = Readonly< 9 | JSX.IntrinsicElements['button'] & { 10 | iconUrl?: string | undefined 11 | isHighlighted: boolean 12 | isUnclickable: boolean 13 | title: string 14 | tooltip?: string | undefined 15 | } 16 | > 17 | function BookmarkRow({ 18 | iconUrl, 19 | isHighlighted, 20 | isUnclickable, 21 | title, 22 | tooltip, 23 | ...buttonProps 24 | }: Props) { 25 | return ( 26 | 38 | {iconUrl && } 39 | {title} 40 | 41 | ) 42 | } 43 | 44 | export default BookmarkRow 45 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/utils/generators.ts: -------------------------------------------------------------------------------- 1 | import webExtension from 'webextension-polyfill' 2 | 3 | import folderIcon from '@/popup/images/folder.svg' 4 | 5 | import { 6 | BOOKMARK_TYPES, 7 | DRAG_INDICATOR_ID, 8 | NO_BOOKMARK_ID_PREFIX, 9 | SEARCH_RESULT_ID, 10 | } from '../constants.js' 11 | import type { BookmarkInfo } from '../types.js' 12 | 13 | const simulatedBookmarkInfo = { 14 | isRoot: false, 15 | isSimulated: true, 16 | isUnmodifiable: true, 17 | storageIndex: -1, 18 | title: '', 19 | } as const 20 | 21 | export function generateDragIndicator(parentId: string) { 22 | return { 23 | ...simulatedBookmarkInfo, 24 | id: DRAG_INDICATOR_ID, 25 | parentId, 26 | type: BOOKMARK_TYPES.DRAG_INDICATOR, 27 | } as const satisfies BookmarkInfo 28 | } 29 | 30 | export function generateNoBookmarkPlaceholder(parentId: string) { 31 | return { 32 | ...simulatedBookmarkInfo, 33 | id: `${NO_BOOKMARK_ID_PREFIX}${parentId}`, 34 | parentId, 35 | title: webExtension.i18n.getMessage('noBkmark'), 36 | type: BOOKMARK_TYPES.NO_BOOKMARK, 37 | } as const satisfies BookmarkInfo 38 | } 39 | 40 | export function generateSearchResultParent() { 41 | return { 42 | ...simulatedBookmarkInfo, 43 | id: SEARCH_RESULT_ID, 44 | type: BOOKMARK_TYPES.FOLDER, 45 | iconUrl: folderIcon, 46 | } as const satisfies BookmarkInfo 47 | } 48 | -------------------------------------------------------------------------------- /src/popup/components/floatingWindow/useGlobalBodySize.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react' 2 | 3 | import { notNullish } from '@/core/utils/array.js' 4 | 5 | import { 6 | type BodySize, 7 | useFloatingWindowContext, 8 | } from './FloatingWindowContext.js' 9 | 10 | export default function useGlobalBodySize() { 11 | const { bodySizeStack, setBodySizeStack } = useFloatingWindowContext() 12 | 13 | const globalBodySize = useMemo(() => { 14 | const heights = bodySizeStack.map((x) => x.height).filter(notNullish) 15 | const maxHeight = heights.length > 0 ? Math.max(...heights) : undefined 16 | 17 | const widths = bodySizeStack.map((x) => x.width).filter(notNullish) 18 | const maxWidth = widths.length > 0 ? Math.max(...widths) : undefined 19 | 20 | return { 21 | height: maxHeight, 22 | width: maxWidth, 23 | } 24 | }, [bodySizeStack]) 25 | 26 | const insertBodySize = useCallback( 27 | (newBodySize: BodySize) => { 28 | setBodySizeStack((prevBodySize) => [...prevBodySize, newBodySize]) 29 | 30 | return { 31 | removeBodySize: () => { 32 | setBodySizeStack((prevBodySize) => { 33 | return prevBodySize.filter((state) => state !== newBodySize) 34 | }) 35 | }, 36 | } 37 | }, 38 | [setBodySizeStack], 39 | ) 40 | 41 | return { 42 | globalBodySize, 43 | insertBodySize, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/core/components/baseItems/StylelessButton/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 StylelessButton from './index.js' 6 | 7 | describe('StylelessButton', () => { 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 | {name} 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 | {name} 36 |
    , 37 | ) 38 | 39 | await user.click(screen.getByRole('button', { name })) 40 | 41 | expect(handleSubmit).toHaveBeenCalled() 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /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/popup/components/menu/Menu.tsx: -------------------------------------------------------------------------------- 1 | import type { MouseEventHandler } from 'react' 2 | 3 | import PlainList from '@/core/components/baseItems/PlainList/index.js' 4 | 5 | import * as classes from './menu.module.css' 6 | import MenuRow from './MenuRow.js' 7 | import type { MenuPattern } from './types.js' 8 | 9 | type Props = Readonly<{ 10 | highlightedIndex?: number | undefined 11 | menuPattern: MenuPattern 12 | onRowClick: MouseEventHandler 13 | onRowMouseEnter: (index: number) => MouseEventHandler 14 | onRowMouseLeave: (index: number) => MouseEventHandler 15 | unclickableRows: ReadonlyArray 16 | }> 17 | export default function Menu(props: Props) { 18 | const allRowNames = props.menuPattern.flat() 19 | 20 | return ( 21 |
    22 | {props.menuPattern.map((rowNames) => ( 23 | 24 | {rowNames.map((rowName) => { 25 | const rowIndex = allRowNames.indexOf(rowName) 26 | return ( 27 | 36 | ) 37 | })} 38 | 39 | ))} 40 |
    41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/options/components/Contributors.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react' 2 | 3 | import * as classes from './contributors.module.css' 4 | 5 | const contributors: Record = { 6 | Developer: ['foray1010'], 7 | Tester: ['David Bryant'], 8 | 'Dutch Translator': ['Marzas'], 9 | 'French Translators': ['foXaCe', 'Alexis Schapman'], 10 | 'German Translator': ['Gürkan ZENGIN'], 11 | 'Italian Translator': ['Giacomo Fabio Leone'], 12 | 'Korean Translator': ['zenyr'], 13 | 'Norwegian Bokmål Translator': ['Bjorn Tore Asheim'], 14 | 'Portuguese Translator': ['Ubelloch'], 15 | 'Russian Translators': ['kameo', 'Oleg K,'], 16 | 'Spanish Translator': ['cyanine'], 17 | 'Vietnamese Translator': ['Anh Phan'], 18 | Sponsors: [ 19 | 'Abtin Samadi', 20 | 'Drake Roman', 21 | 'j**************r@u******.nl', 22 | 'Claudine J Haddock', 23 | 'Carlos Velazquez', 24 | 'Jacob Randall', 25 | 'Николай Кондрашов', 26 | 'a******s@v********.ca', 27 | 'L. Smith', 28 | 'j*********e@g****.com', 29 | 'J*****y@c*.com', 30 | ], 31 | } 32 | 33 | export default function Contributors() { 34 | return ( 35 |
    36 | {Object.entries(contributors).map( 37 | ([contributeType, contributorsOfType]) => ( 38 | 39 |
    {contributeType}
    40 | {contributorsOfType.map((contributor) => ( 41 |
    {contributor}
    42 | ))} 43 |
    44 | ), 45 | )} 46 |
    47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTrees/index.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode, useMemo } from 'react' 2 | 3 | import { OPTIONS } from '@/core/constants/index.js' 4 | import { WindowId } from '@/popup/constants/windows.js' 5 | import { useBookmarkTreesContext } from '@/popup/modules/bookmarks/contexts/bookmarkTrees.js' 6 | import { LastPositionsProvider } from '@/popup/modules/lastPositions/index.js' 7 | import { useOptions } from '@/popup/modules/options.js' 8 | 9 | import { KeyBindingsWindow } from '../keyBindings/index.js' 10 | import BookmarkTrees from './BookmarkTrees.js' 11 | import withDragAndDropEvents from './withDragAndDropEvents.js' 12 | import withKeyboardNav from './withKeyboardNav.js' 13 | 14 | type Props = Readonly<{ 15 | firstTreeHeader: ReactNode 16 | }> 17 | function InnerBookmarkTreesContainer(props: Props) { 18 | const options = useOptions() 19 | 20 | const { bookmarkTrees } = useBookmarkTreesContext() 21 | const treeIds = useMemo( 22 | () => bookmarkTrees.map((tree) => tree.parent.id), 23 | [bookmarkTrees], 24 | ) 25 | 26 | return ( 27 | 28 | 29 | 34 | 35 | 36 | ) 37 | } 38 | 39 | const BookmarkTreesContainer = withDragAndDropEvents( 40 | withKeyboardNav(InnerBookmarkTreesContainer), 41 | ) 42 | export default BookmarkTreesContainer 43 | -------------------------------------------------------------------------------- /src/options/components/NavBar/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import type { ValueOf } from 'type-fest' 3 | import webExtension from 'webextension-polyfill' 4 | 5 | import Button from '@/core/components/baseItems/Button/index.js' 6 | import { RoutePath } from '@/options/constants/index.js' 7 | 8 | import { useNavigationContext } from '../navigationContext.js' 9 | import * as classes from './styles.module.css' 10 | 11 | const navBarItemInfos = [ 12 | { 13 | path: RoutePath.General, 14 | title: webExtension.i18n.getMessage('general'), 15 | }, 16 | { 17 | path: RoutePath.UserInterface, 18 | title: webExtension.i18n.getMessage('userInterface'), 19 | }, 20 | { 21 | path: RoutePath.Control, 22 | title: webExtension.i18n.getMessage('control'), 23 | }, 24 | { 25 | path: RoutePath.Contributors, 26 | title: webExtension.i18n.getMessage('contributors'), 27 | }, 28 | ] as const satisfies ReadonlyArray< 29 | Readonly<{ 30 | path: ValueOf 31 | title: string 32 | }> 33 | > 34 | 35 | export default function NavBar() { 36 | const { currentPath, setCurrentPath } = useNavigationContext() 37 | 38 | return ( 39 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /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/popup/components/Search/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import { type ComponentProps, type FC, useId } from 'react' 3 | import webExtension from 'webextension-polyfill' 4 | 5 | import Input from '@/core/components/baseItems/Input/index.js' 6 | import StylelessButton from '@/core/components/baseItems/StylelessButton/index.js' 7 | import { Component as Cross } from '@/popup/images/cross.svg?svgUse' 8 | import { Component as Search } from '@/popup/images/search.svg?svgUse' 9 | 10 | import * as classes from './search-input.module.css' 11 | 12 | type Props = Readonly< 13 | ComponentProps & { 14 | onCancel(): void 15 | } 16 | > 17 | const SearchInput: FC = ({ onCancel, ref, ...inputProps }) => { 18 | const inputId = useId() 19 | 20 | return ( 21 |
    22 | 23 | 31 | {inputProps.value ? ( 32 | 38 | 39 | 40 | ) : null} 41 |
    42 | ) 43 | } 44 | 45 | export default SearchInput 46 | -------------------------------------------------------------------------------- /src/options/components/Donate.tsx: -------------------------------------------------------------------------------- 1 | import donateIcon from '../images/btn_donateCC_LG.webp' 2 | import * as classes from './donate.module.css' 3 | import ExternalLink from './ExternalLink/index.js' 4 | 5 | const paypalUrl = 6 | 'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=TP67BBZ7VK934' 7 | 8 | export default function Donate() { 9 | return ( 10 |
    11 | 12 | 13 | donate 14 | 15 | 16 | If you like Popup my Bookmarks, please consider to: 17 |
      18 |
    1. 19 | rate it on  20 | 21 | Chrome Web Store 22 | 23 |
    2. 24 |
    3. 25 | fork me on  26 | 27 | GitHub 28 | 29 |
    4. 30 |
    5. 31 | buy me a drink via  32 | PayPal 33 |  or  34 | 35 | Bitcoin 36 | 37 |  :) 38 |
    6. 39 |
    40 |
    41 |
    42 |
    43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/popup/components/Search/index.tsx: -------------------------------------------------------------------------------- 1 | import { startTransition, useRef, useState } from 'react' 2 | 3 | import { WindowId } from '@/popup/constants/windows.js' 4 | import { useBookmarkTreesContext } from '@/popup/modules/bookmarks/contexts/bookmarkTrees.js' 5 | 6 | import { useKeyBindingsEvent } from '../keyBindings/index.js' 7 | import SearchInput from './SearchInput.js' 8 | 9 | export default function SearchContainer() { 10 | const [inputValue, setInputValue] = useState('') 11 | 12 | const { setSearchQuery } = useBookmarkTreesContext() 13 | const executeSearch = (value: string) => { 14 | startTransition(() => { 15 | setSearchQuery(value) 16 | }) 17 | } 18 | 19 | const updateInputValue = ({ 20 | value, 21 | isComposing, 22 | }: Readonly<{ 23 | value: string 24 | isComposing: boolean 25 | }>): void => { 26 | setInputValue(value) 27 | 28 | // do not search during IME composition 29 | if (!isComposing) { 30 | executeSearch(value) 31 | } 32 | } 33 | 34 | const inputRef = useRef(null) 35 | useKeyBindingsEvent({ key: /^.$/u, windowId: WindowId.Base }, () => { 36 | const isFocusedOnInput = document.activeElement instanceof HTMLInputElement 37 | if (isFocusedOnInput) return 38 | 39 | inputRef.current?.focus() 40 | }) 41 | 42 | return ( 43 | updateInputValue({ value: '', isComposing: false })} 47 | onChange={(evt) => { 48 | updateInputValue({ 49 | value: evt.currentTarget.value, 50 | isComposing: (evt.nativeEvent as InputEvent).isComposing, 51 | }) 52 | }} 53 | onCompositionEnd={() => executeSearch(inputValue)} 54 | /> 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/popup/components/listNavigation/ListNavigationContext.ts: -------------------------------------------------------------------------------- 1 | import constate from 'constate' 2 | import { useCallback, useState } from 'react' 3 | 4 | import deleteFromMap from '@/popup/utils/deleteFromMap.js' 5 | 6 | function useListNavigation() { 7 | const [highlightedIndices, setHighlightedIndices] = useState< 8 | ReadonlyMap 9 | >(new Map()) 10 | 11 | const setHighlightedIndex = useCallback( 12 | (listIndex: number, itemIndex: number) => { 13 | setHighlightedIndices((prevState) => 14 | new Map(prevState).set(listIndex, itemIndex), 15 | ) 16 | }, 17 | [], 18 | ) 19 | 20 | const unsetHighlightedIndex = useCallback( 21 | (listIndex: number, itemIndex: number) => { 22 | setHighlightedIndices((prevState) => { 23 | if (prevState.get(listIndex) !== itemIndex) return prevState 24 | 25 | return deleteFromMap(prevState, listIndex) 26 | }) 27 | }, 28 | [], 29 | ) 30 | 31 | const [itemCounts, setItemCounts] = useState>( 32 | new Map(), 33 | ) 34 | 35 | const setItemCount = useCallback((listIndex: number, itemCount: number) => { 36 | setItemCounts((prevState) => new Map(prevState).set(listIndex, itemCount)) 37 | }, []) 38 | 39 | const removeList = useCallback((listIndex: number) => { 40 | setHighlightedIndices((prevState) => deleteFromMap(prevState, listIndex)) 41 | setItemCounts((prevState) => deleteFromMap(prevState, listIndex)) 42 | }, []) 43 | 44 | return { 45 | listNavigation: { highlightedIndices, itemCounts }, 46 | setHighlightedIndex, 47 | unsetHighlightedIndex, 48 | setItemCount, 49 | removeList, 50 | } 51 | } 52 | 53 | export const [ListNavigationProvider, useListNavigationContext] = 54 | constate(useListNavigation) 55 | -------------------------------------------------------------------------------- /src/core/styles/globals.module.css: -------------------------------------------------------------------------------- 1 | @import-normalize; 2 | 3 | @font-face { 4 | font-display: fallback; 5 | font-family: 'Archivo Narrow'; 6 | src: url('@fontsource/archivo-narrow/files/archivo-narrow-latin-400-normal.woff2'); 7 | } 8 | 9 | :root { 10 | --gap-px: 2px; 11 | /* upper limit is 4px because the popup size is limited to 800x600, and we need to save space */ 12 | --gap-rem: min(0.125rem, 4px); 13 | --gap-rem-2x: calc(var(--gap-rem) * 2); 14 | --gap-rem-3x: calc(var(--gap-rem) * 3); 15 | --gap-rem-4x: calc(var(--gap-rem) * 4); 16 | --highlight-level1: oklch(95% 0 0deg); 17 | --highlight-level2: oklch(85% 0 0deg); 18 | --highlight-level3: oklch(75% 0 0deg); 19 | --highlight-level4: oklch(65% 0 0deg); 20 | --highlight-level5: oklch(55% 0 0deg); 21 | --bg-color: oklch(100% 0 0deg); 22 | --main-color1: oklch(70% 0.1488 246.66deg); 23 | --main-color2: oklch(77.03% 0.1741 64.05deg); 24 | --main-color3: oklch(65.6% 0.1071 185.34deg); 25 | --main-color4: oklch(64.2% 0.215 28.8deg); 26 | --main-color5: oklch(56.5% 0.043 40deg); 27 | --main-font-color: oklch(24.7% 0 0deg); 28 | 29 | background-color: var(--bg-color); 30 | color: var(--main-font-color); 31 | font-family: system-ui, sans-serif; 32 | } 33 | 34 | @media (prefers-color-scheme: dark) { 35 | :root { 36 | --highlight-level1: oklch(35% 0 0deg); 37 | --highlight-level2: oklch(45% 0 0deg); 38 | --highlight-level3: oklch(55% 0 0deg); 39 | --highlight-level4: oklch(65% 0 0deg); 40 | --highlight-level5: oklch(75% 0 0deg); 41 | --bg-color: oklch(24.7% 0 0deg); 42 | --main-font-color: oklch(100% 0 0deg); 43 | } 44 | } 45 | 46 | body { 47 | margin: 0; 48 | font-family: inherit; /* use the font-family set from :root or html tag */ 49 | } 50 | 51 | [hidden] { 52 | display: none !important; 53 | } 54 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/utils/clickBookmark.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | KeyboardEvent as ReactKeyboardEvent, 3 | MouseEvent as ReactMouseEvent, 4 | } from 'react' 5 | import type { ValueOf } from 'type-fest' 6 | 7 | import { OPTIONS } from '@/core/constants/index.js' 8 | import { OPEN_IN_TYPES } from '@/popup/constants/menu.js' 9 | 10 | export function getClickOptionNameByEvent( 11 | evt: Readonly< 12 | KeyboardEvent | MouseEvent | ReactKeyboardEvent | ReactMouseEvent 13 | >, 14 | ) { 15 | if (evt.ctrlKey || evt.metaKey) { 16 | return OPTIONS.CLICK_BY_LEFT_CTRL 17 | } 18 | 19 | if (evt.shiftKey) { 20 | return OPTIONS.CLICK_BY_LEFT_SHIFT 21 | } 22 | 23 | return OPTIONS.CLICK_BY_LEFT 24 | } 25 | 26 | export function mapOptionToOpenBookmarkProps(option: number): Readonly<{ 27 | openIn: ValueOf 28 | isCloseThisExtension: boolean 29 | }> { 30 | switch (option) { 31 | case 0: // current tab 32 | case 1: // current tab (without closing PmB) 33 | return { 34 | openIn: OPEN_IN_TYPES.CURRENT_TAB, 35 | isCloseThisExtension: option === 0, 36 | } 37 | 38 | default: 39 | case 2: // new tab 40 | return { 41 | openIn: OPEN_IN_TYPES.NEW_TAB, 42 | isCloseThisExtension: true, 43 | } 44 | 45 | case 3: // background tab 46 | case 4: // background tab (without closing PmB) 47 | return { 48 | openIn: OPEN_IN_TYPES.BACKGROUND_TAB, 49 | isCloseThisExtension: option === 3, 50 | } 51 | 52 | case 5: // new window 53 | return { 54 | openIn: OPEN_IN_TYPES.NEW_WINDOW, 55 | isCloseThisExtension: true, 56 | } 57 | 58 | case 6: // incognito window 59 | return { 60 | openIn: OPEN_IN_TYPES.INCOGNITO_WINDOW, 61 | isCloseThisExtension: true, 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTree/BookmarkRow/useTooltip.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { ROOT_ID } from '@/core/constants/index.js' 4 | import { getBookmarkInfo } from '@/popup/modules/bookmarks/methods/getBookmark.js' 5 | import type { BookmarkInfo } from '@/popup/modules/bookmarks/types.js' 6 | 7 | async function getBreadcrumbs( 8 | id: string | undefined, 9 | ): Promise { 10 | if (!id || id === ROOT_ID) return [] 11 | 12 | const bookmarkInfo = await getBookmarkInfo(id) 13 | 14 | const parentBreadcrumbs = await getBreadcrumbs(bookmarkInfo.parentId) 15 | return parentBreadcrumbs.concat(bookmarkInfo.title) 16 | } 17 | 18 | function joinLines(lines: ReadonlyArray): string { 19 | return lines.filter(Boolean).join('\n') 20 | } 21 | 22 | export default function useTooltip({ 23 | bookmarkInfo, 24 | isSearching, 25 | isShowTooltip, 26 | }: Readonly<{ 27 | bookmarkInfo: BookmarkInfo 28 | isSearching: boolean 29 | isShowTooltip: boolean 30 | }>): string | undefined { 31 | const tooltip = isShowTooltip 32 | ? joinLines([bookmarkInfo.title, bookmarkInfo.url]) 33 | : undefined 34 | 35 | const [breadcrumbs, setBreadcrumbs] = useState([]) 36 | 37 | useEffect(() => { 38 | if (!isSearching) return 39 | 40 | const abortController = new AbortController() 41 | 42 | getBreadcrumbs(bookmarkInfo.parentId) 43 | .then((tooltip) => { 44 | if (abortController.signal.aborted) return true 45 | 46 | setBreadcrumbs(tooltip) 47 | }) 48 | .catch(console.error) 49 | 50 | return () => { 51 | abortController.abort() 52 | } 53 | }, [bookmarkInfo, isSearching]) 54 | 55 | if (isSearching && breadcrumbs.length > 0) { 56 | return joinLines([tooltip, `[${breadcrumbs.join(' > ')}]`]) 57 | } 58 | 59 | return tooltip 60 | } 61 | -------------------------------------------------------------------------------- /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/popup/components/keyBindings/KeyBindingsWindow.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren, useEffect, useRef } from 'react' 2 | import type { ValueOf } from 'type-fest' 3 | 4 | import type { WindowId } from '@/popup/constants/windows.js' 5 | 6 | import { useKeyBindingsContext } from './KeyBindingsContext.js' 7 | import * as classes from './KeyBindingsWindow.module.css' 8 | 9 | type Props = Readonly< 10 | PropsWithChildren<{ 11 | windowId: ValueOf 12 | }> 13 | > 14 | export default function KeyBindingsWindow({ children, windowId }: Props) { 15 | const { activeWindowId, appendActiveWindowId, removeActiveWindowId } = 16 | useKeyBindingsContext() 17 | 18 | useEffect(() => { 19 | appendActiveWindowId(windowId) 20 | 21 | return () => { 22 | removeActiveWindowId(windowId) 23 | } 24 | }, [appendActiveWindowId, removeActiveWindowId, windowId]) 25 | 26 | const isInert = activeWindowId !== windowId 27 | 28 | const windowRef = useRef(null) 29 | useEffect(() => { 30 | // this element is `inert` at the beginning and it is not focusable 31 | if (!isInert) { 32 | // this function must run after other window is set to `inert`, because whenever that happens, the `document.activeElement` will be reset to the `document.body` in Chrome 102. The hacky way to fix it is to wrap it within `requestAnimationFrame`. 33 | requestAnimationFrame(() => { 34 | // when right clicking an bookmark to open menu, then pressing arrow up/down key, the bookmark tree will scroll together. We need to focus to the window to prevent this. 35 | windowRef.current?.focus() 36 | }) 37 | } 38 | }, [isInert]) 39 | 40 | return ( 41 |
    47 | {children} 48 |
    49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/methods/createBookmark.ts: -------------------------------------------------------------------------------- 1 | import webExtension from 'webextension-polyfill' 2 | 3 | import { SEPARATE_THIS_URL } from '../constants.js' 4 | import { toBookmarkInfo } from '../utils/transformers.js' 5 | import { getBookmarkInfo } from './getBookmark.js' 6 | 7 | async function createBookmark( 8 | bookmark: Readonly, 9 | ) { 10 | const bookmarkNode = await webExtension.bookmarks.create({ 11 | ...bookmark, 12 | title: bookmark.title?.trim(), 13 | url: bookmark.url?.trim(), 14 | }) 15 | return toBookmarkInfo(bookmarkNode) 16 | } 17 | 18 | export async function createBookmarkAfterId({ 19 | createAfterId, 20 | ...rest 21 | }: Readonly< 22 | Omit & { 23 | createAfterId: string 24 | } 25 | >) { 26 | const bookmarkInfo = await getBookmarkInfo(createAfterId) 27 | 28 | return await createBookmark({ 29 | ...rest, 30 | index: bookmarkInfo.storageIndex + 1, 31 | parentId: bookmarkInfo.parentId, 32 | }) 33 | } 34 | 35 | export async function bookmarkCurrentPage( 36 | rest: Readonly>, 37 | ) { 38 | const [currentTab] = await webExtension.tabs.query({ 39 | currentWindow: true, 40 | active: true, 41 | }) 42 | if (!currentTab) throw new Error('cannot get current tab') 43 | 44 | return await createBookmark({ 45 | ...rest, 46 | title: currentTab.title, 47 | url: currentTab.url, 48 | }) 49 | } 50 | 51 | export async function createSeparator( 52 | rest: Readonly>, 53 | ) { 54 | return await createBookmark({ 55 | ...rest, 56 | title: '- '.repeat(54), 57 | // avoid duplicated URL which may be cleaned up by third-party tools 58 | url: `${SEPARATE_THIS_URL}#${crypto.randomUUID()}`, 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /manifest.ts: -------------------------------------------------------------------------------- 1 | import browserslist from 'browserslist' 2 | import type browser from 'webextension-polyfill' 3 | 4 | import pkg from './package.json' with { type: 'json' } 5 | 6 | const minimumChromeVersion = browserslist() 7 | .map((browserVersion) => { 8 | const matchResult = browserVersion.match(/^chrome ((?:\d|\.)+)$/u) 9 | return matchResult?.[1] 10 | }) 11 | .filter(Boolean) 12 | .toSorted((a, b) => Number(a) - Number(b))[0] 13 | 14 | export const manifest: 15 | | browser._manifest.WebExtensionManifest 16 | // eslint-disable-next-line no-undef 17 | | chrome.runtime.ManifestV3 = { 18 | name: 'Popup my Bookmarks', 19 | short_name: 'PmB', 20 | version: pkg.version, 21 | manifest_version: 3, 22 | minimum_chrome_version: minimumChromeVersion, 23 | offline_enabled: true, 24 | 25 | default_locale: 'en', 26 | description: '__MSG_extDesc__', 27 | homepage_url: 'https://github.com/foray1010/Popup-my-Bookmarks', 28 | 29 | icons: { 30 | '16': './images/icon16.png', 31 | '48': './images/icon48.png', 32 | '128': './images/icon128.png', 33 | }, 34 | 35 | action: { 36 | default_icon: './images/icon38.png', 37 | default_popup: './popup.html', 38 | }, 39 | 40 | options_ui: { 41 | page: './options.html', 42 | }, 43 | 44 | commands: { 45 | _execute_action: { 46 | suggested_key: { 47 | default: 'Alt+Shift+B', 48 | }, 49 | }, 50 | }, 51 | 52 | content_security_policy: { 53 | extension_pages: 54 | "base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; script-src 'self'; style-src 'unsafe-inline';", 55 | }, 56 | 57 | permissions: [ 58 | // this grant permission to run bookmarklet on current page 59 | 'activeTab', 60 | 'bookmarks', 61 | 'favicon', 62 | // required to execute bookmarklet 63 | 'scripting', 64 | 'storage', 65 | // this is a fix for we cannot save current page as bookmark, 66 | // if we open the popup before the current page finished loading 67 | 'tabs', 68 | ], 69 | } 70 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/utils/transformers.ts: -------------------------------------------------------------------------------- 1 | import { ROOT_ID } from '@/core/constants/index.js' 2 | import folderIcon from '@/popup/images/folder.svg' 3 | 4 | import { BOOKMARK_TYPES, SEPARATE_THIS_URL } from '../constants.js' 5 | import type { BookmarkInfo } from '../types.js' 6 | import { faviconUrl } from './faviconUrl.js' 7 | 8 | function getType(bookmarkNode: Readonly) { 9 | if (bookmarkNode.url == null) return BOOKMARK_TYPES.FOLDER 10 | if (bookmarkNode.url.startsWith(SEPARATE_THIS_URL)) { 11 | return BOOKMARK_TYPES.SEPARATOR 12 | } 13 | return BOOKMARK_TYPES.BOOKMARK 14 | } 15 | 16 | function isRoot( 17 | bookmarkNode: Readonly, 18 | ): boolean { 19 | return bookmarkNode.id === ROOT_ID || bookmarkNode.parentId === ROOT_ID 20 | } 21 | 22 | export function toBookmarkInfo( 23 | bookmarkNode: Readonly, 24 | ) { 25 | const bookmarkInfo = { 26 | id: bookmarkNode.id, 27 | isRoot: isRoot(bookmarkNode), 28 | isSimulated: false, 29 | isUnmodifiable: isRoot(bookmarkNode) || Boolean(bookmarkNode.unmodifiable), 30 | parentId: bookmarkNode.parentId, 31 | storageIndex: 32 | typeof bookmarkNode.index === 'number' ? bookmarkNode.index : -1, 33 | title: bookmarkNode.title, 34 | } as const satisfies Partial 35 | 36 | const type = getType(bookmarkNode) 37 | switch (type) { 38 | case BOOKMARK_TYPES.BOOKMARK: 39 | return { 40 | ...bookmarkInfo, 41 | type, 42 | iconUrl: faviconUrl(bookmarkNode.url!), 43 | url: bookmarkNode.url!, 44 | } as const satisfies BookmarkInfo 45 | 46 | case BOOKMARK_TYPES.FOLDER: 47 | return { 48 | ...bookmarkInfo, 49 | type, 50 | iconUrl: folderIcon, 51 | } as const satisfies BookmarkInfo 52 | 53 | case BOOKMARK_TYPES.SEPARATOR: 54 | return { 55 | ...bookmarkInfo, 56 | type, 57 | url: bookmarkNode.url!, 58 | } as const satisfies BookmarkInfo 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/popup/components/menu/utils.ts: -------------------------------------------------------------------------------- 1 | import { BOOKMARK_TYPES } from '@/popup/modules/bookmarks/constants.js' 2 | import type { BookmarkInfo } from '@/popup/modules/bookmarks/types.js' 3 | 4 | import { MenuItem } from './constants.js' 5 | import type { MenuPattern } from './types.js' 6 | 7 | function getBookmarkManagePattern( 8 | bookmarkInfo: BookmarkInfo, 9 | isSearching: boolean, 10 | ): MenuPattern { 11 | if (bookmarkInfo.isRoot) return [] 12 | 13 | if (isSearching) return [[MenuItem.Cut, MenuItem.Copy]] 14 | 15 | const copySelfPattern = bookmarkInfo.isSimulated 16 | ? [] 17 | : [MenuItem.Cut, MenuItem.Copy] 18 | return [ 19 | [...copySelfPattern, MenuItem.Paste], 20 | [MenuItem.AddPage, MenuItem.AddFolder, MenuItem.AddSeparator], 21 | // it will be useless if no bookmark to sort 22 | bookmarkInfo.type === BOOKMARK_TYPES.NO_BOOKMARK 23 | ? [] 24 | : [MenuItem.SortByName], 25 | ].filter((x) => x.length) 26 | } 27 | 28 | function getMutatePattern(bookmarkInfo: BookmarkInfo): MenuPattern { 29 | if (bookmarkInfo.isSimulated || bookmarkInfo.isUnmodifiable) return [] 30 | 31 | return [ 32 | [ 33 | bookmarkInfo.type === BOOKMARK_TYPES.FOLDER 34 | ? MenuItem.Rename 35 | : MenuItem.Edit, 36 | MenuItem.Delete, 37 | ], 38 | ] 39 | } 40 | 41 | function getOpenByPattern(bookmarkInfo: BookmarkInfo): MenuPattern { 42 | if (bookmarkInfo.isSimulated) return [] 43 | 44 | if (bookmarkInfo.type === BOOKMARK_TYPES.FOLDER) { 45 | return [ 46 | [ 47 | MenuItem.OpenAll, 48 | MenuItem.OpenAllInNewWindow, 49 | MenuItem.OpenAllInIncognitoWindow, 50 | ], 51 | ] 52 | } 53 | 54 | return [ 55 | [ 56 | MenuItem.OpenInBackgroundTab, 57 | MenuItem.OpenInNewWindow, 58 | MenuItem.OpenInIncognitoWindow, 59 | ], 60 | ] 61 | } 62 | 63 | export function getMenuPattern( 64 | bookmarkInfo: BookmarkInfo, 65 | isSearching: boolean, 66 | ): MenuPattern { 67 | return [ 68 | ...getOpenByPattern(bookmarkInfo), 69 | ...getMutatePattern(bookmarkInfo), 70 | ...getBookmarkManagePattern(bookmarkInfo, isSearching), 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /src/popup/components/dragAndDrop/useDragZoneEvents.ts: -------------------------------------------------------------------------------- 1 | import { type MouseEventHandler, useCallback, useEffect, useRef } from 'react' 2 | 3 | import { useDragAndDropContext } from './DragAndDropContext.js' 4 | 5 | function useScroll() { 6 | const scrollingTimeoutRef = useRef | null>( 7 | null, 8 | ) 9 | 10 | const clearScroll = useCallback(() => { 11 | if (scrollingTimeoutRef.current !== null) { 12 | clearInterval(scrollingTimeoutRef.current) 13 | scrollingTimeoutRef.current = null 14 | } 15 | }, []) 16 | 17 | const scroll = useCallback( 18 | ( 19 | containerElement: Readonly, 20 | { isUpward }: Readonly<{ isUpward: boolean }>, 21 | ) => { 22 | clearScroll() 23 | 24 | scrollingTimeoutRef.current = setInterval(() => { 25 | containerElement.scrollTo({ 26 | top: containerElement.scrollTop + (isUpward ? -1 : 1) * 20, 27 | }) 28 | }, 50) 29 | }, 30 | [clearScroll], 31 | ) 32 | 33 | return { clearScroll, scroll } 34 | } 35 | 36 | export default function useDragZoneEvents({ 37 | margin = 20, 38 | }: Readonly<{ 39 | margin?: number 40 | }> = {}) { 41 | const { activeKey } = useDragAndDropContext() 42 | const isDragging = activeKey !== null 43 | 44 | const { clearScroll, scroll } = useScroll() 45 | 46 | useEffect(() => { 47 | if (!isDragging) { 48 | clearScroll() 49 | } 50 | }, [clearScroll, isDragging]) 51 | 52 | const onMouseMove = useCallback( 53 | (evt) => { 54 | if (!(evt.currentTarget instanceof HTMLElement)) return 55 | 56 | if (!isDragging) return 57 | 58 | const rect = evt.currentTarget.getBoundingClientRect() 59 | 60 | const displacementTop = Math.abs(rect.top - evt.clientY) 61 | const displacementBottom = Math.abs(rect.bottom - evt.clientY) 62 | 63 | if (displacementTop <= margin) { 64 | scroll(evt.currentTarget, { 65 | isUpward: true, 66 | }) 67 | } else if (displacementBottom <= margin) { 68 | scroll(evt.currentTarget, { 69 | isUpward: false, 70 | }) 71 | } else { 72 | clearScroll() 73 | } 74 | }, 75 | [clearScroll, isDragging, margin, scroll], 76 | ) 77 | 78 | return { 79 | /** when the mouse is on top/bottom of the drag zone, scroll the zone */ 80 | onMouseMove, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTree/useRowDragEvents.ts: -------------------------------------------------------------------------------- 1 | import { type MouseEvent, useMemo } from 'react' 2 | 3 | import { BOOKMARK_TYPES } from '@/popup/modules/bookmarks/constants.js' 4 | import { useBookmarkTreesContext } from '@/popup/modules/bookmarks/contexts/bookmarkTrees.js' 5 | import type { 6 | BookmarkInfo, 7 | BookmarkTreeInfo, 8 | } from '@/popup/modules/bookmarks/types.js' 9 | 10 | import type { ResponseEvent } from '../dragAndDrop/index.js' 11 | 12 | export default function useRowDragEvents({ 13 | closeNextTrees, 14 | treeInfo, 15 | }: Readonly<{ closeNextTrees: () => void; treeInfo: BookmarkTreeInfo }>) { 16 | const { removeDragIndicator, setDragIndicator } = useBookmarkTreesContext() 17 | 18 | return useMemo(() => { 19 | return { 20 | handleRowDragOver: 21 | (bookmarkInfo: BookmarkInfo) => 22 | (evt: Readonly, responseEvent: ResponseEvent) => { 23 | if ( 24 | !bookmarkInfo.parentId || 25 | // avoid infinite loop 26 | bookmarkInfo.type === BOOKMARK_TYPES.DRAG_INDICATOR 27 | ) { 28 | return 29 | } 30 | 31 | const targetOffset = evt.currentTarget.getBoundingClientRect() 32 | const isOverBottomPart = 33 | evt.clientY - targetOffset.top > targetOffset.height / 2 34 | 35 | const childrenWithoutDragIndicator = treeInfo.children.filter( 36 | (child) => child.type !== BOOKMARK_TYPES.DRAG_INDICATOR, 37 | ) 38 | 39 | const activeIndex = childrenWithoutDragIndicator.findIndex( 40 | (item) => item.id === responseEvent.activeKey, 41 | ) 42 | const currentIndex = childrenWithoutDragIndicator.findIndex( 43 | (item) => item.id === responseEvent.itemKey, 44 | ) 45 | const targetIndex = currentIndex + (isOverBottomPart ? 1 : 0) 46 | 47 | const isNearActiveItem = 48 | activeIndex === -1 49 | ? false 50 | : [activeIndex, activeIndex + 1].includes(targetIndex) 51 | if (isNearActiveItem) { 52 | console.debug('skip as nearby active item') 53 | removeDragIndicator() 54 | return 55 | } 56 | 57 | setDragIndicator(bookmarkInfo.parentId, targetIndex) 58 | }, 59 | handleRowDragStart: closeNextTrees, 60 | } 61 | }, [closeNextTrees, removeDragIndicator, setDragIndicator, treeInfo.children]) 62 | } 63 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTree/useRowHoverEvents.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { useDebouncedCallback } from 'use-debounce' 3 | 4 | import { OPTIONS } from '@/core/constants/index.js' 5 | import { BOOKMARK_TYPES } from '@/popup/modules/bookmarks/constants.js' 6 | import { useBookmarkTreesContext } from '@/popup/modules/bookmarks/contexts/bookmarkTrees.js' 7 | import type { 8 | BookmarkInfo, 9 | BookmarkTreeInfo, 10 | } from '@/popup/modules/bookmarks/types.js' 11 | import { useOptions } from '@/popup/modules/options.js' 12 | 13 | import { useDragAndDropContext } from '../dragAndDrop/index.js' 14 | import { useListNavigationContext } from '../listNavigation/index.js' 15 | 16 | export default function useRowHoverEvents({ 17 | closeNextTrees, 18 | treeIndex, 19 | treeInfo, 20 | }: Readonly<{ 21 | closeNextTrees: () => void 22 | treeIndex: number 23 | treeInfo: BookmarkTreeInfo 24 | }>) { 25 | const options = useOptions() 26 | 27 | const { openBookmarkTree } = useBookmarkTreesContext() 28 | 29 | const { activeKey } = useDragAndDropContext() 30 | const { setHighlightedIndex, unsetHighlightedIndex } = 31 | useListNavigationContext() 32 | 33 | const toggleBookmarkTree = useDebouncedCallback( 34 | async (bookmarkInfo: BookmarkInfo) => { 35 | if ( 36 | bookmarkInfo.type === BOOKMARK_TYPES.FOLDER && 37 | bookmarkInfo.id !== activeKey 38 | ) { 39 | await openBookmarkTree(bookmarkInfo.id, treeInfo.parent.id) 40 | } else { 41 | closeNextTrees() 42 | } 43 | }, 44 | 300, 45 | ) 46 | 47 | return useMemo(() => { 48 | return { 49 | handleRowMouseEnter: (bookmarkInfo: BookmarkInfo) => async () => { 50 | const index = treeInfo.children.findIndex( 51 | (x) => x.id === bookmarkInfo.id, 52 | ) 53 | setHighlightedIndex(treeIndex, index) 54 | 55 | if (!options[OPTIONS.OP_FOLDER_BY]) { 56 | await toggleBookmarkTree(bookmarkInfo) 57 | } 58 | }, 59 | handleRowMouseLeave: (bookmarkInfo: BookmarkInfo) => () => { 60 | toggleBookmarkTree.cancel() 61 | 62 | const index = treeInfo.children.findIndex( 63 | (x) => x.id === bookmarkInfo.id, 64 | ) 65 | unsetHighlightedIndex(treeIndex, index) 66 | }, 67 | } 68 | }, [ 69 | options, 70 | setHighlightedIndex, 71 | toggleBookmarkTree, 72 | treeIndex, 73 | treeInfo.children, 74 | unsetHighlightedIndex, 75 | ]) 76 | } 77 | -------------------------------------------------------------------------------- /src/core/types/options.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from 'type-fest' 2 | 3 | import type { OPTIONS } from '../constants/index.js' 4 | 5 | export type Options = Readonly<{ 6 | [OPTIONS.CLICK_BY_LEFT]: number 7 | [OPTIONS.CLICK_BY_LEFT_CTRL]: number 8 | [OPTIONS.CLICK_BY_LEFT_SHIFT]: number 9 | [OPTIONS.CLICK_BY_MIDDLE]: number 10 | [OPTIONS.DEF_EXPAND]: number 11 | [OPTIONS.FONT_FAMILY]: string 12 | [OPTIONS.FONT_SIZE]: number 13 | [OPTIONS.HIDE_ROOT_FOLDER]: ReadonlyArray 14 | [OPTIONS.MAX_RESULTS]: number 15 | [OPTIONS.OP_FOLDER_BY]: boolean 16 | [OPTIONS.REMEMBER_POS]: boolean 17 | [OPTIONS.SEARCH_TARGET]: number 18 | [OPTIONS.SET_WIDTH]: number 19 | [OPTIONS.TOOLTIP]: boolean 20 | [OPTIONS.WARN_OPEN_MANY]: boolean 21 | }> 22 | // check if all OPTIONS are handled in Options, will be removed by minifier so won't affect bundle size 23 | ;({}) as Options satisfies Record, unknown> 24 | 25 | export type ArrayOptionConfig = Readonly<{ 26 | type: 'array' 27 | default: ReadonlyArray 28 | choices: ReadonlyArray 29 | }> 30 | export type BooleanOptionConfig = Readonly<{ 31 | type: 'boolean' 32 | default: boolean 33 | }> 34 | export type IntegerOptionConfig = Readonly<{ 35 | type: 'integer' 36 | default: number 37 | minimum: number 38 | maximum: number 39 | }> 40 | export type SelectOptionConfig = Readonly<{ 41 | type: 'select' 42 | default: number 43 | choices: ReadonlyArray 44 | }> 45 | export type StringOptionConfig = Readonly<{ 46 | type: 'string' 47 | default: string 48 | choices: ReadonlyArray 49 | }> 50 | export type OptionsConfig = Readonly<{ 51 | [OPTIONS.CLICK_BY_LEFT]: SelectOptionConfig 52 | [OPTIONS.CLICK_BY_LEFT_CTRL]: SelectOptionConfig 53 | [OPTIONS.CLICK_BY_LEFT_SHIFT]: SelectOptionConfig 54 | [OPTIONS.CLICK_BY_MIDDLE]: SelectOptionConfig 55 | [OPTIONS.DEF_EXPAND]: SelectOptionConfig 56 | [OPTIONS.FONT_FAMILY]: StringOptionConfig 57 | [OPTIONS.FONT_SIZE]: IntegerOptionConfig 58 | [OPTIONS.HIDE_ROOT_FOLDER]: ArrayOptionConfig 59 | [OPTIONS.MAX_RESULTS]: IntegerOptionConfig 60 | [OPTIONS.OP_FOLDER_BY]: BooleanOptionConfig 61 | [OPTIONS.REMEMBER_POS]: BooleanOptionConfig 62 | [OPTIONS.SEARCH_TARGET]: SelectOptionConfig 63 | [OPTIONS.SET_WIDTH]: IntegerOptionConfig 64 | [OPTIONS.TOOLTIP]: BooleanOptionConfig 65 | [OPTIONS.WARN_OPEN_MANY]: BooleanOptionConfig 66 | }> 67 | // check if all OPTIONS are handled in OptionsConfig, will be removed by minifier so won't affect bundle size 68 | ;({}) as OptionsConfig satisfies Record, unknown> 69 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | .PHONY: help 13 | .DEFAULT_GOAL := help 14 | 15 | bin_dir := node_modules/.bin 16 | 17 | src_dir := src 18 | 19 | locales: # download the latest locale files from transifex 20 | node scripts/generateLocalesFromTransifex.ts 21 | .PHONY: locales 22 | 23 | size-limit: build-prod # limit build size 24 | $(bin_dir)/size-limit 25 | .PHONY: size-limit 26 | 27 | tcm := $(bin_dir)/tcm 28 | rspack := node $(bin_dir)/rspack 29 | 30 | build-css-types: 31 | $(tcm) $(src_dir) --color | sed "s|$(CURDIR)/||g" 32 | .PHONY: build-css-types 33 | 34 | build-prod: build-css-types 35 | NODE_ENV=production $(rspack) 36 | .PHONY: build-prod 37 | 38 | build: build-prod size-limit # build production extension 39 | .PHONY: build 40 | 41 | dev: # build development extension in watch mode 42 | $(tcm) $(src_dir) --watch & 43 | NODE_ENV=development $(rspack) 44 | .PHONY: dev 45 | 46 | markdown_dir := markdown 47 | 48 | define buildmd 49 | mkdir -p $(dir $(2)) 50 | find $(1) | xargs -I{} sh -c "cat {}; echo" | sed '$$d' > $(2) 51 | endef 52 | build/store.md: $(markdown_dir)/description.md $(markdown_dir)/todo.md $(markdown_dir)/contributing.md 53 | $(call buildmd,$^,$@) 54 | 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 55 | $(call buildmd,$^,$@) 56 | md: build/store.md README.md # generate markdown files 57 | .PHONY: md 58 | 59 | lint-css: # lint by stylelint 60 | yarn stylelint '**/*.css' 61 | .PHONY: lint-css 62 | 63 | lint-format: md # check if files follow prettier formatting 64 | yarn prettier --check . 65 | .PHONY: lint-format 66 | 67 | lint-md: md # lint by remark 68 | yarn remark . 69 | .PHONY: lint-md 70 | 71 | lint-js: build-css-types # lint by eslint 72 | $(bin_dir)/eslint . 73 | .PHONY: lint-js 74 | 75 | lint: lint-css lint-format lint-md lint-js # run all lint tasks 76 | .PHONY: lint 77 | 78 | test: # run tests 79 | $(bin_dir)/jest 80 | .PHONY: test 81 | 82 | type-check: build-css-types # type check by tsc 83 | $(bin_dir)/tsc 84 | .PHONY: type-check 85 | 86 | type-coverage: build-css-types # check type coverage 87 | $(bin_dir)/type-coverage --strict --at-least 99 --detail --color -- $$(find $(src_dir) -name "*.ts" -o -name "*.tsx") | sed "s|$(CURDIR)/||g" 88 | .PHONY: type-coverage 89 | 90 | type: type-check type-coverage # run all type tasks 91 | .PHONY: type 92 | 93 | ci: build md lint test type # run all checkings on CI 94 | .PHONY: ci 95 | -------------------------------------------------------------------------------- /src/popup/components/listNavigation/useKeyboardNav.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from 'type-fest' 2 | 3 | import type { WindowId } from '@/popup/constants/windows.js' 4 | import cycle from '@/popup/utils/cycle.js' 5 | import getLastMapKey from '@/popup/utils/getLastMapKey.js' 6 | 7 | import { useKeyBindingsEvent } from '../keyBindings/index.js' 8 | import { useListNavigationContext } from './ListNavigationContext.js' 9 | 10 | export default function useKeyboardNav({ 11 | windowId, 12 | onPressArrowLeft, 13 | onPressArrowRight, 14 | }: Readonly<{ 15 | windowId: ValueOf 16 | onPressArrowLeft?: (evt: Readonly) => void 17 | onPressArrowRight?: (evt: Readonly) => void 18 | }>): void { 19 | const { listNavigation, removeList, setHighlightedIndex } = 20 | useListNavigationContext() 21 | 22 | function handlePressArrowVertical(offset: number) { 23 | const { highlightedIndices, itemCounts } = listNavigation 24 | 25 | const lastListIndex = getLastMapKey(itemCounts) 26 | if (lastListIndex === undefined) return 27 | 28 | const itemCount = itemCounts.get(lastListIndex) 29 | if (itemCount === undefined) return 30 | 31 | const currentItemIndex = highlightedIndices.get(lastListIndex) 32 | const nextItemIndex = cycle( 33 | 0, 34 | itemCount - 1, 35 | (currentItemIndex !== undefined ? currentItemIndex : -1) + offset, 36 | ) 37 | setHighlightedIndex(lastListIndex, nextItemIndex) 38 | } 39 | 40 | function handlePressArrowDown() { 41 | handlePressArrowVertical(1) 42 | } 43 | useKeyBindingsEvent({ key: 'ArrowDown', windowId }, handlePressArrowDown) 44 | 45 | function handlePressArrowUp() { 46 | handlePressArrowVertical(-1) 47 | } 48 | useKeyBindingsEvent({ key: 'ArrowUp', windowId }, handlePressArrowUp) 49 | 50 | useKeyBindingsEvent({ key: 'Tab', windowId }, (evt) => { 51 | // do not use default focusable orders for now, as it does not work with virtualized lists 52 | evt.preventDefault() 53 | 54 | if (evt.shiftKey) { 55 | handlePressArrowUp() 56 | } else { 57 | handlePressArrowDown() 58 | } 59 | }) 60 | 61 | useKeyBindingsEvent({ key: 'ArrowLeft', windowId }, (evt) => { 62 | onPressArrowLeft?.(evt) 63 | 64 | const { itemCounts } = listNavigation 65 | if (itemCounts.size <= 1) return 66 | 67 | const lastListIndex = getLastMapKey(itemCounts) 68 | if (lastListIndex === undefined) return 69 | 70 | removeList(lastListIndex) 71 | }) 72 | 73 | useKeyBindingsEvent({ key: 'ArrowRight', windowId }, (evt) => { 74 | onPressArrowRight?.(evt) 75 | 76 | const { itemCounts } = listNavigation 77 | 78 | const lastListIndex = getLastMapKey(itemCounts) 79 | if (lastListIndex === undefined) return 80 | 81 | setHighlightedIndex(lastListIndex + 1, 0) 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /src/popup/components/editor/EditorForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from '@tanstack/react-form' 2 | import { type CSSProperties, useId } from 'react' 3 | import webExtension from 'webextension-polyfill' 4 | 5 | import ActionlessForm from '@/core/components/baseItems/ActionlessForm/index.js' 6 | import Button from '@/core/components/baseItems/Button/index.js' 7 | import Input from '@/core/components/baseItems/Input/index.js' 8 | 9 | import * as classes from './editor-form.module.css' 10 | 11 | type Props = Readonly<{ 12 | defaultTitle?: string 13 | defaultUrl?: string | undefined 14 | header: string 15 | isAllowedToEditUrl: boolean 16 | onCancel: () => void 17 | onConfirm: (title: string, url?: string) => void | Promise 18 | style?: CSSProperties 19 | }> 20 | export default function Editor({ onConfirm, ...props }: Props) { 21 | const form = useForm({ 22 | defaultValues: { 23 | title: props.defaultTitle ?? '', 24 | url: props.defaultUrl, 25 | }, 26 | async onSubmit({ value }) { 27 | await onConfirm(value.title, value.url) 28 | }, 29 | }) 30 | 31 | const headerId = useId() 32 | 33 | return ( 34 | form.handleSubmit()} 39 | > 40 |

    41 | {props.header} 42 |

    43 | 44 | 45 | {(field) => { 46 | return ( 47 | field.handleChange(evt.currentTarget.value)} 55 | /> 56 | ) 57 | }} 58 | 59 | {props.isAllowedToEditUrl && ( 60 | 61 | {(field) => { 62 | return ( 63 | field.handleChange(evt.currentTarget.value)} 69 | /> 70 | ) 71 | }} 72 | 73 | )} 74 | 75 |
    76 | !state.canSubmit || state.isSubmitting} 78 | > 79 | {(disabled) => { 80 | return ( 81 | 84 | ) 85 | }} 86 | 87 | 90 |
    91 |
    92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /src/options/components/OptionForm/OptionForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from '@tanstack/react-form' 2 | import type { ValueOf } from 'type-fest' 3 | import webExtension from 'webextension-polyfill' 4 | 5 | import ActionlessForm from '@/core/components/baseItems/ActionlessForm/index.js' 6 | import Button from '@/core/components/baseItems/Button/index.js' 7 | import type { OPTIONS } from '@/core/constants/index.js' 8 | import type { Options, OptionsConfig } from '@/core/types/options.js' 9 | 10 | import * as classes from './option-form.module.css' 11 | import OptionItem from './OptionItem/index.js' 12 | 13 | type Props = Readonly<{ 14 | defaultValues: Partial 15 | onReset: () => void 16 | onSubmit: (variables: Partial) => void 17 | optionsConfig: OptionsConfig 18 | selectedOptionFormMap: ReadonlyArray> 19 | }> 20 | export default function OptionForm(props: Props) { 21 | const form = useForm({ 22 | defaultValues: props.defaultValues, 23 | onSubmit({ value }) { 24 | props.onSubmit(value) 25 | }, 26 | }) 27 | 28 | return ( 29 | form.handleSubmit()} 34 | > 35 | 36 | 37 | {props.selectedOptionFormMap.map((optionName) => ( 38 | 39 | 40 | 57 | 58 | ))} 59 | 60 | 61 | 62 | 75 | 80 | 81 | 82 |
    {webExtension.i18n.getMessage(optionName)} 41 | 42 | {(field) => { 43 | const optionConfig = props.optionsConfig[optionName] 44 | return ( 45 | 53 | ) 54 | }} 55 | 56 |
    63 | !state.canSubmit || state.isSubmitting} 65 | > 66 | {(disabled) => { 67 | return ( 68 | 71 | ) 72 | }} 73 | 74 | 76 | 79 |
    83 |
    84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /src/popup/components/floatingWindow/FloatingWindow.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren, useLayoutEffect, useState } from 'react' 2 | import { createPortal } from 'react-dom' 3 | import useResizeObserver from 'use-resize-observer' 4 | 5 | import Backdrop from '../Backdrop/index.js' 6 | import useGlobalBodySize from './useGlobalBodySize.js' 7 | 8 | type Props = Readonly< 9 | PropsWithChildren<{ 10 | positionLeft: number 11 | positionTop: number 12 | onClose: () => void 13 | }> 14 | > 15 | export default function FloatingWindow({ 16 | children, 17 | positionLeft, 18 | positionTop, 19 | onClose, 20 | }: Props) { 21 | const { insertBodySize } = useGlobalBodySize() 22 | 23 | const [windowSize, setWindowSize] = useState< 24 | Readonly<{ 25 | height: number | undefined 26 | width: number | undefined 27 | }> 28 | >() 29 | const { ref } = useResizeObserver({ 30 | onResize: setWindowSize, 31 | round: Math.ceil, 32 | }) 33 | 34 | const [calibratedPosition, setCalibratedPosition] = useState< 35 | Readonly<{ 36 | left: number 37 | top: number 38 | }> 39 | >() 40 | 41 | // make sure the floating window is within the body 42 | useLayoutEffect(() => { 43 | if (windowSize?.height === undefined || windowSize?.width === undefined) { 44 | return 45 | } 46 | 47 | const currentBodyHeight = document.body.scrollHeight 48 | const updatedBodyHeight = 49 | windowSize.height > currentBodyHeight ? windowSize.height : undefined 50 | 51 | const currentBodyWidth = document.body.offsetWidth 52 | const updatedBodyWidth = 53 | windowSize.width > currentBodyWidth ? windowSize.width : undefined 54 | 55 | setCalibratedPosition({ 56 | left: Math.min( 57 | positionLeft, 58 | (updatedBodyWidth ?? currentBodyWidth) - windowSize.width, 59 | ), 60 | top: Math.min( 61 | positionTop, 62 | (updatedBodyHeight ?? currentBodyHeight) - windowSize.height, 63 | ), 64 | }) 65 | 66 | const { removeBodySize } = insertBodySize({ 67 | height: updatedBodyHeight, 68 | width: updatedBodyWidth, 69 | }) 70 | return () => { 71 | removeBodySize() 72 | } 73 | }, [ 74 | insertBodySize, 75 | positionLeft, 76 | positionTop, 77 | windowSize?.height, 78 | windowSize?.width, 79 | ]) 80 | 81 | return createPortal( 82 | <> 83 | 84 | 103 | , 104 | document.body, 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /src/popup/components/dragAndDrop/DragAndDropConsumer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type DragEvent, 3 | type DragEventHandler, 4 | type MouseEvent, 5 | type MouseEventHandler, 6 | type PropsWithChildren, 7 | useCallback, 8 | useState, 9 | } from 'react' 10 | 11 | import { useDragAndDropContext } from './DragAndDropContext.js' 12 | import type { ResponseEvent } from './types.js' 13 | 14 | type Props = Readonly< 15 | PropsWithChildren<{ 16 | disableDrag?: boolean 17 | disableDrop?: boolean 18 | itemKey: string 19 | onDragOver: ( 20 | evt: Readonly, 21 | responseEvent: ResponseEvent, 22 | ) => void 23 | onDragStart: ( 24 | evt: Readonly, 25 | responseEvent: ResponseEvent, 26 | ) => void 27 | }> 28 | > 29 | 30 | function useDragEvents({ itemKey, onDragOver, onDragStart }: Props) { 31 | const { activeKey, setActiveKey } = useDragAndDropContext() 32 | 33 | return { 34 | handleDragOver: useCallback( 35 | (evt) => { 36 | if (activeKey === null) return 37 | 38 | onDragOver(evt, { 39 | activeKey, 40 | itemKey, 41 | }) 42 | }, 43 | [activeKey, itemKey, onDragOver], 44 | ), 45 | handleDragStart: useCallback( 46 | (evt) => { 47 | // not using native drag because it does not work when the element is removed from the DOM 48 | evt.preventDefault() 49 | 50 | setActiveKey(itemKey) 51 | 52 | onDragStart(evt, { 53 | activeKey: itemKey, 54 | itemKey, 55 | }) 56 | }, 57 | [itemKey, onDragStart, setActiveKey], 58 | ), 59 | } 60 | } 61 | 62 | function useMouseEvents() { 63 | const { activeKey } = useDragAndDropContext() 64 | 65 | const isDragging = activeKey !== null 66 | 67 | const [shouldDisableNextClick, setShouldDisableNextClick] = useState(false) 68 | 69 | return { 70 | // avoid the user to open the bookmark after dragging is started and user drops on the original bookmark 71 | // have to use with mouseupcapture event because the mouseup event is fired before `activeKey` is reset to null 72 | handleClickCapture: useCallback( 73 | (evt) => { 74 | if (shouldDisableNextClick) { 75 | evt.stopPropagation() 76 | setShouldDisableNextClick(false) 77 | } 78 | }, 79 | [shouldDisableNextClick], 80 | ), 81 | handleMouseUpCapture: useCallback(() => { 82 | if (isDragging) { 83 | setShouldDisableNextClick(true) 84 | } 85 | }, [isDragging]), 86 | } 87 | } 88 | 89 | export default function DragAndDropConsumer(props: Props) { 90 | const { handleClickCapture, handleMouseUpCapture } = useMouseEvents() 91 | 92 | const { handleDragStart, handleDragOver } = useDragEvents(props) 93 | 94 | return ( 95 |
    107 | {props.children} 108 |
    109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /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.8", 17 | "@tanstack/react-form": "1.27.3", 18 | "@tanstack/react-query": "5.90.5", 19 | "@tanstack/react-query-devtools": "5.90.2", 20 | "@tanstack/react-virtual": "3.13.12", 21 | "classix": "2.2.6", 22 | "constate": "3.3.3", 23 | "core-js": "3.46.0", 24 | "react": "19.2.0", 25 | "react-dom": "19.2.0", 26 | "use-debounce": "10.0.6", 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": "20.1.0", 34 | "@commitlint/config-conventional": "20.0.0", 35 | "@foray1010/eslint-config": "15.1.1", 36 | "@foray1010/jest-preset": "6.0.0", 37 | "@foray1010/prettier-config": "12.0.0", 38 | "@foray1010/remark-preset": "11.0.0", 39 | "@foray1010/stylelint-config": "17.0.0", 40 | "@foray1010/tsconfig": "15.0.0", 41 | "@rspack/cli": "1.6.8", 42 | "@rspack/core": "1.6.8", 43 | "@size-limit/file": "11.2.0", 44 | "@svg-use/react": "1.0.0", 45 | "@svg-use/webpack": "1.0.0", 46 | "@swc/core": "1.14.0", 47 | "@swc/jest": "0.2.39", 48 | "@tanstack/eslint-plugin-query": "5.91.2", 49 | "@testing-library/dom": "10.4.1", 50 | "@testing-library/jest-dom": "6.9.1", 51 | "@testing-library/react": "16.3.1", 52 | "@testing-library/user-event": "14.6.1", 53 | "@transifex/api": "7.1.5", 54 | "@types/chrome": "0.1.32", 55 | "@types/duplicate-package-checker-webpack-plugin": "2.1.5", 56 | "@types/firefox-webext-browser": "143.0.0", 57 | "@types/jest": "30.0.0", 58 | "@types/node": "24.9.2", 59 | "@types/react": "19.2.7", 60 | "@types/react-dom": "19.2.3", 61 | "@types/zip-webpack-plugin": "3.0.6", 62 | "browserslist": "4.27.0", 63 | "css-loader": "7.1.2", 64 | "csstype": "3.1.3", 65 | "duplicate-package-checker-webpack-plugin": "3.0.0", 66 | "eslint": "9.39.0", 67 | "husky": "9.1.7", 68 | "ignore-sync": "8.0.0", 69 | "jest": "30.2.0", 70 | "jest-environment-jsdom": "30.2.0", 71 | "jsonc-parser": "3.3.1", 72 | "lint-staged": "16.2.7", 73 | "node-notifier": "10.0.1", 74 | "postcss": "8.5.6", 75 | "postcss-loader": "8.2.0", 76 | "postcss-normalize": "13.0.1", 77 | "prettier": "3.6.2", 78 | "remark-cli": "12.0.1", 79 | "sharp": "0.34.5", 80 | "size-limit": "11.2.0", 81 | "stylelint": "16.25.0", 82 | "svgo": "4.0.0", 83 | "type-coverage": "2.29.7", 84 | "type-fest": "5.2.0", 85 | "typed-css-modules": "0.9.1", 86 | "typescript": "5.9.3", 87 | "webpack": "5.102.1", 88 | "zip-webpack-plugin": "4.0.3" 89 | }, 90 | "packageManager": "yarn@4.11.0", 91 | "engines": { 92 | "node": "24.11.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/popup/components/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentProps, useMemo } from 'react' 2 | import webExtension from 'webextension-polyfill' 3 | 4 | import { OPTIONS } from '@/core/constants/index.js' 5 | import { WindowId } from '@/popup/constants/windows.js' 6 | import useGetBookmarkInfo from '@/popup/modules/bookmarks/hooks/useGetBookmarkInfo.js' 7 | import { createBookmarkAfterId } from '@/popup/modules/bookmarks/methods/createBookmark.js' 8 | import { useOptions } from '@/popup/modules/options.js' 9 | 10 | import { FloatingWindow } from '../floatingWindow/index.js' 11 | import { KeyBindingsWindow } from '../keyBindings/index.js' 12 | import { useEditorContext } from './EditorContext.js' 13 | import EditorForm from './EditorForm.js' 14 | 15 | type EditorFormProps = Readonly> 16 | 17 | type CreateEditorFormProps = Readonly< 18 | EditorFormProps & { 19 | createAfterId: string 20 | } 21 | > 22 | function CreateEditorForm({ 23 | createAfterId, 24 | onConfirm, 25 | ...editorFormProps 26 | }: CreateEditorFormProps) { 27 | return ( 28 | { 32 | await createBookmarkAfterId({ createAfterId, title, url }) 33 | 34 | await onConfirm(title, url) 35 | }} 36 | /> 37 | ) 38 | } 39 | 40 | type UpdateEditorFormProps = Readonly< 41 | EditorFormProps & { 42 | editTargetId: string 43 | } 44 | > 45 | function UpdateEditorForm({ 46 | editTargetId, 47 | onConfirm, 48 | ...editorFormProps 49 | }: UpdateEditorFormProps) { 50 | const { data: bookmarkInfo } = useGetBookmarkInfo(editTargetId) 51 | 52 | if (!bookmarkInfo) return null 53 | 54 | return ( 55 | { 60 | await webExtension.bookmarks.update(editTargetId, { title, url }) 61 | 62 | await onConfirm(title, url) 63 | }} 64 | /> 65 | ) 66 | } 67 | 68 | export default function Editor() { 69 | const { close, state } = useEditorContext() 70 | 71 | const options = useOptions() 72 | 73 | const style = useMemo( 74 | () => ({ 75 | width: `${options[OPTIONS.SET_WIDTH]}px`, 76 | }), 77 | [options], 78 | ) 79 | 80 | if (!state.isOpen) return null 81 | 82 | const commonProps: EditorFormProps = { 83 | header: state.isAllowedToEditUrl 84 | ? webExtension.i18n.getMessage('edit') 85 | : webExtension.i18n.getMessage('rename'), 86 | isAllowedToEditUrl: state.isAllowedToEditUrl, 87 | style, 88 | onCancel: close, 89 | onConfirm: close, 90 | } 91 | return ( 92 | 97 | 98 | {state.isCreating ? ( 99 | 103 | ) : ( 104 | 108 | )} 109 | 110 | 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /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/core/utils/getOptionsConfig.ts: -------------------------------------------------------------------------------- 1 | import webExtension from 'webextension-polyfill' 2 | 3 | import { OPTIONS, ROOT_ID } from '../constants/index.js' 4 | import type { OptionsConfig } from '../types/options.js' 5 | import isMac from './isMac.js' 6 | 7 | function getMessages(messageKeys: readonly string[]) { 8 | return messageKeys.map((k) => webExtension.i18n.getMessage(k)) 9 | } 10 | 11 | export default async function getOptionsConfig(): Promise { 12 | const openBookmarkChoices = getMessages([ 13 | 'clickOption1', 14 | 'clickOption2', 15 | 'clickOption3', 16 | 'clickOption4', 17 | 'clickOption5', 18 | 'clickOption6', 19 | 'clickOption7', 20 | ]) 21 | 22 | const rootFolderChoicesMutable: string[] = [] 23 | // get the root folders' title and set as the choices of 'defExpand' 24 | const rootFolders = await webExtension.bookmarks.getChildren(ROOT_ID) 25 | for (const rootFolder of rootFolders) { 26 | const rootFolderIdNum = Number(rootFolder.id) 27 | rootFolderChoicesMutable[rootFolderIdNum] = rootFolder.title 28 | } 29 | 30 | return { 31 | [OPTIONS.CLICK_BY_LEFT]: { 32 | type: 'select', 33 | default: 0, 34 | choices: openBookmarkChoices, 35 | }, 36 | [OPTIONS.CLICK_BY_LEFT_CTRL]: { 37 | type: 'select', 38 | default: 4, 39 | choices: openBookmarkChoices, 40 | }, 41 | [OPTIONS.CLICK_BY_LEFT_SHIFT]: { 42 | type: 'select', 43 | default: 5, 44 | choices: openBookmarkChoices, 45 | }, 46 | [OPTIONS.CLICK_BY_MIDDLE]: { 47 | type: 'select', 48 | default: 2, 49 | choices: openBookmarkChoices, 50 | }, 51 | [OPTIONS.DEF_EXPAND]: { 52 | type: 'select', 53 | default: 1, 54 | choices: rootFolderChoicesMutable, 55 | }, 56 | [OPTIONS.FONT_FAMILY]: { 57 | type: 'string', 58 | // `system-ui` may not work well on some OS/language combinations, but macOS is fine 59 | // see https://github.com/w3c/csswg-drafts/issues/3658 60 | default: isMac() ? 'system-ui' : 'sans-serif', 61 | choices: [ 62 | 'system-ui', 63 | 'sans-serif', 64 | 'serif', 65 | 'monospace', 66 | 'Archivo Narrow', 67 | 'Arial', 68 | 'Comic Sans MS', 69 | 'Georgia', 70 | 'Lucida Sans Unicode', 71 | 'Tahoma', 72 | 'Trebuchet MS', 73 | 'Verdana', 74 | ], 75 | }, 76 | [OPTIONS.FONT_SIZE]: { 77 | type: 'integer', 78 | default: 12, 79 | minimum: 10, 80 | maximum: 30, 81 | }, 82 | [OPTIONS.HIDE_ROOT_FOLDER]: { 83 | type: 'array', 84 | default: [], 85 | choices: rootFolderChoicesMutable, 86 | }, 87 | [OPTIONS.MAX_RESULTS]: { 88 | type: 'integer', 89 | default: 50, 90 | minimum: 10, 91 | maximum: 200, 92 | }, 93 | [OPTIONS.OP_FOLDER_BY]: { 94 | type: 'boolean', 95 | default: false, 96 | }, 97 | [OPTIONS.REMEMBER_POS]: { 98 | type: 'boolean', 99 | default: false, 100 | }, 101 | [OPTIONS.SEARCH_TARGET]: { 102 | type: 'select', 103 | default: 0, 104 | choices: getMessages(['searchTargetOption1', 'searchTargetOption2']), 105 | }, 106 | [OPTIONS.SET_WIDTH]: { 107 | type: 'integer', 108 | default: 280, 109 | minimum: 100, 110 | maximum: 399, 111 | }, 112 | [OPTIONS.TOOLTIP]: { 113 | type: 'boolean', 114 | default: false, 115 | }, 116 | [OPTIONS.WARN_OPEN_MANY]: { 117 | type: 'boolean', 118 | default: true, 119 | }, 120 | } as const 121 | } 122 | -------------------------------------------------------------------------------- /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 | import * as readline from 'node:readline/promises' 7 | 8 | import { type Collection, transifexApi } from '@transifex/api' 9 | 10 | const rl = readline.createInterface({ 11 | input: process.stdin, 12 | output: process.stdout, 13 | }) 14 | 15 | const organizationSlug = 'foray1010' 16 | const projectSlug = 'popup-my-bookmarks' 17 | const resourceSlug = 'messagesjson-1' 18 | 19 | const localesPath = path.join('src', 'public', '_locales') 20 | 21 | async function main(): Promise { 22 | const transifexApiKey: string = await rl.question( 23 | 'transifex api key (get from https://www.transifex.com/user/settings/api/): ', 24 | ) 25 | if (!transifexApiKey) throw new Error('transifexApiKey is required') 26 | 27 | transifexApi.setup({ 28 | auth: transifexApiKey, 29 | }) 30 | 31 | const organization = await transifexApi.Organization.get({ 32 | slug: organizationSlug, 33 | }) 34 | 35 | const project = await transifexApi.Project.get({ 36 | organization, 37 | slug: projectSlug, 38 | }) 39 | 40 | const resource = await transifexApi.Resource.get({ 41 | project, 42 | slug: resourceSlug, 43 | }) 44 | 45 | const languages = (await project.fetch('languages', false)) as Collection & 46 | Readonly<{ 47 | data: ReadonlyArray< 48 | Readonly<{ 49 | attributes: Readonly<{ 50 | code: string 51 | }> 52 | }> 53 | > 54 | }> 55 | await languages.fetch() 56 | 57 | await Promise.all( 58 | languages.data.map(async (language) => { 59 | let mappedLanguage: string 60 | switch (language.attributes.code) { 61 | case 'nb_NO': 62 | mappedLanguage = 'nb' 63 | break 64 | 65 | case 'es_ES': 66 | mappedLanguage = 'es' 67 | break 68 | 69 | default: 70 | mappedLanguage = language.attributes.code 71 | } 72 | 73 | console.log(`processing "${mappedLanguage}"...`) 74 | 75 | const url: string = 76 | await transifexApi.ResourceTranslationsAsyncDownload.download({ 77 | resource, 78 | language, 79 | mode: 'onlytranslated', 80 | }) 81 | const messagesJson: Record< 82 | string, 83 | Readonly<{ 84 | message: string 85 | description?: string 86 | }> 87 | > = await (await fetch(url)).json() 88 | 89 | const sortedMessagesJson = Object.fromEntries( 90 | Object.entries(messagesJson) 91 | .map(([k, v]) => { 92 | const trimmedMessage = v.message.trim() 93 | if (!trimmedMessage) return 94 | 95 | return [k, { ...v, message: trimmedMessage }] as const 96 | }) 97 | .filter((x) => x != null) 98 | .sort(([a], [b]) => a.localeCompare(b)), 99 | ) satisfies typeof messagesJson 100 | 101 | await fsPromises.mkdir(path.join(localesPath, mappedLanguage), { 102 | recursive: true, 103 | }) 104 | 105 | await fsPromises.writeFile( 106 | path.join(localesPath, mappedLanguage, 'messages.json'), 107 | JSON.stringify(sortedMessagesJson, null, 2) + '\n', 108 | ) 109 | 110 | console.log(`"${mappedLanguage}" is generated`) 111 | }), 112 | ) 113 | } 114 | 115 | main() 116 | .catch((err: Readonly) => { 117 | console.error(err) 118 | process.exitCode = 1 119 | }) 120 | .finally(() => { 121 | rl.close() 122 | }) 123 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /src/popup/modules/lastPositions/index.ts: -------------------------------------------------------------------------------- 1 | import constate from 'constate' 2 | import { type UIEventHandler, useCallback, useEffect, useState } from 'react' 3 | import webExtension from 'webextension-polyfill' 4 | 5 | import type { LastPosition } from './types.js' 6 | 7 | function useLastPositions({ isEnabled }: Readonly<{ isEnabled: boolean }>) { 8 | const [lastPositions, setLastPositions] = useState( 9 | [], 10 | ) 11 | const [isInitialized, setIsInitialized] = useState(false) 12 | useEffect(() => { 13 | if (isEnabled) { 14 | type LocaleStorage = Readonly<{ 15 | lastPositions?: readonly LastPosition[] 16 | }> 17 | webExtension.storage.local 18 | .get() 19 | .then((localStorage: LocaleStorage) => { 20 | setLastPositions(localStorage.lastPositions ?? []) 21 | setIsInitialized(true) 22 | }) 23 | .catch(console.error) 24 | } else { 25 | setLastPositions([]) 26 | setIsInitialized(false) 27 | } 28 | }, [isEnabled]) 29 | useEffect(() => { 30 | if (!isInitialized) return 31 | 32 | webExtension.storage.local.set({ lastPositions }).catch(console.error) 33 | }, [isInitialized, lastPositions]) 34 | 35 | return { 36 | isInitialized, 37 | lastPositions: isEnabled ? lastPositions : undefined, 38 | 39 | registerLastPosition: useCallback( 40 | (index: number, id: string) => { 41 | if (!isInitialized) return 42 | 43 | setLastPositions((prevLastPositions) => { 44 | if ( 45 | index > prevLastPositions.length || 46 | prevLastPositions.some((x) => x.id === id) 47 | ) { 48 | return prevLastPositions 49 | } 50 | 51 | return [...prevLastPositions.slice(0, index), { id, scrollTop: 0 }] 52 | }) 53 | }, 54 | [isInitialized], 55 | ), 56 | 57 | unregisterLastPosition: useCallback( 58 | (id: string) => { 59 | if (!isInitialized) return 60 | 61 | setLastPositions((prevLastPositions) => { 62 | const index = prevLastPositions.findIndex((x) => x.id === id) 63 | if (index === -1) return prevLastPositions 64 | 65 | return prevLastPositions.slice(0, index) 66 | }) 67 | }, 68 | [isInitialized], 69 | ), 70 | 71 | updateLastPosition: useCallback( 72 | (id: string, scrollTop: number) => { 73 | if (!isInitialized) return 74 | 75 | setLastPositions((prevLastPositions) => { 76 | return prevLastPositions.map((lastPosition) => { 77 | if (lastPosition.id === id) return { id, scrollTop } 78 | return lastPosition 79 | }) 80 | }) 81 | }, 82 | [isInitialized], 83 | ), 84 | } 85 | } 86 | 87 | export const [LastPositionsProvider, useLastPositionsContext] = 88 | constate(useLastPositions) 89 | 90 | export function useRememberLastPosition({ 91 | treeIndex, 92 | treeId, 93 | }: Readonly<{ treeIndex: number; treeId: string }>) { 94 | const { 95 | lastPositions, 96 | registerLastPosition, 97 | unregisterLastPosition, 98 | updateLastPosition, 99 | } = useLastPositionsContext() 100 | 101 | useEffect(() => { 102 | registerLastPosition(treeIndex, treeId) 103 | 104 | return () => { 105 | unregisterLastPosition(treeId) 106 | } 107 | }, [registerLastPosition, treeId, treeIndex, unregisterLastPosition]) 108 | 109 | return { 110 | lastScrollTop: lastPositions?.find((x) => x.id === treeId)?.scrollTop, 111 | onScroll: useCallback( 112 | (evt) => updateLastPosition(treeId, evt.currentTarget.scrollTop), 113 | [treeId, updateLastPosition], 114 | ), 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/methods/sortBookmarksByName.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from 'type-fest' 2 | import webExtension from 'webextension-polyfill' 3 | 4 | import { BOOKMARK_TYPES } from '../constants.js' 5 | import type { BookmarkInfo } from '../types.js' 6 | import sortByTitle from '../utils/sortByTitle.js' 7 | import { getBookmarkInfo, getBookmarkTreeInfo } from './getBookmark.js' 8 | 9 | function splitBySeparator( 10 | bookmarkInfos: readonly BookmarkInfo[], 11 | ): readonly (readonly BookmarkInfo[])[] { 12 | return bookmarkInfos.reduce( 13 | (acc, bookmarkInfo) => { 14 | if (acc.length === 0 || bookmarkInfo.type === BOOKMARK_TYPES.SEPARATOR) { 15 | acc.push([]) 16 | } 17 | 18 | acc.at(-1)?.push(bookmarkInfo) 19 | 20 | return acc 21 | }, 22 | [[]], 23 | ) 24 | } 25 | 26 | type BookmarkGroup = Readonly<{ 27 | type: ValueOf 28 | members: readonly BookmarkInfo[] 29 | }> 30 | function groupByType( 31 | bookmarkInfos: readonly BookmarkInfo[], 32 | ): readonly BookmarkGroup[] { 33 | return bookmarkInfos.reduce< 34 | Array< 35 | Readonly<{ 36 | type: ValueOf 37 | members: BookmarkInfo[] 38 | }> 39 | > 40 | >((acc, bookmarkInfo) => { 41 | const matchType = (group: BookmarkGroup) => group.type === bookmarkInfo.type 42 | 43 | if (!acc.some(matchType)) { 44 | acc.push({ 45 | type: bookmarkInfo.type, 46 | members: [], 47 | }) 48 | } 49 | 50 | const matchedGroup = acc.find(matchType) 51 | if (matchedGroup) { 52 | matchedGroup.members.push(bookmarkInfo) 53 | } 54 | 55 | return acc 56 | }, []) 57 | } 58 | 59 | function sortGroupByPriority( 60 | groups: readonly BookmarkGroup[], 61 | ): readonly BookmarkGroup[] { 62 | const priority = [ 63 | BOOKMARK_TYPES.SEPARATOR, 64 | BOOKMARK_TYPES.FOLDER, 65 | BOOKMARK_TYPES.BOOKMARK, 66 | // shouldn't exist 67 | BOOKMARK_TYPES.DRAG_INDICATOR, 68 | BOOKMARK_TYPES.NO_BOOKMARK, 69 | ] as const satisfies readonly ValueOf[] 70 | return Array.from(groups).sort((groupA, groupB) => { 71 | return priority.indexOf(groupA.type) - priority.indexOf(groupB.type) 72 | }) 73 | } 74 | 75 | function mergeGroups( 76 | nestedGroups: readonly (readonly BookmarkGroup[])[], 77 | ): readonly BookmarkInfo[] { 78 | return nestedGroups.flatMap((nestedGroup) => 79 | nestedGroup.flatMap((group) => group.members), 80 | ) 81 | } 82 | 83 | function sortBookmarks( 84 | bookmarkInfos: readonly BookmarkInfo[], 85 | ): readonly BookmarkInfo[] { 86 | const nestedGroups = splitBySeparator(bookmarkInfos) 87 | .map(groupByType) 88 | .map((groups) => { 89 | return groups.map((group) => { 90 | return { 91 | ...group, 92 | members: sortByTitle(group.members), 93 | } 94 | }) 95 | }) 96 | .map(sortGroupByPriority) 97 | return mergeGroups(nestedGroups) 98 | } 99 | 100 | export default async function sortBookmarksByName(parentId: string) { 101 | const bookmarkTree = await getBookmarkTreeInfo(parentId) 102 | 103 | const sortedBookmarkInfos = sortBookmarks(bookmarkTree.children) 104 | 105 | // Moving bookmarks to sorted index 106 | for (const [index, bookmarkInfo] of sortedBookmarkInfos.entries()) { 107 | const currentBookmarkInfo = await getBookmarkInfo(bookmarkInfo.id) 108 | const currentIndex = currentBookmarkInfo.storageIndex 109 | if (currentIndex !== index) { 110 | await webExtension.bookmarks.move(bookmarkInfo.id, { 111 | // if new index is after current index, need to add 1, 112 | // because index means the position in current array, 113 | // which also count the current position 114 | index: index + (index > currentIndex ? 1 : 0), 115 | }) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /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 | - `build/store.md` - Description for Chrome Web Store 87 | - `README.md` - Description for GitHub 88 | 89 | 1. md 90 | 91 | ```sh 92 | make md 93 | ``` 94 | 95 | To generate markdown files 96 | - `build/store.md` - Description for Chrome Web Store 97 | - `README.md` - Description for GitHub 98 | 99 | ## Todo & Working Progress 100 | 101 | See 102 | 103 | ## Contributing 104 | 105 | - Translate to other languages. It's all depended on volunteers as I am not a linguist. ;-) 106 | 107 | Please join our translation team on 108 | 109 | - Fork me on GitHub, join our development! 110 | 111 | Repo: 112 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTree/useRowClickEvents.ts: -------------------------------------------------------------------------------- 1 | import { type MouseEvent, useMemo } from 'react' 2 | 3 | import { OPTIONS } from '@/core/constants/index.js' 4 | import { OPEN_IN_TYPES } from '@/popup/constants/menu.js' 5 | import { BOOKMARK_TYPES } from '@/popup/modules/bookmarks/constants.js' 6 | import { useBookmarkTreesContext } from '@/popup/modules/bookmarks/contexts/bookmarkTrees.js' 7 | import { 8 | openBookmarksInBrowser, 9 | openFolderInBrowser, 10 | } from '@/popup/modules/bookmarks/methods/openBookmark.js' 11 | import type { 12 | BookmarkInfo, 13 | BookmarkTreeInfo, 14 | } from '@/popup/modules/bookmarks/types.js' 15 | import { 16 | getClickOptionNameByEvent, 17 | mapOptionToOpenBookmarkProps, 18 | } from '@/popup/modules/bookmarks/utils/clickBookmark.js' 19 | import { useOptions } from '@/popup/modules/options.js' 20 | 21 | import { useMenuContext } from '../menu/index.js' 22 | 23 | export default function useRowClickEvents({ 24 | treeInfo, 25 | }: Readonly<{ treeInfo: BookmarkTreeInfo }>) { 26 | const options = useOptions() 27 | 28 | const { toggleBookmarkTree } = useBookmarkTreesContext() 29 | 30 | const { open: openMenu } = useMenuContext() 31 | 32 | return useMemo(() => { 33 | async function handleRowMiddleClick(bookmarkInfo: BookmarkInfo) { 34 | if (bookmarkInfo.type === BOOKMARK_TYPES.FOLDER) { 35 | await openFolderInBrowser(bookmarkInfo.id, { 36 | openIn: OPEN_IN_TYPES.NEW_TAB, 37 | isAllowBookmarklet: false, 38 | isCloseThisExtension: true, 39 | }) 40 | } else { 41 | const openBookmarkProps = mapOptionToOpenBookmarkProps( 42 | options[OPTIONS.CLICK_BY_MIDDLE], 43 | ) 44 | await openBookmarksInBrowser([bookmarkInfo.id], { 45 | ...openBookmarkProps, 46 | isAllowBookmarklet: true, 47 | }) 48 | } 49 | } 50 | function handleRowRightClick( 51 | bookmarkInfo: BookmarkInfo, 52 | evt: Readonly, 53 | ) { 54 | const offset = document 55 | .querySelector(`[data-bookmarkid="${bookmarkInfo.id}"`) 56 | ?.getBoundingClientRect() 57 | openMenu({ 58 | targetId: bookmarkInfo.id, 59 | displayPositions: { 60 | top: evt.clientY, 61 | left: evt.clientX, 62 | }, 63 | targetPositions: { 64 | top: offset?.top ?? 0, 65 | left: offset?.left ?? 0, 66 | }, 67 | }) 68 | } 69 | 70 | return { 71 | handleRowAuxClick: 72 | (bookmarkInfo: BookmarkInfo) => async (evt: Readonly) => { 73 | if (evt.button === 1) { 74 | await handleRowMiddleClick(bookmarkInfo) 75 | } 76 | }, 77 | handleRowClick: 78 | (bookmarkInfo: BookmarkInfo) => async (evt: Readonly) => { 79 | switch (bookmarkInfo.type) { 80 | case BOOKMARK_TYPES.FOLDER: 81 | if (options[OPTIONS.OP_FOLDER_BY]) { 82 | await toggleBookmarkTree(bookmarkInfo.id, treeInfo.parent.id) 83 | } 84 | break 85 | 86 | case BOOKMARK_TYPES.BOOKMARK: { 87 | const option = options[getClickOptionNameByEvent(evt)] 88 | const openBookmarkProps = mapOptionToOpenBookmarkProps(option) 89 | await openBookmarksInBrowser([bookmarkInfo.id], { 90 | ...openBookmarkProps, 91 | isAllowBookmarklet: true, 92 | }) 93 | break 94 | } 95 | 96 | case BOOKMARK_TYPES.DRAG_INDICATOR: 97 | case BOOKMARK_TYPES.NO_BOOKMARK: 98 | case BOOKMARK_TYPES.SEPARATOR: 99 | break 100 | } 101 | }, 102 | handleRowContextMenu: 103 | (bookmarkInfo: BookmarkInfo) => (evt: Readonly) => { 104 | handleRowRightClick(bookmarkInfo, evt) 105 | }, 106 | } 107 | }, [openMenu, options, toggleBookmarkTree, treeInfo.parent.id]) 108 | } 109 | -------------------------------------------------------------------------------- /src/popup/modules/bookmarks/methods/openBookmark.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from 'type-fest' 2 | import webExtension from 'webextension-polyfill' 3 | 4 | import { OPTIONS } from '@/core/constants/index.js' 5 | import { notNullish } from '@/core/utils/array.js' 6 | import { OPEN_IN_TYPES } from '@/popup/constants/menu.js' 7 | 8 | import { getOptions } from '../../options.js' 9 | import { BOOKMARK_TYPES } from '../constants.js' 10 | import { getBookmarkInfo, getBookmarkTreeInfo } from './getBookmark.js' 11 | 12 | async function getUrls(ids: readonly string[]): Promise { 13 | const bookmarkInfos = await Promise.all(ids.map(getBookmarkInfo)) 14 | 15 | const filteredBookmarkInfos = bookmarkInfos.filter( 16 | (bookmarkInfo) => 17 | bookmarkInfo.isSimulated === false && 18 | bookmarkInfo.type === BOOKMARK_TYPES.BOOKMARK, 19 | ) 20 | return filteredBookmarkInfos 21 | .map((bookmarkInfo) => bookmarkInfo.url) 22 | .filter(notNullish) 23 | } 24 | 25 | // suggested by https://groups.google.com/a/chromium.org/g/chromium-extensions/c/Inq88qfVoIs/m/gOeI5x2tBgAJ 26 | async function execInPage(code: string) { 27 | const [currentTab] = await webExtension.tabs.query({ 28 | currentWindow: true, 29 | active: true, 30 | }) 31 | if (currentTab?.id === undefined) return 32 | 33 | await webExtension.scripting.executeScript({ 34 | target: { tabId: currentTab.id }, 35 | func: (code: string): void => { 36 | const el = document.createElement('script') 37 | el.textContent = code 38 | document.body.append(el) 39 | el.remove() 40 | }, 41 | args: [code], 42 | world: 'MAIN', 43 | }) 44 | } 45 | 46 | type OpenBookmarkProps = Readonly<{ 47 | openIn: ValueOf 48 | isAllowBookmarklet: boolean 49 | isCloseThisExtension: boolean 50 | }> 51 | export async function openBookmarksInBrowser( 52 | ids: readonly string[], 53 | openBookmarkProps: OpenBookmarkProps, 54 | ) { 55 | const options = await getOptions() 56 | 57 | const allUrls = await getUrls(ids) 58 | 59 | const isJSProtocol = (url: string) => url.startsWith('javascript:') 60 | 61 | const bookmarkletUrls = allUrls.filter(isJSProtocol) 62 | if (openBookmarkProps.isAllowBookmarklet) { 63 | for (const bookmarkletUrl of bookmarkletUrls) { 64 | await execInPage(bookmarkletUrl) 65 | } 66 | } 67 | 68 | const urls = allUrls.filter((x) => !isJSProtocol(x)) 69 | if (urls.length > 0) { 70 | if (urls.length > 5) { 71 | const msgAskOpenAllTemplate = webExtension.i18n.getMessage('askOpenAll') 72 | const msgAskOpenAll = msgAskOpenAllTemplate.replace( 73 | '%bkmarkCount%', 74 | String(urls.length), 75 | ) 76 | if (options[OPTIONS.WARN_OPEN_MANY] && !globalThis.confirm(msgAskOpenAll)) 77 | return 78 | } 79 | 80 | switch (openBookmarkProps.openIn) { 81 | case OPEN_IN_TYPES.BACKGROUND_TAB: 82 | case OPEN_IN_TYPES.NEW_TAB: 83 | for (const url of urls) { 84 | await webExtension.tabs.create({ 85 | url, 86 | active: openBookmarkProps.openIn !== OPEN_IN_TYPES.BACKGROUND_TAB, 87 | }) 88 | } 89 | break 90 | 91 | case OPEN_IN_TYPES.CURRENT_TAB: 92 | await webExtension.tabs.update({ url: urls[0] }) 93 | break 94 | 95 | case OPEN_IN_TYPES.INCOGNITO_WINDOW: 96 | case OPEN_IN_TYPES.NEW_WINDOW: 97 | await webExtension.windows.create({ 98 | url: urls, 99 | incognito: 100 | openBookmarkProps.openIn === OPEN_IN_TYPES.INCOGNITO_WINDOW, 101 | }) 102 | break 103 | } 104 | } 105 | 106 | if (openBookmarkProps.isCloseThisExtension) window.close() 107 | } 108 | 109 | export async function openFolderInBrowser( 110 | id: string, 111 | openBookmarkProps: OpenBookmarkProps, 112 | ) { 113 | const bookmarkTree = await getBookmarkTreeInfo(id) 114 | 115 | const bookmarkIds = bookmarkTree.children.map((x) => x.id) 116 | 117 | await openBookmarksInBrowser(bookmarkIds, openBookmarkProps) 118 | } 119 | -------------------------------------------------------------------------------- /src/popup/components/BookmarkTree/BookmarkRow/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classix' 2 | import { 3 | type DragEventHandler, 4 | type FC, 5 | type JSX, 6 | type MouseEvent, 7 | type MouseEventHandler, 8 | useMemo, 9 | } from 'react' 10 | import type { Merge } from 'type-fest' 11 | 12 | import { BOOKMARK_TYPES } from '@/popup/modules/bookmarks/constants.js' 13 | import type { BookmarkInfo } from '@/popup/modules/bookmarks/types.js' 14 | 15 | import { 16 | DragAndDropConsumer, 17 | type ResponseEvent, 18 | } from '../../dragAndDrop/index.js' 19 | import * as classes from './bookmark-row.module.css' 20 | import BookmarkRow from './BookmarkRow.js' 21 | import useTooltip from './useTooltip.js' 22 | 23 | type Props = Merge< 24 | JSX.IntrinsicElements['li'], 25 | { 26 | bookmarkInfo: BookmarkInfo 27 | isDisableDragAndDrop: boolean 28 | isHighlighted: boolean 29 | isSearching: boolean 30 | isShowTooltip: boolean 31 | isUnclickable: boolean 32 | onAuxClick: (bookmarkInfo: BookmarkInfo) => MouseEventHandler 33 | onClick: (bookmarkInfo: BookmarkInfo) => MouseEventHandler 34 | onContextMenu: (bookmarkInfo: BookmarkInfo) => MouseEventHandler 35 | onDragOver: ( 36 | bookmarkInfo: BookmarkInfo, 37 | ) => (evt: Readonly, responseEvent: ResponseEvent) => void 38 | onDragStart: DragEventHandler 39 | onMouseEnter: (bookmarkInfo: BookmarkInfo) => MouseEventHandler 40 | onMouseLeave: (bookmarkInfo: BookmarkInfo) => MouseEventHandler 41 | } 42 | > 43 | const BookmarkRowContainer: FC = ({ 44 | bookmarkInfo, 45 | isDisableDragAndDrop, 46 | isHighlighted, 47 | isSearching, 48 | isShowTooltip, 49 | isUnclickable, 50 | onAuxClick, 51 | onClick, 52 | onContextMenu, 53 | onDragOver, 54 | onDragStart, 55 | onMouseEnter, 56 | onMouseLeave, 57 | ...liProps 58 | }) => { 59 | const tooltip = useTooltip({ 60 | isSearching, 61 | isShowTooltip, 62 | bookmarkInfo, 63 | }) 64 | 65 | const handleAuxClick = useMemo( 66 | () => onAuxClick(bookmarkInfo), 67 | [bookmarkInfo, onAuxClick], 68 | ) 69 | const handleClick = useMemo( 70 | () => onClick(bookmarkInfo), 71 | [bookmarkInfo, onClick], 72 | ) 73 | const handleContextMenu = useMemo( 74 | () => onContextMenu(bookmarkInfo), 75 | [bookmarkInfo, onContextMenu], 76 | ) 77 | const handleDragOver = useMemo( 78 | () => onDragOver(bookmarkInfo), 79 | [bookmarkInfo, onDragOver], 80 | ) 81 | const handleMouseEnter = useMemo( 82 | () => onMouseEnter(bookmarkInfo), 83 | [bookmarkInfo, onMouseEnter], 84 | ) 85 | const handleMouseLeave = useMemo( 86 | () => onMouseLeave(bookmarkInfo), 87 | [bookmarkInfo, onMouseLeave], 88 | ) 89 | 90 | const isSeparator = bookmarkInfo.type === BOOKMARK_TYPES.SEPARATOR 91 | 92 | return ( 93 |
  • 94 | 105 | 124 | 125 |
  • 126 | ) 127 | } 128 | export default BookmarkRowContainer 129 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/popup/components/keyBindings/KeyBindingsContext.ts: -------------------------------------------------------------------------------- 1 | import constate from 'constate' 2 | import { useCallback, useState } from 'react' 3 | import type { ValueOf } from 'type-fest' 4 | import useListener from 'use-typed-event-listener' 5 | 6 | import type { WindowId } from '@/popup/constants/windows.js' 7 | 8 | import type { KeyBindingEventCallback, KeyDefinition } from './types.js' 9 | 10 | function useActiveWindowState() { 11 | const [activeWindowQueue, setActiveWindowQueue] = useState< 12 | ReadonlySet> 13 | >(new Set()) 14 | 15 | const appendActiveWindowId = useCallback( 16 | (windowId: ValueOf) => { 17 | setActiveWindowQueue((prevState) => { 18 | const newState = new Set(prevState) 19 | newState.add(windowId) 20 | return newState 21 | }) 22 | }, 23 | [], 24 | ) 25 | 26 | const removeActiveWindowId = useCallback( 27 | (windowId: ValueOf) => { 28 | setActiveWindowQueue((prevState) => { 29 | const newState = new Set(prevState) 30 | newState.delete(windowId) 31 | return newState 32 | }) 33 | }, 34 | [], 35 | ) 36 | 37 | return { 38 | activeWindowId: Array.from(activeWindowQueue).at(-1), 39 | appendActiveWindowId, 40 | removeActiveWindowId, 41 | } 42 | } 43 | 44 | function useKeyBindingsPerWindowState() { 45 | const [keyBindingsPerWindow, setKeyBindingsPerWindow] = useState< 46 | ReadonlyMap< 47 | ValueOf, 48 | ReadonlyArray< 49 | Readonly<{ 50 | key: KeyDefinition 51 | callback: KeyBindingEventCallback 52 | }> 53 | > 54 | > 55 | >(new Map()) 56 | 57 | type AddOrRemoveListener = ( 58 | meta: Readonly<{ key: KeyDefinition; windowId: ValueOf }>, 59 | callback: KeyBindingEventCallback, 60 | ) => void 61 | 62 | const addListener: AddOrRemoveListener = useCallback( 63 | ({ key, windowId }, callback) => { 64 | setKeyBindingsPerWindow((prevState) => { 65 | const keyBindings = prevState.get(windowId) 66 | 67 | const updatedKeyBindings = [...(keyBindings ?? []), { callback, key }] 68 | 69 | return new Map(prevState).set(windowId, updatedKeyBindings) 70 | }) 71 | }, 72 | [], 73 | ) 74 | 75 | const removeListener: AddOrRemoveListener = useCallback( 76 | ({ key, windowId }, callback) => { 77 | setKeyBindingsPerWindow((prevState) => { 78 | const keyBindings = prevState.get(windowId) 79 | if (!keyBindings) return prevState 80 | 81 | const updatedKeyBindings = keyBindings.filter((keyBinding) => { 82 | return ( 83 | keyBinding.callback !== callback || 84 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 85 | String(keyBinding.key) !== String(key) 86 | ) 87 | }) 88 | 89 | return new Map(prevState).set(windowId, updatedKeyBindings) 90 | }) 91 | }, 92 | [], 93 | ) 94 | 95 | return { 96 | keyBindingsPerWindow, 97 | addListener, 98 | removeListener, 99 | } 100 | } 101 | 102 | function useKeyBindingsState() { 103 | const activeWindowState = useActiveWindowState() 104 | 105 | const keyBindingsPerWindowState = useKeyBindingsPerWindowState() 106 | 107 | return { 108 | ...activeWindowState, 109 | ...keyBindingsPerWindowState, 110 | } 111 | } 112 | 113 | function useKeyBindings() { 114 | const state = useKeyBindingsState() 115 | 116 | useListener(document, 'keydown', (evt) => { 117 | const { keyBindingsPerWindow, activeWindowId } = state 118 | if (!activeWindowId) return 119 | 120 | const keyBindings = keyBindingsPerWindow.get(activeWindowId) 121 | if (!keyBindings) return 122 | 123 | const matchedKeyBindings = keyBindings 124 | .filter((keyBinding) => { 125 | return keyBinding.key instanceof RegExp 126 | ? keyBinding.key.test(evt.key) 127 | : keyBinding.key === evt.key 128 | }) 129 | .reverse() 130 | 131 | matchedKeyBindings.forEach((keyBinding) => { 132 | keyBinding.callback(evt) 133 | }) 134 | }) 135 | 136 | return state 137 | } 138 | 139 | export const [KeyBindingsProvider, useKeyBindingsContext] = 140 | constate(useKeyBindings) 141 | --------------------------------------------------------------------------------