@blocknote/core 0.11.2 → 0.12.1

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 (138) hide show
  1. package/README.md +13 -17
  2. package/dist/blocknote.js +1662 -1447
  3. package/dist/blocknote.js.map +1 -1
  4. package/dist/blocknote.umd.cjs +6 -6
  5. package/dist/blocknote.umd.cjs.map +1 -1
  6. package/dist/style.css +1 -1
  7. package/dist/webpack-stats.json +1 -1
  8. package/package.json +7 -3
  9. package/src/api/blockManipulation/blockManipulation.test.ts +19 -15
  10. package/src/api/blockManipulation/blockManipulation.ts +107 -17
  11. package/src/api/exporters/html/externalHTMLExporter.ts +3 -7
  12. package/src/api/exporters/html/htmlConversion.test.ts +6 -3
  13. package/src/api/exporters/html/internalHTMLSerializer.ts +3 -7
  14. package/src/api/exporters/html/util/sharedHTMLConversion.ts +3 -3
  15. package/src/api/exporters/markdown/markdownExporter.test.ts +7 -3
  16. package/src/api/exporters/markdown/markdownExporter.ts +2 -6
  17. package/src/api/getCurrentBlockContentType.ts +14 -0
  18. package/src/api/nodeConversions/nodeConversions.test.ts +14 -7
  19. package/src/api/nodeConversions/nodeConversions.ts +1 -2
  20. package/src/api/parsers/html/parseHTML.test.ts +5 -1
  21. package/src/api/parsers/html/parseHTML.ts +2 -6
  22. package/src/api/parsers/html/util/nestedLists.ts +11 -1
  23. package/src/api/parsers/markdown/parseMarkdown.test.ts +3 -0
  24. package/src/api/parsers/markdown/parseMarkdown.ts +2 -6
  25. package/src/api/testUtil/cases/customBlocks.ts +18 -16
  26. package/src/api/testUtil/cases/customInlineContent.ts +12 -13
  27. package/src/api/testUtil/cases/customStyles.ts +12 -10
  28. package/src/api/testUtil/index.ts +4 -2
  29. package/src/api/testUtil/partialBlockTestUtil.ts +2 -6
  30. package/src/blocks/HeadingBlockContent/HeadingBlockContent.ts +50 -21
  31. package/src/blocks/ImageBlockContent/ImageBlockContent.ts +1 -2
  32. package/src/blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts +8 -1
  33. package/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +18 -5
  34. package/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts +7 -1
  35. package/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +18 -5
  36. package/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts +14 -5
  37. package/src/blocks/defaultBlockHelpers.ts +3 -3
  38. package/src/blocks/defaultBlockTypeGuards.ts +84 -0
  39. package/src/blocks/defaultBlocks.ts +29 -3
  40. package/src/editor/Block.css +2 -31
  41. package/src/editor/BlockNoteEditor.ts +223 -267
  42. package/src/editor/BlockNoteExtensions.ts +5 -2
  43. package/src/editor/BlockNoteSchema.ts +98 -0
  44. package/src/editor/BlockNoteTipTapEditor.ts +162 -0
  45. package/src/editor/cursorPositionTypes.ts +2 -6
  46. package/src/editor/editor.css +0 -1
  47. package/src/editor/selectionTypes.ts +2 -6
  48. package/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +22 -29
  49. package/src/extensions/{ImageToolbar → ImagePanel}/ImageToolbarPlugin.ts +54 -60
  50. package/src/extensions/LinkToolbar/LinkToolbarPlugin.ts +330 -0
  51. package/src/extensions/Placeholder/PlaceholderExtension.ts +81 -88
  52. package/src/extensions/SideMenu/SideMenuPlugin.ts +55 -56
  53. package/src/extensions/SuggestionMenu/DefaultSuggestionItem.ts +8 -0
  54. package/src/extensions/SuggestionMenu/SuggestionPlugin.ts +353 -0
  55. package/src/extensions/{SlashMenu/defaultSlashMenuItems.ts → SuggestionMenu/getDefaultSlashMenuItems.ts} +119 -89
  56. package/src/extensions/TableHandles/TableHandlesPlugin.ts +62 -45
  57. package/src/extensions-shared/UiElementPosition.ts +4 -0
  58. package/src/index.ts +8 -8
  59. package/src/pm-nodes/BlockContainer.ts +5 -5
  60. package/src/schema/blocks/types.ts +15 -15
  61. package/src/schema/inlineContent/createSpec.ts +2 -2
  62. package/src/schema/inlineContent/types.ts +1 -1
  63. package/src/util/browser.ts +6 -4
  64. package/src/util/typescript.ts +7 -4
  65. package/types/src/api/blockManipulation/blockManipulation.d.ts +6 -1
  66. package/types/src/api/exporters/html/externalHTMLExporter.d.ts +2 -1
  67. package/types/src/api/exporters/html/internalHTMLSerializer.d.ts +2 -1
  68. package/types/src/api/exporters/markdown/markdownExporter.d.ts +2 -1
  69. package/types/src/api/getCurrentBlockContentType.d.ts +2 -0
  70. package/types/src/api/nodeConversions/nodeConversions.d.ts +2 -1
  71. package/types/src/api/parsers/html/parseHTML.d.ts +2 -1
  72. package/types/src/api/parsers/markdown/parseMarkdown.d.ts +2 -1
  73. package/types/src/api/testUtil/cases/customBlocks.d.ts +72 -13
  74. package/types/src/api/testUtil/cases/customInlineContent.d.ts +281 -6
  75. package/types/src/api/testUtil/cases/customStyles.d.ts +247 -13
  76. package/types/src/api/testUtil/index.d.ts +4 -2
  77. package/types/src/api/testUtil/partialBlockTestUtil.d.ts +2 -1
  78. package/types/src/blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.d.ts +6 -1
  79. package/types/src/blocks/defaultBlockHelpers.d.ts +2 -2
  80. package/types/src/blocks/defaultBlockTypeGuards.d.ts +24 -0
  81. package/types/src/blocks/defaultBlocks.d.ts +21 -15
  82. package/types/src/editor/BlockNoteEditor.d.ts +51 -56
  83. package/types/src/editor/BlockNoteExtensions.d.ts +1 -0
  84. package/types/src/editor/BlockNoteSchema.d.ts +34 -0
  85. package/types/src/editor/BlockNoteTipTapEditor.d.ts +28 -0
  86. package/types/src/editor/cursorPositionTypes.d.ts +2 -1
  87. package/types/src/editor/selectionTypes.d.ts +2 -1
  88. package/types/src/extensions/FormattingToolbar/FormattingToolbarPlugin.d.ts +5 -6
  89. package/types/src/extensions/ImagePanel/ImageToolbarPlugin.d.ts +32 -0
  90. package/types/src/extensions/LinkToolbar/LinkToolbarPlugin.d.ts +40 -0
  91. package/types/src/extensions/Placeholder/PlaceholderExtension.d.ts +2 -15
  92. package/types/src/extensions/SideMenu/SideMenuPlugin.d.ts +8 -7
  93. package/types/src/extensions/SuggestionMenu/DefaultSuggestionItem.d.ts +8 -0
  94. package/types/src/extensions/SuggestionMenu/SuggestionPlugin.d.ts +31 -0
  95. package/types/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.d.ts +10 -0
  96. package/types/src/extensions/TableHandles/TableHandlesPlugin.d.ts +7 -7
  97. package/types/src/extensions-shared/UiElementPosition.d.ts +4 -0
  98. package/types/src/index.d.ts +8 -8
  99. package/types/src/pm-nodes/BlockContainer.d.ts +3 -2
  100. package/types/src/pm-nodes/BlockGroup.d.ts +1 -1
  101. package/types/src/schema/blocks/types.d.ts +15 -15
  102. package/types/src/schema/inlineContent/types.d.ts +1 -1
  103. package/types/src/util/browser.d.ts +1 -0
  104. package/types/src/util/typescript.d.ts +1 -0
  105. package/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts +0 -335
  106. package/src/extensions/SlashMenu/BaseSlashMenuItem.ts +0 -12
  107. package/src/extensions/SlashMenu/SlashMenuPlugin.ts +0 -53
  108. package/src/extensions-shared/BaseUiElementTypes.ts +0 -8
  109. package/src/extensions-shared/README.md +0 -3
  110. package/src/extensions-shared/suggestion/SuggestionItem.ts +0 -3
  111. package/src/extensions-shared/suggestion/SuggestionPlugin.ts +0 -448
  112. package/types/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.d.ts +0 -38
  113. package/types/src/extensions/ImageToolbar/ImageToolbarPlugin.d.ts +0 -31
  114. package/types/src/extensions/SlashMenu/BaseSlashMenuItem.d.ts +0 -7
  115. package/types/src/extensions/SlashMenu/SlashMenuPlugin.d.ts +0 -13
  116. package/types/src/extensions/SlashMenu/defaultSlashMenuItems.d.ts +0 -3
  117. package/types/src/extensions-shared/BaseUiElementTypes.d.ts +0 -7
  118. package/types/src/extensions-shared/suggestion/SuggestionItem.d.ts +0 -3
  119. package/types/src/extensions-shared/suggestion/SuggestionPlugin.d.ts +0 -36
  120. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-100.woff +0 -0
  121. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-100.woff2 +0 -0
  122. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-200.woff +0 -0
  123. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-200.woff2 +0 -0
  124. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-300.woff +0 -0
  125. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-300.woff2 +0 -0
  126. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-500.woff +0 -0
  127. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-500.woff2 +0 -0
  128. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-600.woff +0 -0
  129. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-600.woff2 +0 -0
  130. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-700.woff +0 -0
  131. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-700.woff2 +0 -0
  132. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-800.woff +0 -0
  133. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-800.woff2 +0 -0
  134. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-900.woff +0 -0
  135. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-900.woff2 +0 -0
  136. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-regular.woff +0 -0
  137. /package/src/{assets → fonts}/inter-v12-latin/inter-v12-latin-regular.woff2 +0 -0
  138. /package/src/{assets/fonts-inter.css → fonts/inter.css} +0 -0
@@ -2,20 +2,17 @@ import { PluginView } from "@tiptap/pm/state";
2
2
  import { Node } from "prosemirror-model";
3
3
  import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state";
4
4
  import { EditorView } from "prosemirror-view";
5
+
5
6
  import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter";
6
7
  import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer";
7
8
  import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter";
8
9
  import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos";
10
+ import { Block } from "../../blocks/defaultBlocks";
9
11
  import type { BlockNoteEditor } from "../../editor/BlockNoteEditor";
10
- import { BaseUiElementState } from "../../extensions-shared/BaseUiElementTypes";
11
- import {
12
- Block,
13
- BlockSchema,
14
- InlineContentSchema,
15
- StyleSchema,
16
- } from "../../schema";
12
+ import { UiElementPosition } from "../../extensions-shared/UiElementPosition";
13
+ import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema";
17
14
  import { EventEmitter } from "../../util/EventEmitter";
18
- import { slashMenuPluginKey } from "../SlashMenu/SlashMenuPlugin";
15
+ import { suggestionMenuPluginKey } from "../SuggestionMenu/SuggestionPlugin";
19
16
  import { MultipleNodeSelection } from "./MultipleNodeSelection";
20
17
 
21
18
  let dragImageElement: Element | undefined;
@@ -24,7 +21,7 @@ export type SideMenuState<
24
21
  BSchema extends BlockSchema,
25
22
  I extends InlineContentSchema,
26
23
  S extends StyleSchema
27
- > = BaseUiElementState & {
24
+ > = UiElementPosition & {
28
25
  // The block that the side menu is attached to.
29
26
  block: Block<BSchema, I, S>;
30
27
  };
@@ -255,7 +252,8 @@ export class SideMenuView<
255
252
  S extends StyleSchema
256
253
  > implements PluginView
257
254
  {
258
- private sideMenuState?: SideMenuState<BSchema, I, S>;
255
+ private state?: SideMenuState<BSchema, I, S>;
256
+ private readonly emitUpdate: (state: SideMenuState<BSchema, I, S>) => void;
259
257
 
260
258
  // When true, the drag handle with be anchored at the same level as root elements
261
259
  // When false, the drag handle with be just to the left of the element
@@ -273,10 +271,16 @@ export class SideMenuView<
273
271
  constructor(
274
272
  private readonly editor: BlockNoteEditor<BSchema, I, S>,
275
273
  private readonly pmView: EditorView,
276
- private readonly updateSideMenu: (
277
- sideMenuState: SideMenuState<BSchema, I, S>
278
- ) => void
274
+ emitUpdate: (state: SideMenuState<BSchema, I, S>) => void
279
275
  ) {
276
+ this.emitUpdate = () => {
277
+ if (!this.state) {
278
+ throw new Error("Attempting to update uninitialized side menu");
279
+ }
280
+
281
+ emitUpdate(this.state);
282
+ };
283
+
280
284
  this.horizontalPosAnchoredAtRoot = true;
281
285
  this.horizontalPosAnchor = (
282
286
  this.pmView.dom.firstChild! as HTMLElement
@@ -369,17 +373,17 @@ export class SideMenuView<
369
373
  };
370
374
 
371
375
  onKeyDown = (_event: KeyboardEvent) => {
372
- if (this.sideMenuState?.show) {
373
- this.sideMenuState.show = false;
374
- this.updateSideMenu(this.sideMenuState);
376
+ if (this.state?.show) {
377
+ this.state.show = false;
378
+ this.emitUpdate(this.state);
375
379
  }
376
380
  this.menuFrozen = false;
377
381
  };
378
382
 
379
383
  onMouseDown = (_event: MouseEvent) => {
380
- if (this.sideMenuState && !this.sideMenuState.show) {
381
- this.sideMenuState.show = true;
382
- this.updateSideMenu(this.sideMenuState);
384
+ if (this.state && !this.state.show) {
385
+ this.state.show = true;
386
+ this.emitUpdate(this.state);
383
387
  }
384
388
  this.menuFrozen = false;
385
389
  };
@@ -421,9 +425,9 @@ export class SideMenuView<
421
425
  editorWrapper.contains(event.target as HTMLElement)
422
426
  )
423
427
  ) {
424
- if (this.sideMenuState?.show) {
425
- this.sideMenuState.show = false;
426
- this.updateSideMenu(this.sideMenuState);
428
+ if (this.state?.show) {
429
+ this.state.show = false;
430
+ this.emitUpdate(this.state);
427
431
  }
428
432
 
429
433
  return;
@@ -440,9 +444,9 @@ export class SideMenuView<
440
444
 
441
445
  // Closes the menu if the mouse cursor is beyond the editor vertically.
442
446
  if (!block || !this.editor.isEditable) {
443
- if (this.sideMenuState?.show) {
444
- this.sideMenuState.show = false;
445
- this.updateSideMenu(this.sideMenuState);
447
+ if (this.state?.show) {
448
+ this.state.show = false;
449
+ this.emitUpdate(this.state);
446
450
  }
447
451
 
448
452
  return;
@@ -450,7 +454,7 @@ export class SideMenuView<
450
454
 
451
455
  // Doesn't update if the menu is already open and the mouse cursor is still hovering the same block.
452
456
  if (
453
- this.sideMenuState?.show &&
457
+ this.state?.show &&
454
458
  this.hoveredBlock?.hasAttribute("data-id") &&
455
459
  this.hoveredBlock?.getAttribute("data-id") === block.id
456
460
  ) {
@@ -470,7 +474,7 @@ export class SideMenuView<
470
474
  if (this.editor.isEditable) {
471
475
  const blockContentBoundingBox = blockContent.getBoundingClientRect();
472
476
 
473
- this.sideMenuState = {
477
+ this.state = {
474
478
  show: true,
475
479
  referencePos: new DOMRect(
476
480
  this.horizontalPosAnchoredAtRoot
@@ -485,16 +489,16 @@ export class SideMenuView<
485
489
  )!,
486
490
  };
487
491
 
488
- this.updateSideMenu(this.sideMenuState);
492
+ this.emitUpdate(this.state);
489
493
  }
490
494
  };
491
495
 
492
496
  onScroll = () => {
493
- if (this.sideMenuState?.show) {
497
+ if (this.state?.show) {
494
498
  const blockContent = this.hoveredBlock!.firstChild as HTMLElement;
495
499
  const blockContentBoundingBox = blockContent.getBoundingClientRect();
496
500
 
497
- this.sideMenuState.referencePos = new DOMRect(
501
+ this.state.referencePos = new DOMRect(
498
502
  this.horizontalPosAnchoredAtRoot
499
503
  ? this.horizontalPosAnchor
500
504
  : blockContentBoundingBox.x,
@@ -502,16 +506,16 @@ export class SideMenuView<
502
506
  blockContentBoundingBox.width,
503
507
  blockContentBoundingBox.height
504
508
  );
505
- this.updateSideMenu(this.sideMenuState);
509
+ this.emitUpdate(this.state);
506
510
  }
507
511
  };
508
512
 
509
513
  destroy() {
510
- if (this.sideMenuState?.show) {
511
- this.sideMenuState.show = false;
512
- this.updateSideMenu(this.sideMenuState);
514
+ if (this.state?.show) {
515
+ this.state.show = false;
516
+ this.emitUpdate(this.state);
513
517
  }
514
- document.body.removeEventListener("mousemove", this.onMouseMove);
518
+ document.body.removeEventListener("mousemove", this.onMouseMove, true);
515
519
  document.body.removeEventListener("dragover", this.onDragOver);
516
520
  this.pmView.dom.removeEventListener("dragstart", this.onDragStart);
517
521
  document.body.removeEventListener("drop", this.onDrop, true);
@@ -521,9 +525,9 @@ export class SideMenuView<
521
525
  }
522
526
 
523
527
  addBlock() {
524
- if (this.sideMenuState?.show) {
525
- this.sideMenuState.show = false;
526
- this.updateSideMenu(this.sideMenuState);
528
+ if (this.state?.show) {
529
+ this.state.show = false;
530
+ this.emitUpdate(this.state);
527
531
  }
528
532
 
529
533
  this.menuFrozen = true;
@@ -560,7 +564,7 @@ export class SideMenuView<
560
564
  this.editor._tiptapEditor
561
565
  .chain()
562
566
  .BNCreateBlock(newBlockInsertionPos)
563
- .BNUpdateBlock(newBlockContentPos, { type: "paragraph", props: {} })
567
+ // .BNUpdateBlock(newBlockContentPos, { type: "paragraph", props: {} })
564
568
  .setTextSelection(newBlockContentPos)
565
569
  .run();
566
570
  } else {
@@ -570,10 +574,9 @@ export class SideMenuView<
570
574
  // Focuses and activates the suggestion menu.
571
575
  this.pmView.focus();
572
576
  this.pmView.dispatch(
573
- this.pmView.state.tr.scrollIntoView().setMeta(slashMenuPluginKey, {
574
- // TODO import suggestion plugin key
575
- activate: true,
576
- type: "drag",
577
+ this.pmView.state.tr.scrollIntoView().setMeta(suggestionMenuPluginKey, {
578
+ triggerCharacter: "/",
579
+ fromUserInput: false,
577
580
  })
578
581
  );
579
582
  }
@@ -586,7 +589,7 @@ export class SideMenuProsemirrorPlugin<
586
589
  I extends InlineContentSchema,
587
590
  S extends StyleSchema
588
591
  > extends EventEmitter<any> {
589
- private sideMenuView: SideMenuView<BSchema, I, S> | undefined;
592
+ public view: SideMenuView<BSchema, I, S> | undefined;
590
593
  public readonly plugin: Plugin;
591
594
 
592
595
  constructor(private readonly editor: BlockNoteEditor<BSchema, I, S>) {
@@ -594,14 +597,10 @@ export class SideMenuProsemirrorPlugin<
594
597
  this.plugin = new Plugin({
595
598
  key: sideMenuPluginKey,
596
599
  view: (editorView) => {
597
- this.sideMenuView = new SideMenuView(
598
- editor,
599
- editorView,
600
- (sideMenuState) => {
601
- this.emit("update", sideMenuState);
602
- }
603
- );
604
- return this.sideMenuView;
600
+ this.view = new SideMenuView(editor, editorView, (state) => {
601
+ this.emit("update", state);
602
+ });
603
+ return this.view;
605
604
  },
606
605
  });
607
606
  }
@@ -614,7 +613,7 @@ export class SideMenuProsemirrorPlugin<
614
613
  * If the block is empty, opens the slash menu. If the block has content,
615
614
  * creates a new block below and opens the slash menu in it.
616
615
  */
617
- addBlock = () => this.sideMenuView!.addBlock();
616
+ addBlock = () => this.view!.addBlock();
618
617
 
619
618
  /**
620
619
  * Handles drag & drop events for blocks.
@@ -623,7 +622,7 @@ export class SideMenuProsemirrorPlugin<
623
622
  dataTransfer: DataTransfer | null;
624
623
  clientY: number;
625
624
  }) => {
626
- this.sideMenuView!.isDragging = true;
625
+ this.view!.isDragging = true;
627
626
  dragStart(event, this.editor);
628
627
  };
629
628
 
@@ -636,11 +635,11 @@ export class SideMenuProsemirrorPlugin<
636
635
  * attached to the same block regardless of which block is hovered by the
637
636
  * mouse cursor.
638
637
  */
639
- freezeMenu = () => (this.sideMenuView!.menuFrozen = true);
638
+ freezeMenu = () => (this.view!.menuFrozen = true);
640
639
  /**
641
640
  * Unfreezes the side menu. When frozen, the side menu will stay
642
641
  * attached to the same block regardless of which block is hovered by the
643
642
  * mouse cursor.
644
643
  */
645
- unfreezeMenu = () => (this.sideMenuView!.menuFrozen = false);
644
+ unfreezeMenu = () => (this.view!.menuFrozen = false);
646
645
  }
@@ -0,0 +1,8 @@
1
+ export type DefaultSuggestionItem = {
2
+ title: string;
3
+ onItemClick: () => void;
4
+ subtext?: string;
5
+ badge?: string;
6
+ aliases?: string[];
7
+ group?: string;
8
+ };
@@ -0,0 +1,353 @@
1
+ import { findParentNode } from "@tiptap/core";
2
+ import { EditorState, Plugin, PluginKey } from "prosemirror-state";
3
+ import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
4
+
5
+ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor";
6
+ import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema";
7
+ import { UiElementPosition } from "../../extensions-shared/UiElementPosition";
8
+ import { EventEmitter } from "../../util/EventEmitter";
9
+
10
+ const findBlock = findParentNode((node) => node.type.name === "blockContainer");
11
+
12
+ export type SuggestionMenuState = UiElementPosition & {
13
+ query: string;
14
+ };
15
+
16
+ class SuggestionMenuView<
17
+ BSchema extends BlockSchema,
18
+ I extends InlineContentSchema,
19
+ S extends StyleSchema
20
+ > {
21
+ private state?: SuggestionMenuState;
22
+ public emitUpdate: (triggerCharacter: string) => void;
23
+
24
+ pluginState: SuggestionPluginState;
25
+
26
+ constructor(
27
+ private readonly editor: BlockNoteEditor<BSchema, I, S>,
28
+ emitUpdate: (menuName: string, state: SuggestionMenuState) => void
29
+ ) {
30
+ this.pluginState = undefined;
31
+
32
+ this.emitUpdate = (menuName: string) => {
33
+ if (!this.state) {
34
+ throw new Error("Attempting to update uninitialized suggestions menu");
35
+ }
36
+
37
+ emitUpdate(menuName, this.state);
38
+ };
39
+
40
+ document.addEventListener("scroll", this.handleScroll);
41
+ }
42
+
43
+ handleScroll = () => {
44
+ if (this.state?.show) {
45
+ const decorationNode = document.querySelector(
46
+ `[data-decoration-id="${this.pluginState!.decorationId}"]`
47
+ );
48
+ this.state.referencePos = decorationNode!.getBoundingClientRect();
49
+ this.emitUpdate(this.pluginState!.triggerCharacter!);
50
+ }
51
+ };
52
+
53
+ update(view: EditorView, prevState: EditorState) {
54
+ const prev: SuggestionPluginState =
55
+ suggestionMenuPluginKey.getState(prevState);
56
+ const next: SuggestionPluginState = suggestionMenuPluginKey.getState(
57
+ view.state
58
+ );
59
+
60
+ // See how the state changed
61
+ const started = prev === undefined && next !== undefined;
62
+ const stopped = prev !== undefined && next === undefined;
63
+ const changed = prev !== undefined && next !== undefined;
64
+
65
+ // Cancel when suggestion isn't active
66
+ if (!started && !changed && !stopped) {
67
+ return;
68
+ }
69
+
70
+ this.pluginState = stopped ? prev : next;
71
+
72
+ if (stopped || !this.editor.isEditable) {
73
+ this.state!.show = false;
74
+ this.emitUpdate(this.pluginState!.triggerCharacter);
75
+
76
+ return;
77
+ }
78
+
79
+ const decorationNode = document.querySelector(
80
+ `[data-decoration-id="${this.pluginState!.decorationId}"]`
81
+ );
82
+
83
+ if (this.editor.isEditable) {
84
+ this.state = {
85
+ show: true,
86
+ referencePos: decorationNode!.getBoundingClientRect(),
87
+ query: this.pluginState!.query,
88
+ };
89
+
90
+ this.emitUpdate(this.pluginState!.triggerCharacter!);
91
+ }
92
+ }
93
+
94
+ destroy() {
95
+ document.removeEventListener("scroll", this.handleScroll);
96
+ }
97
+
98
+ closeMenu = () => {
99
+ this.editor._tiptapEditor.view.dispatch(
100
+ this.editor._tiptapEditor.view.state.tr.setMeta(
101
+ suggestionMenuPluginKey,
102
+ null
103
+ )
104
+ );
105
+ };
106
+
107
+ clearQuery = () => {
108
+ if (this.pluginState === undefined) {
109
+ return;
110
+ }
111
+
112
+ this.editor._tiptapEditor
113
+ .chain()
114
+ .focus()
115
+ .deleteRange({
116
+ from:
117
+ this.pluginState.queryStartPos! -
118
+ (this.pluginState.fromUserInput
119
+ ? this.pluginState.triggerCharacter!.length
120
+ : 0),
121
+ to: this.editor._tiptapEditor.state.selection.from,
122
+ })
123
+ .run();
124
+ };
125
+ }
126
+
127
+ type SuggestionPluginState =
128
+ | {
129
+ triggerCharacter: string;
130
+ fromUserInput: boolean;
131
+ queryStartPos: number;
132
+ query: string;
133
+ decorationId: string;
134
+ }
135
+ | undefined;
136
+
137
+ export const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin");
138
+
139
+ /**
140
+ * A ProseMirror plugin for suggestions, designed to make '/'-commands possible as well as mentions.
141
+ *
142
+ * This is basically a simplified version of TipTap's [Suggestions](https://github.com/ueberdosis/tiptap/tree/db92a9b313c5993b723c85cd30256f1d4a0b65e1/packages/suggestion) plugin.
143
+ *
144
+ * This version is adapted from the aforementioned version in the following ways:
145
+ * - This version supports generic items instead of only strings (to allow for more advanced filtering for example)
146
+ * - This version hides some unnecessary complexity from the user of the plugin.
147
+ * - This version handles key events differently
148
+ */
149
+ export class SuggestionMenuProseMirrorPlugin<
150
+ BSchema extends BlockSchema,
151
+ I extends InlineContentSchema,
152
+ S extends StyleSchema
153
+ > extends EventEmitter<any> {
154
+ private view: SuggestionMenuView<BSchema, I, S> | undefined;
155
+ public readonly plugin: Plugin;
156
+
157
+ private triggerCharacters: string[] = [];
158
+
159
+ constructor(editor: BlockNoteEditor<BSchema, I, S>) {
160
+ super();
161
+ const triggerCharacters = this.triggerCharacters;
162
+ this.plugin = new Plugin({
163
+ key: suggestionMenuPluginKey,
164
+
165
+ view: () => {
166
+ this.view = new SuggestionMenuView<BSchema, I, S>(
167
+ editor,
168
+ (triggerCharacter, state) => {
169
+ this.emit(`update ${triggerCharacter}`, state);
170
+ }
171
+ );
172
+ return this.view;
173
+ },
174
+
175
+ state: {
176
+ // Initialize the plugin's internal state.
177
+ init(): SuggestionPluginState {
178
+ return undefined;
179
+ },
180
+
181
+ // Apply changes to the plugin state from an editor transaction.
182
+ apply(transaction, prev, _oldState, newState): SuggestionPluginState {
183
+ // TODO: More clearly define which transactions should be ignored.
184
+ if (transaction.getMeta("orderedListIndexing") !== undefined) {
185
+ return prev;
186
+ }
187
+
188
+ // Either contains the trigger character if the menu should be shown,
189
+ // or null if it should be hidden.
190
+ const suggestionPluginTransactionMeta: {
191
+ triggerCharacter: string;
192
+ fromUserInput?: boolean;
193
+ } | null = transaction.getMeta(suggestionMenuPluginKey);
194
+
195
+ // Only opens a menu of no menu is already open
196
+ if (
197
+ typeof suggestionPluginTransactionMeta === "object" &&
198
+ suggestionPluginTransactionMeta !== null &&
199
+ prev === undefined
200
+ ) {
201
+ return {
202
+ triggerCharacter:
203
+ suggestionPluginTransactionMeta.triggerCharacter,
204
+ fromUserInput:
205
+ suggestionPluginTransactionMeta.fromUserInput !== false,
206
+ queryStartPos: newState.selection.from,
207
+ query: "",
208
+ decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`,
209
+ };
210
+ }
211
+
212
+ // Checks if the menu is hidden, in which case it doesn't need to be hidden or updated.
213
+ if (prev === undefined) {
214
+ return prev;
215
+ }
216
+
217
+ // Checks if the menu should be hidden.
218
+ if (
219
+ // Highlighting text should hide the menu.
220
+ newState.selection.from !== newState.selection.to ||
221
+ // Transactions with plugin metadata should hide the menu.
222
+ suggestionPluginTransactionMeta === null ||
223
+ // Certain mouse events should hide the menu.
224
+ // TODO: Change to global mousedown listener.
225
+ transaction.getMeta("focus") ||
226
+ transaction.getMeta("blur") ||
227
+ transaction.getMeta("pointer") ||
228
+ // Moving the caret before the character which triggered the menu should hide it.
229
+ (prev.triggerCharacter !== undefined &&
230
+ newState.selection.from < prev.queryStartPos!)
231
+ ) {
232
+ return undefined;
233
+ }
234
+
235
+ const next = { ...prev };
236
+
237
+ // Updates the current query.
238
+ next.query = newState.doc.textBetween(
239
+ prev.queryStartPos!,
240
+ newState.selection.from
241
+ );
242
+
243
+ return next;
244
+ },
245
+ },
246
+
247
+ props: {
248
+ handleKeyDown(view, event) {
249
+ const suggestionPluginState: SuggestionPluginState = (
250
+ this as Plugin
251
+ ).getState(view.state);
252
+
253
+ if (
254
+ triggerCharacters.includes(event.key) &&
255
+ suggestionPluginState === undefined
256
+ ) {
257
+ event.preventDefault();
258
+
259
+ view.dispatch(
260
+ view.state.tr
261
+ .insertText(event.key)
262
+ .scrollIntoView()
263
+ .setMeta(suggestionMenuPluginKey, {
264
+ triggerCharacter: event.key,
265
+ })
266
+ );
267
+
268
+ return true;
269
+ }
270
+
271
+ return false;
272
+ },
273
+
274
+ // Setup decorator on the currently active suggestion.
275
+ decorations(state) {
276
+ const suggestionPluginState: SuggestionPluginState = (
277
+ this as Plugin
278
+ ).getState(state);
279
+
280
+ if (suggestionPluginState === undefined) {
281
+ return null;
282
+ }
283
+
284
+ // If the menu was opened programmatically by another extension, it may not use a trigger character. In this
285
+ // case, the decoration is set on the whole block instead, as the decoration range would otherwise be empty.
286
+ if (!suggestionPluginState.fromUserInput) {
287
+ const blockNode = findBlock(state.selection);
288
+ if (blockNode) {
289
+ return DecorationSet.create(state.doc, [
290
+ Decoration.node(
291
+ blockNode.pos,
292
+ blockNode.pos + blockNode.node.nodeSize,
293
+ {
294
+ nodeName: "span",
295
+ class: "bn-suggestion-decorator",
296
+ "data-decoration-id": suggestionPluginState.decorationId,
297
+ }
298
+ ),
299
+ ]);
300
+ }
301
+ }
302
+ // Creates an inline decoration around the trigger character.
303
+ return DecorationSet.create(state.doc, [
304
+ Decoration.inline(
305
+ suggestionPluginState.queryStartPos! -
306
+ suggestionPluginState.triggerCharacter!.length,
307
+ suggestionPluginState.queryStartPos!,
308
+ {
309
+ nodeName: "span",
310
+ class: "bn-suggestion-decorator",
311
+ "data-decoration-id": suggestionPluginState.decorationId,
312
+ }
313
+ ),
314
+ ]);
315
+ },
316
+ },
317
+ });
318
+ }
319
+
320
+ public onUpdate(
321
+ triggerCharacter: string,
322
+ callback: (state: SuggestionMenuState) => void
323
+ ) {
324
+ if (!this.triggerCharacters.includes(triggerCharacter)) {
325
+ this.addTriggerCharacter(triggerCharacter);
326
+ }
327
+ // TODO: be able to remove the triggerCharacter
328
+ return this.on(`update ${triggerCharacter}`, callback);
329
+ }
330
+
331
+ addTriggerCharacter = (triggerCharacter: string) => {
332
+ this.triggerCharacters.push(triggerCharacter);
333
+ };
334
+
335
+ // TODO: Should this be called automatically when listeners are removed?
336
+ removeTriggerCharacter = (triggerCharacter: string) => {
337
+ this.triggerCharacters = this.triggerCharacters.filter(
338
+ (c) => c !== triggerCharacter
339
+ );
340
+ };
341
+
342
+ closeMenu = () => this.view!.closeMenu();
343
+
344
+ clearQuery = () => this.view!.clearQuery();
345
+ }
346
+
347
+ export function createSuggestionMenu<
348
+ BSchema extends BlockSchema,
349
+ I extends InlineContentSchema,
350
+ S extends StyleSchema
351
+ >(editor: BlockNoteEditor<BSchema, I, S>, triggerCharacter: string) {
352
+ editor.suggestionMenus.addTriggerCharacter(triggerCharacter);
353
+ }