@agent-native/toolkit 0.6.0 → 0.8.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 (174) hide show
  1. package/README.md +17 -1
  2. package/agent-native.eject.json +411 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  4. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  5. package/dist/chat-history/ChatHistoryList.js +109 -0
  6. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  8. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  10. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  11. package/dist/chat-history/index.d.ts +2 -0
  12. package/dist/chat-history/index.d.ts.map +1 -0
  13. package/dist/chat-history/index.js +2 -0
  14. package/dist/chat-history/index.js.map +1 -0
  15. package/dist/chat-history.css +314 -0
  16. package/package.json +20 -1
  17. package/src/app-shell/header-actions.tsx +84 -0
  18. package/src/app-shell/index.ts +1 -0
  19. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  20. package/src/chat-history/ChatHistoryList.tsx +469 -0
  21. package/src/chat-history/index.ts +7 -0
  22. package/src/chat-history.css +314 -0
  23. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  24. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  25. package/src/collab-ui/PresenceBar.tsx +346 -0
  26. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  27. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  28. package/src/collab-ui/agent-identity.ts +16 -0
  29. package/src/collab-ui/index.ts +39 -0
  30. package/src/collab-ui/lead-client.ts +32 -0
  31. package/src/collab-ui/types.spec.ts +18 -0
  32. package/src/collab-ui/types.ts +96 -0
  33. package/src/composer/AgentComposerFrame.tsx +59 -0
  34. package/src/composer/ComposerPlusMenu.tsx +983 -0
  35. package/src/composer/MentionPopover.tsx +412 -0
  36. package/src/composer/PastedTextChip.tsx +150 -0
  37. package/src/composer/PromptComposer.spec.ts +73 -0
  38. package/src/composer/PromptComposer.tsx +718 -0
  39. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  40. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  41. package/src/composer/TiptapComposer.spec.ts +390 -0
  42. package/src/composer/TiptapComposer.tsx +2936 -0
  43. package/src/composer/VoiceButton.spec.ts +33 -0
  44. package/src/composer/VoiceButton.tsx +255 -0
  45. package/src/composer/asset-picker-url.spec.ts +56 -0
  46. package/src/composer/asset-picker-url.ts +80 -0
  47. package/src/composer/attachment-accept.spec.ts +22 -0
  48. package/src/composer/attachment-accept.ts +71 -0
  49. package/src/composer/draft-key.spec.ts +19 -0
  50. package/src/composer/draft-key.ts +7 -0
  51. package/src/composer/extensions/FileReference.tsx +55 -0
  52. package/src/composer/extensions/MentionReference.tsx +93 -0
  53. package/src/composer/extensions/SkillReference.tsx +56 -0
  54. package/src/composer/index.ts +79 -0
  55. package/src/composer/pasted-text.spec.ts +162 -0
  56. package/src/composer/pasted-text.ts +160 -0
  57. package/src/composer/prompt-attachments.spec.ts +80 -0
  58. package/src/composer/prompt-attachments.ts +93 -0
  59. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  60. package/src/composer/realtime-voice-audio-level.ts +64 -0
  61. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  62. package/src/composer/realtime-voice-transcript.ts +134 -0
  63. package/src/composer/runtime-adapters.tsx +343 -0
  64. package/src/composer/types.ts +66 -0
  65. package/src/composer/use-file-search.ts +64 -0
  66. package/src/composer/use-mention-search.ts +90 -0
  67. package/src/composer/use-skills.ts +45 -0
  68. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  69. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  70. package/src/composer/useVoiceDictation.spec.ts +45 -0
  71. package/src/composer/useVoiceDictation.ts +1191 -0
  72. package/src/context-ui/ContextMeter.tsx +101 -0
  73. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  74. package/src/context-ui/ContextTreemap.tsx +125 -0
  75. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  76. package/src/context-ui/format.ts +42 -0
  77. package/src/context-ui/index.ts +15 -0
  78. package/src/context-ui/types.ts +43 -0
  79. package/src/design-tweaks/index.ts +1 -0
  80. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  81. package/src/editor/BubbleToolbar.tsx +253 -0
  82. package/src/editor/CodeBlockNode.tsx +254 -0
  83. package/src/editor/DragHandle.spec.ts +588 -0
  84. package/src/editor/DragHandle.ts +1437 -0
  85. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  86. package/src/editor/ImageExtension.ts +306 -0
  87. package/src/editor/RegistryBlockContext.tsx +107 -0
  88. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  89. package/src/editor/RegistryBlockNode.tsx +652 -0
  90. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  91. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  92. package/src/editor/RichMarkdownEditor.tsx +81 -0
  93. package/src/editor/RunId.ts +61 -0
  94. package/src/editor/SharedRichEditor.tsx +321 -0
  95. package/src/editor/SlashCommandMenu.tsx +310 -0
  96. package/src/editor/disableHistory.spec.ts +85 -0
  97. package/src/editor/extensions.ts +402 -0
  98. package/src/editor/gfmDoc.spec.ts +230 -0
  99. package/src/editor/gfmDoc.ts +92 -0
  100. package/src/editor/index.ts +93 -0
  101. package/src/editor/registrySlashCommands.ts +131 -0
  102. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  103. package/src/editor/surgical-apply.spec.ts +279 -0
  104. package/src/editor/surgical-apply.ts +192 -0
  105. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  106. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  107. package/src/editor/useCollabReconcile.ts +589 -0
  108. package/src/editor.css +510 -0
  109. package/src/hooks/index.ts +2 -0
  110. package/src/hooks/use-mobile.tsx +21 -0
  111. package/src/hooks/use-toast.ts +188 -0
  112. package/src/index.ts +8 -0
  113. package/src/onboarding/index.ts +1 -0
  114. package/src/provider.tsx +47 -0
  115. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  116. package/src/sharing/VisibilityBadge.tsx +37 -0
  117. package/src/sharing/index.ts +4 -0
  118. package/src/styles.css +21 -0
  119. package/src/ui/accordion.tsx +56 -0
  120. package/src/ui/alert-dialog.tsx +141 -0
  121. package/src/ui/alert.tsx +59 -0
  122. package/src/ui/aspect-ratio.tsx +5 -0
  123. package/src/ui/avatar.tsx +48 -0
  124. package/src/ui/badge.tsx +37 -0
  125. package/src/ui/breadcrumb.tsx +115 -0
  126. package/src/ui/button.tsx +78 -0
  127. package/src/ui/calendar.tsx +214 -0
  128. package/src/ui/card.tsx +86 -0
  129. package/src/ui/carousel.tsx +260 -0
  130. package/src/ui/chart.tsx +375 -0
  131. package/src/ui/checkbox.tsx +28 -0
  132. package/src/ui/collapsible.tsx +9 -0
  133. package/src/ui/command.spec.tsx +51 -0
  134. package/src/ui/command.tsx +174 -0
  135. package/src/ui/context-menu.tsx +202 -0
  136. package/src/ui/date-picker.tsx +64 -0
  137. package/src/ui/dialog.spec.tsx +43 -0
  138. package/src/ui/dialog.tsx +146 -0
  139. package/src/ui/drawer.tsx +126 -0
  140. package/src/ui/dropdown-menu.tsx +218 -0
  141. package/src/ui/form.tsx +177 -0
  142. package/src/ui/hover-card.tsx +27 -0
  143. package/src/ui/index.ts +52 -0
  144. package/src/ui/input-otp.tsx +69 -0
  145. package/src/ui/input.tsx +22 -0
  146. package/src/ui/label.tsx +24 -0
  147. package/src/ui/logo.tsx +36 -0
  148. package/src/ui/menubar.tsx +235 -0
  149. package/src/ui/navigation-menu.tsx +128 -0
  150. package/src/ui/pagination.tsx +121 -0
  151. package/src/ui/popover.tsx +44 -0
  152. package/src/ui/progress.tsx +26 -0
  153. package/src/ui/radio-group.tsx +42 -0
  154. package/src/ui/resizable.tsx +43 -0
  155. package/src/ui/scroll-area.tsx +46 -0
  156. package/src/ui/select.tsx +158 -0
  157. package/src/ui/separator.tsx +29 -0
  158. package/src/ui/sheet.tsx +160 -0
  159. package/src/ui/sidebar.tsx +777 -0
  160. package/src/ui/skeleton.tsx +18 -0
  161. package/src/ui/slider.tsx +26 -0
  162. package/src/ui/sonner.tsx +58 -0
  163. package/src/ui/spinner.tsx +17 -0
  164. package/src/ui/switch.tsx +27 -0
  165. package/src/ui/table.tsx +117 -0
  166. package/src/ui/tabs.tsx +53 -0
  167. package/src/ui/textarea.tsx +23 -0
  168. package/src/ui/toast.tsx +127 -0
  169. package/src/ui/toaster.tsx +33 -0
  170. package/src/ui/toggle-group.tsx +59 -0
  171. package/src/ui/toggle.tsx +43 -0
  172. package/src/ui/tooltip.tsx +53 -0
  173. package/src/ui/use-toast.ts +3 -0
  174. package/src/utils.ts +8 -0
@@ -0,0 +1,1437 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
3
+ import {
4
+ Plugin,
5
+ PluginKey,
6
+ NodeSelection,
7
+ TextSelection,
8
+ type Transaction,
9
+ } from "@tiptap/pm/state";
10
+ import { type EditorView } from "@tiptap/pm/view";
11
+
12
+ /**
13
+ * Default editor-wrapper CSS selector the drag handle scopes itself to.
14
+ *
15
+ * The handle, the drop indicator, and the `position: relative` anchor are all
16
+ * appended to / measured against the closest ancestor matching this selector.
17
+ * Content's editor wraps its ProseMirror DOM in a `.visual-editor-wrapper`
18
+ * element, so that is the historical default. Other apps (e.g. the plan editor)
19
+ * pass their own wrapper selector via {@link DragHandleOptions.wrapperSelector}.
20
+ */
21
+ export const DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR = ".visual-editor-wrapper";
22
+
23
+ export interface DragHandleOptions {
24
+ /**
25
+ * CSS selector for the editor wrapper element the handle is anchored to.
26
+ *
27
+ * Must match an ancestor of the ProseMirror editor DOM. The wrapper gets
28
+ * `position: relative` so the absolutely-positioned grip and drop indicator
29
+ * can be placed relative to it. Defaults to
30
+ * {@link DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR} so Content keeps working
31
+ * unchanged.
32
+ */
33
+ wrapperSelector: string;
34
+ /**
35
+ * Optional source-side payload for a cross-editor block move. The editor doc
36
+ * carries ProseMirror node content, but app-owned side-map data (for example a
37
+ * plan `diagram` block's HTML/CSS) can live outside the doc; this lets the
38
+ * host carry that data to the receiving editor before the node is inserted.
39
+ */
40
+ getDragTransferData?: (context: {
41
+ view: EditorView;
42
+ node: ProseMirrorNode;
43
+ pos: number;
44
+ }) => unknown;
45
+ /**
46
+ * Optional target-side receiver for cross-editor transfer data. Called before
47
+ * the node is inserted into the target editor so the target's serializer can
48
+ * resolve app-owned data during the synchronous ProseMirror update.
49
+ */
50
+ receiveDragTransferData?: (
51
+ data: unknown,
52
+ context: {
53
+ view: EditorView;
54
+ node: ProseMirrorNode;
55
+ pos: number;
56
+ sourceView: EditorView;
57
+ },
58
+ ) => void;
59
+ /**
60
+ * Optional host-level drop handler for document-specific structure changes.
61
+ * Returning true tells the shared drag handle that the host fully handled the
62
+ * move and no ProseMirror insert/delete should run. This is used for
63
+ * Notion-style side drops where dropping a block to the left/right creates or
64
+ * inserts into a column layout rather than inserting into the target editor.
65
+ */
66
+ handleDrop?: (data: unknown, context: DragHandleDropContext) => boolean;
67
+ }
68
+
69
+ const dragHandleKey = new PluginKey("dragHandle");
70
+ const HOVER_SIDE_OUTSET_REM = 8;
71
+ // Notion-style side drop: drag a block to a neighbour's LEFT/RIGHT region and it
72
+ // builds (or joins) a column layout instead of reordering. The activation region
73
+ // has to be GENEROUS or the gesture is dead for a real human — a natural drag
74
+ // releases somewhere over the block's body, nowhere near a thin edge sliver. The
75
+ // old values (28% of width, capped at 140px, AND only the vertical middle 60%)
76
+ // left a wide ~820px plan block with two ~17%-of-width edge slivers in a 35px-tall
77
+ // band as the ONLY column targets — ~66% of the block (the whole centre) plus the
78
+ // top/bottom only ever reordered, so "drag side by side" essentially never made
79
+ // columns. Now each side claims ~a third of the width across the FULL block
80
+ // height, with a middle band always preserved for before/after reorder.
81
+ const SIDE_DROP_ZONE_RATIO = 0.33;
82
+ const SIDE_DROP_ZONE_MIN_PX = 56;
83
+ const SIDE_DROP_ZONE_MAX_PX = 320;
84
+ // Never let the two side zones swallow the whole block: keep at least the middle
85
+ // ~10% of the width as the before/after reorder band so dropping over the centre
86
+ // still moves the block above/below the target (Notion keeps reorder reachable).
87
+ const SIDE_DROP_ZONE_MAX_WIDTH_FRACTION = 0.45;
88
+ const DRAG_HANDLE_MENU_STYLE_ID = "an-rich-md-drag-menu-styles";
89
+ const DRAG_HANDLE_MENU_WIDTH = 220;
90
+ const DRAG_HANDLE_MENU_GAP = 6;
91
+ const DRAG_HANDLE_MENU_VIEWPORT_PADDING = 8;
92
+
93
+ /**
94
+ * Wraps Tabler outline icon path data in the standard 24×24 stroke SVG so the
95
+ * DOM-based block menu renders the same icons the React UI uses (Tabler is the
96
+ * framework-wide icon set). The editor is plain DOM, not React, so we inline the
97
+ * markup instead of importing `@tabler/icons-react` components.
98
+ */
99
+ const tablerIconSvg = (paths: string): string =>
100
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${paths}</svg>`;
101
+
102
+ // Tabler `copy`, `trash`, and `plus` (outline). Path data copied verbatim from
103
+ // @tabler/icons so the glyphs stay pixel-identical to the React icon set.
104
+ const DRAG_HANDLE_MENU_ICON_DUPLICATE = tablerIconSvg(
105
+ '<path d="M7 9.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667l0 -8.666" /><path d="M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1" />',
106
+ );
107
+ const DRAG_HANDLE_MENU_ICON_DELETE = tablerIconSvg(
108
+ '<path d="M4 7l16 0" /><path d="M10 11l0 6" /><path d="M14 11l0 6" /><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" /><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />',
109
+ );
110
+ const DRAG_HANDLE_MENU_ICON_INSERT = tablerIconSvg(
111
+ '<path d="M12 5l0 14" /><path d="M5 12l14 0" />',
112
+ );
113
+ // Tabler `grip-vertical` (outline) for the left-margin drag grip.
114
+ const DRAG_HANDLE_GRIP_ICON = tablerIconSvg(
115
+ '<path d="M8 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M8 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M8 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M14 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M14 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />',
116
+ );
117
+
118
+ type DropTarget = {
119
+ registration: DragHandleRegistration;
120
+ view: EditorView;
121
+ block: HTMLElement;
122
+ placement: DragHandleDropPlacement;
123
+ pos: number;
124
+ targetPos: number;
125
+ targetNodeSize: number;
126
+ rect: DOMRect;
127
+ };
128
+
129
+ export type DragHandleDropPlacement = "before" | "after" | "left" | "right";
130
+
131
+ export type DragHandleDropContext = {
132
+ view: EditorView;
133
+ sourceView: EditorView;
134
+ sourceNode: ProseMirrorNode;
135
+ sourcePos: number;
136
+ sourceNodeSize: number;
137
+ targetNode: ProseMirrorNode;
138
+ targetPos: number;
139
+ targetNodeSize: number;
140
+ insertPos: number;
141
+ placement: DragHandleDropPlacement;
142
+ };
143
+
144
+ type DragSession = {
145
+ view: EditorView;
146
+ sourceBlock: HTMLElement;
147
+ sourcePos: number;
148
+ sourceNodeSize: number;
149
+ startX: number;
150
+ startY: number;
151
+ dragging: boolean;
152
+ preview: HTMLElement | null;
153
+ dropLine: HTMLElement | null;
154
+ dropTarget: DropTarget | null;
155
+ };
156
+
157
+ type HoverBlock = {
158
+ node: HTMLElement;
159
+ pmPos: number;
160
+ rect: DOMRect;
161
+ };
162
+
163
+ type DragHandleMenuContext = {
164
+ view: EditorView;
165
+ sourceBlock: HTMLElement;
166
+ sourcePos: number;
167
+ sourceNodeSize: number;
168
+ };
169
+
170
+ type DragHandleRegistration = {
171
+ view: EditorView;
172
+ wrapperSelector: string;
173
+ getDragTransferData?: DragHandleOptions["getDragTransferData"];
174
+ receiveDragTransferData?: DragHandleOptions["receiveDragTransferData"];
175
+ handleDrop?: DragHandleOptions["handleDrop"];
176
+ canHover?: () => boolean;
177
+ findHoverBlock?: (clientX: number, clientY: number) => HoverBlock | null;
178
+ showHoverBlock?: (block: HoverBlock) => void;
179
+ hideHover?: () => void;
180
+ /** The currently displayed grip's bounding rect, or null when hidden. */
181
+ gripRect?: () => DOMRect | null;
182
+ };
183
+
184
+ const dragHandleRegistrations = new Set<DragHandleRegistration>();
185
+ let dragHandleGlobalHoverListeners = 0;
186
+ let activeDragRegistration: DragHandleRegistration | null = null;
187
+ // The registration whose grip is currently shown. Used to keep that grip alive
188
+ // while the cursor travels from a block's body to its grip, even when the grip
189
+ // sits in a contested gap (an inter-column gap or a tab body's left offset)
190
+ // where another editor's wide forgiving zone would otherwise re-win the hover
191
+ // and hide the grip out from under the approaching cursor.
192
+ let activeHoverRegistration: DragHandleRegistration | null = null;
193
+
194
+ const clamp = (value: number, min: number, max: number) =>
195
+ Math.min(Math.max(value, min), max);
196
+
197
+ const editorArea = (registration: DragHandleRegistration) => {
198
+ const rect = registration.view.dom.getBoundingClientRect();
199
+ return rect.width * rect.height;
200
+ };
201
+
202
+ const updateRegisteredHover = (clientX: number, clientY: number) => {
203
+ if (activeDragRegistration) {
204
+ for (const registration of dragHandleRegistrations) {
205
+ registration.hideHover?.();
206
+ }
207
+ activeHoverRegistration = null;
208
+ return;
209
+ }
210
+
211
+ const candidates: Array<{
212
+ registration: DragHandleRegistration;
213
+ block: HoverBlock;
214
+ }> = [];
215
+
216
+ for (const registration of dragHandleRegistrations) {
217
+ if (!registration.view.dom.isConnected || !registration.canHover?.()) {
218
+ registration.hideHover?.();
219
+ continue;
220
+ }
221
+ const block = registration.findHoverBlock?.(clientX, clientY);
222
+ if (block) {
223
+ candidates.push({ registration, block });
224
+ } else {
225
+ registration.hideHover?.();
226
+ }
227
+ }
228
+
229
+ // Grip keepalive. Once a block's grip is showing, hold it while the cursor
230
+ // travels LEFT of that block's content toward its grip glyph — within the
231
+ // block's own vertical row and no further left than the glyph itself. This is
232
+ // what makes grips grabbable for blocks that are NOT flush with the page's
233
+ // left gutter (a right column, a tab body): their grip sits in a gap that the
234
+ // neighbour's wide forgiving zone also claims, so the normal picker would flip
235
+ // hover to the neighbour mid-approach and the grip would vanish before the
236
+ // cursor reaches it. The keepalive only bridges the body→grip gap — it does
237
+ // NOT fire while the cursor is over content (so the innermost/nested picking
238
+ // and gutter-grab rules below still decide there) and the row guard stops it
239
+ // from sticking the grip across vertical moves to another block's row.
240
+ if (activeHoverRegistration) {
241
+ const held = candidates.find(
242
+ (candidate) => candidate.registration === activeHoverRegistration,
243
+ );
244
+ const grip = activeHoverRegistration.gripRect?.();
245
+ if (
246
+ held &&
247
+ grip &&
248
+ clientY >= held.block.rect.top &&
249
+ clientY < held.block.rect.bottom &&
250
+ clientX >= grip.left - 4 &&
251
+ clientX < held.block.rect.left
252
+ ) {
253
+ for (const registration of dragHandleRegistrations) {
254
+ if (registration !== held.registration) registration.hideHover?.();
255
+ }
256
+ held.registration.showHoverBlock?.(held.block);
257
+ return;
258
+ }
259
+ }
260
+
261
+ // Pick which editor owns the grip when several register a hover block at this
262
+ // point. Nested region editors (e.g. each column inside a `columns` block) tile
263
+ // their container's whole footprint AND extend a wide forgiving zone
264
+ // (HOVER_SIDE_OUTSET_REM) into its left-margin gutter, so a pure
265
+ // "smallest editor wins" rule lets an inner block beat the container everywhere
266
+ // and leaves the container itself impossible to grab. Split candidates by where
267
+ // the cursor sits relative to each block:
268
+ // - Over a block's body (clientX at/after its left edge) the innermost
269
+ // (smallest) editor wins, so nested blocks stay grabbable from their content.
270
+ // - In the shared left-margin gutter (clientX left of every candidate's
271
+ // content, where the grip lives) the outermost (largest) editor wins, so the
272
+ // container block can be picked up and reordered.
273
+ // Prefer the candidate whose block actually sits UNDER the cursor
274
+ // horizontally. Without this, a left column's forgiving side zone reaches
275
+ // across the inter-column gap, ties the right column's editor on area, and
276
+ // wins — so hovering a right-column block shows the grip for the LEFT block
277
+ // (and right-column blocks appear to have no grip at all). `overContent`
278
+ // restricts to blocks the cursor is genuinely within; `rightOfLeftEdge` keeps
279
+ // the gutter-grab behaviour; fully left of every block → the container wins.
280
+ const overContent = candidates.filter(
281
+ (candidate) =>
282
+ clientX >= candidate.block.rect.left &&
283
+ clientX <= candidate.block.rect.right,
284
+ );
285
+ // The grip renders in a narrow band just LEFT of each block (≈24px). A block
286
+ // must OWN that band so moving the cursor onto its grip keeps showing (and
287
+ // lets you press) that block's grip — otherwise, for a column block whose grip
288
+ // sits in the gutter/inter-column gap, the "gutter → largest editor" rule
289
+ // below would flip the hover to the columns container and the grip would
290
+ // vanish out from under the cursor, making inner column blocks impossible to
291
+ // drag. The band is narrow, so it does not collide with the neighbouring
292
+ // column's content (the right column's grip lives in the inter-column gap,
293
+ // left of its own content but right of the left column's content).
294
+ const GRIP_HOVER_ZONE_PX = 28;
295
+ const overGrip = candidates.filter(
296
+ (candidate) =>
297
+ clientX >= candidate.block.rect.left - GRIP_HOVER_ZONE_PX &&
298
+ clientX < candidate.block.rect.left,
299
+ );
300
+ const rightOfLeftEdge = candidates.filter(
301
+ (candidate) => clientX >= candidate.block.rect.left,
302
+ );
303
+ let active: {
304
+ registration: DragHandleRegistration;
305
+ block: HoverBlock;
306
+ } | null;
307
+ const innerPool =
308
+ overContent.length > 0
309
+ ? overContent
310
+ : overGrip.length > 0
311
+ ? overGrip
312
+ : rightOfLeftEdge;
313
+ if (innerPool.length > 0) {
314
+ innerPool.sort(
315
+ (a, b) => editorArea(a.registration) - editorArea(b.registration),
316
+ );
317
+ active = innerPool[0];
318
+ } else {
319
+ candidates.sort(
320
+ (a, b) => editorArea(b.registration) - editorArea(a.registration),
321
+ );
322
+ active = candidates[0] ?? null;
323
+ }
324
+
325
+ for (const registration of dragHandleRegistrations) {
326
+ if (registration !== active?.registration) registration.hideHover?.();
327
+ }
328
+ active?.registration.showHoverBlock?.(active.block);
329
+ activeHoverRegistration = active?.registration ?? null;
330
+ };
331
+
332
+ const handleGlobalHoverMove = (event: MouseEvent) => {
333
+ updateRegisteredHover(event.clientX, event.clientY);
334
+ };
335
+
336
+ const retainGlobalHoverListener = () => {
337
+ dragHandleGlobalHoverListeners += 1;
338
+ if (dragHandleGlobalHoverListeners === 1) {
339
+ document.addEventListener("mousemove", handleGlobalHoverMove);
340
+ }
341
+ };
342
+
343
+ const releaseGlobalHoverListener = () => {
344
+ dragHandleGlobalHoverListeners = Math.max(
345
+ 0,
346
+ dragHandleGlobalHoverListeners - 1,
347
+ );
348
+ if (dragHandleGlobalHoverListeners === 0) {
349
+ document.removeEventListener("mousemove", handleGlobalHoverMove);
350
+ }
351
+ };
352
+
353
+ const ensureDragHandleMenuStyles = () => {
354
+ if (document.getElementById(DRAG_HANDLE_MENU_STYLE_ID)) return;
355
+
356
+ const style = document.createElement("style");
357
+ style.id = DRAG_HANDLE_MENU_STYLE_ID;
358
+ style.textContent = `
359
+ .an-rich-md-drag-menu {
360
+ position: fixed;
361
+ z-index: 9999;
362
+ width: ${DRAG_HANDLE_MENU_WIDTH}px;
363
+ padding: 4px;
364
+ border: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
365
+ border-radius: 7px;
366
+ background: hsl(var(--popover, 0 0% 100%));
367
+ color: hsl(var(--popover-foreground, var(--foreground, 222.2 84% 4.9%)));
368
+ box-shadow:
369
+ 0 12px 32px rgb(15 23 42 / 0.16),
370
+ 0 2px 8px rgb(15 23 42 / 0.08);
371
+ font-family: inherit;
372
+ font-size: 13px;
373
+ line-height: 1.35;
374
+ }
375
+
376
+ .an-rich-md-drag-menu__item {
377
+ display: flex;
378
+ width: 100%;
379
+ align-items: center;
380
+ gap: 9px;
381
+ border: 0;
382
+ border-radius: 5px;
383
+ background: transparent;
384
+ color: inherit;
385
+ cursor: pointer;
386
+ font: inherit;
387
+ letter-spacing: 0;
388
+ padding: 7px 8px;
389
+ text-align: left;
390
+ }
391
+
392
+ .an-rich-md-drag-menu__item:hover,
393
+ .an-rich-md-drag-menu__item:focus-visible {
394
+ background: hsl(var(--accent, 210 40% 96.1%));
395
+ color: hsl(var(--accent-foreground, var(--foreground, 222.2 84% 4.9%)));
396
+ outline: none;
397
+ }
398
+
399
+ .an-rich-md-drag-menu__item[data-danger="true"] {
400
+ color: hsl(var(--destructive, 0 84.2% 60.2%));
401
+ }
402
+
403
+ .an-rich-md-drag-menu__item[data-danger="true"]:hover,
404
+ .an-rich-md-drag-menu__item[data-danger="true"]:focus-visible {
405
+ background: hsl(var(--destructive, 0 84.2% 60.2%) / 0.1);
406
+ }
407
+
408
+ .an-rich-md-drag-menu__icon {
409
+ display: flex;
410
+ flex: 0 0 auto;
411
+ align-items: center;
412
+ justify-content: center;
413
+ width: 18px;
414
+ height: 18px;
415
+ color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%));
416
+ }
417
+
418
+ .an-rich-md-drag-menu__item[data-danger="true"] .an-rich-md-drag-menu__icon {
419
+ color: currentColor;
420
+ }
421
+
422
+ .an-rich-md-drag-menu__icon svg {
423
+ width: 17px;
424
+ height: 17px;
425
+ }
426
+
427
+ .an-rich-md-drag-menu__label {
428
+ min-width: 0;
429
+ overflow: hidden;
430
+ text-overflow: ellipsis;
431
+ white-space: nowrap;
432
+ }
433
+ `;
434
+ document.head.appendChild(style);
435
+ };
436
+
437
+ /**
438
+ * App-agnostic Tiptap extension providing a Notion-style left-margin drag grip
439
+ * (the `::` handle), block selection, and drag-to-reorder over top-level block
440
+ * nodes.
441
+ *
442
+ * Behavior:
443
+ * - On hover over any top-level block, a `.drag-handle` grip appears in the left
444
+ * margin (forgiving hit zone extends {@link HOVER_SIDE_OUTSET_REM}rem to the
445
+ * sides and into the gap above/between blocks).
446
+ * - Single-clicking the grip selects the block and opens a block action menu.
447
+ * Dragging past a small threshold starts a reorder, showing a floating clone
448
+ * preview (`.notion-drag-preview`) and a `.notion-drop-indicator` line.
449
+ * `Escape` cancels.
450
+ * - While dragging, the source block carries `.notion-block--dragging` and the
451
+ * document element carries `.notion-editor-is-dragging` so apps can style the
452
+ * in-flight state. Apps own all of these CSS class names.
453
+ * - Works for ANY top-level node ProseMirror renders as a direct child of the
454
+ * editor — including `group: "block"`, `draggable: true` atoms such as the
455
+ * plan editor's `planBlock`.
456
+ *
457
+ * The only app-specific coupling — the editor wrapper element the handle and
458
+ * drop indicator are anchored to — is configurable via
459
+ * {@link DragHandleOptions.wrapperSelector}, defaulting to
460
+ * {@link DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR} (`.visual-editor-wrapper`) so the
461
+ * Content editor keeps working byte-identically. The plan editor passes its own
462
+ * wrapper selector via `DragHandle.configure({ wrapperSelector })`.
463
+ */
464
+ export const DragHandle = Extension.create<DragHandleOptions>({
465
+ name: "dragHandle",
466
+
467
+ addOptions() {
468
+ return {
469
+ wrapperSelector: DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR,
470
+ getDragTransferData: undefined,
471
+ receiveDragTransferData: undefined,
472
+ };
473
+ },
474
+
475
+ addProseMirrorPlugins() {
476
+ const editor = this.editor;
477
+ const wrapperSelector = this.options.wrapperSelector;
478
+ const getDragTransferData = this.options.getDragTransferData;
479
+ const receiveDragTransferData = this.options.receiveDragTransferData;
480
+ const handleDrop = this.options.handleDrop;
481
+ let handle: HTMLElement | null = null;
482
+ let menu: HTMLElement | null = null;
483
+ let menuContext: DragHandleMenuContext | null = null;
484
+ let currentBlock: HTMLElement | null = null;
485
+ let dragStartPos: number | null = null;
486
+ let dragSession: DragSession | null = null;
487
+ let currentRegistration: DragHandleRegistration | null = null;
488
+
489
+ const getHoverSideOutset = () => {
490
+ const rootFontSize = Number.parseFloat(
491
+ getComputedStyle(document.documentElement).fontSize,
492
+ );
493
+ return (
494
+ (Number.isFinite(rootFontSize) ? rootFontSize : 16) *
495
+ HOVER_SIDE_OUTSET_REM
496
+ );
497
+ };
498
+
499
+ const getTopLevelBlocks = (editorView: EditorView): HoverBlock[] => {
500
+ const blocks: HoverBlock[] = [];
501
+
502
+ editorView.state.doc.forEach((_node, offset) => {
503
+ const dom = editorView.nodeDOM(offset);
504
+ if (!(dom instanceof HTMLElement)) return;
505
+
506
+ blocks.push({
507
+ node: dom,
508
+ pmPos: offset,
509
+ rect: dom.getBoundingClientRect(),
510
+ });
511
+ });
512
+
513
+ return blocks;
514
+ };
515
+
516
+ const registrationForView = (
517
+ editorView: EditorView,
518
+ ): DragHandleRegistration | null => {
519
+ for (const registration of dragHandleRegistrations) {
520
+ if (registration.view === editorView) return registration;
521
+ }
522
+ return null;
523
+ };
524
+
525
+ const findForgivingBlock = (
526
+ editorView: EditorView,
527
+ clientX: number,
528
+ clientY: number,
529
+ ): HoverBlock | null => {
530
+ const blocks = getTopLevelBlocks(editorView);
531
+ if (blocks.length === 0) return null;
532
+
533
+ const sideOutset = getHoverSideOutset();
534
+ const pageLeft = 0;
535
+ const pageRight = window.visualViewport?.width ?? window.innerWidth;
536
+
537
+ for (let index = 0; index < blocks.length; index++) {
538
+ const block = blocks[index];
539
+ const nextBlock = blocks[index + 1];
540
+ const blockBottomGap = nextBlock
541
+ ? Math.max(0, nextBlock.rect.top - block.rect.bottom)
542
+ : 0;
543
+ const zoneLeft = Math.max(pageLeft, block.rect.left - sideOutset);
544
+ const zoneRight = Math.min(pageRight, block.rect.right + sideOutset);
545
+ const zoneTop =
546
+ index === 0
547
+ ? Math.max(0, block.rect.top - blockBottomGap)
548
+ : block.rect.top;
549
+ const zoneBottom = nextBlock ? nextBlock.rect.top : block.rect.bottom;
550
+
551
+ if (
552
+ clientX >= zoneLeft &&
553
+ clientX <= zoneRight &&
554
+ clientY >= zoneTop &&
555
+ clientY < zoneBottom
556
+ ) {
557
+ return block;
558
+ }
559
+ }
560
+
561
+ return null;
562
+ };
563
+
564
+ const showHandleForBlock = (editorView: EditorView, block: HoverBlock) => {
565
+ if (!handle) return;
566
+ currentBlock = block.node;
567
+ dragStartPos = block.pmPos;
568
+
569
+ const wrapper = editorView.dom.closest(wrapperSelector);
570
+ if (!wrapper) return;
571
+
572
+ // Lazily (re)attach the grip the first time a wrapper is actually
573
+ // available. At plugin `view()` init the editor DOM may not yet be mounted
574
+ // inside the wrapper (React mounts `EditorContent` after the EditorView is
575
+ // constructed), so the init-time append can silently no-op and leave the
576
+ // grip orphaned. Re-home it here once the wrapper exists.
577
+ if (handle.parentElement !== wrapper) {
578
+ (wrapper as HTMLElement).style.position = "relative";
579
+ wrapper.appendChild(handle);
580
+ }
581
+
582
+ const wrapperRect = wrapper.getBoundingClientRect();
583
+ const handleLeft = block.rect.left - wrapperRect.left - 24;
584
+
585
+ handle.style.display = "flex";
586
+ handle.style.top = `${block.rect.top - wrapperRect.top + 2}px`;
587
+ handle.style.left = `${handleLeft}px`;
588
+ };
589
+
590
+ const selectBlockAt = (editorView: EditorView, pos: number) => {
591
+ try {
592
+ const sel = NodeSelection.create(editorView.state.doc, pos);
593
+ editorView.dispatch(editorView.state.tr.setSelection(sel));
594
+ editorView.focus();
595
+ return sel;
596
+ } catch {
597
+ return null;
598
+ }
599
+ };
600
+
601
+ const cleanupDragVisuals = () => {
602
+ dragSession?.preview?.remove();
603
+ dragSession?.dropLine?.remove();
604
+ dragSession?.sourceBlock.classList.remove("notion-block--dragging");
605
+ document.documentElement.classList.remove("notion-editor-is-dragging");
606
+ };
607
+
608
+ const createDragPreview = (block: HTMLElement): HTMLElement => {
609
+ const blockRect = block.getBoundingClientRect();
610
+ const preview = document.createElement("div");
611
+ const clone = block.cloneNode(true) as HTMLElement;
612
+
613
+ clone.classList.remove(
614
+ "ProseMirror-selectednode",
615
+ "notion-block--dragging",
616
+ );
617
+ clone.removeAttribute("contenteditable");
618
+ clone.style.background = "transparent";
619
+ clone.style.backgroundColor = "transparent";
620
+ clone.querySelectorAll("[contenteditable]").forEach((node) => {
621
+ node.removeAttribute("contenteditable");
622
+ });
623
+ clone.querySelectorAll<HTMLElement>("*").forEach((node) => {
624
+ node.style.background = "transparent";
625
+ node.style.backgroundColor = "transparent";
626
+ });
627
+
628
+ preview.className = "notion-drag-preview";
629
+ preview.style.width = `${blockRect.width}px`;
630
+ preview.appendChild(clone);
631
+ document.body.appendChild(preview);
632
+
633
+ return preview;
634
+ };
635
+
636
+ const createDropLine = (
637
+ registration: DragHandleRegistration,
638
+ ): HTMLElement | null => {
639
+ const wrapper = registration.view.dom.closest(
640
+ registration.wrapperSelector,
641
+ );
642
+ if (!wrapper) return null;
643
+
644
+ const line = document.createElement("div");
645
+ line.className = "notion-drop-indicator";
646
+ wrapper.appendChild(line);
647
+ return line;
648
+ };
649
+
650
+ const forceHideHandle = () => {
651
+ if (handle) {
652
+ handle.style.display = "none";
653
+ handle.setAttribute("aria-expanded", "false");
654
+ }
655
+ currentBlock = null;
656
+ dragStartPos = null;
657
+ };
658
+
659
+ const closeMenu = ({ hideGrip = false }: { hideGrip?: boolean } = {}) => {
660
+ menu?.remove();
661
+ menu = null;
662
+ menuContext = null;
663
+ handle?.setAttribute("aria-expanded", "false");
664
+ document.removeEventListener("mousedown", handleMenuDocumentMouseDown, {
665
+ capture: true,
666
+ });
667
+ document.removeEventListener("keydown", handleMenuKeyDown, {
668
+ capture: true,
669
+ });
670
+ window.removeEventListener("resize", handleMenuViewportChange);
671
+ window.removeEventListener("scroll", handleMenuViewportChange, {
672
+ capture: true,
673
+ });
674
+ if (hideGrip) forceHideHandle();
675
+ };
676
+
677
+ const resolveMenuContext = (context: DragHandleMenuContext) => {
678
+ const latestBlock = getTopLevelBlocks(context.view).find(
679
+ (block) => block.node === context.sourceBlock,
680
+ );
681
+ const sourcePos = latestBlock?.pmPos ?? context.sourcePos;
682
+ const sourceNode = context.view.state.doc.nodeAt(sourcePos);
683
+ if (!sourceNode) return null;
684
+
685
+ return {
686
+ ...context,
687
+ sourcePos,
688
+ sourceNode,
689
+ sourceNodeSize: sourceNode.nodeSize,
690
+ };
691
+ };
692
+
693
+ const focusSelectionNear = (
694
+ view: EditorView,
695
+ tr: Transaction,
696
+ pos: number,
697
+ bias: -1 | 1,
698
+ ) => {
699
+ tr.setSelection(
700
+ TextSelection.near(
701
+ tr.doc.resolve(clamp(pos, 0, tr.doc.content.size)),
702
+ bias,
703
+ ),
704
+ );
705
+ view.dispatch(tr.scrollIntoView());
706
+ view.focus();
707
+ };
708
+
709
+ const duplicateBlock = (context: DragHandleMenuContext) => {
710
+ const resolved = resolveMenuContext(context);
711
+ if (!resolved) return;
712
+
713
+ const insertPos = resolved.sourcePos + resolved.sourceNodeSize;
714
+ const tr = resolved.view.state.tr.insert(insertPos, resolved.sourceNode);
715
+
716
+ try {
717
+ tr.setSelection(NodeSelection.create(tr.doc, insertPos));
718
+ resolved.view.dispatch(tr.scrollIntoView());
719
+ resolved.view.focus();
720
+ } catch {
721
+ focusSelectionNear(resolved.view, tr, insertPos, 1);
722
+ }
723
+ };
724
+
725
+ const deleteBlock = (context: DragHandleMenuContext) => {
726
+ const resolved = resolveMenuContext(context);
727
+ if (!resolved) return;
728
+
729
+ const { view, sourcePos, sourceNodeSize } = resolved;
730
+ const paragraph = view.state.schema.nodes.paragraph;
731
+ const sourceEnd = sourcePos + sourceNodeSize;
732
+
733
+ if (view.state.doc.childCount <= 1 && paragraph) {
734
+ const replacement = paragraph.createAndFill() ?? paragraph.create();
735
+ const tr = view.state.tr.replaceWith(sourcePos, sourceEnd, replacement);
736
+ focusSelectionNear(view, tr, sourcePos + 1, 1);
737
+ return;
738
+ }
739
+
740
+ const tr = view.state.tr.delete(sourcePos, sourceEnd);
741
+ const selectionBias = sourcePos >= tr.doc.content.size ? -1 : 1;
742
+ focusSelectionNear(view, tr, sourcePos, selectionBias);
743
+ };
744
+
745
+ const insertParagraphBelow = (context: DragHandleMenuContext) => {
746
+ const resolved = resolveMenuContext(context);
747
+ const paragraph = resolved?.view.state.schema.nodes.paragraph;
748
+ if (!resolved || !paragraph) return;
749
+
750
+ const insertPos = resolved.sourcePos + resolved.sourceNodeSize;
751
+ const paragraphNode = paragraph.createAndFill() ?? paragraph.create();
752
+ const tr = resolved.view.state.tr.insert(insertPos, paragraphNode);
753
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
754
+ resolved.view.dispatch(tr.scrollIntoView());
755
+ resolved.view.focus();
756
+ };
757
+
758
+ const positionMenu = (anchorRect: DOMRect) => {
759
+ if (!menu) return;
760
+
761
+ const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
762
+ const viewportHeight =
763
+ window.visualViewport?.height ?? window.innerHeight;
764
+ const menuHeight = menu.offsetHeight || 118;
765
+ const preferredLeft = anchorRect.right + DRAG_HANDLE_MENU_GAP;
766
+ const alternateLeft =
767
+ anchorRect.left - DRAG_HANDLE_MENU_WIDTH - DRAG_HANDLE_MENU_GAP;
768
+ const left =
769
+ preferredLeft +
770
+ DRAG_HANDLE_MENU_WIDTH +
771
+ DRAG_HANDLE_MENU_VIEWPORT_PADDING <=
772
+ viewportWidth
773
+ ? preferredLeft
774
+ : alternateLeft;
775
+
776
+ menu.style.left = `${clamp(
777
+ left,
778
+ DRAG_HANDLE_MENU_VIEWPORT_PADDING,
779
+ viewportWidth -
780
+ DRAG_HANDLE_MENU_WIDTH -
781
+ DRAG_HANDLE_MENU_VIEWPORT_PADDING,
782
+ )}px`;
783
+ menu.style.top = `${clamp(
784
+ anchorRect.top - 4,
785
+ DRAG_HANDLE_MENU_VIEWPORT_PADDING,
786
+ viewportHeight - menuHeight - DRAG_HANDLE_MENU_VIEWPORT_PADDING,
787
+ )}px`;
788
+ };
789
+
790
+ const createMenuItem = (
791
+ label: string,
792
+ iconSvg: string,
793
+ action: (context: DragHandleMenuContext) => void,
794
+ options: { danger?: boolean } = {},
795
+ ) => {
796
+ const button = document.createElement("button");
797
+ button.type = "button";
798
+ button.className = "an-rich-md-drag-menu__item";
799
+ button.setAttribute("role", "menuitem");
800
+ button.setAttribute("data-plan-interactive", "true");
801
+ if (options.danger) button.setAttribute("data-danger", "true");
802
+
803
+ const icon = document.createElement("span");
804
+ icon.className = "an-rich-md-drag-menu__icon";
805
+ icon.setAttribute("aria-hidden", "true");
806
+ icon.innerHTML = iconSvg;
807
+
808
+ const labelElement = document.createElement("span");
809
+ labelElement.className = "an-rich-md-drag-menu__label";
810
+ labelElement.textContent = label;
811
+
812
+ button.append(icon, labelElement);
813
+ button.addEventListener("mousedown", (event) => {
814
+ event.preventDefault();
815
+ });
816
+ button.addEventListener("click", (event) => {
817
+ event.preventDefault();
818
+ event.stopPropagation();
819
+ const context = menuContext;
820
+ if (!context) return;
821
+ closeMenu({ hideGrip: true });
822
+ action(context);
823
+ });
824
+
825
+ return button;
826
+ };
827
+
828
+ const openMenu = (context: DragHandleMenuContext, anchorRect: DOMRect) => {
829
+ const resolved = resolveMenuContext(context);
830
+ if (!resolved) return;
831
+
832
+ closeMenu();
833
+ selectBlockAt(resolved.view, resolved.sourcePos);
834
+ ensureDragHandleMenuStyles();
835
+
836
+ const el = document.createElement("div");
837
+ el.className = "an-rich-md-drag-menu";
838
+ el.setAttribute("role", "menu");
839
+ el.setAttribute("aria-label", "Block actions");
840
+ el.setAttribute("data-plan-interactive", "true");
841
+
842
+ el.append(
843
+ createMenuItem(
844
+ "Duplicate",
845
+ DRAG_HANDLE_MENU_ICON_DUPLICATE,
846
+ duplicateBlock,
847
+ ),
848
+ createMenuItem("Delete", DRAG_HANDLE_MENU_ICON_DELETE, deleteBlock, {
849
+ danger: true,
850
+ }),
851
+ createMenuItem(
852
+ "Insert block below",
853
+ DRAG_HANDLE_MENU_ICON_INSERT,
854
+ insertParagraphBelow,
855
+ ),
856
+ );
857
+
858
+ menu = el;
859
+ menuContext = {
860
+ view: resolved.view,
861
+ sourceBlock: resolved.sourceBlock,
862
+ sourcePos: resolved.sourcePos,
863
+ sourceNodeSize: resolved.sourceNodeSize,
864
+ };
865
+ document.body.appendChild(el);
866
+ positionMenu(anchorRect);
867
+ handle?.setAttribute("aria-expanded", "true");
868
+ document.addEventListener("mousedown", handleMenuDocumentMouseDown, {
869
+ capture: true,
870
+ });
871
+ document.addEventListener("keydown", handleMenuKeyDown, {
872
+ capture: true,
873
+ });
874
+ window.addEventListener("resize", handleMenuViewportChange);
875
+ window.addEventListener("scroll", handleMenuViewportChange, {
876
+ capture: true,
877
+ });
878
+
879
+ el.querySelector<HTMLButtonElement>("button")?.focus({
880
+ preventScroll: true,
881
+ });
882
+ };
883
+
884
+ const findDropTarget = (
885
+ registration: DragHandleRegistration,
886
+ clientX: number,
887
+ clientY: number,
888
+ ): DropTarget | null => {
889
+ const view = registration.view;
890
+ const block = findForgivingBlock(view, clientX, clientY);
891
+ if (!block) return null;
892
+
893
+ const node = view.state.doc.nodeAt(block.pmPos);
894
+ if (!node) return null;
895
+
896
+ let placement: DragHandleDropPlacement;
897
+ const withinBlockY =
898
+ clientY >= block.rect.top && clientY <= block.rect.bottom;
899
+ // Side (column) zones span the FULL block height — only the horizontal
900
+ // position decides column-vs-reorder. Restricting to the vertical middle
901
+ // (the old 0.2 band) made the already-tiny edge slivers nearly unhittable.
902
+ const sideZoneWidth = Math.min(
903
+ clamp(
904
+ block.rect.width * SIDE_DROP_ZONE_RATIO,
905
+ SIDE_DROP_ZONE_MIN_PX,
906
+ SIDE_DROP_ZONE_MAX_PX,
907
+ ),
908
+ block.rect.width * SIDE_DROP_ZONE_MAX_WIDTH_FRACTION,
909
+ );
910
+
911
+ if (
912
+ registration.handleDrop &&
913
+ withinBlockY &&
914
+ clientX <= block.rect.left + sideZoneWidth
915
+ ) {
916
+ placement = "left";
917
+ } else if (
918
+ registration.handleDrop &&
919
+ withinBlockY &&
920
+ clientX >= block.rect.right - sideZoneWidth
921
+ ) {
922
+ placement = "right";
923
+ } else {
924
+ placement =
925
+ clientY < block.rect.top ||
926
+ (clientY <= block.rect.bottom &&
927
+ clientY < block.rect.top + block.rect.height / 2)
928
+ ? "before"
929
+ : "after";
930
+ }
931
+ const before = placement === "before" || placement === "left";
932
+
933
+ return {
934
+ registration,
935
+ view,
936
+ block: block.node,
937
+ placement,
938
+ pos: before ? block.pmPos : block.pmPos + node.nodeSize,
939
+ targetPos: block.pmPos,
940
+ targetNodeSize: node.nodeSize,
941
+ rect: block.rect,
942
+ };
943
+ };
944
+
945
+ const findAnyDropTarget = (
946
+ session: DragSession,
947
+ clientX: number,
948
+ clientY: number,
949
+ ): DropTarget | null => {
950
+ const candidates: DropTarget[] = [];
951
+
952
+ for (const registration of dragHandleRegistrations) {
953
+ if (!registration.view.dom.isConnected) continue;
954
+ if (
955
+ registration.view !== session.view &&
956
+ session.sourceBlock.contains(registration.view.dom)
957
+ ) {
958
+ continue;
959
+ }
960
+ const target = findDropTarget(registration, clientX, clientY);
961
+ if (target) candidates.push(target);
962
+ }
963
+
964
+ candidates.sort((a, b) => {
965
+ const aRect = a.view.dom.getBoundingClientRect();
966
+ const bRect = b.view.dom.getBoundingClientRect();
967
+ return aRect.width * aRect.height - bRect.width * bRect.height;
968
+ });
969
+
970
+ return candidates[0] ?? null;
971
+ };
972
+
973
+ const positionDragPreview = (
974
+ session: DragSession,
975
+ clientX: number,
976
+ clientY: number,
977
+ ) => {
978
+ if (!session.preview) return;
979
+
980
+ session.preview.style.transform = `translate3d(${clientX + 12}px, ${clientY + 10}px, 0)`;
981
+ };
982
+
983
+ const updateDropLine = (
984
+ session: DragSession,
985
+ target: DropTarget | null,
986
+ ) => {
987
+ const sourceEnd = session.sourcePos + session.sourceNodeSize;
988
+ const isSideDrop =
989
+ target?.placement === "left" || target?.placement === "right";
990
+ if (
991
+ !target ||
992
+ (target.view === session.view &&
993
+ (isSideDrop
994
+ ? // A side drop only ever builds columns; the ProseMirror seam
995
+ // position is irrelevant. The only no-op is dropping a block on
996
+ // ITS OWN side — adjacent *different* blocks must still form
997
+ // columns (otherwise dropping onto an immediate neighbour's
998
+ // facing edge silently does nothing, which reads as "side drop
999
+ // works sometimes").
1000
+ target.targetPos === session.sourcePos
1001
+ : target.pos === session.sourcePos ||
1002
+ target.pos === sourceEnd ||
1003
+ (target.pos > session.sourcePos && target.pos < sourceEnd)))
1004
+ ) {
1005
+ session.dropTarget = null;
1006
+ session.dropLine?.remove();
1007
+ session.dropLine = null;
1008
+ return;
1009
+ }
1010
+
1011
+ const wrapper = target.view.dom.closest(
1012
+ target.registration.wrapperSelector,
1013
+ );
1014
+ if (!wrapper) return;
1015
+
1016
+ if (!session.dropLine || session.dropLine.parentElement !== wrapper) {
1017
+ session.dropLine?.remove();
1018
+ session.dropLine = createDropLine(target.registration);
1019
+ }
1020
+ if (!session.dropLine) return;
1021
+
1022
+ const wrapperRect = wrapper.getBoundingClientRect();
1023
+ const editorRect = target.view.dom.getBoundingClientRect();
1024
+
1025
+ session.dropTarget = target;
1026
+ // A column (side) drop and a reorder (before/after) drop both draw the
1027
+ // `.notion-drop-indicator`, but they mean very different things, so the
1028
+ // column case carries a modifier class apps style distinctly (a bolder,
1029
+ // glowing vertical bar) — without a clear cue a human can't tell they've
1030
+ // entered column-build mode before releasing.
1031
+ const isColumnDrop =
1032
+ target.placement === "left" || target.placement === "right";
1033
+ session.dropLine.classList.toggle(
1034
+ "notion-drop-indicator--column",
1035
+ isColumnDrop,
1036
+ );
1037
+ if (isColumnDrop) {
1038
+ // A vertical bar centred on the seam at the target's left/right edge,
1039
+ // spanning the block's full height.
1040
+ const SIDE_BAR_WIDTH = 4;
1041
+ const seam =
1042
+ target.placement === "left" ? target.rect.left : target.rect.right;
1043
+ session.dropLine.style.left = `${seam - wrapperRect.left - SIDE_BAR_WIDTH / 2}px`;
1044
+ session.dropLine.style.top = `${target.rect.top - wrapperRect.top}px`;
1045
+ session.dropLine.style.width = `${SIDE_BAR_WIDTH}px`;
1046
+ session.dropLine.style.height = `${target.rect.height}px`;
1047
+ return;
1048
+ }
1049
+
1050
+ const top =
1051
+ target.placement === "before" ? target.rect.top : target.rect.bottom;
1052
+ session.dropLine.style.left = `${editorRect.left - wrapperRect.left}px`;
1053
+ session.dropLine.style.top = `${top - wrapperRect.top}px`;
1054
+ session.dropLine.style.width = `${editorRect.width}px`;
1055
+ session.dropLine.style.height = "3px";
1056
+ };
1057
+
1058
+ const createHandle = () => {
1059
+ const el = document.createElement("div");
1060
+ el.className = "drag-handle";
1061
+ el.contentEditable = "false";
1062
+ el.draggable = false;
1063
+ el.tabIndex = 0;
1064
+ el.setAttribute("role", "button");
1065
+ el.setAttribute("aria-label", "Open block menu or drag to reorder");
1066
+ el.setAttribute("aria-haspopup", "menu");
1067
+ el.setAttribute("aria-expanded", "false");
1068
+ el.title = "Open block menu or drag to reorder";
1069
+ // The icon must not be its own hit target: a real mouse-down inside a
1070
+ // nested editor (a column) lands on the SVG, and a container block's
1071
+ // capture-phase block-select handler (RegistryBlockNode) only spares the
1072
+ // grip DIV — so a press on the icon gets swallowed and the block can't be
1073
+ // dragged out of / between columns. `pointer-events:none` makes every
1074
+ // press in the grip area resolve to the DIV instead.
1075
+ // Tabler `grip-vertical` (the framework-wide icon set). `pointer-events:none`
1076
+ // keeps every press in the grip area resolving to the DIV, not the SVG.
1077
+ el.innerHTML = DRAG_HANDLE_GRIP_ICON;
1078
+ const gripSvg = el.querySelector("svg");
1079
+ if (gripSvg) {
1080
+ gripSvg.setAttribute("width", "16");
1081
+ gripSvg.setAttribute("height", "16");
1082
+ gripSvg.style.pointerEvents = "none";
1083
+ }
1084
+ return el;
1085
+ };
1086
+
1087
+ const hideHandle = () => {
1088
+ if (menu) return;
1089
+ forceHideHandle();
1090
+ };
1091
+
1092
+ const removeDragListeners = () => {
1093
+ document.removeEventListener("mousemove", handleDocumentMouseMove);
1094
+ document.removeEventListener("mouseup", handleDocumentMouseUp);
1095
+ document.removeEventListener("keydown", handleDocumentKeyDown);
1096
+ };
1097
+
1098
+ function handleMenuDocumentMouseDown(event: MouseEvent) {
1099
+ const target = event.target;
1100
+ if (!(target instanceof Node)) return;
1101
+ if (menu?.contains(target) || handle?.contains(target)) return;
1102
+ closeMenu({ hideGrip: true });
1103
+ }
1104
+
1105
+ function handleMenuKeyDown(event: KeyboardEvent) {
1106
+ if (!menu) return;
1107
+
1108
+ if (event.key === "Escape") {
1109
+ event.preventDefault();
1110
+ closeMenu({ hideGrip: true });
1111
+ return;
1112
+ }
1113
+
1114
+ if (
1115
+ event.key !== "ArrowDown" &&
1116
+ event.key !== "ArrowUp" &&
1117
+ event.key !== "Home" &&
1118
+ event.key !== "End"
1119
+ ) {
1120
+ return;
1121
+ }
1122
+
1123
+ const buttons = Array.from(
1124
+ menu.querySelectorAll<HTMLButtonElement>("button"),
1125
+ );
1126
+ if (buttons.length === 0) return;
1127
+
1128
+ event.preventDefault();
1129
+ const activeIndex = buttons.indexOf(
1130
+ document.activeElement as HTMLButtonElement,
1131
+ );
1132
+ let nextIndex = activeIndex < 0 ? 0 : activeIndex;
1133
+
1134
+ if (event.key === "ArrowDown") {
1135
+ nextIndex = (nextIndex + 1) % buttons.length;
1136
+ } else if (event.key === "ArrowUp") {
1137
+ nextIndex = (nextIndex - 1 + buttons.length) % buttons.length;
1138
+ } else if (event.key === "Home") {
1139
+ nextIndex = 0;
1140
+ } else if (event.key === "End") {
1141
+ nextIndex = buttons.length - 1;
1142
+ }
1143
+
1144
+ buttons[nextIndex]?.focus({ preventScroll: true });
1145
+ }
1146
+
1147
+ function handleMenuViewportChange() {
1148
+ closeMenu({ hideGrip: true });
1149
+ }
1150
+
1151
+ const finishDragSession = (commit: boolean, event?: MouseEvent) => {
1152
+ const session = dragSession;
1153
+ if (!session) return;
1154
+
1155
+ removeDragListeners();
1156
+
1157
+ if (commit && session.dragging && session.dropTarget) {
1158
+ const sourceStart = session.sourcePos;
1159
+ const sourceEnd = session.sourcePos + session.sourceNodeSize;
1160
+ const target = session.dropTarget;
1161
+ const dropPos = target.pos;
1162
+ const isSideDrop =
1163
+ target.placement === "left" || target.placement === "right";
1164
+
1165
+ if (
1166
+ target.view !== session.view ||
1167
+ (isSideDrop
1168
+ ? // Side drop (column build): proceed for any block that isn't the
1169
+ // source itself, including the source's immediate neighbour.
1170
+ target.targetPos !== sourceStart
1171
+ : dropPos !== sourceStart &&
1172
+ dropPos !== sourceEnd &&
1173
+ !(dropPos > sourceStart && dropPos < sourceEnd))
1174
+ ) {
1175
+ const sourceNode = session.view.state.doc.nodeAt(sourceStart);
1176
+ if (sourceNode) {
1177
+ const sourceRegistration = registrationForView(session.view);
1178
+ const transferData = sourceRegistration?.getDragTransferData?.({
1179
+ view: session.view,
1180
+ node: sourceNode,
1181
+ pos: sourceStart,
1182
+ });
1183
+ const targetNode = target.view.state.doc.nodeAt(target.targetPos);
1184
+ const handled =
1185
+ !!targetNode &&
1186
+ (target.registration.handleDrop?.(transferData, {
1187
+ view: target.view,
1188
+ sourceView: session.view,
1189
+ sourceNode,
1190
+ sourcePos: sourceStart,
1191
+ sourceNodeSize: sourceNode.nodeSize,
1192
+ targetNode,
1193
+ targetPos: target.targetPos,
1194
+ targetNodeSize: target.targetNodeSize,
1195
+ insertPos: dropPos,
1196
+ placement: target.placement,
1197
+ }) ??
1198
+ false);
1199
+
1200
+ if (handled) {
1201
+ target.view.focus();
1202
+ } else if (target.view === session.view) {
1203
+ const insertPos =
1204
+ dropPos > sourceStart ? dropPos - sourceNode.nodeSize : dropPos;
1205
+ const tr = session.view.state.tr
1206
+ .delete(sourceStart, sourceEnd)
1207
+ .insert(insertPos, sourceNode);
1208
+
1209
+ tr.setSelection(NodeSelection.create(tr.doc, insertPos));
1210
+
1211
+ session.view.dispatch(tr.scrollIntoView());
1212
+ session.view.focus();
1213
+ } else {
1214
+ try {
1215
+ const targetNode = target.view.state.schema.nodeFromJSON(
1216
+ sourceNode.toJSON(),
1217
+ );
1218
+ target.registration.receiveDragTransferData?.(transferData, {
1219
+ view: target.view,
1220
+ node: targetNode,
1221
+ pos: dropPos,
1222
+ sourceView: session.view,
1223
+ });
1224
+ const insertTr = target.view.state.tr.insert(
1225
+ dropPos,
1226
+ targetNode,
1227
+ );
1228
+ insertTr.setSelection(
1229
+ NodeSelection.create(insertTr.doc, dropPos),
1230
+ );
1231
+ target.view.dispatch(insertTr.scrollIntoView());
1232
+
1233
+ const deleteTr = session.view.state.tr.delete(
1234
+ sourceStart,
1235
+ sourceEnd,
1236
+ );
1237
+ session.view.dispatch(deleteTr);
1238
+ target.view.focus();
1239
+ } catch {
1240
+ // If the target schema cannot accept this node, leave the
1241
+ // source document untouched.
1242
+ }
1243
+ }
1244
+ }
1245
+ }
1246
+ } else if (commit && !session.dragging && event) {
1247
+ openMenu(
1248
+ {
1249
+ view: session.view,
1250
+ sourceBlock: session.sourceBlock,
1251
+ sourcePos: session.sourcePos,
1252
+ sourceNodeSize: session.sourceNodeSize,
1253
+ },
1254
+ handle?.getBoundingClientRect() ??
1255
+ session.sourceBlock.getBoundingClientRect(),
1256
+ );
1257
+ }
1258
+
1259
+ cleanupDragVisuals();
1260
+ dragSession = null;
1261
+ if (activeDragRegistration === currentRegistration) {
1262
+ activeDragRegistration = null;
1263
+ }
1264
+ if (session.dragging || !commit) hideHandle();
1265
+ };
1266
+
1267
+ const beginDragSession = (session: DragSession, event: MouseEvent) => {
1268
+ session.dragging = true;
1269
+ session.preview = createDragPreview(session.sourceBlock);
1270
+ session.sourceBlock.classList.add("notion-block--dragging");
1271
+ document.documentElement.classList.add("notion-editor-is-dragging");
1272
+ positionDragPreview(session, event.clientX, event.clientY);
1273
+ updateDropLine(
1274
+ session,
1275
+ findAnyDropTarget(session, event.clientX, event.clientY),
1276
+ );
1277
+ };
1278
+
1279
+ function handleDocumentMouseMove(event: MouseEvent) {
1280
+ if (!dragSession) return;
1281
+ event.preventDefault();
1282
+
1283
+ const movedEnough =
1284
+ Math.hypot(
1285
+ event.clientX - dragSession.startX,
1286
+ event.clientY - dragSession.startY,
1287
+ ) > 4;
1288
+
1289
+ if (!dragSession.dragging && movedEnough) {
1290
+ beginDragSession(dragSession, event);
1291
+ }
1292
+
1293
+ if (!dragSession.dragging) return;
1294
+
1295
+ positionDragPreview(dragSession, event.clientX, event.clientY);
1296
+ updateDropLine(
1297
+ dragSession,
1298
+ findAnyDropTarget(dragSession, event.clientX, event.clientY),
1299
+ );
1300
+ }
1301
+
1302
+ function handleDocumentMouseUp(event: MouseEvent) {
1303
+ event.preventDefault();
1304
+ finishDragSession(true, event);
1305
+ }
1306
+
1307
+ function handleDocumentKeyDown(event: KeyboardEvent) {
1308
+ if (event.key !== "Escape") return;
1309
+ event.preventDefault();
1310
+ finishDragSession(false);
1311
+ }
1312
+
1313
+ return [
1314
+ new Plugin({
1315
+ key: dragHandleKey,
1316
+ view(editorView) {
1317
+ const registration: DragHandleRegistration = {
1318
+ view: editorView,
1319
+ wrapperSelector,
1320
+ getDragTransferData,
1321
+ receiveDragTransferData,
1322
+ handleDrop,
1323
+ canHover: () =>
1324
+ !!handle && !menu && !dragSession && editor.isEditable,
1325
+ findHoverBlock: (clientX, clientY) =>
1326
+ findForgivingBlock(editorView, clientX, clientY),
1327
+ showHoverBlock: (block) => showHandleForBlock(editorView, block),
1328
+ hideHover: () => hideHandle(),
1329
+ gripRect: () =>
1330
+ handle && handle.style.display !== "none"
1331
+ ? handle.getBoundingClientRect()
1332
+ : null,
1333
+ };
1334
+ currentRegistration = registration;
1335
+ dragHandleRegistrations.add(registration);
1336
+ retainGlobalHoverListener();
1337
+ handle = createHandle();
1338
+ const wrapper = editorView.dom.closest(wrapperSelector);
1339
+ if (wrapper) {
1340
+ (wrapper as HTMLElement).style.position = "relative";
1341
+ wrapper.appendChild(handle);
1342
+ }
1343
+
1344
+ handle.addEventListener("mousedown", (e) => {
1345
+ e.stopPropagation();
1346
+ if (e.button !== 0) return;
1347
+ closeMenu();
1348
+ if (!editor.isEditable) {
1349
+ e.preventDefault();
1350
+ return;
1351
+ }
1352
+
1353
+ if (!currentBlock || dragStartPos === null) return;
1354
+
1355
+ const sourceNode = editorView.state.doc.nodeAt(dragStartPos);
1356
+ if (!sourceNode) return;
1357
+
1358
+ e.preventDefault();
1359
+ dragSession = {
1360
+ view: editorView,
1361
+ sourceBlock: currentBlock,
1362
+ sourcePos: dragStartPos,
1363
+ sourceNodeSize: sourceNode.nodeSize,
1364
+ startX: e.clientX,
1365
+ startY: e.clientY,
1366
+ dragging: false,
1367
+ preview: null,
1368
+ dropLine: null,
1369
+ dropTarget: null,
1370
+ };
1371
+ activeDragRegistration = registration;
1372
+
1373
+ document.addEventListener("mousemove", handleDocumentMouseMove);
1374
+ document.addEventListener("mouseup", handleDocumentMouseUp);
1375
+ document.addEventListener("keydown", handleDocumentKeyDown);
1376
+ });
1377
+
1378
+ handle.addEventListener("keydown", (e) => {
1379
+ if (e.key !== "Enter" && e.key !== " ") return;
1380
+ e.preventDefault();
1381
+ e.stopPropagation();
1382
+ closeMenu();
1383
+ if (!editor.isEditable || !currentBlock || dragStartPos === null) {
1384
+ return;
1385
+ }
1386
+
1387
+ const sourceNode = editorView.state.doc.nodeAt(dragStartPos);
1388
+ if (!sourceNode) return;
1389
+
1390
+ openMenu(
1391
+ {
1392
+ view: editorView,
1393
+ sourceBlock: currentBlock,
1394
+ sourcePos: dragStartPos,
1395
+ sourceNodeSize: sourceNode.nodeSize,
1396
+ },
1397
+ handle?.getBoundingClientRect() ??
1398
+ currentBlock.getBoundingClientRect(),
1399
+ );
1400
+ });
1401
+
1402
+ return {
1403
+ destroy() {
1404
+ closeMenu({ hideGrip: true });
1405
+ finishDragSession(false);
1406
+ releaseGlobalHoverListener();
1407
+ dragHandleRegistrations.delete(registration);
1408
+ if (activeDragRegistration === registration) {
1409
+ activeDragRegistration = null;
1410
+ }
1411
+ if (activeHoverRegistration === registration) {
1412
+ activeHoverRegistration = null;
1413
+ }
1414
+ handle?.remove();
1415
+ handle = null;
1416
+ currentRegistration = null;
1417
+ },
1418
+ };
1419
+ },
1420
+ props: {
1421
+ handleDOMEvents: {
1422
+ mousemove(_view, event) {
1423
+ updateRegisteredHover(event.clientX, event.clientY);
1424
+ return false;
1425
+ },
1426
+ drop() {
1427
+ closeMenu({ hideGrip: true });
1428
+ finishDragSession(false);
1429
+ hideHandle();
1430
+ return false;
1431
+ },
1432
+ },
1433
+ },
1434
+ }),
1435
+ ];
1436
+ },
1437
+ });