@rtif-sdk/web 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.
Files changed (215) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/block-drag-handler.d.ts +189 -0
  4. package/dist/block-drag-handler.d.ts.map +1 -0
  5. package/dist/block-drag-handler.js +745 -0
  6. package/dist/block-drag-handler.js.map +1 -0
  7. package/dist/block-renderer.d.ts +402 -0
  8. package/dist/block-renderer.d.ts.map +1 -0
  9. package/dist/block-renderer.js +424 -0
  10. package/dist/block-renderer.js.map +1 -0
  11. package/dist/clipboard.d.ts +178 -0
  12. package/dist/clipboard.d.ts.map +1 -0
  13. package/dist/clipboard.js +432 -0
  14. package/dist/clipboard.js.map +1 -0
  15. package/dist/command-bus.d.ts +113 -0
  16. package/dist/command-bus.d.ts.map +1 -0
  17. package/dist/command-bus.js +70 -0
  18. package/dist/command-bus.js.map +1 -0
  19. package/dist/composition.d.ts +220 -0
  20. package/dist/composition.d.ts.map +1 -0
  21. package/dist/composition.js +271 -0
  22. package/dist/composition.js.map +1 -0
  23. package/dist/content-extraction.d.ts +69 -0
  24. package/dist/content-extraction.d.ts.map +1 -0
  25. package/dist/content-extraction.js +228 -0
  26. package/dist/content-extraction.js.map +1 -0
  27. package/dist/content-handler-file.d.ts +40 -0
  28. package/dist/content-handler-file.d.ts.map +1 -0
  29. package/dist/content-handler-file.js +91 -0
  30. package/dist/content-handler-file.js.map +1 -0
  31. package/dist/content-handler-image.d.ts +82 -0
  32. package/dist/content-handler-image.d.ts.map +1 -0
  33. package/dist/content-handler-image.js +120 -0
  34. package/dist/content-handler-image.js.map +1 -0
  35. package/dist/content-handler-url.d.ts +129 -0
  36. package/dist/content-handler-url.d.ts.map +1 -0
  37. package/dist/content-handler-url.js +244 -0
  38. package/dist/content-handler-url.js.map +1 -0
  39. package/dist/content-handlers.d.ts +67 -0
  40. package/dist/content-handlers.d.ts.map +1 -0
  41. package/dist/content-handlers.js +263 -0
  42. package/dist/content-handlers.js.map +1 -0
  43. package/dist/content-pipeline.d.ts +383 -0
  44. package/dist/content-pipeline.d.ts.map +1 -0
  45. package/dist/content-pipeline.js +232 -0
  46. package/dist/content-pipeline.js.map +1 -0
  47. package/dist/cursor-nav.d.ts +149 -0
  48. package/dist/cursor-nav.d.ts.map +1 -0
  49. package/dist/cursor-nav.js +230 -0
  50. package/dist/cursor-nav.js.map +1 -0
  51. package/dist/cursor-rect.d.ts +65 -0
  52. package/dist/cursor-rect.d.ts.map +1 -0
  53. package/dist/cursor-rect.js +98 -0
  54. package/dist/cursor-rect.js.map +1 -0
  55. package/dist/drop-indicator.d.ts +108 -0
  56. package/dist/drop-indicator.d.ts.map +1 -0
  57. package/dist/drop-indicator.js +236 -0
  58. package/dist/drop-indicator.js.map +1 -0
  59. package/dist/editor.d.ts +41 -0
  60. package/dist/editor.d.ts.map +1 -0
  61. package/dist/editor.js +710 -0
  62. package/dist/editor.js.map +1 -0
  63. package/dist/floating-toolbar.d.ts +93 -0
  64. package/dist/floating-toolbar.d.ts.map +1 -0
  65. package/dist/floating-toolbar.js +159 -0
  66. package/dist/floating-toolbar.js.map +1 -0
  67. package/dist/index.d.ts +62 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +119 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/input-bridge.d.ts +273 -0
  72. package/dist/input-bridge.d.ts.map +1 -0
  73. package/dist/input-bridge.js +884 -0
  74. package/dist/input-bridge.js.map +1 -0
  75. package/dist/link-popover.d.ts +38 -0
  76. package/dist/link-popover.d.ts.map +1 -0
  77. package/dist/link-popover.js +278 -0
  78. package/dist/link-popover.js.map +1 -0
  79. package/dist/mark-renderer.d.ts +275 -0
  80. package/dist/mark-renderer.d.ts.map +1 -0
  81. package/dist/mark-renderer.js +210 -0
  82. package/dist/mark-renderer.js.map +1 -0
  83. package/dist/perf.d.ts +145 -0
  84. package/dist/perf.d.ts.map +1 -0
  85. package/dist/perf.js +260 -0
  86. package/dist/perf.js.map +1 -0
  87. package/dist/plugin-kit.d.ts +265 -0
  88. package/dist/plugin-kit.d.ts.map +1 -0
  89. package/dist/plugin-kit.js +234 -0
  90. package/dist/plugin-kit.js.map +1 -0
  91. package/dist/plugins/alignment-plugin.d.ts +68 -0
  92. package/dist/plugins/alignment-plugin.d.ts.map +1 -0
  93. package/dist/plugins/alignment-plugin.js +98 -0
  94. package/dist/plugins/alignment-plugin.js.map +1 -0
  95. package/dist/plugins/block-utils.d.ts +113 -0
  96. package/dist/plugins/block-utils.d.ts.map +1 -0
  97. package/dist/plugins/block-utils.js +191 -0
  98. package/dist/plugins/block-utils.js.map +1 -0
  99. package/dist/plugins/blockquote-plugin.d.ts +39 -0
  100. package/dist/plugins/blockquote-plugin.d.ts.map +1 -0
  101. package/dist/plugins/blockquote-plugin.js +88 -0
  102. package/dist/plugins/blockquote-plugin.js.map +1 -0
  103. package/dist/plugins/bold-plugin.d.ts +37 -0
  104. package/dist/plugins/bold-plugin.d.ts.map +1 -0
  105. package/dist/plugins/bold-plugin.js +48 -0
  106. package/dist/plugins/bold-plugin.js.map +1 -0
  107. package/dist/plugins/callout-plugin.d.ts +100 -0
  108. package/dist/plugins/callout-plugin.d.ts.map +1 -0
  109. package/dist/plugins/callout-plugin.js +200 -0
  110. package/dist/plugins/callout-plugin.js.map +1 -0
  111. package/dist/plugins/code-block-plugin.d.ts +62 -0
  112. package/dist/plugins/code-block-plugin.d.ts.map +1 -0
  113. package/dist/plugins/code-block-plugin.js +176 -0
  114. package/dist/plugins/code-block-plugin.js.map +1 -0
  115. package/dist/plugins/code-plugin.d.ts +37 -0
  116. package/dist/plugins/code-plugin.d.ts.map +1 -0
  117. package/dist/plugins/code-plugin.js +48 -0
  118. package/dist/plugins/code-plugin.js.map +1 -0
  119. package/dist/plugins/embed-plugin.d.ts +90 -0
  120. package/dist/plugins/embed-plugin.d.ts.map +1 -0
  121. package/dist/plugins/embed-plugin.js +147 -0
  122. package/dist/plugins/embed-plugin.js.map +1 -0
  123. package/dist/plugins/font-family-plugin.d.ts +58 -0
  124. package/dist/plugins/font-family-plugin.d.ts.map +1 -0
  125. package/dist/plugins/font-family-plugin.js +57 -0
  126. package/dist/plugins/font-family-plugin.js.map +1 -0
  127. package/dist/plugins/font-size-plugin.d.ts +57 -0
  128. package/dist/plugins/font-size-plugin.d.ts.map +1 -0
  129. package/dist/plugins/font-size-plugin.js +56 -0
  130. package/dist/plugins/font-size-plugin.js.map +1 -0
  131. package/dist/plugins/heading-plugin.d.ts +52 -0
  132. package/dist/plugins/heading-plugin.d.ts.map +1 -0
  133. package/dist/plugins/heading-plugin.js +114 -0
  134. package/dist/plugins/heading-plugin.js.map +1 -0
  135. package/dist/plugins/hr-plugin.d.ts +33 -0
  136. package/dist/plugins/hr-plugin.d.ts.map +1 -0
  137. package/dist/plugins/hr-plugin.js +75 -0
  138. package/dist/plugins/hr-plugin.js.map +1 -0
  139. package/dist/plugins/image-plugin.d.ts +115 -0
  140. package/dist/plugins/image-plugin.d.ts.map +1 -0
  141. package/dist/plugins/image-plugin.js +199 -0
  142. package/dist/plugins/image-plugin.js.map +1 -0
  143. package/dist/plugins/indent-plugin.d.ts +62 -0
  144. package/dist/plugins/indent-plugin.d.ts.map +1 -0
  145. package/dist/plugins/indent-plugin.js +128 -0
  146. package/dist/plugins/indent-plugin.js.map +1 -0
  147. package/dist/plugins/index.d.ts +45 -0
  148. package/dist/plugins/index.d.ts.map +1 -0
  149. package/dist/plugins/index.js +42 -0
  150. package/dist/plugins/index.js.map +1 -0
  151. package/dist/plugins/italic-plugin.d.ts +37 -0
  152. package/dist/plugins/italic-plugin.d.ts.map +1 -0
  153. package/dist/plugins/italic-plugin.js +48 -0
  154. package/dist/plugins/italic-plugin.js.map +1 -0
  155. package/dist/plugins/link-plugin.d.ts +129 -0
  156. package/dist/plugins/link-plugin.d.ts.map +1 -0
  157. package/dist/plugins/link-plugin.js +212 -0
  158. package/dist/plugins/link-plugin.js.map +1 -0
  159. package/dist/plugins/list-plugin.d.ts +53 -0
  160. package/dist/plugins/list-plugin.d.ts.map +1 -0
  161. package/dist/plugins/list-plugin.js +309 -0
  162. package/dist/plugins/list-plugin.js.map +1 -0
  163. package/dist/plugins/mark-utils.d.ts +173 -0
  164. package/dist/plugins/mark-utils.d.ts.map +1 -0
  165. package/dist/plugins/mark-utils.js +425 -0
  166. package/dist/plugins/mark-utils.js.map +1 -0
  167. package/dist/plugins/mention-plugin.d.ts +191 -0
  168. package/dist/plugins/mention-plugin.d.ts.map +1 -0
  169. package/dist/plugins/mention-plugin.js +295 -0
  170. package/dist/plugins/mention-plugin.js.map +1 -0
  171. package/dist/plugins/strikethrough-plugin.d.ts +37 -0
  172. package/dist/plugins/strikethrough-plugin.d.ts.map +1 -0
  173. package/dist/plugins/strikethrough-plugin.js +48 -0
  174. package/dist/plugins/strikethrough-plugin.js.map +1 -0
  175. package/dist/plugins/text-color-plugin.d.ts +57 -0
  176. package/dist/plugins/text-color-plugin.d.ts.map +1 -0
  177. package/dist/plugins/text-color-plugin.js +56 -0
  178. package/dist/plugins/text-color-plugin.js.map +1 -0
  179. package/dist/plugins/underline-plugin.d.ts +37 -0
  180. package/dist/plugins/underline-plugin.d.ts.map +1 -0
  181. package/dist/plugins/underline-plugin.js +48 -0
  182. package/dist/plugins/underline-plugin.js.map +1 -0
  183. package/dist/presets.d.ts +95 -0
  184. package/dist/presets.d.ts.map +1 -0
  185. package/dist/presets.js +159 -0
  186. package/dist/presets.js.map +1 -0
  187. package/dist/renderer.d.ts +125 -0
  188. package/dist/renderer.d.ts.map +1 -0
  189. package/dist/renderer.js +415 -0
  190. package/dist/renderer.js.map +1 -0
  191. package/dist/scroll-to-cursor.d.ts +25 -0
  192. package/dist/scroll-to-cursor.d.ts.map +1 -0
  193. package/dist/scroll-to-cursor.js +59 -0
  194. package/dist/scroll-to-cursor.js.map +1 -0
  195. package/dist/selection-sync.d.ts +159 -0
  196. package/dist/selection-sync.d.ts.map +1 -0
  197. package/dist/selection-sync.js +527 -0
  198. package/dist/selection-sync.js.map +1 -0
  199. package/dist/shortcut-handler.d.ts +98 -0
  200. package/dist/shortcut-handler.d.ts.map +1 -0
  201. package/dist/shortcut-handler.js +155 -0
  202. package/dist/shortcut-handler.js.map +1 -0
  203. package/dist/toolbar.d.ts +103 -0
  204. package/dist/toolbar.d.ts.map +1 -0
  205. package/dist/toolbar.js +134 -0
  206. package/dist/toolbar.js.map +1 -0
  207. package/dist/trigger-manager.d.ts +205 -0
  208. package/dist/trigger-manager.d.ts.map +1 -0
  209. package/dist/trigger-manager.js +466 -0
  210. package/dist/trigger-manager.js.map +1 -0
  211. package/dist/types.d.ts +216 -0
  212. package/dist/types.d.ts.map +1 -0
  213. package/dist/types.js +2 -0
  214. package/dist/types.js.map +1 -0
  215. package/package.json +30 -0
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Block drag-and-drop handler — enables reordering blocks via drag handles.
3
+ *
4
+ * Provides two main exports:
5
+ * - {@link computeMoveBlockOps} — Pure function that computes the RTIF operations
6
+ * needed to move a block from one position to another.
7
+ * - {@link BlockDragHandler} — DOM event handler class that manages drag handles,
8
+ * drag events, drop indicator, and dispatches move operations.
9
+ *
10
+ * Since RTIF has no `move_block` core operation, block reordering is composed
11
+ * from existing operations: `delete_text`, `merge_block`, `split_block`,
12
+ * `insert_text`, `set_block_type`, `set_block_attrs`, and `set_span_marks`.
13
+ *
14
+ * @module
15
+ */
16
+ import { blockTextLength } from '@rtif-sdk/core';
17
+ import { DropIndicator } from './drop-indicator.js';
18
+ // ---------------------------------------------------------------------------
19
+ // CSS class names
20
+ // ---------------------------------------------------------------------------
21
+ const CSS_HANDLE = 'rtif-drag-handle';
22
+ const CSS_DRAGGING = 'rtif-block-dragging';
23
+ const CSS_DRAG_OVER = 'rtif-block-drag-over';
24
+ // ---------------------------------------------------------------------------
25
+ // Pure helpers
26
+ // ---------------------------------------------------------------------------
27
+ /**
28
+ * Compute the shallow-merge diff needed to transform `currentAttrs` into
29
+ * `targetAttrs` via a single `set_block_attrs` operation.
30
+ *
31
+ * Returns `null` if no changes are needed (attrs are already equivalent).
32
+ *
33
+ * @param currentAttrs - The block's current attrs (may be undefined)
34
+ * @param targetAttrs - The desired attrs (may be undefined)
35
+ * @returns An attrs diff object for `set_block_attrs`, or null if no diff
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * computeAttrsDiff({ level: 1 }, { level: 2 });
40
+ * // => { level: 2 }
41
+ *
42
+ * computeAttrsDiff({ level: 1, indent: 2 }, { color: 'red' });
43
+ * // => { level: null, indent: null, color: 'red' }
44
+ *
45
+ * computeAttrsDiff({ level: 1 }, { level: 1 });
46
+ * // => null
47
+ * ```
48
+ */
49
+ export function computeAttrsDiff(currentAttrs, targetAttrs) {
50
+ const current = currentAttrs ?? {};
51
+ const target = targetAttrs ?? {};
52
+ const diff = {};
53
+ let hasChanges = false;
54
+ // Keys in current but not in target: remove (set to null)
55
+ for (const key of Object.keys(current)) {
56
+ if (!(key in target)) {
57
+ diff[key] = null;
58
+ hasChanges = true;
59
+ }
60
+ }
61
+ // Keys in target: set if different from current
62
+ for (const key of Object.keys(target)) {
63
+ if (current[key] !== target[key]) {
64
+ diff[key] = target[key];
65
+ hasChanges = true;
66
+ }
67
+ }
68
+ return hasChanges ? diff : null;
69
+ }
70
+ /**
71
+ * Compute the absolute document offset where a block begins.
72
+ *
73
+ * Sums all preceding block text lengths plus virtual `\n` separators.
74
+ *
75
+ * @param doc - The RTIF document
76
+ * @param blockIndex - The index of the block
77
+ * @returns The absolute offset of the block's first character
78
+ */
79
+ function blockStartOffset(doc, blockIndex) {
80
+ let offset = 0;
81
+ for (let i = 0; i < blockIndex; i++) {
82
+ offset += blockTextLength(doc.blocks[i]) + 1; // +1 for virtual \n
83
+ }
84
+ return offset;
85
+ }
86
+ /**
87
+ * Concatenate all span texts in a block into a single string.
88
+ *
89
+ * @param block - The block to extract text from
90
+ * @returns The full text content of the block
91
+ */
92
+ function blockFullText(block) {
93
+ return block.spans.map((s) => s.text).join('');
94
+ }
95
+ /**
96
+ * Compute the RTIF operations needed to move a block to a new position.
97
+ *
98
+ * This is a pure function that takes the current document, the block ID to
99
+ * move, and the desired target index in the final array. It returns an
100
+ * array of RTIF operations that, when applied in sequence, produce a
101
+ * document with the block at the target position.
102
+ *
103
+ * The algorithm has three phases:
104
+ * 1. **Remove** — Delete the source block's text and merge it away
105
+ * 2. **Create** — Split at the target position to create an empty block
106
+ * 3. **Restore** — Insert text, set type/attrs, apply marks
107
+ *
108
+ * Block IDs are preserved when the source block is not the first block
109
+ * and the target position is not 0. In the target=0 case, the moved
110
+ * block inherits the ID of the block that was previously at index 0.
111
+ *
112
+ * @param doc - The current RTIF document
113
+ * @param blockId - The ID of the block to move
114
+ * @param targetIndex - The desired final index (0-based) in the result
115
+ * @returns Array of operations to apply, or empty array if no move needed
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * // Move third block to first position
120
+ * const ops = computeMoveBlockOps(doc, 'b3', 0);
121
+ * for (const op of ops) {
122
+ * const result = apply(currentDoc, op);
123
+ * currentDoc = result.doc;
124
+ * }
125
+ * ```
126
+ */
127
+ export function computeMoveBlockOps(doc, blockId, targetIndex) {
128
+ const sourceIdx = doc.blocks.findIndex((b) => b.id === blockId);
129
+ // Guard: block not found, same position, or invalid target
130
+ if (sourceIdx === -1)
131
+ return [];
132
+ if (sourceIdx === targetIndex)
133
+ return [];
134
+ if (doc.blocks.length <= 1)
135
+ return [];
136
+ if (targetIndex < 0 || targetIndex >= doc.blocks.length)
137
+ return [];
138
+ const srcBlock = doc.blocks[sourceIdx];
139
+ const srcTextLen = blockTextLength(srcBlock);
140
+ const srcText = blockFullText(srcBlock);
141
+ const srcType = srcBlock.type;
142
+ const srcAttrs = srcBlock.attrs;
143
+ const srcSpans = srcBlock.spans;
144
+ const ops = [];
145
+ // ===================================================================
146
+ // PHASE 1: Remove the source block
147
+ // ===================================================================
148
+ const srcStartOff = blockStartOffset(doc, sourceIdx);
149
+ // Step 1a: Delete all text in the source block
150
+ if (srcTextLen > 0) {
151
+ ops.push({ type: 'delete_text', offset: srcStartOff, count: srcTextLen });
152
+ }
153
+ // Step 1b: Remove the now-empty block via merge
154
+ if (sourceIdx > 0) {
155
+ // Source is not the first block — merge into previous
156
+ ops.push({ type: 'merge_block', blockId });
157
+ }
158
+ else {
159
+ // Source IS the first block — merge second block into first
160
+ const secondBlock = doc.blocks[1];
161
+ ops.push({ type: 'merge_block', blockId: secondBlock.id });
162
+ // After merge, block[0] keeps srcBlock's id/type/attrs but has
163
+ // secondBlock's content. We need to fix type and attrs to match
164
+ // what secondBlock had (since that's what should remain at index 0).
165
+ if (srcType !== secondBlock.type) {
166
+ ops.push({
167
+ type: 'set_block_type',
168
+ blockId: srcBlock.id,
169
+ blockType: secondBlock.type,
170
+ });
171
+ }
172
+ const attrsDiff = computeAttrsDiff(srcAttrs, secondBlock.attrs);
173
+ if (attrsDiff !== null) {
174
+ ops.push({
175
+ type: 'set_block_attrs',
176
+ blockId: srcBlock.id,
177
+ attrs: attrsDiff,
178
+ });
179
+ }
180
+ }
181
+ // ===================================================================
182
+ // After Phase 1, we have N-1 blocks.
183
+ // Compute text lengths and ids of the reduced block array.
184
+ // ===================================================================
185
+ const reducedTextLens = [];
186
+ const reducedIds = [];
187
+ const reducedTypes = [];
188
+ const reducedAttrs = [];
189
+ if (sourceIdx > 0) {
190
+ // Removed sourceIdx; all others unchanged
191
+ for (let i = 0; i < doc.blocks.length; i++) {
192
+ if (i === sourceIdx)
193
+ continue;
194
+ const b = doc.blocks[i];
195
+ reducedTextLens.push(blockTextLength(b));
196
+ reducedIds.push(b.id);
197
+ reducedTypes.push(b.type);
198
+ reducedAttrs.push(b.attrs);
199
+ }
200
+ }
201
+ else {
202
+ // sourceIdx === 0: merged second into first
203
+ // Block 0 now has second block's text length (and after fixes, its type/attrs)
204
+ reducedTextLens.push(blockTextLength(doc.blocks[1]));
205
+ reducedIds.push(srcBlock.id); // keeps first block's id
206
+ reducedTypes.push(doc.blocks[1].type); // type was fixed
207
+ reducedAttrs.push(doc.blocks[1].attrs); // attrs were fixed
208
+ for (let i = 2; i < doc.blocks.length; i++) {
209
+ const b = doc.blocks[i];
210
+ reducedTextLens.push(blockTextLength(b));
211
+ reducedIds.push(b.id);
212
+ reducedTypes.push(b.type);
213
+ reducedAttrs.push(b.attrs);
214
+ }
215
+ }
216
+ // ===================================================================
217
+ // PHASE 2: Create an empty block at targetIndex in the reduced array
218
+ // ===================================================================
219
+ let insertOffset;
220
+ let targetBlockId;
221
+ if (targetIndex === 0) {
222
+ // Split the first block at offset 0
223
+ // Left part: empty block (keeps reduced block 0's id, type, attrs)
224
+ // Right part: reduced block 0's content (gets new id)
225
+ //
226
+ // We use a generated id for the right part. The left part (our target)
227
+ // gets the reduced block 0's id.
228
+ const rightBlockId = `${reducedIds[0]}_moved`;
229
+ ops.push({ type: 'split_block', offset: 0, newBlockId: rightBlockId });
230
+ // The right block inherits the split-from block's type but NOT attrs.
231
+ // The left block (empty) keeps the original block's id, type, and attrs.
232
+ // After the split, the right block needs to be fixed if the reduced block[0]
233
+ // had attrs (since split doesn't inherit attrs).
234
+ const rb0Attrs = reducedAttrs[0];
235
+ if (rb0Attrs && Object.keys(rb0Attrs).length > 0) {
236
+ ops.push({
237
+ type: 'set_block_attrs',
238
+ blockId: rightBlockId,
239
+ attrs: { ...rb0Attrs },
240
+ });
241
+ }
242
+ targetBlockId = reducedIds[0];
243
+ insertOffset = 0;
244
+ }
245
+ else {
246
+ // Split the block at reducedIndex (targetIndex - 1) at its end.
247
+ // This creates a new empty block after it.
248
+ let endOfPrev = 0;
249
+ for (let i = 0; i < targetIndex; i++) {
250
+ if (i > 0)
251
+ endOfPrev += 1; // virtual \n
252
+ endOfPrev += reducedTextLens[i];
253
+ }
254
+ // When sourceIdx was 0, the reduced block at index 0 kept the source
255
+ // block's id (merge_block semantics). We can't reuse that id for the
256
+ // new block — it would create a duplicate. Use a generated id instead.
257
+ const canReuseId = sourceIdx > 0;
258
+ const newBlockId = canReuseId ? blockId : `${blockId}_moved`;
259
+ ops.push({
260
+ type: 'split_block',
261
+ offset: endOfPrev,
262
+ newBlockId,
263
+ });
264
+ targetBlockId = newBlockId;
265
+ insertOffset = endOfPrev + 1; // +1 for the virtual \n created by the split
266
+ }
267
+ // ===================================================================
268
+ // PHASE 3: Fill the empty block with source content
269
+ // ===================================================================
270
+ // Step 3a: Insert text
271
+ if (srcTextLen > 0) {
272
+ ops.push({ type: 'insert_text', offset: insertOffset, text: srcText });
273
+ }
274
+ // Step 3b: Set block type
275
+ // The empty block inherited its type from the split-from block.
276
+ // Determine the inherited type:
277
+ let inheritedType;
278
+ if (targetIndex === 0) {
279
+ // The target block is the left part of splitting reduced block[0]
280
+ inheritedType = reducedTypes[0];
281
+ }
282
+ else {
283
+ // The target block is the right part of splitting reduced block[targetIndex-1]
284
+ inheritedType = reducedTypes[targetIndex - 1];
285
+ }
286
+ if (inheritedType !== srcType) {
287
+ ops.push({
288
+ type: 'set_block_type',
289
+ blockId: targetBlockId,
290
+ blockType: srcType,
291
+ });
292
+ }
293
+ // Step 3c: Set block attrs
294
+ // For targetIndex > 0: new block from split has NO attrs (split doesn't inherit attrs)
295
+ // For targetIndex === 0: the empty block (left part) keeps the split-from block's attrs
296
+ if (targetIndex === 0) {
297
+ // The target block has reduced block[0]'s attrs. Diff to srcAttrs.
298
+ const attrsDiff = computeAttrsDiff(reducedAttrs[0], srcAttrs);
299
+ if (attrsDiff !== null) {
300
+ ops.push({
301
+ type: 'set_block_attrs',
302
+ blockId: targetBlockId,
303
+ attrs: attrsDiff,
304
+ });
305
+ }
306
+ }
307
+ else {
308
+ // The target block has NO attrs. Set srcAttrs directly if any.
309
+ if (srcAttrs && Object.keys(srcAttrs).length > 0) {
310
+ ops.push({
311
+ type: 'set_block_attrs',
312
+ blockId: targetBlockId,
313
+ attrs: { ...srcAttrs },
314
+ });
315
+ }
316
+ }
317
+ // Step 3d: Apply marks to the inserted text
318
+ // The inserted text has no marks (inserted into an empty block).
319
+ // Walk source spans and emit set_span_marks for each span with marks.
320
+ if (srcTextLen > 0) {
321
+ let markOffset = insertOffset;
322
+ for (const span of srcSpans) {
323
+ if (span.marks &&
324
+ Object.keys(span.marks).length > 0 &&
325
+ span.text.length > 0) {
326
+ ops.push({
327
+ type: 'set_span_marks',
328
+ offset: markOffset,
329
+ count: span.text.length,
330
+ marks: { ...span.marks },
331
+ });
332
+ }
333
+ markOffset += span.text.length;
334
+ }
335
+ }
336
+ return ops;
337
+ }
338
+ // ---------------------------------------------------------------------------
339
+ // BlockDragHandler class
340
+ // ---------------------------------------------------------------------------
341
+ /**
342
+ * DOM event handler for block drag-and-drop reordering.
343
+ *
344
+ * Shows a drag handle on block hover. When the user drags a block handle
345
+ * and drops it between other blocks, computes and dispatches the RTIF
346
+ * operations to move the block to the new position.
347
+ *
348
+ * Uses the existing {@link DropIndicator} for visual drop feedback.
349
+ *
350
+ * @example
351
+ * ```ts
352
+ * const handler = new BlockDragHandler({
353
+ * root: editorRoot,
354
+ * getDoc: () => engine.state.doc,
355
+ * dispatch: (ops) => engine.dispatch(ops),
356
+ * isReadOnly: () => false,
357
+ * });
358
+ *
359
+ * handler.attach();
360
+ * // ... user drags blocks ...
361
+ * handler.detach();
362
+ * ```
363
+ */
364
+ export class BlockDragHandler {
365
+ _deps;
366
+ _dropIndicator;
367
+ _dragBlockId = null;
368
+ _handleEl = null;
369
+ _attached = false;
370
+ // Bound event handlers for cleanup
371
+ _onMouseOver;
372
+ _onMouseOut;
373
+ _onDragStart;
374
+ _onDragOver;
375
+ _onDragLeave;
376
+ _onDrop;
377
+ _onDragEnd;
378
+ /**
379
+ * Create a block drag handler.
380
+ *
381
+ * @param deps - Injected dependencies
382
+ *
383
+ * @example
384
+ * ```ts
385
+ * const handler = new BlockDragHandler(deps);
386
+ * handler.attach();
387
+ * ```
388
+ */
389
+ constructor(deps) {
390
+ this._deps = deps;
391
+ this._dropIndicator = new DropIndicator(deps.root);
392
+ // Bind event handlers
393
+ this._onMouseOver = this._handleMouseOver.bind(this);
394
+ this._onMouseOut = this._handleMouseOut.bind(this);
395
+ this._onDragStart = this._handleDragStart.bind(this);
396
+ this._onDragOver = this._handleDragOver.bind(this);
397
+ this._onDragLeave = this._handleDragLeave.bind(this);
398
+ this._onDrop = this._handleDrop.bind(this);
399
+ this._onDragEnd = this._handleDragEnd.bind(this);
400
+ }
401
+ /**
402
+ * Attach event listeners to the editor root.
403
+ *
404
+ * @example
405
+ * ```ts
406
+ * handler.attach();
407
+ * ```
408
+ */
409
+ attach() {
410
+ if (this._attached)
411
+ return;
412
+ this._attached = true;
413
+ const root = this._deps.root;
414
+ root.addEventListener('mouseover', this._onMouseOver);
415
+ root.addEventListener('mouseout', this._onMouseOut);
416
+ root.addEventListener('dragstart', this._onDragStart);
417
+ root.addEventListener('dragover', this._onDragOver);
418
+ root.addEventListener('dragleave', this._onDragLeave);
419
+ root.addEventListener('drop', this._onDrop);
420
+ root.addEventListener('dragend', this._onDragEnd);
421
+ }
422
+ /**
423
+ * Detach event listeners and clean up DOM.
424
+ *
425
+ * @example
426
+ * ```ts
427
+ * handler.detach();
428
+ * ```
429
+ */
430
+ detach() {
431
+ if (!this._attached)
432
+ return;
433
+ this._attached = false;
434
+ const root = this._deps.root;
435
+ root.removeEventListener('mouseover', this._onMouseOver);
436
+ root.removeEventListener('mouseout', this._onMouseOut);
437
+ root.removeEventListener('dragstart', this._onDragStart);
438
+ root.removeEventListener('dragover', this._onDragOver);
439
+ root.removeEventListener('dragleave', this._onDragLeave);
440
+ root.removeEventListener('drop', this._onDrop);
441
+ root.removeEventListener('dragend', this._onDragEnd);
442
+ this._removeHandle();
443
+ this._dropIndicator.destroy();
444
+ this._dragBlockId = null;
445
+ }
446
+ // -----------------------------------------------------------------------
447
+ // Private: Handle rendering
448
+ // -----------------------------------------------------------------------
449
+ /**
450
+ * Show the drag handle next to a block element.
451
+ */
452
+ _showHandle(blockEl) {
453
+ if (this._deps.isReadOnly())
454
+ return;
455
+ if (this._dragBlockId !== null)
456
+ return; // Don't show during drag
457
+ if (!this._handleEl) {
458
+ this._handleEl = createHandleElement();
459
+ this._deps.root.appendChild(this._handleEl);
460
+ }
461
+ // Position relative to the block element
462
+ const rootRect = this._deps.root.getBoundingClientRect();
463
+ const blockRect = blockEl.getBoundingClientRect();
464
+ this._handleEl.style.top = `${blockRect.top - rootRect.top}px`;
465
+ this._handleEl.style.left = '-24px'; // Just outside left edge
466
+ this._handleEl.style.height = `${blockRect.height}px`;
467
+ this._handleEl.style.display = 'flex';
468
+ // Store the block ID on the handle for dragstart
469
+ const blockId = blockEl.getAttribute('data-rtif-block');
470
+ if (blockId) {
471
+ this._handleEl.setAttribute('data-drag-block-id', blockId);
472
+ }
473
+ }
474
+ /**
475
+ * Hide the drag handle.
476
+ */
477
+ _hideHandle() {
478
+ if (this._handleEl) {
479
+ this._handleEl.style.display = 'none';
480
+ }
481
+ }
482
+ /**
483
+ * Remove the drag handle from the DOM.
484
+ */
485
+ _removeHandle() {
486
+ if (this._handleEl && this._handleEl.parentNode) {
487
+ this._handleEl.parentNode.removeChild(this._handleEl);
488
+ }
489
+ this._handleEl = null;
490
+ }
491
+ // -----------------------------------------------------------------------
492
+ // Private: Event handlers
493
+ // -----------------------------------------------------------------------
494
+ _handleMouseOver(e) {
495
+ const blockEl = findBlockElement(e.target, this._deps.root);
496
+ if (blockEl) {
497
+ this._showHandle(blockEl);
498
+ }
499
+ }
500
+ _handleMouseOut(e) {
501
+ // Only hide if leaving the editor root entirely or moving to a non-block area
502
+ const relatedTarget = e.relatedTarget;
503
+ if (relatedTarget && this._deps.root.contains(relatedTarget)) {
504
+ // Check if we're moving to the handle itself
505
+ if (this._handleEl && this._handleEl.contains(relatedTarget)) {
506
+ return;
507
+ }
508
+ // Check if still on a block
509
+ const blockEl = findBlockElement(relatedTarget, this._deps.root);
510
+ if (blockEl)
511
+ return;
512
+ }
513
+ this._hideHandle();
514
+ }
515
+ _handleDragStart(e) {
516
+ if (this._deps.isReadOnly())
517
+ return;
518
+ // Only start drag from the handle element
519
+ const target = e.target;
520
+ if (!target.classList?.contains(CSS_HANDLE) && !this._handleEl?.contains(target)) {
521
+ return;
522
+ }
523
+ const blockId = this._handleEl?.getAttribute('data-drag-block-id');
524
+ if (!blockId) {
525
+ e.preventDefault();
526
+ return;
527
+ }
528
+ this._dragBlockId = blockId;
529
+ this._hideHandle();
530
+ // Set drag data
531
+ if (e.dataTransfer) {
532
+ e.dataTransfer.effectAllowed = 'move';
533
+ e.dataTransfer.setData('application/x-rtif-block-id', blockId);
534
+ }
535
+ // Add dragging class to the block element
536
+ const blockEl = this._deps.root.querySelector(`[data-rtif-block="${escapeAttrValue(blockId)}"]`);
537
+ if (blockEl) {
538
+ blockEl.classList.add(CSS_DRAGGING);
539
+ }
540
+ }
541
+ _handleDragOver(e) {
542
+ if (!this._dragBlockId)
543
+ return;
544
+ if (!e.dataTransfer)
545
+ return;
546
+ e.preventDefault();
547
+ e.dataTransfer.dropEffect = 'move';
548
+ // Show drop indicator between blocks
549
+ const blockEl = findNearestBlock(this._deps.root, e.clientY);
550
+ if (blockEl) {
551
+ const blockRect = blockEl.getBoundingClientRect();
552
+ const midY = blockRect.top + blockRect.height / 2;
553
+ const position = e.clientY < midY ? 'before' : 'after';
554
+ this._dropIndicator.showBetweenBlocks(blockEl, position);
555
+ }
556
+ }
557
+ _handleDragLeave(e) {
558
+ if (!this._dragBlockId)
559
+ return;
560
+ // Only hide if leaving the editor root
561
+ if (e.relatedTarget &&
562
+ this._deps.root.contains(e.relatedTarget)) {
563
+ return;
564
+ }
565
+ this._dropIndicator.hide();
566
+ }
567
+ _handleDrop(e) {
568
+ e.preventDefault();
569
+ this._dropIndicator.hide();
570
+ if (!this._dragBlockId)
571
+ return;
572
+ if (this._deps.isReadOnly())
573
+ return;
574
+ const doc = this._deps.getDoc();
575
+ const sourceBlockId = this._dragBlockId;
576
+ // Determine target index from drop position
577
+ const targetIndex = computeDropTargetIndex(this._deps.root, doc, e.clientY, sourceBlockId);
578
+ if (targetIndex === null) {
579
+ this._cleanup();
580
+ return;
581
+ }
582
+ // Compute and dispatch the move operations
583
+ const ops = computeMoveBlockOps(doc, sourceBlockId, targetIndex);
584
+ if (ops.length > 0) {
585
+ this._deps.dispatch(ops);
586
+ }
587
+ this._cleanup();
588
+ }
589
+ _handleDragEnd(_e) {
590
+ this._cleanup();
591
+ }
592
+ /**
593
+ * Clean up drag state after drop or cancel.
594
+ */
595
+ _cleanup() {
596
+ if (this._dragBlockId) {
597
+ // Remove dragging class
598
+ const blockEl = this._deps.root.querySelector(`[data-rtif-block="${escapeAttrValue(this._dragBlockId)}"]`);
599
+ if (blockEl) {
600
+ blockEl.classList.remove(CSS_DRAGGING);
601
+ }
602
+ }
603
+ this._dragBlockId = null;
604
+ this._dropIndicator.hide();
605
+ }
606
+ }
607
+ // ---------------------------------------------------------------------------
608
+ // DOM helpers
609
+ // ---------------------------------------------------------------------------
610
+ /**
611
+ * Create the drag handle element.
612
+ *
613
+ * A small vertical "grip" icon positioned to the left of blocks.
614
+ * Set `draggable="true"` so it can initiate HTML5 drag events.
615
+ */
616
+ function createHandleElement() {
617
+ const el = document.createElement('div');
618
+ el.className = CSS_HANDLE;
619
+ el.setAttribute('draggable', 'true');
620
+ el.setAttribute('aria-hidden', 'true');
621
+ el.setAttribute('role', 'button');
622
+ el.setAttribute('tabindex', '-1');
623
+ el.title = 'Drag to reorder';
624
+ // Styling (inline for self-containment; consumers can override via CSS)
625
+ el.style.position = 'absolute';
626
+ el.style.width = '20px';
627
+ el.style.display = 'none';
628
+ el.style.alignItems = 'center';
629
+ el.style.justifyContent = 'center';
630
+ el.style.cursor = 'grab';
631
+ el.style.opacity = '0.4';
632
+ el.style.userSelect = 'none';
633
+ el.style.zIndex = '5';
634
+ el.style.fontSize = '14px';
635
+ el.style.lineHeight = '1';
636
+ el.style.color = 'currentColor';
637
+ // Grip icon (six dots pattern)
638
+ el.textContent = '\u2807'; // BRAILLE PATTERN DOTS-123
639
+ return el;
640
+ }
641
+ /**
642
+ * Walk up the DOM from a node to find the nearest block element.
643
+ *
644
+ * @param node - The starting DOM node
645
+ * @param root - The editor root (stop walking here)
646
+ * @returns The block element, or null if not found
647
+ */
648
+ function findBlockElement(node, root) {
649
+ let current = node;
650
+ while (current && current !== root) {
651
+ if (current instanceof Element &&
652
+ current.hasAttribute('data-rtif-block')) {
653
+ return current;
654
+ }
655
+ current = current.parentNode;
656
+ }
657
+ return null;
658
+ }
659
+ /**
660
+ * Find the nearest block element to a Y coordinate.
661
+ *
662
+ * @param root - The editor root element
663
+ * @param clientY - The Y coordinate (from mouse/drag event)
664
+ * @returns The nearest block element, or null
665
+ */
666
+ function findNearestBlock(root, clientY) {
667
+ const blocks = root.querySelectorAll('[data-rtif-block]');
668
+ if (blocks.length === 0)
669
+ return null;
670
+ let nearest = null;
671
+ let nearestDistance = Infinity;
672
+ for (let i = 0; i < blocks.length; i++) {
673
+ const block = blocks[i];
674
+ const rect = block.getBoundingClientRect();
675
+ const midY = rect.top + rect.height / 2;
676
+ const distance = Math.abs(clientY - midY);
677
+ if (distance < nearestDistance) {
678
+ nearestDistance = distance;
679
+ nearest = block;
680
+ }
681
+ }
682
+ return nearest;
683
+ }
684
+ /**
685
+ * Compute the target block index from a drop position.
686
+ *
687
+ * Determines which block the drop is nearest to and whether the drop
688
+ * is above or below it, then computes the target index accordingly.
689
+ *
690
+ * @param root - The editor root element
691
+ * @param doc - The current RTIF document
692
+ * @param clientY - The Y coordinate of the drop
693
+ * @param sourceBlockId - The ID of the block being dragged
694
+ * @returns The target index, or null if the drop position is invalid
695
+ */
696
+ function computeDropTargetIndex(root, doc, clientY, sourceBlockId) {
697
+ const blockEls = root.querySelectorAll('[data-rtif-block]');
698
+ if (blockEls.length === 0)
699
+ return null;
700
+ // Find nearest block and determine before/after
701
+ let nearestIdx = 0;
702
+ let nearestDistance = Infinity;
703
+ for (let i = 0; i < blockEls.length; i++) {
704
+ const rect = blockEls[i].getBoundingClientRect();
705
+ const midY = rect.top + rect.height / 2;
706
+ const distance = Math.abs(clientY - midY);
707
+ if (distance < nearestDistance) {
708
+ nearestDistance = distance;
709
+ nearestIdx = i;
710
+ }
711
+ }
712
+ const nearestRect = blockEls[nearestIdx].getBoundingClientRect();
713
+ const midY = nearestRect.top + nearestRect.height / 2;
714
+ const dropBefore = clientY < midY;
715
+ // Compute target index:
716
+ // - dropBefore means we want to place at nearestIdx (pushing it down)
717
+ // - dropAfter means we want to place at nearestIdx + 1
718
+ let targetIndex = dropBefore ? nearestIdx : nearestIdx + 1;
719
+ // Clamp to valid range
720
+ if (targetIndex >= doc.blocks.length) {
721
+ targetIndex = doc.blocks.length - 1;
722
+ }
723
+ // Find source index and check if target is effectively the same position
724
+ const sourceIdx = doc.blocks.findIndex((b) => b.id === sourceBlockId);
725
+ if (sourceIdx === -1)
726
+ return null;
727
+ if (targetIndex === sourceIdx)
728
+ return null;
729
+ // When dropping right after the source block, the effective position
730
+ // doesn't change (the block stays where it is).
731
+ // targetIndex = sourceIdx + 1 would place it one after its current position,
732
+ // but since we're removing from sourceIdx first, this is effectively a no-op
733
+ // only when targetIndex equals sourceIdx. Already handled above.
734
+ return targetIndex;
735
+ }
736
+ /**
737
+ * Escape a string for use in a CSS attribute selector value.
738
+ *
739
+ * jsdom doesn't support `CSS.escape()`, so we do minimal escaping
740
+ * of characters that would break `[attr="value"]` selectors.
741
+ */
742
+ function escapeAttrValue(value) {
743
+ return value.replace(/["\\]/g, '\\$&');
744
+ }
745
+ //# sourceMappingURL=block-drag-handler.js.map