@sovann72-dev/lynqify-ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/package.json +240 -0
- package/src/components/RichTextEditor/Extension/Indent/backspace.indent.handlers.ts +77 -0
- package/src/components/RichTextEditor/Extension/Indent/indent.extension.ts +285 -0
- package/src/components/RichTextEditor/Extension/Indent/indent.handlers.ts +121 -0
- package/src/components/RichTextEditor/Extension/Indent/indent.types.ts +63 -0
- package/src/components/RichTextEditor/Extension/Indent/indent.utils.ts +8 -0
- package/src/components/RichTextEditor/Extension/Indent/outdent.handlers.ts +71 -0
- package/src/components/RichTextEditor/Extension/Indent/shifttab.indent.handlers.ts +133 -0
- package/src/components/RichTextEditor/Extension/Indent/tab.indent.handlers.ts +103 -0
- package/src/components/RichTextEditor/Extension/List/custom-list-item.extension.ts +107 -0
- package/src/components/RichTextEditor/Extension/List/dynamic-bullet-styling.extension.ts +40 -0
- package/src/components/RichTextEditor/Extension/batch-segment-images.extension.ts +486 -0
- package/src/components/RichTextEditor/Extension/batch-segment-images.types.ts +35 -0
- package/src/components/RichTextEditor/Extension/custom-image.extension.ts +18 -0
- package/src/components/RichTextEditor/Extension/custom-link.extension.ts +58 -0
- package/src/components/RichTextEditor/Extension/custom-mention.extension.ts +29 -0
- package/src/components/RichTextEditor/Extension/custom-paragraph.extension.ts +46 -0
- package/src/components/RichTextEditor/Extension/extensions.ts +118 -0
- package/src/components/RichTextEditor/Extension/file-filtering.extension.ts +0 -0
- package/src/components/RichTextEditor/Extension/list-indent-integration.extension.ts +125 -0
- package/src/components/RichTextEditor/Extension/mentionstorage.extension.ts +10 -0
- package/src/components/RichTextEditor/Extension/tiptap-extension-fontsize.ts +73 -0
- package/src/components/RichTextEditor/Extension/tiptap-extension-lineheight.ts +73 -0
- package/src/index.ts +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# React + TypeScript + Vite
|
|
2
|
+
|
|
3
|
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
4
|
+
|
|
5
|
+
Currently, two official plugins are available:
|
|
6
|
+
|
|
7
|
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
|
8
|
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
9
|
+
|
|
10
|
+
## React Compiler
|
|
11
|
+
|
|
12
|
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
13
|
+
|
|
14
|
+
## Expanding the ESLint configuration
|
|
15
|
+
|
|
16
|
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
export default defineConfig([
|
|
20
|
+
globalIgnores(['dist']),
|
|
21
|
+
{
|
|
22
|
+
files: ['**/*.{ts,tsx}'],
|
|
23
|
+
extends: [
|
|
24
|
+
// Other configs...
|
|
25
|
+
|
|
26
|
+
// Remove tseslint.configs.recommended and replace with this
|
|
27
|
+
tseslint.configs.recommendedTypeChecked,
|
|
28
|
+
// Alternatively, use this for stricter rules
|
|
29
|
+
tseslint.configs.strictTypeChecked,
|
|
30
|
+
// Optionally, add this for stylistic rules
|
|
31
|
+
tseslint.configs.stylisticTypeChecked,
|
|
32
|
+
|
|
33
|
+
// Other configs...
|
|
34
|
+
],
|
|
35
|
+
languageOptions: {
|
|
36
|
+
parserOptions: {
|
|
37
|
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
38
|
+
tsconfigRootDir: import.meta.dirname,
|
|
39
|
+
},
|
|
40
|
+
// other options...
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// eslint.config.js
|
|
50
|
+
import reactX from 'eslint-plugin-react-x'
|
|
51
|
+
import reactDom from 'eslint-plugin-react-dom'
|
|
52
|
+
|
|
53
|
+
export default defineConfig([
|
|
54
|
+
globalIgnores(['dist']),
|
|
55
|
+
{
|
|
56
|
+
files: ['**/*.{ts,tsx}'],
|
|
57
|
+
extends: [
|
|
58
|
+
// Other configs...
|
|
59
|
+
// Enable lint rules for React
|
|
60
|
+
reactX.configs['recommended-typescript'],
|
|
61
|
+
// Enable lint rules for React DOM
|
|
62
|
+
reactDom.configs.recommended,
|
|
63
|
+
],
|
|
64
|
+
languageOptions: {
|
|
65
|
+
parserOptions: {
|
|
66
|
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
67
|
+
tsconfigRootDir: import.meta.dirname,
|
|
68
|
+
},
|
|
69
|
+
// other options...
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
])
|
|
73
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sovann72-dev/lynqify-ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "vite",
|
|
16
|
+
"start:local": "env-cmd -f .env.local vite",
|
|
17
|
+
"build": "NODE_OPTIONS=--max_old_space_size=8192 vite build",
|
|
18
|
+
"build:win": "set NODE_OPTIONS=--max_old_space_size=8192 && vite build",
|
|
19
|
+
"lint": "eslint src --ext .ts,.tsx,.js,.jsx && yarn check-types",
|
|
20
|
+
"lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix",
|
|
21
|
+
"lint:prettier": "prettier --write .",
|
|
22
|
+
"check-types": "tsc --project tsconfig.json --pretty --noEmit",
|
|
23
|
+
"test": "vite test",
|
|
24
|
+
"vitest": "vitest",
|
|
25
|
+
"cy:open": "cypress open",
|
|
26
|
+
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
|
27
|
+
"build:analyze": "ANALYZE=true vite build",
|
|
28
|
+
"prepare": "husky"
|
|
29
|
+
},
|
|
30
|
+
"lint-staged": {
|
|
31
|
+
"src/**/*.{js,jsx,ts,tsx}": [
|
|
32
|
+
"eslint --fix",
|
|
33
|
+
"prettier --write"
|
|
34
|
+
],
|
|
35
|
+
"*.{json,md,scss,css}": [
|
|
36
|
+
"prettier --write"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"eslintConfig": {
|
|
40
|
+
"extends": [
|
|
41
|
+
"react-app",
|
|
42
|
+
"react-app/jest"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"browserslist": {
|
|
46
|
+
"production": [
|
|
47
|
+
">0.2%",
|
|
48
|
+
"not dead",
|
|
49
|
+
"not op_mini all"
|
|
50
|
+
],
|
|
51
|
+
"development": [
|
|
52
|
+
"last 1 chrome version",
|
|
53
|
+
"last 1 firefox version",
|
|
54
|
+
"last 1 safari version"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
|
|
59
|
+
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
|
|
60
|
+
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
|
61
|
+
"@casl/ability": "^6.7.2",
|
|
62
|
+
"@casl/react": "^4.0.0",
|
|
63
|
+
"@emotion/react": "^11.14.0",
|
|
64
|
+
"@emotion/styled": "^11.14.0",
|
|
65
|
+
"@floating-ui/dom": "^1.6.0",
|
|
66
|
+
"@hocuspocus/provider": "^2.13.0",
|
|
67
|
+
"@hookform/resolvers": "^3.3.4",
|
|
68
|
+
"@mui/icons-material": "^5.16.4",
|
|
69
|
+
"@mui/material": "^5.16.4",
|
|
70
|
+
"@mui/x-date-pickers": "^7.10.0",
|
|
71
|
+
"@posthog/react": "^1.5.2",
|
|
72
|
+
"@radix-ui/react-accordion": "^1.1.2",
|
|
73
|
+
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
|
74
|
+
"@radix-ui/react-avatar": "^1.0.4",
|
|
75
|
+
"@radix-ui/react-checkbox": "^1.0.4",
|
|
76
|
+
"@radix-ui/react-dialog": "^1.0.5",
|
|
77
|
+
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
|
78
|
+
"@radix-ui/react-icons": "^1.3.0",
|
|
79
|
+
"@radix-ui/react-label": "^2.0.2",
|
|
80
|
+
"@radix-ui/react-popover": "^1.0.7",
|
|
81
|
+
"@radix-ui/react-radio-group": "^1.2.0",
|
|
82
|
+
"@radix-ui/react-select": "^2.1.1",
|
|
83
|
+
"@radix-ui/react-separator": "^1.0.3",
|
|
84
|
+
"@radix-ui/react-slider": "^1.1.2",
|
|
85
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
86
|
+
"@radix-ui/react-switch": "^1.0.3",
|
|
87
|
+
"@radix-ui/react-tabs": "^1.1.2",
|
|
88
|
+
"@radix-ui/react-tooltip": "^1.0.7",
|
|
89
|
+
"@react-google-maps/api": "^2.20.8",
|
|
90
|
+
"@tailwindcss/forms": "^0.5.7",
|
|
91
|
+
"@tanstack/react-table": "^8.12.0",
|
|
92
|
+
"@tiptap/core": "^3.22.5",
|
|
93
|
+
"@tiptap/extension-bold": "^3.22.5",
|
|
94
|
+
"@tiptap/extension-color": "^3.22.5",
|
|
95
|
+
"@tiptap/extension-document": "^3.22.5",
|
|
96
|
+
"@tiptap/extension-file-handler": "^3.22.5",
|
|
97
|
+
"@tiptap/extension-hard-break": "^3.22.5",
|
|
98
|
+
"@tiptap/extension-heading": "^3.22.5",
|
|
99
|
+
"@tiptap/extension-highlight": "^3.22.5",
|
|
100
|
+
"@tiptap/extension-image": "^3.22.5",
|
|
101
|
+
"@tiptap/extension-italic": "^3.22.5",
|
|
102
|
+
"@tiptap/extension-link": "^3.22.5",
|
|
103
|
+
"@tiptap/extension-list": "^3.22.5",
|
|
104
|
+
"@tiptap/extension-mention": "^3.22.5",
|
|
105
|
+
"@tiptap/extension-paragraph": "^3.22.5",
|
|
106
|
+
"@tiptap/extension-table": "^3.22.5",
|
|
107
|
+
"@tiptap/extension-text": "^3.22.5",
|
|
108
|
+
"@tiptap/extension-text-align": "^3.22.5",
|
|
109
|
+
"@tiptap/extension-text-style": "^3.22.5",
|
|
110
|
+
"@tiptap/extension-underline": "^3.22.5",
|
|
111
|
+
"@tiptap/extensions": "^3.22.5",
|
|
112
|
+
"@tiptap/pm": "^3.22.5",
|
|
113
|
+
"@tiptap/react": "^3.22.5",
|
|
114
|
+
"@tiptap/starter-kit": "^3.22.5",
|
|
115
|
+
"@tiptap/suggestion": "^3.22.5",
|
|
116
|
+
"@types/react-input-mask": "^3.0.6",
|
|
117
|
+
"@uiw/react-color-sketch": "^2.3.1",
|
|
118
|
+
"@userback/react": "^0.3.7",
|
|
119
|
+
"axios": "^1.6.7",
|
|
120
|
+
"class-variance-authority": "^0.7.0",
|
|
121
|
+
"clipboard-polyfill": "^4.0.2",
|
|
122
|
+
"clsx": "^2.1.0",
|
|
123
|
+
"cmdk": "^1.0.0",
|
|
124
|
+
"date-fns": "^3.6.0",
|
|
125
|
+
"dayjs": "^1.11.10",
|
|
126
|
+
"dompurify": "^3.3.3",
|
|
127
|
+
"env-cmd": "^10.1.0",
|
|
128
|
+
"exceljs": "^4.4.0",
|
|
129
|
+
"heic2any": "^0.0.4",
|
|
130
|
+
"html2canvas": "^1.4.1",
|
|
131
|
+
"i18next": "^23.11.5",
|
|
132
|
+
"input-otp": "^1.2.4",
|
|
133
|
+
"libphonenumber-js": "^1.10.56",
|
|
134
|
+
"lightbox.js-react": "1.1.7",
|
|
135
|
+
"lodash": "^4.17.21",
|
|
136
|
+
"lottie-react": "^2.4.0",
|
|
137
|
+
"material-react-table": "^2.13.0",
|
|
138
|
+
"mathjs": "^14.0.1",
|
|
139
|
+
"mobx": "^6.12.5",
|
|
140
|
+
"mobx-react": "^9.1.0",
|
|
141
|
+
"platform": "^1.3.6",
|
|
142
|
+
"posthog-js": "^1.310.1",
|
|
143
|
+
"qs": "^6.14.0",
|
|
144
|
+
"react": "^18.2.0",
|
|
145
|
+
"react-color": "^2.19.3",
|
|
146
|
+
"react-datepicker": "^8.2.0",
|
|
147
|
+
"react-day-picker": "^8.10.1",
|
|
148
|
+
"react-dom": "^18.2.0",
|
|
149
|
+
"react-draggable": "^4.4.6",
|
|
150
|
+
"react-dropzone": "^14.3.8",
|
|
151
|
+
"react-easy-crop": "^5.0.8",
|
|
152
|
+
"react-error-boundary": "^4.0.13",
|
|
153
|
+
"react-hook-form": "^7.50.1",
|
|
154
|
+
"react-html-string": "^0.1.1",
|
|
155
|
+
"react-i18next": "^14.1.2",
|
|
156
|
+
"react-icons": "^5.0.1",
|
|
157
|
+
"react-infinite-scroll-hook": "^4.1.1",
|
|
158
|
+
"react-input-mask": "^3.0.0-alpha.2",
|
|
159
|
+
"react-international-phone": "^4.3.0",
|
|
160
|
+
"react-router-dom": "^6.24.1",
|
|
161
|
+
"react-signature-pad-wrapper": "^3.3.4",
|
|
162
|
+
"react-to-print": "^3.1.0",
|
|
163
|
+
"react-toastify": "^10.0.4",
|
|
164
|
+
"react-tooltip": "^5.26.4",
|
|
165
|
+
"react-virtuoso": "^4.14.1",
|
|
166
|
+
"recharts": "^2.12.7",
|
|
167
|
+
"sass": "^1.77.6",
|
|
168
|
+
"shadcn-ui": "^0.8.0",
|
|
169
|
+
"styled-components": "^6.1.8",
|
|
170
|
+
"tailwind-merge": "^2.2.1",
|
|
171
|
+
"tailwindcss-animate": "^1.0.7",
|
|
172
|
+
"tiny-invariant": "^1.3.3",
|
|
173
|
+
"use-file-picker": "^2.1.1",
|
|
174
|
+
"uuid": "^9.0.1",
|
|
175
|
+
"vaul": "^0.9.0",
|
|
176
|
+
"web-vitals": "^2.1.4",
|
|
177
|
+
"y-prosemirror": "^1.3.7",
|
|
178
|
+
"y-protocols": "^1.0.6",
|
|
179
|
+
"yjs": "^13.6.27",
|
|
180
|
+
"zod": "^3.22.4"
|
|
181
|
+
},
|
|
182
|
+
"devDependencies": {
|
|
183
|
+
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
|
184
|
+
"@babel/plugin-proposal-decorators": "^7.25.9",
|
|
185
|
+
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
|
186
|
+
"@commitlint/cli": "^20.5.3",
|
|
187
|
+
"@commitlint/config-conventional": "^20.5.3",
|
|
188
|
+
"@eslint/js": "^9.5.0",
|
|
189
|
+
"@faker-js/faker": "^9.6.0",
|
|
190
|
+
"@nabla/vite-plugin-eslint": "^2.0.5",
|
|
191
|
+
"@testing-library/jest-dom": "^5.17.0",
|
|
192
|
+
"@testing-library/react": "^13.4.0",
|
|
193
|
+
"@testing-library/user-event": "^13.5.0",
|
|
194
|
+
"@types/jest": "^27.5.2",
|
|
195
|
+
"@types/js-cookie": "^3.0.6",
|
|
196
|
+
"@types/lodash": "^4.14.202",
|
|
197
|
+
"@types/node": "^16.18.80",
|
|
198
|
+
"@types/platform": "^1.3.6",
|
|
199
|
+
"@types/qs": "^6.14.0",
|
|
200
|
+
"@types/react": "^18.2.55",
|
|
201
|
+
"@types/react-color": "^3.0.12",
|
|
202
|
+
"@types/react-dom": "^18.2.19",
|
|
203
|
+
"@types/uuid": "^9.0.8",
|
|
204
|
+
"@typescript-eslint/eslint-plugin": "^7.8.0",
|
|
205
|
+
"@typescript-eslint/parser": "^7.8.0",
|
|
206
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
207
|
+
"autoprefixer": "^10.4.19",
|
|
208
|
+
"babel-plugin-module-resolver": "^5.0.0",
|
|
209
|
+
"cypress": "^14.2.0",
|
|
210
|
+
"eslint": "8",
|
|
211
|
+
"eslint-config-prettier": "^9.1.0",
|
|
212
|
+
"eslint-import-resolver-typescript": "^3.6.1",
|
|
213
|
+
"eslint-plugin-check-file": "^2.8.0",
|
|
214
|
+
"eslint-plugin-import": "^2.29.1",
|
|
215
|
+
"eslint-plugin-jest-dom": "^5.4.0",
|
|
216
|
+
"eslint-plugin-jsx-a11y": "^6.8.0",
|
|
217
|
+
"eslint-plugin-playwright": "^1.6.0",
|
|
218
|
+
"eslint-plugin-prettier": "^5.1.3",
|
|
219
|
+
"eslint-plugin-react": "^7.34.1",
|
|
220
|
+
"eslint-plugin-react-hooks": "^4.6.2",
|
|
221
|
+
"eslint-plugin-tailwindcss": "^3.15.1",
|
|
222
|
+
"eslint-plugin-testing-library": "^6.2.2",
|
|
223
|
+
"husky": "^9.1.7",
|
|
224
|
+
"lint-staged": "^15.2.0",
|
|
225
|
+
"prettier": "^3.3.3",
|
|
226
|
+
"prettier-plugin-tailwindcss": "^0.6.6",
|
|
227
|
+
"rollup-plugin-visualizer": "6.0.5",
|
|
228
|
+
"source-map-explorer": "^2.5.3",
|
|
229
|
+
"tailwindcss": "^3.4.1",
|
|
230
|
+
"typescript": "5.5.4",
|
|
231
|
+
"typescript-eslint": "^7.13.0",
|
|
232
|
+
"vite": "^6.1.0",
|
|
233
|
+
"vite-plugin-checker": "^0.9.0",
|
|
234
|
+
"vite-plugin-compression": "^0.5.1",
|
|
235
|
+
"vite-plugin-svgr": "^4.3.0",
|
|
236
|
+
"vite-tsconfig-paths": "^5.1.4",
|
|
237
|
+
"vitest": "^3.0.7"
|
|
238
|
+
},
|
|
239
|
+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
|
240
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type ShortcutHandler, isListParent, isTextNode } from './indent.types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WHY: When the cursor is at the very start of an indented text block,
|
|
5
|
+
* Backspace should visually un-indent the line rather than delete backwards.
|
|
6
|
+
* This mirrors how Google Docs treats Backspace at the start of an indented line.
|
|
7
|
+
*/
|
|
8
|
+
export const handleBackspaceOutdentAtLineStart: ShortcutHandler = ({
|
|
9
|
+
editor,
|
|
10
|
+
$from,
|
|
11
|
+
currentNode,
|
|
12
|
+
}) => {
|
|
13
|
+
const isAtStart = $from.pos === $from.start();
|
|
14
|
+
const hasIndent = currentNode.attrs.indent > 0;
|
|
15
|
+
const isIndentedTextNode = isTextNode(currentNode.type.name) && hasIndent;
|
|
16
|
+
|
|
17
|
+
if (isIndentedTextNode && isAtStart) {
|
|
18
|
+
return editor.commands.outdent();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return false;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* WHY: An empty indented line has no meaningful content to merge with the previous block.
|
|
26
|
+
* Deleting it entirely (rather than joining it) keeps the document structure clean.
|
|
27
|
+
*/
|
|
28
|
+
export const handleBackspaceDeleteEmptyIndentedLine: ShortcutHandler = ({
|
|
29
|
+
state,
|
|
30
|
+
view,
|
|
31
|
+
$from,
|
|
32
|
+
currentNode,
|
|
33
|
+
}) => {
|
|
34
|
+
const isEmpty = currentNode.content.size === 0;
|
|
35
|
+
const hasIndent = currentNode.attrs.indent > 0;
|
|
36
|
+
const isEmptyIndentedTextNode = isTextNode(currentNode.type.name) && hasIndent && isEmpty;
|
|
37
|
+
|
|
38
|
+
if (!isEmptyIndentedTextNode) return false;
|
|
39
|
+
|
|
40
|
+
const nodePos = $from.before($from.depth);
|
|
41
|
+
const tr = state.tr;
|
|
42
|
+
tr.delete(nodePos, nodePos + currentNode.nodeSize);
|
|
43
|
+
view.dispatch(tr);
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* WHY: Backspace on an empty unindented paragraph right after a list would normally
|
|
49
|
+
* cause ProseMirror to merge the paragraph into the last list item, which is confusing.
|
|
50
|
+
* We delete the empty paragraph outright to prevent that unwanted merge.
|
|
51
|
+
*/
|
|
52
|
+
export const handleBackspaceDeleteEmptyParagraphAfterList: ShortcutHandler = ({
|
|
53
|
+
state,
|
|
54
|
+
view,
|
|
55
|
+
$from,
|
|
56
|
+
currentNode,
|
|
57
|
+
}) => {
|
|
58
|
+
const isAtStart = $from.pos === $from.start();
|
|
59
|
+
const isEmpty = currentNode.content.size === 0;
|
|
60
|
+
const hasIndent = currentNode.attrs.indent > 0;
|
|
61
|
+
|
|
62
|
+
const nodePos = $from.before($from.depth);
|
|
63
|
+
const prevNode = state.doc.resolve(nodePos).nodeBefore;
|
|
64
|
+
const prevNodeIsList = prevNode != null && isListParent(prevNode.type.name);
|
|
65
|
+
|
|
66
|
+
const isEmptyUnindentedTextNode =
|
|
67
|
+
isTextNode(currentNode.type.name) && !hasIndent && isEmpty && isAtStart;
|
|
68
|
+
|
|
69
|
+
if (isEmptyUnindentedTextNode && prevNodeIsList) {
|
|
70
|
+
const tr = state.tr;
|
|
71
|
+
tr.delete(nodePos, nodePos + currentNode.nodeSize);
|
|
72
|
+
view.dispatch(tr);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return false;
|
|
77
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { Plugin, Transaction } from '@tiptap/pm/state';
|
|
2
|
+
import { CommandProps, Extension } from '@tiptap/react';
|
|
3
|
+
|
|
4
|
+
import { handleOutdentMultiNodeSelection } from 'src/components/RichTextEditor/Extension/Indent/outdent.handlers';
|
|
5
|
+
import {
|
|
6
|
+
handleBackspaceOutdentAtLineStart,
|
|
7
|
+
handleBackspaceDeleteEmptyIndentedLine,
|
|
8
|
+
handleBackspaceDeleteEmptyParagraphAfterList,
|
|
9
|
+
} from './backspace.indent.handlers';
|
|
10
|
+
import {
|
|
11
|
+
handleIndentMultiNodeSelection,
|
|
12
|
+
handleIndentList,
|
|
13
|
+
handleIndentTextNode,
|
|
14
|
+
} from './indent.handlers';
|
|
15
|
+
import {
|
|
16
|
+
ShortcutContext,
|
|
17
|
+
isListParent,
|
|
18
|
+
isTextNode,
|
|
19
|
+
runChain,
|
|
20
|
+
runIndentChain,
|
|
21
|
+
} from './indent.types';
|
|
22
|
+
import {
|
|
23
|
+
handleShiftTabMultiNodeSelection,
|
|
24
|
+
handleShiftTabFromEmptyParagraphAfterList,
|
|
25
|
+
handleShiftTabInTopLevelListItem,
|
|
26
|
+
} from './shifttab.indent.handlers';
|
|
27
|
+
import {
|
|
28
|
+
handleTabMultiNodeSelection,
|
|
29
|
+
handleTabSingleNodeTextSelection,
|
|
30
|
+
handleTabInsideListItem,
|
|
31
|
+
handleTabMidTextCursor,
|
|
32
|
+
} from './tab.indent.handlers';
|
|
33
|
+
|
|
34
|
+
type IndentOptions = {
|
|
35
|
+
types: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
declare module '@tiptap/core' {
|
|
39
|
+
interface Commands<ReturnType> {
|
|
40
|
+
indent: {
|
|
41
|
+
indent: () => ReturnType;
|
|
42
|
+
outdent: () => ReturnType;
|
|
43
|
+
outdentShiftTab: () => ReturnType;
|
|
44
|
+
increaseIndent: () => ReturnType;
|
|
45
|
+
decreaseIndent: () => ReturnType;
|
|
46
|
+
_handleEnterKeyDown: () => ReturnType;
|
|
47
|
+
_handleSpaceKeyDown: () => ReturnType;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Builds a ShortcutContext from the current editor state.
|
|
54
|
+
* Resolved once per keypress so every handler in the chain sees the same snapshot.
|
|
55
|
+
*/
|
|
56
|
+
function buildContext(editor: any): ShortcutContext {
|
|
57
|
+
const { state, view } = editor;
|
|
58
|
+
const { $from, $to, empty: selectionIsEmpty } = state.selection;
|
|
59
|
+
return { editor, state, view, $from, $to, selectionIsEmpty, currentNode: $from.node() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Adds Google-Doc-style indentation to paragraphs, headings, and lists.
|
|
64
|
+
*
|
|
65
|
+
* Indentation is stored as a `data-indent` attribute and rendered as
|
|
66
|
+
* `padding-left` on the node. Structural list nesting (sinkListItem / liftListItem)
|
|
67
|
+
* is handled separately by the List extension and is not affected by this attribute.
|
|
68
|
+
*/
|
|
69
|
+
export const IndentExtension = Extension.create<IndentOptions>({
|
|
70
|
+
name: 'indent',
|
|
71
|
+
|
|
72
|
+
addKeyboardShortcuts() {
|
|
73
|
+
return {
|
|
74
|
+
Enter: () => this.editor.commands._handleEnterKeyDown(),
|
|
75
|
+
|
|
76
|
+
Tab: () => {
|
|
77
|
+
const context = buildContext(this.editor);
|
|
78
|
+
const handled = runChain(
|
|
79
|
+
[
|
|
80
|
+
handleTabMultiNodeSelection,
|
|
81
|
+
handleTabSingleNodeTextSelection,
|
|
82
|
+
handleTabInsideListItem,
|
|
83
|
+
handleTabMidTextCursor,
|
|
84
|
+
],
|
|
85
|
+
context,
|
|
86
|
+
);
|
|
87
|
+
return handled || this.editor.commands.indent();
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
Backspace: () => {
|
|
91
|
+
const context = buildContext(this.editor);
|
|
92
|
+
return runChain(
|
|
93
|
+
[
|
|
94
|
+
handleBackspaceOutdentAtLineStart,
|
|
95
|
+
handleBackspaceDeleteEmptyIndentedLine,
|
|
96
|
+
handleBackspaceDeleteEmptyParagraphAfterList,
|
|
97
|
+
],
|
|
98
|
+
context,
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
'Shift-Tab': () => {
|
|
103
|
+
const context = buildContext(this.editor);
|
|
104
|
+
const handled = runChain(
|
|
105
|
+
[
|
|
106
|
+
handleShiftTabMultiNodeSelection,
|
|
107
|
+
handleShiftTabFromEmptyParagraphAfterList,
|
|
108
|
+
handleShiftTabInTopLevelListItem,
|
|
109
|
+
],
|
|
110
|
+
context,
|
|
111
|
+
);
|
|
112
|
+
return handled || this.editor.chain().outdentShiftTab().run();
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
Space: () => this.editor.commands._handleSpaceKeyDown(),
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
addProseMirrorPlugins() {
|
|
120
|
+
return [
|
|
121
|
+
new Plugin({
|
|
122
|
+
// WHY: When a paragraph/heading inside a listItem gets an indent attribute (e.g. from a
|
|
123
|
+
// paste or undo), the indent belongs on the parent list node, not the text node inside it.
|
|
124
|
+
// This plugin corrects that automatically on every document change.
|
|
125
|
+
appendTransaction: (transactions, _oldState, newState) => {
|
|
126
|
+
const hasDocChanged = transactions.some((t) => t.docChanged);
|
|
127
|
+
if (!hasDocChanged) return null;
|
|
128
|
+
|
|
129
|
+
let tr: Transaction | null = null;
|
|
130
|
+
|
|
131
|
+
newState.doc.descendants((node, pos) => {
|
|
132
|
+
const isIndentedTextInsideDocument =
|
|
133
|
+
isTextNode(node.type.name) && (node?.attrs?.indent ?? 0) > 0;
|
|
134
|
+
if (!isIndentedTextInsideDocument) return;
|
|
135
|
+
|
|
136
|
+
const resolvedPos = newState.doc.resolve(pos);
|
|
137
|
+
const isDirectChildOfListItem = resolvedPos.parent.type.name === 'listItem';
|
|
138
|
+
if (!isDirectChildOfListItem) return;
|
|
139
|
+
|
|
140
|
+
// Walk up to find the enclosing bulletList or orderedList position.
|
|
141
|
+
// WHY: resolvedPos.start(d) gives the first content position inside the node,
|
|
142
|
+
// so we subtract 1 to get the node's own document position.
|
|
143
|
+
let enclosingListPos: number | null = null;
|
|
144
|
+
for (let d = resolvedPos.depth - 1; d > 0; d--) {
|
|
145
|
+
if (isListParent(resolvedPos.node(d).type.name)) {
|
|
146
|
+
enclosingListPos = resolvedPos.start(d) - 1;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (enclosingListPos === null || enclosingListPos < 0) return;
|
|
152
|
+
|
|
153
|
+
if (!tr) tr = newState.tr;
|
|
154
|
+
|
|
155
|
+
const indentToPromote = node.attrs.indent;
|
|
156
|
+
tr.setNodeAttribute(pos, 'indent', 0); // Clear from text node
|
|
157
|
+
tr.setNodeAttribute(enclosingListPos, 'indent', indentToPromote); // Promote to list
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return tr;
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
];
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
addGlobalAttributes() {
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
types: this.options.types ?? ['orderedList', 'bulletList', 'paragraph', 'heading'],
|
|
170
|
+
attributes: {
|
|
171
|
+
indent: {
|
|
172
|
+
default: 1,
|
|
173
|
+
parseHTML: (element) => parseInt(element.getAttribute('data-indent') || '1', 10),
|
|
174
|
+
renderHTML: (attributes) => {
|
|
175
|
+
if (!attributes?.indent) return {};
|
|
176
|
+
return { style: `padding-left: ${attributes.indent * 20}px` };
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
addCommands() {
|
|
185
|
+
return {
|
|
186
|
+
_handleEnterKeyDown: () => (commands: CommandProps) => {
|
|
187
|
+
const { tr, state, dispatch } = commands;
|
|
188
|
+
const { $from } = state.selection;
|
|
189
|
+
const currentNode = $from.node();
|
|
190
|
+
|
|
191
|
+
const isIndentedParagraph =
|
|
192
|
+
currentNode?.type?.name === 'paragraph' && currentNode.attrs.indent;
|
|
193
|
+
if (!isIndentedParagraph) return false;
|
|
194
|
+
|
|
195
|
+
const currentIndent = currentNode.attrs.indent;
|
|
196
|
+
tr.split($from.pos);
|
|
197
|
+
tr.setNodeAttribute(tr.selection.$from.before(), 'indent', currentIndent);
|
|
198
|
+
tr.scrollIntoView();
|
|
199
|
+
tr.setMeta('addToHistory', false);
|
|
200
|
+
dispatch?.(tr);
|
|
201
|
+
return true;
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
_handleSpaceKeyDown: () => () => false,
|
|
205
|
+
|
|
206
|
+
indent: () => (commands: CommandProps) => {
|
|
207
|
+
return runIndentChain(
|
|
208
|
+
[handleIndentMultiNodeSelection, handleIndentList, handleIndentTextNode],
|
|
209
|
+
commands,
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
// WHY: `outdent` is used by Backspace. It only reduces indent when the cursor
|
|
214
|
+
// is at the very start of the node, preventing accidental outdenting mid-sentence.
|
|
215
|
+
outdent: () => (commands: CommandProps) => {
|
|
216
|
+
const { state, view, tr, dispatch } = commands;
|
|
217
|
+
const { $from, $to, empty: selectionIsEmpty } = state.selection;
|
|
218
|
+
const context: ShortcutContext = {
|
|
219
|
+
editor: commands.editor,
|
|
220
|
+
state,
|
|
221
|
+
view,
|
|
222
|
+
$from,
|
|
223
|
+
$to,
|
|
224
|
+
selectionIsEmpty,
|
|
225
|
+
currentNode: $from.node(),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if (handleOutdentMultiNodeSelection(context)) return true;
|
|
229
|
+
|
|
230
|
+
const currentNode = $from.node();
|
|
231
|
+
const isIndentedTextNode =
|
|
232
|
+
isTextNode(currentNode.type.name) && currentNode.attrs.indent > 0;
|
|
233
|
+
if (!isIndentedTextNode) return false;
|
|
234
|
+
|
|
235
|
+
const isCursorAtLineStart = $from.pos === $from.start();
|
|
236
|
+
if (!isCursorAtLineStart) return false;
|
|
237
|
+
|
|
238
|
+
const newIndent = Math.max(0, currentNode.attrs.indent - 1);
|
|
239
|
+
tr.setNodeAttribute($from.before($from.depth), 'indent', newIndent);
|
|
240
|
+
tr.setMeta('addToHistory', false);
|
|
241
|
+
dispatch?.(tr);
|
|
242
|
+
return true;
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
// WHY: `outdentShiftTab` is used by Shift+Tab. Unlike `outdent`, it reduces indent
|
|
246
|
+
// regardless of where the cursor is — Shift+Tab is an explicit intent to un-indent.
|
|
247
|
+
outdentShiftTab: () => (commands: CommandProps) => {
|
|
248
|
+
const { state, tr, dispatch } = commands;
|
|
249
|
+
const { $from } = state.selection;
|
|
250
|
+
const currentNode = $from.node();
|
|
251
|
+
|
|
252
|
+
const isIndentedTextNode =
|
|
253
|
+
isTextNode(currentNode.type.name) && currentNode.attrs.indent > 0;
|
|
254
|
+
if (!isIndentedTextNode) return false;
|
|
255
|
+
|
|
256
|
+
const newIndent = Math.max(0, currentNode.attrs.indent - 1);
|
|
257
|
+
tr.setNodeAttribute($from.before($from.depth), 'indent', newIndent);
|
|
258
|
+
tr.setMeta('addToHistory', false);
|
|
259
|
+
dispatch?.(tr);
|
|
260
|
+
return true;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
increaseIndent:
|
|
264
|
+
() =>
|
|
265
|
+
({ commands }: CommandProps) => {
|
|
266
|
+
return commands.indent();
|
|
267
|
+
},
|
|
268
|
+
decreaseIndent: () => (commands: CommandProps) => {
|
|
269
|
+
const { state, view } = commands;
|
|
270
|
+
const { $from, $to, empty: selectionIsEmpty } = state.selection;
|
|
271
|
+
const context: ShortcutContext = {
|
|
272
|
+
editor: commands.editor,
|
|
273
|
+
state,
|
|
274
|
+
view,
|
|
275
|
+
$from,
|
|
276
|
+
$to,
|
|
277
|
+
selectionIsEmpty,
|
|
278
|
+
currentNode: $from.node(),
|
|
279
|
+
};
|
|
280
|
+
if (handleOutdentMultiNodeSelection(context)) return true;
|
|
281
|
+
return commands.chain().outdentShiftTab().run();
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
});
|