@domternal/extension-block-controls 0.10.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.
@@ -0,0 +1,488 @@
1
+ import { Extension, FloatingMenuOptions, Editor, FloatingMenuItemsOverride, FloatingMenuItem } from '@domternal/core';
2
+ export { CreateFloatingMenuPluginOptions, FloatingMenuKeymap, FloatingMenuOptions, createFloatingMenuPlugin, floatingMenuPluginKey } from '@domternal/core';
3
+ import { PluginKey, Plugin } from '@domternal/pm/state';
4
+ import { Node, ResolvedPos, Attrs } from '@domternal/pm/model';
5
+ import { EditorView } from '@domternal/pm/view';
6
+
7
+ /**
8
+ * FloatingMenu extension: registers the floating-menu plugin under the shared
9
+ * `floatingMenuPluginKey`. The plugin machinery (visibility, positioning,
10
+ * dismiss, keyboard entry) lives in `@domternal/core` so framework wrappers
11
+ * can build their menus without depending on this package; everything is
12
+ * re-exported here for backward compatibility.
13
+ */
14
+
15
+ declare const FloatingMenu: Extension<FloatingMenuOptions, unknown>;
16
+
17
+ /**
18
+ * Gutter-bias resolution for nested drag-target selection.
19
+ *
20
+ * When the cursor sits near a configured edge of a candidate's rect, that
21
+ * candidate counts as "in the gutter" and its rank is reduced proportionally to
22
+ * its depth, so shallower ancestors win against deeper descendants in the gutter
23
+ * zone. "Deepest-match" mode skips this bias and returns the innermost allowed
24
+ * block under the cursor.
25
+ */
26
+ /** Cardinal edge of a candidate's bounding rect. */
27
+ type GutterEdge = 'left' | 'right' | 'top' | 'bottom';
28
+ /**
29
+ * Named presets for the bias config:
30
+ * - `'left'` → ['left', 'top'] (left gutter, default)
31
+ * - `'right'` → ['right', 'top'] (right gutter, RTL-friendly)
32
+ * - `'both'` → ['left', 'right', 'top']
33
+ * - `'none'` → no gutter bias (deepest match wins)
34
+ */
35
+ type GutterBiasPreset = 'left' | 'right' | 'both' | 'none';
36
+ interface GutterBiasConfig {
37
+ /** Edges that constitute the "gutter" zone. */
38
+ edges: GutterEdge[];
39
+ /** Pixel distance from the edge at which the bias activates. */
40
+ threshold: number;
41
+ /** Bias factor applied per depth level when in the gutter. */
42
+ strength: number;
43
+ }
44
+
45
+ /**
46
+ * Predicate-based block matching for nested drag-target resolution. A matcher
47
+ * answers "is this candidate eligible to become the drag target?" with an
48
+ * allow/reject verdict; the resolver filters before ranking. The contract is
49
+ * binary (eligible or not), with rank decided separately by depth + optional
50
+ * gutter bias.
51
+ */
52
+
53
+ /** Eligibility verdict returned by a matcher's `test()`. */
54
+ type MatchVerdict = 'allow' | 'reject';
55
+ /**
56
+ * Information passed to a matcher: the node, its position metadata, parent
57
+ * context, and the live editor view (for DOM lookups).
58
+ */
59
+ interface BlockCandidate {
60
+ /** The PM node being considered as a drag target. */
61
+ block: Node;
62
+ /** Document position immediately before `block`. */
63
+ documentPos: number;
64
+ /** Depth in the PM tree; 0 is the doc root and is never a candidate. */
65
+ treeDepth: number;
66
+ /** Parent node, or `null` when there is no parent. */
67
+ container: Node | null;
68
+ /** Index of `block` inside `container.content`. */
69
+ positionInContainer: number;
70
+ /** Convenience: `positionInContainer === 0`. */
71
+ isFirstChild: boolean;
72
+ /** Convenience: `positionInContainer === container.childCount - 1`. */
73
+ isLastChild: boolean;
74
+ /** The ProseMirror resolved position the resolver started from. */
75
+ resolvedPos: ResolvedPos;
76
+ /** Live editor view; matchers may call `editorView.nodeDOM(pos)`. */
77
+ editorView: EditorView;
78
+ }
79
+ /** A matcher: a stable name (for debugging / opt-out) and a pure predicate. */
80
+ interface BlockMatcher {
81
+ name: string;
82
+ test(candidate: BlockCandidate): MatchVerdict;
83
+ }
84
+
85
+ /**
86
+ * BlockHandle Extension
87
+ *
88
+ * Notion-style gutter handle on the left of each top-level block, shown on
89
+ * hover. Two buttons:
90
+ *
91
+ * 1. `⋮⋮` drag handle (click → opens BlockContextMenu, drag → reorder)
92
+ * 2. `+` insert button (inserts an empty paragraph below; FloatingMenu
93
+ * picks up the empty-line state and auto-shows its insert menu)
94
+ *
95
+ * This plugin owns visibility + drag + context-menu trigger; the menu UI
96
+ * lives in `BlockContextMenu.ts`, which listens for the
97
+ * `dm:block-context-menu-open` event dispatched here.
98
+ *
99
+ * Styles ship via `@domternal/theme` (`_block-handle.scss`). The plugin adds
100
+ * a `dm-editor--has-block-handle` class so the theme can widen `.ProseMirror`
101
+ * padding-left for gutter space inside the `overflow:hidden` wrapper.
102
+ */
103
+
104
+ /** Default list of nodes treated as drag-targetable when `nested: true`. */
105
+ declare const DEFAULT_NESTED_NODES: string[];
106
+ declare const blockHandlePluginKey: PluginKey<BlockHandlePluginState>;
107
+ interface BlockHandleOptions {
108
+ /**
109
+ * Ms to wait before hiding the handle after the mouse leaves the editor, so
110
+ * users can move onto the handle without it vanishing.
111
+ * @default 200
112
+ */
113
+ hideDelay?: number;
114
+ /**
115
+ * Disable drag-to-reorder while still showing the plus/drag buttons
116
+ * (drag becomes a no-op, click opens context menu only).
117
+ * @default false
118
+ */
119
+ disableDrag?: boolean;
120
+ /**
121
+ * Auto-scroll the nearest scrollable ancestor when dragging near the
122
+ * top/bottom edge. Disable if the host app manages its own drag scroll.
123
+ * @default true
124
+ */
125
+ autoScroll?: boolean;
126
+ /**
127
+ * Distance in CSS px from the top/bottom edge that triggers auto-scroll.
128
+ * @default 48
129
+ */
130
+ autoScrollThreshold?: number;
131
+ /**
132
+ * Peak scroll speed in CSS px per frame. Ramps linearly from 0 at the
133
+ * threshold to this value at the edge.
134
+ * @default 18
135
+ */
136
+ autoScrollMaxSpeed?: number;
137
+ /**
138
+ * Whether the handle should resolve to nested block containers (list
139
+ * items, task items, and optionally others) instead of always the
140
+ * top-level block.
141
+ *
142
+ * - `false` - only top-level blocks are hoverable / draggable (default).
143
+ * - `true` - list items and task items resolve individually (Notion behaviour).
144
+ * - object - fine-grained config; see `NestedConfig`.
145
+ *
146
+ * @default false
147
+ */
148
+ nested?: boolean | NestedConfig;
149
+ /**
150
+ * Px threshold from a list item's LEFT edge past which a drop becomes
151
+ * nested-child (dragged block becomes a child of that item) instead of
152
+ * sibling. Mirrors Notion's "drop indented = nested, drop on the marker =
153
+ * sibling" UX. Set to `0` to disable nested-drop (every drop is sibling).
154
+ *
155
+ * X-detection only fires when nested mode is on AND the target is a
156
+ * `listItem`/`taskItem`; other containers stay sibling-only.
157
+ *
158
+ * @default 28
159
+ */
160
+ nestThreshold?: number;
161
+ /**
162
+ * Custom drop indicator that mirrors exactly where a handle-drag lands.
163
+ * Replaces `prosemirror-dropcursor` for handle drags (PM's `posAtCoords` can
164
+ * disagree with our resolver in the gutter / inter-block gap). During a drag
165
+ * the editor gets a `dm-block-handle-dragging` class so the theme can hide
166
+ * the native dropcursor for this drag only; non-handle drags (text selection,
167
+ * file drops) keep it. Set `false` to use the native dropcursor.
168
+ * @default true
169
+ */
170
+ dropIndicator?: boolean;
171
+ }
172
+ /**
173
+ * Configuration for nested resolution. Backwards-compatible with the
174
+ * earlier `{ allowedNodes }` literal - every field is optional.
175
+ */
176
+ interface NestedConfig {
177
+ /**
178
+ * Node type names treated as drag targets when nested mode is on.
179
+ * @default ['listItem', 'taskItem']
180
+ */
181
+ allowedNodes?: string[];
182
+ /**
183
+ * Restrict resolution to nodes that have at least one ancestor of one
184
+ * of these type names. Use to scope nested mode to specific structures
185
+ * (e.g. only inside `table`). Empty / omitted → no restriction.
186
+ */
187
+ allowedContainers?: string[];
188
+ /**
189
+ * "Promote to parent at the gutter": within `threshold` px of a configured
190
+ * edge, a candidate's score drops by `strength * depth`, so a shallower
191
+ * ancestor (e.g. the wrapping list) wins near the boundary.
192
+ *
193
+ * - `false` / `undefined` / `'none'` → deepest match wins.
194
+ * - `true` / `'left'` → defaults: edges `['left','top']`, threshold 12, strength 500.
195
+ * - `'right'` / `'both'` → preset variants.
196
+ * - object → custom config (any field optional, merged over defaults).
197
+ *
198
+ * @default false
199
+ */
200
+ promoteOnEdge?: boolean | GutterBiasPreset | Partial<GutterBiasConfig>;
201
+ /**
202
+ * Append custom block matchers. Default matchers still apply unless
203
+ * `defaultMatchers: false` is also set.
204
+ */
205
+ matchers?: BlockMatcher[];
206
+ /**
207
+ * Set `false` to disable the built-in matchers (firstChildOfListItem,
208
+ * listContainerSkip, tableInternals, inlineNodes). Almost always wanted;
209
+ * opt out only for testing or specialised host editors.
210
+ * @default true
211
+ */
212
+ defaultMatchers?: boolean;
213
+ }
214
+ interface BlockHandlePluginState {
215
+ /** Absolute position of the top-level block currently under the cursor, or null. */
216
+ hoveredPos: number | null;
217
+ /**
218
+ * Source position of the block being dragged (set on dragstart, cleared on
219
+ * dragend). `handleDrop` uses it to tell whether the drop came from our handle.
220
+ */
221
+ draggedFrom: number | null;
222
+ }
223
+ /**
224
+ * Internal, fully-resolved view of `NestedConfig`: plain arrays + optional edge
225
+ * config so the resolver doesn't interpret presets at hover time.
226
+ */
227
+ interface NestedResolution {
228
+ /** Allowed drag targets. Empty array → top-level-only mode. */
229
+ allowedNodes: string[];
230
+ /** Optional ancestor whitelist; empty array → no restriction. */
231
+ allowedContainers: string[];
232
+ /** Gutter bias config; `null` → deepest match wins. */
233
+ gutterBias: GutterBiasConfig | null;
234
+ /** Effective matcher list (defaults + user, or just user when defaults off). */
235
+ matchers: BlockMatcher[];
236
+ }
237
+ interface CreateBlockHandlePluginOptions {
238
+ pluginKey: PluginKey<BlockHandlePluginState>;
239
+ editor: Editor;
240
+ hideDelay: number;
241
+ disableDrag: boolean;
242
+ autoScroll: boolean;
243
+ autoScrollThreshold: number;
244
+ autoScrollMaxSpeed: number;
245
+ nested: NestedResolution;
246
+ dropIndicator: boolean;
247
+ /**
248
+ * Pixel threshold for nested-drop X-detection (see `BlockHandleOptions.nestThreshold`).
249
+ * `0` disables nested-drop.
250
+ */
251
+ nestThreshold: number;
252
+ }
253
+ /**
254
+ * Creates a standalone BlockHandle PM plugin. Exported so framework wrappers can
255
+ * build on it without the Extension factory.
256
+ */
257
+ declare function createBlockHandlePlugin(options: CreateBlockHandlePluginOptions): Plugin<BlockHandlePluginState>;
258
+ declare const BlockHandle: Extension<BlockHandleOptions, unknown>;
259
+
260
+ /**
261
+ * Built-in eligibility matchers. They constrain the drag resolver to
262
+ * user-visible block units (paragraphs, headings, list/task items, images) and
263
+ * exclude structural plumbing (table cells, inline text, list containers whose
264
+ * items are draggable individually). Hosts can disable them with
265
+ * `defaultMatchers: false` and supply their own via `matchers`.
266
+ */
267
+
268
+ declare const DEFAULT_BLOCK_MATCHERS: readonly BlockMatcher[];
269
+
270
+ /**
271
+ * `Mod-Shift-ArrowUp` / `Mod-Shift-ArrowDown` move the top-level block
272
+ * containing the selection. Accessibility companion to BlockHandle drag.
273
+ * Shares `moveBlock` so position math and self-move rejection match.
274
+ */
275
+
276
+ declare const KeyboardReorder: Extension<unknown, unknown>;
277
+
278
+ /** Wrapper-style "Turn into" commands, each from the matching node extension.
279
+ * Lists use the per-item `turnInto*` commands (Notion turn-into: convert only
280
+ * the targeted block and split the run); the whole-list `toggle*List` commands
281
+ * stay reserved for the toolbar button and Mod-Shift shortcut. */
282
+ type WrapperCommand = 'turnIntoBulletList' | 'turnIntoOrderedList' | 'turnIntoTaskList' | 'toggleBlockquote';
283
+
284
+ /**
285
+ * Popup menu opened by clicking the BlockHandle drag handle without dragging.
286
+ * Offers Delete, Duplicate, and Turn into (change block type) for the target.
287
+ *
288
+ * Triggered by the `dm:block-context-menu-open` event from BlockHandle, with
289
+ * payload `{ blockPos, anchorElement }`. Mirrors FloatingMenu / SlashCommand
290
+ * styling: `role="menu"`, `role="menuitem"`, `data-show`, positionFloatingOnce.
291
+ */
292
+
293
+ /**
294
+ * `props.decorations` applies the `dm-block-context-active` class via a PM
295
+ * Decoration (not inline classList) so the highlight survives view rerenders
296
+ * from other transactions, e.g. UniqueID stamping `id` via setNodeMarkup.
297
+ */
298
+ interface BlockContextMenuPluginState {
299
+ activeBlockPos: number | null;
300
+ }
301
+ declare const blockContextMenuPluginKey: PluginKey<BlockContextMenuPluginState>;
302
+ /** A block type offered by the "Turn into" submenu. */
303
+ interface TurnIntoTarget {
304
+ /** Display label, e.g. "Heading 1". */
305
+ label: string;
306
+ /** Icon key resolved against `defaultIcons`. */
307
+ icon: string;
308
+ /** Schema node name, e.g. "heading", "paragraph", "blockquote". */
309
+ nodeType: string;
310
+ /** Optional node attributes (e.g. `{ level: 1 }` for Heading 1). */
311
+ attrs?: Attrs;
312
+ /**
313
+ * Command for wrapper (non-textblock) targets like lists and blockquote.
314
+ * When set, runTurnInto routes through `turnIntoWrapper` instead of
315
+ * `setBlockType`. Leave undefined for textblock targets (paragraph,
316
+ * heading, codeBlock).
317
+ */
318
+ command?: WrapperCommand;
319
+ }
320
+ interface BlockContextMenuOptions {
321
+ /**
322
+ * Show the "Turn into" section of the menu. Disable to limit operations
323
+ * to Delete + Duplicate.
324
+ * @default true
325
+ */
326
+ turnIntoEnabled?: boolean;
327
+ /**
328
+ * Block types offered by "Turn into". Override to curate the list or
329
+ * add project-specific block types.
330
+ * @default DEFAULT_TURN_INTO
331
+ */
332
+ turnIntoTargets?: TurnIntoTarget[];
333
+ /**
334
+ * Show "Copy link" when the target block has an id attribute AND the
335
+ * `UniqueID` extension is loaded. No effect without UniqueID.
336
+ * @default true
337
+ */
338
+ copyLinkEnabled?: boolean;
339
+ /**
340
+ * Builds the URL written to the clipboard on "Copy link". Default appends
341
+ * `#<id>` to the current pathname+search (works for static pages); apps
342
+ * with client-side routing should provide a callback matching their scheme.
343
+ */
344
+ onCopyLink?: (blockId: string, editor: Editor) => string;
345
+ /**
346
+ * Show the Colors section (text + background) when the `BlockColor`
347
+ * extension is loaded and the target block is in its `types` list.
348
+ * @default true
349
+ */
350
+ blockColorEnabled?: boolean;
351
+ }
352
+ interface CreateBlockContextMenuPluginOptions {
353
+ pluginKey: PluginKey<BlockContextMenuPluginState>;
354
+ editor: Editor;
355
+ turnIntoEnabled: boolean;
356
+ turnIntoTargets: TurnIntoTarget[];
357
+ copyLinkEnabled: boolean;
358
+ onCopyLink: (blockId: string, editor: Editor) => string;
359
+ blockColorEnabled: boolean;
360
+ }
361
+ /**
362
+ * Builds the popup DOM, listens for `dm:block-context-menu-open` on
363
+ * `.dm-editor`, and runs block operations via `helpers/blockOperations.ts`.
364
+ */
365
+ declare function createBlockContextMenuPlugin(options: CreateBlockContextMenuPluginOptions): Plugin;
366
+ declare const BlockContextMenu: Extension<BlockContextMenuOptions, unknown>;
367
+
368
+ /**
369
+ * `/` trigger that opens a filtered popup of insertable blocks. Items are
370
+ * shared with FloatingMenu and BlockHandle via `addFloatingMenuItems()`.
371
+ * On select, the `/query` range is deleted and the item's command runs at
372
+ * the now-empty cursor (so "Heading 1" transforms the block, "Image" opens
373
+ * its popover, etc.).
374
+ */
375
+
376
+ declare const slashCommandPluginKey: PluginKey<SlashCommandPluginState>;
377
+ interface SlashCommandProps {
378
+ /** The editor instance (passed so custom renderers can read state). */
379
+ editor: Editor;
380
+ /** Current query string (text after the `/`). */
381
+ query: string;
382
+ /** Document range of the `/` + query (for replacement). */
383
+ range: {
384
+ from: number;
385
+ to: number;
386
+ };
387
+ /** Filtered and ranked items matching the query. */
388
+ items: FloatingMenuItem[];
389
+ /** Execute the selected item and close the popup. */
390
+ command: (item: FloatingMenuItem) => void;
391
+ /** Returns the client rect of the cursor for positioning the popup. */
392
+ clientRect: () => DOMRect | null;
393
+ /** The editor's ProseMirror DOM node (for portal parents etc.). */
394
+ element: HTMLElement;
395
+ }
396
+ interface SlashCommandRenderer {
397
+ onStart: (props: SlashCommandProps) => void;
398
+ onUpdate: (props: SlashCommandProps) => void;
399
+ onExit: () => void;
400
+ /** Return `true` to consume the key event and prevent default handling. */
401
+ onKeyDown: (event: KeyboardEvent) => boolean;
402
+ }
403
+ interface SlashCommandOptions {
404
+ /**
405
+ * The trigger character. @default '/'
406
+ */
407
+ char?: string;
408
+ /**
409
+ * Items override. When omitted, items from `editor.floatingMenuItems`
410
+ * (collected via `addFloatingMenuItems()`) are used. An array replaces
411
+ * defaults; a function transforms them.
412
+ */
413
+ items?: FloatingMenuItemsOverride;
414
+ /**
415
+ * Factory returning render callbacks for the popup. Default uses
416
+ * `createSlashSuggestionRenderer()`.
417
+ */
418
+ render?: () => SlashCommandRenderer;
419
+ /**
420
+ * Node types where slash should NOT activate (e.g. `codeBlock`).
421
+ * @default ['codeBlock']
422
+ */
423
+ invalidNodes?: string[];
424
+ }
425
+ interface CreateSlashCommandPluginOptions {
426
+ pluginKey: PluginKey<SlashCommandPluginState>;
427
+ editor: Editor;
428
+ char: string;
429
+ items?: FloatingMenuItemsOverride;
430
+ render: () => SlashCommandRenderer;
431
+ invalidNodes: string[];
432
+ }
433
+ interface SlashCommandPluginState {
434
+ active: boolean;
435
+ query: string;
436
+ range: {
437
+ from: number;
438
+ to: number;
439
+ } | null;
440
+ }
441
+ /**
442
+ * Filters and ranks FloatingMenuItems against a query. Priority:
443
+ * 1. Exact label prefix (case-insensitive)
444
+ * 2. Label substring match
445
+ * 3. Keyword match (preserving original keyword index for stable ranking)
446
+ * Returns items in rank order, stable within the same rank.
447
+ */
448
+ declare function filterSlashItems(items: FloatingMenuItem[], query: string): FloatingMenuItem[];
449
+ declare function createSlashCommandPlugin(options: CreateSlashCommandPluginOptions): Plugin<SlashCommandPluginState>;
450
+ declare const SlashCommand: Extension<SlashCommandOptions, unknown>;
451
+ /** Programmatically dismisses the slash suggestion. */
452
+ declare function dismissSlashCommand(view: EditorView): void;
453
+
454
+ declare function createSlashSuggestionRenderer(): SlashCommandRenderer;
455
+
456
+ /**
457
+ * Pasting block-level content at an INLINE position normally goes through PM's
458
+ * content fitter, which strips the block wrapper and pastes only inline text.
459
+ * SmartPaste catches the relevant cases and routes each to the right strategy:
460
+ *
461
+ * 1. List slice into a list ancestor: same-kind items merge as siblings; a
462
+ * different-kind list keeps its kind and splits the host list around it.
463
+ * 2. Trailing hardBreak (Shift+Enter): trim the hardBreak, insert as sibling.
464
+ * 3. Truly empty parent paragraph (`parentSize === 0`): replace the parent.
465
+ * 4-6. Caret at start / end / middle: insert as sibling or split-and-insert.
466
+ * 7. Range selection: delete first, then run through 2-6.
467
+ *
468
+ * Skipped (PM default applies) when: cursor isn't in a textblock; the slice's
469
+ * top-level blocks are ALL plain paragraphs; or the slice is a SINGLE top-level
470
+ * block of the SAME TYPE as the destination (heading-into-heading, etc.) where
471
+ * PM's inline merge is what the user wants.
472
+ *
473
+ * Do NOT bail on `openStart > 0`: PM's clipboard parser routinely sets
474
+ * `openStart=1` even for closed-looking input like `<h1>x</h1>`. Top-level
475
+ * children of the slice are what matter.
476
+ */
477
+
478
+ interface SmartPasteOptions {
479
+ /**
480
+ * Disable the plugin without removing the extension (falls back to PM's
481
+ * default paste handling).
482
+ * @default true
483
+ */
484
+ enabled?: boolean;
485
+ }
486
+ declare const SmartPaste: Extension<SmartPasteOptions, unknown>;
487
+
488
+ export { type BlockCandidate, BlockContextMenu, type BlockContextMenuOptions, BlockHandle, type BlockHandleOptions, type BlockHandlePluginState, type BlockMatcher, type CreateBlockContextMenuPluginOptions, type CreateBlockHandlePluginOptions, type CreateSlashCommandPluginOptions, DEFAULT_BLOCK_MATCHERS, DEFAULT_NESTED_NODES, FloatingMenu, KeyboardReorder, type MatchVerdict, type NestedConfig, SlashCommand, type SlashCommandOptions, type SlashCommandPluginState, type SlashCommandProps, type SlashCommandRenderer, SmartPaste, type SmartPasteOptions, type TurnIntoTarget, type WrapperCommand, blockContextMenuPluginKey, blockHandlePluginKey, createBlockContextMenuPlugin, createBlockHandlePlugin, createSlashCommandPlugin, createSlashSuggestionRenderer, dismissSlashCommand, filterSlashItems, slashCommandPluginKey };