├── .npmrc ├── main.ts ├── screenshots ├── usage.gif ├── command.gif └── conditions.gif ├── src ├── components │ ├── ui │ │ ├── combobox │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ ├── combobox-empty.tsx │ │ │ ├── context.ts │ │ │ ├── combobox-item.tsx │ │ │ ├── combobox-input.tsx │ │ │ ├── combobox-content.tsx │ │ │ └── combobox.tsx │ │ ├── input.tsx │ │ └── popover.tsx │ ├── setting │ │ ├── DroppableWrap.tsx │ │ ├── index.tsx │ │ ├── DraggableWrap.tsx │ │ ├── SortableItem.tsx │ │ ├── action-types │ │ │ ├── regex.tsx │ │ │ ├── command.tsx │ │ │ ├── icon.tsx │ │ │ └── hotkeys.tsx │ │ ├── NewCustomAction.tsx │ │ └── Setting.tsx │ ├── Popover.tsx │ └── Item.tsx ├── lib │ └── utils.ts ├── L.ts ├── defaultSetting.ts ├── actions │ ├── index.ts │ ├── search.ts │ ├── date.ts │ ├── stats.ts │ ├── math.ts │ ├── basicFormat.ts │ ├── app.ts │ ├── clipboard.ts │ ├── list.ts │ ├── letterCase.ts │ └── translation.ts ├── types.ts ├── render.tsx ├── Plugin.ts ├── cm-extension.ts ├── index.css └── utils.ts ├── .typesafe-i18n.json ├── .editorconfig ├── postcss.config.ts ├── manifest.json ├── tailwind.config.ts ├── i18n ├── formatters.ts ├── i18n-util.sync.ts ├── i18n-util.async.ts ├── i18n-util.ts ├── zh │ └── index.ts ├── en │ └── index.ts └── i18n-types.ts ├── .gitignore ├── components.json ├── versions.json ├── version-bump.mjs ├── .github ├── workflows │ └── release.yml └── FUNDING.yml ├── tsconfig.json ├── LICENSE ├── esbuild.config.mjs ├── tailwind.config.js ├── package.json ├── README.md ├── eslint.config.mjs ├── styles.css └── main.css /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import Plugin from './src/Plugin'; 2 | 3 | export default Plugin; 4 | -------------------------------------------------------------------------------- /screenshots/usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhouhua/obsidian-popkit/HEAD/screenshots/usage.gif -------------------------------------------------------------------------------- /screenshots/command.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhouhua/obsidian-popkit/HEAD/screenshots/command.gif -------------------------------------------------------------------------------- /screenshots/conditions.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhouhua/obsidian-popkit/HEAD/screenshots/conditions.gif -------------------------------------------------------------------------------- /src/components/ui/combobox/types.ts: -------------------------------------------------------------------------------- 1 | export interface ComboboxItemBase { 2 | label: string; 3 | value: string; 4 | disabled?: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /.typesafe-i18n.json: -------------------------------------------------------------------------------- 1 | { 2 | "outputPath": "./i18n/", 3 | "adapters": [], 4 | "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json" 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /postcss.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'postcss-load-config'; 2 | 3 | const config: Config = { 4 | plugins: { 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /src/components/ui/combobox/index.ts: -------------------------------------------------------------------------------- 1 | export * from './combobox'; 2 | export * from './combobox-content'; 3 | export * from './combobox-empty'; 4 | export * from './combobox-input'; 5 | export * from './combobox-item'; 6 | export * from './context'; 7 | export type * from './types'; 8 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "popkit", 3 | "name": "PopKit", 4 | "version": "1.1.10", 5 | "minAppVersion": "1.5.3", 6 | "description": "Select text to instantly access quick tools", 7 | "author": "Zhou Hua", 8 | "authorUrl": "https://zhouhua.site", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/**/*.{js,jsx,ts,tsx}', 6 | './main.ts', 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /i18n/formatters.ts: -------------------------------------------------------------------------------- 1 | import type { FormattersInitializer } from 'typesafe-i18n'; 2 | import type { Locales, Formatters } from './i18n-types'; 3 | 4 | export const initFormatters: FormattersInitializer = (locale: Locales) => { 5 | const formatters: Formatters = { 6 | // add your formatter functions here 7 | }; 8 | 9 | return formatters; 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /src/components/setting/DroppableWrap.tsx: -------------------------------------------------------------------------------- 1 | import { useDroppable } from '@dnd-kit/core'; 2 | 3 | const DroppableWrap = ({ children, id, Component }: { 4 | children?: React.ReactNode; 5 | id: string; 6 | Component?: React.ElementType; 7 | }) => { 8 | const { setNodeRef } = useDroppable({ 9 | id, 10 | }); 11 | 12 | return Component 13 | ? ({children}) 14 | : (
{children}
); 15 | }; 16 | 17 | export default DroppableWrap; 18 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.5.3", 3 | "1.0.1": "1.5.3", 4 | "1.0.2": "1.5.3", 5 | "1.0.3": "1.5.3", 6 | "1.0.4": "1.5.3", 7 | "1.0.5": "1.5.3", 8 | "1.0.6": "1.5.3", 9 | "1.0.7": "1.5.3", 10 | "1.0.8": "1.5.3", 11 | "1.0.9": "1.5.3", 12 | "1.0.10": "1.5.3", 13 | "1.0.11": "1.5.3", 14 | "1.0.12": "1.5.3", 15 | "1.0.13": "1.5.3", 16 | "1.1.0": "1.5.3", 17 | "1.1.1": "1.5.3", 18 | "1.1.2": "1.5.3", 19 | "1.1.3": "1.5.3", 20 | "1.1.4": "1.5.3", 21 | "1.1.5": "1.5.3", 22 | "1.1.6": "1.5.3", 23 | "1.1.7": "1.5.3", 24 | "1.1.8": "1.5.3", 25 | "1.1.9": "1.5.3", 26 | "1.1.10": "1.5.3" 27 | } -------------------------------------------------------------------------------- /src/L.ts: -------------------------------------------------------------------------------- 1 | import { loadAllLocales } from '../i18n/i18n-util.sync'; 2 | import { i18n } from '../i18n/i18n-util'; 3 | import type { Locales } from '../i18n/i18n-types'; 4 | 5 | loadAllLocales(); 6 | 7 | declare global { 8 | interface Window { 9 | i18next: { 10 | language: string; 11 | }; 12 | } 13 | } 14 | 15 | let locale: Locales = 'en'; 16 | try { 17 | locale = (window.i18next.language || '').startsWith('zh') ? 'zh' : 'en'; 18 | } 19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 20 | catch (e) { 21 | /* empty */ 22 | } 23 | 24 | const L = i18n()[locale]; 25 | 26 | export default L; 27 | -------------------------------------------------------------------------------- /src/components/setting/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import React from 'react'; 3 | import Setting from './Setting'; 4 | import type { App } from 'obsidian'; 5 | import type { ISetting } from 'src/types'; 6 | 7 | export default function renderSetting( 8 | el: HTMLElement, 9 | initialSetting: ISetting, 10 | app: App, 11 | updateSetting: (data: ISetting) => void, 12 | ) { 13 | const root = createRoot(el); 14 | root.render( 15 | , 20 | ); 21 | return root; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ui/combobox/combobox-empty.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | import { useComboboxContext } from './context'; 6 | 7 | export const ComboboxEmpty = ({ 8 | className, 9 | children, 10 | ...props 11 | }: ComponentPropsWithoutRef<'div'>) => { 12 | const { filteredItems } = useComboboxContext(); 13 | if (filteredItems && filteredItems.length > 0) return null; 14 | 15 | return ( 16 |
20 | {children} 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/defaultSetting.ts: -------------------------------------------------------------------------------- 1 | import type { ISetting } from './types'; 2 | import { ItemType } from './types'; 3 | import * as basicFormat from './actions/basicFormat'; 4 | import * as search from './actions/search'; 5 | 6 | export default { 7 | refreshKey: 1, 8 | disableNativeToolbar: true, 9 | mouseSelectionOnly: false, 10 | customActionList: [], 11 | actionList: [ 12 | ...Object.values(basicFormat).map(action => ({ type: ItemType.Action, action })), 13 | { type: ItemType.Divider } as const, 14 | ...Object.values(search).map(action => ({ type: ItemType.Action, action })), 15 | ].map((item, i) => ({ ...item, id: `list_${i}` })), 16 | } satisfies ISetting; 17 | -------------------------------------------------------------------------------- /src/components/setting/DraggableWrap.tsx: -------------------------------------------------------------------------------- 1 | import { useDraggable } from '@dnd-kit/core'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | 4 | const DraggableWrap = ({ children, id }: { children?: React.ReactNode; id: string; }) => { 5 | const { attributes, listeners, setNodeRef, transform } = useDraggable({ 6 | id, 7 | }); 8 | const style = transform 9 | ? { transform: CSS.Translate.toString(transform) } 10 | : undefined; 11 | 12 | return ( 13 |
19 | {children} 20 |
21 | ); 22 | }; 23 | 24 | export default DraggableWrap; 25 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import * as clipboard from './clipboard'; 2 | import * as basicFormat from './basicFormat'; 3 | import * as search from './search'; 4 | import * as web from './web'; 5 | import * as translation from './translation'; 6 | import * as math from './math'; 7 | import * as letterCase from './letterCase'; 8 | import * as stats from './stats'; 9 | import * as appActions from './app'; 10 | import * as list from './list'; 11 | import * as date from './date'; 12 | import type { Action } from 'src/types'; 13 | 14 | export default [clipboard, basicFormat, search, web, translation, math, letterCase, stats, appActions, list, date] 15 | .reduce((acc, cur) => { 16 | return acc.concat(Object.values(cur)); 17 | }, []); 18 | -------------------------------------------------------------------------------- /src/actions/search.ts: -------------------------------------------------------------------------------- 1 | import L from 'src/L'; 2 | import type { Action } from 'src/types'; 3 | 4 | export const find: Action = { 5 | id: 'find', 6 | version: 0, 7 | icon: 'Search', 8 | name: 'find', 9 | desc: L.actions.find(), 10 | test: '.+', 11 | command: 'editor:open-search', 12 | }; 13 | 14 | export const replace: Action = { 15 | id: 'replace', 16 | version: 0, 17 | icon: 'Replace', 18 | name: 'replace', 19 | desc: L.actions.replace(), 20 | test: '.+', 21 | command: 'editor:open-search-replace', 22 | }; 23 | 24 | export const search: Action = { 25 | id: 'search', 26 | version: 0, 27 | icon: 'ScanSearch', 28 | name: 'global search', 29 | desc: L.actions.search(), 30 | test: '.+', 31 | command: 'global-search:open', 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/setting/SortableItem.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from '@dnd-kit/sortable'; 2 | import { CSS } from '@dnd-kit/utilities'; 3 | 4 | const SortableItem = ({ children, id, className }: { children: React.ReactNode; id: string; className?: string; }) => { 5 | const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }); 6 | 7 | const style = { 8 | transform: CSS.Transform.toString(transform), 9 | transition, 10 | }; 11 | 12 | return ( 13 |
14 |
20 | {children} 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default SortableItem; 27 | -------------------------------------------------------------------------------- /src/actions/date.ts: -------------------------------------------------------------------------------- 1 | import type { Action, HandlerParams } from 'src/types'; 2 | import { moment } from 'obsidian'; 3 | import L from 'src/L'; 4 | 5 | export const date: Action = { 6 | id: 'date', 7 | version: 0, 8 | name: () => { 9 | const now = moment(); 10 | return now.format('YYYY-MM-DD'); 11 | }, 12 | desc: L.actions.date(), 13 | handler: ({ action, replace }: HandlerParams) => { 14 | replace(typeof action.name === 'string' ? action.name : action.name({})); 15 | }, 16 | }; 17 | 18 | export const time: Action = { 19 | id: 'time', 20 | version: 0, 21 | name: () => { 22 | const now = moment(); 23 | return now.format('HH:mm:ss'); 24 | }, 25 | desc: L.actions.time(), 26 | handler: ({ action, replace }: HandlerParams) => { replace(typeof action.name === 'string' ? action.name : action.name({})); }, 27 | }; 28 | -------------------------------------------------------------------------------- /i18n/i18n-util.sync.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { initFormatters } from './formatters' 5 | import type { Locales, Translations } from './i18n-types' 6 | import { loadedFormatters, loadedLocales, locales } from './i18n-util' 7 | 8 | import en from './en' 9 | import zh from './zh' 10 | 11 | const localeTranslations = { 12 | en, 13 | zh, 14 | } 15 | 16 | export const loadLocale = (locale: Locales): void => { 17 | if (loadedLocales[locale]) return 18 | 19 | loadedLocales[locale] = localeTranslations[locale] as unknown as Translations 20 | loadFormatters(locale) 21 | } 22 | 23 | export const loadAllLocales = (): void => locales.forEach(loadLocale) 24 | 25 | export const loadFormatters = (locale: Locales): void => 26 | void (loadedFormatters[locale] = initFormatters(locale)) 27 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | 4 | import { readFileSync, writeFileSync } from 'fs'; 5 | import process from 'process'; 6 | 7 | const targetVersion = process.env.npm_package_version; 8 | 9 | // read minAppVersion from manifest.json and bump version to target version 10 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); 11 | const { minAppVersion } = manifest; 12 | manifest.version = targetVersion; 13 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')); 14 | 15 | // update versions.json with target version and minAppVersion from manifest.json 16 | let versions = JSON.parse(readFileSync('versions.json', 'utf8')); 17 | versions[targetVersion] = minAppVersion; 18 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "20.x" 19 | 20 | - name: setup pnpm 21 | uses: pnpm/action-setup@v3 22 | with: 23 | version: latest 24 | run_install: true 25 | 26 | - name: Build plugin 27 | run: pnpm build 28 | 29 | - name: Create release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | run: | 33 | tag="${GITHUB_REF#refs/tags/}" 34 | 35 | gh release create "$tag" \ 36 | --title="$tag" \ 37 | main.js manifest.json styles.css 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "jsx": "react-jsx", 5 | "module": "ESNext", 6 | "target": "ESNext", 7 | "allowJs": true, 8 | "noImplicitAny": true, 9 | "moduleResolution": "node", 10 | "importHelpers": true, 11 | "isolatedModules": true, 12 | "strictNullChecks": true, 13 | "noImplicitReturns": true, 14 | "allowSyntheticDefaultImports": true, 15 | "lib": [ 16 | "DOM", 17 | "ES7" 18 | ], 19 | "outDir": "dist", 20 | "types": [ 21 | "node", 22 | "obsidian-typings", 23 | "@codemirror/state", 24 | "@codemirror/view" 25 | ], 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": [ 33 | "**/*.ts", 34 | "**/*.tsx", 35 | "src/**/*.d.ts", 36 | "*.mjs", 37 | "tailwind.config.js" 38 | ] 39 | } -------------------------------------------------------------------------------- /src/actions/stats.ts: -------------------------------------------------------------------------------- 1 | import type { Action, HandlerParams } from 'src/types'; 2 | import words from 'lodash/words'; 3 | import L from 'src/L'; 4 | 5 | export const wordCount: Action = { 6 | id: 'word-count', 7 | version: 0, 8 | name: ({ selection }: Partial) => { 9 | const count = words(selection, /[\p{sc=Katakana}\p{sc=Hiragana}\p{sc=Han}]|\p{L}+['’]\p{L}+|\p{L}+/gu).length; 10 | return `W ${count}`; 11 | }, 12 | desc: L.actions.wordCount(), 13 | test: '.+', 14 | handler: () => {}, 15 | exampleText: 'hello world', 16 | }; 17 | 18 | export const lineCount: Action = { 19 | id: 'line-count', 20 | version: 0, 21 | name: ({ selection }: Partial) => { 22 | const count = selection?.split('\n').length || 0; 23 | return `L ${count}`; 24 | }, 25 | desc: L.actions.lineCount(), 26 | test: '.+', 27 | handler: () => {}, 28 | exampleText: 'hello world', 29 | }; 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: zhouhua 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /src/components/ui/combobox/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | import type { UseComboboxReturnValue } from 'downshift'; 3 | 4 | import type { ComboboxItemBase } from './types'; 5 | 6 | export type ComboboxContextValue = Partial< 7 | Pick< 8 | UseComboboxReturnValue, 9 | | 'getInputProps' 10 | | 'getItemProps' 11 | | 'getMenuProps' 12 | | 'highlightedIndex' 13 | | 'inputValue' 14 | | 'isOpen' 15 | | 'selectedItem' 16 | | 'selectItem' 17 | | 'setInputValue' 18 | > & { 19 | filteredItems: ComboboxItemBase[]; 20 | items: ComboboxItemBase[]; 21 | onItemsChange: (items: ComboboxItemBase[]) => void; 22 | onValueChange: (value: string | null) => void; 23 | openedOnce: boolean; 24 | } 25 | >; 26 | 27 | export const ComboboxContext = createContext({}); 28 | 29 | export const useComboboxContext = () => useContext(ComboboxContext); 30 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ); 18 | }, 19 | ); 20 | Input.displayName = 'Input'; 21 | 22 | export { Input }; 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Zhou Hua 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /i18n/i18n-util.async.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { initFormatters } from './formatters' 5 | import type { Locales, Translations } from './i18n-types' 6 | import { loadedFormatters, loadedLocales, locales } from './i18n-util' 7 | 8 | const localeTranslationLoaders = { 9 | en: () => import('./en'), 10 | zh: () => import('./zh'), 11 | } 12 | 13 | const updateDictionary = (locale: Locales, dictionary: Partial): Translations => 14 | loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary } 15 | 16 | export const importLocaleAsync = async (locale: Locales): Promise => 17 | (await localeTranslationLoaders[locale]()).default as unknown as Translations 18 | 19 | export const loadLocaleAsync = async (locale: Locales): Promise => { 20 | updateDictionary(locale, await importLocaleAsync(locale)) 21 | loadFormatters(locale) 22 | } 23 | 24 | export const loadAllLocalesAsync = (): Promise => Promise.all(locales.map(loadLocaleAsync)) 25 | 26 | export const loadFormatters = (locale: Locales): void => 27 | void (loadedFormatters[locale] = initFormatters(locale)) 28 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === 'production'; 12 | 13 | const context = await esbuild.context({ 14 | banner: { 15 | js: banner, 16 | }, 17 | entryPoints: ['main.ts'], 18 | mainFields: ['browser', 'module', 'main'], 19 | conditions: ['browser'], 20 | bundle: true, 21 | external: [ 22 | 'obsidian', 23 | 'electron', 24 | '@codemirror/autocomplete', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/language', 28 | '@codemirror/lint', 29 | '@codemirror/search', 30 | '@codemirror/state', 31 | '@codemirror/view', 32 | '@lezer/common', 33 | '@lezer/highlight', 34 | '@lezer/lr', 35 | ...builtins, 36 | ], 37 | format: 'cjs', 38 | target: 'esnext', 39 | logLevel: 'info', 40 | sourcemap: prod ? false : 'inline', 41 | treeShaking: true, 42 | outfile: 'main.js', 43 | supported: { 44 | 'async-await': true, 45 | }, 46 | }); 47 | 48 | if (prod) { 49 | await context.rebuild(); 50 | process.exit(0); 51 | } 52 | else { 53 | await context.watch(); 54 | } 55 | -------------------------------------------------------------------------------- /src/actions/math.ts: -------------------------------------------------------------------------------- 1 | import type { Unit } from 'mathjs'; 2 | import { evaluate, format, typeOf } from 'mathjs'; 3 | import L from 'src/L'; 4 | import type { Action, HandlerParams } from 'src/types'; 5 | 6 | export const calc: Action = { 7 | id: 'calc', 8 | version: 0, 9 | name: ({ selection }: Partial) => { 10 | const result: unknown = evaluate(selection?.trim() || ''); 11 | if (typeOf(result).startsWith('ResultSet')) { 12 | // @ts-expect-error mathjs type error 13 | // eslint-disable-next-line 14 | const resultArray: number[] = result.valueOf(); 15 | const lastResult = resultArray[resultArray.length - 1]; // take final entry of resultset 16 | return format(lastResult, { notation: 'fixed' }); 17 | } 18 | else if (typeOf(result) === 'number') { 19 | return format(result, { notation: 'fixed' }); 20 | } 21 | else if (typeOf(result) === 'Unit') { 22 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 23 | return (result as Unit).valueOf(); 24 | } 25 | return ''; 26 | }, 27 | desc: L.actions.calc(), 28 | test: '^[^\n]+$', 29 | handler: ({ action, replace }: HandlerParams) => { 30 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 31 | replace(action.name as string); 32 | }, 33 | defaultIcon: 'Calculator', 34 | }; 35 | -------------------------------------------------------------------------------- /src/actions/basicFormat.ts: -------------------------------------------------------------------------------- 1 | import L from 'src/L'; 2 | import type { Action } from 'src/types'; 3 | 4 | export const bold: Action = { 5 | id: 'bold', 6 | version: 0, 7 | name: 'bold', 8 | desc: L.actions.bold(), 9 | icon: 'Bold', 10 | command: 'editor:toggle-bold', 11 | }; 12 | 13 | export const italic: Action = { 14 | id: 'italic', 15 | version: 0, 16 | name: 'italic', 17 | desc: L.actions.italic(), 18 | icon: 'Italic', 19 | command: 'editor:toggle-italics', 20 | }; 21 | 22 | export const strikethrough: Action = { 23 | id: 'strikethrough', 24 | version: 0, 25 | name: 'strikethrough', 26 | desc: L.actions.strikethrough(), 27 | icon: 'Strikethrough', 28 | command: 'editor:toggle-strikethrough', 29 | }; 30 | 31 | export const addAttach: Action = { 32 | id: 'add-attach', 33 | version: 0, 34 | name: 'add attachment', 35 | desc: L.actions.addAttach(), 36 | icon: 'Paperclip', 37 | command: 'editor:attach-file', 38 | }; 39 | 40 | export const blockquote: Action = { 41 | id: 'blockquote', 42 | version: 0, 43 | name: 'blockquote', 44 | desc: L.actions.blockquote(), 45 | icon: 'Quote', 46 | command: 'editor:toggle-blockquote', 47 | }; 48 | 49 | export const clearFormat: Action = { 50 | id: 'clear-format', 51 | version: 0, 52 | name: 'clear format', 53 | desc: L.actions.clearFormat(), 54 | icon: 'RemoveFormatting', 55 | test: '.+', 56 | command: 'editor:clear-formatting', 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PopoverPrimitive from '@radix-ui/react-popover'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 32 | -------------------------------------------------------------------------------- /src/components/setting/action-types/regex.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import L from 'src/L'; 3 | 4 | interface RegexFormProps { 5 | test: string; 6 | onChange: (test: string) => void; 7 | } 8 | 9 | const RegexForm: FC = ({ 10 | test, 11 | onChange, 12 | }) => { 13 | return ( 14 |
15 |
16 |
{L.setting.testRegexLabel()}
17 |
18 | {L.setting.testRegexDesc()} 19 |
20 |
21 | ^[a-z]+$ 22 | {' '} 23 | - 24 | {' '} 25 | {L.setting.testRegexExample1()} 26 |
27 |
28 | \d+ 29 | {' '} 30 | - 31 | {' '} 32 | {L.setting.testRegexExample2()} 33 |
34 |
35 | ^# 36 | {' '} 37 | - 38 | {' '} 39 | {L.setting.testRegexExample3()} 40 |
41 |
42 |
43 |
44 |
45 | { onChange(e.target.value); }} 51 | /> 52 |
53 |
54 | ); 55 | }; 56 | 57 | export default RegexForm; 58 | -------------------------------------------------------------------------------- /src/actions/app.ts: -------------------------------------------------------------------------------- 1 | import L from 'src/L'; 2 | import type { Action, HandlerParams } from 'src/types'; 3 | 4 | export const openHelp: Action = { 5 | id: 'open-help', 6 | version: 0, 7 | icon: 'CircleHelp', 8 | name: 'help', 9 | desc: L.actions.help(), 10 | test: '^$', 11 | command: 'app:open-help', 12 | }; 13 | 14 | export const openSetting: Action = { 15 | id: 'open-setting', 16 | version: 0, 17 | icon: 'Settings', 18 | name: 'setting', 19 | desc: L.actions.setting(), 20 | test: '^$', 21 | command: 'app:open-setting', 22 | }; 23 | 24 | export const addBookmark: Action = { 25 | id: 'add-bookmark', 26 | version: 0, 27 | icon: 'BookmarkCheck', 28 | name: 'add bookmark', 29 | desc: L.actions.addBookmark(), 30 | handler: ({ selection, app }: HandlerParams) => { 31 | app.commands.executeCommandById(selection ? 'bookmarks:bookmark-current-section' : 'bookmarks:bookmark-current-view'); 32 | }, 33 | }; 34 | 35 | export const openBookmark: Action = { 36 | id: 'open-bookmark', 37 | version: 0, 38 | icon: 'BookMarked', 39 | name: 'open bookmark', 40 | desc: L.actions.openBookmark(), 41 | command: 'bookmarks:open', 42 | }; 43 | 44 | export const lineUp: Action = { 45 | id: 'line-up', 46 | version: 0, 47 | icon: 'CornerRightUp', 48 | name: 'line up', 49 | desc: L.actions.lineUp(), 50 | command: 'editor:swap-line-up', 51 | }; 52 | 53 | export const lineDown: Action = { 54 | id: 'line-down', 55 | version: 0, 56 | icon: 'CornerRightDown', 57 | name: 'line down', 58 | desc: L.actions.lineDown(), 59 | command: 'editor:swap-line-down', 60 | }; 61 | 62 | export const highlight: Action = { 63 | id: 'highlight', 64 | version: 0, 65 | icon: 'Highlighter', 66 | name: 'highlight', 67 | desc: L.actions.highlight(), 68 | test: '.+', 69 | command: 'editor:toggle-highlight', 70 | }; 71 | -------------------------------------------------------------------------------- /i18n/i18n-util.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | 4 | import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n' 5 | import type { LocaleDetector } from 'typesafe-i18n/detectors' 6 | import type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n' 7 | import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors' 8 | import { initExtendDictionary } from 'typesafe-i18n/utils' 9 | import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types' 10 | 11 | export const baseLocale: Locales = 'en' 12 | 13 | export const locales: Locales[] = [ 14 | 'en', 15 | 'zh' 16 | ] 17 | 18 | export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales) 19 | 20 | export const loadedLocales: Record = {} as Record 21 | 22 | export const loadedFormatters: Record = {} as Record 23 | 24 | export const extendDictionary = initExtendDictionary() 25 | 26 | export const i18nString = (locale: Locales): TranslateByString => initI18nString(locale, loadedFormatters[locale]) 27 | 28 | export const i18nObject = (locale: Locales): TranslationFunctions => 29 | initI18nObject( 30 | locale, 31 | loadedLocales[locale], 32 | loadedFormatters[locale] 33 | ) 34 | 35 | export const i18n = (): LocaleTranslationFunctions => 36 | initI18n(loadedLocales, loadedFormatters) 37 | 38 | export const detectLocale = (...detectors: LocaleDetector[]): Locales => detectLocaleFn(baseLocale, locales, ...detectors) 39 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | darkMode: ['class'], 5 | content: ['src/**/*.{ts,tsx}'], 6 | prefix: 'pk-', 7 | theme: { 8 | extend: { 9 | colors: { 10 | border: 'hsl(var(--pk-border))', 11 | input: 'hsl(var(--pk-input))', 12 | ring: 'hsl(var(--pk-ring))', 13 | background: 'hsl(var(--pk-background))', 14 | foreground: 'hsl(var(--pk-foreground))', 15 | primary: { 16 | DEFAULT: 'hsl(var(--pk-primary))', 17 | foreground: 'hsl(var(--pk-primary-foreground))', 18 | }, 19 | secondary: { 20 | DEFAULT: 'hsl(var(--pk-secondary))', 21 | foreground: 'hsl(var(--pk-secondary-foreground))', 22 | }, 23 | destructive: { 24 | DEFAULT: 'hsl(var(--pk-destructive))', 25 | foreground: 'hsl(var(--pk-destructive-foreground))', 26 | }, 27 | muted: { 28 | DEFAULT: 'hsl(var(--pk-muted))', 29 | foreground: 'hsl(var(--pk-muted-foreground))', 30 | }, 31 | accent: { 32 | DEFAULT: 'hsl(var(--pk-accent))', 33 | foreground: 'hsl(var(--pk-accent-foreground))', 34 | }, 35 | popover: { 36 | DEFAULT: 'hsl(var(--pk-popover))', 37 | foreground: 'hsl(var(--pk-popover-foreground))', 38 | }, 39 | card: { 40 | DEFAULT: 'hsl(var(--pk-card))', 41 | foreground: 'hsl(var(--pk-card-foreground))', 42 | }, 43 | }, 44 | borderRadius: { 45 | lg: `var(--pk-radius)`, 46 | md: `calc(var(--pk-radius) - 2px)`, 47 | sm: 'calc(var(--pk-radius) - 4px)', 48 | }, 49 | }, 50 | }, 51 | // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef 52 | plugins: [require('tailwindcss-animate')], 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/ui/combobox/combobox-item.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentPropsWithoutRef, useMemo } from 'react'; 2 | import { CircleIcon } from 'lucide-react'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | import { useComboboxContext } from './context'; 7 | import type { ComboboxItemBase } from './types'; 8 | 9 | export type ComboboxItemProps = ComboboxItemBase & 10 | ComponentPropsWithoutRef<'li'>; 11 | 12 | export const ComboboxItem = ({ 13 | label, 14 | value, 15 | disabled, 16 | className, 17 | children, 18 | ...props 19 | }: ComboboxItemProps) => { 20 | const { filteredItems, getItemProps, selectedItem } = useComboboxContext(); 21 | 22 | const isSelected = selectedItem?.value === value; 23 | const item = useMemo( 24 | () => ({ disabled, label, value }), 25 | [disabled, label, value], 26 | ); 27 | const index = (filteredItems || []).findIndex( 28 | item => item.value.toLowerCase() === value.toLowerCase(), 29 | ); 30 | if (index < 0) return null; 31 | 32 | return ( 33 |
  • 43 | {children || ( 44 | <> 45 | {label} 46 | {isSelected && ( 47 | 48 | 49 | 50 | )} 51 | 52 | )} 53 |
  • 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/ui/combobox/combobox-input.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState, type ChangeEvent, type ComponentPropsWithoutRef } from 'react'; 2 | import { PopoverAnchor } from '@radix-ui/react-popover'; 3 | import type { UseComboboxGetInputPropsReturnValue } from 'downshift'; 4 | import { ChevronDownIcon, X } from 'lucide-react'; 5 | 6 | import { Input } from '@/components/ui/input'; 7 | 8 | import { useComboboxContext } from './context'; 9 | 10 | export type ComboboxInputProps = Omit< 11 | ComponentPropsWithoutRef<'input'>, 12 | keyof UseComboboxGetInputPropsReturnValue 13 | > & { 14 | onChange?: (e: ChangeEvent) => void; 15 | value?: string; 16 | }; 17 | 18 | export const ComboboxInput = (props: ComboboxInputProps) => { 19 | const { getInputProps } = useComboboxContext(); 20 | const inputProps = getInputProps?.(); 21 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 22 | const { value, onChange } = inputProps || {} as UseComboboxGetInputPropsReturnValue; 23 | 24 | const [inputValue, setInputValue] = useState(value); 25 | 26 | useEffect(() => { 27 | setInputValue(props.value || ''); 28 | }, [props.value]); 29 | 30 | const mergedOnChange = useCallback((e: ChangeEvent) => { 31 | setInputValue(e.target.value); 32 | onChange?.(e); 33 | props.onChange?.(e); 34 | }, [onChange, props.onChange]); 35 | 36 | return ( 37 |
    38 | 39 | 40 | 41 |
    42 | {inputValue ? { setInputValue(''); }} /> : } 43 |
    44 |
    45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { App, Editor } from 'obsidian'; 2 | 3 | export interface HandlerParams { 4 | app: App; 5 | editor: Editor; 6 | replace: (text: string) => void; 7 | getMarkdown: () => string; 8 | selection: string; 9 | action: Action; 10 | } 11 | 12 | interface IActionBase { 13 | name: string | ((param: Partial) => string); 14 | desc: string; 15 | icon?: string | ((param: Partial) => string); 16 | test?: string; 17 | dependencies?: string[]; 18 | exampleText?: string; 19 | defaultIcon?: string; 20 | origin?: Action; 21 | id?: string; 22 | version?: number; 23 | } 24 | 25 | export interface IActionWithHandler extends IActionBase { 26 | handler: string | ((params: HandlerParams) => Promise | void); 27 | } 28 | 29 | export function hasHandler(action: Action): action is IActionWithHandler { 30 | return 'handler' in action; 31 | } 32 | 33 | export interface IActionWithHandlerString extends IActionBase { 34 | handlerString: string; 35 | } 36 | 37 | export function hasHandlerString(action: Action): action is IActionWithHandlerString { 38 | return 'handlerString' in action; 39 | } 40 | 41 | export interface IActionWithCommand extends IActionBase { 42 | command: string; 43 | } 44 | 45 | export function hasCommand(action: Action): action is IActionWithCommand { 46 | return 'command' in action; 47 | } 48 | 49 | export interface IActionWithHotkeys extends IActionBase { 50 | hotkeys: string[]; 51 | } 52 | 53 | export function hasHotkeys(action: Action): action is IActionWithHotkeys { 54 | return 'hotkeys' in action && !('command' in action); 55 | } 56 | 57 | export type Action = IActionWithHandler | IActionWithHandlerString | IActionWithCommand | IActionWithHotkeys; 58 | 59 | export enum ItemType { 60 | // eslint-disable-next-line @typescript-eslint/no-shadow 61 | Action = 'action', 62 | Divider = 'divider', 63 | } 64 | export type PopoverItem = 65 | | { type: ItemType.Action; action: Action; id: string; } 66 | | { type: ItemType.Divider; id: string; }; 67 | 68 | export interface ISetting { 69 | refreshKey: number; 70 | disableNativeToolbar: boolean; 71 | mouseSelectionOnly: boolean; 72 | actionList: PopoverItem[]; 73 | customActionList: Action[]; 74 | } 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-popkit", 3 | "version": "1.1.10", 4 | "description": "Select text to instantly access quick tools", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "node esbuild.config.mjs production && tailwindcss -i ./src/index.css -o ./styles.css", 9 | "build:css": "tailwindcss -i ./src/index.css -o ./styles.css", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json", 11 | "typesafe-i18n": "typesafe-i18n", 12 | "lint": "eslint ./" 13 | }, 14 | "keywords": [ 15 | "obsidian" 16 | ], 17 | "author": "Zhou Hua", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/zhouhua/obsidian-popkit" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/zhouhua/obsidian-popkit/issues" 24 | }, 25 | "license": "MIT", 26 | "devDependencies": { 27 | "@eslint/js": "^9.18.0", 28 | "@stylistic/eslint-plugin": "^2.13.0", 29 | "@types/codemirror": "^5.60.15", 30 | "@types/eslint__js": "^8.42.3", 31 | "@types/lodash": "^4.17.14", 32 | "@types/node": "^22.10.7", 33 | "@types/react": "^19.0.7", 34 | "@types/react-dom": "^19.0.3", 35 | "@types/react-window": "^1.8.8", 36 | "@typescript-eslint/parser": "8.20.0", 37 | "builtin-modules": "4.0.0", 38 | "esbuild": "0.24.2", 39 | "eslint": "^9.18.0", 40 | "obsidian": "^1.7.2", 41 | "obsidian-typings": "^2.14.3", 42 | "tailwindcss": "^3.4.17", 43 | "tslib": "2.8.1", 44 | "typescript": "5.7.3", 45 | "typescript-eslint": "^8.20.0" 46 | }, 47 | "dependencies": { 48 | "@codemirror/basic-setup": "^0.20.0", 49 | "@codemirror/commands": "^6.8.0", 50 | "@codemirror/lang-javascript": "^6.2.2", 51 | "@codemirror/language": "^6.10.8", 52 | "@codemirror/state": "^6.5.1", 53 | "@codemirror/view": "^6.36.2", 54 | "@dnd-kit/core": "^6.3.1", 55 | "@dnd-kit/sortable": "^10.0.0", 56 | "@dnd-kit/utilities": "^3.2.2", 57 | "@emotion/react": "^11.14.0", 58 | "@monaco-editor/react": "^4.6.0", 59 | "@mui/base": "5.0.0-beta.68", 60 | "@radix-ui/react-popover": "^1.1.5", 61 | "@radix-ui/react-radio-group": "^1.2.2", 62 | "@radix-ui/react-scroll-area": "^1.2.2", 63 | "ahooks": "^3.8.4", 64 | "class-variance-authority": "^0.7.1", 65 | "clsx": "^2.1.1", 66 | "codemirror": "5.65.16", 67 | "downshift": "^9.0.8", 68 | "lodash": "^4.17.21", 69 | "lucide-react": "^0.471.1", 70 | "mathjs": "^14.0.1", 71 | "monaco-editor": "^0.52.2", 72 | "react": "^19.0.0", 73 | "react-dom": "^19.0.0", 74 | "react-window": "^1.8.11", 75 | "tailwind-merge": "^2.6.0", 76 | "tailwindcss-animate": "^1.0.7", 77 | "typesafe-i18n": "^5.26.2" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /i18n/zh/index.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from '../i18n-types'; 2 | 3 | const zh: Translation = { 4 | actions: { 5 | help: '打开帮助', 6 | setting: '打开设置', 7 | addBookmark: '添加书签', 8 | openBookmark: '打开书签', 9 | lineUp: '上移一行', 10 | lineDown: '下移一行', 11 | highlight: '高亮/取消高亮', 12 | bold: '粗体/取消粗体', 13 | italic: '斜体/取消斜体', 14 | comment: '注释/取消注释', 15 | strikethrough: '删除线/取消删除线', 16 | addAttach: '添加附件', 17 | blockquote: '块引用/取消块引用', 18 | clearFormat: '清除格式', 19 | cut: '剪切', 20 | copy: '复制', 21 | paste: '粘贴', 22 | copyHtml: '复制 HTML', 23 | date: '插入今日日期', 24 | time: '插入当前时间', 25 | upperCase: '转成大写', 26 | lowerCase: '转成小写', 27 | capitalCase: '转成首字母大写', 28 | list: '转化成无序列表', 29 | orderedList: '转化成有序列表', 30 | mergeLines: '合并多行文本成一行', 31 | sortLines: '对多行文本排序', 32 | reverseLines: '反转多行文本', 33 | shuffleLines: '打乱多行文本', 34 | calc: '计算数学表达式', 35 | find: '在当前笔记中查找', 36 | replace: '在当前笔记中查找并替换', 37 | search: '在所有文件中查找', 38 | wordCount: '字数统计', 39 | lineCount: '行数统计', 40 | bingTranslation: '使用 Bing 翻译', 41 | googleTranslation: '使用 Google 翻译', 42 | google: '用 Google 搜索', 43 | baidu: '用百度搜索', 44 | zhihu: '用知乎搜索', 45 | quora: '用 Quora 搜索', 46 | bing: '用 Bing 搜索', 47 | wikipedia: '用 Wikipedia 搜索', 48 | wolframAlpha: '用 Wolfram Alpha 搜索', 49 | duckduckgo: '用 DuckDuckGo 搜索', 50 | }, 51 | setting: { 52 | delete: '拖动按钮到此处删除', 53 | buildIn: '内置快捷工具', 54 | custom: '自定义快捷工具', 55 | add: '添加自定义快捷工具', 56 | divider: '分割线', 57 | upload: '上传', 58 | customTitle: '自定义快捷工具', 59 | addSuccess: '自定义快捷工具添加成功', 60 | plugin: '来源插件:', 61 | command: '命令:', 62 | icon: '图标:', 63 | empty: '暂无自定义快捷工具,可以通过下方表单添加。', 64 | disableNativeToolbar: '禁用选中文本时的系统工具条(针对编辑模式)', 65 | noResult: '没有找到结果', 66 | pickItem: '请选择……', 67 | mouseSelectionOnly: '仅在鼠标选择时显示 Popkit', 68 | actionType: '自定义快捷工具类型', 69 | actionTypeDesc: '选择你想要创建的自定义快捷工具类型', 70 | commandLabel: '命令', 71 | hotkeysLabel: '快捷键', 72 | descriptionLabel: '描述', 73 | descriptionDesc: '鼠标指向时悬浮显示', 74 | descriptionPlaceholder: '输入描述', 75 | iconNotice: '请选择图标', 76 | commandNotice: '请选择命令', 77 | invalidCommand: '无效的命令', 78 | hotkeysNotice: '请设置快捷键', 79 | descriptionNotice: '请输入描述', 80 | hotkeysDesc: '按下你想要使用的快捷键或组合键', 81 | deleteHotkey: '删除快捷键', 82 | notSet: '未设置', 83 | customHotkey: '自定义快捷键', 84 | cancelSetting: '取消设置', 85 | testRegexLabel: '正则表达式过滤', 86 | testRegexDesc: '通过正则表达式控制何时显示该操作。只有当选中的文本匹配此模式时,该操作才会出现。例如:', 87 | testRegexPlaceholder: '输入正则表达式(可选)', 88 | testRegexExample1: '仅当选中文本只包含小写字母时显示', 89 | testRegexExample2: '仅当选中文本包含数字时显示', 90 | testRegexExample3: '仅当选中文本以井号开头时显示', 91 | invalidRegex: '无效的正则表达式', 92 | }, 93 | }; 94 | 95 | export default zh; 96 | -------------------------------------------------------------------------------- /src/components/setting/action-types/command.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from 'lucide-react'; 2 | import type { App, Command } from 'obsidian'; 3 | import type { FC } from 'react'; 4 | import type { OrderItemProps } from 'src/utils'; 5 | import L from 'src/L'; 6 | import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem } from '../../ui/combobox'; 7 | 8 | interface CommandWithPluginInfo extends Command { 9 | pluginId: string; 10 | } 11 | 12 | interface CommandFormProps { 13 | app: App; 14 | cmd: string; 15 | cmdInput: string; 16 | orderedCmdList: OrderItemProps[]; 17 | setCmd: (value: string) => void; 18 | setCmdInput: (value: string) => void; 19 | } 20 | 21 | const CommandForm: FC = ({ 22 | cmd, 23 | cmdInput, 24 | orderedCmdList, 25 | setCmd, 26 | setCmdInput, 27 | }) => { 28 | return ( 29 |
    30 |
    31 |
    {L.setting.command()}
    32 |
    33 | {L.setting.pickItem()} 34 |
    35 |
    36 |
    37 | list} 40 | onValueChange={value => { 41 | setCmd(value || ''); 42 | setCmdInput(value || ''); 43 | }} 44 | > 45 | { setCmdInput(e.target.value); }} 49 | /> 50 | 51 | {orderedCmdList.map(command => { 52 | return ( 53 | 58 |
    59 | 65 | 66 |
    67 | {cmd === command.origin.id && ( 68 | 69 | 70 | 71 | )} 72 |
    73 | ); 74 | })} 75 | {L.setting.noResult()} 76 |
    77 |
    78 |
    79 |
    80 | ); 81 | }; 82 | 83 | export default CommandForm; 84 | -------------------------------------------------------------------------------- /src/actions/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownRenderChild, MarkdownRenderer } from 'obsidian'; 2 | import L from 'src/L'; 3 | import type { Action, HandlerParams } from 'src/types'; 4 | 5 | export const cut: Action = { 6 | id: 'cut', 7 | version: 0, 8 | name: 'Cut', 9 | desc: L.actions.cut(), 10 | test: '.+', 11 | handler: async ({ selection, replace }: HandlerParams) => { 12 | replace(''); 13 | await navigator.clipboard.writeText(selection); 14 | }, 15 | }; 16 | 17 | export const cutWithIcon: Action = { 18 | id: 'cut-with-icon', 19 | version: 0, 20 | icon: 'Scissors', 21 | name: 'Cut', 22 | desc: L.actions.cut(), 23 | test: '.+', 24 | handler: async ({ selection, replace }: HandlerParams) => { 25 | replace(''); 26 | await navigator.clipboard.writeText(selection); 27 | }, 28 | }; 29 | 30 | export const copy: Action = { 31 | id: 'copy', 32 | version: 0, 33 | name: 'Copy', 34 | desc: L.actions.copy(), 35 | test: '.+', 36 | handler: async ({ selection }: HandlerParams) => { 37 | await navigator.clipboard.writeText(selection); 38 | }, 39 | }; 40 | 41 | export const copyWithIcon: Action = { 42 | id: 'copy-with-icon', 43 | version: 0, 44 | icon: 'Copy', 45 | name: 'Copy', 46 | desc: L.actions.copy(), 47 | test: '.+', 48 | handler: async ({ selection }: HandlerParams) => { 49 | await navigator.clipboard.writeText(selection); 50 | }, 51 | }; 52 | 53 | export const paste: Action = { 54 | id: 'paste', 55 | version: 0, 56 | name: 'Paste', 57 | desc: L.actions.paste(), 58 | handler: async ({ replace }: HandlerParams) => { 59 | const text = await navigator.clipboard.readText(); 60 | replace(text); 61 | }, 62 | }; 63 | 64 | export const pasteWithIcon: Action = { 65 | id: 'paste-with-icon', 66 | version: 0, 67 | icon: 'ClipboardCheck', 68 | name: 'Paste', 69 | desc: L.actions.paste(), 70 | handler: async ({ replace }: HandlerParams) => { 71 | const text = await navigator.clipboard.readText(); 72 | replace(text); 73 | }, 74 | }; 75 | 76 | export const zCopyHtml: Action = { 77 | id: 'copy-html', 78 | version: 0, 79 | icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxnIGZpbGw9Im5vbmUiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIj48cGF0aCBkPSJNMTQgM3Y0YTEgMSAwIDAgMCAxIDFoNCIvPjxwYXRoIGQ9Ik01IDEyVjVhMiAyIDAgMCAxIDItMmg3bDUgNXY0TTIgMjF2LTZtMyAwdjZtLTMtM2gzbTE1LTN2NmgybS05IDB2LTZsMiAzbDItM3Y2bS05LjUtNmgzTTkgMTV2NiIvPjwvZz48L3N2Zz4=', 80 | name: 'Copy HTML', 81 | desc: L.actions.copyHtml(), 82 | test: '.+', 83 | handler: async ({ selection, app }: HandlerParams) => { 84 | const div = createDiv(); 85 | await MarkdownRenderer.render( 86 | app, 87 | selection, 88 | div, 89 | '', 90 | new MarkdownRenderChild(div), 91 | ); 92 | const html = div.innerHTML; 93 | div.remove(); 94 | await navigator.clipboard.writeText(html); 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /src/actions/list.ts: -------------------------------------------------------------------------------- 1 | import type { Action, HandlerParams } from 'src/types'; 2 | import shuffle from 'lodash/shuffle'; 3 | import L from 'src/L'; 4 | 5 | export const list: Action = { 6 | id: 'list', 7 | version: 0, 8 | name: 'list', 9 | desc: L.actions.list(), 10 | icon: 'List', 11 | test: '.+', 12 | command: 'editor:toggle-bullet-list', 13 | }; 14 | 15 | export const sortedList: Action = { 16 | id: 'sorted-list', 17 | version: 0, 18 | name: 'ordered list', 19 | desc: L.actions.orderedList(), 20 | icon: 'ListOrdered', 21 | test: '.+', 22 | command: 'editor:toggle-numbered-list', 23 | }; 24 | 25 | export const mergeLines: Action = { 26 | id: 'merge-lines', 27 | version: 0, 28 | name: 'merge lines', 29 | desc: L.actions.mergeLines(), 30 | icon: 'ListStart', 31 | test: '.+', 32 | handler: ({ replace, selection }: HandlerParams) => { 33 | replace(selection.split(/[\r\n]+/).map(line => line.trim()).join('')); 34 | }, 35 | }; 36 | 37 | export const sortLines: Action = { 38 | id: 'sort-lines', 39 | version: 0, 40 | name: 'sort lines', 41 | desc: L.actions.sortLines(), 42 | icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMTQgMTQiPjxwYXRoIGZpbGw9IndoaXRlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zLjc1IDBhMSAxIDAgMCAxIDEgMXY5aDEuNWEuNzUuNzUgMCAwIDEgLjUzIDEuMjhsLTIuNSAyLjVhLjc1Ljc1IDAgMCAxLTEuMDYgMGwtMi41LTIuNUEuNzUuNzUgMCAwIDEgMS4yNSAxMGgxLjVWMWExIDEgMCAwIDEgMS0xbTguMTY3Ljc1YS43NS43NSAwIDAgMC0xLjUgMGEuNS41IDAgMCAxLS41LjVIOS41YS43NS43NSAwIDAgMCAwIDEuNWguNDE3Yy4xNzIgMCAuMzQtLjAyMi41LS4wNjNWNUg5LjVhLjc1Ljc1IDAgMCAwIDAgMS41aDMuMzM0YS43NS43NSAwIDAgMCAwLTEuNWgtLjkxN3ptLS44MzkgOS4zNDRoLS42NTZhLjY3Mi42NzIgMCAwIDEgMC0xLjM0NGguNjU2Yy4zNyAwIC42Ny4yOTguNjcyLjY2N3YuMDFhLjY3Mi42NzIgMCAwIDEtLjY3Mi42NjdtMC0yLjg0NGMxLjExIDAgMi4wMjcuODMzIDIuMTU3IDEuOTA5Yy4wMS4wNS4wMTUuMS4wMTUuMTUzdjIuNjI3YTIuMDYyIDIuMDYyIDAgMCAxLTIuMDYyIDIuMDYyaC0uODc1Yy0uOSAwLTEuNjYyLS41NzUtMS45NDUtMS4zNzVhLjc1Ljc1IDAgMSAxIDEuNDE0LS41Yy4wNzguMjIuMjg3LjM3NS41My4zNzVoLjg3NmMuMzEgMCAuNTYyLS4yNTIuNTYyLS41NjN2LS40NWEyLjE3IDIuMTcgMCAwIDEtLjY3Mi4xMDdoLS42NTZhMi4xNzIgMi4xNzIgMCAwIDEgMC00LjM0NHoiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==', 43 | test: '\n', 44 | handler: ({ replace, selection }: HandlerParams) => { 45 | replace(selection.split(/[\r\n]+/).sort().join('\n')); 46 | }, 47 | }; 48 | 49 | export const reverseLine: Action = { 50 | id: 'reverse-lines', 51 | version: 0, 52 | name: 'reverse lines', 53 | desc: L.actions.reverseLines(), 54 | icon: 'ListRestart', 55 | test: '\n', 56 | handler: ({ replace, selection }: HandlerParams) => { 57 | replace(selection.split(/[\r\n]+/).reverse().join('\n')); 58 | }, 59 | }; 60 | 61 | export const shuffleLine: Action = { 62 | id: 'shuffle-lines', 63 | version: 0, 64 | name: 'shuffle lines', 65 | desc: L.actions.shuffleLines(), 66 | icon: 'Shuffle', 67 | test: '\n', 68 | handler: ({ replace, selection }: HandlerParams) => { 69 | replace(shuffle(selection.split(/[\r\n]+/)).join('\n')); 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/ui/combobox/combobox-content.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Children, 3 | type ComponentPropsWithoutRef, 4 | isValidElement, 5 | type ReactElement, 6 | useEffect, 7 | useMemo, 8 | } from 'react'; 9 | import type { ListChildComponentProps } from 'react-window'; 10 | import { FixedSizeList } from 'react-window'; 11 | 12 | import { PopoverContent } from '@/components/ui/popover'; 13 | import { cn } from '@/lib/utils'; 14 | 15 | import { ComboboxItem, type ComboboxItemProps } from './combobox-item'; 16 | import { useComboboxContext } from './context'; 17 | 18 | const ITEM_HEIGHT = 28; // 每个选项的高度 19 | const LIST_HEIGHT = 360; // 列表最大高度 20 | 21 | interface VirtualizedListProps { 22 | children: ReactElement[]; 23 | } 24 | 25 | const VirtualizedList = ({ children }: VirtualizedListProps) => { 26 | if (!children.length) return null; 27 | 28 | const height = Math.min(children.length * ITEM_HEIGHT, LIST_HEIGHT); 29 | 30 | return ( 31 |
    32 | 39 | {({ index, style }: ListChildComponentProps) => ( 40 |
    41 | {children[index]} 42 |
    43 | )} 44 |
    45 |
    46 | ); 47 | }; 48 | 49 | export const ComboboxContent = ({ 50 | onOpenAutoFocus, 51 | children, 52 | className, 53 | ...props 54 | }: ComponentPropsWithoutRef) => { 55 | const { getMenuProps, isOpen, openedOnce, onItemsChange } 56 | = useComboboxContext(); 57 | 58 | const childItems = useMemo( 59 | () => 60 | Children.toArray(children).filter( 61 | (child): child is ReactElement => 62 | isValidElement(child) && child.type === ComboboxItem, 63 | ), 64 | [children], 65 | ); 66 | 67 | const otherChildren = useMemo( 68 | () => 69 | Children.toArray(children).filter( 70 | child => 71 | !isValidElement(child) || child.type !== ComboboxItem, 72 | ), 73 | [children], 74 | ); 75 | 76 | useEffect(() => { 77 | onItemsChange?.( 78 | childItems.map(child => ({ 79 | disabled: child.props.disabled, 80 | label: child.props.label, 81 | value: child.props.value, 82 | })), 83 | ); 84 | }, [childItems, onItemsChange]); 85 | 86 | return ( 87 | { 97 | e.preventDefault(); 98 | onOpenAutoFocus?.(e); 99 | }} 100 | {...getMenuProps?.({}, { suppressRefError: true })} 101 | > 102 | 103 | {childItems} 104 | 105 | {otherChildren} 106 | 107 | ); 108 | }; 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian PopKit 2 | 3 | ![GitHub Release](https://img.shields.io/github/v/release/zhouhua/obsidian-popkit?include_prereleases&style=flat) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/zhouhua/obsidian-popkit/total?style=flat) 4 | 5 | Obsidian PopKit is a powerful plugin for [Obsidian](https://obsidian.md/), providing a suite of built-in tools to enhance your note-taking and productivity workflows. Inspired by the Mac software [PopClip](https://pilotmoon.com/popclip/), PopKit offers quick access to a variety of useful utilities. 6 | 7 | ## Installation 8 | 9 | 1. Open Obsidian and go to **Settings** > **Community plugins**. 10 | 2. Click on **Browse** and search for "Obsidian PopKit". 11 | 3. Click **Install**. 12 | 4. Once installed, enable the Obsidian PopKit plugin from the **Installed plugins** list. 13 | 14 | ## Usage 15 | 16 | After installing and enabling the plugin, you can access the various tools via the PopKit toolbar. The toolbar can be customized from the plugin settings to include only the tools you frequently use. 17 | 18 | ### How to Use 19 | 20 | - Select a piece of text in the Obsidian editor, and the PopKit toolset will instantly appear. Click on a tool to use it. 21 | 22 | ![Usage](./screenshots/usage.gif) 23 | 24 | - You can manually invoke the PopKit toolbar using the command "show PopKit" (of course, assigning a shortcut key to this command would be more convenient). Since there might be no selected content at this point, some built-in actions may not be relevant, and the tooltip will exclude those tools. 25 | 26 | ![Command](./screenshots/command.gif) 27 | 28 | - In fact, the PopKit toolbar can display quite dynamic content. As mentioned earlier, it can decide whether to show specific buttons based on whether there is selected text. Additionally, it can perform mathematical calculations, display date and time, count words, and more. Below is an example of mathematical calculations. 29 | 30 | ![Calculation](./screenshots/conditions.gif) 31 | 32 | ## Customization 33 | 34 | You can customize the tools available in the PopKit toolbar by going to **Settings** > **PopKit** and selecting the tools you want to enable or disable. 35 | 36 | - This plugin comes with dozens of tools. To enable the ones you want, simply drag them into the preview toolbar in the settings interface. 37 | 38 | - The tools in the preview toolbar can be freely dragged to adjust their order. If you want to remove a tool, simply drag it from the preview toolbar to the delete box. 39 | 40 | - In addition to the built-in tools, you can easily add any Obsidian built-in commands or commands registered by enabled plugins as custom tools. To enable a custom tool, simply drag it into the preview toolbar. 41 | 42 | ## Future Features 43 | 44 | - [ ] Allow users to add custom tools. 45 | 46 | --- 47 | 48 | Thank you for using Obsidian PopKit! I hope it enhances your productivity and note-taking experience. 49 | 50 | ## My Other Obsidian Plugins 51 | 52 | - [Export Image](https://github.com/zhouhua/obsidian-export-image) 53 | - [Markdown Media Card](https://github.com/zhouhua/obsidian-markdown-media-card) 54 | - [vConsole](https://github.com/zhouhua/obsidian-vconsole) 55 | - [POWER MODE](https://github.com/zhouhua/obsidian-power-mode) 56 | - [Another Sticky Headings](https://github.com/zhouhua/obsidian-sticky-headings) 57 | -------------------------------------------------------------------------------- /src/render.tsx: -------------------------------------------------------------------------------- 1 | import type { App, Editor } from 'obsidian'; 2 | import Popover from './components/Popover'; 3 | import type { Root } from 'react-dom/client'; 4 | import { createRoot } from 'react-dom/client'; 5 | import { StrictMode } from 'react'; 6 | import type { ISetting } from './types'; 7 | 8 | const instanceList: PopoverManager[] = []; 9 | 10 | export default class PopoverManager { 11 | el: HTMLElement; 12 | root: Root; 13 | destroying: boolean = false; 14 | editor: Editor; 15 | settings: ISetting; 16 | app: App; 17 | 18 | constructor(editor: Editor, app: App, settings: ISetting) { 19 | const sameInstance = instanceList.find(instance => instance.editor.containerEl === editor.containerEl); 20 | try { 21 | // 清除其他编辑器的 popover 22 | instanceList.forEach(instance => { 23 | if (instance.editor.containerEl !== editor.containerEl) { 24 | instance.destroy(); 25 | } 26 | }); 27 | } 28 | catch (error) { 29 | console.warn(error); 30 | } 31 | 32 | if (!sameInstance) { 33 | this.editor = editor; 34 | this.app = app; 35 | this.settings = settings; 36 | instanceList.push(this); 37 | 38 | const out = editor.containerEl.find('.cm-scroller'); 39 | /* @ts-igonre */ 40 | this.el = out.createDiv({ 41 | cls: ['popkit-popover'], 42 | }); 43 | // 添加 CSS transition 44 | this.el.style.transition = 'transform 100ms ease-out'; 45 | 46 | this.root = createRoot(this.el); 47 | this.root.render( 48 | 49 | { 51 | this.destroy(); 52 | }} 53 | editor={editor} 54 | actions={settings.actionList} 55 | out={out} 56 | app={app} 57 | type="normal" 58 | /> 59 | , 60 | ); 61 | 62 | // 延迟触发第一次位置计算 63 | setTimeout(() => { 64 | const event = new Event('popkit-popover-render'); 65 | window.dispatchEvent(event); 66 | }, 0); 67 | } 68 | else { 69 | // 更新 editor 对象并重新渲染 70 | sameInstance.editor = editor; 71 | sameInstance.root.render( 72 | 73 | { 75 | sameInstance.destroy(); 76 | }} 77 | editor={editor} 78 | actions={settings.actionList} 79 | out={editor.containerEl.find('.cm-scroller')} 80 | app={app} 81 | type="normal" 82 | /> 83 | , 84 | ); 85 | // 延迟触发位置更新事件 86 | setTimeout(() => { 87 | const event = new Event('popkit-popover-render'); 88 | window.dispatchEvent(event); 89 | }, 0); 90 | return sameInstance; 91 | } 92 | return this; 93 | } 94 | 95 | destroy() { 96 | if (this.destroying) { 97 | return; 98 | } 99 | this.destroying = true; 100 | this.root.unmount(); 101 | this.el.remove(); 102 | const index = instanceList.indexOf(this); 103 | if (index !== -1) { 104 | instanceList.splice(index, 1); 105 | } 106 | } 107 | } 108 | 109 | export const clearPopover = () => { 110 | instanceList.forEach(instance => { 111 | instance.destroy(); 112 | }); 113 | }; 114 | -------------------------------------------------------------------------------- /src/actions/letterCase.ts: -------------------------------------------------------------------------------- 1 | import L from 'src/L'; 2 | import type { Action, HandlerParams } from 'src/types'; 3 | 4 | export const upperCase: Action = { 5 | id: 'upper-case', 6 | version: 0, 7 | name: 'upperCase', 8 | desc: L.actions.upperCase(), 9 | test: '.+', 10 | icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMTUgMTUiPjxwYXRoIGZpbGw9IndoaXRlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zLjYyNiAyLjc1YS41LjUgMCAwIDEgLjQ2OC4zMjdsMy4wNzYgOC4zMmEuNS41IDAgMCAxLS45MzguMzQ2TDUuMjI0IDkuMDE2SDIuMDI3TDEuMDIgMTEuNzQzYS41LjUgMCAxIDEtLjkzOC0uMzQ3bDMuMDc2LTguMzJhLjUuNSAwIDAgMSAuNDY5LS4zMjZtMCAxLjk0Mkw0LjkxIDguMTY2SDIuMzR6bTcuNzQ2LTEuOTQyYS41LjUgMCAwIDEgLjQ2OS4zMjdsMy4wNzUgOC4zMmEuNS41IDAgMSAxLS45MzguMzQ2TDEyLjk3IDkuMDE2SDkuNzczbC0xLjAwOCAyLjcyN2EuNS41IDAgMSAxLS45MzgtLjM0N2wzLjA3Ni04LjMyYS41LjUgMCAwIDEgLjQ2OS0uMzI2bTAgMS45NDJsMS4yODQgMy40NzRoLTIuNTY4eiIgY2xpcC1ydWxlPSJldmVub2RkIi8+PC9zdmc+', 11 | handler: ({ replace, selection }: HandlerParams) => { 12 | replace(selection.toUpperCase()); 13 | }, 14 | }; 15 | 16 | export const lowerCase: Action = { 17 | id: 'lower-case', 18 | version: 0, 19 | name: 'lowerCase', 20 | desc: L.actions.lowerCase(), 21 | test: '.+', 22 | icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMTUgMTUiPjxwYXRoIGZpbGw9IndoaXRlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zLjY5OSA1LjIwN2MtMS42NCAwLTIuODkgMS40NzktMi44OSAzLjQwM2MwIDIuMDI0IDEuMzUgMy40MDIgMi44OSAzLjQwMmEzLjA2IDMuMDYgMCAwIDAgMi4yNTUtLjk5di41MDhhLjQ1LjQ1IDAgMCAwIC45IDBWNS43MmEuNDUuNDUgMCAwIDAtLjkgMHYuNTAzYTMuMDEgMy4wMSAwIDAgMC0yLjI1NS0xLjAxNm0yLjI1NSA0LjU5MlY3LjMwMWMtLjM5LS43Mi0xLjIxMy0xLjI0My0yLjA2Ny0xLjI0M2MtLjk3OCAwLTIuMDUyLjkwOC0yLjA1MiAyLjU1MmMwIDEuNTQzLjk3NCAyLjU1MiAyLjA1MiAyLjU1MmMuODgzIDAgMS42ODQtLjY2NiAyLjA2Ny0xLjM2M200Ljg0NS00LjU5MmMtMS42NCAwLTIuODkgMS40NzktMi44OSAzLjQwM2MwIDIuMDI0IDEuMzUgMy40MDIgMi44OSAzLjQwMmEzLjA2IDMuMDYgMCAwIDAgMi4yNTUtLjk5di41MDhhLjQ1LjQ1IDAgMCAwIC45IDBWNS43MmEuNDUuNDUgMCAxIDAtLjkgMHYuNTAzQTMuMDEgMy4wMSAwIDAgMCAxMC44IDUuMjA3bTIuMjU1IDQuNTkxVjcuMzAyYy0uMzktLjcyMS0xLjIxMy0xLjI0NC0yLjA2Ny0xLjI0NGMtLjk3OCAwLTIuMDUyLjkwOC0yLjA1MiAyLjU1MmMwIDEuNTQzLjk3NCAyLjU1MiAyLjA1MiAyLjU1MmMuODgzIDAgMS42ODUtLjY2NyAyLjA2Ny0xLjM2NCIgY2xpcC1ydWxlPSJldmVub2RkIi8+PC9zdmc+', 23 | handler: ({ replace, selection }: HandlerParams) => { 24 | replace(selection.toLowerCase()); 25 | }, 26 | }; 27 | 28 | export const capitalCase: Action = { 29 | id: 'capital-case', 30 | version: 0, 31 | name: 'capitalCase', 32 | desc: L.actions.capitalCase(), 33 | test: '.+', 34 | icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMTUgMTUiPjxwYXRoIGZpbGw9IndoaXRlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zLjY5IDIuNzVhLjUuNSAwIDAgMSAuNDY3LjMybDMuMjEgOC4zMmEuNS41IDAgMCAxLS45MzMuMzZMNS4zODMgOS4wMjVIMi4wMUwuOTY3IDExLjc1YS41LjUgMCAwIDEtLjkzNC0uMzU4bDMuMTktOC4zMmEuNS41IDAgMCAxIC40NjctLjMyMW0uMDAyIDEuODkzbDEuMzYzIDMuNTMySDIuMzM3em03LjIwNy41NjRjLTEuNjQgMC0yLjg5IDEuNDc5LTIuODkgMy40MDNjMCAyLjAyNCAxLjM1IDMuNDAyIDIuODkgMy40MDJhMy4wNiAzLjA2IDAgMCAwIDIuMjU1LS45OXYuNTA4YS40NS40NSAwIDEgMCAuOSAwVjUuNzJhLjQ1LjQ1IDAgMCAwLS45IDB2LjUwM0EzLjAxIDMuMDEgMCAwIDAgMTAuOSA1LjIwN20yLjI1NSA0LjU5MVY3LjMwMmMtLjM5LS43MjEtMS4yMTMtMS4yNDQtMi4wNjctMS4yNDRjLS45NzggMC0yLjA1Mi45MDgtMi4wNTIgMi41NTJjMCAxLjU0My45NzQgMi41NTIgMi4wNTIgMi41NTJjLjg4MyAwIDEuNjg1LS42NjcgMi4wNjctMS4zNjQiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==', 35 | handler: ({ replace, selection }: HandlerParams) => { 36 | const regex = /(\p{L}+['’]\p{L}+|\p{L}+)/gu; 37 | replace(selection.replace(regex, match => match.charAt(0).toUpperCase() + match.slice(1).toLowerCase())); 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /i18n/en/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseTranslation } from '../i18n-types'; 2 | 3 | const en: BaseTranslation = { 4 | actions: { 5 | help: 'Open help', 6 | setting: 'Open setting', 7 | addBookmark: 'Add bookmark', 8 | openBookmark: 'Open bookmark', 9 | lineUp: 'Move line up', 10 | lineDown: 'Move line down', 11 | highlight: 'Toggle highlight', 12 | bold: 'Bold', 13 | italic: 'Italics', 14 | comment: 'Comment', 15 | strikethrough: 'Strikethrough', 16 | addAttach: 'Add attachment', 17 | blockquote: 'Blockquote', 18 | clearFormat: 'Clear format', 19 | cut: 'Cut', 20 | copy: 'Copy', 21 | paste: 'Paste', 22 | copyHtml: 'Copy HTML', 23 | date: 'Insert today\'s date', 24 | time: 'Insert current time', 25 | upperCase: 'Uppercase', 26 | lowerCase: 'Lowercase', 27 | capitalCase: 'Capital case', 28 | list: 'Toggle list', 29 | orderedList: 'Toggle ordered list', 30 | mergeLines: 'Merge lines into single line', 31 | sortLines: 'Sort lines', 32 | reverseLines: 'Reverse lines', 33 | shuffleLines: 'Shuffle lines', 34 | calc: 'Calculate math expression', 35 | find: 'Find in current note', 36 | replace: 'Find and replace in current note', 37 | search: 'Search in all files', 38 | wordCount: 'Word count', 39 | lineCount: 'Line count', 40 | bingTranslation: 'Translation with Bing', 41 | googleTranslation: 'Translation with Google', 42 | google: 'Search with Google', 43 | baidu: 'Search with Baidu', 44 | zhihu: 'Search with Zhihu', 45 | quora: 'Search with Quora', 46 | bing: 'Search with Bing', 47 | wikipedia: 'Search with Wikipedia', 48 | wolframAlpha: 'Search with Wolfram Alpha', 49 | duckduckgo: 'Search with DuckDuckGo', 50 | }, 51 | setting: { 52 | delete: 'Drag the button here to delete', 53 | buildIn: 'Buildin actions', 54 | custom: 'Custom actions', 55 | add: 'Add custom action', 56 | divider: 'Divider', 57 | upload: 'Upload', 58 | customTitle: 'Add custom action', 59 | addSuccess: 'A custom action has been added successfully', 60 | plugin: 'Source plugin: ', 61 | command: 'Command: ', 62 | icon: 'Icon: ', 63 | empty: 'No custom actions available yet. You can add them using the form below.', 64 | disableNativeToolbar: 'Disable the system toolbar when selecting text (for edit mode)', 65 | mouseSelectionOnly: 'Only show Popkit when selecting text with mouse', 66 | noResult: 'No results.', 67 | pickItem: 'Please pick an item...', 68 | actionType: 'Custom action type', 69 | actionTypeDesc: 'Select the type of action you want to create', 70 | commandLabel: 'Command', 71 | hotkeysLabel: 'Hotkeys', 72 | descriptionLabel: 'Description', 73 | descriptionDesc: 'Shown when hovering over the mouse', 74 | descriptionPlaceholder: 'Enter description', 75 | iconNotice: 'Please select an icon', 76 | commandNotice: 'Please select a command', 77 | invalidCommand: 'Invalid command', 78 | hotkeysNotice: 'Please set hotkeys', 79 | descriptionNotice: 'Please enter a description', 80 | hotkeysDesc: 'Press the combination of keys you want to use', 81 | deleteHotkey: 'Delete hotkey', 82 | notSet: 'Not set', 83 | customHotkey: 'Custom hotkey', 84 | cancelSetting: 'Cancel setting', 85 | testRegexLabel: 'Regular Expression Filter', 86 | testRegexDesc: 'Control when this action is available by regular expression. For example:', 87 | testRegexPlaceholder: 'Enter regular expression (optional)', 88 | testRegexExample1: 'Only shows when selected text contains only lowercase letters', 89 | testRegexExample2: 'Only shows when selected text contains numbers', 90 | testRegexExample3: 'Only shows when selected text starts with a hashtag', 91 | invalidRegex: 'Invalid regular expression', 92 | }, 93 | }; 94 | export default en; 95 | -------------------------------------------------------------------------------- /src/Plugin.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'obsidian'; 2 | import { Platform, Plugin, PluginSettingTab, Setting } from 'obsidian'; 3 | import PopoverManager from './render'; 4 | import { hasHandler, ItemType, type ISetting } from './types'; 5 | import defaultSetting from './defaultSetting'; 6 | import renderSetting from './components/setting'; 7 | import type { Root } from 'react-dom/client'; 8 | import { stringifyFunction, updateSettings } from './utils'; 9 | import actions from './actions'; 10 | import L from './L'; 11 | import { popoverPlugin } from './cm-extension'; 12 | 13 | export default class PopkitPlugin extends Plugin { 14 | settings: ISetting; 15 | async onload() { 16 | await this.loadSettings(); 17 | this.registerEditorExtension([popoverPlugin(this)]); 18 | this.registerDomEvent( 19 | document.body, 20 | 'contextmenu', 21 | e => { 22 | if (this.settings.disableNativeToolbar && Platform.isMobile) { 23 | const { target } = e; 24 | if (target instanceof HTMLElement) { 25 | const isInEditor = target.closest('.cm-editor') !== null; 26 | if (isInEditor) { 27 | e.preventDefault(); 28 | } 29 | } 30 | } 31 | }, 32 | ); 33 | this.addCommand({ 34 | id: 'show', 35 | name: 'Show PopKit', 36 | // hotkeys: [{ modifiers: ["Mod"], key: "." }], 37 | editorCallback: editor => { 38 | new PopoverManager(editor, this.app, this.settings); 39 | }, 40 | }); 41 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 42 | this.addSettingTab(new PopkitSetting(this.app, this)); 43 | } 44 | 45 | onunload() {} 46 | 47 | async loadSettings() { 48 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 49 | const savedData: Partial = (await this.loadData() || {}) as Partial; 50 | if (savedData.actionList?.length) { 51 | savedData.actionList = updateSettings(actions, savedData.actionList, savedData.refreshKey); 52 | } 53 | this.settings = { ...defaultSetting, ...savedData, refreshKey: defaultSetting.refreshKey }; 54 | } 55 | 56 | async saveSettings() { 57 | const newSetting = { 58 | ...this.settings, 59 | actionList: this.settings.actionList.map(item => { 60 | if (item.type === ItemType.Divider) { 61 | return item; 62 | } 63 | const { action } = item; 64 | if (hasHandler(action)) { 65 | return { 66 | ...item, 67 | action: stringifyFunction(action), 68 | }; 69 | } 70 | return item; 71 | }), 72 | }; 73 | await this.saveData(newSetting); 74 | } 75 | } 76 | 77 | class PopkitSetting extends PluginSettingTab { 78 | plugin: PopkitPlugin; 79 | render: (settings: ISetting) => void; 80 | root?: Root; 81 | 82 | constructor(app: App, plugin: PopkitPlugin) { 83 | super(app, plugin); 84 | this.plugin = plugin; 85 | } 86 | 87 | update(data: ISetting) { 88 | this.plugin.settings = data; 89 | this.plugin.saveSettings(); 90 | } 91 | 92 | display(): void { 93 | const { containerEl } = this; 94 | if (this.root) { 95 | this.root.unmount(); 96 | } 97 | containerEl.empty(); 98 | if (Platform.isMobile) { 99 | new Setting(containerEl) 100 | .setName(L.setting.disableNativeToolbar()) 101 | .addToggle(toggle => { 102 | toggle 103 | .setValue(this.plugin.settings.disableNativeToolbar) 104 | .onChange(value => { 105 | this.update({ 106 | ...this.plugin.settings, 107 | disableNativeToolbar: value, 108 | }); 109 | }); 110 | }); 111 | } 112 | 113 | new Setting(containerEl) 114 | .setName(L.setting.mouseSelectionOnly()) 115 | .addToggle(toggle => { 116 | toggle 117 | .setValue(this.plugin.settings.mouseSelectionOnly) 118 | .onChange(value => { 119 | this.update({ 120 | ...this.plugin.settings, 121 | mouseSelectionOnly: value, 122 | }); 123 | }); 124 | }); 125 | 126 | const rootEl = containerEl.createDiv(); 127 | this.root = renderSetting( 128 | rootEl, 129 | this.plugin.settings, 130 | this.app, 131 | data => { this.update(data); }, 132 | ); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/components/setting/action-types/icon.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, icons } from 'lucide-react'; 2 | import type { FC } from 'react'; 3 | import { memo } from 'react'; 4 | import type { OrderItemProps } from 'src/utils'; 5 | import L from 'src/L'; 6 | import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem } from '../../ui/combobox'; 7 | 8 | const IconItem = memo(({ item, icon }: { item: OrderItemProps; icon: string; }) => { 9 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 10 | const OptionIcon = icons[item.origin as keyof typeof icons]; 11 | return ( 12 | 13 |
    14 | 15 | 21 | 22 |
    23 | {icon === item.origin && ( 24 | 25 | 26 | 27 | )} 28 |
    29 | ); 30 | }); 31 | 32 | interface IconFormProps { 33 | icon: string; 34 | iconInput: string; 35 | orderedIconList: OrderItemProps[]; 36 | setIcon: (value: string) => void; 37 | setIconInput: (value: string) => void; 38 | onUpload: () => Promise; 39 | inputReference: React.RefObject; 40 | } 41 | 42 | const IconForm: FC = ({ 43 | icon, 44 | iconInput, 45 | orderedIconList, 46 | setIcon, 47 | setIconInput, 48 | onUpload, 49 | inputReference, 50 | }) => { 51 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 52 | const Icon = icon in icons ? icons[icon as keyof typeof icons] : undefined; 53 | 54 | return ( 55 |
    56 |
    57 |
    {L.setting.icon()}
    58 |
    59 |
    60 | {icon && ( 61 |
    62 | {Icon && } 63 | {!Icon && } 64 |
    65 | )} 66 | 76 | list} 79 | onValueChange={value => { 80 | setIcon(value || ''); 81 | setIconInput(value || ''); 82 | }} 83 | > 84 | { 88 | setIconInput(e.target.value); 89 | }} 90 | /> 91 | 92 | {orderedIconList.map(item => { 93 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 94 | const OptionIcon = icons[item.origin as keyof typeof icons]; 95 | return ( 96 | 97 |
    98 | 99 | 105 | 106 |
    107 | {icon === item.origin && ( 108 | 109 | 110 | 111 | )} 112 |
    113 | ); 114 | })} 115 |
    116 |
    117 |
    118 |
    119 | ); 120 | }; 121 | 122 | export default IconForm; 123 | -------------------------------------------------------------------------------- /src/components/ui/combobox/combobox.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type PropsWithChildren, 3 | useCallback, 4 | useEffect, 5 | useState, 6 | } from 'react'; 7 | import { useCombobox, type UseComboboxProps } from 'downshift'; 8 | 9 | import { Popover } from '@/components/ui/popover'; 10 | 11 | import { ComboboxContext } from './context'; 12 | import type { ComboboxItemBase } from './types'; 13 | 14 | const { stateChangeTypes } = useCombobox; 15 | 16 | const defaultFilter = (inputValue: string, items: ComboboxItemBase[]) => 17 | items.filter( 18 | item => 19 | !inputValue || item.label.toLowerCase().includes(inputValue.toLowerCase()), 20 | ); 21 | 22 | export type ComboboxProps = PropsWithChildren<{ 23 | value?: string | null; 24 | onValueChange?: (value: string | null) => void; 25 | filterItems?: ( 26 | inputValue: string, 27 | items: ComboboxItemBase[] 28 | ) => ComboboxItemBase[]; 29 | }>; 30 | 31 | export const Combobox = ({ 32 | value, 33 | onValueChange, 34 | filterItems = defaultFilter, 35 | children, 36 | }: ComboboxProps) => { 37 | const [items, setItems] = useState([]), 38 | [filteredItems, setFilteredItems] = useState(items); 39 | const [openedOnce, setOpenedOnce] = useState(false); 40 | 41 | const stateReducer = useCallback< 42 | NonNullable['stateReducer']> 43 | >( 44 | (prev, { type, changes }) => { 45 | switch (type) { 46 | case stateChangeTypes.InputChange: { 47 | const filteredEnabledItems = filterItems( 48 | changes.inputValue || prev.inputValue, 49 | items, 50 | ).filter(({ disabled }) => !disabled); 51 | const highlightedIndex 52 | = typeof changes.highlightedIndex === 'number' 53 | ? changes.highlightedIndex 54 | : prev.highlightedIndex; 55 | 56 | return { 57 | ...changes, 58 | highlightedIndex: 59 | changes.inputValue 60 | && filteredEnabledItems.length > 0 61 | && highlightedIndex < 0 62 | ? 0 63 | : changes.highlightedIndex, 64 | }; 65 | } 66 | 67 | case stateChangeTypes.InputBlur: 68 | case stateChangeTypes.InputClick: 69 | case stateChangeTypes.InputKeyDownEnter: 70 | case stateChangeTypes.InputKeyDownEscape: { 71 | if (changes.isOpen || !prev.isOpen) 72 | return { 73 | ...changes, 74 | inputValue: prev.inputValue, 75 | selectedItem: prev.selectedItem, 76 | }; 77 | if (!prev.inputValue && prev.highlightedIndex < 0) 78 | return { ...changes, inputValue: '', selectedItem: null }; 79 | 80 | const _inputValue 81 | = changes.selectedItem?.label || prev.selectedItem?.label || ''; 82 | return { ...changes, inputValue: _inputValue }; 83 | } 84 | 85 | default: 86 | return changes; 87 | } 88 | }, 89 | [filterItems, items], 90 | ); 91 | 92 | const { 93 | getInputProps, 94 | getItemProps, 95 | getMenuProps, 96 | highlightedIndex, 97 | inputValue, 98 | isOpen, 99 | selectedItem, 100 | selectItem, 101 | setInputValue, 102 | } = useCombobox({ 103 | items: filteredItems, 104 | itemToString: item => (item ? item.label : ''), 105 | isItemDisabled: item => item.disabled ?? false, 106 | 107 | selectedItem: 108 | typeof value !== 'undefined' 109 | ? items.find(item => item.value === value) || null 110 | : undefined, 111 | onSelectedItemChange: ({ selectedItem }) => 112 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 113 | onValueChange?.(selectedItem?.value || null), 114 | 115 | stateReducer, 116 | }); 117 | 118 | useEffect(() => { 119 | if (isOpen && !openedOnce) setOpenedOnce(isOpen); 120 | }, [isOpen, openedOnce]); 121 | 122 | useEffect(() => { 123 | setFilteredItems(filterItems(inputValue, items)); 124 | }, [filterItems, inputValue, items]); 125 | 126 | return ( 127 | 145 | {children} 146 | 147 | ); 148 | }; 149 | -------------------------------------------------------------------------------- /src/actions/translation.ts: -------------------------------------------------------------------------------- 1 | import L from 'src/L'; 2 | import type { Action, HandlerParams } from 'src/types'; 3 | 4 | export const bingTranslation: Action = { 5 | id: 'bing-translation', 6 | version: 0, 7 | icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IndoaXRlIiBkPSJNMy4yIDBBMy4xOTMgMy4xOTMgMCAwIDAgMCAzLjJ2MTcuNkMwIDIyLjU3MyAxLjQyNyAyNCAzLjIgMjRoMTcuNmMxLjc3MyAwIDMuMi0xLjQyNyAzLjItMy4yVjMuMkMyNCAxLjQyNyAyMi41NzMgMCAyMC44IDB6bTExLjExMyA0LjAwMmwuMDQ5LjAwMmMxLjA1Mi4yMDkgMS40MzguMzc2IDEuNDM4LjYyYS4yLjIgMCAwIDEtLjAwOC4wNDRsLS4wNTguMTljLS4wNDMuMTM3LS4xMS4zNDUtLjE4My42MDZxLjQ1LS4xNi43NjYtLjQwN2MuNDI2LS4zMzIuODA4LS40MzcgMS4wNTktLjI5N2EuNDEuNDEgMCAwIDEgLjIuNDM1Yy0uMDMzLjI3Ny0uMzIyLjY3MS0xLjAxNy44NmMtLjQyMi4xMTUtLjgyMS4yLTEuMjE1LjI2NmE5IDkgMCAwIDAtLjE5MiAxLjU4YTUuNiA1LjYgMCAwIDEgMS4wMy0uMjY2Yy0uMTEyLS4xMDUtLjMyOC0uMjIzLS41NTQtLjM0NmMtLjA2LS4wMzQtLjIwNS0uMTEyLS4yMDUtLjI2YzAtLjAxNi4wMDYtLjA2Mi4wMTQtLjA3OGMuMDQtLjEzNi4yMDMtLjE1Mi4zMjItLjE1MnEuMDg0LS4wMDIuMjE2LjAxNWMuNjguMDMzIDEuMTAyLjE5IDEuMjg4LjQ3NGEuNTQuNTQgMCAwIDEgLjA4OC4yOTZ2LjAxM2MuNzY2LjA4MSAxLjQxMi4zNzQgMS44My44MzZjLjMzMy4zNjkuNTA5LjgzMi41MDggMS4zNHEwIC4wNjMtLjAwNi4xMjdjLS4xMiAyLjE3OC0yLjk0MiAyLjk5Ni0zLjA2MiAzLjAzYS4zLjMgMCAwIDEtLjA1Mi4wMDdhLjQuNCAwIDAgMS0uMDY3LS4wMTJjLS4xMDItLjA0LS4zMDYtLjEyLS4zMDYtLjMwM2MuMDEyLS4xMi4wOC0uMjM5LjMzNi0uMjY4Yy0uMDAzLS4wMDMgMi4zMTEtLjg3NyAyLjMxMS0yLjNxMC0uMDg4LS4wMS0uMTgzYy0uMTQyLTEuMDE3LS43MDUtMS41NDgtMS43MjItMS42MjFjLS40MTYuOTU5LS44NjIgMS43NjYtMS4zMjcgMi40MDRjLjE0NS4yMjMuMzA3LjQyMi40ODIuNTg2YS4xOTUuMTk1IDAgMCAxIC4wMzUuMjM3YS40NS40NSAwIDAgMS0uMzE3LjIxOGMtLjIwMy4wMzMtLjQyMS0uMDcyLS42NTctLjMwNnEtLjA0LS4wNDItLjA4LS4wODdjLS44MjIuODY3LTEuNTM1IDEuMDUzLTEuOTkyIDEuMDUzYTEuNCAxLjQgMCAwIDEtLjYxLS4xMjhjLS4yNTgtLjEzMy0uNTQtLjU1Ni0uNTQyLTEuMTdjMC0uNjIuMjg1LTEuNzkyIDIuMTg4LTIuNzhhMTAgMTAgMCAwIDEtLjAxMy0uNDg1YzAtLjQxMS4wMjUtLjg3LjA2OC0xLjM2NmE4IDggMCAwIDEtMS4xOS0uMDMzYS4yLjIgMCAwIDEtLjA3LS4wMjJhMS43IDEuNyAwIDAgMS0uNTc4LS40ODNsLS4wOC0uMDk1Yy0uMDg3LS4xLS4xODYtLjIxNS0uMTM0LS4zNjFjLjAxOS0uMDUzLjA3NS0uMTQ0LjIzNy0uMTY3Yy0uMDA1IDAgLjI0LS4wNDEuNDMyLjExYy4wNS4wMzcuMDkuMDgzLjEzNi4xMzNjLjA4My4wOTQuMTQzLjE2My4yMTYuMTcyYy4wNjUuMDA5LjI5Ny4wMzQuNjIyLjAzNHEuMjQ4LS4wMDEuNDg4LS4wMnEuMDU3LS40Ni4xNDItLjk2N2EuNC40IDAgMCAwLS4yMTktLjIwOWMtLjE1Ny0uMDYyLS4zMi0uMTI4LS4zMi0uMjg3YzAtLjAxLjAwMi0uMDMyLjAwNS0uMDRjLjAxOS0uMTU4LjE4OC0uMTc4LjI4LS4xOVptLTguOTcgMS43NzJsMS42NDggMS42NTJjMS42ODMtMS4xNiAzLjg4Ny0uODA3IDUuMDEzLS4wNjZjLTEuMjA2LjA4OC0yLjU5LjgwMi0zLjIzOSAxLjg0NmwxLjgwOCAxLjgxM2gtNS4yM3ptMTAuODEyIDIuNTMzYTUuNyA1LjcgMCAwIDAtLjk5NS4yNjljLjAyNy40MTQuMDk3LjgwMi4yMDUgMS4xNTZjLjM5LS42MDUuNjgxLTEuMTU4Ljc2NS0xLjM2NnEuMDE0LS4wMy4wMjUtLjA1OW0tMS44MDcuNjc0Yy0uNzU3LjUyOC0xLjQxMyAxLjUxOS0xLjQxMyAyLjE3OGwuMDAxLjA1OWMuMDE0LjIyNy4xMTcuMzc1LjMyMy40NThjLjMyNS4xMjguODY4LS4yNzEgMS41MTItMS4wOTljLS4yMDQtLjQzNS0uMzQ2LS45NzEtLjQyMy0xLjU5Nm0tNi40NyAzLjQzNHExLjIzNCAwIDEuOTA3LjUxMnEuNjc0LjUxMS42NzQgMS43NDRxLS4wMDEuMzI2LS4wMzIgMS4xODRxLS4wMzEuODYtLjAzMiAxLjE0NnEuMDAyIDEuMjY3LjE4OSAyLjI1NmwtMS4wODkuMDQxYTIuNyAyLjcgMCAwIDEtLjA4My0uNjQ3aC0uMDdxLS44NTQuNzgtMi4zMjUuNzhxLS43OTggMC0xLjM3NC0uNDY5Yy0uMzgzLS4zMTQtLjU3Ni0uODc4LS41NzYtMS40NTljMC0uNzE1LjE5Ni0xLjMzLjc0NC0xLjgzMmMuNjU0LS41OTggMi4wMTYtLjg2OCAzLjUwOS0uNDU4Yy4xMDguMDMuMDIyLS4yNC4wMjItLjM4M3EtLjAwMS0uODU3LS40NTUtMS4xNDJ0LTEuMzc3LS4yODVjLS43MjggMC0uNjY2LjE0Ny0xLjM3LjMyN2wtLjE3Ny0uOTZjLjY5NS0uMTk5IDEuMDEyLS4zNTUgMS45MTQtLjM1NVptNS4yODIgMS44MDZoNS4yMjh2NS4yNDZsLTEuNjQ1LTEuNjUyYy0xLjY4NSAxLjE2LTMuODg4LjgwNi01LjAxNS4wNjVjMS4yMDYtLjA4NyAyLjU5MS0uOCAzLjI0MS0xLjg0NXptLTUuMDM4IDEuNjI0YTIuNiAyLjYgMCAwIDAtLjg5NS4xNzNjLS41NTEuMjQtMS4wNTYuNTU4LTEuMDMgMS4zNzNjLjAxOS42My40NDUgMS4wODYgMS4zMzQgMS4wODZxMS4xNDMgMCAxLjgyNS0uNTcxYTU5IDU5IDAgMCAxLS4wMzYtMS45YTMuNyAzLjcgMCAwIDAtMS4xOTgtLjE2WiIvPjwvc3ZnPg==', 8 | name: 'bing translation', 9 | desc: L.actions.bingTranslation(), 10 | test: '.+', 11 | handler: ({ selection }: HandlerParams) => { 12 | window.open(`https://www.bing.com/translator/?text=${encodeURIComponent(selection)}`); 13 | }, 14 | }; 15 | 16 | export const googleTranslation: Action = { 17 | id: 'google-translation', 18 | version: 0, 19 | icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IndoaXRlIiBkPSJtMTIgMjJsLTEtM0g0cS0uODI1IDAtMS40MTItLjU4N1QyIDE3VjRxMC0uODI1LjU4OC0xLjQxMlQ0IDJoNmwuODc1IDNIMjBxLjg3NSAwIDEuNDM4LjU2M1QyMiA3djEzcTAgLjgyNS0uNTYyIDEuNDEzVDIwIDIyem0tNC44NS03LjRxMS43MjUgMCAyLjgzOC0xLjExMlQxMS4xIDEwLjZxMC0uMi0uMDEyLS4zNjJ0LS4wNjMtLjMzOGgtMy45NXYxLjU1SDkuM3EtLjIuNy0uNzYzIDEuMDg4dC0xLjM2Mi4zODdxLS45NzUgMC0xLjY3NS0uN1Q0LjggMTAuNXQuNy0xLjcyNXQxLjY3NS0uN3EuNDUgMCAuODUuMTYzdC43MjUuNDg3TDkuOTc1IDcuNTVROS40NSA3IDguNzEyIDYuN1Q3LjE1IDYuNHEtMS42NzUgMC0yLjg2MyAxLjE4OFQzLjEgMTAuNXQxLjE4OCAyLjkxM1Q3LjE1IDE0LjZtNi43LjVsLjU1LS41MjVxLS4zNS0uNDI1LS42MzctLjgyNXQtLjU2My0uODV6bTEuMjUtMS4yNzVxLjctLjgyNSAxLjA2My0xLjU3NXQuNDg3LTEuMTc1aC0zLjk3NWwuMyAxLjA1aDFxLjIuMzc1LjQ3NS44MTN0LjY1Ljg4N00xMyAyMWg3cS40NSAwIC43MjUtLjI4OFQyMSAyMFY3cTAtLjQ1LS4yNzUtLjcyNVQyMCA2aC04LjgyNWwxLjE3NSA0LjA1aDEuOTc1VjloMS4wMjV2MS4wNUgxOXYxLjAyNWgtMS4yNzVxLS4yNS45NS0uNzUgMS44NVQxNS44IDE0LjZsMi43MjUgMi42NzVMMTcuOCAxOGwtMi43LTIuN2wtLjkuOTI1TDE1IDE5eiIvPjwvc3ZnPg==', 20 | name: 'google translation', 21 | desc: L.actions.googleTranslation(), 22 | test: '.+', 23 | handler: ({ selection }: HandlerParams) => { 24 | window.open(`https://translate.google.com/?sl=auto&op=translate&text=${encodeURIComponent(selection)}`); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/cm-extension.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-type-assertions */ 2 | import type { EditorView, ViewUpdate } from '@codemirror/view'; 3 | import { ViewPlugin } from '@codemirror/view'; 4 | import type { Editor, EditorPosition } from 'obsidian'; 5 | import type PopkitPlugin from './Plugin'; 6 | import PopoverManager, { clearPopover } from './render'; 7 | 8 | export function popoverPlugin(plugin: PopkitPlugin) { 9 | return ViewPlugin.fromClass(class { 10 | private isMouseSelection: boolean = false; 11 | private mouseDown: boolean = false; 12 | 13 | constructor(private view: EditorView) { 14 | // 监听鼠标事件 15 | this.view.dom.addEventListener('mousedown', () => { 16 | this.mouseDown = true; 17 | }); 18 | 19 | this.view.dom.addEventListener('mousemove', () => { 20 | if (this.mouseDown) { 21 | this.isMouseSelection = true; 22 | } 23 | }); 24 | 25 | this.view.dom.addEventListener('mouseup', () => { 26 | this.mouseDown = false; 27 | }); 28 | 29 | // 监听键盘事件,重置鼠标选择状态 30 | this.view.dom.addEventListener('keydown', () => { 31 | this.isMouseSelection = false; 32 | this.mouseDown = false; 33 | }); 34 | } 35 | 36 | createEditor(selectedText: string, selection: { from: number; to: number; }): Editor { 37 | const offsetToPos = (offset: number): EditorPosition => { 38 | const clampedOffset = Math.min(Math.max(0, offset), this.view.state.doc.length); 39 | const line = this.view.state.doc.lineAt(clampedOffset); 40 | return { 41 | line: line.number - 1, 42 | ch: clampedOffset - line.from, 43 | } as EditorPosition; 44 | }; 45 | 46 | const posToOffset = (pos: EditorPosition): number => { 47 | if (pos.line < 0) return 0; 48 | const line = this.view.state.doc.line(pos.line + 1); 49 | if (pos.ch < 0) { 50 | return line.from + Math.max(0, line.length + pos.ch); 51 | } 52 | return line.from + Math.min(pos.ch, line.length); 53 | }; 54 | 55 | return { 56 | somethingSelected: () => true, 57 | getSelection: () => selectedText, 58 | getCursor: () => { 59 | const pos = this.view.state.selection.main.head; 60 | const line = this.view.state.doc.lineAt(pos); 61 | return { 62 | line: line.number - 1, 63 | ch: pos - line.from, 64 | } as EditorPosition; 65 | }, 66 | posAtDOM: (x: number, y: number): number => { 67 | const pos = this.view.posAtCoords({ x, y }); 68 | return pos ?? selection.to; 69 | }, 70 | containerEl: this.view.dom, 71 | coordsAtPos: (pos: EditorPosition) => { 72 | const offset = posToOffset(pos); 73 | return this.view.coordsAtPos(offset); 74 | }, 75 | posToOffset, 76 | offsetToPos, 77 | cm: this.view, 78 | getValue: () => { 79 | return this.view.state.doc.toString(); 80 | }, 81 | replaceSelection: (replacement: string) => { 82 | const transaction = this.view.state.update({ 83 | changes: { 84 | from: selection.from, 85 | to: selection.to, 86 | insert: replacement, 87 | }, 88 | }); 89 | this.view.dispatch(transaction); 90 | }, 91 | getLine: (n: number) => { 92 | return this.view.state.doc.line(n + 1).text; 93 | }, 94 | setLine: (n: number, text: string) => { 95 | const line = this.view.state.doc.line(n + 1); 96 | const transaction = this.view.state.update({ 97 | changes: { 98 | from: line.from, 99 | to: line.to, 100 | insert: text, 101 | }, 102 | }); 103 | this.view.dispatch(transaction); 104 | }, 105 | getRange: (from: EditorPosition, to: EditorPosition) => { 106 | const fromOffset = posToOffset(from); 107 | const toOffset = posToOffset(to); 108 | return this.view.state.sliceDoc(fromOffset, toOffset); 109 | }, 110 | replaceRange: (replacement: string, from: EditorPosition, to: EditorPosition) => { 111 | const fromOffset = posToOffset(from); 112 | const toOffset = posToOffset(to); 113 | const transaction = this.view.state.update({ 114 | changes: { 115 | from: fromOffset, 116 | to: toOffset, 117 | insert: replacement, 118 | }, 119 | }); 120 | this.view.dispatch(transaction); 121 | }, 122 | } as unknown as Editor; 123 | } 124 | 125 | update = (update: ViewUpdate): void => { 126 | // 检查是否有真正的选择变化 127 | const hasSelectionChange = update.transactions.some(tr => tr.selection); 128 | 129 | if (hasSelectionChange) { 130 | const selection = this.view.state.selection.main; 131 | const selectedText = selection.empty ? '' : this.view.state.sliceDoc(selection.from, selection.to); 132 | // 如果有选中内容,并且满足鼠标选择的要求 133 | if (selectedText && (!plugin.settings.mouseSelectionOnly || this.isMouseSelection)) { 134 | const editor = this.createEditor(selectedText, selection); 135 | new PopoverManager(editor, plugin.app, plugin.settings); 136 | } 137 | else { 138 | clearPopover(); 139 | } 140 | // 如果是键盘选择,重置鼠标选择状态 141 | if (!this.isMouseSelection) { 142 | this.mouseDown = false; 143 | } 144 | } 145 | }; 146 | }); 147 | } 148 | -------------------------------------------------------------------------------- /src/components/setting/action-types/hotkeys.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @stylistic/indent */ 2 | import type { FC } from 'react'; 3 | import { useState, useCallback, useRef, useEffect } from 'react'; 4 | import { X, PlusCircle } from 'lucide-react'; 5 | import L from 'src/L'; 6 | import { Platform } from 'obsidian'; 7 | 8 | interface HotkeysFormProps { 9 | hotkey: string; 10 | onChange: (hotkey: string) => void; 11 | } 12 | 13 | const HotkeysForm: FC = ({ 14 | hotkey, 15 | onChange, 16 | }) => { 17 | const [recording, setRecording] = useState(false); 18 | const [currentHotkey, setCurrentHotkey] = useState(''); 19 | const keysPressed = useRef>(new Set()); 20 | 21 | const generateHotkeyString = useCallback((keys: string[]) => { 22 | const modifiers: string[] = []; 23 | const mainKeys: string[] = []; 24 | 25 | // 先处理修饰键,按固定顺序 26 | if (Platform.isMacOS) { 27 | if (keys.includes('Meta')) modifiers.push('⌘'); 28 | if (keys.includes('Control')) modifiers.push('Ctrl'); 29 | if (keys.includes('Alt')) modifiers.push('⌥'); 30 | if (keys.includes('Shift')) modifiers.push('⇧'); 31 | } 32 | else { 33 | if (keys.includes('Control')) modifiers.push('Ctrl'); 34 | if (keys.includes('Alt')) modifiers.push('Alt'); 35 | if (keys.includes('Shift')) modifiers.push('Shift'); 36 | if (keys.includes('Meta')) modifiers.push('Win'); 37 | } 38 | 39 | // 处理其他键 40 | for (const key of keys) { 41 | if (!['Meta', 'Control', 'Alt', 'Shift'].includes(key)) { 42 | mainKeys.push(key); 43 | } 44 | } 45 | 46 | return [...modifiers, ...mainKeys].join(' '); 47 | }, []); 48 | 49 | const updateHotkey = useCallback(() => { 50 | const keys = Array.from(keysPressed.current); 51 | const newHotkey = generateHotkeyString(keys); 52 | if (newHotkey) { 53 | setCurrentHotkey(newHotkey); 54 | } 55 | }, [generateHotkeyString]); 56 | 57 | useEffect(() => { 58 | if (!recording) return; 59 | 60 | const handleKeyDown = (e: KeyboardEvent) => { 61 | e.preventDefault(); 62 | e.stopPropagation(); 63 | e.stopImmediatePropagation(); 64 | 65 | // 添加修饰键 66 | if (e.metaKey) keysPressed.current.add('Meta'); 67 | if (e.ctrlKey) keysPressed.current.add('Control'); 68 | if (e.altKey) keysPressed.current.add('Alt'); 69 | if (e.shiftKey) keysPressed.current.add('Shift'); 70 | 71 | // 添加主键(非修饰键) 72 | if (!['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) { 73 | keysPressed.current.add(e.key); 74 | } 75 | 76 | updateHotkey(); 77 | }; 78 | 79 | const handleKeyUp = (e: KeyboardEvent) => { 80 | e.preventDefault(); 81 | e.stopPropagation(); 82 | e.stopImmediatePropagation(); 83 | 84 | // 在第一个按键释放时结束录制 85 | const finalHotkey = generateHotkeyString(Array.from(keysPressed.current)); 86 | if (finalHotkey) { 87 | onChange(finalHotkey); 88 | } 89 | 90 | setRecording(false); 91 | keysPressed.current.clear(); 92 | window.removeEventListener('keydown', handleKeyDown, true); 93 | window.removeEventListener('keyup', handleKeyUp, true); 94 | }; 95 | 96 | window.addEventListener('keydown', handleKeyDown, true); 97 | window.addEventListener('keyup', handleKeyUp, true); 98 | 99 | return () => { 100 | window.removeEventListener('keydown', handleKeyDown, true); 101 | window.removeEventListener('keyup', handleKeyUp, true); 102 | keysPressed.current.clear(); 103 | }; 104 | }, [recording, onChange, updateHotkey, generateHotkeyString]); 105 | 106 | const startRecording = useCallback(() => { 107 | setRecording(true); 108 | setCurrentHotkey(''); 109 | keysPressed.current.clear(); 110 | }, []); 111 | 112 | const cancelRecording = useCallback(() => { 113 | setRecording(false); 114 | setCurrentHotkey(''); 115 | keysPressed.current.clear(); 116 | }, []); 117 | 118 | return ( 119 |
    120 |
    121 |
    {L.setting.hotkeysLabel()}
    122 |
    123 | {L.setting.hotkeysDesc()} 124 |
    125 |
    126 |
    127 |
    128 | {recording 129 | ? ( 130 | 131 | {currentHotkey || 'Press hotkey...'} 132 | 133 | ) 134 | : hotkey 135 | ? ( 136 | 137 | {hotkey} 138 | { onChange(''); }} 142 | > 143 | 144 | 145 | 146 | ) 147 | : ( 148 | 149 | {L.setting.notSet()} 150 | 151 | )} 152 |
    153 | {!recording && !hotkey && ( 154 | 159 | 160 | 161 | )} 162 | {recording && ( 163 | 168 | 169 | 170 | )} 171 |
    172 |
    173 | ); 174 | }; 175 | 176 | export default HotkeysForm; 177 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind components; 2 | @tailwind utilities; 3 | 4 | :root { 5 | --pk-background: 0 0% 100%; 6 | --pk-foreground: 222.2 47.4% 11.2%; 7 | --pk-muted: 210 40% 96.1%; 8 | --pk-muted-foreground: 215.4 16.3% 46.9%; 9 | --pk-popover: 0 0% 100%; 10 | --pk-popover-foreground: 222.2 47.4% 11.2%; 11 | --pk-border: 214.3 31.8% 91.4%; 12 | --pk-input: 214.3 31.8% 91.4%; 13 | --pk-card: 0 0% 100%; 14 | --pk-card-foreground: 222.2 47.4% 11.2%; 15 | --pk-primary: 222.2 47.4% 11.2%; 16 | --pk-primary-foreground: 210 40% 98%; 17 | --pk-secondary: 210 40% 96.1%; 18 | --pk-secondary-foreground: 222.2 47.4% 11.2%; 19 | --pk-accent: 210 40% 96.1%; 20 | --pk-accent-foreground: 222.2 47.4% 11.2%; 21 | --pk-destructive: 0 100% 50%; 22 | --pk-destructive-foreground: 210 40% 98%; 23 | --pk-ring: 215 20.2% 65.1%; 24 | --pk-radius: 0.5rem; 25 | } 26 | 27 | .dark { 28 | --pk-background: 224 71% 4%; 29 | --pk-foreground: 213 31% 91%; 30 | --pk-muted: 223 47% 11%; 31 | --pk-muted-foreground: 215.4 16.3% 56.9%; 32 | --pk-accent: 216 34% 17%; 33 | --pk-accent-foreground: 210 40% 98%; 34 | --pk-popover: 224 71% 4%; 35 | --pk-popover-foreground: 215 20.2% 65.1%; 36 | --pk-border: 216 34% 17%; 37 | --pk-input: 216 34% 17%; 38 | --pk-card: 224 71% 4%; 39 | --pk-card-foreground: 213 31% 91%; 40 | --pk-primary: 210 40% 98%; 41 | --pk-primary-foreground: 222.2 47.4% 1.2%; 42 | --pk-secondary: 222.2 47.4% 11.2%; 43 | --pk-secondary-foreground: 210 40% 98%; 44 | --pk-destructive: 0 63% 31%; 45 | --pk-destructive-foreground: 210 40% 98%; 46 | --pk-ring: 216 34% 17%; 47 | } 48 | 49 | .popkit-popover .popkit-container { 50 | border-radius: 6px; 51 | background-color: #000; 52 | overflow: hidden; 53 | max-width: 100%; 54 | left: 0; 55 | top: 0; 56 | } 57 | 58 | .popkit-popover .popkit-container.popkit-normal { 59 | position: absolute; 60 | display: block; 61 | z-index: 10; 62 | } 63 | 64 | .popkit-popover .popkit-container.popkit-setting { 65 | position: static; 66 | display: flex; 67 | flex-wrap: wrap; 68 | } 69 | 70 | .popkit-popover .popkit-container ul { 71 | display: flex; 72 | max-width: 100%; 73 | flex-wrap: wrap; 74 | list-style: none; 75 | padding: 0 !important; 76 | margin: 0; 77 | } 78 | 79 | .popkit-popover .popkit-container li { 80 | list-style: none; 81 | padding: 0 !important; 82 | margin: 0; 83 | display: flex; 84 | } 85 | 86 | .popkit-popover .popkit-container li::before { 87 | content: none !important; 88 | } 89 | 90 | .popkit-popover .popkit-divider { 91 | width: 1px; 92 | margin: 6px 4px; 93 | background-color: #fff; 94 | height: 16px; 95 | } 96 | 97 | .popkit-popover .popkit-alert { 98 | font-size: 12px; 99 | padding: 0 12px; 100 | line-height: 28px; 101 | } 102 | 103 | .popkit-setting-section .popkit-item, 104 | .popkit-popover .popkit-item { 105 | padding: 4px 6px; 106 | transition: all 200ms ease-in-out; 107 | opacity: 0.8; 108 | cursor: pointer; 109 | background-color: #000; 110 | font-family: var(--font-default); 111 | color: #fff; 112 | height: 28px; 113 | font-size: 16px; 114 | line-height: 20px; 115 | border-radius: 4px; 116 | } 117 | 118 | .popkit-setting-section .popkit-item:hover 119 | .popkit-popover .popkit-item:hover { 120 | opacity: 1; 121 | background-color: var(--color-blue); 122 | } 123 | 124 | .popkit-setting-section .popkit-item-image, 125 | .popkit-popover .popkit-item-image { 126 | background-size: contain; 127 | background-position: center center; 128 | background-repeat: no-repeat; 129 | height: 20px; 130 | width: 20px; 131 | } 132 | 133 | .popkit-setting-section .popkit-item-text, 134 | .popkit-popover .popkit-item-text { 135 | max-width: 100px; 136 | overflow: hidden; 137 | text-overflow: ellipsis; 138 | white-space: nowrap; 139 | } 140 | 141 | .popkit-setting-form { 142 | display: flex; 143 | flex-direction: column; 144 | padding: 20px; 145 | border-radius: 6px; 146 | border: 1px solid var(--background-modifier-border); 147 | } 148 | 149 | .popkit-setting-form-icon-container { 150 | background-color: #000; 151 | width: 24px; 152 | height: 24px; 153 | border-radius: 6px; 154 | padding: 2px; 155 | } 156 | 157 | .popkit-setting-section { 158 | position: relative; 159 | border: 1px solid var(--background-modifier-border); 160 | border-radius: 8px; 161 | padding: 20px; 162 | margin-bottom: 20px; 163 | } 164 | 165 | .popkit-setting-section h3 { 166 | margin-top: 0; 167 | } 168 | 169 | .popkit-setting-section p { 170 | opacity: 0.6; 171 | margin: 0; 172 | } 173 | 174 | .popkit-setting-actions-container { 175 | display: flex; 176 | flex-wrap: wrap; 177 | gap: 10px; 178 | } 179 | 180 | .popkit-setting-droppable-area { 181 | margin: 20px 0; 182 | } 183 | 184 | .popkit-setting-delete-area { 185 | height: 40px; 186 | border: 1px dashed #ccc; 187 | border-radius: 4px; 188 | margin-top: 20px; 189 | display: flex; 190 | justify-content: center; 191 | align-items: center; 192 | color: #ccc; 193 | transition: all 200ms ease-in-out; 194 | } 195 | 196 | .popkit-setting-delete-area.popkit-setting-delete-area-highlight { 197 | border-color: red; 198 | color: red; 199 | } 200 | 201 | .popkit-setting-add { 202 | flex-grow: 1; 203 | min-width: 20px; 204 | height: 28px; 205 | } 206 | 207 | .popkit-sortable-over { 208 | position: relative; 209 | opacity: 0.5; 210 | transition: all 0.2s ease; 211 | padding-left: 4px; 212 | } 213 | 214 | .popkit-sortable-over::after { 215 | content: ''; 216 | position: absolute; 217 | left: 0px; 218 | top: -4px; 219 | bottom: -4px; 220 | border: 2px solid var(--interactive-accent); 221 | border-radius: 6px; 222 | pointer-events: none; 223 | } 224 | 225 | .popkit-setting .popkit-sortable-over { 226 | transform: scale(1.02); 227 | transition: transform 0.2s ease; 228 | } 229 | 230 | .popkit-drag-overlay { 231 | position: fixed !important; 232 | pointer-events: none; 233 | z-index: 9999 !important; 234 | left: 0; 235 | top: 0; 236 | width: 100%; 237 | height: 100%; 238 | } 239 | 240 | .popkit-drag-overlay .popkit-item { 241 | opacity: 0.7; 242 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); 243 | } 244 | 245 | .popkit-drag-overlay .popkit-divider { 246 | opacity: 0.7; 247 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); 248 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import stylistic from '@stylistic/eslint-plugin'; 2 | import eslint from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | 5 | export default tseslint.config({ 6 | extends: [ 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommendedTypeChecked, 9 | stylistic.configs['recommended-flat'], 10 | ], 11 | plugins: { 12 | '@stylistic': stylistic, 13 | }, 14 | languageOptions: { 15 | parserOptions: { 16 | project: true, 17 | tsconfigRootDir: import.meta.dirname, 18 | }, 19 | }, 20 | }, 21 | { 22 | ignores: [ 23 | 'main.js', 24 | 'typings/**', 25 | ], 26 | }, 27 | { 28 | 29 | rules: { 30 | 'no-unused-vars': 'off', 31 | 'no-dupe-class-members': 'off', 32 | 'no-loop-func': 'off', 33 | 'no-shadow': 'off', 34 | 'no-unused-expressions': 'off', 35 | 'no-use-before-define': 'off', 36 | 'no-throw-literal': 'off', 37 | 'prefer-destructuring': 'off', 38 | 39 | '@stylistic/ban-ts-comment': 'off', 40 | '@stylistic/no-prototype-builtins': 'off', 41 | '@stylistic/no-empty-function': 'off', 42 | '@stylistic/semi': ['error', 'always'], 43 | '@stylistic/arrow-parens': ['error', 'as-needed'], 44 | '@stylistic/array-bracket-newline': 'error', 45 | '@stylistic/array-element-newline': ['error', 'consistent'], 46 | '@stylistic/function-call-argument-newline': ['error', 'consistent'], 47 | '@stylistic/function-call-spacing': ['error', 'never'], 48 | '@stylistic/generator-star-spacing': ['error', 'before'], 49 | '@stylistic/max-len': [ 50 | 'error', { 51 | code: 120, 52 | ignoreComments: true, 53 | ignoreUrls: true, 54 | ignoreStrings: true, 55 | ignoreTemplateLiterals: true, 56 | ignoreRegExpLiterals: true, 57 | ignoreTrailingComments: true, 58 | }, 59 | ], 60 | '@stylistic/linebreak-style': ['error', 'unix'], 61 | '@stylistic/no-confusing-arrow': 'error', 62 | '@stylistic/no-extra-semi': 'error', 63 | '@stylistic/object-curly-newline': [ 64 | 'error', { 65 | multiline: true, 66 | consistent: true, 67 | }, 68 | ], 69 | '@stylistic/one-var-declaration-per-line': ['error', 'always'], 70 | '@stylistic/semi-style': ['error', 'last'], 71 | '@stylistic/switch-colon-spacing': ['error', { after: true, before: false }], 72 | '@stylistic/jsx-pascal-case': [ 73 | 'error', { 74 | allowAllCaps: false, 75 | allowLeadingUnderscore: false, 76 | allowNamespace: true, 77 | }, 78 | ], 79 | '@stylistic/jsx-props-no-multi-spaces': 'error', 80 | '@stylistic/jsx-self-closing-comp': [ 81 | 'error', { 82 | component: true, 83 | html: false, 84 | }, 85 | ], 86 | '@stylistic/jsx-sort-props': [ 87 | 'error', { 88 | callbacksLast: true, 89 | shorthandFirst: true, 90 | multiline: 'first', 91 | noSortAlphabetically: true, 92 | reservedFirst: true, 93 | }, 94 | ], 95 | '@stylistic/member-delimiter-style': [ 96 | 'error', { 97 | multiline: { 98 | delimiter: 'semi', 99 | requireLast: true, 100 | }, 101 | multilineDetection: 'brackets', 102 | singleline: { 103 | delimiter: 'semi', 104 | requireLast: true, 105 | }, 106 | }, 107 | ], 108 | '@typescript-eslint/no-floating-promises': 'off', 109 | '@stylistic/object-property-newline': [ 110 | 'error', { 111 | allowAllPropertiesOnSameLine: true, 112 | }, 113 | ], 114 | '@typescript-eslint/no-unused-vars': ['error', { args: 'none' }], 115 | '@typescript-eslint/adjacent-overload-signatures': 'error', 116 | '@typescript-eslint/consistent-indexed-object-style': 'error', 117 | '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }], 118 | '@typescript-eslint/consistent-type-definitions': 'error', 119 | '@typescript-eslint/consistent-type-exports': ['error', { fixMixedExportsWithInlineTypeSpecifier: false }], 120 | '@typescript-eslint/consistent-type-imports': [ 121 | 'error', { 122 | prefer: 'type-imports', 123 | fixStyle: 'separate-type-imports', 124 | disallowTypeAnnotations: true, 125 | }, 126 | ], 127 | '@typescript-eslint/member-ordering': ['warn'], 128 | '@typescript-eslint/no-array-delete': 'error', 129 | '@typescript-eslint/no-confusing-non-null-assertion': 'error', 130 | '@typescript-eslint/no-confusing-void-expression': 'error', 131 | '@typescript-eslint/no-dupe-class-members': 'error', 132 | '@typescript-eslint/no-dynamic-delete': 'error', 133 | '@typescript-eslint/no-empty-interface': 'error', 134 | '@typescript-eslint/no-empty-object-type': 'error', 135 | '@typescript-eslint/no-invalid-void-type': 'error', 136 | '@typescript-eslint/no-loop-func': 'error', 137 | '@typescript-eslint/no-meaningless-void-operator': 'error', 138 | '@typescript-eslint/no-mixed-enums': 'error', 139 | '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', 140 | '@typescript-eslint/no-require-imports': 'error', 141 | '@typescript-eslint/no-shadow': [ 142 | 'error', 143 | { 144 | builtinGlobals: false, 145 | hoist: 'all', 146 | allow: [], 147 | ignoreTypeValueShadow: false, 148 | ignoreFunctionTypeParameterNameValueShadow: false, 149 | }, 150 | ], 151 | '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error', 152 | '@typescript-eslint/no-unnecessary-condition': 'error', 153 | '@typescript-eslint/no-unnecessary-qualifier': 'error', 154 | '@typescript-eslint/no-unnecessary-template-expression': 'error', 155 | '@typescript-eslint/no-unused-expressions': [ 156 | 'error', { 157 | allowShortCircuit: true, 158 | allowTernary: true, 159 | allowTaggedTemplates: true, 160 | }, 161 | ], 162 | '@typescript-eslint/no-use-before-define': 'error', 163 | '@typescript-eslint/no-useless-empty-export': 'error', 164 | '@typescript-eslint/only-throw-error': 'error', 165 | '@typescript-eslint/prefer-destructuring': 'error', 166 | '@typescript-eslint/prefer-enum-initializers': 'error', 167 | '@typescript-eslint/prefer-find': 'error', 168 | '@typescript-eslint/prefer-for-of': 'error', 169 | '@typescript-eslint/prefer-includes': 'error', 170 | '@typescript-eslint/prefer-namespace-keyword': 'error', 171 | '@typescript-eslint/prefer-optional-chain': 'error', 172 | '@typescript-eslint/prefer-reduce-type-parameter': 'error', 173 | '@typescript-eslint/prefer-string-starts-ends-with': 'error', 174 | '@typescript-eslint/require-array-sort-compare': 'error', 175 | '@typescript-eslint/switch-exhaustiveness-check': 'error', 176 | '@typescript-eslint/unified-signatures': 'error', 177 | }, 178 | }); 179 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Action, HandlerParams, IActionWithHandler, PopoverItem } from './types'; 2 | import { hasHandler, ItemType } from './types'; 3 | 4 | export function changeAction(action: Action, type: 'normal' | 'setting', selection?: string) { 5 | let newAction = { ...action }; 6 | if (type === 'setting') { 7 | newAction.origin = action; 8 | } 9 | else { 10 | newAction = { ...newAction, ...(action.origin || {}) }; 11 | } 12 | if (type === 'setting' && action.defaultIcon) { 13 | newAction.icon = action.defaultIcon; 14 | } 15 | if (typeof action.name === 'function') { 16 | try { 17 | newAction.name = action.name({ selection }); 18 | } 19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 20 | catch (e) { 21 | newAction.name = ''; 22 | } 23 | } 24 | if (typeof action.icon === 'function') { 25 | try { 26 | newAction.icon = action.icon({ selection }); 27 | } 28 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 29 | catch (e) { 30 | newAction.icon = ''; 31 | } 32 | } 33 | return newAction; 34 | } 35 | 36 | export async function fileToBase64(file: Blob): Promise { 37 | const reader = new FileReader(); 38 | reader.readAsDataURL(file); 39 | return new Promise((resolve, reject) => { 40 | reader.addEventListener('load', () => { 41 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 42 | resolve(reader.result as string); 43 | }); 44 | 45 | reader.onerror = error => { 46 | // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors 47 | reject(error); 48 | }; 49 | }); 50 | } 51 | 52 | export function parseFunction(action: IActionWithHandler) { 53 | if (typeof action.handler === 'string') { 54 | return { 55 | ...action, 56 | handler: function (params: HandlerParams) { 57 | // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-call 58 | return Promise.resolve(new Function(`return (${action.handler as string})`)()(params)); 59 | }, 60 | }; 61 | } 62 | return action; 63 | } 64 | 65 | export function stringifyFunction(action: IActionWithHandler) { 66 | if (typeof action.handler === 'function') { 67 | return { 68 | ...action, 69 | handler: action.handler.toString(), 70 | }; 71 | } 72 | return action; 73 | } 74 | 75 | export function updateSettings(allActions: Action[], enabledActions: PopoverItem[], storedKey?: number) { 76 | const refreshKey = 1; 77 | const actionMap: Record = {}; 78 | allActions.forEach(action => { 79 | if (action.id) { 80 | actionMap[action.id] = action; 81 | } 82 | }); 83 | enabledActions.forEach(item => { 84 | if ( 85 | item.type === ItemType.Action 86 | && item.action.id 87 | && item.action.id in actionMap 88 | && (item.action.version !== actionMap[item.action.id].version 89 | || refreshKey !== storedKey) 90 | ) { 91 | item.action = actionMap[item.action.id]; 92 | } 93 | }); 94 | return enabledActions.map(item => { 95 | if (item.type === ItemType.Divider) { 96 | return { 97 | ...item, 98 | }; 99 | } 100 | const { action } = item; 101 | if (hasHandler(action)) { 102 | return { 103 | ...item, 104 | action: stringifyFunction(action), 105 | }; 106 | } 107 | else { 108 | return { 109 | ...item, 110 | }; 111 | } 112 | }); 113 | } 114 | 115 | export interface OrderItemProps { 116 | origin: T; 117 | label: string; 118 | isMatch: boolean; 119 | markString: string; 120 | } 121 | 122 | interface FuzzyResult { 123 | isMatch: boolean; 124 | markString: string; 125 | } 126 | 127 | function fuzzy(input: string, pattern: string): FuzzyResult { 128 | if (!pattern) { 129 | return { 130 | isMatch: true, 131 | markString: input, 132 | }; 133 | } 134 | 135 | const lowerInput = input.toLowerCase(); 136 | const lowerPattern = pattern.toLowerCase(); 137 | 138 | let inputIndex = 0; 139 | let patternIndex = 0; 140 | const marks: boolean[] = new Array(input.length).fill(false); 141 | 142 | // 匹配过程 143 | while (inputIndex < input.length && patternIndex < pattern.length) { 144 | if (lowerInput[inputIndex] === lowerPattern[patternIndex]) { 145 | marks[inputIndex] = true; 146 | patternIndex++; 147 | } 148 | inputIndex++; 149 | } 150 | 151 | // 生成带标记的字符串 152 | if (patternIndex === pattern.length) { 153 | let result = ''; 154 | for (let i = 0; i < input.length; i++) { 155 | if (marks[i]) { 156 | result += '' + input[i] + ''; 157 | } 158 | else { 159 | result += input[i]; 160 | } 161 | } 162 | return { 163 | isMatch: true, 164 | markString: result, 165 | }; 166 | } 167 | 168 | return { 169 | isMatch: false, 170 | markString: input, 171 | }; 172 | } 173 | 174 | export function orderList( 175 | list: T[], 176 | inputValue: string, 177 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 178 | getKey: (item: T) => string = (item: T) => (item as string), 179 | ): OrderItemProps[] { 180 | // 当输入为空时,直接返回原列表 181 | if (!inputValue) { 182 | return list.map(item => ({ 183 | origin: item, 184 | label: getKey(item), 185 | isMatch: true, 186 | markString: getKey(item), 187 | })); 188 | } 189 | 190 | const containsMatchRes: OrderItemProps[] = []; 191 | const fuzzyMatchRes: OrderItemProps[] = []; 192 | const noMatchRes: OrderItemProps[] = []; 193 | 194 | const lowerInputValue = inputValue.toLowerCase(); 195 | 196 | list.forEach(item => { 197 | const label = getKey(item); 198 | const lowerLabel = label.toLowerCase(); 199 | 200 | // 先检查是否包含完整匹配词 201 | if (lowerLabel.includes(lowerInputValue)) { 202 | // 对于包含匹配的项,创建带高亮的 markString 203 | let markString = ''; 204 | const index = lowerLabel.indexOf(lowerInputValue); 205 | 206 | markString = label.slice(0, index) 207 | + '' + label.slice(index, index + inputValue.length) + '' 208 | + label.slice(index + inputValue.length); 209 | 210 | containsMatchRes.push({ 211 | origin: item, 212 | label, 213 | isMatch: true, 214 | markString, 215 | }); 216 | return; // 提前结束当前项的处理 217 | } 218 | 219 | // 只对不包含完整匹配词的项进行 fuzzy 匹配 220 | const fuzzyRes = fuzzy(label, inputValue); 221 | const orderItem: OrderItemProps = { 222 | origin: item, 223 | label, 224 | isMatch: fuzzyRes.isMatch, 225 | markString: fuzzyRes.isMatch ? fuzzyRes.markString : label, 226 | }; 227 | 228 | if (fuzzyRes.isMatch) { 229 | fuzzyMatchRes.push(orderItem); 230 | } 231 | else { 232 | noMatchRes.push(orderItem); 233 | } 234 | }); 235 | 236 | return [...containsMatchRes, ...fuzzyMatchRes, ...noMatchRes]; 237 | } 238 | -------------------------------------------------------------------------------- /src/components/Popover.tsx: -------------------------------------------------------------------------------- 1 | import type { App, Editor } from 'obsidian'; 2 | import type { FC } from 'react'; 3 | import { useEffect, useLayoutEffect, useRef, useState, useMemo } from 'react'; 4 | import type { PopoverItem } from 'src/types'; 5 | import { ItemType } from 'src/types'; 6 | import Item from './Item'; 7 | import { changeAction } from 'src/utils'; 8 | import type { InternalPluginNameType } from 'obsidian-typings'; 9 | 10 | interface PopoverProps { 11 | editor?: Editor; 12 | destory?: () => void; 13 | out?: HTMLElement; 14 | actions: PopoverItem[]; 15 | app: App; 16 | type?: 'normal' | 'setting'; 17 | } 18 | 19 | const Popover: FC = ({ 20 | editor, 21 | destory, 22 | out, 23 | actions, 24 | app, 25 | type = 'normal', 26 | }) => { 27 | const selection = editor?.getSelection(); 28 | const listRef = useRef(null); 29 | const [positionLeft, setPositionLeft] = useState(0); 30 | const [positionTop, setPositionTop] = useState(0); 31 | const [firstRender, setFirstRender] = useState(-1); 32 | 33 | function calcPosition() { 34 | if (!editor || !out) return; 35 | 36 | // 使用 requestAnimationFrame 确保在下一帧计算位置 37 | requestAnimationFrame(() => { 38 | const pos = editor.getCursor(); 39 | const coord = editor.coordsAtPos(pos, false); 40 | let left = 0; 41 | let top = 0; 42 | const rect = out.getBoundingClientRect(); 43 | 44 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 45 | if (!coord) { 46 | const cmContainer = editor.cm.contentDOM; 47 | const outRect = cmContainer.getBoundingClientRect(); 48 | const position = editor.cm.lineBlockAt(editor.posToOffset(pos)); 49 | left = outRect.x; 50 | top = (position.top || 0) + cmContainer.offsetTop; 51 | } 52 | else { 53 | left = coord.left - rect.left; 54 | top = coord.top - rect.top + out.scrollTop; 55 | } 56 | 57 | const height = listRef.current?.clientHeight || 0; 58 | const width = listRef.current?.clientWidth || 0; 59 | 60 | if (width <= rect.width - 40) { 61 | if (left - width / 2 <= 20) { 62 | left = 20; 63 | } 64 | else if (left + width / 2 > rect.width - 20) { 65 | left = rect.width - width - 20; 66 | } 67 | else { 68 | left = left - width / 2; 69 | } 70 | } 71 | else if (width <= rect.width) { 72 | if (left - width / 2 <= 0) { 73 | left = 0; 74 | } 75 | else if (left + width / 2 > rect.width) { 76 | left = rect.width - width; 77 | } 78 | else { 79 | left = left - width / 2; 80 | } 81 | } 82 | else { 83 | left = 0; 84 | } 85 | 86 | const spaceAbove = top; 87 | const spaceBelow = rect.height - top; 88 | 89 | if (height < spaceAbove || spaceBelow < height) { 90 | top = top - height - 5; 91 | } 92 | else { 93 | top = top + 20; 94 | } 95 | 96 | setPositionLeft(left); 97 | setPositionTop(top); 98 | }); 99 | } 100 | 101 | function getMarkdown() { 102 | return editor!.getValue(); 103 | } 104 | 105 | function replace(text: string) { 106 | editor!.replaceSelection(text); 107 | } 108 | 109 | const filterList = useMemo(() => { 110 | return actions 111 | .map(item => { 112 | if (item.type === ItemType.Divider) { 113 | return item; 114 | } 115 | return { type: item.type, action: changeAction(item.action, type, type === 'setting' ? item.action.exampleText : selection) }; 116 | }) 117 | .filter(item => { 118 | if (type === 'setting' || item.type === ItemType.Divider) { 119 | return true; 120 | } 121 | const { action } = item; 122 | let valid = Boolean(action.name || action.icon); 123 | if (valid && action.test) { 124 | const reg = new RegExp(action.test); 125 | valid = reg.test(selection ?? '') && Boolean(action.name || action.icon); 126 | } 127 | if (valid && action.dependencies) { 128 | valid = action.dependencies.every(dep => { 129 | return [ 130 | 'editor', 131 | 'app', 132 | 'workspace', 133 | 'file-explorer', 134 | 'markdown', 135 | 'open-with-default-app', 136 | 'theme', 137 | 'window', 138 | ].includes(dep) 139 | // eslint-disable-next-line @stylistic/indent-binary-ops 140 | || app.plugins.enabledPlugins.has(dep) 141 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 142 | || app.internalPlugins.getEnabledPluginById(dep as InternalPluginNameType); 143 | }); 144 | } 145 | return valid; 146 | }) 147 | .filter((item, i, list) => { 148 | if (item.type === ItemType.Divider) { 149 | if (i > 0 && list[i - 1].type === ItemType.Divider) { 150 | return false; 151 | } 152 | return i !== 0 && i !== list.length - 1; 153 | } 154 | return true; 155 | }); 156 | }, [actions, type, selection, app.plugins.enabledPlugins]); 157 | 158 | useEffect(() => { 159 | if (type === 'normal') { 160 | calcPosition(); 161 | const observer = new ResizeObserver(calcPosition); 162 | observer.observe(out!); 163 | out!.addEventListener('scroll', calcPosition); 164 | window.addEventListener('popkit-popover-render', calcPosition); 165 | return () => { 166 | observer.unobserve(out!); 167 | out!.removeEventListener('scroll', calcPosition); 168 | window.removeEventListener('popkit-popover-render', calcPosition); 169 | }; 170 | } 171 | }, [type, editor]); 172 | 173 | useLayoutEffect(() => { 174 | if (positionLeft !== 0 && positionTop !== 0) { 175 | setFirstRender(pre => Math.min(pre + 1, 10)); 176 | } 177 | }, [positionLeft, positionTop]); 178 | 179 | if (!filterList.some(i => i.type === ItemType.Action)) { 180 | return null; 181 | } 182 | 183 | return ( 184 |
    192 |
      193 | {filterList.map((popoverItem, i) => ( 194 |
    • 195 | {popoverItem.type === ItemType.Action && ( 196 | 206 | )} 207 | {popoverItem.type === ItemType.Divider &&
      } 208 |
    • 209 | ))} 210 |
    211 |
    212 | ); 213 | }; 214 | 215 | export default Popover; 216 | -------------------------------------------------------------------------------- /src/components/Item.tsx: -------------------------------------------------------------------------------- 1 | import type { App, Editor } from 'obsidian'; 2 | import { setTooltip } from 'obsidian'; 3 | import type { MouseEvent } from 'react'; 4 | import { useState, useRef, useEffect, forwardRef } from 'react'; 5 | import type { Action, HandlerParams } from 'src/types'; 6 | import { hasCommand, hasHandler, hasHandlerString, hasHotkeys } from 'src/types'; 7 | import uniqueId from 'lodash/uniqueId'; 8 | import { icons } from 'lucide-react'; 9 | import { parseFunction } from 'src/utils'; 10 | 11 | interface ItemProps { 12 | action: Action; 13 | editor?: Editor; 14 | replace?: (text: string) => void; 15 | getMarkdown?: () => string; 16 | selection?: string; 17 | finish?: () => void; 18 | app: App; 19 | type: 'normal' | 'setting'; 20 | } 21 | 22 | const AsyncFunction = async function () { }.constructor; 23 | 24 | interface KeyboardEventParams { 25 | key: string; 26 | code: string; 27 | ctrlKey: boolean; 28 | metaKey: boolean; 29 | altKey: boolean; 30 | shiftKey: boolean; 31 | charCode?: number; 32 | keyCode?: number; 33 | which?: number; 34 | } 35 | 36 | // 获取键码映射 37 | const KEY_CODE_MAP: Record = { 38 | a: 65, 39 | b: 66, 40 | c: 67, 41 | d: 68, 42 | e: 69, 43 | f: 70, 44 | g: 71, 45 | h: 72, 46 | i: 73, 47 | j: 74, 48 | k: 75, 49 | l: 76, 50 | m: 77, 51 | n: 78, 52 | o: 79, 53 | p: 80, 54 | q: 81, 55 | r: 82, 56 | s: 83, 57 | t: 84, 58 | u: 85, 59 | v: 86, 60 | w: 87, 61 | x: 88, 62 | y: 89, 63 | z: 90, 64 | Control: 17, 65 | Meta: 91, 66 | Alt: 18, 67 | Shift: 16, 68 | }; 69 | 70 | function createKeyboardEvent(type: 'keydown' | 'keyup' | 'keypress', params: KeyboardEventParams): KeyboardEvent { 71 | const keyCode = params.key.length === 1 72 | ? KEY_CODE_MAP[params.key.toLowerCase()] || params.key.charCodeAt(0) 73 | : KEY_CODE_MAP[params.key] || 0; 74 | 75 | const init: KeyboardEventInit = { 76 | ...params, 77 | bubbles: false, 78 | cancelable: true, 79 | composed: true, 80 | repeat: false, 81 | isComposing: false, 82 | location: 0, 83 | keyCode, 84 | which: keyCode, 85 | charCode: type === 'keypress' ? keyCode : 0, 86 | view: window, 87 | }; 88 | 89 | return new KeyboardEvent(type, init); 90 | } 91 | 92 | async function simulateKeyboardEvents(target: EventTarget, params: KeyboardEventParams) { 93 | const modifiers = [ 94 | { key: 'Control', pressed: params.ctrlKey }, 95 | { key: 'Meta', pressed: params.metaKey }, 96 | { key: 'Alt', pressed: params.altKey }, 97 | { key: 'Shift', pressed: params.shiftKey }, 98 | ].filter(m => m.pressed); 99 | 100 | const doc = target instanceof Window ? target.document : document; 101 | 102 | // 跟踪修饰键的状态 103 | const modifierState = { 104 | ctrlKey: false, 105 | metaKey: false, 106 | altKey: false, 107 | shiftKey: false, 108 | }; 109 | 110 | // 按下修饰键 111 | for (const mod of modifiers) { 112 | // 更新当前修饰键的状态 113 | switch (mod.key) { 114 | case 'Control': 115 | modifierState.ctrlKey = true; 116 | break; 117 | case 'Meta': 118 | modifierState.metaKey = true; 119 | break; 120 | case 'Alt': 121 | modifierState.altKey = true; 122 | break; 123 | case 'Shift': 124 | modifierState.shiftKey = true; 125 | break; 126 | } 127 | 128 | const modEvent = createKeyboardEvent('keydown', { 129 | ...params, 130 | key: mod.key, 131 | code: `${mod.key}Left`, 132 | // 使用当前的修饰键状态 133 | ...modifierState, 134 | }); 135 | doc.dispatchEvent(modEvent); 136 | await new Promise(resolve => setTimeout(resolve, 10)); 137 | } 138 | 139 | // 主键按下 - 使用所有修饰键的最终状态 140 | const downEvent = createKeyboardEvent('keydown', params); 141 | doc.dispatchEvent(downEvent); 142 | 143 | // 如果是可打印字符,触发 keypress 144 | if (params.key.length === 1) { 145 | const pressEvent = createKeyboardEvent('keypress', params); 146 | doc.dispatchEvent(pressEvent); 147 | } 148 | 149 | await new Promise(resolve => setTimeout(resolve, 50)); 150 | 151 | // 主键释放 - 仍然使用所有修饰键的状态 152 | const upEvent = createKeyboardEvent('keyup', params); 153 | doc.dispatchEvent(upEvent); 154 | 155 | // 释放修饰键(反序) 156 | for (const mod of modifiers.reverse()) { 157 | // 更新当前修饰键的状态 158 | switch (mod.key) { 159 | case 'Control': 160 | modifierState.ctrlKey = false; 161 | break; 162 | case 'Meta': 163 | modifierState.metaKey = false; 164 | break; 165 | case 'Alt': 166 | modifierState.altKey = false; 167 | break; 168 | case 'Shift': 169 | modifierState.shiftKey = false; 170 | break; 171 | } 172 | 173 | const modUpEvent = createKeyboardEvent('keyup', { 174 | ...params, 175 | key: mod.key, 176 | code: `${mod.key}Left`, 177 | // 使用当前的修饰键状态 178 | ...modifierState, 179 | }); 180 | doc.dispatchEvent(modUpEvent); 181 | await new Promise(resolve => setTimeout(resolve, 10)); 182 | } 183 | } 184 | 185 | const Item = forwardRef(({ 186 | action, 187 | editor, 188 | replace, 189 | getMarkdown, 190 | selection, 191 | finish, 192 | app, 193 | type, 194 | }, ref) => { 195 | const [id] = useState(uniqueId('action-item-')); 196 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 197 | const icon = type === 'setting' && action.defaultIcon ? action.defaultIcon : action.icon as string; 198 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 199 | const Icon = icons[icon as keyof typeof icons]; 200 | const itemRef = useRef(null); 201 | function click(event: MouseEvent) { 202 | if (type === 'setting') { 203 | return; 204 | } 205 | event.preventDefault(); 206 | event.stopPropagation(); 207 | 208 | const executeAction = async () => { 209 | try { 210 | if (hasHandler(action)) { 211 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 212 | await (parseFunction(action).handler as ((params: HandlerParams) => Promise | void))({ 213 | editor: editor!, 214 | getMarkdown: getMarkdown!, 215 | replace: replace!, 216 | app, 217 | selection: selection!, 218 | action, 219 | }); 220 | finish!(); 221 | } 222 | else if (hasCommand(action)) { 223 | const result = app.commands.executeCommandById(action.command); 224 | if (!result) { 225 | console.error(`Command ${action.command} not found`); 226 | } 227 | finish!(); 228 | } 229 | else if (hasHandlerString(action)) { 230 | // @ts-expect-error AsyncFunction is ok 231 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call 232 | const fn: (param: HandlerParams) => Promise = new AsyncFunction('context', action.handlerString); 233 | await fn({ 234 | editor: editor!, 235 | getMarkdown: getMarkdown!, 236 | replace: replace!, 237 | app, 238 | selection: selection!, 239 | action, 240 | }); 241 | finish!(); 242 | } 243 | else if (hasHotkeys(action)) { 244 | const [hotkeyStr] = action.hotkeys; 245 | const key = hotkeyStr.split(' ').filter(k => !['Ctrl', '⌘', '⌥', '⇧'].includes(k)).pop() || ''; 246 | const params: KeyboardEventParams = { 247 | key, 248 | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, 249 | ctrlKey: hotkeyStr.includes('Ctrl'), 250 | metaKey: hotkeyStr.includes('⌘'), 251 | altKey: hotkeyStr.includes('⌥'), 252 | shiftKey: hotkeyStr.includes('⇧'), 253 | charCode: key.length === 1 ? key.charCodeAt(0) : undefined, 254 | keyCode: key.length === 1 ? key.charCodeAt(0) : undefined, 255 | which: key.length === 1 ? key.charCodeAt(0) : undefined, 256 | }; 257 | 258 | const target = document; 259 | try { 260 | await simulateKeyboardEvents(target, params); 261 | } 262 | finally { 263 | // finish!(); 264 | } 265 | } 266 | else { 267 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 268 | const exception: never = action; 269 | } 270 | } 271 | catch (e) { 272 | console.error(e); 273 | finish!(); 274 | } 275 | }; 276 | 277 | void executeAction(); 278 | } 279 | useEffect(() => { 280 | if (itemRef.current) { 281 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 282 | setTooltip(itemRef.current, action.desc || action.name as string, { 283 | placement: 'top', 284 | delay: 50, 285 | }); 286 | } 287 | }, [itemRef.current, action]); 288 | return ( 289 |
    290 |
    296 | {icon && ( 297 | /^https?:|^data:/.test(icon) 298 | ? ( 299 |
    300 | ) 301 | : icon in icons 302 | ? ( 303 | 304 | ) 305 | : ( 306 |
    {icon}
    307 | ) 308 | )} 309 | {!icon && ( 310 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 311 |
    {action.name as string}
    312 | )} 313 |
    314 |
    315 | ); 316 | }); 317 | 318 | export default Item; 319 | -------------------------------------------------------------------------------- /src/components/setting/NewCustomAction.tsx: -------------------------------------------------------------------------------- 1 | import type { App, Command } from 'obsidian'; 2 | import { Notice } from 'obsidian'; 3 | import type { FC } from 'react'; 4 | import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; 5 | import L from 'src/L'; 6 | import type { OrderItemProps } from 'src/utils'; 7 | import { fileToBase64, orderList } from 'src/utils'; 8 | import type { Action, IActionWithCommand, IActionWithHotkeys } from 'src/types'; 9 | import { useDebounce } from 'ahooks'; 10 | import CommandForm from './action-types/command'; 11 | import HotkeysForm from './action-types/hotkeys'; 12 | import IconForm from './action-types/icon'; 13 | import RegexForm from './action-types/regex'; 14 | import { icons } from 'lucide-react'; 15 | import startCase from 'lodash/startCase'; 16 | 17 | type ActionType = 'command' | 'hotkeys'; 18 | 19 | const NewCustomAction: FC<{ 20 | app: App; 21 | onChange: (action: Action) => void; 22 | }> = ({ app, onChange }) => { 23 | const [actionType, setActionType] = useState('command'); 24 | const [icon, setIcon] = useState(''); 25 | const [cmd, setCmd] = useState(''); 26 | const [iconInput, setIconInput] = useState(''); 27 | const [cmdInput, setCmdInput] = useState(''); 28 | const [hotkey, setHotkey] = useState(''); 29 | const [description, setDescription] = useState(''); 30 | const [test, setTest] = useState(''); 31 | const inputReference = useRef(null); 32 | const iconInputDebounce = useDebounce(iconInput, { wait: 400 }); 33 | const cmdInputDebounce = useDebounce(cmdInput, { wait: 400 }); 34 | 35 | interface CommandWithPluginInfo extends Command { 36 | pluginId: string; 37 | } 38 | 39 | const { commands } = app.commands; 40 | const { plugins } = app.plugins; 41 | const { plugins: internalPlugins, config: internalPluginConfig } = app.internalPlugins; 42 | 43 | // 创建一个包含所有命令的统一列表,同时保留命令所属的插件信息 44 | const allCommands: CommandWithPluginInfo[] = useMemo(() => { 45 | const result: CommandWithPluginInfo[] = []; 46 | Object.keys(commands).forEach(key => { 47 | const [pluginId, cmdId] = key.split(':'); 48 | if (pluginId && cmdId) { 49 | const isEnabled = app.plugins.enabledPlugins.has(pluginId) 50 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 51 | || Boolean((internalPluginConfig as Record)[pluginId]); 52 | 53 | const pluginExists = pluginId in plugins || pluginId in internalPlugins; 54 | 55 | if (pluginId && isEnabled && pluginExists) { 56 | const command = commands[key]; 57 | result.push({ 58 | ...command, 59 | pluginId, 60 | }); 61 | } 62 | } 63 | }); 64 | return result; 65 | }, [commands, plugins, internalPlugins, internalPluginConfig, app.plugins.enabledPlugins]); 66 | 67 | useEffect(() => { 68 | if (cmd) { 69 | const selectedCommand = allCommands.find(c => c.id === cmd); 70 | if (selectedCommand) { 71 | let cmdIcon = selectedCommand.icon; 72 | if (cmdIcon?.startsWith('lucide-')) { 73 | cmdIcon = cmdIcon.replace(/^lucide-/, '').replace(/\s/g, ''); 74 | } 75 | if (cmdIcon) { 76 | cmdIcon = startCase(cmdIcon).replace(/\s/g, ''); 77 | if (cmdIcon in icons) { 78 | setIcon(cmdIcon); 79 | setIconInput(cmdIcon); 80 | } 81 | } 82 | if (selectedCommand.name) { 83 | setDescription(selectedCommand.name); 84 | } 85 | } 86 | } 87 | else if (actionType !== 'command') { 88 | setIcon(''); 89 | setIconInput(''); 90 | } 91 | }, [allCommands, cmd, actionType]); 92 | 93 | const orderedCmdList = useMemo[]>(() => { 94 | return orderList( 95 | allCommands, 96 | cmdInputDebounce, 97 | (item: CommandWithPluginInfo) => `${item.name} (${item.pluginId})`, 98 | ); 99 | }, [allCommands, cmdInputDebounce]); 100 | 101 | const orderedIconList = useMemo[]>(() => { 102 | return orderList(Object.keys(icons), iconInputDebounce); 103 | }, [iconInputDebounce]); 104 | 105 | const upload = useCallback(async () => { 106 | const file = inputReference.current?.files?.[0]; 107 | if (file) { 108 | const base64 = await fileToBase64(file); 109 | setIcon(base64); 110 | } 111 | }, []); 112 | 113 | const add = useCallback(() => { 114 | if (!icon) { 115 | new Notice(L.setting.iconNotice()); 116 | return; 117 | } 118 | 119 | // 检查正则表达式是否合法 120 | if (test) { 121 | try { 122 | new RegExp(test); 123 | } 124 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 125 | catch (e) { 126 | new Notice(L.setting.invalidRegex()); 127 | return; 128 | } 129 | } 130 | 131 | let action: IActionWithCommand | IActionWithHotkeys; 132 | const baseAction = { 133 | icon, 134 | name: '', 135 | desc: description || '', 136 | test: test || undefined, 137 | }; 138 | 139 | switch (actionType) { 140 | case 'command': { 141 | if (!cmd) { 142 | new Notice(L.setting.commandNotice()); 143 | return; 144 | } 145 | 146 | // 从所有命令中找到选择的命令 147 | const selectedCommand = allCommands.find(c => c.id === cmd); 148 | if (!selectedCommand) { 149 | new Notice(L.setting.invalidCommand()); 150 | return; 151 | } 152 | 153 | action = { 154 | ...baseAction, 155 | name: selectedCommand.name, 156 | desc: description || selectedCommand.name, 157 | dependencies: [selectedCommand.pluginId], 158 | command: cmd, 159 | }; 160 | break; 161 | } 162 | case 'hotkeys': { 163 | if (!hotkey) { 164 | new Notice(L.setting.hotkeysNotice()); 165 | return; 166 | } 167 | if (!description) { 168 | new Notice(L.setting.descriptionNotice()); 169 | return; 170 | } 171 | action = { 172 | ...baseAction, 173 | name: description, 174 | hotkeys: [hotkey], 175 | }; 176 | break; 177 | } 178 | } 179 | 180 | onChange(action); 181 | new Notice(L.setting.addSuccess()); 182 | }, [actionType, cmd, allCommands, icon, hotkey, description, test, onChange]); 183 | 184 | return ( 185 |
    186 |

    {L.setting.customTitle()}

    187 | 188 | {/* Action Type Selector */} 189 |
    190 |
    191 |
    {L.setting.actionType()}
    192 |
    193 | {L.setting.actionTypeDesc()} 194 |
    195 |
    196 |
    197 |
    198 | 206 | 214 |
    215 |
    216 |
    217 | 218 | {/* Command Type Fields */} 219 | {actionType === 'command' && ( 220 | 228 | )} 229 | 230 | {/* Hotkeys Type Fields */} 231 | {actionType === 'hotkeys' && ( 232 | 236 | )} 237 | 238 | {/* Icon Selector - Always visible */} 239 | 248 | 249 | {/* Description Field - Always visible */} 250 |
    251 |
    252 |
    {L.setting.descriptionLabel()}
    253 |
    254 | {L.setting.descriptionDesc()} 255 |
    256 |
    257 |
    258 | { setDescription(e.target.value); }} 264 | /> 265 |
    266 |
    267 | 268 | {/* RegEx Test Field - Always visible */} 269 | 273 | 274 | {/* Add Button */} 275 |
    276 |
    277 |
    278 | 279 |
    280 |
    281 |
    282 | ); 283 | }; 284 | 285 | export default NewCustomAction; 286 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .pk-pointer-events-none { 2 | pointer-events: none; 3 | } 4 | 5 | .pk-absolute { 6 | position: absolute; 7 | } 8 | 9 | .pk-relative { 10 | position: relative; 11 | } 12 | 13 | .pk-inset-y-0 { 14 | top: 0px; 15 | bottom: 0px; 16 | } 17 | 18 | .pk-end-3 { 19 | inset-inline-end: 0.75rem; 20 | } 21 | 22 | .pk-start-2 { 23 | inset-inline-start: 0.5rem; 24 | } 25 | 26 | .pk-start-3 { 27 | inset-inline-start: 0.75rem; 28 | } 29 | 30 | .pk-top-0 { 31 | top: 0px; 32 | } 33 | 34 | .pk-z-50 { 35 | z-index: 50; 36 | } 37 | 38 | .pk-mt-2 { 39 | margin-top: 0.5rem; 40 | } 41 | 42 | .pk-flex { 43 | display: flex; 44 | } 45 | 46 | .pk-grid { 47 | display: grid; 48 | } 49 | 50 | .pk-hidden { 51 | display: none; 52 | } 53 | 54 | .pk-size-2 { 55 | width: 0.5rem; 56 | height: 0.5rem; 57 | } 58 | 59 | .pk-size-4 { 60 | width: 1rem; 61 | height: 1rem; 62 | } 63 | 64 | .pk-h-9 { 65 | height: 2.25rem; 66 | } 67 | 68 | .pk-h-full { 69 | height: 100%; 70 | } 71 | 72 | .pk-w-72 { 73 | width: 18rem; 74 | } 75 | 76 | .pk-w-\[--radix-popper-anchor-width\] { 77 | width: var(--radix-popper-anchor-width); 78 | } 79 | 80 | .pk-w-full { 81 | width: 100%; 82 | } 83 | 84 | .pk-cursor-default { 85 | cursor: default; 86 | } 87 | 88 | .pk-cursor-pointer { 89 | cursor: pointer; 90 | } 91 | 92 | .pk-select-none { 93 | -webkit-user-select: none; 94 | -moz-user-select: none; 95 | user-select: none; 96 | } 97 | 98 | .pk-flex-col { 99 | flex-direction: column; 100 | } 101 | 102 | .pk-place-items-center { 103 | place-items: center; 104 | } 105 | 106 | .pk-items-center { 107 | align-items: center; 108 | } 109 | 110 | .pk-justify-center { 111 | justify-content: center; 112 | } 113 | 114 | .pk-gap-1 { 115 | gap: 0.25rem; 116 | } 117 | 118 | .pk-gap-2 { 119 | gap: 0.5rem; 120 | } 121 | 122 | .pk-gap-4 { 123 | gap: 1rem; 124 | } 125 | 126 | .pk-overflow-hidden { 127 | overflow: hidden; 128 | } 129 | 130 | .pk-truncate { 131 | overflow: hidden; 132 | text-overflow: ellipsis; 133 | white-space: nowrap; 134 | } 135 | 136 | .pk-rounded-md { 137 | border-radius: calc(var(--pk-radius) - 2px); 138 | } 139 | 140 | .pk-rounded-sm { 141 | border-radius: calc(var(--pk-radius) - 4px); 142 | } 143 | 144 | .pk-border { 145 | border-width: 1px; 146 | } 147 | 148 | .pk-border-input { 149 | border-color: hsl(var(--pk-input)); 150 | } 151 | 152 | .pk-bg-popover { 153 | background-color: hsl(var(--pk-popover)); 154 | } 155 | 156 | .pk-bg-transparent { 157 | background-color: transparent; 158 | } 159 | 160 | .pk-fill-current { 161 | fill: currentColor; 162 | } 163 | 164 | .pk-p-0 { 165 | padding: 0px; 166 | } 167 | 168 | .pk-p-4 { 169 | padding: 1rem; 170 | } 171 | 172 | .pk-px-3 { 173 | padding-left: 0.75rem; 174 | padding-right: 0.75rem; 175 | } 176 | 177 | .pk-py-1 { 178 | padding-top: 0.25rem; 179 | padding-bottom: 0.25rem; 180 | } 181 | 182 | .pk-py-1\.5 { 183 | padding-top: 0.375rem; 184 | padding-bottom: 0.375rem; 185 | } 186 | 187 | .pk-pl-5 { 188 | padding-left: 1.25rem; 189 | } 190 | 191 | .pk-ps-8 { 192 | padding-inline-start: 2rem; 193 | } 194 | 195 | .pk-text-center { 196 | text-align: center; 197 | } 198 | 199 | .pk-text-base { 200 | font-size: 1rem; 201 | line-height: 1.5rem; 202 | } 203 | 204 | .pk-text-sm { 205 | font-size: 0.875rem; 206 | line-height: 1.25rem; 207 | } 208 | 209 | .pk-text-xs { 210 | font-size: 0.75rem; 211 | line-height: 1rem; 212 | } 213 | 214 | .pk-text-foreground { 215 | color: hsl(var(--pk-foreground)); 216 | } 217 | 218 | .pk-text-muted-foreground { 219 | color: hsl(var(--pk-muted-foreground)); 220 | } 221 | 222 | .pk-text-popover-foreground { 223 | color: hsl(var(--pk-popover-foreground)); 224 | } 225 | 226 | .pk-opacity-50 { 227 | opacity: 0.5; 228 | } 229 | 230 | .pk-shadow-md { 231 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 232 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 233 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 234 | } 235 | 236 | .pk-shadow-sm { 237 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 238 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 239 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 240 | } 241 | 242 | .pk-outline-none { 243 | outline: 2px solid transparent; 244 | outline-offset: 2px; 245 | } 246 | 247 | .pk-transition-colors { 248 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 249 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 250 | transition-duration: 150ms; 251 | } 252 | 253 | @keyframes enter { 254 | from { 255 | opacity: var(--tw-enter-opacity, 1); 256 | transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0)); 257 | } 258 | } 259 | 260 | @keyframes exit { 261 | to { 262 | opacity: var(--tw-exit-opacity, 1); 263 | transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0)); 264 | } 265 | } 266 | 267 | :root { 268 | --pk-background: 0 0% 100%; 269 | --pk-foreground: 222.2 47.4% 11.2%; 270 | --pk-muted: 210 40% 96.1%; 271 | --pk-muted-foreground: 215.4 16.3% 46.9%; 272 | --pk-popover: 0 0% 100%; 273 | --pk-popover-foreground: 222.2 47.4% 11.2%; 274 | --pk-border: 214.3 31.8% 91.4%; 275 | --pk-input: 214.3 31.8% 91.4%; 276 | --pk-card: 0 0% 100%; 277 | --pk-card-foreground: 222.2 47.4% 11.2%; 278 | --pk-primary: 222.2 47.4% 11.2%; 279 | --pk-primary-foreground: 210 40% 98%; 280 | --pk-secondary: 210 40% 96.1%; 281 | --pk-secondary-foreground: 222.2 47.4% 11.2%; 282 | --pk-accent: 210 40% 96.1%; 283 | --pk-accent-foreground: 222.2 47.4% 11.2%; 284 | --pk-destructive: 0 100% 50%; 285 | --pk-destructive-foreground: 210 40% 98%; 286 | --pk-ring: 215 20.2% 65.1%; 287 | --pk-radius: 0.5rem; 288 | } 289 | 290 | .dark { 291 | --pk-background: 224 71% 4%; 292 | --pk-foreground: 213 31% 91%; 293 | --pk-muted: 223 47% 11%; 294 | --pk-muted-foreground: 215.4 16.3% 56.9%; 295 | --pk-accent: 216 34% 17%; 296 | --pk-accent-foreground: 210 40% 98%; 297 | --pk-popover: 224 71% 4%; 298 | --pk-popover-foreground: 215 20.2% 65.1%; 299 | --pk-border: 216 34% 17%; 300 | --pk-input: 216 34% 17%; 301 | --pk-card: 224 71% 4%; 302 | --pk-card-foreground: 213 31% 91%; 303 | --pk-primary: 210 40% 98%; 304 | --pk-primary-foreground: 222.2 47.4% 1.2%; 305 | --pk-secondary: 222.2 47.4% 11.2%; 306 | --pk-secondary-foreground: 210 40% 98%; 307 | --pk-destructive: 0 63% 31%; 308 | --pk-destructive-foreground: 210 40% 98%; 309 | --pk-ring: 216 34% 17%; 310 | } 311 | 312 | .popkit-popover .popkit-container { 313 | border-radius: 6px; 314 | background-color: #000; 315 | overflow: hidden; 316 | max-width: 100%; 317 | left: 0; 318 | top: 0; 319 | } 320 | 321 | .popkit-popover .popkit-container.popkit-normal { 322 | position: absolute; 323 | display: block; 324 | z-index: 10; 325 | } 326 | 327 | .popkit-popover .popkit-container.popkit-setting { 328 | position: static; 329 | display: flex; 330 | flex-wrap: wrap; 331 | } 332 | 333 | .popkit-popover .popkit-container ul { 334 | display: flex; 335 | max-width: 100%; 336 | flex-wrap: wrap; 337 | list-style: none; 338 | padding: 0 !important; 339 | margin: 0; 340 | } 341 | 342 | .popkit-popover .popkit-container li { 343 | list-style: none; 344 | padding: 0 !important; 345 | margin: 0; 346 | display: flex; 347 | } 348 | 349 | .popkit-popover .popkit-container li::before { 350 | content: none !important; 351 | } 352 | 353 | .popkit-popover .popkit-divider { 354 | width: 1px; 355 | margin: 6px 4px; 356 | background-color: #fff; 357 | height: 16px; 358 | } 359 | 360 | .popkit-popover .popkit-alert { 361 | font-size: 12px; 362 | padding: 0 12px; 363 | line-height: 28px; 364 | } 365 | 366 | .popkit-setting-section .popkit-item, 367 | .popkit-popover .popkit-item { 368 | padding: 4px 6px; 369 | transition: all 200ms ease-in-out; 370 | opacity: 0.8; 371 | cursor: pointer; 372 | background-color: #000; 373 | font-family: var(--font-default); 374 | color: #fff; 375 | height: 28px; 376 | font-size: 16px; 377 | line-height: 20px; 378 | border-radius: 4px; 379 | } 380 | 381 | .popkit-setting-section .popkit-item:hover 382 | .popkit-popover .popkit-item:hover { 383 | opacity: 1; 384 | background-color: var(--color-blue); 385 | } 386 | 387 | .popkit-setting-section .popkit-item-image, 388 | .popkit-popover .popkit-item-image { 389 | background-size: contain; 390 | background-position: center center; 391 | background-repeat: no-repeat; 392 | height: 20px; 393 | width: 20px; 394 | } 395 | 396 | .popkit-setting-section .popkit-item-text, 397 | .popkit-popover .popkit-item-text { 398 | max-width: 100px; 399 | overflow: hidden; 400 | text-overflow: ellipsis; 401 | white-space: nowrap; 402 | } 403 | 404 | .popkit-setting-form { 405 | display: flex; 406 | flex-direction: column; 407 | padding: 20px; 408 | border-radius: 6px; 409 | border: 1px solid var(--background-modifier-border); 410 | } 411 | 412 | .popkit-setting-form-icon-container { 413 | background-color: #000; 414 | width: 24px; 415 | height: 24px; 416 | border-radius: 6px; 417 | padding: 2px; 418 | } 419 | 420 | .popkit-setting-section { 421 | position: relative; 422 | border: 1px solid var(--background-modifier-border); 423 | border-radius: 8px; 424 | padding: 20px; 425 | margin-bottom: 20px; 426 | } 427 | 428 | .popkit-setting-section h3 { 429 | margin-top: 0; 430 | } 431 | 432 | .popkit-setting-section p { 433 | opacity: 0.6; 434 | margin: 0; 435 | } 436 | 437 | .popkit-setting-actions-container { 438 | display: flex; 439 | flex-wrap: wrap; 440 | gap: 10px; 441 | } 442 | 443 | .popkit-setting-droppable-area { 444 | margin: 20px 0; 445 | } 446 | 447 | .popkit-setting-delete-area { 448 | height: 40px; 449 | border: 1px dashed #ccc; 450 | border-radius: 4px; 451 | margin-top: 20px; 452 | display: flex; 453 | justify-content: center; 454 | align-items: center; 455 | color: #ccc; 456 | transition: all 200ms ease-in-out; 457 | } 458 | 459 | .popkit-setting-delete-area.popkit-setting-delete-area-highlight { 460 | border-color: red; 461 | color: red; 462 | } 463 | 464 | .popkit-setting-add { 465 | flex-grow: 1; 466 | min-width: 20px; 467 | height: 28px; 468 | } 469 | 470 | .popkit-sortable-over { 471 | position: relative; 472 | opacity: 0.5; 473 | transition: all 0.2s ease; 474 | padding-left: 4px; 475 | } 476 | 477 | .popkit-sortable-over::after { 478 | content: ''; 479 | position: absolute; 480 | left: 0px; 481 | top: -4px; 482 | bottom: -4px; 483 | border: 2px solid var(--interactive-accent); 484 | border-radius: 6px; 485 | pointer-events: none; 486 | } 487 | 488 | .popkit-setting .popkit-sortable-over { 489 | transform: scale(1.02); 490 | transition: transform 0.2s ease; 491 | } 492 | 493 | .popkit-drag-overlay { 494 | position: fixed !important; 495 | pointer-events: none; 496 | z-index: 9999 !important; 497 | left: 0; 498 | top: 0; 499 | width: 100%; 500 | height: 100%; 501 | } 502 | 503 | .popkit-drag-overlay .popkit-item { 504 | opacity: 0.7; 505 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); 506 | } 507 | 508 | .popkit-drag-overlay .popkit-divider { 509 | opacity: 0.7; 510 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); 511 | } -------------------------------------------------------------------------------- /src/components/setting/Setting.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-type-assertions */ 2 | import type { App } from 'obsidian'; 3 | import type { FC, PropsWithChildren } from 'react'; 4 | import { useCallback, useState } from 'react'; 5 | import type { ISetting, PopoverItem, Action } from 'src/types'; 6 | import { ItemType } from 'src/types'; 7 | import buildIn from '../../actions'; 8 | import Item from '../Item'; 9 | import { changeAction } from 'src/utils'; 10 | import type { 11 | DragStartEvent, 12 | DragEndEvent, 13 | DragOverEvent, 14 | } from '@dnd-kit/core'; 15 | import { 16 | DndContext, 17 | rectIntersection, 18 | KeyboardSensor, 19 | PointerSensor, 20 | useSensor, 21 | useSensors, 22 | DragOverlay, 23 | defaultDropAnimationSideEffects, 24 | } from '@dnd-kit/core'; 25 | import { 26 | arrayMove, 27 | SortableContext, 28 | sortableKeyboardCoordinates, 29 | horizontalListSortingStrategy, 30 | } from '@dnd-kit/sortable'; 31 | import DraggableWrap from './DraggableWrap'; 32 | import SortableItem from './SortableItem'; 33 | import DroppableWrap from './DroppableWrap'; 34 | import L from 'src/L'; 35 | import NewCustomAction from './NewCustomAction'; 36 | 37 | const Add: FC = ({ children, ...props }) =>
    {children}
    ; 38 | 39 | const Setting: FC<{ 40 | initialSetting: ISetting; 41 | updateSetting: (data: ISetting) => void; 42 | app: App; 43 | }> = ({ initialSetting, updateSetting, app }) => { 44 | const [formData, setFormData] = useState(initialSetting); 45 | const [highlight, setHighlight] = useState(false); 46 | const [activeItem, setActiveItem] = useState(null); 47 | const [overId, setOverId] = useState(null); 48 | 49 | const update = useCallback( 50 | (data: ISetting) => { 51 | updateSetting(data); 52 | setFormData(data); 53 | }, 54 | [setFormData, updateSetting], 55 | ); 56 | 57 | const sensors = useSensors( 58 | useSensor(PointerSensor), 59 | useSensor(KeyboardSensor, { 60 | coordinateGetter: sortableKeyboardCoordinates, 61 | }), 62 | ); 63 | 64 | function handleDragStart(event: DragStartEvent) { 65 | const { active } = event; 66 | const [type, id] = `${active.id}`.split('_'); 67 | 68 | if (type === 'custom') { 69 | const action = formData.customActionList[Number(id)]; 70 | setActiveItem({ 71 | action, 72 | type: ItemType.Action, 73 | id: `custom_${id}`, 74 | }); 75 | setHighlight(true); 76 | } 77 | else if (type === 'list') { 78 | const activeAction = formData.actionList[Number(id)]; 79 | setActiveItem(activeAction); 80 | setHighlight(true); 81 | } 82 | else if (type === 'all') { 83 | const action = buildIn[Number(id)]; 84 | setActiveItem({ 85 | action, 86 | type: ItemType.Action, 87 | id: `all_${id}`, 88 | }); 89 | } 90 | else if (type === 'divider') { 91 | setActiveItem({ 92 | type: ItemType.Divider, 93 | id: 'divider', 94 | }); 95 | } 96 | } 97 | 98 | function handleDragCancel() { 99 | setActiveItem(null); 100 | setHighlight(false); 101 | } 102 | 103 | function handleDragOver(event: DragOverEvent) { 104 | const { over } = event; 105 | setOverId(over?.id as string || null); 106 | } 107 | 108 | function handleDragEnd(event: DragEndEvent) { 109 | const { active, over } = event; 110 | 111 | // 重置状态 112 | setActiveItem(null); 113 | setHighlight(false); 114 | setOverId(null); 115 | 116 | if (!over) { 117 | return; 118 | } 119 | 120 | const [activType, activeId] = `${active.id}`.split('_'); 121 | const [overType, overId] = `${over.id}`.split('_'); 122 | 123 | // 如果是拖到 buildIn 区域,直接返回 124 | if (overType === 'all') { 125 | return; 126 | } 127 | 128 | let newList = [...formData.actionList]; 129 | let newCustomList = [...formData.customActionList]; 130 | 131 | if (activType === 'all' && overType === 'list') { 132 | // 设置新的按钮 133 | newList.splice(Number(overId), 0, { 134 | action: buildIn[Number(activeId)], 135 | type: ItemType.Action, 136 | id: '', 137 | }); 138 | } 139 | else if (activType === 'custom' && overType === 'list') { 140 | // 设置新的自定义按钮 141 | newList.splice(Number(overId), 0, { 142 | action: newCustomList[Number(activeId)], 143 | type: ItemType.Action, 144 | id: '', 145 | }); 146 | } 147 | else if (activType === 'list' && overType === 'list') { 148 | // 调整顺序 149 | newList = arrayMove(newList, Number(activeId), Number(overId)); 150 | } 151 | else if (activType === 'list' && overType === 'add') { 152 | // 调整顺序 153 | newList = arrayMove(newList, Number(activeId), newList.length - 1); 154 | } 155 | else if (activType === 'custom' && overType === 'custom') { 156 | // 调整自定义按钮顺序 157 | newCustomList = arrayMove(newCustomList, Number(activeId), Number(overId)); 158 | } 159 | else if (activType === 'list' && overType === 'delete') { 160 | // 删除按钮 161 | newList.splice(Number(activeId), 1); 162 | } 163 | else if (activType === 'custom' && overType === 'delete') { 164 | // 删除自定义按钮 165 | newCustomList.splice(Number(activeId), 1); 166 | } 167 | else if (activType === 'all' && overType === 'add') { 168 | // 添加按钮 169 | newList.push( 170 | { type: ItemType.Action, action: buildIn[Number(activeId)], id: '' }, 171 | ); 172 | } 173 | else if (activType === 'custom' && overType === 'add') { 174 | // 添加自定义按钮 175 | newList.push( 176 | { type: ItemType.Action, action: newCustomList[Number(activeId)], id: '' }, 177 | ); 178 | } 179 | else if (activType === 'divider' && overType === 'list') { 180 | // 添加分割线 181 | newList.splice(Number(overId), 0, { 182 | type: ItemType.Divider, 183 | id: '', 184 | }); 185 | } 186 | else if (activType === 'divider' && overType === 'add') { 187 | // 添加分割线 188 | newList.push({ 189 | type: ItemType.Divider, 190 | id: '', 191 | }); 192 | } 193 | else { 194 | return; 195 | } 196 | newList = newList.filter((item, index) => { 197 | if (index > 0) { 198 | if (item.type === ItemType.Divider && newList[index - 1].type === ItemType.Divider) { 199 | return false; 200 | } 201 | } 202 | return true; 203 | }); 204 | newList.forEach((item, index) => { 205 | item.id = `list_${index}`; 206 | }); 207 | update({ 208 | ...formData, 209 | actionList: newList, 210 | customActionList: newCustomList, 211 | }); 212 | } 213 | 214 | function addCustomAction(action: Action) { 215 | update({ 216 | ...formData, 217 | customActionList: [...formData.customActionList, action], 218 | }); 219 | } 220 | 221 | return ( 222 | 230 |
    231 |

    {L.setting.buildIn()}

    232 |
    233 | {buildIn.map((action, i) => ( 234 | 235 | 240 | 241 | ))} 242 | 243 | 252 | 253 |
    254 |
    255 |
    256 |

    {L.setting.custom()}

    257 |
    258 | {formData.customActionList.length 259 | ? ( 260 | `custom_${i}`)} 262 | strategy={horizontalListSortingStrategy} 263 | > 264 | {formData.customActionList.map((action, i) => ( 265 | 266 | 272 | 273 | ))} 274 | 275 | ) 276 | : ( 277 |

    {L.setting.empty()}

    278 | )} 279 |
    280 |
    281 |
    282 |
    283 | 287 | {formData.actionList.map((popoverItem, i) => { 288 | const id = `list_${i}`; 289 | const isOver = overId === id; 290 | 291 | return ( 292 | 297 | {popoverItem.type === ItemType.Action 298 | ? ( 299 | 305 | ) 306 | : ( 307 |
    308 | )} 309 | 310 | ); 311 | })} 312 | 313 | 314 |
    315 | 316 |
    {L.setting.delete()}
    317 |
    318 |
    319 | 320 | 321 | 340 | {activeItem && ( 341 |
    342 | {activeItem.type === ItemType.Action 343 | ? ( 344 | 349 | ) 350 | : ( 351 |
    352 | )} 353 |
    354 | )} 355 | 356 | 357 | ); 358 | }; 359 | 360 | export default Setting; 361 | -------------------------------------------------------------------------------- /main.css: -------------------------------------------------------------------------------- 1 | /* node_modules/.pnpm/codemirror@5.65.16/node_modules/codemirror/lib/codemirror.css */ 2 | .CodeMirror { 3 | font-family: monospace; 4 | height: 300px; 5 | color: black; 6 | direction: ltr; 7 | } 8 | .CodeMirror-lines { 9 | padding: 4px 0; 10 | } 11 | .CodeMirror pre.CodeMirror-line, 12 | .CodeMirror pre.CodeMirror-line-like { 13 | padding: 0 4px; 14 | } 15 | .CodeMirror-scrollbar-filler, 16 | .CodeMirror-gutter-filler { 17 | background-color: white; 18 | } 19 | .CodeMirror-gutters { 20 | border-right: 1px solid #ddd; 21 | background-color: #f7f7f7; 22 | white-space: nowrap; 23 | } 24 | .CodeMirror-linenumbers { 25 | } 26 | .CodeMirror-linenumber { 27 | padding: 0 3px 0 5px; 28 | min-width: 20px; 29 | text-align: right; 30 | color: #999; 31 | white-space: nowrap; 32 | } 33 | .CodeMirror-guttermarker { 34 | color: black; 35 | } 36 | .CodeMirror-guttermarker-subtle { 37 | color: #999; 38 | } 39 | .CodeMirror-cursor { 40 | border-left: 1px solid black; 41 | border-right: none; 42 | width: 0; 43 | } 44 | .CodeMirror div.CodeMirror-secondarycursor { 45 | border-left: 1px solid silver; 46 | } 47 | .cm-fat-cursor .CodeMirror-cursor { 48 | width: auto; 49 | border: 0 !important; 50 | background: #7e7; 51 | } 52 | .cm-fat-cursor div.CodeMirror-cursors { 53 | z-index: 1; 54 | } 55 | .cm-fat-cursor .CodeMirror-line::selection, 56 | .cm-fat-cursor .CodeMirror-line > span::selection, 57 | .cm-fat-cursor .CodeMirror-line > span > span::selection { 58 | background: transparent; 59 | } 60 | .cm-fat-cursor .CodeMirror-line::-moz-selection, 61 | .cm-fat-cursor .CodeMirror-line > span::-moz-selection, 62 | .cm-fat-cursor .CodeMirror-line > span > span::-moz-selection { 63 | background: transparent; 64 | } 65 | .cm-fat-cursor { 66 | caret-color: transparent; 67 | } 68 | @-moz-keyframes blink { 69 | 0% { 70 | } 71 | 50% { 72 | background-color: transparent; 73 | } 74 | 100% { 75 | } 76 | } 77 | @-webkit-keyframes blink { 78 | 0% { 79 | } 80 | 50% { 81 | background-color: transparent; 82 | } 83 | 100% { 84 | } 85 | } 86 | @keyframes blink { 87 | 0% { 88 | } 89 | 50% { 90 | background-color: transparent; 91 | } 92 | 100% { 93 | } 94 | } 95 | .CodeMirror-overwrite .CodeMirror-cursor { 96 | } 97 | .cm-tab { 98 | display: inline-block; 99 | text-decoration: inherit; 100 | } 101 | .CodeMirror-rulers { 102 | position: absolute; 103 | left: 0; 104 | right: 0; 105 | top: -50px; 106 | bottom: 0; 107 | overflow: hidden; 108 | } 109 | .CodeMirror-ruler { 110 | border-left: 1px solid #ccc; 111 | top: 0; 112 | bottom: 0; 113 | position: absolute; 114 | } 115 | .cm-s-default .cm-header { 116 | color: blue; 117 | } 118 | .cm-s-default .cm-quote { 119 | color: #090; 120 | } 121 | .cm-negative { 122 | color: #d44; 123 | } 124 | .cm-positive { 125 | color: #292; 126 | } 127 | .cm-header, 128 | .cm-strong { 129 | font-weight: bold; 130 | } 131 | .cm-em { 132 | font-style: italic; 133 | } 134 | .cm-link { 135 | text-decoration: underline; 136 | } 137 | .cm-strikethrough { 138 | text-decoration: line-through; 139 | } 140 | .cm-s-default .cm-keyword { 141 | color: #708; 142 | } 143 | .cm-s-default .cm-atom { 144 | color: #219; 145 | } 146 | .cm-s-default .cm-number { 147 | color: #164; 148 | } 149 | .cm-s-default .cm-def { 150 | color: #00f; 151 | } 152 | .cm-s-default .cm-variable, 153 | .cm-s-default .cm-punctuation, 154 | .cm-s-default .cm-property, 155 | .cm-s-default .cm-operator { 156 | } 157 | .cm-s-default .cm-variable-2 { 158 | color: #05a; 159 | } 160 | .cm-s-default .cm-variable-3, 161 | .cm-s-default .cm-type { 162 | color: #085; 163 | } 164 | .cm-s-default .cm-comment { 165 | color: #a50; 166 | } 167 | .cm-s-default .cm-string { 168 | color: #a11; 169 | } 170 | .cm-s-default .cm-string-2 { 171 | color: #f50; 172 | } 173 | .cm-s-default .cm-meta { 174 | color: #555; 175 | } 176 | .cm-s-default .cm-qualifier { 177 | color: #555; 178 | } 179 | .cm-s-default .cm-builtin { 180 | color: #30a; 181 | } 182 | .cm-s-default .cm-bracket { 183 | color: #997; 184 | } 185 | .cm-s-default .cm-tag { 186 | color: #170; 187 | } 188 | .cm-s-default .cm-attribute { 189 | color: #00c; 190 | } 191 | .cm-s-default .cm-hr { 192 | color: #999; 193 | } 194 | .cm-s-default .cm-link { 195 | color: #00c; 196 | } 197 | .cm-s-default .cm-error { 198 | color: #f00; 199 | } 200 | .cm-invalidchar { 201 | color: #f00; 202 | } 203 | .CodeMirror-composing { 204 | border-bottom: 2px solid; 205 | } 206 | div.CodeMirror span.CodeMirror-matchingbracket { 207 | color: #0b0; 208 | } 209 | div.CodeMirror span.CodeMirror-nonmatchingbracket { 210 | color: #a22; 211 | } 212 | .CodeMirror-matchingtag { 213 | background: rgba(255, 150, 0, .3); 214 | } 215 | .CodeMirror-activeline-background { 216 | background: #e8f2ff; 217 | } 218 | .CodeMirror { 219 | position: relative; 220 | overflow: hidden; 221 | background: white; 222 | } 223 | .CodeMirror-scroll { 224 | overflow: scroll !important; 225 | margin-bottom: -50px; 226 | margin-right: -50px; 227 | padding-bottom: 50px; 228 | height: 100%; 229 | outline: none; 230 | position: relative; 231 | z-index: 0; 232 | } 233 | .CodeMirror-sizer { 234 | position: relative; 235 | border-right: 50px solid transparent; 236 | } 237 | .CodeMirror-vscrollbar, 238 | .CodeMirror-hscrollbar, 239 | .CodeMirror-scrollbar-filler, 240 | .CodeMirror-gutter-filler { 241 | position: absolute; 242 | z-index: 6; 243 | display: none; 244 | outline: none; 245 | } 246 | .CodeMirror-vscrollbar { 247 | right: 0; 248 | top: 0; 249 | overflow-x: hidden; 250 | overflow-y: scroll; 251 | } 252 | .CodeMirror-hscrollbar { 253 | bottom: 0; 254 | left: 0; 255 | overflow-y: hidden; 256 | overflow-x: scroll; 257 | } 258 | .CodeMirror-scrollbar-filler { 259 | right: 0; 260 | bottom: 0; 261 | } 262 | .CodeMirror-gutter-filler { 263 | left: 0; 264 | bottom: 0; 265 | } 266 | .CodeMirror-gutters { 267 | position: absolute; 268 | left: 0; 269 | top: 0; 270 | min-height: 100%; 271 | z-index: 3; 272 | } 273 | .CodeMirror-gutter { 274 | white-space: normal; 275 | height: 100%; 276 | display: inline-block; 277 | vertical-align: top; 278 | margin-bottom: -50px; 279 | } 280 | .CodeMirror-gutter-wrapper { 281 | position: absolute; 282 | z-index: 4; 283 | background: none !important; 284 | border: none !important; 285 | } 286 | .CodeMirror-gutter-background { 287 | position: absolute; 288 | top: 0; 289 | bottom: 0; 290 | z-index: 4; 291 | } 292 | .CodeMirror-gutter-elt { 293 | position: absolute; 294 | cursor: default; 295 | z-index: 4; 296 | } 297 | .CodeMirror-gutter-wrapper ::selection { 298 | background-color: transparent; 299 | } 300 | .CodeMirror-gutter-wrapper ::-moz-selection { 301 | background-color: transparent; 302 | } 303 | .CodeMirror-lines { 304 | cursor: text; 305 | min-height: 1px; 306 | } 307 | .CodeMirror pre.CodeMirror-line, 308 | .CodeMirror pre.CodeMirror-line-like { 309 | -moz-border-radius: 0; 310 | -webkit-border-radius: 0; 311 | border-radius: 0; 312 | border-width: 0; 313 | background: transparent; 314 | font-family: inherit; 315 | font-size: inherit; 316 | margin: 0; 317 | white-space: pre; 318 | word-wrap: normal; 319 | line-height: inherit; 320 | color: inherit; 321 | z-index: 2; 322 | position: relative; 323 | overflow: visible; 324 | -webkit-tap-highlight-color: transparent; 325 | -webkit-font-variant-ligatures: contextual; 326 | font-variant-ligatures: contextual; 327 | } 328 | .CodeMirror-wrap pre.CodeMirror-line, 329 | .CodeMirror-wrap pre.CodeMirror-line-like { 330 | word-wrap: break-word; 331 | white-space: pre-wrap; 332 | word-break: normal; 333 | } 334 | .CodeMirror-linebackground { 335 | position: absolute; 336 | left: 0; 337 | right: 0; 338 | top: 0; 339 | bottom: 0; 340 | z-index: 0; 341 | } 342 | .CodeMirror-linewidget { 343 | position: relative; 344 | z-index: 2; 345 | padding: 0.1px; 346 | } 347 | .CodeMirror-widget { 348 | } 349 | .CodeMirror-rtl pre { 350 | direction: rtl; 351 | } 352 | .CodeMirror-code { 353 | outline: none; 354 | } 355 | .CodeMirror-scroll, 356 | .CodeMirror-sizer, 357 | .CodeMirror-gutter, 358 | .CodeMirror-gutters, 359 | .CodeMirror-linenumber { 360 | -moz-box-sizing: content-box; 361 | box-sizing: content-box; 362 | } 363 | .CodeMirror-measure { 364 | position: absolute; 365 | width: 100%; 366 | height: 0; 367 | overflow: hidden; 368 | visibility: hidden; 369 | } 370 | .CodeMirror-cursor { 371 | position: absolute; 372 | pointer-events: none; 373 | } 374 | .CodeMirror-measure pre { 375 | position: static; 376 | } 377 | div.CodeMirror-cursors { 378 | visibility: hidden; 379 | position: relative; 380 | z-index: 3; 381 | } 382 | div.CodeMirror-dragcursors { 383 | visibility: visible; 384 | } 385 | .CodeMirror-focused div.CodeMirror-cursors { 386 | visibility: visible; 387 | } 388 | .CodeMirror-selected { 389 | background: #d9d9d9; 390 | } 391 | .CodeMirror-focused .CodeMirror-selected { 392 | background: #d7d4f0; 393 | } 394 | .CodeMirror-crosshair { 395 | cursor: crosshair; 396 | } 397 | .CodeMirror-line::selection, 398 | .CodeMirror-line > span::selection, 399 | .CodeMirror-line > span > span::selection { 400 | background: #d7d4f0; 401 | } 402 | .CodeMirror-line::-moz-selection, 403 | .CodeMirror-line > span::-moz-selection, 404 | .CodeMirror-line > span > span::-moz-selection { 405 | background: #d7d4f0; 406 | } 407 | .cm-searching { 408 | background-color: #ffa; 409 | background-color: rgba(255, 255, 0, .4); 410 | } 411 | .cm-force-border { 412 | padding-right: .1px; 413 | } 414 | @media print { 415 | .CodeMirror div.CodeMirror-cursors { 416 | visibility: hidden; 417 | } 418 | } 419 | .cm-tab-wrap-hack:after { 420 | content: ""; 421 | } 422 | span.CodeMirror-selectedtext { 423 | background: none; 424 | } 425 | 426 | /* node_modules/.pnpm/codemirror@5.65.16/node_modules/codemirror/theme/monokai.css */ 427 | .cm-s-monokai.CodeMirror { 428 | background: #272822; 429 | color: #f8f8f2; 430 | } 431 | .cm-s-monokai div.CodeMirror-selected { 432 | background: #49483E; 433 | } 434 | .cm-s-monokai .CodeMirror-line::selection, 435 | .cm-s-monokai .CodeMirror-line > span::selection, 436 | .cm-s-monokai .CodeMirror-line > span > span::selection { 437 | background: rgba(73, 72, 62, .99); 438 | } 439 | .cm-s-monokai .CodeMirror-line::-moz-selection, 440 | .cm-s-monokai .CodeMirror-line > span::-moz-selection, 441 | .cm-s-monokai .CodeMirror-line > span > span::-moz-selection { 442 | background: rgba(73, 72, 62, .99); 443 | } 444 | .cm-s-monokai .CodeMirror-gutters { 445 | background: #272822; 446 | border-right: 0px; 447 | } 448 | .cm-s-monokai .CodeMirror-guttermarker { 449 | color: white; 450 | } 451 | .cm-s-monokai .CodeMirror-guttermarker-subtle { 452 | color: #d0d0d0; 453 | } 454 | .cm-s-monokai .CodeMirror-linenumber { 455 | color: #d0d0d0; 456 | } 457 | .cm-s-monokai .CodeMirror-cursor { 458 | border-left: 1px solid #f8f8f0; 459 | } 460 | .cm-s-monokai span.cm-comment { 461 | color: #75715e; 462 | } 463 | .cm-s-monokai span.cm-atom { 464 | color: #ae81ff; 465 | } 466 | .cm-s-monokai span.cm-number { 467 | color: #ae81ff; 468 | } 469 | .cm-s-monokai span.cm-comment.cm-attribute { 470 | color: #97b757; 471 | } 472 | .cm-s-monokai span.cm-comment.cm-def { 473 | color: #bc9262; 474 | } 475 | .cm-s-monokai span.cm-comment.cm-tag { 476 | color: #bc6283; 477 | } 478 | .cm-s-monokai span.cm-comment.cm-type { 479 | color: #5998a6; 480 | } 481 | .cm-s-monokai span.cm-property, 482 | .cm-s-monokai span.cm-attribute { 483 | color: #a6e22e; 484 | } 485 | .cm-s-monokai span.cm-keyword { 486 | color: #f92672; 487 | } 488 | .cm-s-monokai span.cm-builtin { 489 | color: #66d9ef; 490 | } 491 | .cm-s-monokai span.cm-string { 492 | color: #e6db74; 493 | } 494 | .cm-s-monokai span.cm-variable { 495 | color: #f8f8f2; 496 | } 497 | .cm-s-monokai span.cm-variable-2 { 498 | color: #9effff; 499 | } 500 | .cm-s-monokai span.cm-variable-3, 501 | .cm-s-monokai span.cm-type { 502 | color: #66d9ef; 503 | } 504 | .cm-s-monokai span.cm-def { 505 | color: #fd971f; 506 | } 507 | .cm-s-monokai span.cm-bracket { 508 | color: #f8f8f2; 509 | } 510 | .cm-s-monokai span.cm-tag { 511 | color: #f92672; 512 | } 513 | .cm-s-monokai span.cm-header { 514 | color: #ae81ff; 515 | } 516 | .cm-s-monokai span.cm-link { 517 | color: #ae81ff; 518 | } 519 | .cm-s-monokai span.cm-error { 520 | background: #f92672; 521 | color: #f8f8f0; 522 | } 523 | .cm-s-monokai .CodeMirror-activeline-background { 524 | background: #373831; 525 | } 526 | .cm-s-monokai .CodeMirror-matchingbracket { 527 | text-decoration: underline; 528 | color: white !important; 529 | } 530 | -------------------------------------------------------------------------------- /i18n/i18n-types.ts: -------------------------------------------------------------------------------- 1 | // This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. 2 | /* eslint-disable */ 3 | import type { BaseTranslation as BaseTranslationType, LocalizedString } from 'typesafe-i18n' 4 | 5 | export type BaseTranslation = BaseTranslationType 6 | export type BaseLocale = 'en' 7 | 8 | export type Locales = 9 | | 'en' 10 | | 'zh' 11 | 12 | export type Translation = RootTranslation 13 | 14 | export type Translations = RootTranslation 15 | 16 | type RootTranslation = { 17 | actions: { 18 | /** 19 | * O​p​e​n​ ​h​e​l​p 20 | */ 21 | help: string 22 | /** 23 | * O​p​e​n​ ​s​e​t​t​i​n​g 24 | */ 25 | setting: string 26 | /** 27 | * A​d​d​ ​b​o​o​k​m​a​r​k 28 | */ 29 | addBookmark: string 30 | /** 31 | * O​p​e​n​ ​b​o​o​k​m​a​r​k 32 | */ 33 | openBookmark: string 34 | /** 35 | * M​o​v​e​ ​l​i​n​e​ ​u​p 36 | */ 37 | lineUp: string 38 | /** 39 | * M​o​v​e​ ​l​i​n​e​ ​d​o​w​n 40 | */ 41 | lineDown: string 42 | /** 43 | * T​o​g​g​l​e​ ​h​i​g​h​l​i​g​h​t 44 | */ 45 | highlight: string 46 | /** 47 | * B​o​l​d 48 | */ 49 | bold: string 50 | /** 51 | * I​t​a​l​i​c​s 52 | */ 53 | italic: string 54 | /** 55 | * C​o​m​m​e​n​t 56 | */ 57 | comment: string 58 | /** 59 | * S​t​r​i​k​e​t​h​r​o​u​g​h 60 | */ 61 | strikethrough: string 62 | /** 63 | * A​d​d​ ​a​t​t​a​c​h​m​e​n​t 64 | */ 65 | addAttach: string 66 | /** 67 | * B​l​o​c​k​q​u​o​t​e 68 | */ 69 | blockquote: string 70 | /** 71 | * C​l​e​a​r​ ​f​o​r​m​a​t 72 | */ 73 | clearFormat: string 74 | /** 75 | * C​u​t 76 | */ 77 | cut: string 78 | /** 79 | * C​o​p​y 80 | */ 81 | copy: string 82 | /** 83 | * P​a​s​t​e 84 | */ 85 | paste: string 86 | /** 87 | * C​o​p​y​ ​H​T​M​L 88 | */ 89 | copyHtml: string 90 | /** 91 | * I​n​s​e​r​t​ ​t​o​d​a​y​'​s​ ​d​a​t​e 92 | */ 93 | date: string 94 | /** 95 | * I​n​s​e​r​t​ ​c​u​r​r​e​n​t​ ​t​i​m​e 96 | */ 97 | time: string 98 | /** 99 | * U​p​p​e​r​c​a​s​e 100 | */ 101 | upperCase: string 102 | /** 103 | * L​o​w​e​r​c​a​s​e 104 | */ 105 | lowerCase: string 106 | /** 107 | * C​a​p​i​t​a​l​ ​c​a​s​e 108 | */ 109 | capitalCase: string 110 | /** 111 | * T​o​g​g​l​e​ ​l​i​s​t 112 | */ 113 | list: string 114 | /** 115 | * T​o​g​g​l​e​ ​o​r​d​e​r​e​d​ ​l​i​s​t 116 | */ 117 | orderedList: string 118 | /** 119 | * M​e​r​g​e​ ​l​i​n​e​s​ ​i​n​t​o​ ​s​i​n​g​l​e​ ​l​i​n​e 120 | */ 121 | mergeLines: string 122 | /** 123 | * S​o​r​t​ ​l​i​n​e​s 124 | */ 125 | sortLines: string 126 | /** 127 | * R​e​v​e​r​s​e​ ​l​i​n​e​s 128 | */ 129 | reverseLines: string 130 | /** 131 | * S​h​u​f​f​l​e​ ​l​i​n​e​s 132 | */ 133 | shuffleLines: string 134 | /** 135 | * C​a​l​c​u​l​a​t​e​ ​m​a​t​h​ ​e​x​p​r​e​s​s​i​o​n 136 | */ 137 | calc: string 138 | /** 139 | * F​i​n​d​ ​i​n​ ​c​u​r​r​e​n​t​ ​n​o​t​e 140 | */ 141 | find: string 142 | /** 143 | * F​i​n​d​ ​a​n​d​ ​r​e​p​l​a​c​e​ ​i​n​ ​c​u​r​r​e​n​t​ ​n​o​t​e 144 | */ 145 | replace: string 146 | /** 147 | * S​e​a​r​c​h​ ​i​n​ ​a​l​l​ ​f​i​l​e​s 148 | */ 149 | search: string 150 | /** 151 | * W​o​r​d​ ​c​o​u​n​t 152 | */ 153 | wordCount: string 154 | /** 155 | * L​i​n​e​ ​c​o​u​n​t 156 | */ 157 | lineCount: string 158 | /** 159 | * T​r​a​n​s​l​a​t​i​o​n​ ​w​i​t​h​ ​B​i​n​g 160 | */ 161 | bingTranslation: string 162 | /** 163 | * T​r​a​n​s​l​a​t​i​o​n​ ​w​i​t​h​ ​G​o​o​g​l​e 164 | */ 165 | googleTranslation: string 166 | /** 167 | * S​e​a​r​c​h​ ​w​i​t​h​ ​G​o​o​g​l​e 168 | */ 169 | google: string 170 | /** 171 | * S​e​a​r​c​h​ ​w​i​t​h​ ​B​a​i​d​u 172 | */ 173 | baidu: string 174 | /** 175 | * S​e​a​r​c​h​ ​w​i​t​h​ ​Z​h​i​h​u 176 | */ 177 | zhihu: string 178 | /** 179 | * S​e​a​r​c​h​ ​w​i​t​h​ ​Q​u​o​r​a 180 | */ 181 | quora: string 182 | /** 183 | * S​e​a​r​c​h​ ​w​i​t​h​ ​B​i​n​g 184 | */ 185 | bing: string 186 | /** 187 | * S​e​a​r​c​h​ ​w​i​t​h​ ​W​i​k​i​p​e​d​i​a 188 | */ 189 | wikipedia: string 190 | /** 191 | * S​e​a​r​c​h​ ​w​i​t​h​ ​W​o​l​f​r​a​m​ ​A​l​p​h​a 192 | */ 193 | wolframAlpha: string 194 | /** 195 | * S​e​a​r​c​h​ ​w​i​t​h​ ​D​u​c​k​D​u​c​k​G​o 196 | */ 197 | duckduckgo: string 198 | } 199 | setting: { 200 | /** 201 | * D​r​a​g​ ​t​h​e​ ​b​u​t​t​o​n​ ​h​e​r​e​ ​t​o​ ​d​e​l​e​t​e 202 | */ 203 | 'delete': string 204 | /** 205 | * B​u​i​l​d​i​n​ ​a​c​t​i​o​n​s 206 | */ 207 | buildIn: string 208 | /** 209 | * C​u​s​t​o​m​ ​a​c​t​i​o​n​s 210 | */ 211 | custom: string 212 | /** 213 | * A​d​d​ ​c​u​s​t​o​m​ ​a​c​t​i​o​n 214 | */ 215 | add: string 216 | /** 217 | * D​i​v​i​d​e​r 218 | */ 219 | divider: string 220 | /** 221 | * U​p​l​o​a​d 222 | */ 223 | upload: string 224 | /** 225 | * A​d​d​ ​c​u​s​t​o​m​ ​a​c​t​i​o​n 226 | */ 227 | customTitle: string 228 | /** 229 | * A​ ​c​u​s​t​o​m​ ​a​c​t​i​o​n​ ​h​a​s​ ​b​e​e​n​ ​a​d​d​e​d​ ​s​u​c​c​e​s​s​f​u​l​l​y 230 | */ 231 | addSuccess: string 232 | /** 233 | * S​o​u​r​c​e​ ​p​l​u​g​i​n​:​ 234 | */ 235 | plugin: string 236 | /** 237 | * C​o​m​m​a​n​d​:​ 238 | */ 239 | command: string 240 | /** 241 | * I​c​o​n​:​ 242 | */ 243 | icon: string 244 | /** 245 | * N​o​ ​c​u​s​t​o​m​ ​a​c​t​i​o​n​s​ ​a​v​a​i​l​a​b​l​e​ ​y​e​t​.​ ​Y​o​u​ ​c​a​n​ ​a​d​d​ ​t​h​e​m​ ​u​s​i​n​g​ ​t​h​e​ ​f​o​r​m​ ​b​e​l​o​w​. 246 | */ 247 | empty: string 248 | /** 249 | * D​i​s​a​b​l​e​ ​t​h​e​ ​s​y​s​t​e​m​ ​t​o​o​l​b​a​r​ ​w​h​e​n​ ​s​e​l​e​c​t​i​n​g​ ​t​e​x​t​ ​(​f​o​r​ ​e​d​i​t​ ​m​o​d​e​) 250 | */ 251 | disableNativeToolbar: string 252 | /** 253 | * O​n​l​y​ ​s​h​o​w​ ​P​o​p​k​i​t​ ​w​h​e​n​ ​s​e​l​e​c​t​i​n​g​ ​t​e​x​t​ ​w​i​t​h​ ​m​o​u​s​e 254 | */ 255 | mouseSelectionOnly: string 256 | /** 257 | * N​o​ ​r​e​s​u​l​t​s​. 258 | */ 259 | noResult: string 260 | /** 261 | * P​l​e​a​s​e​ ​p​i​c​k​ ​a​n​ ​i​t​e​m​.​.​. 262 | */ 263 | pickItem: string 264 | /** 265 | * C​u​s​t​o​m​ ​a​c​t​i​o​n​ ​t​y​p​e 266 | */ 267 | actionType: string 268 | /** 269 | * S​e​l​e​c​t​ ​t​h​e​ ​t​y​p​e​ ​o​f​ ​a​c​t​i​o​n​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​c​r​e​a​t​e 270 | */ 271 | actionTypeDesc: string 272 | /** 273 | * C​o​m​m​a​n​d 274 | */ 275 | commandLabel: string 276 | /** 277 | * H​o​t​k​e​y​s 278 | */ 279 | hotkeysLabel: string 280 | /** 281 | * D​e​s​c​r​i​p​t​i​o​n 282 | */ 283 | descriptionLabel: string 284 | /** 285 | * S​h​o​w​n​ ​w​h​e​n​ ​h​o​v​e​r​i​n​g​ ​o​v​e​r​ ​t​h​e​ ​m​o​u​s​e 286 | */ 287 | descriptionDesc: string 288 | /** 289 | * E​n​t​e​r​ ​d​e​s​c​r​i​p​t​i​o​n 290 | */ 291 | descriptionPlaceholder: string 292 | /** 293 | * P​l​e​a​s​e​ ​s​e​l​e​c​t​ ​a​n​ ​i​c​o​n 294 | */ 295 | iconNotice: string 296 | /** 297 | * P​l​e​a​s​e​ ​s​e​l​e​c​t​ ​a​ ​c​o​m​m​a​n​d 298 | */ 299 | commandNotice: string 300 | /** 301 | * I​n​v​a​l​i​d​ ​c​o​m​m​a​n​d 302 | */ 303 | invalidCommand: string 304 | /** 305 | * P​l​e​a​s​e​ ​s​e​t​ ​h​o​t​k​e​y​s 306 | */ 307 | hotkeysNotice: string 308 | /** 309 | * P​l​e​a​s​e​ ​e​n​t​e​r​ ​a​ ​d​e​s​c​r​i​p​t​i​o​n 310 | */ 311 | descriptionNotice: string 312 | /** 313 | * P​r​e​s​s​ ​t​h​e​ ​c​o​m​b​i​n​a​t​i​o​n​ ​o​f​ ​k​e​y​s​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​u​s​e 314 | */ 315 | hotkeysDesc: string 316 | /** 317 | * D​e​l​e​t​e​ ​h​o​t​k​e​y 318 | */ 319 | deleteHotkey: string 320 | /** 321 | * N​o​t​ ​s​e​t 322 | */ 323 | notSet: string 324 | /** 325 | * C​u​s​t​o​m​ ​h​o​t​k​e​y 326 | */ 327 | customHotkey: string 328 | /** 329 | * C​a​n​c​e​l​ ​s​e​t​t​i​n​g 330 | */ 331 | cancelSetting: string 332 | /** 333 | * R​e​g​u​l​a​r​ ​E​x​p​r​e​s​s​i​o​n​ ​F​i​l​t​e​r 334 | */ 335 | testRegexLabel: string 336 | /** 337 | * C​o​n​t​r​o​l​ ​w​h​e​n​ ​t​h​i​s​ ​a​c​t​i​o​n​ ​i​s​ ​a​v​a​i​l​a​b​l​e​ ​b​y​ ​r​e​g​u​l​a​r​ ​e​x​p​r​e​s​s​i​o​n​.​ ​F​o​r​ ​e​x​a​m​p​l​e​: 338 | */ 339 | testRegexDesc: string 340 | /** 341 | * E​n​t​e​r​ ​r​e​g​u​l​a​r​ ​e​x​p​r​e​s​s​i​o​n​ ​(​o​p​t​i​o​n​a​l​) 342 | */ 343 | testRegexPlaceholder: string 344 | /** 345 | * O​n​l​y​ ​s​h​o​w​s​ ​w​h​e​n​ ​s​e​l​e​c​t​e​d​ ​t​e​x​t​ ​c​o​n​t​a​i​n​s​ ​o​n​l​y​ ​l​o​w​e​r​c​a​s​e​ ​l​e​t​t​e​r​s 346 | */ 347 | testRegexExample1: string 348 | /** 349 | * O​n​l​y​ ​s​h​o​w​s​ ​w​h​e​n​ ​s​e​l​e​c​t​e​d​ ​t​e​x​t​ ​c​o​n​t​a​i​n​s​ ​n​u​m​b​e​r​s 350 | */ 351 | testRegexExample2: string 352 | /** 353 | * O​n​l​y​ ​s​h​o​w​s​ ​w​h​e​n​ ​s​e​l​e​c​t​e​d​ ​t​e​x​t​ ​s​t​a​r​t​s​ ​w​i​t​h​ ​a​ ​h​a​s​h​t​a​g 354 | */ 355 | testRegexExample3: string 356 | /** 357 | * I​n​v​a​l​i​d​ ​r​e​g​u​l​a​r​ ​e​x​p​r​e​s​s​i​o​n 358 | */ 359 | invalidRegex: string 360 | } 361 | } 362 | 363 | export type TranslationFunctions = { 364 | actions: { 365 | /** 366 | * Open help 367 | */ 368 | help: () => LocalizedString 369 | /** 370 | * Open setting 371 | */ 372 | setting: () => LocalizedString 373 | /** 374 | * Add bookmark 375 | */ 376 | addBookmark: () => LocalizedString 377 | /** 378 | * Open bookmark 379 | */ 380 | openBookmark: () => LocalizedString 381 | /** 382 | * Move line up 383 | */ 384 | lineUp: () => LocalizedString 385 | /** 386 | * Move line down 387 | */ 388 | lineDown: () => LocalizedString 389 | /** 390 | * Toggle highlight 391 | */ 392 | highlight: () => LocalizedString 393 | /** 394 | * Bold 395 | */ 396 | bold: () => LocalizedString 397 | /** 398 | * Italics 399 | */ 400 | italic: () => LocalizedString 401 | /** 402 | * Comment 403 | */ 404 | comment: () => LocalizedString 405 | /** 406 | * Strikethrough 407 | */ 408 | strikethrough: () => LocalizedString 409 | /** 410 | * Add attachment 411 | */ 412 | addAttach: () => LocalizedString 413 | /** 414 | * Blockquote 415 | */ 416 | blockquote: () => LocalizedString 417 | /** 418 | * Clear format 419 | */ 420 | clearFormat: () => LocalizedString 421 | /** 422 | * Cut 423 | */ 424 | cut: () => LocalizedString 425 | /** 426 | * Copy 427 | */ 428 | copy: () => LocalizedString 429 | /** 430 | * Paste 431 | */ 432 | paste: () => LocalizedString 433 | /** 434 | * Copy HTML 435 | */ 436 | copyHtml: () => LocalizedString 437 | /** 438 | * Insert today's date 439 | */ 440 | date: () => LocalizedString 441 | /** 442 | * Insert current time 443 | */ 444 | time: () => LocalizedString 445 | /** 446 | * Uppercase 447 | */ 448 | upperCase: () => LocalizedString 449 | /** 450 | * Lowercase 451 | */ 452 | lowerCase: () => LocalizedString 453 | /** 454 | * Capital case 455 | */ 456 | capitalCase: () => LocalizedString 457 | /** 458 | * Toggle list 459 | */ 460 | list: () => LocalizedString 461 | /** 462 | * Toggle ordered list 463 | */ 464 | orderedList: () => LocalizedString 465 | /** 466 | * Merge lines into single line 467 | */ 468 | mergeLines: () => LocalizedString 469 | /** 470 | * Sort lines 471 | */ 472 | sortLines: () => LocalizedString 473 | /** 474 | * Reverse lines 475 | */ 476 | reverseLines: () => LocalizedString 477 | /** 478 | * Shuffle lines 479 | */ 480 | shuffleLines: () => LocalizedString 481 | /** 482 | * Calculate math expression 483 | */ 484 | calc: () => LocalizedString 485 | /** 486 | * Find in current note 487 | */ 488 | find: () => LocalizedString 489 | /** 490 | * Find and replace in current note 491 | */ 492 | replace: () => LocalizedString 493 | /** 494 | * Search in all files 495 | */ 496 | search: () => LocalizedString 497 | /** 498 | * Word count 499 | */ 500 | wordCount: () => LocalizedString 501 | /** 502 | * Line count 503 | */ 504 | lineCount: () => LocalizedString 505 | /** 506 | * Translation with Bing 507 | */ 508 | bingTranslation: () => LocalizedString 509 | /** 510 | * Translation with Google 511 | */ 512 | googleTranslation: () => LocalizedString 513 | /** 514 | * Search with Google 515 | */ 516 | google: () => LocalizedString 517 | /** 518 | * Search with Baidu 519 | */ 520 | baidu: () => LocalizedString 521 | /** 522 | * Search with Zhihu 523 | */ 524 | zhihu: () => LocalizedString 525 | /** 526 | * Search with Quora 527 | */ 528 | quora: () => LocalizedString 529 | /** 530 | * Search with Bing 531 | */ 532 | bing: () => LocalizedString 533 | /** 534 | * Search with Wikipedia 535 | */ 536 | wikipedia: () => LocalizedString 537 | /** 538 | * Search with Wolfram Alpha 539 | */ 540 | wolframAlpha: () => LocalizedString 541 | /** 542 | * Search with DuckDuckGo 543 | */ 544 | duckduckgo: () => LocalizedString 545 | } 546 | setting: { 547 | /** 548 | * Drag the button here to delete 549 | */ 550 | 'delete': () => LocalizedString 551 | /** 552 | * Buildin actions 553 | */ 554 | buildIn: () => LocalizedString 555 | /** 556 | * Custom actions 557 | */ 558 | custom: () => LocalizedString 559 | /** 560 | * Add custom action 561 | */ 562 | add: () => LocalizedString 563 | /** 564 | * Divider 565 | */ 566 | divider: () => LocalizedString 567 | /** 568 | * Upload 569 | */ 570 | upload: () => LocalizedString 571 | /** 572 | * Add custom action 573 | */ 574 | customTitle: () => LocalizedString 575 | /** 576 | * A custom action has been added successfully 577 | */ 578 | addSuccess: () => LocalizedString 579 | /** 580 | * Source plugin: 581 | */ 582 | plugin: () => LocalizedString 583 | /** 584 | * Command: 585 | */ 586 | command: () => LocalizedString 587 | /** 588 | * Icon: 589 | */ 590 | icon: () => LocalizedString 591 | /** 592 | * No custom actions available yet. You can add them using the form below. 593 | */ 594 | empty: () => LocalizedString 595 | /** 596 | * Disable the system toolbar when selecting text (for edit mode) 597 | */ 598 | disableNativeToolbar: () => LocalizedString 599 | /** 600 | * Only show Popkit when selecting text with mouse 601 | */ 602 | mouseSelectionOnly: () => LocalizedString 603 | /** 604 | * No results. 605 | */ 606 | noResult: () => LocalizedString 607 | /** 608 | * Please pick an item... 609 | */ 610 | pickItem: () => LocalizedString 611 | /** 612 | * Custom action type 613 | */ 614 | actionType: () => LocalizedString 615 | /** 616 | * Select the type of action you want to create 617 | */ 618 | actionTypeDesc: () => LocalizedString 619 | /** 620 | * Command 621 | */ 622 | commandLabel: () => LocalizedString 623 | /** 624 | * Hotkeys 625 | */ 626 | hotkeysLabel: () => LocalizedString 627 | /** 628 | * Description 629 | */ 630 | descriptionLabel: () => LocalizedString 631 | /** 632 | * Shown when hovering over the mouse 633 | */ 634 | descriptionDesc: () => LocalizedString 635 | /** 636 | * Enter description 637 | */ 638 | descriptionPlaceholder: () => LocalizedString 639 | /** 640 | * Please select an icon 641 | */ 642 | iconNotice: () => LocalizedString 643 | /** 644 | * Please select a command 645 | */ 646 | commandNotice: () => LocalizedString 647 | /** 648 | * Invalid command 649 | */ 650 | invalidCommand: () => LocalizedString 651 | /** 652 | * Please set hotkeys 653 | */ 654 | hotkeysNotice: () => LocalizedString 655 | /** 656 | * Please enter a description 657 | */ 658 | descriptionNotice: () => LocalizedString 659 | /** 660 | * Press the combination of keys you want to use 661 | */ 662 | hotkeysDesc: () => LocalizedString 663 | /** 664 | * Delete hotkey 665 | */ 666 | deleteHotkey: () => LocalizedString 667 | /** 668 | * Not set 669 | */ 670 | notSet: () => LocalizedString 671 | /** 672 | * Custom hotkey 673 | */ 674 | customHotkey: () => LocalizedString 675 | /** 676 | * Cancel setting 677 | */ 678 | cancelSetting: () => LocalizedString 679 | /** 680 | * Regular Expression Filter 681 | */ 682 | testRegexLabel: () => LocalizedString 683 | /** 684 | * Control when this action is available by regular expression. For example: 685 | */ 686 | testRegexDesc: () => LocalizedString 687 | /** 688 | * Enter regular expression (optional) 689 | */ 690 | testRegexPlaceholder: () => LocalizedString 691 | /** 692 | * Only shows when selected text contains only lowercase letters 693 | */ 694 | testRegexExample1: () => LocalizedString 695 | /** 696 | * Only shows when selected text contains numbers 697 | */ 698 | testRegexExample2: () => LocalizedString 699 | /** 700 | * Only shows when selected text starts with a hashtag 701 | */ 702 | testRegexExample3: () => LocalizedString 703 | /** 704 | * Invalid regular expression 705 | */ 706 | invalidRegex: () => LocalizedString 707 | } 708 | } 709 | 710 | export type Formatters = {} 711 | --------------------------------------------------------------------------------