@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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/block-drag-handler.d.ts +189 -0
- package/dist/block-drag-handler.d.ts.map +1 -0
- package/dist/block-drag-handler.js +745 -0
- package/dist/block-drag-handler.js.map +1 -0
- package/dist/block-renderer.d.ts +402 -0
- package/dist/block-renderer.d.ts.map +1 -0
- package/dist/block-renderer.js +424 -0
- package/dist/block-renderer.js.map +1 -0
- package/dist/clipboard.d.ts +178 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +432 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/command-bus.d.ts +113 -0
- package/dist/command-bus.d.ts.map +1 -0
- package/dist/command-bus.js +70 -0
- package/dist/command-bus.js.map +1 -0
- package/dist/composition.d.ts +220 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +271 -0
- package/dist/composition.js.map +1 -0
- package/dist/content-extraction.d.ts +69 -0
- package/dist/content-extraction.d.ts.map +1 -0
- package/dist/content-extraction.js +228 -0
- package/dist/content-extraction.js.map +1 -0
- package/dist/content-handler-file.d.ts +40 -0
- package/dist/content-handler-file.d.ts.map +1 -0
- package/dist/content-handler-file.js +91 -0
- package/dist/content-handler-file.js.map +1 -0
- package/dist/content-handler-image.d.ts +82 -0
- package/dist/content-handler-image.d.ts.map +1 -0
- package/dist/content-handler-image.js +120 -0
- package/dist/content-handler-image.js.map +1 -0
- package/dist/content-handler-url.d.ts +129 -0
- package/dist/content-handler-url.d.ts.map +1 -0
- package/dist/content-handler-url.js +244 -0
- package/dist/content-handler-url.js.map +1 -0
- package/dist/content-handlers.d.ts +67 -0
- package/dist/content-handlers.d.ts.map +1 -0
- package/dist/content-handlers.js +263 -0
- package/dist/content-handlers.js.map +1 -0
- package/dist/content-pipeline.d.ts +383 -0
- package/dist/content-pipeline.d.ts.map +1 -0
- package/dist/content-pipeline.js +232 -0
- package/dist/content-pipeline.js.map +1 -0
- package/dist/cursor-nav.d.ts +149 -0
- package/dist/cursor-nav.d.ts.map +1 -0
- package/dist/cursor-nav.js +230 -0
- package/dist/cursor-nav.js.map +1 -0
- package/dist/cursor-rect.d.ts +65 -0
- package/dist/cursor-rect.d.ts.map +1 -0
- package/dist/cursor-rect.js +98 -0
- package/dist/cursor-rect.js.map +1 -0
- package/dist/drop-indicator.d.ts +108 -0
- package/dist/drop-indicator.d.ts.map +1 -0
- package/dist/drop-indicator.js +236 -0
- package/dist/drop-indicator.js.map +1 -0
- package/dist/editor.d.ts +41 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +710 -0
- package/dist/editor.js.map +1 -0
- package/dist/floating-toolbar.d.ts +93 -0
- package/dist/floating-toolbar.d.ts.map +1 -0
- package/dist/floating-toolbar.js +159 -0
- package/dist/floating-toolbar.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/input-bridge.d.ts +273 -0
- package/dist/input-bridge.d.ts.map +1 -0
- package/dist/input-bridge.js +884 -0
- package/dist/input-bridge.js.map +1 -0
- package/dist/link-popover.d.ts +38 -0
- package/dist/link-popover.d.ts.map +1 -0
- package/dist/link-popover.js +278 -0
- package/dist/link-popover.js.map +1 -0
- package/dist/mark-renderer.d.ts +275 -0
- package/dist/mark-renderer.d.ts.map +1 -0
- package/dist/mark-renderer.js +210 -0
- package/dist/mark-renderer.js.map +1 -0
- package/dist/perf.d.ts +145 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +260 -0
- package/dist/perf.js.map +1 -0
- package/dist/plugin-kit.d.ts +265 -0
- package/dist/plugin-kit.d.ts.map +1 -0
- package/dist/plugin-kit.js +234 -0
- package/dist/plugin-kit.js.map +1 -0
- package/dist/plugins/alignment-plugin.d.ts +68 -0
- package/dist/plugins/alignment-plugin.d.ts.map +1 -0
- package/dist/plugins/alignment-plugin.js +98 -0
- package/dist/plugins/alignment-plugin.js.map +1 -0
- package/dist/plugins/block-utils.d.ts +113 -0
- package/dist/plugins/block-utils.d.ts.map +1 -0
- package/dist/plugins/block-utils.js +191 -0
- package/dist/plugins/block-utils.js.map +1 -0
- package/dist/plugins/blockquote-plugin.d.ts +39 -0
- package/dist/plugins/blockquote-plugin.d.ts.map +1 -0
- package/dist/plugins/blockquote-plugin.js +88 -0
- package/dist/plugins/blockquote-plugin.js.map +1 -0
- package/dist/plugins/bold-plugin.d.ts +37 -0
- package/dist/plugins/bold-plugin.d.ts.map +1 -0
- package/dist/plugins/bold-plugin.js +48 -0
- package/dist/plugins/bold-plugin.js.map +1 -0
- package/dist/plugins/callout-plugin.d.ts +100 -0
- package/dist/plugins/callout-plugin.d.ts.map +1 -0
- package/dist/plugins/callout-plugin.js +200 -0
- package/dist/plugins/callout-plugin.js.map +1 -0
- package/dist/plugins/code-block-plugin.d.ts +62 -0
- package/dist/plugins/code-block-plugin.d.ts.map +1 -0
- package/dist/plugins/code-block-plugin.js +176 -0
- package/dist/plugins/code-block-plugin.js.map +1 -0
- package/dist/plugins/code-plugin.d.ts +37 -0
- package/dist/plugins/code-plugin.d.ts.map +1 -0
- package/dist/plugins/code-plugin.js +48 -0
- package/dist/plugins/code-plugin.js.map +1 -0
- package/dist/plugins/embed-plugin.d.ts +90 -0
- package/dist/plugins/embed-plugin.d.ts.map +1 -0
- package/dist/plugins/embed-plugin.js +147 -0
- package/dist/plugins/embed-plugin.js.map +1 -0
- package/dist/plugins/font-family-plugin.d.ts +58 -0
- package/dist/plugins/font-family-plugin.d.ts.map +1 -0
- package/dist/plugins/font-family-plugin.js +57 -0
- package/dist/plugins/font-family-plugin.js.map +1 -0
- package/dist/plugins/font-size-plugin.d.ts +57 -0
- package/dist/plugins/font-size-plugin.d.ts.map +1 -0
- package/dist/plugins/font-size-plugin.js +56 -0
- package/dist/plugins/font-size-plugin.js.map +1 -0
- package/dist/plugins/heading-plugin.d.ts +52 -0
- package/dist/plugins/heading-plugin.d.ts.map +1 -0
- package/dist/plugins/heading-plugin.js +114 -0
- package/dist/plugins/heading-plugin.js.map +1 -0
- package/dist/plugins/hr-plugin.d.ts +33 -0
- package/dist/plugins/hr-plugin.d.ts.map +1 -0
- package/dist/plugins/hr-plugin.js +75 -0
- package/dist/plugins/hr-plugin.js.map +1 -0
- package/dist/plugins/image-plugin.d.ts +115 -0
- package/dist/plugins/image-plugin.d.ts.map +1 -0
- package/dist/plugins/image-plugin.js +199 -0
- package/dist/plugins/image-plugin.js.map +1 -0
- package/dist/plugins/indent-plugin.d.ts +62 -0
- package/dist/plugins/indent-plugin.d.ts.map +1 -0
- package/dist/plugins/indent-plugin.js +128 -0
- package/dist/plugins/indent-plugin.js.map +1 -0
- package/dist/plugins/index.d.ts +45 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +42 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/italic-plugin.d.ts +37 -0
- package/dist/plugins/italic-plugin.d.ts.map +1 -0
- package/dist/plugins/italic-plugin.js +48 -0
- package/dist/plugins/italic-plugin.js.map +1 -0
- package/dist/plugins/link-plugin.d.ts +129 -0
- package/dist/plugins/link-plugin.d.ts.map +1 -0
- package/dist/plugins/link-plugin.js +212 -0
- package/dist/plugins/link-plugin.js.map +1 -0
- package/dist/plugins/list-plugin.d.ts +53 -0
- package/dist/plugins/list-plugin.d.ts.map +1 -0
- package/dist/plugins/list-plugin.js +309 -0
- package/dist/plugins/list-plugin.js.map +1 -0
- package/dist/plugins/mark-utils.d.ts +173 -0
- package/dist/plugins/mark-utils.d.ts.map +1 -0
- package/dist/plugins/mark-utils.js +425 -0
- package/dist/plugins/mark-utils.js.map +1 -0
- package/dist/plugins/mention-plugin.d.ts +191 -0
- package/dist/plugins/mention-plugin.d.ts.map +1 -0
- package/dist/plugins/mention-plugin.js +295 -0
- package/dist/plugins/mention-plugin.js.map +1 -0
- package/dist/plugins/strikethrough-plugin.d.ts +37 -0
- package/dist/plugins/strikethrough-plugin.d.ts.map +1 -0
- package/dist/plugins/strikethrough-plugin.js +48 -0
- package/dist/plugins/strikethrough-plugin.js.map +1 -0
- package/dist/plugins/text-color-plugin.d.ts +57 -0
- package/dist/plugins/text-color-plugin.d.ts.map +1 -0
- package/dist/plugins/text-color-plugin.js +56 -0
- package/dist/plugins/text-color-plugin.js.map +1 -0
- package/dist/plugins/underline-plugin.d.ts +37 -0
- package/dist/plugins/underline-plugin.d.ts.map +1 -0
- package/dist/plugins/underline-plugin.js +48 -0
- package/dist/plugins/underline-plugin.js.map +1 -0
- package/dist/presets.d.ts +95 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +159 -0
- package/dist/presets.js.map +1 -0
- package/dist/renderer.d.ts +125 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +415 -0
- package/dist/renderer.js.map +1 -0
- package/dist/scroll-to-cursor.d.ts +25 -0
- package/dist/scroll-to-cursor.d.ts.map +1 -0
- package/dist/scroll-to-cursor.js +59 -0
- package/dist/scroll-to-cursor.js.map +1 -0
- package/dist/selection-sync.d.ts +159 -0
- package/dist/selection-sync.d.ts.map +1 -0
- package/dist/selection-sync.js +527 -0
- package/dist/selection-sync.js.map +1 -0
- package/dist/shortcut-handler.d.ts +98 -0
- package/dist/shortcut-handler.d.ts.map +1 -0
- package/dist/shortcut-handler.js +155 -0
- package/dist/shortcut-handler.js.map +1 -0
- package/dist/toolbar.d.ts +103 -0
- package/dist/toolbar.d.ts.map +1 -0
- package/dist/toolbar.js +134 -0
- package/dist/toolbar.js.map +1 -0
- package/dist/trigger-manager.d.ts +205 -0
- package/dist/trigger-manager.d.ts.map +1 -0
- package/dist/trigger-manager.js +466 -0
- package/dist/trigger-manager.js.map +1 -0
- package/dist/types.d.ts +216 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|