├── .gitignore ├── LICENSE.md ├── README.md ├── package.json ├── prettier.config.cjs ├── rollup.config.js ├── src ├── clipboard-serializer.ts └── index.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | dist 4 | .idea 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024, Niclas Gregor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tiptap Extension: GlobalDragHandle 2 | NPM Downloads 3 | 4 | 5 | ## Install 6 | 7 | NPM 8 | ``` 9 | $ npm install tiptap-extension-global-drag-handle 10 | ``` 11 | 12 | Yarn 13 | ``` 14 | $ yarn add tiptap-extension-global-drag-handle 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | import GlobalDragHandle from 'tiptap-extension-global-drag-handle' 21 | 22 | new Editor({ 23 | extensions: [ 24 | GlobalDragHandle, 25 | ], 26 | }) 27 | ``` 28 | 29 | In order to enjoy all the advantages of the drag handle, it is recommended to install the [AutoJoiner](https://github.com/NiclasDev63/tiptap-extension-auto-joiner) extension as well, which allows you to automatically join various nodes such as 2 lists that are next to each other. 30 | 31 | ## Configuration 32 | 33 | Optionally, you can also configure the drag handle. 34 | 35 | ```js 36 | import GlobalDragHandle from 'tiptap-extension-global-drag-handle' 37 | 38 | new Editor({ 39 | extensions: [ 40 | GlobalDragHandle.configure({ 41 | dragHandleWidth: 20, // default 42 | 43 | // The scrollTreshold specifies how close the user must drag an element to the edge of the lower/upper screen for automatic 44 | // scrolling to take place. For example, scrollTreshold = 100 means that scrolling starts automatically when the user drags an 45 | // element to a position that is max. 99px away from the edge of the screen 46 | // You can set this to 0 to prevent auto scrolling caused by this extension 47 | scrollTreshold: 100, // default 48 | 49 | // The css selector to query for the drag handle. (eg: '.custom-handle'). 50 | // If handle element is found, that element will be used as drag handle. 51 | // If not, a default handle will be created 52 | dragHandleSelector: ".custom-drag-handle", // default is undefined 53 | 54 | 55 | // Tags to be excluded for drag handle 56 | // If you want to hide the global drag handle for specific HTML tags, you can use this option. 57 | // For example, setting this option to ['p', 'hr'] will hide the global drag handle for

and


tags. 58 | excludedTags: [], // default 59 | 60 | // Custom nodes to be included for drag handle 61 | // For example having a custom Alert component. Add data-type="alert" to the node component wrapper. 62 | // Then add it to this list as ['alert'] 63 | // 64 | customNodes: [], 65 | }), 66 | ], 67 | }) 68 | ``` 69 | 70 | ## Styling 71 | By default the drag handle is headless, which means it doesn't contain any css. If you want to apply styling to the drag handle, use the class "drag-handle" in your css file. 72 | Take a look at [this](https://github.com/steven-tey/novel/blob/main/apps/web/styles/prosemirror.css#L131) example, to see how you can apply styling. 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiptap-extension-global-drag-handle", 3 | "description": "drag handle extension for tiptap", 4 | "version": "0.1.17", 5 | "author": { 6 | "name": "Niclas Gregor", 7 | "email": "niclas.gregor20@gmail.com", 8 | "github": "https://github.com/NiclasDev63" 9 | }, 10 | "license": "MIT", 11 | "keywords": [ 12 | "tiptap", 13 | "tiptap extension", 14 | "drag handle" 15 | ], 16 | "exports": { 17 | ".": { 18 | "import": "./dist/index.js", 19 | "require": "./dist/index.cjs", 20 | "types": "./dist/src/index.d.ts" 21 | } 22 | }, 23 | "main": "dist/index.cjs", 24 | "module": "dist/index.js", 25 | "umd": "dist/index.umd.js", 26 | "types": "dist/src/index.d.ts", 27 | "type": "module", 28 | "files": [ 29 | "dist" 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/NiclasDev63/tiptap-extension-global-drag-handle" 34 | }, 35 | "scripts": { 36 | "clean": "rm -rf dist", 37 | "build": "npm run clean && rollup -c" 38 | }, 39 | "devDependencies": { 40 | "@atomico/rollup-plugin-sizes": "^1.1.4", 41 | "@rollup/plugin-babel": "^5.3.0", 42 | "@rollup/plugin-commonjs": "^21.0.1", 43 | "@rollup/plugin-node-resolve": "^13.1.3", 44 | "@tiptap/core": ">=2.1.0", 45 | "@tiptap/pm": "^2.11.5", 46 | "rollup": "^2.67.0", 47 | "rollup-plugin-auto-external": "^2.0.0", 48 | "rollup-plugin-sourcemaps": "^0.6.3", 49 | "rollup-plugin-typescript2": "^0.31.2", 50 | "ts-loader": "9.3.1", 51 | "tsup": "^6.5.0", 52 | "typescript": "^5.4.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | semi: true, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | printWidth: 80, 7 | tabWidth: 2, 8 | }; 9 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import sizes from '@atomico/rollup-plugin-sizes'; 2 | import babel from '@rollup/plugin-babel'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import resolve from '@rollup/plugin-node-resolve'; 5 | import autoExternal from 'rollup-plugin-auto-external'; 6 | import sourcemaps from 'rollup-plugin-sourcemaps'; 7 | import typescript from 'rollup-plugin-typescript2'; 8 | 9 | import pkg from './package.json'; 10 | 11 | export default { 12 | external: [/@tiptap\/pm\/.*/, '@tiptap/core'], 13 | input: 'src/index.ts', 14 | output: [ 15 | { 16 | name: pkg.name, 17 | file: pkg.umd, 18 | format: 'umd', 19 | sourcemap: true, 20 | }, 21 | { 22 | name: pkg.name, 23 | file: pkg.main, 24 | format: 'cjs', 25 | sourcemap: true, 26 | exports: 'auto', 27 | }, 28 | { 29 | name: pkg.name, 30 | file: pkg.module, 31 | format: 'es', 32 | sourcemap: true, 33 | }, 34 | ], 35 | plugins: [ 36 | autoExternal({ 37 | packagePath: './package.json', 38 | }), 39 | sourcemaps(), 40 | resolve(), 41 | commonjs(), 42 | babel({ 43 | babelHelpers: 'bundled', 44 | exclude: './node_modules/**', 45 | }), 46 | sizes(), 47 | typescript({ 48 | tsconfig: './tsconfig.json', 49 | tsconfigOverride: { 50 | compilerOptions: { 51 | baseUrl: '.', 52 | declaration: true, 53 | paths: { 54 | './*': ['src/*'], 55 | }, 56 | }, 57 | include: null, 58 | }, 59 | }), 60 | ], 61 | }; 62 | -------------------------------------------------------------------------------- /src/clipboard-serializer.ts: -------------------------------------------------------------------------------- 1 | import { Slice } from '@tiptap/pm/model'; 2 | import { EditorView } from '@tiptap/pm/view'; 3 | import * as pmView from '@tiptap/pm/view'; 4 | 5 | function getPmView() { 6 | try { 7 | return pmView; 8 | } catch (error) { 9 | return null; 10 | } 11 | } 12 | 13 | 14 | export function serializeForClipboard(view: EditorView, slice: Slice) { 15 | // Newer Tiptap/ProseMirror 16 | // @ts-ignore 17 | if (view && typeof view.serializeForClipboard === 'function') { 18 | return view.serializeForClipboard(slice); 19 | } 20 | 21 | // Older version fallback 22 | const proseMirrorView = getPmView(); 23 | // @ts-ignore 24 | if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') { 25 | // @ts-ignore 26 | return proseMirrorView.__serializeForClipboard(view, slice); 27 | } 28 | 29 | throw new Error('No supported clipboard serialization method found.'); 30 | } 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from '@tiptap/core'; 2 | import { 3 | NodeSelection, 4 | Plugin, 5 | PluginKey, 6 | TextSelection, 7 | } from '@tiptap/pm/state'; 8 | import { Fragment, Slice, Node } from '@tiptap/pm/model'; 9 | import { EditorView } from '@tiptap/pm/view'; 10 | import { serializeForClipboard } from './clipboard-serializer'; 11 | 12 | export interface GlobalDragHandleOptions { 13 | /** 14 | * The width of the drag handle 15 | */ 16 | dragHandleWidth: number; 17 | 18 | /** 19 | * The treshold for scrolling 20 | */ 21 | scrollTreshold: number; 22 | 23 | /* 24 | * The css selector to query for the drag handle. (eg: '.custom-handle'). 25 | * If handle element is found, that element will be used as drag handle. If not, a default handle will be created 26 | */ 27 | dragHandleSelector?: string; 28 | 29 | /** 30 | * Tags to be excluded for drag handle 31 | */ 32 | excludedTags: string[]; 33 | 34 | /** 35 | * Custom nodes to be included for drag handle 36 | */ 37 | customNodes: string[]; 38 | } 39 | function absoluteRect(node: Element) { 40 | const data = node.getBoundingClientRect(); 41 | const modal = node.closest('[role="dialog"]'); 42 | 43 | if (modal && window.getComputedStyle(modal).transform !== 'none') { 44 | const modalRect = modal.getBoundingClientRect(); 45 | 46 | return { 47 | top: data.top - modalRect.top, 48 | left: data.left - modalRect.left, 49 | width: data.width, 50 | }; 51 | } 52 | return { 53 | top: data.top, 54 | left: data.left, 55 | width: data.width, 56 | }; 57 | } 58 | 59 | function nodeDOMAtCoords( 60 | coords: { x: number; y: number }, 61 | options: GlobalDragHandleOptions, 62 | ) { 63 | const selectors = [ 64 | 'li', 65 | 'p:not(:first-child)', 66 | 'pre', 67 | 'blockquote', 68 | 'h1', 69 | 'h2', 70 | 'h3', 71 | 'h4', 72 | 'h5', 73 | 'h6', 74 | ...options.customNodes.map((node) => `[data-type=${node}]`), 75 | ].join(', '); 76 | return document 77 | .elementsFromPoint(coords.x, coords.y) 78 | .find( 79 | (elem: Element) => 80 | elem.parentElement?.matches?.('.ProseMirror') || 81 | elem.matches(selectors), 82 | ); 83 | } 84 | function nodePosAtDOM( 85 | node: Element, 86 | view: EditorView, 87 | options: GlobalDragHandleOptions, 88 | ) { 89 | const boundingRect = node.getBoundingClientRect(); 90 | 91 | return view.posAtCoords({ 92 | left: boundingRect.left + 50 + options.dragHandleWidth, 93 | top: boundingRect.top + 1, 94 | })?.inside; 95 | } 96 | 97 | function calcNodePos(pos: number, view: EditorView) { 98 | const $pos = view.state.doc.resolve(pos); 99 | if ($pos.depth > 1) return $pos.before($pos.depth); 100 | return pos; 101 | } 102 | 103 | export function DragHandlePlugin( 104 | options: GlobalDragHandleOptions & { pluginKey: string }, 105 | ) { 106 | let listType = ''; 107 | function handleDragStart(event: DragEvent, view: EditorView) { 108 | view.focus(); 109 | 110 | if (!event.dataTransfer) return; 111 | 112 | const node = nodeDOMAtCoords( 113 | { 114 | x: event.clientX + 50 + options.dragHandleWidth, 115 | y: event.clientY, 116 | }, 117 | options, 118 | ); 119 | 120 | if (!(node instanceof Element)) return; 121 | 122 | let draggedNodePos = nodePosAtDOM(node, view, options); 123 | if (draggedNodePos == null || draggedNodePos < 0) return; 124 | draggedNodePos = calcNodePos(draggedNodePos, view); 125 | 126 | const { from, to } = view.state.selection; 127 | const diff = from - to; 128 | 129 | const fromSelectionPos = calcNodePos(from, view); 130 | let differentNodeSelected = false; 131 | 132 | const nodePos = view.state.doc.resolve(fromSelectionPos); 133 | 134 | // Check if nodePos points to the top level node 135 | if (nodePos.node().type.name === 'doc') differentNodeSelected = true; 136 | else { 137 | const nodeSelection = NodeSelection.create( 138 | view.state.doc, 139 | nodePos.before(), 140 | ); 141 | 142 | // Check if the node where the drag event started is part of the current selection 143 | differentNodeSelected = !( 144 | draggedNodePos + 1 >= nodeSelection.$from.pos && 145 | draggedNodePos <= nodeSelection.$to.pos 146 | ); 147 | } 148 | let selection = view.state.selection; 149 | if ( 150 | !differentNodeSelected && 151 | diff !== 0 && 152 | !(view.state.selection instanceof NodeSelection) 153 | ) { 154 | const endSelection = NodeSelection.create(view.state.doc, to - 1); 155 | selection = TextSelection.create( 156 | view.state.doc, 157 | draggedNodePos, 158 | endSelection.$to.pos, 159 | ); 160 | } else { 161 | selection = NodeSelection.create(view.state.doc, draggedNodePos); 162 | 163 | // if inline node is selected, e.g mention -> go to the parent node to select the whole node 164 | // if table row is selected, go to the parent node to select the whole node 165 | if ( 166 | (selection as NodeSelection).node.type.isInline || 167 | (selection as NodeSelection).node.type.name === 'tableRow' 168 | ) { 169 | let $pos = view.state.doc.resolve(selection.from); 170 | selection = NodeSelection.create(view.state.doc, $pos.before()); 171 | } 172 | } 173 | view.dispatch(view.state.tr.setSelection(selection)); 174 | 175 | // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL 176 | if ( 177 | view.state.selection instanceof NodeSelection && 178 | view.state.selection.node.type.name === 'listItem' 179 | ) { 180 | listType = node.parentElement!.tagName; 181 | } 182 | 183 | const slice = view.state.selection.content(); 184 | const { dom, text } = serializeForClipboard(view, slice); 185 | 186 | event.dataTransfer.clearData(); 187 | event.dataTransfer.setData('text/html', dom.innerHTML); 188 | event.dataTransfer.setData('text/plain', text); 189 | event.dataTransfer.effectAllowed = 'copyMove'; 190 | 191 | event.dataTransfer.setDragImage(node, 0, 0); 192 | 193 | view.dragging = { slice, move: event.ctrlKey }; 194 | } 195 | 196 | let dragHandleElement: HTMLElement | null = null; 197 | 198 | function hideDragHandle() { 199 | if (dragHandleElement) { 200 | dragHandleElement.classList.add('hide'); 201 | } 202 | } 203 | 204 | function showDragHandle() { 205 | if (dragHandleElement) { 206 | dragHandleElement.classList.remove('hide'); 207 | } 208 | } 209 | 210 | function hideHandleOnEditorOut(event: MouseEvent) { 211 | if (event.target instanceof Element) { 212 | // Check if the relatedTarget class is still inside the editor 213 | const relatedTarget = event.relatedTarget as HTMLElement; 214 | const isInsideEditor = 215 | relatedTarget?.classList.contains('tiptap') || 216 | relatedTarget?.classList.contains('drag-handle'); 217 | 218 | if (isInsideEditor) return; 219 | } 220 | hideDragHandle(); 221 | } 222 | 223 | return new Plugin({ 224 | key: new PluginKey(options.pluginKey), 225 | view: (view) => { 226 | const handleBySelector = options.dragHandleSelector 227 | ? document.querySelector(options.dragHandleSelector) 228 | : null; 229 | dragHandleElement = handleBySelector ?? document.createElement('div'); 230 | dragHandleElement.draggable = true; 231 | dragHandleElement.dataset.dragHandle = ''; 232 | dragHandleElement.classList.add('drag-handle'); 233 | 234 | function onDragHandleDragStart(e: DragEvent) { 235 | handleDragStart(e, view); 236 | } 237 | 238 | dragHandleElement.addEventListener('dragstart', onDragHandleDragStart); 239 | 240 | function onDragHandleDrag(e: DragEvent) { 241 | hideDragHandle(); 242 | let scrollY = window.scrollY; 243 | if (e.clientY < options.scrollTreshold) { 244 | window.scrollTo({ top: scrollY - 30, behavior: 'smooth' }); 245 | } else if (window.innerHeight - e.clientY < options.scrollTreshold) { 246 | window.scrollTo({ top: scrollY + 30, behavior: 'smooth' }); 247 | } 248 | } 249 | 250 | dragHandleElement.addEventListener('drag', onDragHandleDrag); 251 | 252 | hideDragHandle(); 253 | 254 | if (!handleBySelector) { 255 | view?.dom?.parentElement?.appendChild(dragHandleElement); 256 | } 257 | view?.dom?.parentElement?.addEventListener( 258 | 'mouseout', 259 | hideHandleOnEditorOut, 260 | ); 261 | 262 | return { 263 | destroy: () => { 264 | if (!handleBySelector) { 265 | dragHandleElement?.remove?.(); 266 | } 267 | dragHandleElement?.removeEventListener('drag', onDragHandleDrag); 268 | dragHandleElement?.removeEventListener( 269 | 'dragstart', 270 | onDragHandleDragStart, 271 | ); 272 | dragHandleElement = null; 273 | view?.dom?.parentElement?.removeEventListener( 274 | 'mouseout', 275 | hideHandleOnEditorOut, 276 | ); 277 | }, 278 | }; 279 | }, 280 | props: { 281 | handleDOMEvents: { 282 | mousemove: (view, event) => { 283 | if (!view.editable) { 284 | return; 285 | } 286 | 287 | const node = nodeDOMAtCoords( 288 | { 289 | x: event.clientX + 50 + options.dragHandleWidth, 290 | y: event.clientY, 291 | }, 292 | options, 293 | ); 294 | 295 | const notDragging = node?.closest('.not-draggable'); 296 | const excludedTagList = options.excludedTags 297 | .concat(['ol', 'ul']) 298 | .join(', '); 299 | 300 | if ( 301 | !(node instanceof Element) || 302 | node.matches(excludedTagList) || 303 | notDragging 304 | ) { 305 | hideDragHandle(); 306 | return; 307 | } 308 | 309 | const compStyle = window.getComputedStyle(node); 310 | const parsedLineHeight = parseInt(compStyle.lineHeight, 10); 311 | const lineHeight = isNaN(parsedLineHeight) 312 | ? parseInt(compStyle.fontSize) * 1.2 313 | : parsedLineHeight; 314 | const paddingTop = parseInt(compStyle.paddingTop, 10); 315 | 316 | const rect = absoluteRect(node); 317 | 318 | rect.top += (lineHeight - 24) / 2; 319 | rect.top += paddingTop; 320 | // Li markers 321 | if (node.matches('ul:not([data-type=taskList]) li, ol li')) { 322 | rect.left -= options.dragHandleWidth; 323 | } 324 | rect.width = options.dragHandleWidth; 325 | 326 | if (!dragHandleElement) return; 327 | 328 | dragHandleElement.style.left = `${rect.left - rect.width}px`; 329 | dragHandleElement.style.top = `${rect.top}px`; 330 | showDragHandle(); 331 | }, 332 | keydown: () => { 333 | hideDragHandle(); 334 | }, 335 | mousewheel: () => { 336 | hideDragHandle(); 337 | }, 338 | // dragging class is used for CSS 339 | dragstart: (view) => { 340 | view.dom.classList.add('dragging'); 341 | }, 342 | drop: (view, event) => { 343 | view.dom.classList.remove('dragging'); 344 | hideDragHandle(); 345 | let droppedNode: Node | null = null; 346 | const dropPos = view.posAtCoords({ 347 | left: event.clientX, 348 | top: event.clientY, 349 | }); 350 | 351 | if (!dropPos) return; 352 | 353 | if (view.state.selection instanceof NodeSelection) { 354 | droppedNode = view.state.selection.node; 355 | } 356 | if (!droppedNode) return; 357 | 358 | const resolvedPos = view.state.doc.resolve(dropPos.pos); 359 | 360 | const isDroppedInsideList = 361 | resolvedPos.parent.type.name === 'listItem'; 362 | 363 | // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside
    tag otherwise ol list items will be transformed into ul list item when dropped 364 | if ( 365 | view.state.selection instanceof NodeSelection && 366 | view.state.selection.node.type.name === 'listItem' && 367 | !isDroppedInsideList && 368 | listType == 'OL' 369 | ) { 370 | const newList = view.state.schema.nodes.orderedList?.createAndFill( 371 | null, 372 | droppedNode, 373 | ); 374 | const slice = new Slice(Fragment.from(newList), 0, 0); 375 | view.dragging = { slice, move: event.ctrlKey }; 376 | } 377 | }, 378 | dragend: (view) => { 379 | view.dom.classList.remove('dragging'); 380 | }, 381 | }, 382 | }, 383 | }); 384 | } 385 | 386 | const GlobalDragHandle = Extension.create({ 387 | name: 'globalDragHandle', 388 | 389 | addOptions() { 390 | return { 391 | dragHandleWidth: 20, 392 | scrollTreshold: 100, 393 | excludedTags: [], 394 | customNodes: [], 395 | }; 396 | }, 397 | 398 | addProseMirrorPlugins() { 399 | return [ 400 | DragHandlePlugin({ 401 | pluginKey: 'globalDragHandle', 402 | dragHandleWidth: this.options.dragHandleWidth, 403 | scrollTreshold: this.options.scrollTreshold, 404 | dragHandleSelector: this.options.dragHandleSelector, 405 | excludedTags: this.options.excludedTags, 406 | customNodes: this.options.customNodes, 407 | }), 408 | ]; 409 | }, 410 | }); 411 | 412 | export default GlobalDragHandle; 413 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "allowJs": true, 8 | "moduleDetection": "force", 9 | "isolatedModules": true, 10 | /* Strictness */ 11 | "strict": true, 12 | "noUncheckedIndexedAccess": true, 13 | /* If transpiling with TypeScript: */ 14 | "moduleResolution": "node", 15 | "module": "ESNext", 16 | "outDir": "./dist", 17 | "sourceMap": true, 18 | /* AND if you're building for a library: */ 19 | "declaration": true, 20 | 21 | "noEmit": true, 22 | /* If your code runs in the DOM: */ 23 | "lib": ["es2022", "dom", "dom.iterable"] 24 | }, 25 | 26 | "include": ["src"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------