├── .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 |
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 |
--------------------------------------------------------------------------------