├── .build.mjs ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs └── README.zh.md ├── examples ├── nuxt-basic │ ├── .eslintrc │ ├── .gitignore │ ├── .npmrc │ ├── .stackblitzrc │ ├── LICENSE │ ├── README.md │ ├── app.vue │ ├── components │ │ ├── Counter.vue │ │ ├── DarkToggle.vue │ │ ├── Footer.vue │ │ ├── InputEntry.vue │ │ ├── Logos.vue │ │ ├── PageView.vue │ │ └── ToggleEle.vue │ ├── composables │ │ ├── count.ts │ │ ├── cursor.ts │ │ └── user.ts │ ├── config │ │ └── pwa.ts │ ├── constants │ │ └── index.ts │ ├── layouts │ │ ├── README.md │ │ ├── default.vue │ │ └── home.vue │ ├── netlify.toml │ ├── nuxt.config.ts │ ├── package.json │ ├── pages │ │ ├── [...all].vue │ │ ├── hi │ │ │ └── [id].vue │ │ └── index.vue │ ├── plugins │ │ └── cursor-directive.ts │ ├── pnpm-lock.yaml │ ├── public │ │ ├── apple-touch-icon.png │ │ ├── favicon.ico │ │ ├── maskable-icon.png │ │ ├── nuxt.svg │ │ ├── pwa-192x192.png │ │ ├── pwa-512x512.png │ │ ├── robots.txt │ │ └── vite.png │ ├── server │ │ ├── api │ │ │ └── pageview.ts │ │ └── tsconfig.json │ ├── tsconfig.json │ └── uno.config.ts └── react-basic │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .npmrc │ ├── index.html │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ └── vite.svg │ ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── index.css │ ├── main.tsx │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── uno.config.ts │ └── vite.config.ts ├── package.json ├── playground ├── .vscode │ └── extensions.json ├── index.html ├── public │ ├── ipad-cursor-dark.svg │ ├── ipad-cursor.svg │ ├── og-image.jpg │ └── screenshot.gif ├── src │ ├── App.vue │ ├── assets │ │ └── vue.svg │ ├── codes │ │ ├── basic.ts │ │ ├── blur-effect.ts │ │ ├── custom-anime-speed.ts │ │ ├── custom-bg.ts │ │ ├── custom-border.ts │ │ └── install.ts │ ├── components │ │ ├── AppHeader.vue │ │ ├── CodeBox │ │ │ └── index.vue │ │ └── LanguageIcon.vue │ ├── constants │ │ └── index.ts │ ├── main.ts │ ├── pages │ │ └── index.vue │ ├── style.css │ ├── types │ │ └── lang.ts │ ├── utils │ │ └── clipboard.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── uno.config.ts └── vite.config.ts ├── pnpm-lock.yaml ├── rollup.config.js ├── src ├── index.d.ts ├── index.ts ├── react │ ├── README.md │ ├── index.tsx │ └── tsconfig.json └── vue │ └── index.ts ├── tsconfig.json └── uno.config.ts /.build.mjs: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import fs from "fs"; 4 | import { readFile, writeFile } from "fs/promises"; 5 | import { execa } from "execa"; 6 | import { execSync } from "child_process"; 7 | import chalk from "chalk"; 8 | import prompts from "prompts"; 9 | import brotliSize from "brotli-size"; 10 | import prettyBytes from "pretty-bytes"; 11 | 12 | const info = (m) => console.log(chalk.blue(m)); 13 | const error = (m) => console.log(chalk.red(m)); 14 | const success = (m) => console.log(chalk.green(m)); 15 | const details = (m) => console.log(chalk.pink(m)); 16 | 17 | const __filename = fileURLToPath(import.meta.url); 18 | const __dirname = dirname(__filename); 19 | const rootDir = resolve(__dirname); 20 | const isPublishing = process.argv[2] === "--publish"; 21 | 22 | async function clean() { 23 | if (!fs.existsSync(`${rootDir}/dist`)) return; 24 | await execa("shx", ["rm", "-rf", `${rootDir}/dist`]); 25 | } 26 | 27 | async function baseBuild() { 28 | info("Rolling up primary package"); 29 | await execa("npx", ["rollup", "-c", "rollup.config.js"]); 30 | } 31 | 32 | async function baseBuildMin() { 33 | info("Minifying primary package"); 34 | await execa("npx", [ 35 | "rollup", 36 | "-c", 37 | "rollup.config.js", 38 | "--environment", 39 | "MIN:true", 40 | ]); 41 | } 42 | 43 | async function vueBuild() { 44 | info("Rolling up Vue package"); 45 | await execa("npx", [ 46 | "rollup", 47 | "-c", 48 | "rollup.config.js", 49 | "--environment", 50 | "FRAMEWORK:vue", 51 | ]); 52 | /** 53 | * This is a super hack — for some reason these imports need to be explicitly 54 | * to .mjs files so...we make it so. 55 | */ 56 | let raw = await readFile(resolve(rootDir, "dist/vue/index.mjs"), "utf8"); 57 | raw = raw.replace("from '../index'", "from '../index.mjs'"); 58 | await writeFile(resolve(rootDir, "dist/vue/index.mjs"), raw); 59 | } 60 | 61 | async function reactBuild() { 62 | info("Rolling up React package"); 63 | await execa("npx", [ 64 | "rollup", 65 | "-c", 66 | "rollup.config.js", 67 | "--environment", 68 | "FRAMEWORK:react", 69 | ]); 70 | /** 71 | * This is a super hack — for some reason these imports need to be explicitly 72 | * to .mjs files so...we make it so. 73 | */ 74 | let raw = await readFile(resolve(rootDir, "dist/react/index.mjs"), "utf8"); 75 | raw = raw.replace("from '../index'", "from '../index.mjs'"); 76 | await writeFile(resolve(rootDir, "dist/react/index.mjs"), raw); 77 | } 78 | 79 | async function declarationsBuild() { 80 | info("Outputting declarations"); 81 | await execa("npx", [ 82 | "rollup", 83 | "-c", 84 | "rollup.config.js", 85 | "--environment", 86 | "DECLARATIONS:true", 87 | ]); 88 | } 89 | 90 | async function bundleDeclarations() { 91 | info("Bundling declarations"); 92 | await execa("shx", [ 93 | "mv", 94 | `${rootDir}/dist/src/index.d.ts`, 95 | `${rootDir}/dist/index.d.ts`, 96 | ]); 97 | await execa("shx", [ 98 | "cp", 99 | `${rootDir}/dist/index.d.ts`, 100 | `${rootDir}/src/index.d.ts`, 101 | ]); 102 | await execa("shx", [ 103 | "mv", 104 | `${rootDir}/dist/src/vue/index.d.ts`, 105 | `${rootDir}/dist/vue/index.d.ts`, 106 | ]); 107 | await execa("shx", ["rm", "-rf", `${rootDir}/dist/src`]); 108 | await execa("shx", ["rm", `${rootDir}/dist/index.js`]); 109 | await execa("pnpm", ["build:react-ts"]); 110 | } 111 | 112 | async function addPackageJSON() { 113 | info("Writing package.json"); 114 | const raw = await readFile(resolve(rootDir, "package.json"), "utf8"); 115 | const packageJSON = JSON.parse(raw); 116 | delete packageJSON.private; 117 | delete packageJSON.devDependencies; 118 | delete packageJSON.scripts; 119 | await writeFile( 120 | resolve(rootDir, "dist/package.json"), 121 | JSON.stringify(packageJSON, null, 2) 122 | ); 123 | } 124 | 125 | async function addAssets() { 126 | info("Writing readme and license."); 127 | await execa("shx", [ 128 | "cp", 129 | `${rootDir}/README.md`, 130 | `${rootDir}/dist/README.md`, 131 | ]); 132 | await execa("shx", ["cp", `${rootDir}/LICENSE`, `${rootDir}/dist/LICENSE`]); 133 | } 134 | 135 | async function prepareForPublishing() { 136 | info("Preparing for publication"); 137 | if (!/npm-cli\.js$/.test(process.env.npm_execpath)) { 138 | error(`⚠️ You must run this command with npm instead of yarn.`); 139 | info("Please try again with:\n\n» npm run publish\n\n"); 140 | process.exit(); 141 | } 142 | const isClean = !execSync(`git status --untracked-files=no --porcelain`, { 143 | encoding: "utf-8", 144 | }); 145 | if (!isClean) { 146 | error("Commit your changes before publishing."); 147 | process.exit(); 148 | } 149 | const raw = await readFile(resolve(rootDir, "package.json"), "utf8"); 150 | const packageJSON = JSON.parse(raw); 151 | const response = await prompts([ 152 | { 153 | type: "confirm", 154 | name: "value", 155 | message: `Confirm you want to publish version ${chalk.red( 156 | packageJSON.version 157 | )}?`, 158 | initial: false, 159 | }, 160 | ]); 161 | if (!response.value) { 162 | error("Please adjust the version and try again"); 163 | process.exit(); 164 | } 165 | } 166 | 167 | async function publish() { 168 | const raw = await readFile(resolve(rootDir, "package.json"), "utf8"); 169 | const packageJSON = JSON.parse(raw); 170 | const response = await prompts([ 171 | { 172 | type: "confirm", 173 | name: "value", 174 | message: `Project is build. Ready to publish?`, 175 | initial: false, 176 | }, 177 | ]); 178 | if (response.value) { 179 | execSync("npm publish ./dist --registry https://registry.npmjs.org"); 180 | // use `npx bumpp` to bump version, create and push tag 181 | // execSync(`git tag ${packageJSON.version}`); 182 | // execSync(`git push origin --tags`); 183 | } 184 | } 185 | 186 | async function outputSize() { 187 | const raw = await readFile(resolve(rootDir, "dist/index.min.js"), "utf8"); 188 | console.log("Brotli size: " + prettyBytes(brotliSize.sync(raw))); 189 | } 190 | 191 | if (isPublishing) await prepareForPublishing(); 192 | await clean(); 193 | await baseBuild(); 194 | await baseBuildMin(); 195 | await vueBuild(); 196 | await reactBuild(); 197 | await declarationsBuild(); 198 | await bundleDeclarations(); 199 | await addPackageJSON(); 200 | await addAssets(); 201 | await outputSize(); 202 | isPublishing ? await publish() : success("Build complete"); 203 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .DS_Store 3 | node_modules 4 | coverage 5 | temp 6 | *.log 7 | /.aws/credentials 8 | .yalc 9 | .idea 10 | .vercel 11 | .vite-ssg-temp -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Attributify", 4 | "brotli", 5 | "btns", 6 | "darcula", 7 | "execa", 8 | "highlightjs", 9 | "Mergeble", 10 | "mousemove", 11 | "prismjs", 12 | "rgba", 13 | "Roadmap", 14 | "srcset", 15 | "taze", 16 | "unhead" 17 | ] 18 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 CatsJuice 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | 5 | 6 | 7 |

8 | 9 | 10 |

ipad-curosr

11 | 12 |

13 | Mouse effect hacking of iPad in browser that can be used in any frameworks 14 |

15 |

16 | 17 | 18 | 19 |

20 | 21 |

22 | 23 | 24 | 25 |

26 | 27 |

28 | 29 | 30 | 31 |

32 | 33 | --- 34 | 35 | ## Install 36 | 37 | - NPM 38 | 39 | ```bash 40 | npm install ipad-cursor --save 41 | ``` 42 | 43 | - CDN 44 | 45 | Only support `ESM` module 46 | 47 | ```html 48 |
Block
49 |
text
50 | 51 | 55 | ``` 56 | 57 | See [cursor.oooo.so](https://ipad-cursor.oooo.so) for more details. 58 | 59 | ## Usage 60 | 61 | ### Basic usage 62 | 63 | Apply `data-cursor` attribute to the element you want to add the effect. 64 | 65 | - `data-cursor="text"`: text cursor 66 | - `data-cursor="block"`: block cursor 67 | 68 | ```html 69 |
Text Cursor
70 |
Block Cursor
71 | ``` 72 | 73 | After your dom loaded, call `initCursor` to start the effect. You may need to call `updateCursor()` when dom updated. 74 | 75 | ```js 76 | import { initCursor } from 'ipad-cursor' 77 | 78 | initCursor() 79 | ``` 80 | 81 | > ⚠️ **Notice**: As so far, you need to manage `when to updateCursor` yourself. Make sure to call `updateCursor` after dom updated. 82 | > In the future, there maybe a better way to handle this, see [Roadmap](#roadmap) for more details. 83 | 84 | ### Custom Style 85 | 86 | You can customize the style of the cursor by [Config](#config), config can be passed to `initCursor` method, or use `updateConfig` method to update config. Every type can be configured separately. 87 | 88 | ```ts 89 | import { initCursor, updateConfig } from 'ipad-cursor' 90 | import type { IpadCursorConfig, IpadCursorStyle } from 'ipad-cursor' 91 | 92 | const normalStyle: IpadCursorStyle = { background: 'red' } 93 | const textStyle: IpadCursorStyle = { background: 'blue' } 94 | const blockStyle: IpadCursorStyle = { background: 'green' } 95 | const config: IpadCursorConfig = { 96 | normalStyle, 97 | textStyle, 98 | blockStyle, 99 | }; 100 | initCursor(config) 101 | ``` 102 | 103 | Sometimes, you may want to customize the style of the cursor for a specific element, you can use `data-cursor-style` attribute to do this. 104 | 105 | The value of `data-cursor-style` attribute is a string, split by `;`, and each part is a style, split by `:`. For example, `background:red;color:blue`. 106 | 107 | It is recommended to use [customCursorStyle](#customCursorStyle%28style%29) method to create style string. 108 | 109 | For example, customize the style for a circle element (Like avatar). 110 | 111 | ```html 112 |
117 | 118 | 122 | ``` 123 | 124 | See [Style](#style) for full style list. 125 | 126 | ### Use in framework 127 | 128 | - [Vue.js](https://vuejs.org/) 129 | - **hooks** 130 | 131 | You can use `useCursor` hook to call `updateCursor()` automatically on mounted and unmounted. 132 | ```ts 133 | 138 | ``` 139 | - **directive** (v0.5.2+) 140 | 141 | Register plugin globally 142 | ```ts 143 | // src/main.ts 144 | import { ipadCursorPlugin } from "ipad-cursor/vue" 145 | 146 | app.use(ipadCursorPlugin, { 147 | // global configurations 148 | blockStyle: { radius: "auto" } 149 | }) 150 | ``` 151 | 152 | Use in component 153 | ```html 154 |
155 |
156 |
157 | ``` 158 | 159 | - [React](https://react.dev) 160 | See [App.tsx](./examples/react-basic/src/App.tsx) 161 | - [Hexo](https://hexo.io/) 162 | See [@zqqcee](https://github.com/zqqcee)'s [Blog](https://zqqcee.github.io/2023/07/23/ebae3e5deab8/) 163 | 164 | ## Principle 165 | 166 | When `initCursor` called, it will remove default cursor, and generate a fake cursor use `div` element. Then listen `mousemove` event, and move the fake cursor to the mouse position. 167 | 168 | After init finished, it will call `updateCursor` method, scan element with `data-cursor` attribute, detect the cursor type, and add event listener to the element. 169 | 170 | When mouse enter the element, apply styles. 171 | 172 | ## API 173 | 174 | ### initCursor(cfg) 175 | > see [Config](#config) for more details. 176 | 177 | Init cursor, remove default cursor, and generate a fake cursor use `div` element. Then listen `mousemove` event, and move the fake cursor to the mouse position. 178 | 179 | 180 | ### updateCursor 181 | Scan element to observe hover event, and apply styles, as well as remove unused element's event listener. 182 | 183 | ### disposeCursor 184 | Remove fake cursor, and remove all event listener, recover default cursor. 185 | 186 | ### updateConfig(cfg) 187 | Update config, see [Config](#config) for more details. 188 | 189 | ### customCursorStyle(style) 190 | Create style string that can be used as `data-cursor-style` attribute. 191 | This method is used for better type hinting. 192 | 193 | ### resetCursor 194 | Reset cursor to default style. 195 | 196 | ## Config 197 | 198 | It is recommended to see [index.d.ts](./src/index.d.ts) in the npm package. 199 | 200 | | Name | Type | Default | Description | required | 201 | | ------------------------------------------------- | ----------------- | ------------------- | -------------------------------------------------------------------------------------- | -------- | 202 | | `adsorptionStrength` | `number` | `0.2` | The strength of adsorption effect, number between 0 and 30 | No | 203 | | `className` | `string` | `'ipad-cursor'` | The class name of fake cursor | No | 204 | | `blockPadding` | `number` | `auto` | The padding of cursor when hover on block, set to `auto` will calculate automatic | No | 205 | | `enableAutoTextCursor`(`v0.2.0+`) | `boolean` | `false` | Auto detect text cursor, see [#12](https://github.com/CatsJuice/ipad-cursor/pull/12) | No | 206 | | `enableLighting`(`v0.3.0+`) | `boolean` | `false` | Add a lighting effect to block [#14](https://github.com/CatsJuice/ipad-cursor/pull/14) | No | 207 | | `enableMouseDownEffect`(`v0.4.3+`, Experimental) | `boolean` | `false` | Add a effect when mouse down, customize style by config `mouseDownStyle` | No | 208 | | `enableAutoUpdateCursor`(`v0.5.0+`) | `boolean` | `false` | Auto update cursor when dom updated | No | 209 | | `normalStyle` | `IpadCursorStyle` | see [Style](#style) | The style of normal cursor, see [Style](#style) | No | 210 | | `textStyle` | `IpadCursorStyle` | see [Style](#style) | The style of text cursor, see [Style](#style) | No | 211 | | `blockStyle` | `IpadCursorStyle` | see [Style](#style) | The style of block cursor, see [Style](#style) | No | 212 | | `mouseDownStyle`(`v0.4.3+`, Experimental) | `IpadCursorStyle` | see [Style](#style) | The style of cursor when mouse is pressed, see [Style](#style) | No | 213 | 214 | ## Style 215 | 216 | | Name | Type | Description | example | 217 | | ------------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | 218 | | `width` | `MaybeSize` | The width of cursor | `'10px'`, `10`, `'10'` | 219 | | `height` | `MaybeSize` | The height of cursor | `'10px'`, `10`, `'10'` | 220 | | `radius` | `MaybeSize` \| `'auto'` | The border radius of cursor, if set to `auto` for `blockStyle`, it will be calculated by dom's css `border-radius` and `config.blockPadding`. | `'10px'`, `10`, `'10'`, `'auto'` | 221 | | `background` | `string` | The background color of cursor | `'#fff'`, `'red'`, `'rgba(0,0,0)'` | 222 | | `border` | `string` | The css border property of cursor | `'1px solid black'` | 223 | | `zIndex` | `number` | The z-index of cursor | `1` | 224 | | `scale` | `number` | The scale of cursor | `1.05` | 225 | | `backdropBlur` | `MaybeSize` | The backdrop-filter blur of cursor | `'10px'`, `10`, `'10'` | 226 | | `backdropSaturate` | `string` | The backdrop-filter saturate of cursor | `180%` | 227 | | `durationBase` | `MaybeDuration` | Transition duration of basic properties like `width`, `height`, `radius`, `border`, `background-color`, if unit if not specified, `ms` will be used | `'1000'`, `1000`, `200ms`, `0.23s` | 228 | | `durationPosition` | `MaybeDuration` | Transition duration of position properties like `top`, `left`, if unit if not specified, `ms` will be used | `'1000'`, `1000`, `200ms`, `0.23s` | 229 | | `durationBackdropFilter` | `MaybeDuration` | Transition duration of backdrop-filter property, if unit if not specified, `ms` will be used | `'1000'`, `1000`, `200ms`, `0.23s` | 230 | 231 | ### Default Style 232 | 233 | See `getDefaultConfig` method in [index.ts](./src/index.ts) for more details. 234 | 235 | 236 | ## Roadmap 237 | 238 | - [x] Add Chinese document 239 | - [x] API Docs 240 | - [ ] More examples 241 | - [x] Auto detect dom update, and call `updateCursor` automatically 242 | - Maybe use [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) 243 | 244 | 245 | ## Showcase 246 | 247 | - [oooo.so](https://oooo.so) 248 | - [ipad-cursor.oooo.so](https://ipad-cursor.oooo.so) 249 | -------------------------------------------------------------------------------- /docs/README.zh.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | 5 | 6 | 7 |

8 | 9 | 10 |

ipad-mouse

11 | 12 |

13 | 在浏览器中实现 iPad 的鼠标效果,可在任何框架中使用 14 |

15 |

16 | 17 | 18 | 19 |

20 | 21 |

22 | 23 | 24 | 25 |

26 | 27 |

28 | 29 | 30 | 31 |

32 | 33 | 34 | --- 35 | 36 | 37 | ## 安装 38 | 39 | - NPM 40 | 41 | ```bash 42 | npm install ipad-cursor --save 43 | ``` 44 | 45 | - CDN 46 | 47 | 目前仅支持 `ESM` 模块 48 | ```html 49 |
Block
50 |
text
51 | 52 | 56 | ``` 57 | 58 | 更多详细信息请查看 [cursor.oooo.so](https://ipad-cursor.oooo.so)。 59 | 60 | ## 使用 61 | 62 | ### 基本使用 63 | 64 | 在你想要添加效果的元素上应用 `data-cursor` 属性。 65 | 66 | - `data-cursor="text"`: 文本光标 67 | - `data-cursor="block"`: 块状光标 68 | 69 | ```html 70 |
文本光标
71 |
块状光标
72 | ``` 73 | 74 | 在你的dom加载后,调用 `initCursor` 来启动效果。当dom更新时,你可能需要调用 `updateCursor()`。 75 | 76 | ```js 77 | import { initCursor } from 'ipad-cursor' 78 | 79 | initCursor() 80 | ``` 81 | 82 | > ⚠️ **注意**:到目前为止,你需要自己管理 `何时更新光标`。确保在dom更新后调用 `updateCursor`。 83 | > 在未来,可能会有更好的方式来处理这个问题,详见 [路线图](#路线图)。 84 | 85 | ### 自定义样式 86 | 87 | 你可以通过配置 [配置](#配置) 来自定义光标的样式,可以在调用 `initCursor` 时传入配置,也可以在初始化后调用 `updateConfig` 来更新配置。每一种光标类型都可以单独定义样式。 88 | 89 | ```ts 90 | import { initCursor, updateConfig } from 'ipad-cursor' 91 | import type { IpadCursorConfig, IpadCursorStyle } from 'ipad-cursor' 92 | 93 | const normalStyle: IpadCursorStyle = { background: 'red' } 94 | const textStyle: IpadCursorStyle = { background: 'blue' } 95 | const blockStyle: IpadCursorStyle = { background: 'green' } 96 | const config: IpadCursorConfig = { 97 | normalStyle, 98 | textStyle, 99 | blockStyle, 100 | }; 101 | initCursor(config) 102 | ``` 103 | 104 | 有时候,你可能需要针对某个元素自定义样式, 可以通过给元素绑定 `data-cursor-style` 来设置样式,该属性的值是由 `;` 分割的多个 `key:value` 对,其中 `key` 是 `IpadCursorStyle` 的属性,`value` 是对应的值,如果传入的 `key` 不是 `IpadCursorStyle` 的属性,会被当作 `css` 属性。 105 | 106 | 推荐通过 [customCursorStyle](#customCursorStyle%28style%29) 方法来创建样式字符串以获得更好的类型提示。 107 | 108 | 例如,为一个圆形的元素(如头像),需要单独设置: 109 | 110 | ```html 111 |
116 | 117 | 121 | ``` 122 | 123 | 查看 [样式](#样式) 浏览完整的样式列表 124 | 125 | ### 在框架中使用 126 | 127 | - [Vue.js](https://vuejs.org/) 128 | - **hooks** 129 | 130 | 你可以通过使用 `useCursor` hook, 当组件挂载和销毁时自动调用 `updateCursor()` 131 | ```ts 132 | 137 | ``` 138 | - **自定义指令** (v0.5.2+) 139 | 140 | 全局注册插件: 141 | ```ts 142 | // src/main.ts 143 | import { ipadCursorPlugin } from "ipad-cursor/vue" 144 | 145 | app.use(ipadCursorPlugin, { 146 | // global configurations 147 | blockStyle: { radius: "auto" } 148 | }) 149 | ``` 150 | 151 | 然后就可以在组件中使用: 152 | ```html 153 |
154 |
155 |
156 | ``` 157 | 158 | - [React](https://react.dev) 159 | 可参考 [App.tsx](./examples/react-basic/src/App.tsx) 160 | - [Hexo](https://hexo.io/zh-cn/) 161 | 可参考 [@zqqcee](https://github.com/zqqcee)'s [Blog](https://zqqcee.github.io/2023/07/23/ebae3e5deab8/) 162 | 163 | 164 | ## 原理 165 | 166 | 当调用 `initCursor` 时,它将移除默认光标,并使用 `div` 元素生成一个假光标。然后监听 `mousemove` 事件,并将假光标移动到鼠标位置。 167 | 168 | 初始化完成后,它将调用 `updateCursor` 方法,扫描带有 `data-cursor` 属性的元素,检测光标类型,并为元素添加事件监听器。 169 | 170 | 当鼠标进入元素时,应用样式。 171 | 172 | ## API 173 | 174 | ### initCursor(cfg) 175 | > 更多详细信息请查看 [配置](#配置)。 176 | 177 | 初始化光标,移除默认光标,并使用 `div` 元素生成一个假光标。然后监听 `mousemove` 事件,并将假光标移动到鼠标位置。 178 | 179 | ### updateCursor 180 | 扫描元素以观察悬停事件,并应用样式,以及移除未使用元素的事件监听器。 181 | 182 | ### disposeCursor 183 | 移除假光标,并移除所有事件监听器,恢复默认光标。 184 | 185 | ### updateConfig(cfg) 186 | 更新配置,详见 [配置](#配置)。 187 | 188 | ### customCursorStyle(style) 189 | 创建可用作 `data-cursor-style` 属性的样式字符串。 190 | 这个方法用于更好的类型提示。 191 | 192 | ### resetCursor 193 | 将光标重置为默认样式。 194 | 195 | ## 配置 196 | 197 | 建议查看 npm 包中的 [index.d.ts](./src/index.d.ts)。 198 | 199 | | 名称 | 类型 | 默认值 | 描述 | 是否必须 | 200 | | ------------------------------------------- | ----------------- | -------------------- | ---------------------------------------------------------------------------------- | -------- | 201 | | `adsorptionStrength` | `number` | `0.2` | 吸附力强度 | No | 202 | | `className` | `string` | `'ipad-cursor'` | 光标的css类名 | No | 203 | | `blockPadding` | `number` | `auto` | 当光标聚焦在 `block` 时的内边距,设置为 `auto` 将自动计算 | No | 204 | | `enableAutoTextCursor`(`v0.2.0+`) | `boolean` | `false` | 自动检测 `text` 类型的光标 [#12](https://github.com/CatsJuice/ipad-cursor/pull/12) | No | 205 | | `enableLighting`(`v0.3.0+`) | `boolean` | `false` | 给 `block` 增加光照效果 [#14](https://github.com/CatsJuice/ipad-cursor/pull/14) | No | 206 | | `enableMouseDownEffect`(`v0.4.3+`, 实验性) | `boolean` | `false` | 当鼠标按下时应用样式, 通过配置 `mouseDownStyle` 来自定义样式 | No | 207 | | `enableAutoUpdateCursor`(`v0.5.0+`) | `boolean` | `false` | 当 dom 更新时自动更更新光标的扫描 | No | 208 | | `normalStyle` | `IpadCursorStyle` | 请查看 [样式](#样式) | 正常情况下的光标样式, 请查看 [样式](#样式) | No | 209 | | `textStyle` | `IpadCursorStyle` | 请查看 [样式](#样式) | 文字模式下的光标样式, 请查看 [样式](#样式) | No | 210 | | `blockStyle` | `IpadCursorStyle` | 请查看 [样式](#样式) | 块元素下的光标样式, 请查看 [样式](#样式) | No | 211 | | `mouseDownStyle`(`v0.4.3+`, 实验性) | `IpadCursorStyle` | 请查看 [样式](#样式) | 鼠标按下时的光标样式, 请查看 [样式](#样式) | No | 212 | 213 | 214 | ## 样式 215 | 216 | | 名称 | 类型 | 描述 | 例子 | 217 | | ------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | 218 | | `width` | `MaybeSize` | 光标宽度 | `'10px'`, `10`, `'10'` | 219 | | `height` | `MaybeSize` | 光标高度 | `'10px'`, `10`, `'10'` | 220 | | `radius` | `MaybeSize` \| `'auto'` | 光标半径, 当给 `blockStyle` 设置为 `auto` ,将根据元素的 css `border-radius` 和 `config.blockPadding` 自动计算 | `'10px'`, `10`, `'10'`, `'auto'` | 221 | | `background` | `string` | 光标背景颜色 | `'#fff'`, `'red'`, `'rgba(0,0,0)'` | 222 | | `border` | `string` | 光标边框的css样式 | `'1px solid black'` | 223 | | `zIndex` | `number` | 光标的z-index层级 | `1` | 224 | | `scale` | `number` | 光标缩放 | `1.05` | 225 | | `backdropBlur` | `MaybeSize` | 光标的 backdrop-filter 模糊 | `'10px'`, `10`, `'10'` | 226 | | `backdropSaturate` | `string` | 光标的 backdrop-filter 饱和度 | `180%` | 227 | | `durationBase` | `MaybeDuration` | 光标的基础属性过度时间 如 `width`, `height`, `radius`, `border`, `background-color`, 如果未指定单位, 将会使用 `ms` | `'1000'`, `1000`, `200ms`, `0.23s` | 228 | | `durationPosition` | `MaybeDuration` | 光标的位置属性过度时间 如 `top`, `left`, 如果未指定单位, 将会使用 `ms` | `'1000'`, `1000`, `200ms`, `0.23s` | 229 | | `durationBackdropFilter` | `MaybeDuration` | 光标的backdrop-filter属性过度时间, 如果未指定单位, 将会使用 `ms` | `'1000'`, `1000`, `200ms`, `0.23s` | 230 | 231 | ### 默认样式 232 | 233 | 请查看 [index.ts](./src/index.ts) 中的 `getDefaultConfig` 方法 234 | 235 | 236 | ## 路线图 237 | 238 | - [x] 添加中文文档 239 | - [x] API 文档 240 | - [ ] 更多示例 241 | - [x] 自动检测 dom 更新,并自动调用 `updateCursor` 242 | - 可能会使用 [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) 243 | 244 | 245 | ## 展示 246 | 247 | - [oooo.so](https://oooo.so) 248 | - [ipad-cursor.oooo.so](https://ipad-cursor.oooo.so) -------------------------------------------------------------------------------- /examples/nuxt-basic/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@antfu", 4 | "@unocss" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /examples/nuxt-basic/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | dist 4 | .output 5 | .nuxt 6 | .env 7 | .idea/ 8 | -------------------------------------------------------------------------------- /examples/nuxt-basic/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | shell-emulator=true 4 | -------------------------------------------------------------------------------- /examples/nuxt-basic/.stackblitzrc: -------------------------------------------------------------------------------- 1 | { 2 | "installDependencies": true, 3 | "startCommand": "npm run dev" 4 | } 5 | -------------------------------------------------------------------------------- /examples/nuxt-basic/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-PRESENT Anthony Fu 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 | -------------------------------------------------------------------------------- /examples/nuxt-basic/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | Vitesse for Nuxt 3 7 |


8 | 9 |
10 | 🧪 Working in Progress
11 | 
12 | 13 |

14 |
15 | 🖥 Online Preview 16 |

17 | 18 |

19 | 20 | ## Features 21 | 22 | - 💚 [Nuxt 3](https://nuxt.com/) - SSR, ESR, File-based routing, components auto importing, modules, etc. 23 | 24 | - ⚡️ Vite - Instant HMR. 25 | 26 | - 🎨 [UnoCSS](https://github.com/unocss/unocss) - The instant on-demand atomic CSS engine. 27 | 28 | - 😃 Use icons from any icon sets in Pure CSS, powered by [UnoCSS](https://github.com/unocss/unocss). 29 | 30 | - 🔥 The ` 15 | 16 | 22 | 23 | 35 | -------------------------------------------------------------------------------- /examples/nuxt-basic/components/Counter.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | -------------------------------------------------------------------------------- /examples/nuxt-basic/components/DarkToggle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /examples/nuxt-basic/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /examples/nuxt-basic/components/InputEntry.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 36 | -------------------------------------------------------------------------------- /examples/nuxt-basic/components/Logos.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /examples/nuxt-basic/components/PageView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /examples/nuxt-basic/components/ToggleEle.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | -------------------------------------------------------------------------------- /examples/nuxt-basic/composables/count.ts: -------------------------------------------------------------------------------- 1 | export function useCount() { 2 | const count = useState('count', () => Math.round(Math.random() * 20)) 3 | 4 | function inc() { 5 | count.value += 1 6 | } 7 | function dec() { 8 | count.value -= 1 9 | } 10 | 11 | return { 12 | count, 13 | inc, 14 | dec, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/nuxt-basic/composables/cursor.ts: -------------------------------------------------------------------------------- 1 | // production 2 | // import { useCursor } from 'ipad-cursor/vue' 3 | 4 | // development 5 | import { useCursor } from '../../../src/vue' 6 | 7 | export default useCursor 8 | -------------------------------------------------------------------------------- /examples/nuxt-basic/composables/user.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from 'pinia' 2 | 3 | export const useUserStore = defineStore('user', () => { 4 | /** 5 | * Current named of the user. 6 | */ 7 | const savedName = ref('') 8 | const previousNames = ref(new Set()) 9 | 10 | const usedNames = computed(() => Array.from(previousNames.value)) 11 | const otherNames = computed(() => usedNames.value.filter(name => name !== savedName.value)) 12 | 13 | /** 14 | * Changes the current name of the user and saves the one that was used 15 | * before. 16 | * 17 | * @param name - new name to set 18 | */ 19 | function setNewName(name: string) { 20 | if (savedName.value) 21 | previousNames.value.add(savedName.value) 22 | 23 | savedName.value = name 24 | } 25 | 26 | return { 27 | setNewName, 28 | otherNames, 29 | savedName, 30 | } 31 | }) 32 | 33 | if (import.meta.hot) 34 | import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot)) 35 | -------------------------------------------------------------------------------- /examples/nuxt-basic/config/pwa.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions } from '@vite-pwa/nuxt' 2 | import { appDescription, appName } from '../constants/index' 3 | 4 | const scope = '/' 5 | 6 | export const pwa: ModuleOptions = { 7 | registerType: 'autoUpdate', 8 | scope, 9 | base: scope, 10 | manifest: { 11 | id: scope, 12 | scope, 13 | name: appName, 14 | short_name: appName, 15 | description: appDescription, 16 | theme_color: '#ffffff', 17 | icons: [ 18 | { 19 | src: 'pwa-192x192.png', 20 | sizes: '192x192', 21 | type: 'image/png', 22 | }, 23 | { 24 | src: 'pwa-512x512.png', 25 | sizes: '512x512', 26 | type: 'image/png', 27 | }, 28 | { 29 | src: 'maskable-icon.png', 30 | sizes: '512x512', 31 | type: 'image/png', 32 | purpose: 'any maskable', 33 | }, 34 | ], 35 | }, 36 | workbox: { 37 | globPatterns: ['**/*.{js,css,html,txt,png,ico,svg}'], 38 | navigateFallbackDenylist: [/^\/api\//], 39 | navigateFallback: '/', 40 | cleanupOutdatedCaches: true, 41 | runtimeCaching: [ 42 | { 43 | urlPattern: /^https:\/\/fonts.googleapis.com\/.*/i, 44 | handler: 'CacheFirst', 45 | options: { 46 | cacheName: 'google-fonts-cache', 47 | expiration: { 48 | maxEntries: 10, 49 | maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days 50 | }, 51 | cacheableResponse: { 52 | statuses: [0, 200], 53 | }, 54 | }, 55 | }, 56 | { 57 | urlPattern: /^https:\/\/fonts.gstatic.com\/.*/i, 58 | handler: 'CacheFirst', 59 | options: { 60 | cacheName: 'gstatic-fonts-cache', 61 | expiration: { 62 | maxEntries: 10, 63 | maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days 64 | }, 65 | cacheableResponse: { 66 | statuses: [0, 200], 67 | }, 68 | }, 69 | }, 70 | ], 71 | }, 72 | registerWebManifestInRouteRules: true, 73 | writePlugin: true, 74 | devOptions: { 75 | enabled: process.env.VITE_PLUGIN_PWA === 'true', 76 | navigateFallback: scope, 77 | }, 78 | } 79 | -------------------------------------------------------------------------------- /examples/nuxt-basic/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const appName = 'Vitesse for Nuxt 3' 2 | export const appDescription = 'Vitesse for Nuxt 3' 3 | -------------------------------------------------------------------------------- /examples/nuxt-basic/layouts/README.md: -------------------------------------------------------------------------------- 1 | ## Layouts 2 | 3 | Vue components in this dir are used as layouts. 4 | 5 | By default, `default.vue` will be used unless an alternative is specified in the route meta. 6 | 7 | ```html 8 | 13 | ``` 14 | 15 | Learn more on https://nuxt.com/docs/guide/directory-structure/layouts 16 | -------------------------------------------------------------------------------- /examples/nuxt-basic/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /examples/nuxt-basic/layouts/home.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /examples/nuxt-basic/netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NODE_VERSION = "16" 3 | 4 | [build] 5 | publish = "dist" 6 | command = "pnpm run build" 7 | 8 | [[redirects]] 9 | from = "/*" 10 | to = "/index.html" 11 | status = 200 12 | -------------------------------------------------------------------------------- /examples/nuxt-basic/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { pwa } from './config/pwa' 2 | import { appDescription } from './constants/index' 3 | 4 | export default defineNuxtConfig({ 5 | modules: [ 6 | '@vueuse/nuxt', 7 | '@unocss/nuxt', 8 | '@pinia/nuxt', 9 | '@nuxtjs/color-mode', 10 | '@vite-pwa/nuxt', 11 | ], 12 | 13 | experimental: { 14 | // when using generate, payload js assets included in sw precache manifest 15 | // but missing on offline, disabling extraction it until fixed 16 | payloadExtraction: false, 17 | inlineSSRStyles: false, 18 | renderJsonPayloads: true, 19 | typedPages: true, 20 | }, 21 | 22 | css: [ 23 | '@unocss/reset/tailwind.css', 24 | ], 25 | 26 | colorMode: { 27 | classSuffix: '', 28 | }, 29 | 30 | nitro: { 31 | esbuild: { 32 | options: { 33 | target: 'esnext', 34 | }, 35 | }, 36 | prerender: { 37 | crawlLinks: false, 38 | routes: ['/'], 39 | ignore: ['/hi'], 40 | }, 41 | }, 42 | 43 | app: { 44 | head: { 45 | viewport: 'width=device-width,initial-scale=1', 46 | link: [ 47 | { rel: 'icon', href: '/favicon.ico', sizes: 'any' }, 48 | { rel: 'icon', type: 'image/svg+xml', href: '/nuxt.svg' }, 49 | { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }, 50 | ], 51 | meta: [ 52 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 53 | { name: 'description', content: appDescription }, 54 | { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }, 55 | ], 56 | }, 57 | }, 58 | 59 | pwa, 60 | 61 | devtools: { 62 | enabled: true, 63 | }, 64 | }) 65 | -------------------------------------------------------------------------------- /examples/nuxt-basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "packageManager": "pnpm@8.6.3", 4 | "scripts": { 5 | "build": "nuxi build", 6 | "dev": "nuxi dev", 7 | "dev:pwa": "VITE_PLUGIN_PWA=true nuxi dev", 8 | "start": "node .output/server/index.mjs", 9 | "typecheck": "vue-tsc --noEmit", 10 | "lint": "eslint .", 11 | "postinstall": "nuxi prepare", 12 | "generate": "nuxi generate", 13 | "start:generate": "npx serve .output/public" 14 | }, 15 | "devDependencies": { 16 | "@antfu/eslint-config": "^0.39.5", 17 | "@iconify-json/carbon": "^1.1.18", 18 | "@iconify-json/twemoji": "^1.1.11", 19 | "@nuxt/devtools": "^0.6.2", 20 | "@nuxtjs/color-mode": "^3.3.0", 21 | "@pinia/nuxt": "^0.4.11", 22 | "@unocss/eslint-config": "^0.53.1", 23 | "@unocss/nuxt": "^0.53.1", 24 | "@vite-pwa/nuxt": "^0.1.0", 25 | "@vueuse/nuxt": "^10.2.0", 26 | "consola": "^3.1.0", 27 | "eslint": "^8.43.0", 28 | "nuxt": "^3.5.3", 29 | "pinia": "^2.1.4", 30 | "typescript": "^5.1.3", 31 | "vue-tsc": "^1.8.1" 32 | }, 33 | "dependencies": { 34 | "ipad-cursor": "^0.4.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/nuxt-basic/pages/[...all].vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /examples/nuxt-basic/pages/hi/[id].vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 56 | -------------------------------------------------------------------------------- /examples/nuxt-basic/pages/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 30 | -------------------------------------------------------------------------------- /examples/nuxt-basic/plugins/cursor-directive.ts: -------------------------------------------------------------------------------- 1 | import { ipadCursorPlugin } from '../../../src/vue/index' 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | // Doing something with nuxtApp 5 | nuxtApp.vueApp.use(ipadCursorPlugin, { 6 | blockStyle: { 7 | radius: 'auto', 8 | }, 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /examples/nuxt-basic/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatsJuice/ipad-cursor/34190bd39827e91aff6b6188a39b27695eba23cc/examples/nuxt-basic/public/apple-touch-icon.png -------------------------------------------------------------------------------- /examples/nuxt-basic/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatsJuice/ipad-cursor/34190bd39827e91aff6b6188a39b27695eba23cc/examples/nuxt-basic/public/favicon.ico -------------------------------------------------------------------------------- /examples/nuxt-basic/public/maskable-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatsJuice/ipad-cursor/34190bd39827e91aff6b6188a39b27695eba23cc/examples/nuxt-basic/public/maskable-icon.png -------------------------------------------------------------------------------- /examples/nuxt-basic/public/nuxt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/nuxt-basic/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatsJuice/ipad-cursor/34190bd39827e91aff6b6188a39b27695eba23cc/examples/nuxt-basic/public/pwa-192x192.png -------------------------------------------------------------------------------- /examples/nuxt-basic/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatsJuice/ipad-cursor/34190bd39827e91aff6b6188a39b27695eba23cc/examples/nuxt-basic/public/pwa-512x512.png -------------------------------------------------------------------------------- /examples/nuxt-basic/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /examples/nuxt-basic/public/vite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatsJuice/ipad-cursor/34190bd39827e91aff6b6188a39b27695eba23cc/examples/nuxt-basic/public/vite.png -------------------------------------------------------------------------------- /examples/nuxt-basic/server/api/pageview.ts: -------------------------------------------------------------------------------- 1 | const startAt = Date.now() 2 | let count = 0 3 | 4 | export default defineEventHandler(() => ({ 5 | pageview: count++, 6 | startAt, 7 | })) 8 | -------------------------------------------------------------------------------- /examples/nuxt-basic/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nuxt-basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nuxt-basic/uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetTypography, 6 | presetUno, 7 | presetWebFonts, 8 | transformerDirectives, 9 | transformerVariantGroup, 10 | } from 'unocss' 11 | 12 | export default defineConfig({ 13 | shortcuts: [ 14 | ['btn', 'px-4 py-1 rounded inline-block bg-teal-600 text-white cursor-pointer hover:bg-teal-700 disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50'], 15 | ['icon-btn', 'inline-block cursor-pointer select-none opacity-75 transition duration-200 ease-in-out hover:opacity-100 hover:text-teal-600'], 16 | ], 17 | presets: [ 18 | presetUno(), 19 | presetAttributify(), 20 | presetIcons({ 21 | scale: 1.2, 22 | }), 23 | presetTypography(), 24 | presetWebFonts({ 25 | fonts: { 26 | sans: 'DM Sans', 27 | serif: 'DM Serif Display', 28 | mono: 'DM Mono', 29 | }, 30 | }), 31 | ], 32 | transformers: [ 33 | transformerDirectives(), 34 | transformerVariantGroup(), 35 | ], 36 | }) 37 | -------------------------------------------------------------------------------- /examples/react-basic/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | root: true, 5 | env: { browser: true, es2020: true }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 10 | 'plugin:react-hooks/recommended', 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | project: true, 17 | tsconfigRootDir: __dirname, 18 | }, 19 | plugins: ['react-refresh'], 20 | rules: { 21 | 'react-refresh/only-export-components': [ 22 | 'warn', 23 | { allowConstantExport: true }, 24 | ], 25 | '@typescript-eslint/no-non-null-assertion': 'off', 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /examples/react-basic/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/react-basic/.npmrc: -------------------------------------------------------------------------------- 1 | ipad-cursor:registry=https://registry.npmjs.org -------------------------------------------------------------------------------- /examples/react-basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/react-basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-basic", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "ipad-cursor": "0.4.3", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.14", 19 | "@types/react-dom": "^18.2.6", 20 | "@typescript-eslint/eslint-plugin": "^5.61.0", 21 | "@typescript-eslint/parser": "^5.61.0", 22 | "@vitejs/plugin-react": "^4.0.1", 23 | "eslint": "^8.44.0", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.1", 26 | "typescript": "^5.0.2", 27 | "unocss": "^0.53.5", 28 | "vite": "^4.4.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/react-basic/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/react-basic/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /examples/react-basic/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import reactLogo from "./assets/react.svg"; 3 | import viteLogo from "/vite.svg"; 4 | import "./App.css"; 5 | 6 | import { IPadCursorProvider, useIPadCursor } from "ipad-cursor/react"; 7 | import { IpadCursorConfig } from "ipad-cursor"; 8 | 9 | function App() { 10 | const config: IpadCursorConfig = { 11 | blockPadding: "auto", 12 | blockStyle: { 13 | radius: "auto", 14 | }, 15 | enableAutoTextCursor: true, 16 | }; 17 | useIPadCursor(); 18 | const [count, setCount] = useState(0); 19 | 20 | return ( 21 | 22 | 28 | Vite logo 29 | 30 | 36 | React logo 37 | 38 |

Vite + React

39 |
40 | 43 |

44 | Edit src/App.tsx and save to test HMR 45 |

46 |
47 |

48 | Click on the Vite and React logos to learn more 49 |

50 |
51 | ); 52 | } 53 | 54 | export default App; 55 | -------------------------------------------------------------------------------- /examples/react-basic/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/react-basic/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | display: flex; 29 | place-items: center; 30 | min-width: 320px; 31 | min-height: 100vh; 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | button { 40 | border-radius: 8px; 41 | border: 1px solid transparent; 42 | padding: 0.6em 1.2em; 43 | font-size: 1em; 44 | font-weight: 500; 45 | font-family: inherit; 46 | background-color: #1a1a1a; 47 | cursor: pointer; 48 | transition: border-color 0.25s; 49 | } 50 | button:hover { 51 | border-color: #646cff; 52 | } 53 | button:focus, 54 | button:focus-visible { 55 | outline: 4px auto -webkit-focus-ring-color; 56 | } 57 | 58 | @media (prefers-color-scheme: light) { 59 | :root { 60 | color: #213547; 61 | background-color: #ffffff; 62 | } 63 | a:hover { 64 | color: #747bff; 65 | } 66 | button { 67 | background-color: #f9f9f9; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /examples/react-basic/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | import 'virtual:uno.css' 6 | 7 | ReactDOM.createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | , 11 | ) 12 | -------------------------------------------------------------------------------- /examples/react-basic/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/react-basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /examples/react-basic/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/react-basic/uno.config.ts: -------------------------------------------------------------------------------- 1 | // uno.config.ts 2 | import { 3 | defineConfig, 4 | presetAttributify, 5 | presetIcons, 6 | presetTypography, 7 | presetUno, 8 | transformerDirectives, 9 | transformerVariantGroup, 10 | } from "unocss"; 11 | 12 | export default defineConfig({ 13 | // ...UnoCSS options 14 | shortcuts: [ 15 | ['btn', 'px4 py1 rounded-md whitespace-nowrap bg-gray/20 hover:bg-gray/30 font-500'], 16 | ['icon-btn', 'p3 rounded-md'], 17 | ['full', 'w-full h-full'], 18 | ['flex-center', 'flex items-center justify-center'], 19 | ], 20 | presets: [ 21 | presetUno(), 22 | presetAttributify(), 23 | presetIcons({ 24 | scale: 1.2, 25 | autoInstall: true, 26 | }), 27 | presetTypography(), 28 | ], 29 | transformers: [transformerDirectives(), transformerVariantGroup()], 30 | }); 31 | -------------------------------------------------------------------------------- /examples/react-basic/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import UnoCSS from "unocss/vite" 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), UnoCSS()], 8 | }) 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ipad-cursor", 3 | "version": "0.5.2", 4 | "description": "Mouse effect of iPad in browser that can be used in any framework", 5 | "scripts": { 6 | "dev": "cd ./playground && vite", 7 | "build": "node ./.build.mjs", 8 | "build:playground": "cd ./playground && vite-ssg build -c ./vite.config.ts", 9 | "publish": "node ./.build.mjs --publish", 10 | "build:react-ts": "cd ./src/react && tsc" 11 | }, 12 | "type": "module", 13 | "main": "index.mjs", 14 | "module": "index.mjs", 15 | "types": "index.d.ts", 16 | "exports": { 17 | "./vue": { 18 | "import": "./vue/index.mjs", 19 | "types": "./vue/index.d.ts", 20 | "default": "./vue/index.mjs" 21 | }, 22 | "./react": { 23 | "import": "./react/index.mjs", 24 | "types": "./react/index.d.ts", 25 | "default": "./react/index.mjs" 26 | }, 27 | ".": { 28 | "import": "./index.mjs", 29 | "types": "./index.d.ts", 30 | "default": "./index.mjs" 31 | } 32 | }, 33 | "keywords": [ 34 | "iPad Cursor", 35 | "Mouse Effect", 36 | "Vue", 37 | "iPad", 38 | "Mouse", 39 | "Cursor" 40 | ], 41 | "author": "CatsJuice", 42 | "repository": "https://github.com/catsjuice/ipad-cursor/", 43 | "license": "MIT", 44 | "devDependencies": { 45 | "@formkit/auto-animate": "^0.7.0", 46 | "@highlightjs/vue-plugin": "^2.1.0", 47 | "@iconify-json/carbon": "^1.1.18", 48 | "@iconify-json/logos": "^1.1.33", 49 | "@iconify-json/vscode-icons": "^1.1.25", 50 | "@rollup/plugin-terser": "^0.4.3", 51 | "@rollup/plugin-typescript": "^11.1.2", 52 | "@types/highlight.js": "^10.1.0", 53 | "@types/node": "^20.3.3", 54 | "@types/react": "^18.2.14", 55 | "@unhead/vue": "^1.1.30", 56 | "@vitejs/plugin-vue": "^4.1.0", 57 | "@vueuse/core": "^10.2.1", 58 | "brotli-size": "^4.0.0", 59 | "bumpp": "^9.1.1", 60 | "chalk": "^5.3.0", 61 | "execa": "^7.1.1", 62 | "highlight.js": "^11.8.0", 63 | "highlightjs-vue": "^1.0.0", 64 | "pretty-bytes": "^6.1.0", 65 | "prompts": "^2.4.2", 66 | "react": "^18.2.0", 67 | "shx": "^0.3.4", 68 | "taze": "^0.11.2", 69 | "tslib": "^2.6.0", 70 | "typescript": "^5.0.2", 71 | "unocss": "^0.53.4", 72 | "url": "^0.11.1", 73 | "vite": "^4.3.9", 74 | "vite-ssg": "^0.23.0", 75 | "vue": "^3.2.47", 76 | "vue-highlight.js": "^3.1.0", 77 | "vue-router": "^4.2.3", 78 | "vue-tsc": "^1.4.2" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /playground/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + Vue + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /playground/public/ipad-cursor-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /playground/public/ipad-cursor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /playground/public/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatsJuice/ipad-cursor/34190bd39827e91aff6b6188a39b27695eba23cc/playground/public/og-image.jpg -------------------------------------------------------------------------------- /playground/public/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CatsJuice/ipad-cursor/34190bd39827e91aff6b6188a39b27695eba23cc/playground/public/screenshot.gif -------------------------------------------------------------------------------- /playground/src/App.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 37 | 38 | 75 | -------------------------------------------------------------------------------- /playground/src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/src/codes/basic.ts: -------------------------------------------------------------------------------- 1 | import { UNPKG_CDN } from "../constants"; 2 | import { Code } from "../types/lang"; 3 | 4 | export const basicCodes: Code[] = [ 5 | { 6 | lang: "html", 7 | code: ` 8 | 9 |
10 | 11 | will be text 12 | 13 | 16 |
17 | 18 | 22 | `, 23 | }, 24 | { 25 | lang: "vue", 26 | code: ` 31 | 32 | `, 42 | }, 43 | { 44 | lang: "react", 45 | code: `// app.tsx 46 | import { IPadCursorProvider, useIPadCursor } from "ipad-cursor/react"; 47 | import type { IpadCursorConfig } from "ipad-cursor"; 48 | 49 | function App() { 50 | const config: IpadCursorConfig = {}; 51 | useIPadCursor(); 52 | 53 | return ( 54 | 55 |
56 |
57 |
58 | ) 59 | } 60 | ` 61 | } 62 | ]; 63 | -------------------------------------------------------------------------------- /playground/src/codes/blur-effect.ts: -------------------------------------------------------------------------------- 1 | import { UNPKG_CDN } from "../constants"; 2 | import { Code } from "../types/lang"; 3 | 4 | export const blurEffectCodes: Code[] = [ 5 | { 6 | lang: "html", 7 | code: ` 8 | 9 |
10 | 11 | will be text 12 | 13 | 19 |
20 | 21 | 25 | `, 26 | }, 27 | { 28 | lang: "vue", 29 | code: ` 38 | 39 | `, 49 | }, 50 | { 51 | lang: "react", 52 | code: `// app.tsx 53 | import { IPadCursorProvider, useIPadCursor } from "ipad-cursor/react"; 54 | import type { IpadCursorConfig } from "ipad-cursor"; 55 | 56 | function App() { 57 | const config: IpadCursorConfig = {}; 58 | const { customCursorStyle } = useIPadCursor(); 59 | const style = customCursorStyle({ 60 | backdropBlur: 0, 61 | durationBackdropFilter: 1000, 62 | }); 63 | 64 | return ( 65 | 66 | 67 | will be text 68 | 69 | 72 | 73 | ) 74 | } 75 | `, 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /playground/src/codes/custom-anime-speed.ts: -------------------------------------------------------------------------------- 1 | import { UNPKG_CDN } from "../constants"; 2 | import { Code } from "../types/lang"; 3 | 4 | export const customAnimeSpeedCodes: Code[] = [ 5 | { 6 | lang: "html", 7 | code: ` 8 | 9 |
10 | 11 | will be text 12 | 13 | 19 |
20 | 21 | 25 | `, 26 | }, 27 | { 28 | lang: "vue", 29 | code: ` 37 | 38 | `, 48 | }, 49 | 50 | { 51 | lang: "react", 52 | code: `// app.tsx 53 | import { IPadCursorProvider, useIPadCursor } from "ipad-cursor/react"; 54 | import type { IpadCursorConfig } from "ipad-cursor"; 55 | 56 | function App() { 57 | const config: IpadCursorConfig = {}; 58 | const { customCursorStyle } = useIPadCursor(); 59 | const style = customCursorStyle({ 60 | durationBase: 2000, 61 | }); 62 | 63 | return ( 64 | 65 | 66 | will be text 67 | 68 | 71 | 72 | ) 73 | } 74 | `, 75 | }, 76 | ]; 77 | -------------------------------------------------------------------------------- /playground/src/codes/custom-bg.ts: -------------------------------------------------------------------------------- 1 | import { UNPKG_CDN } from "../constants"; 2 | import { Code } from "../types/lang"; 3 | 4 | export const customBgCodes: Code[] = [ 5 | { 6 | lang: "html", 7 | code: ` 8 | 9 |
10 | 11 | will be text 12 | 13 | 16 |
17 | 18 | 22 | `, 23 | }, 24 | { 25 | lang: "vue", 26 | code: ` 31 | 32 | `, 42 | }, 43 | { 44 | lang: "react", 45 | code: `// app.tsx 46 | import { IPadCursorProvider, useIPadCursor } from "ipad-cursor/react"; 47 | import type { IpadCursorConfig } from "ipad-cursor"; 48 | 49 | function App() { 50 | const config: IpadCursorConfig = {}; 51 | useIPadCursor(); 52 | 53 | return ( 54 | 55 | 56 | will be text 57 | 58 | 61 | 62 | ) 63 | } 64 | `, 65 | }, 66 | ]; 67 | -------------------------------------------------------------------------------- /playground/src/codes/custom-border.ts: -------------------------------------------------------------------------------- 1 | import { UNPKG_CDN } from "../constants"; 2 | import { Code } from "../types/lang"; 3 | 4 | export const customBorderCodes: Code[] = [ 5 | { 6 | lang: "html", 7 | code: ` 8 | 9 |
10 | 11 | will be text 12 | 13 | 19 |
20 | 21 | 25 | `, 26 | }, 27 | { 28 | lang: "vue", 29 | code: ` 37 | 38 | `, 48 | }, 49 | 50 | 51 | { 52 | lang: "react", 53 | code: `// app.tsx 54 | import { IPadCursorProvider, useIPadCursor } from "ipad-cursor/react"; 55 | import type { IpadCursorConfig } from "ipad-cursor"; 56 | 57 | function App() { 58 | const config: IpadCursorConfig = {}; 59 | const { customCursorStyle } = useIPadCursor(); 60 | const style = customCursorStyle({ 61 | border: '1px solid currentColor', 62 | }); 63 | 64 | return ( 65 | 66 | 67 | will be text 68 | 69 | 72 | 73 | ) 74 | } 75 | `, 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /playground/src/codes/install.ts: -------------------------------------------------------------------------------- 1 | import { packageName } from "../constants"; 2 | import { Code } from "../types/lang"; 3 | 4 | export const installCodes: Code[] = [ 5 | { 6 | lang: "pnpm", 7 | title: "Pnpm", 8 | code: `pnpm i ${packageName}`, 9 | }, 10 | { 11 | lang: "npm", 12 | title: 'NPM', 13 | code: `npm i ${packageName} --save`, 14 | }, 15 | { 16 | lang: "yarn", 17 | title: "Yarn", 18 | code: `yarn add ${packageName}`, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /playground/src/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 35 | -------------------------------------------------------------------------------- /playground/src/components/CodeBox/index.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 105 | 106 | 123 | -------------------------------------------------------------------------------- /playground/src/components/LanguageIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /playground/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const packageName = 'ipad-cursor'; 2 | export const UNPKG_CDN = `https://unpkg.com/${packageName}@latest`; -------------------------------------------------------------------------------- /playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ViteSSG } from "vite-ssg"; 2 | import "virtual:uno.css"; 3 | import "./style.css"; 4 | import App from "./App.vue"; 5 | import hljsVuePlugin from "@highlightjs/vue-plugin"; 6 | import "highlight.js/styles/base16/darcula.css"; 7 | import hljs from "highlight.js/lib/core"; 8 | import javascript from "highlight.js/lib/languages/javascript"; 9 | import typescript from "highlight.js/lib/languages/typescript"; 10 | import { autoAnimatePlugin } from "@formkit/auto-animate/vue"; 11 | import vue from 'vue-highlight.js/lib/languages/vue'; 12 | import { ipadCursorPlugin } from "../../src/vue"; 13 | 14 | 15 | hljs.registerLanguage("js", javascript); 16 | hljs.registerLanguage("ts", typescript); 17 | hljs.registerLanguage("vue", vue); 18 | 19 | // createApp(App).mount('#app') 20 | export const createApp = ViteSSG( 21 | App, 22 | { 23 | routes: [{ path: "/", component: () => import("./pages/index.vue") }], 24 | }, 25 | (ctx) => { 26 | ctx.app.use(hljsVuePlugin); 27 | ctx.app.use(autoAnimatePlugin); 28 | ctx.app.use(ipadCursorPlugin, { 29 | enableMouseDownEffect: true, 30 | enableAutoTextCursor: true, 31 | enableLighting: true, 32 | normalStyle: { 33 | scale: 1, 34 | }, 35 | blockPadding: "auto", 36 | blockStyle: { 37 | radius: "auto", 38 | }, 39 | }) 40 | } 41 | ); 42 | -------------------------------------------------------------------------------- /playground/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 144 | -------------------------------------------------------------------------------- /playground/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | * { 18 | box-sizing: border-box; 19 | margin: 0; 20 | padding: 0; 21 | } 22 | -------------------------------------------------------------------------------- /playground/src/types/lang.ts: -------------------------------------------------------------------------------- 1 | export type Lang = "js" | "ts" | "vue" | "html" | "yarn" | "npm" | "pnpm" | "react"; 2 | export type Code = { 3 | lang: Lang; 4 | code: string; 5 | title?: string; 6 | }; 7 | -------------------------------------------------------------------------------- /playground/src/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | export function copy2clipboard(text: string) { 2 | const el = document.createElement("textarea"); 3 | el.value = text; 4 | el.setAttribute("readonly", ""); 5 | el.style.position = "absolute"; 6 | el.style.left = "-9999px"; 7 | document.body.appendChild(el); 8 | el.select(); 9 | document.execCommand("copy"); 10 | document.body.removeChild(el); 11 | } -------------------------------------------------------------------------------- /playground/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /playground/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /playground/uno.config.ts: -------------------------------------------------------------------------------- 1 | // uno.config.ts 2 | import { 3 | defineConfig, 4 | presetAttributify, 5 | presetIcons, 6 | presetTypography, 7 | presetUno, 8 | transformerDirectives, 9 | transformerVariantGroup, 10 | } from "unocss"; 11 | 12 | export default defineConfig({ 13 | // ...UnoCSS options 14 | shortcuts: [ 15 | ['btn', 'px4 py1 rounded-md whitespace-nowrap bg-gray/20 hover:bg-gray/30 font-500'], 16 | ['icon-btn', 'p3 rounded-md'], 17 | ['full', 'w-full h-full'], 18 | ['flex-center', 'flex items-center justify-center'], 19 | ], 20 | presets: [ 21 | presetUno(), 22 | presetAttributify(), 23 | presetIcons({ 24 | scale: 1.2, 25 | autoInstall: true, 26 | }), 27 | presetTypography(), 28 | ], 29 | transformers: [transformerDirectives(), transformerVariantGroup()], 30 | }); 31 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import UnoCSS from "unocss/vite"; 2 | import { defineConfig } from "vite"; 3 | import vue from "@vitejs/plugin-vue"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [vue(), UnoCSS()], 8 | ssr: { 9 | noExternal: ['@highlightjs/vue-plugin'] 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import terser from "@rollup/plugin-terser"; 3 | 4 | const FRAMEWORK = process.env.FRAMEWORK || "index"; 5 | const DECLARATIONS = process.env.DECLARATIONS || false; 6 | const MIN = process.env.MIN || false; 7 | 8 | const external = ["vue", "react", "react-dom"]; 9 | 10 | function createOutput() { 11 | if (DECLARATIONS) { 12 | return { 13 | dir: "./dist", 14 | format: "esm", 15 | }; 16 | } 17 | return { 18 | file: `./dist/${FRAMEWORK !== "index" ? FRAMEWORK + "/" : ""}index.${ 19 | MIN ? "min.js" : "mjs" 20 | }`, 21 | format: "esm", 22 | }; 23 | } 24 | 25 | function getExt() { 26 | if (["react"].includes(FRAMEWORK)) return "tsx"; 27 | return "ts"; 28 | } 29 | 30 | const plugins = [ 31 | typescript({ 32 | tsconfig: "tsconfig.json", 33 | compilerOptions: DECLARATIONS 34 | ? { 35 | declaration: true, 36 | emitDeclarationOnly: true, 37 | } 38 | : {}, 39 | rootDir: "./", 40 | outDir: `./dist`, 41 | include: ["./src/**/*"], 42 | exclude: ["./playground"], 43 | }), 44 | ]; 45 | 46 | if (MIN) { 47 | plugins.push(terser()); 48 | } 49 | 50 | export default { 51 | external, 52 | input: `./src/${ 53 | FRAMEWORK === "index" ? "" : FRAMEWORK + "/" 54 | }index.${getExt()}`, 55 | output: createOutput(), 56 | plugins, 57 | }; 58 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export type ICursorType = "normal" | "text" | "block"; 2 | /** 3 | * if without unit, `px` is used by default 4 | */ 5 | type MaybeSize = string | number; 6 | /** if without unit `ms` is used by default */ 7 | type MaybeDuration = string | number; 8 | /** do not use 0x000000, use #000000 instead */ 9 | type MaybeColor = string; 10 | /** 11 | * Configurations for the cursor 12 | */ 13 | export interface IpadCursorConfig { 14 | /** 15 | * Strength of adsorption, the larger the value, 16 | * The higher the value, the greater the range of the block that can be moved when it is hovered 17 | * @type {number} between 0 and 30 18 | * @default 10 19 | */ 20 | adsorptionStrength?: number; 21 | /** 22 | * The class name of the cursor element 23 | * @type {string} 24 | * @default 'cursor' 25 | */ 26 | className?: string; 27 | /** 28 | * The style of the cursor, when it does not hover on any element 29 | */ 30 | normalStyle?: IpadCursorStyle; 31 | /** 32 | * The style of the cursor, when it hovers on text 33 | */ 34 | textStyle?: IpadCursorStyle; 35 | /** 36 | * The style of the cursor, when it hovers on a block 37 | */ 38 | blockStyle?: IpadCursorStyle; 39 | /** 40 | * The style of the cursor, when mousedown 41 | */ 42 | mouseDownStyle?: IpadCursorStyle; 43 | /** 44 | * Cursor padding when hover on block 45 | */ 46 | blockPadding?: number | "auto"; 47 | /** 48 | * detect text node and apply text cursor automatically 49 | **/ 50 | enableAutoTextCursor?: boolean; 51 | /** 52 | * enable detect dom change and auto call updateCursor 53 | **/ 54 | enableAutoUpdateCursor?: boolean; 55 | /** 56 | * whether to enable lighting effect 57 | */ 58 | enableLighting?: boolean; 59 | /** 60 | * whether to apply effect for mousedown action 61 | */ 62 | enableMouseDownEffect?: boolean; 63 | } 64 | /** 65 | * Configurable style of the cursor (Experimental) 66 | * This feature is Experimental, so it's set to false by default. 67 | * And it not support `block` yet 68 | */ 69 | export interface IpadCursorStyle { 70 | /** 71 | * The width of the cursor 72 | */ 73 | width?: MaybeSize; 74 | /** 75 | * The width of the cursor 76 | */ 77 | height?: MaybeSize; 78 | /** 79 | * Border radius of cursor 80 | */ 81 | radius?: MaybeSize | "auto"; 82 | /** 83 | * Transition duration of basic properties like width, height, radius, border, background-color 84 | */ 85 | durationBase?: MaybeDuration; 86 | /** 87 | * Transition duration of position: left, top 88 | */ 89 | durationPosition?: MaybeDuration; 90 | /** 91 | * Transition duration of backdrop-filter 92 | */ 93 | durationBackdropFilter?: MaybeDuration; 94 | /** 95 | * The background color of the cursor 96 | */ 97 | background?: MaybeColor; 98 | /** 99 | * Border of the cursor 100 | * @example '1px solid rgba(100, 100, 100, 0.1)' 101 | */ 102 | border?: string; 103 | /** z-index of cursor */ 104 | zIndex?: number; 105 | /** 106 | * Scale of cursor 107 | */ 108 | scale?: number; 109 | /** 110 | * backdrop-filter blur 111 | */ 112 | backdropBlur?: MaybeSize; 113 | /** 114 | * backdrop-filter saturate 115 | */ 116 | backdropSaturate?: string; 117 | } 118 | /** 119 | * Init cursor, hide default cursor, and listen mousemove event 120 | * will only run once in client even if called multiple times 121 | * @returns 122 | */ 123 | declare function initCursor(_config?: IpadCursorConfig): void; 124 | /** 125 | * destroy cursor, remove event listener and remove cursor element 126 | * @returns 127 | */ 128 | declare function disposeCursor(): void; 129 | /** 130 | * Update current Configuration 131 | * @param _config 132 | */ 133 | declare function updateConfig(_config: IpadCursorConfig): IpadCursorConfig; 134 | /** 135 | * Detect all interactive elements in the page 136 | * Update the binding of events, remove listeners for elements that are removed 137 | * @returns 138 | */ 139 | declare function updateCursor(): void; 140 | /** 141 | * Create custom style that can be bound to `data-cursor-style` 142 | * @param style 143 | */ 144 | declare function customCursorStyle(style: IpadCursorStyle & Record): string; 145 | declare function resetCursor(): void; 146 | declare const CursorType: { 147 | TEXT: ICursorType; 148 | BLOCK: ICursorType; 149 | }; 150 | declare const exported: { 151 | CursorType: { 152 | TEXT: ICursorType; 153 | BLOCK: ICursorType; 154 | }; 155 | resetCursor: typeof resetCursor; 156 | initCursor: typeof initCursor; 157 | updateCursor: typeof updateCursor; 158 | disposeCursor: typeof disposeCursor; 159 | updateConfig: typeof updateConfig; 160 | customCursorStyle: typeof customCursorStyle; 161 | }; 162 | export { CursorType, resetCursor, initCursor, updateCursor, disposeCursor, updateConfig, customCursorStyle, }; 163 | export default exported; 164 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // types 2 | export type ICursorType = "normal" | "text" | "block"; 3 | /** 4 | * if without unit, `px` is used by default 5 | */ 6 | type MaybeSize = string | number; 7 | /** if without unit `ms` is used by default */ 8 | type MaybeDuration = string | number; 9 | /** do not use 0x000000, use #000000 instead */ 10 | type MaybeColor = string; 11 | 12 | /** 13 | * Configurations for the cursor 14 | */ 15 | export interface IpadCursorConfig { 16 | /** 17 | * Strength of adsorption, the larger the value, 18 | * The higher the value, the greater the range of the block that can be moved when it is hovered 19 | * @type {number} between 0 and 30 20 | * @default 10 21 | */ 22 | adsorptionStrength?: number; 23 | 24 | /** 25 | * The class name of the cursor element 26 | * @type {string} 27 | * @default 'cursor' 28 | */ 29 | className?: string; 30 | 31 | /** 32 | * The style of the cursor, when it does not hover on any element 33 | */ 34 | normalStyle?: IpadCursorStyle; 35 | /** 36 | * The style of the cursor, when it hovers on text 37 | */ 38 | textStyle?: IpadCursorStyle; 39 | /** 40 | * The style of the cursor, when it hovers on a block 41 | */ 42 | blockStyle?: IpadCursorStyle; 43 | /** 44 | * The style of the cursor, when mousedown 45 | */ 46 | mouseDownStyle?: IpadCursorStyle; 47 | /** 48 | * Cursor padding when hover on block 49 | */ 50 | blockPadding?: number | "auto"; 51 | 52 | /** 53 | * detect text node and apply text cursor automatically 54 | **/ 55 | enableAutoTextCursor?: boolean; 56 | 57 | /** 58 | * enable detect dom change and auto call updateCursor 59 | **/ 60 | enableAutoUpdateCursor?: boolean; 61 | /** 62 | * whether to enable lighting effect 63 | */ 64 | enableLighting?: boolean; 65 | 66 | /** 67 | * whether to apply effect for mousedown action 68 | */ 69 | enableMouseDownEffect?: boolean; 70 | } 71 | /** 72 | * Configurable style of the cursor (Experimental) 73 | * This feature is Experimental, so it's set to false by default. 74 | * And it not support `block` yet 75 | */ 76 | export interface IpadCursorStyle { 77 | /** 78 | * The width of the cursor 79 | */ 80 | width?: MaybeSize; 81 | /** 82 | * The width of the cursor 83 | */ 84 | height?: MaybeSize; 85 | /** 86 | * Border radius of cursor 87 | */ 88 | radius?: MaybeSize | "auto"; 89 | 90 | /** 91 | * Transition duration of basic properties like width, height, radius, border, background-color 92 | */ 93 | durationBase?: MaybeDuration; 94 | /** 95 | * Transition duration of position: left, top 96 | */ 97 | durationPosition?: MaybeDuration; 98 | /** 99 | * Transition duration of backdrop-filter 100 | */ 101 | durationBackdropFilter?: MaybeDuration; 102 | /** 103 | * The background color of the cursor 104 | */ 105 | background?: MaybeColor; 106 | /** 107 | * Border of the cursor 108 | * @example '1px solid rgba(100, 100, 100, 0.1)' 109 | */ 110 | border?: string; 111 | 112 | /** z-index of cursor */ 113 | zIndex?: number; 114 | 115 | /** 116 | * Scale of cursor 117 | */ 118 | scale?: number; 119 | 120 | /** 121 | * backdrop-filter blur 122 | */ 123 | backdropBlur?: MaybeSize; 124 | 125 | /** 126 | * backdrop-filter saturate 127 | */ 128 | backdropSaturate?: string; 129 | } 130 | 131 | let ready = false; 132 | let observer: MutationObserver | null = null; 133 | let cursorEle: HTMLDivElement | null = null; 134 | let activeDom: Element | null = null; 135 | let isBlockActive = false; 136 | let isTextActive = false; 137 | let isMouseDown = false; 138 | let styleTag: HTMLStyleElement | null = null; 139 | let latestCursorStyle: Record = {}; 140 | let mousedownStyleRecover: Record = {}; 141 | const position = { x: -100, y: -100}; 142 | const isServer = typeof document === "undefined"; 143 | const registeredNodeSet = new Set(); 144 | const eventMap = new Map< 145 | Element, 146 | Array<{ event: string; handler: (e: Event) => void }> 147 | >(); 148 | const config = getDefaultConfig(); 149 | 150 | /** 151 | * Util collection 152 | */ 153 | class Utils { 154 | static clamp(num: number, min: number, max: number) { 155 | return Math.min(Math.max(num, min), max); 156 | } 157 | static isNum(v: string | number) { 158 | return typeof v === "number" || /^\d+$/.test(v); 159 | } 160 | static getSize(size: MaybeSize) { 161 | if (this.isNum(size)) return `${size}px`; 162 | return size; 163 | } 164 | static getDuration(duration: MaybeDuration): string { 165 | if (this.isNum(duration)) return `${duration}ms`; 166 | return `${duration}`; 167 | } 168 | static getColor(color: MaybeColor) { 169 | return color; 170 | } 171 | static objectKeys(obj: Partial>): T[] { 172 | return Object.keys(obj) as T[]; 173 | } 174 | static style2Vars(style: IpadCursorStyle) { 175 | const map: Record = { 176 | backdropBlur: "--cursor-bg-blur", 177 | backdropSaturate: "--cursor-bg-saturate", 178 | background: "--cursor-bg", 179 | border: "--cursor-border", 180 | durationBackdropFilter: "--cursor-blur-duration", 181 | durationBase: "--cursor-duration", 182 | durationPosition: "--cursor-position-duration", 183 | height: "--cursor-height", 184 | radius: "--cursor-radius", 185 | scale: "--cursor-scale", 186 | width: "--cursor-width", 187 | zIndex: "--cursor-z-index", 188 | }; 189 | return this.objectKeys(style).reduce((prev, key) => { 190 | let value = style[key]; 191 | if (value === undefined) return prev; 192 | 193 | const maybeColor = ["background", "border"].includes(key); 194 | const maybeSize = ["width", "height", "radius", "backdropBlur"].includes( 195 | key 196 | ); 197 | const maybeDuration = key.startsWith("duration"); 198 | 199 | if (maybeColor) value = this.getColor(value as MaybeColor); 200 | if (maybeSize) value = this.getSize(value as MaybeSize); 201 | if (maybeDuration) value = this.getDuration(value as MaybeDuration); 202 | 203 | const recordKey = map[key] || key; 204 | return { ...prev, [recordKey]: value }; 205 | }, {}); 206 | } 207 | static isMergebleObject(obj: any) { 208 | const isObject = (o: any) => 209 | o && typeof o === "object" && !Array.isArray(o); 210 | return isObject(obj); 211 | } 212 | static mergeDeep(obj: T, ...sources: any[]): T { 213 | if (!sources.length) return obj; 214 | const source = sources.shift(); 215 | if (!source) return obj; 216 | if (this.isMergebleObject(obj) && this.isMergebleObject(source)) { 217 | Utils.objectKeys(source).forEach((key) => { 218 | if (this.isMergebleObject(source[key])) { 219 | if (!(obj as any)[key]) Object.assign(obj as any, { [key]: {} }); 220 | this.mergeDeep((obj as any)[key], source[key]); 221 | } else { 222 | Object.assign(obj as any, { [key]: source[key] }); 223 | } 224 | }); 225 | } 226 | return this.mergeDeep(obj, ...sources); 227 | } 228 | } 229 | 230 | /** 231 | * Get default config 232 | * @returns 233 | */ 234 | function getDefaultConfig(): IpadCursorConfig { 235 | const normalStyle: IpadCursorStyle = { 236 | width: "20px", 237 | height: "20px", 238 | radius: "10px", 239 | durationBase: "0.23s", 240 | durationPosition: "0s", 241 | durationBackdropFilter: "0s", 242 | background: "rgba(150, 150, 150, 0.2)", 243 | scale: 1, 244 | border: "1px solid rgba(100, 100, 100, 0.1)", 245 | zIndex: 9999, 246 | backdropBlur: "0px", 247 | backdropSaturate: "180%", 248 | }; 249 | 250 | const textStyle: IpadCursorStyle = { 251 | background: "rgba(100, 100, 100, 0.3)", 252 | scale: 1, 253 | width: "4px", 254 | height: "1.2em", 255 | border: "0px solid rgba(100, 100, 100, 0)", 256 | durationBackdropFilter: "1s", 257 | radius: "10px", 258 | }; 259 | 260 | const blockStyle: IpadCursorStyle = { 261 | background: "rgba(100, 100, 100, 0.3)", 262 | border: "1px solid rgba(100, 100, 100, 0.05)", 263 | backdropBlur: "0px", 264 | durationBase: "0.23s", 265 | durationBackdropFilter: "0.1s", 266 | backdropSaturate: "120%", 267 | radius: "10px", 268 | }; 269 | 270 | const mouseDownStyle: IpadCursorStyle = { 271 | background: "rgba(150, 150, 150, 0.3)", 272 | scale: 0.8, 273 | }; 274 | const defaultConfig: IpadCursorConfig = { 275 | blockPadding: "auto", 276 | adsorptionStrength: 10, 277 | className: "ipad-cursor", 278 | normalStyle, 279 | textStyle, 280 | blockStyle, 281 | mouseDownStyle, 282 | }; 283 | return defaultConfig; 284 | } 285 | 286 | /** update cursor style (single or multiple) */ 287 | function updateCursorStyle( 288 | keyOrObj: string | Record, 289 | value?: string 290 | ) { 291 | if (!cursorEle) return; 292 | if (typeof keyOrObj === "string") { 293 | latestCursorStyle[keyOrObj] = value; 294 | value && cursorEle.style.setProperty(keyOrObj, value); 295 | } else { 296 | Object.entries(keyOrObj).forEach(([key, value]) => { 297 | cursorEle && cursorEle.style.setProperty(key, value); 298 | latestCursorStyle[key] = value; 299 | }); 300 | } 301 | } 302 | 303 | /** record mouse position */ 304 | function onMousemove(e: MouseEvent) { 305 | position.x = e.clientX; 306 | position.y = e.clientY; 307 | autoApplyTextCursor(e.target as HTMLElement); 308 | } 309 | 310 | function onMousedown() { 311 | if (isMouseDown || !config.enableMouseDownEffect || isBlockActive) return; 312 | isMouseDown = true; 313 | mousedownStyleRecover = { ...latestCursorStyle }; 314 | updateCursorStyle(Utils.style2Vars(config.mouseDownStyle || {})); 315 | } 316 | 317 | function onMouseup() { 318 | if (!isMouseDown || !config.enableMouseDownEffect || isBlockActive) return; 319 | isMouseDown = false; 320 | const target = mousedownStyleRecover; 321 | const styleToRecover = Utils.objectKeys( 322 | Utils.style2Vars(config.mouseDownStyle || {}) 323 | ).reduce((prev, curr) => ({ ...prev, [curr]: target[curr] }), {}); 324 | updateCursorStyle(styleToRecover); 325 | } 326 | 327 | /** 328 | * Automatically apply cursor style when hover on target 329 | * @param target 330 | * @returns 331 | */ 332 | function autoApplyTextCursor(target: HTMLElement) { 333 | if (isBlockActive || isTextActive || !config.enableAutoTextCursor) return; 334 | if (target && target.childNodes.length === 1) { 335 | const child = target.childNodes[0] as HTMLElement; 336 | if (child.nodeType === 3 && child.textContent?.trim() !== "") { 337 | target.setAttribute("data-cursor", "text"); 338 | applyTextCursor(target); 339 | return; 340 | } 341 | } 342 | resetCursorStyle(); 343 | } 344 | 345 | let lastNode: Element | null = null; 346 | const scrollHandler = () => { 347 | const currentNode = document.elementFromPoint(position.x, position.y); 348 | const mouseLeaveEvent = new MouseEvent("mouseleave", { 349 | bubbles: true, 350 | cancelable: true, 351 | view: window, 352 | }); 353 | if (currentNode !== lastNode && lastNode && mouseLeaveEvent) { 354 | lastNode.dispatchEvent(mouseLeaveEvent); 355 | } 356 | lastNode = currentNode; 357 | }; 358 | 359 | /** 360 | * Init cursor, hide default cursor, and listen mousemove event 361 | * will only run once in client even if called multiple times 362 | * @returns 363 | */ 364 | function initCursor(_config?: IpadCursorConfig) { 365 | if (isServer || ready) return; 366 | if (_config) updateConfig(_config); 367 | ready = true; 368 | window.addEventListener("mousemove", onMousemove); 369 | window.addEventListener("mousedown", onMousedown); 370 | window.addEventListener("mouseup", onMouseup); 371 | window.addEventListener("scroll", scrollHandler); 372 | createCursor(); 373 | createStyle(); 374 | updateCursorPosition(); 375 | updateCursor(); 376 | createObserver(); 377 | } 378 | 379 | function createObserver() { 380 | if (config.enableAutoUpdateCursor) { 381 | observer = new MutationObserver(function () { 382 | updateCursor(); 383 | }); 384 | observer.observe(document.body, { childList: true, subtree: true }); 385 | } 386 | } 387 | 388 | /** 389 | * destroy cursor, remove event listener and remove cursor element 390 | * @returns 391 | */ 392 | function disposeCursor() { 393 | if (!ready) return; 394 | ready = false; 395 | window.removeEventListener("mousemove", onMousemove); 396 | window.removeEventListener("scroll", scrollHandler); 397 | cursorEle && cursorEle.remove(); 398 | styleTag && styleTag.remove(); 399 | styleTag = null; 400 | cursorEle = null; 401 | 402 | // iterate nodesMap 403 | registeredNodeSet.forEach((node) => unregisterNode(node)); 404 | observer?.disconnect() 405 | } 406 | 407 | /** 408 | * Update current Configuration 409 | * @param _config 410 | */ 411 | function updateConfig(_config: IpadCursorConfig) { 412 | if ("adsorptionStrength" in _config) { 413 | config.adsorptionStrength = Utils.clamp( 414 | _config.adsorptionStrength ?? 10, 415 | 0, 416 | 30 417 | ); 418 | } 419 | const newConfig = Utils.mergeDeep(config, _config); 420 | if (!isBlockActive && !isTextActive && _config.normalStyle) { 421 | updateCursorStyle(Utils.style2Vars(newConfig.normalStyle!)); 422 | } else if (isBlockActive && _config.blockStyle) { 423 | updateCursorStyle(Utils.style2Vars(newConfig.blockStyle!)); 424 | } else if (isTextActive && _config.textStyle) { 425 | updateCursorStyle(Utils.style2Vars(newConfig.textStyle!)); 426 | } 427 | return newConfig; 428 | } 429 | 430 | /** 431 | * Create style tag 432 | * @returns 433 | */ 434 | function createStyle() { 435 | if (styleTag) return; 436 | const selector = `.${config.className!.split(/\s+/).join(".")}`; 437 | styleTag = document.createElement("style"); 438 | styleTag.innerHTML = ` 439 | body, * { 440 | cursor: none; 441 | } 442 | ${selector} { 443 | --cursor-transform-duration: 0.23s; 444 | overflow: hidden; 445 | pointer-events: none; 446 | position: fixed; 447 | left: var(--cursor-x); 448 | top: var(--cursor-y); 449 | width: var(--cursor-width); 450 | height: var(--cursor-height); 451 | border-radius: var(--cursor-radius); 452 | background-color: var(--cursor-bg); 453 | border: var(--cursor-border); 454 | z-index: var(--cursor-z-index); 455 | font-size: var(--cursor-font-size); 456 | backdrop-filter: 457 | blur(var(--cursor-bg-blur)) 458 | saturate(var(--cursor-bg-saturate)); 459 | transition: 460 | width var(--cursor-duration) ease, 461 | height var(--cursor-duration) ease, 462 | border-radius var(--cursor-duration) ease, 463 | border var(--cursor-duration) ease, 464 | background-color var(--cursor-duration) ease, 465 | left var(--cursor-position-duration) ease, 466 | top var(--cursor-position-duration) ease, 467 | backdrop-filter var(--cursor-blur-duration) ease, 468 | transform var(--cursor-transform-duration) ease; 469 | transform: 470 | translateX(calc(var(--cursor-translateX, 0px) - 50%)) 471 | translateY(calc(var(--cursor-translateY, 0px) - 50%)) 472 | scale(var(--cursor-scale, 1)); 473 | } 474 | ${selector}.block-active { 475 | --cursor-transform-duration: 0s; 476 | } 477 | ${selector} .lighting { 478 | display: none; 479 | } 480 | ${selector}.lighting--on .lighting { 481 | display: block; 482 | width: 0; 483 | height: 0; 484 | position: absolute; 485 | left: calc(var(--lighting-size) / -2); 486 | top: calc(var(--lighting-size) / -2); 487 | transform: translateX(var(--lighting-offset-x, 0)) translateY(var(--lighting-offset-y, 0)); 488 | background-image: radial-gradient( 489 | circle at center, 490 | rgba(255, 255, 255, 0.1) 0%, 491 | rgba(255, 255, 255, 0) 30% 492 | ); 493 | border-radius: 50%; 494 | } 495 | ${selector}.block-active .lighting { 496 | width: var(--lighting-size, 20px); 497 | height: var(--lighting-size, 20px); 498 | } 499 | `; 500 | document.head.appendChild(styleTag); 501 | } 502 | 503 | /** 504 | * create cursor element, append to body 505 | * @returns 506 | */ 507 | function createCursor() { 508 | if (isServer) return; 509 | cursorEle = document.createElement("div"); 510 | const lightingEle = document.createElement("div"); 511 | cursorEle.classList.add(config.className!); 512 | lightingEle.classList.add("lighting"); 513 | cursorEle.appendChild(lightingEle); 514 | document.body.appendChild(cursorEle); 515 | resetCursorStyle(); 516 | } 517 | 518 | /** 519 | * update cursor position, request animation frame 520 | * @returns 521 | */ 522 | function updateCursorPosition() { 523 | if (isServer || !cursorEle) return; 524 | if (!isBlockActive) { 525 | updateCursorStyle("--cursor-x", `${position.x}px`); 526 | updateCursorStyle("--cursor-y", `${position.y}px`); 527 | } 528 | window.requestAnimationFrame(updateCursorPosition); 529 | } 530 | 531 | /** 532 | * get all hover targets 533 | * @returns 534 | */ 535 | function queryAllTargets() { 536 | if (isServer || !ready) return []; 537 | return document.querySelectorAll("[data-cursor]"); 538 | } 539 | 540 | /** 541 | * Detect all interactive elements in the page 542 | * Update the binding of events, remove listeners for elements that are removed 543 | * @returns 544 | */ 545 | function updateCursor() { 546 | initCursor() 547 | if (isServer || !ready) return; 548 | const nodesMap = new Map(); 549 | // addDataCursorText(document.body.childNodes) 550 | const nodes = queryAllTargets(); 551 | nodes.forEach((node) => { 552 | nodesMap.set(node, true); 553 | if (registeredNodeSet.has(node)) return; 554 | registerNode(node); 555 | }); 556 | 557 | registeredNodeSet.forEach((node) => { 558 | if (nodesMap.has(node)) return; 559 | unregisterNode(node); 560 | }); 561 | } 562 | 563 | function registerNode(node: Element) { 564 | let type = node.getAttribute("data-cursor") as ICursorType; 565 | registeredNodeSet.add(node); 566 | if (type === "text") registerTextNode(node); 567 | if (type === "block") registerBlockNode(node); 568 | else registeredNodeSet.delete(node); 569 | } 570 | 571 | function unregisterNode(node: Element) { 572 | registeredNodeSet.delete(node); 573 | eventMap.get(node)?.forEach(({ event, handler }: any) => { 574 | if (event === 'mouseleave') 575 | handler(); 576 | node.removeEventListener(event, handler); 577 | }); 578 | eventMap.delete(node); 579 | (node as HTMLElement).style.setProperty("transform", "none"); 580 | } 581 | 582 | function extractCustomStyle(node: Element) { 583 | const customStyleRaw = node.getAttribute("data-cursor-style"); 584 | const styleObj: Record = {}; 585 | if (customStyleRaw) { 586 | customStyleRaw.split(/(;)/).forEach((style) => { 587 | const [key, value] = style.split(":").map((s) => s.trim()); 588 | styleObj[key] = value; 589 | }); 590 | } 591 | return styleObj; 592 | } 593 | 594 | /** 595 | * + ---------------------- + 596 | * | TextNode | 597 | * + ---------------------- + 598 | */ 599 | function registerTextNode(node: Element) { 600 | let timer: any; 601 | 602 | function toggleTextActive(active?: boolean) { 603 | isTextActive = !!active; 604 | cursorEle && 605 | (active 606 | ? cursorEle.classList.add("text-active") 607 | : cursorEle.classList.remove("text-active")); 608 | } 609 | function onTextOver(e: Event) { 610 | timer && clearTimeout(timer); 611 | toggleTextActive(true); 612 | // for some edge case, two ele very close 613 | timer = setTimeout(() => toggleTextActive(true)); 614 | applyTextCursor(e.target as HTMLElement); 615 | } 616 | function onTextLeave() { 617 | timer && clearTimeout(timer); 618 | timer = setTimeout(() => toggleTextActive(false)); 619 | resetCursorStyle(); 620 | } 621 | node.addEventListener("mouseover", onTextOver, { passive: true }); 622 | node.addEventListener("mouseleave", onTextLeave, { passive: true }); 623 | eventMap.set(node, [ 624 | { event: "mouseover", handler: onTextOver }, 625 | { event: "mouseleave", handler: onTextLeave }, 626 | ]); 627 | } 628 | 629 | /** 630 | * + ---------------------- + 631 | * | BlockNode | 632 | * + ---------------------- + 633 | */ 634 | function registerBlockNode(_node: Element) { 635 | const node = _node as HTMLElement; 636 | node.addEventListener("mouseenter", onBlockEnter, { passive: true }); 637 | node.addEventListener("mousemove", onBlockMove, { passive: true }); 638 | node.addEventListener("mouseleave", onBlockLeave, { passive: true }); 639 | 640 | let timer: any; 641 | 642 | function toggleBlockActive(active?: boolean) { 643 | isBlockActive = !!active; 644 | cursorEle && 645 | (active 646 | ? cursorEle.classList.add("block-active") 647 | : cursorEle.classList.remove("block-active")); 648 | activeDom = active ? node : null; 649 | } 650 | 651 | function onBlockEnter() { 652 | // TODO: maybe control this in other way 653 | cursorEle && 654 | cursorEle.classList.toggle("lighting--on", !!config.enableLighting); 655 | 656 | // Prevents the cursor from shifting from the node during rapid enter/leave. 657 | toggleNodeTransition(false); 658 | 659 | const rect = node.getBoundingClientRect(); 660 | timer && clearTimeout(timer); 661 | toggleBlockActive(true); 662 | // for some edge case, two ele very close 663 | timer = setTimeout(() => toggleBlockActive(true)); 664 | cursorEle && cursorEle.classList.add("block-active"); 665 | const updateStyleObj: IpadCursorStyle = { ...(config.blockStyle || {}) }; 666 | const blockPadding = config.blockPadding ?? 0; 667 | let padding = blockPadding; 668 | let radius = updateStyleObj?.radius; 669 | if (padding === "auto") { 670 | const size = Math.min(rect.width, rect.height); 671 | padding = Math.max(2, Math.floor(size / 25)); 672 | } 673 | 674 | if (radius === "auto") { 675 | const paddingCss = Utils.getSize(padding); 676 | const nodeRadius = window.getComputedStyle(node).borderRadius; 677 | if (nodeRadius.startsWith("0") || nodeRadius === "none") radius = "0"; 678 | else radius = `calc(${paddingCss} + ${nodeRadius})`; 679 | updateStyleObj.radius = radius; 680 | } 681 | 682 | updateCursorStyle("--cursor-x", `${rect.left + rect.width / 2}px`); 683 | updateCursorStyle("--cursor-y", `${rect.top + rect.height / 2}px`); 684 | updateCursorStyle("--cursor-width", `${rect.width + padding * 2}px`); 685 | updateCursorStyle("--cursor-height", `${rect.height + padding * 2}px`); 686 | 687 | const styleToUpdate: IpadCursorStyle = { 688 | ...updateStyleObj, 689 | ...extractCustomStyle(node), 690 | }; 691 | 692 | if (styleToUpdate.durationPosition === undefined) { 693 | styleToUpdate.durationPosition = 694 | styleToUpdate.durationBase ?? config.normalStyle?.durationBase; 695 | } 696 | 697 | updateCursorStyle(Utils.style2Vars(styleToUpdate)); 698 | 699 | toggleNodeTransition(true); 700 | node.style.setProperty( 701 | "transform", 702 | "translate(var(--translateX), var(--translateY))" 703 | ); 704 | } 705 | function onBlockMove() { 706 | if (!isBlockActive) { 707 | onBlockEnter(); 708 | } 709 | const rect = node.getBoundingClientRect(); 710 | const halfHeight = rect.height / 2; 711 | const topOffset = (position.y - rect.top - halfHeight) / halfHeight; 712 | const halfWidth = rect.width / 2; 713 | const leftOffset = (position.x - rect.left - halfWidth) / halfWidth; 714 | 715 | const strength = config.adsorptionStrength ?? 10; 716 | updateCursorStyle( 717 | "--cursor-translateX", 718 | `${leftOffset * ((rect.width / 100) * strength)}px` 719 | ); 720 | updateCursorStyle( 721 | "--cursor-translateY", 722 | `${topOffset * ((rect.height / 100) * strength)}px` 723 | ); 724 | 725 | toggleNodeTransition(false); 726 | const nodeTranslateX = leftOffset * ((rect.width / 100) * strength); 727 | const nodeTranslateY = topOffset * ((rect.height / 100) * strength); 728 | node.style.setProperty("--translateX", `${nodeTranslateX}px`); 729 | node.style.setProperty("--translateY", `${nodeTranslateY}px`); 730 | 731 | // lighting 732 | if (config.enableLighting) { 733 | const lightingSize = Math.max(rect.width, rect.height) * 3 * 1.2; 734 | const lightingOffsetX = position.x - rect.left; 735 | const lightingOffsetY = position.y - rect.top; 736 | updateCursorStyle("--lighting-size", `${lightingSize}px`); 737 | updateCursorStyle("--lighting-offset-x", `${lightingOffsetX}px`); 738 | updateCursorStyle("--lighting-offset-y", `${lightingOffsetY}px`); 739 | } 740 | } 741 | function onBlockLeave() { 742 | timer && clearTimeout(timer); 743 | timer = setTimeout(() => toggleBlockActive(false)); 744 | resetCursorStyle(); 745 | toggleNodeTransition(true); 746 | node.style.setProperty("transform", "translate(0px, 0px)"); 747 | } 748 | 749 | function toggleNodeTransition(enable?: boolean) { 750 | const duration = enable 751 | ? Utils.getDuration( 752 | config?.blockStyle?.durationPosition ?? 753 | config?.blockStyle?.durationBase ?? 754 | config?.normalStyle?.durationBase ?? 755 | "0.23s" 756 | ) 757 | : ""; 758 | node.style.setProperty( 759 | "transition", 760 | duration ? `all ${duration} cubic-bezier(.58,.09,.46,1.46)` : "none" 761 | ); 762 | } 763 | 764 | eventMap.set(node, [ 765 | { event: "mouseenter", handler: onBlockEnter }, 766 | { event: "mousemove", handler: onBlockMove }, 767 | { event: "mouseleave", handler: onBlockLeave }, 768 | ]); 769 | } 770 | 771 | function resetCursorStyle() { 772 | if (config.normalStyle?.radius === "auto") 773 | config.normalStyle.radius = config.normalStyle.width; 774 | updateCursorStyle(Utils.style2Vars(config.normalStyle || {})); 775 | } 776 | 777 | function applyTextCursor(sourceNode: HTMLElement) { 778 | updateCursorStyle(Utils.style2Vars(config.textStyle || {})); 779 | const fontSize = window.getComputedStyle(sourceNode).fontSize; 780 | updateCursorStyle("--cursor-font-size", fontSize); 781 | updateCursorStyle( 782 | Utils.style2Vars({ 783 | ...config.textStyle, 784 | ...extractCustomStyle(sourceNode), 785 | }) 786 | ); 787 | } 788 | 789 | /** 790 | * Create custom style that can be bound to `data-cursor-style` 791 | * @param style 792 | */ 793 | function customCursorStyle(style: IpadCursorStyle & Record) { 794 | return Object.entries(style) 795 | .map(([key, value]) => `${key}: ${value}`) 796 | .join("; "); 797 | } 798 | 799 | function resetCursor() { 800 | isBlockActive = false; 801 | isTextActive = false; 802 | resetCursorStyle(); 803 | } 804 | 805 | const CursorType = { 806 | TEXT: "text" as ICursorType, 807 | BLOCK: "block" as ICursorType, 808 | }; 809 | 810 | const exported = { 811 | CursorType, 812 | resetCursor, 813 | initCursor, 814 | updateCursor, 815 | disposeCursor, 816 | updateConfig, 817 | customCursorStyle, 818 | }; 819 | export { 820 | CursorType, 821 | resetCursor, 822 | initCursor, 823 | updateCursor, 824 | disposeCursor, 825 | updateConfig, 826 | customCursorStyle, 827 | }; 828 | export default exported; 829 | -------------------------------------------------------------------------------- /src/react/README.md: -------------------------------------------------------------------------------- 1 | ## IPad Cursor React Component 2 | 3 | IPad Cursor support of React component usage. 4 | 5 | ## Usage 6 | 7 | it had exported two function 8 | 9 | 1. `IPadCursorProvider` -> add Context on the Top level code. 10 | ```ts 11 | 12 | 13 | 14 | ``` 15 | 2. `useIPadCursor` -> use a Hook to config 16 | ```typescript 17 | const { 18 | updateConfig, 19 | updateCursor, 20 | // ... and so on 21 | } = useIPadCursor(); 22 | ``` -------------------------------------------------------------------------------- /src/react/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect, useRef } from 'react' 2 | import type * as CursorOutput from '..' 3 | import { 4 | CursorType, 5 | IpadCursorConfig, 6 | customCursorStyle, 7 | disposeCursor, 8 | initCursor, 9 | resetCursor, 10 | updateConfig, 11 | updateCursor, 12 | } from '..' 13 | 14 | const useIPadCursorInit = (config?: IpadCursorConfig) => { 15 | useLayoutEffect(() => { 16 | initCursor(config) 17 | return () => { 18 | disposeCursor() 19 | } 20 | }, [config]) 21 | return null 22 | } 23 | 24 | const CursorContext = React.createContext>({}) 25 | export function IPadCursorProvider({ 26 | children, 27 | config, 28 | }: { 29 | children: React.ReactNode 30 | config?: IpadCursorConfig 31 | }) { 32 | useIPadCursorInit(config) 33 | return ( 34 | 47 | {children} 48 | 49 | ) 50 | } 51 | 52 | export function useIPadCursor() { 53 | return React.useContext(CursorContext) 54 | } 55 | -------------------------------------------------------------------------------- /src/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true 7 | } 8 | } -------------------------------------------------------------------------------- /src/vue/index.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted } from "vue"; 2 | import type { App, Directive, Plugin } from "vue"; 3 | import { 4 | CursorType, 5 | initCursor, 6 | resetCursor, 7 | updateCursor, 8 | updateConfig, 9 | disposeCursor, 10 | IpadCursorStyle, 11 | IpadCursorConfig, 12 | customCursorStyle, 13 | } from ".."; 14 | 15 | export const ipadCursorPlugin: Plugin = { 16 | install(app: App, options?: IpadCursorConfig): any { 17 | initCursor(); 18 | options && updateConfig(options); 19 | 20 | function createDirective(type: "block" | "text") { 21 | return { 22 | mounted: (el, binding) => { 23 | el.setAttribute("data-cursor", type); 24 | if (binding.value) { 25 | el.setAttribute( 26 | "data-cursor-style", 27 | typeof binding.value === "string" 28 | ? binding.value 29 | : customCursorStyle(binding.value) 30 | ); 31 | } 32 | updateCursor(); 33 | }, 34 | unmounted: () => updateCursor(), 35 | } as Directive) | string> 36 | } 37 | 38 | app.directive("cursor-block", createDirective('block')); 39 | app.directive("cursor-text", createDirective('text')); 40 | }, 41 | }; 42 | 43 | export function useCursor(config?: IpadCursorConfig) { 44 | onMounted(() => updateCursor()); 45 | onUnmounted(() => updateCursor()); 46 | config && updateConfig(config); 47 | initCursor(); 48 | return { 49 | CursorType, 50 | resetCursor, 51 | disposeCursor, 52 | initCursor, 53 | updateCursor, 54 | updateConfig, 55 | customCursorStyle, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "experimentalDecorators": true, 5 | "allowSyntheticDefaultImports": true, 6 | "module": "esnext", 7 | "target": "es2019", 8 | "lib": ["es2019", "dom"], 9 | "strict": true, 10 | "allowJs": false, 11 | "moduleResolution": "node", 12 | "outDir": "dist", 13 | "types": [ 14 | "node" 15 | ] 16 | }, 17 | "exclude": [ 18 | "*.vue", 19 | "playground", 20 | "examples" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | // uno.config.ts 2 | import { defineConfig } from "unocss"; 3 | 4 | export default defineConfig({ 5 | configFile: "./playground/uno.config.ts", 6 | }); 7 | --------------------------------------------------------------------------------