@pilotiq/tiptap 3.18.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @pilotiq/tiptap
2
2
 
3
+ ## 3.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 75875da: Slash menu: remove the Align left/center/right, Lead, Small, and Clear formatting
8
+ entries. These remain available as toolbar buttons; dropping them from the `/`
9
+ menu keeps it focused on real content blocks (closes #151, #152).
10
+
11
+ ### Patch Changes
12
+
13
+ - 4b82d23: Don't show the inline mark toolbar inside the callout (alert) block.
14
+
15
+ The selection-based `FloatingToolbar` (bold / italic / strike / code / link) was appearing when text was selected inside an `alert` block, even though the callout owns its own content + chrome through the in-block gear menu (#155). Its `shouldShow` now bails when either selection endpoint sits inside an `alert` at any ancestor depth.
16
+
17
+ - 4013010: Also hide the inline mark toolbar when the whole callout block is "picked".
18
+
19
+ Follow-up to the previous callout fix (#155): the `FloatingToolbar` still appeared when the entire `alert` block was selected via the drag handle, because that is a `NodeSelection` whose `$from` resolves to _before_ the node — so walking ancestors from `$from` never sees the `alert`. The alert-detection is now an exported `isSelectionInAlert(selection)` predicate that handles both a text/range selection inside the alert AND a whole-block `NodeSelection` on it, pinned by `contentBlockAlertSelection.dom.test.ts` against the real schema (including a real `NodeSelection` on the alert).
20
+
21
+ - d09373b: Fix double-Enter trapping an empty node inside landmark blocks (`keyTakeaways` / `summary` / `intro`).
22
+
23
+ These blocks use `content: 'block+'`, so the default list-exit on double-Enter only _lifted_ the empty list item into a paragraph — a valid `block+` child — which stayed trapped inside the block instead of escaping it (#150). A new `LabeledBlockExitKeymap` (high priority, so it runs before `ListItem`'s `splitListItem`) intercepts the gesture: an empty trailing node (a paragraph, or the empty paragraph of a last list item) inside a landmark block is dropped and the cursor lands in a fresh paragraph _after_ the block, mirroring the FAQ block's Enter-flow. The empty-chain-only case replaces the now-useless block with a paragraph. The logic is exported as `planExitLabeledBlock` and pinned by a `contentBlockExit.dom.test.ts` contract test against the real schema.
24
+
3
25
  ## 3.18.0
4
26
 
5
27
  ### Minor Changes
@@ -231,41 +231,6 @@ export function buildSlashItems(blocks, mergeTags, query, insert) {
231
231
  insert.onInsertImage();
232
232
  },
233
233
  }] : []),
234
- {
235
- key: 'align-left', label: 'Align left', icon: '⇤', group: 'Align',
236
- searchKey: 'align left start',
237
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setTextAlign('left').run(),
238
- },
239
- {
240
- key: 'align-center', label: 'Align center', icon: '⇔', group: 'Align',
241
- searchKey: 'align center middle',
242
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setTextAlign('center').run(),
243
- },
244
- {
245
- key: 'align-right', label: 'Align right', icon: '⇥', group: 'Align',
246
- searchKey: 'align right end',
247
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setTextAlign('right').run(),
248
- },
249
- {
250
- key: 'clear-format', label: 'Clear formatting', icon: '⌫', group: 'Basic',
251
- searchKey: 'clear formatting reset',
252
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).clearNodes().unsetAllMarks().run(),
253
- },
254
- // Inline-mark size variants. Slash-menu form leaves the slash range in
255
- // place rather than swallowing it, so the user runs the command on the
256
- // word they were just typing — the alternative ("/lead" deletes the
257
- // range, then user types more) requires re-positioning the cursor and
258
- // breaks the "type-toggle-keep-typing" rhythm authors use most.
259
- {
260
- key: 'lead', label: 'Lead', icon: 'P+', group: 'Style',
261
- searchKey: 'lead lede intro paragraph emphasis',
262
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleMark('lead').run(),
263
- },
264
- {
265
- key: 'small', label: 'Small', icon: 'P-', group: 'Style',
266
- searchKey: 'small fine print footnote caption',
267
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleMark('small').run(),
268
- },
269
234
  ];
270
235
  const customs = blocks.map((b) => ({
271
236
  key: `block:${b.name}`,
@@ -1,4 +1,5 @@
1
1
  import { Node, Extension } from '@tiptap/core';
2
+ import { type EditorState, type Transaction, type Selection } from '@tiptap/pm/state';
2
3
  export { ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType } from './alertVariants.js';
3
4
  export declare const KeyTakeaways: Node<any, any>;
4
5
  export declare const Summary: Node<any, any>;
@@ -26,10 +27,40 @@ export declare const Alert: Node<any, any>;
26
27
  export declare const AlertTitle: Node<any, any>;
27
28
  /** The Alert body — the editable description (`block+`). */
28
29
  export declare const AlertBody: Node<any, any>;
30
+ /**
31
+ * True when `selection` is the callout (`alert`) block or sits inside it.
32
+ * Used by `FloatingToolbar` to suppress the inline mark toolbar in callouts
33
+ * (#155). Two distinct cases:
34
+ * - a text/range selection WITHIN the alert — its endpoints resolve to an
35
+ * `alert` ancestor;
36
+ * - the whole block PICKED via the drag handle — a `NodeSelection` on the
37
+ * alert, whose `$from` resolves to *before* the node, so its ancestors are
38
+ * the doc (not the alert) and the endpoint walk alone would miss it.
39
+ */
40
+ export declare function isSelectionInAlert(selection: Selection): boolean;
29
41
  export declare const ProsCons: Node<any, any>;
30
42
  export declare const ProsColumn: Node<any, any>;
31
43
  export declare const ConsColumn: Node<any, any>;
32
44
  export declare const ContentBlockKeymap: Extension<any, any>;
45
+ /**
46
+ * Plan the "exit a landmark block" Enter gesture. Returns a transaction
47
+ * modifier when the cursor sits in an EMPTY trailing node (a paragraph, or the
48
+ * empty paragraph of a last list item) inside a `block+` landmark block — the
49
+ * modifier removes that empty node and places the cursor in a paragraph after
50
+ * the block. Returns `null` when the gesture doesn't apply, so the keymap
51
+ * yields to the default Enter / list-split behaviour. Exported (alongside the
52
+ * `surgicalOps` planners) so it can be exercised against a real editor in a
53
+ * test without mounting React.
54
+ */
55
+ export declare function planExitLabeledBlock(state: EditorState): ((tr: Transaction) => void) | null;
56
+ /**
57
+ * Enter-to-exit for landmark blocks. A dedicated keymap at high priority so it
58
+ * runs BEFORE `ListItem`'s Enter (`splitListItem`, default priority 100) — and
59
+ * kept separate from `ContentBlockKeymap` so it doesn't change that keymap's
60
+ * Backspace priority (which would alter delete-block behaviour). Returns
61
+ * `false` when `planExitLabeledBlock` declines, yielding to the default Enter.
62
+ */
63
+ export declare const LabeledBlockExitKeymap: Extension<any, any>;
33
64
  /** All inline content-block extensions — registered in the editor's list. */
34
65
  export declare const contentBlockNodes: (Node<any, any> | Extension<any, any>)[];
35
66
  //# sourceMappingURL=contentBlocks.d.ts.map
@@ -1,5 +1,6 @@
1
1
  import { Node, Extension, mergeAttributes } from '@tiptap/core';
2
2
  import { ReactNodeViewRenderer } from '@tiptap/react';
3
+ import { TextSelection, NodeSelection } from '@tiptap/pm/state';
3
4
  import { AlertNodeView } from '../react/AlertNodeView.js';
4
5
  import { FaqNodeView } from '../react/FaqNodeView.js';
5
6
  import { FaqItemNodeView } from '../react/FaqItemNodeView.js';
@@ -501,6 +502,29 @@ export const AlertBody = Node.create({
501
502
  return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alertBody', class: 'pilotiq-alert-description' }), 0];
502
503
  },
503
504
  });
505
+ /** True when `$pos` sits inside an `alert` (callout) block at any ancestor depth. */
506
+ function isInsideAlert($pos) {
507
+ for (let d = $pos.depth; d > 0; d--) {
508
+ if ($pos.node(d).type.name === 'alert')
509
+ return true;
510
+ }
511
+ return false;
512
+ }
513
+ /**
514
+ * True when `selection` is the callout (`alert`) block or sits inside it.
515
+ * Used by `FloatingToolbar` to suppress the inline mark toolbar in callouts
516
+ * (#155). Two distinct cases:
517
+ * - a text/range selection WITHIN the alert — its endpoints resolve to an
518
+ * `alert` ancestor;
519
+ * - the whole block PICKED via the drag handle — a `NodeSelection` on the
520
+ * alert, whose `$from` resolves to *before* the node, so its ancestors are
521
+ * the doc (not the alert) and the endpoint walk alone would miss it.
522
+ */
523
+ export function isSelectionInAlert(selection) {
524
+ if (selection instanceof NodeSelection && selection.node.type.name === 'alert')
525
+ return true;
526
+ return isInsideAlert(selection.$from) || isInsideAlert(selection.$to);
527
+ }
504
528
  // ── Pros & cons — two labelled columns (each a `block+` body, list by default) ──
505
529
  export const ProsCons = Node.create({
506
530
  name: 'prosCons',
@@ -591,6 +615,111 @@ export const ContentBlockKeymap = Extension.create({
591
615
  };
592
616
  },
593
617
  });
618
+ // ── Keyboard: Enter exits a landmark block from an empty trailing node ──
619
+ //
620
+ // Landmark blocks (`keyTakeaways` / `summary` / `intro`) use `content: 'block+'`.
621
+ // The default double-Enter list-exit only *lifts* the empty list item into a
622
+ // paragraph, which is a valid `block+` child — so it stays trapped inside the
623
+ // block instead of escaping it (#150). This handler intercepts the gesture: an
624
+ // empty trailing node (a paragraph, or an empty last list item) inside a
625
+ // landmark block → drop it and land the cursor in a fresh paragraph AFTER the
626
+ // block, mirroring the FAQ block's Enter-flow.
627
+ const LANDMARK_BLOCKS = new Set(['keyTakeaways', 'summary', 'intro']);
628
+ const LIST_CONTAINERS = new Set(['bulletList', 'orderedList', 'taskList']);
629
+ const LIST_ITEMS = new Set(['listItem', 'taskItem']);
630
+ /**
631
+ * Plan the "exit a landmark block" Enter gesture. Returns a transaction
632
+ * modifier when the cursor sits in an EMPTY trailing node (a paragraph, or the
633
+ * empty paragraph of a last list item) inside a `block+` landmark block — the
634
+ * modifier removes that empty node and places the cursor in a paragraph after
635
+ * the block. Returns `null` when the gesture doesn't apply, so the keymap
636
+ * yields to the default Enter / list-split behaviour. Exported (alongside the
637
+ * `surgicalOps` planners) so it can be exercised against a real editor in a
638
+ * test without mounting React.
639
+ */
640
+ export function planExitLabeledBlock(state) {
641
+ const { $from, empty } = state.selection;
642
+ if (!empty)
643
+ return null;
644
+ // Cursor must be in an empty textblock (an empty paragraph / list-item body).
645
+ if (!$from.parent.isTextblock || $from.parent.content.size !== 0)
646
+ return null;
647
+ // Nearest landmark-block ancestor.
648
+ let blockDepth = -1;
649
+ for (let d = $from.depth - 1; d > 0; d--) {
650
+ if (LANDMARK_BLOCKS.has($from.node(d).type.name)) {
651
+ blockDepth = d;
652
+ break;
653
+ }
654
+ }
655
+ if (blockDepth === -1)
656
+ return null;
657
+ // The empty textblock must be the LAST descendant at every level down from
658
+ // the block — i.e. genuinely trailing, not an empty node mid-block.
659
+ for (let d = $from.depth; d > blockDepth; d--) {
660
+ if ($from.index(d - 1) !== $from.node(d - 1).childCount - 1)
661
+ return null;
662
+ }
663
+ // Climb past only-child LIST wrappers (the listItem / list holding the empty
664
+ // paragraph) so we drop the whole empty item, not just its inner paragraph —
665
+ // but never past a non-list single-child wrapper (e.g. a blockquote), which
666
+ // we'd leave illegally empty; bail to the default Enter in that case.
667
+ let removeDepth = $from.depth;
668
+ while (removeDepth > blockDepth + 1 &&
669
+ $from.node(removeDepth - 1).childCount === 1 &&
670
+ (LIST_ITEMS.has($from.node(removeDepth - 1).type.name) ||
671
+ LIST_CONTAINERS.has($from.node(removeDepth - 1).type.name))) {
672
+ removeDepth--;
673
+ }
674
+ // Removing this node would strand an empty non-list parent → let the default
675
+ // Enter handle it.
676
+ if (removeDepth > blockDepth + 1 && $from.node(removeDepth - 1).childCount === 1)
677
+ return null;
678
+ const block = $from.node(blockDepth);
679
+ const blockStart = $from.before(blockDepth);
680
+ const blockEnd = blockStart + block.nodeSize;
681
+ const paragraph = state.schema.nodes['paragraph']?.createAndFill();
682
+ if (!paragraph)
683
+ return null;
684
+ // The empty chain is the block's ONLY content → replace the whole (now
685
+ // useless) block with the empty paragraph.
686
+ if (removeDepth === blockDepth + 1 && block.childCount === 1) {
687
+ return (tr) => {
688
+ tr.replaceWith(blockStart, blockEnd, paragraph);
689
+ tr.setSelection(TextSelection.create(tr.doc, blockStart + 1));
690
+ };
691
+ }
692
+ // Otherwise drop the trailing empty node and add a paragraph after the block.
693
+ const removeStart = $from.before(removeDepth);
694
+ const removeEnd = removeStart + $from.node(removeDepth).nodeSize;
695
+ return (tr) => {
696
+ tr.delete(removeStart, removeEnd);
697
+ const after = tr.mapping.map(blockEnd);
698
+ tr.insert(after, paragraph);
699
+ tr.setSelection(TextSelection.create(tr.doc, after + 1));
700
+ };
701
+ }
702
+ /**
703
+ * Enter-to-exit for landmark blocks. A dedicated keymap at high priority so it
704
+ * runs BEFORE `ListItem`'s Enter (`splitListItem`, default priority 100) — and
705
+ * kept separate from `ContentBlockKeymap` so it doesn't change that keymap's
706
+ * Backspace priority (which would alter delete-block behaviour). Returns
707
+ * `false` when `planExitLabeledBlock` declines, yielding to the default Enter.
708
+ */
709
+ export const LabeledBlockExitKeymap = Extension.create({
710
+ name: 'pilotiqLabeledBlockExitKeymap',
711
+ priority: 1000,
712
+ addKeyboardShortcuts() {
713
+ return {
714
+ Enter: ({ editor }) => {
715
+ const plan = planExitLabeledBlock(editor.state);
716
+ if (!plan)
717
+ return false;
718
+ return editor.commands.command(({ tr }) => { plan(tr); return true; });
719
+ },
720
+ };
721
+ },
722
+ });
594
723
  /** All inline content-block extensions — registered in the editor's list. */
595
724
  export const contentBlockNodes = [
596
725
  KeyTakeaways,
@@ -607,4 +736,5 @@ export const contentBlockNodes = [
607
736
  ProsColumn,
608
737
  ConsColumn,
609
738
  ContentBlockKeymap,
739
+ LabeledBlockExitKeymap,
610
740
  ];
package/dist/index.d.ts CHANGED
@@ -11,5 +11,5 @@ export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js';
11
11
  export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, type AiInlineDiffExtensionOptions, } from './extensions/AiInlineDiffExtension.js';
12
12
  export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planWrapBlocks, planUpdateBlockMark, summarizeBlockStructure, type BlockMarkRange, type TransactionModifier, } from './surgicalOps.js';
13
13
  export { renderRichTextToHtml, isRichTextValue, type RenderRichTextOptions, type TiptapNode, type TiptapMark, } from './render.js';
14
- export { contentBlockNodes, Intro, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType, } from './extensions/contentBlocks.js';
14
+ export { contentBlockNodes, Intro, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, LabeledBlockExitKeymap, planExitLabeledBlock, isSelectionInAlert, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType, } from './extensions/contentBlocks.js';
15
15
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -16,4 +16,4 @@ export { renderRichTextToHtml, isRichTextValue, } from './render.js';
16
16
  // so a consumer can build a headless editor whose schema matches the live
17
17
  // editor — e.g. to parse the content-block HTML or drive the surgical-op
18
18
  // planners (`planInsertBlockBefore` & co.) in a test, without mounting React.
19
- export { contentBlockNodes, Intro, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, } from './extensions/contentBlocks.js';
19
+ export { contentBlockNodes, Intro, Faq, FaqItem, FaqQuestion, FaqAnswer, Alert, AlertTitle, AlertBody, Summary, KeyTakeaways, ProsCons, ProsColumn, ConsColumn, ContentBlockKeymap, LabeledBlockExitKeymap, planExitLabeledBlock, isSelectionInAlert, ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, } from './extensions/contentBlocks.js';
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Tooltip } from '@base-ui/react/tooltip';
4
4
  import { Dialog } from '@base-ui/react/dialog';
5
+ import { isSelectionInAlert } from '../extensions/contentBlocks.js';
5
6
  /**
6
7
  * Selection-based formatting toolbar. Visible whenever the editor has a
7
8
  * non-empty range selection inside text content. Inline marks (B/I/S/Code)
@@ -19,6 +20,13 @@ export function FloatingToolbar({ editor }) {
19
20
  setPos(null);
20
21
  return;
21
22
  }
23
+ // The callout/alert block owns its content + chrome (the in-block gear
24
+ // menu); the inline mark toolbar shouldn't appear inside it — or when the
25
+ // whole block is picked via the drag handle (a NodeSelection) (#155).
26
+ if (isSelectionInAlert(editor.state.selection)) {
27
+ setPos(null);
28
+ return;
29
+ }
22
30
  // Don't show on full-block selections (e.g. clicking a custom block).
23
31
  const slice = editor.state.doc.slice(from, to);
24
32
  if (slice.content.childCount === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.18.0",
3
+ "version": "3.19.0",
4
4
  "description": "Tiptap rich-text editor adapter for @pilotiq/pilotiq — slash menu, draggable blocks, custom-block API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,7 +93,7 @@
93
93
  "react-dom": "^19",
94
94
  "tiptap-markdown": "^0.9",
95
95
  "typescript": "^5",
96
- "@pilotiq/pilotiq": "^0.40.0"
96
+ "@pilotiq/pilotiq": "^0.41.0"
97
97
  },
98
98
  "author": "Suleiman Shahbari",
99
99
  "scripts": {