├── .browserslistrc
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ ├── doc.yml
│ └── publish.yml
├── .gitignore
├── .husky
└── pre-push
├── .npmrc
├── LICENSE
├── README.md
├── babel.config.js
├── package.json
├── pnpm-lock.yaml
├── postcss.config.cjs
├── rollup.config.mjs
├── src
├── index.ts
├── panel
│ ├── index.tsx
│ └── style.module.css
├── toast
│ ├── index.tsx
│ └── style.module.css
├── types
│ ├── css.d.ts
│ └── index.d.ts
└── util
│ ├── base.css
│ ├── index.tsx
│ ├── movable.ts
│ └── theme.module.css
└── tsconfig.json
/.browserslistrc:
--------------------------------------------------------------------------------
1 | Chrome >= 55
2 | Firefox >= 53
3 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 | quote_type = single
12 |
13 | [*.md]
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /*
2 | !/src
3 | !/test
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: [
4 | require.resolve('@gera2ld/plaid/eslint'),
5 | ],
6 | parserOptions: {
7 | project: './tsconfig.json',
8 | },
9 | settings: {
10 | 'import/resolver': {
11 | 'babel-module': {},
12 | },
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/.github/workflows/doc.yml:
--------------------------------------------------------------------------------
1 | name: doc
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: actions/setup-node@v4
15 | with:
16 | node-version: '20'
17 | - uses: marceloprado/has-changed-path@v1
18 | id: changed-src
19 | with:
20 | paths: package.json src
21 | - uses: pnpm/action-setup@v3
22 | if: steps.changed-src.outputs.changed == 'true'
23 | with:
24 | version: 8
25 | - name: Build docs
26 | if: steps.changed-src.outputs.changed == 'true'
27 | run: pnpm i && pnpm build:docs
28 | - name: Deploy to GitHub Pages
29 | if: steps.changed-src.outputs.changed == 'true'
30 | uses: JamesIves/github-pages-deploy-action@v4
31 | with:
32 | branch: gh-pages
33 | folder: docs
34 | single-commit: true
35 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to npmjs
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-node@v4
14 | with:
15 | node-version: '20'
16 | registry-url: 'https://registry.npmjs.org'
17 | - uses: pnpm/action-setup@v3
18 | with:
19 | version: 8
20 | - run: pnpm i && pnpm publish --no-git-checks
21 | env:
22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log
3 | /.idea
4 | /dist
5 | /.nyc_output
6 | /coverage
7 | /types
8 | /docs
9 |
--------------------------------------------------------------------------------
/.husky/pre-push:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npm run lint
5 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | shamefully-hoist = true
2 | strict-peer-dependencies = false
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Gerald
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @violentmonkey/ui
2 |
3 | [](https://npm.im/@violentmonkey/ui)
4 | 
5 |
6 | Common UI for userscripts, working in Violentmonkey as well as other userscript managers.
7 |
8 | ## Dependencies
9 |
10 | - [@violentmonkey/dom](https://github.com/violentmonkey/vm-dom)
11 |
12 | ## Usage
13 |
14 | First, include dependencies:
15 |
16 | ```js
17 | // ...
18 | // @require https://cdn.jsdelivr.net/combine/npm/@violentmonkey/dom@2,npm/@violentmonkey/ui@0.7
19 | // ...
20 | ```
21 |
22 | Then use it like so, all exports can be accessed under namespace `VM`:
23 |
24 | ```js
25 | VM.showToast('hello');
26 | VM.showToast(VM.h('div', {}, 'hello, world'));
27 | ```
28 |
29 | ### Toast
30 |
31 | ```js
32 | const toast = VM.showToast(VM.h('div', {}, 'hello'), {
33 | theme: 'dark', // or 'light'
34 | duration: 2000, // or 0 to manually close it
35 | });
36 |
37 | // Manually close it
38 | toast.close();
39 | ```
40 |
41 | ### Panel
42 |
43 | ```js
44 | const panel = VM.getPanel({
45 | content: VM.h('div', {}, 'This is a panel'),
46 | theme: 'light',
47 | });
48 | panel.wrapper.style.top = '100px';
49 |
50 | // Show panel
51 | panel.show();
52 |
53 | // Hide panel
54 | panel.hide();
55 |
56 | // Allow panel to be moved by mouse dragging
57 | panel.setMovable(true);
58 | ```
59 |
60 | ### SolidJS
61 |
62 | It is recommended to initialize a userscript project using [generator-userscript](https://github.com/violentmonkey/generator-userscript) and use [solid-js](https://solidjs.com/).
63 |
64 | ```js
65 | import { render } from 'solid-js/web';
66 |
67 | const panel = VM.getPanel({ theme: 'light' });
68 | panel.wrapper.style.top = '100px';
69 | render(() => , panel.body);
70 | panel.show();
71 | ```
72 |
73 | ### JSX for @violentmonkey/dom
74 |
75 | **Not recommended** as it is not compatible with [solid-js](https://solidjs.com/) integrated in [generator-userscript](https://github.com/violentmonkey/generator-userscript).
76 |
77 | Use with JSX and bundlers, for example:
78 |
79 | ```js
80 | // .babelrc.js
81 | {
82 | plugins: [
83 | // JSX
84 | ['@babel/plugin-transform-react-jsx', {
85 | pragma: 'VM.h',
86 | pragmaFrag: 'VM.Fragment',
87 | }],
88 | ],
89 | }
90 | ```
91 |
92 | ```js
93 | VM.showToast(
hello, world
);
94 | ```
95 |
96 | ## API
97 |
98 | [](https://www.jsdocs.io/package/@violentmonkey/ui)
99 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: require.resolve('@gera2ld/plaid/config/babelrc-base'),
3 | presets: ['@babel/preset-typescript'],
4 | plugins: [
5 | [
6 | '@babel/plugin-transform-react-jsx',
7 | {
8 | pragma: 'VM.h',
9 | pragmaFrag: 'VM.Fragment',
10 | },
11 | ],
12 | ].filter(Boolean),
13 | };
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@violentmonkey/ui",
3 | "version": "0.7.9",
4 | "description": "Common UI for userscripts in Violentmonkey",
5 | "author": "Gerald ",
6 | "license": "ISC",
7 | "scripts": {
8 | "prepare": "husky",
9 | "dev": "rollup -wc rollup.config.mjs",
10 | "ci": "run-s lint",
11 | "build": "run-s ci clean build:types build:js",
12 | "format": "prettier --ignore-path .eslintignore --write .",
13 | "lint": "prettier --ignore-path .eslintignore --check . && eslint --ext .ts,tsx src",
14 | "prepublishOnly": "run-s build",
15 | "clean": "del-cli dist types",
16 | "build:js": "rollup -c rollup.config.mjs",
17 | "build:types": "tsc",
18 | "build:docs": "typedoc src/index.ts"
19 | },
20 | "repository": "git@github.com:violentmonkey/vm-ui.git",
21 | "publishConfig": {
22 | "access": "public",
23 | "registry": "https://registry.npmjs.org/"
24 | },
25 | "unpkg": "dist/index.js",
26 | "jsdelivr": "dist/index.js",
27 | "typings": "types/index.d.ts",
28 | "main": "dist/index.js",
29 | "module": "dist/index.mjs",
30 | "files": [
31 | "dist",
32 | "types"
33 | ],
34 | "devDependencies": {
35 | "@babel/plugin-transform-react-jsx": "^7.23.4",
36 | "@gera2ld/plaid": "~2.7.0",
37 | "@gera2ld/plaid-rollup": "~2.7.0",
38 | "del-cli": "^5.1.0",
39 | "husky": "^9.0.11",
40 | "typedoc": "^0.25.12"
41 | },
42 | "dependencies": {
43 | "@babel/runtime": "^7.24.1",
44 | "@violentmonkey/dom": "^2.1.7"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'autoprefixer': {},
4 | 'postcss-calc': {},
5 | 'postcss-nested': {},
6 | '@unocss/postcss': {},
7 | },
8 | };
--------------------------------------------------------------------------------
/rollup.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineExternal, definePlugins } from '@gera2ld/plaid-rollup';
2 | import { defineConfig } from 'rollup';
3 | import pkg from './package.json' assert { type: 'json' };
4 |
5 | const banner = `/*! ${pkg.name} v${pkg.version} | ${pkg.license} License */`;
6 |
7 | const external = defineExternal(['@violentmonkey/dom']);
8 | const bundleOptions = {
9 | extend: true,
10 | esModule: false,
11 | };
12 | const postcssOptions = {
13 | inject: false,
14 | minimize: true,
15 | modules: {
16 | generateScopedName: 'vmui-[hash:base64:6]',
17 | },
18 | };
19 | const replaceValues = {
20 | 'process.env.VERSION': pkg.version,
21 | };
22 |
23 | export default defineConfig([
24 | {
25 | input: 'src/index.ts',
26 | plugins: definePlugins({
27 | esm: true,
28 | postcss: postcssOptions,
29 | replaceValues,
30 | }),
31 | external,
32 | output: {
33 | format: 'esm',
34 | file: `dist/index.mjs`,
35 | banner,
36 | },
37 | },
38 | {
39 | input: 'src/index.ts',
40 | plugins: definePlugins({
41 | esm: true,
42 | postcss: postcssOptions,
43 | replaceValues,
44 | }),
45 | external,
46 | output: {
47 | format: 'iife',
48 | file: `dist/index.js`,
49 | name: 'VM',
50 | banner,
51 | globals: {
52 | '@violentmonkey/dom': 'VM',
53 | },
54 | ...bundleOptions,
55 | },
56 | },
57 | ]);
58 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export const versions = Object.assign(
2 | (typeof VM !== 'undefined' && VM?.versions) || {},
3 | {
4 | ui: 'process.env.VERSION',
5 | },
6 | );
7 |
8 | if (typeof VM === 'undefined' || VM?.versions?.dom?.split('.')[0] !== '2') {
9 | throw new Error(`\
10 | [VM-UI] @violentmonkey/dom@2 is required
11 | Please include following code in your metadata:
12 |
13 | // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
14 | // @require https://cdn.jsdelivr.net/npm/@violentmonkey/ui@process.env.VERSION
15 | `);
16 | }
17 |
18 | export * from './util';
19 | export * from './toast';
20 | export * from './panel';
21 |
--------------------------------------------------------------------------------
/src/panel/index.tsx:
--------------------------------------------------------------------------------
1 | import { VChild } from '@gera2ld/jsx-dom';
2 | import { m } from '@violentmonkey/dom';
3 | import {
4 | classNames,
5 | getHostElement,
6 | IHostElementResult,
7 | themes,
8 | themeCss,
9 | Movable,
10 | MovableOptions,
11 | } from '../util';
12 | import styles, { stylesheet } from './style.module.css';
13 |
14 | export interface IPanelOptions {
15 | /**
16 | * Whether to create the toast with ShadowDOM.
17 | * Note that CSS may not work with ShadowDOM in pages with strict CSP limits.
18 | */
19 | shadow?: boolean;
20 |
21 | /**
22 | * Initial DOM content of panel body.
23 | */
24 | content?: VChild;
25 |
26 | /**
27 | * Additional CSS for the toast.
28 | * `:host` can be used to match the host element.
29 | */
30 | style?: string | ((id: string) => string);
31 |
32 | /**
33 | * Additional className for the toast root element
34 | */
35 | className?: string;
36 |
37 | /**
38 | * Apply built-in themes, default as `light`.
39 | * Available values are `light` and `dark`, any other value will disable the theme CSS.
40 | */
41 | theme?: string;
42 | }
43 |
44 | export interface IPanelResult extends IHostElementResult {
45 | /**
46 | * The wrapper element that should be positioned. It should be as simple as possible and let the body to style itself.
47 | */
48 | wrapper: HTMLElement;
49 | /**
50 | * The container of contents. It is recommended to style your panel box here.
51 | */
52 | body: HTMLElement;
53 | /**
54 | * Empty the panel body.
55 | */
56 | clear: () => void;
57 | /**
58 | * Append elements to the panel body, shorthand for `panel.body.append(...)`.
59 | */
60 | append: (...args: VChild[]) => void;
61 | /**
62 | * Replace the content of panel body by clearing it first and then {@link append}.
63 | */
64 | setContent: (...args: VChild[]) => void;
65 | /**
66 | * Whether this panel can be moved by mouse dragging.
67 | */
68 | setMovable: (toggle: boolean, options?: Partial) => void;
69 | }
70 |
71 | export function getPanel(options?: IPanelOptions): IPanelResult {
72 | options = {
73 | shadow: true,
74 | theme: 'light',
75 | ...options,
76 | };
77 | const hostElem = getHostElement(options.shadow);
78 | const body = m(
79 | ,
82 | ) as HTMLElement;
83 | const wrapper = m(
84 |
85 | {body}
86 | ,
87 | ) as HTMLElement;
88 | let { style } = options;
89 | if (typeof style === 'function') style = style(hostElem.id);
90 | hostElem.addStyle([stylesheet, themeCss, style].filter(Boolean).join('\n'));
91 | hostElem.root.append(wrapper);
92 | const clear = () => {
93 | while (body.firstChild) body.firstChild.remove();
94 | };
95 | const append = (...args: VChild[]) => {
96 | body.append(...args.map(m).filter(Boolean));
97 | };
98 | const setContent = (...args: VChild[]) => {
99 | clear();
100 | append(...args);
101 | };
102 | if (options.content) setContent(options.content);
103 |
104 | let movable: Movable;
105 | const setMovable: IPanelResult['setMovable'] = (toggle, options) => {
106 | movable ||= new Movable(wrapper);
107 | if (options) movable.setOptions(options);
108 | if (toggle) {
109 | movable.enable();
110 | } else {
111 | movable.disable();
112 | }
113 | };
114 |
115 | return {
116 | ...hostElem,
117 | tag: 'VM.getPanel',
118 | wrapper,
119 | body,
120 | clear,
121 | append,
122 | setContent,
123 | setMovable,
124 | };
125 | }
126 |
--------------------------------------------------------------------------------
/src/panel/style.module.css:
--------------------------------------------------------------------------------
1 | .panel {
2 | position: fixed;
3 | z-index: 10000;
4 | color: #333;
5 | }
6 |
7 | .body {
8 | position: relative;
9 | display: block;
10 | padding: 8px;
11 | word-break: break-word;
12 | }
13 |
--------------------------------------------------------------------------------
/src/toast/index.tsx:
--------------------------------------------------------------------------------
1 | import { VChild } from '@gera2ld/jsx-dom';
2 | import { m } from '@violentmonkey/dom';
3 | import {
4 | classNames,
5 | getHostElement,
6 | IHostElementResult,
7 | themes,
8 | themeCss,
9 | } from '../util';
10 | import styles, { stylesheet } from './style.module.css';
11 |
12 | export interface IToastOptions {
13 | /**
14 | * The duration for the toast to show.
15 | */
16 | duration: number;
17 |
18 | /**
19 | * Whether to create the toast with ShadowDOM.
20 | * Note that CSS may not work with ShadowDOM in pages with strict CSP limits.
21 | */
22 | shadow: boolean;
23 |
24 | /**
25 | * Apply built-in themes, default as `light`.
26 | * Available values are `light` and `dark`, any other value will disable the theme CSS.
27 | */
28 | theme: string;
29 |
30 | /**
31 | * Additional className for the toast root element
32 | */
33 | className?: string;
34 |
35 | /**
36 | * Additional CSS for the toast.
37 | * `:host` can be used to match the host element.
38 | */
39 | style?: string | ((id: string) => string);
40 |
41 | /**
42 | * Hook before showing the toast, e.g. adding a fade-in transition.
43 | */
44 | beforeEnter?: (result: IToastResult) => Promise;
45 |
46 | /**
47 | * Hook before closing the toast, e.g. adding a fade-out transition.
48 | */
49 | beforeClose?: (result: IToastResult) => Promise;
50 | }
51 |
52 | export interface IToastResult extends IHostElementResult {
53 | body: HTMLElement;
54 | close: () => void;
55 | }
56 |
57 | export function showToast(
58 | content: VChild,
59 | options?: Partial,
60 | ): IToastResult {
61 | options = {
62 | duration: 2000,
63 | shadow: true,
64 | theme: 'light',
65 | beforeEnter: defaultBeforeEnter,
66 | beforeClose: defaultBeforeClose,
67 | ...options,
68 | };
69 | const hostElem = getHostElement(options.shadow);
70 | const { dispose, addStyle } = hostElem;
71 | const body = m(
72 |
79 | {content}
80 | ,
81 | ) as HTMLElement;
82 | hostElem.root.append(body);
83 | let { style } = options;
84 | if (typeof style === 'function') style = style(hostElem.id);
85 | addStyle([stylesheet, themeCss, style].filter(Boolean).join('\n'));
86 | let closed = false;
87 | const result: IToastResult = {
88 | ...hostElem,
89 | tag: 'VM.showToast',
90 | body,
91 | close,
92 | };
93 | result.show();
94 | (async () => {
95 | await options.beforeEnter?.(result);
96 | if (options.duration) {
97 | setTimeout(close, options.duration);
98 | }
99 | })();
100 | return result;
101 |
102 | async function close() {
103 | if (closed) return;
104 | closed = true;
105 | await options.beforeClose?.(result);
106 | dispose();
107 | }
108 | }
109 |
110 | async function defaultBeforeEnter(result: IToastResult) {
111 | const { body } = result;
112 | body.style.transition = 'opacity .2s';
113 | body.style.opacity = '0';
114 | await sleep(0);
115 | body.style.opacity = '1';
116 | await sleep(200);
117 | }
118 |
119 | async function defaultBeforeClose(result: IToastResult) {
120 | const { body } = result;
121 | body.style.transition = 'opacity .2s';
122 | body.style.opacity = '0';
123 | await sleep(200);
124 | }
125 |
126 | async function sleep(time: number) {
127 | return new Promise((resolve) => setTimeout(resolve, time));
128 | }
129 |
--------------------------------------------------------------------------------
/src/toast/style.module.css:
--------------------------------------------------------------------------------
1 | .toast {
2 | position: fixed;
3 | top: 50%;
4 | left: 50%;
5 | transform: translate(-50%, -50%);
6 | padding: 8px 16px;
7 | z-index: 10000;
8 | }
9 |
--------------------------------------------------------------------------------
/src/types/css.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.module.css' {
2 | /**
3 | * Generated CSS for CSS modules
4 | */
5 | export const stylesheet: string;
6 | /**
7 | * Exported classes
8 | */
9 | const classMap: {
10 | [key: string]: string;
11 | };
12 | export default classMap;
13 | }
14 |
15 | declare module '*.css' {
16 | /**
17 | * Generated CSS
18 | */
19 | const css: string;
20 | export default css;
21 | }
22 |
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | const VM: {
3 | versions: Record;
4 | };
5 |
6 | namespace JSX {
7 | /**
8 | * JSX.Element can be different based on pragma in babel config:
9 | * - VChild - when jsxFactory is VM.h
10 | * - DomNode - when jsxFactory is VM.hm
11 | */
12 | type Element = import('@gera2ld/jsx-dom').VChild;
13 | }
14 |
15 | const GM_addStyle: (css: string) => HTMLStyleElement;
16 | }
17 |
18 | export {};
19 |
--------------------------------------------------------------------------------
/src/util/base.css:
--------------------------------------------------------------------------------
1 | :host {
2 | all: initial;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
4 | 'Helvetica Neue', sans-serif;
5 | font-size: 16px;
6 | line-height: 1.5;
7 | }
8 |
--------------------------------------------------------------------------------
/src/util/index.tsx:
--------------------------------------------------------------------------------
1 | import { m, h } from '@violentmonkey/dom';
2 | import baseCss from './base.css';
3 | import themes, { stylesheet as themeCss } from './theme.module.css';
4 |
5 | export { themes, themeCss };
6 | export * from './movable';
7 |
8 | export interface IHostElementResult {
9 | id: string;
10 | tag: string;
11 | shadow: boolean;
12 | host: HTMLElement;
13 | root: ShadowRoot | HTMLElement;
14 | addStyle: (css: string) => void;
15 | show: () => void;
16 | hide: () => void;
17 | dispose: () => void;
18 | }
19 |
20 | export function getHostElement(shadow = true): IHostElementResult {
21 | const id = getUniqueId('vmui-');
22 | const host = m(h(id, { id })) as HTMLElement;
23 | let root: ShadowRoot | HTMLElement;
24 | if (shadow) {
25 | root = host.attachShadow({ mode: 'open' });
26 | } else {
27 | root = m(h(id, {})) as HTMLElement;
28 | host.append(root);
29 | }
30 | const styles: HTMLStyleElement[] = [];
31 | const addStyle = (css: string) => {
32 | if (!shadow && typeof GM_addStyle === 'function') {
33 | styles.push(GM_addStyle(css.replace(/:host\b/g, `#${id} `)));
34 | } else {
35 | root.append(m());
36 | }
37 | };
38 | const dispose = () => {
39 | host.remove();
40 | styles.forEach((style) => style.remove());
41 | };
42 | addStyle(baseCss);
43 | const result: IHostElementResult = {
44 | id,
45 | tag: 'VM.getHostElement',
46 | shadow,
47 | host,
48 | root,
49 | addStyle,
50 | dispose,
51 | show() {
52 | appendToBody(this.tag, this.host);
53 | },
54 | hide() {
55 | this.host.remove();
56 | },
57 | };
58 | return result;
59 | }
60 |
61 | export function appendToBody(
62 | tag: string,
63 | ...children: (string | Node)[]
64 | ): void {
65 | if (!document.body) {
66 | console.warn(`[${tag}] document.body is not ready yet, operation skipped.`);
67 | return;
68 | }
69 | document.body.append(...children);
70 | }
71 |
72 | export function getUniqueId(prefix = '') {
73 | return prefix + Math.random().toString(36).slice(2, 8);
74 | }
75 |
76 | export function classNames(names: string[]) {
77 | return names.filter(Boolean).join(' ');
78 | }
79 |
--------------------------------------------------------------------------------
/src/util/movable.ts:
--------------------------------------------------------------------------------
1 | export interface MovableOrigin {
2 | x: 'auto' | 'start' | 'end';
3 | y: 'auto' | 'start' | 'end';
4 | }
5 |
6 | export interface MovableOptions {
7 | origin: MovableOrigin;
8 | onMoved?: () => void;
9 | }
10 |
11 | export class Movable {
12 | static defaultOptions: {
13 | origin: MovableOrigin;
14 | } = {
15 | origin: { x: 'auto', y: 'auto' },
16 | };
17 |
18 | private dragging: { x: number; y: number };
19 |
20 | private options: MovableOptions;
21 |
22 | constructor(
23 | private el: HTMLElement,
24 | options?: Partial,
25 | ) {
26 | this.setOptions(options);
27 | }
28 |
29 | setOptions(options: Partial) {
30 | this.options = {
31 | ...Movable.defaultOptions,
32 | ...options,
33 | };
34 | }
35 |
36 | onMouseDown = (e: MouseEvent) => {
37 | e.preventDefault();
38 | e.stopPropagation();
39 | const { x, y } = this.el.getBoundingClientRect();
40 | const { clientX, clientY } = e;
41 | this.dragging = { x: clientX - x, y: clientY - y };
42 | document.addEventListener('mousemove', this.onMouseMove);
43 | document.addEventListener('mouseup', this.onMouseUp);
44 | };
45 |
46 | onMouseMove = (e: MouseEvent) => {
47 | if (!this.dragging) return;
48 | const { x, y } = this.dragging;
49 | const { clientX, clientY } = e;
50 | const position = {
51 | top: 'auto',
52 | left: 'auto',
53 | right: 'auto',
54 | bottom: 'auto',
55 | };
56 | const { clientWidth, clientHeight } = document.documentElement;
57 | const width = this.el.offsetWidth;
58 | const height = this.el.offsetHeight;
59 | const left = Math.min(clientWidth - width, Math.max(0, clientX - x));
60 | const top = Math.min(clientHeight - height, Math.max(0, clientY - y));
61 | const { origin } = this.options;
62 | if (
63 | origin.x === 'start' ||
64 | (origin.x === 'auto' && left + left + width < clientWidth)
65 | ) {
66 | position.left = `${left}px`;
67 | } else {
68 | position.right = `${clientWidth - left - width}px`;
69 | }
70 | if (
71 | origin.y === 'start' ||
72 | (origin.y === 'auto' && top + top + height < clientHeight)
73 | ) {
74 | position.top = `${top}px`;
75 | } else {
76 | position.bottom = `${clientHeight - top - height}px`;
77 | }
78 | Object.assign(this.el.style, position);
79 | };
80 |
81 | onMouseUp = () => {
82 | this.dragging = null;
83 | document.removeEventListener('mousemove', this.onMouseMove);
84 | document.removeEventListener('mouseup', this.onMouseUp);
85 | this.options.onMoved?.();
86 | };
87 |
88 | enable() {
89 | this.el.addEventListener('mousedown', this.onMouseDown);
90 | }
91 |
92 | disable() {
93 | this.dragging = undefined;
94 | this.el.removeEventListener('mousedown', this.onMouseDown);
95 | document.removeEventListener('mousemove', this.onMouseMove);
96 | document.removeEventListener('mouseup', this.onMouseUp);
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/util/theme.module.css:
--------------------------------------------------------------------------------
1 | .dark {
2 | background: rgba(0, 0, 0, 0.8);
3 | color: white;
4 | border: 1px solid #333;
5 | box-shadow: 0 0 8px #333;
6 | }
7 |
8 | .light {
9 | background: white;
10 | color: #333;
11 | border: 1px solid #ddd;
12 | box-shadow: 0 0 8px #ddd;
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "es6",
5 | "moduleResolution": "node",
6 | "declaration": true,
7 | "emitDeclarationOnly": true,
8 | "outDir": "types",
9 | "allowSyntheticDefaultImports": true,
10 | "jsx": "react",
11 | "jsxFactory": "VM.h",
12 | "jsxFragmentFactory": "VM.Fragment"
13 | },
14 | "include": ["src/**/*.ts", "src/**/*.tsx"]
15 | }
16 |
--------------------------------------------------------------------------------