@pilotiq/tiptap 3.18.0 → 3.19.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @pilotiq/tiptap
2
2
 
3
+ ## 3.19.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ff44b8d: Show the inline mark toolbar on a bare caret inside a formatting mark.
8
+
9
+ The selection-based `FloatingToolbar` only appeared on a non-empty text selection, so a link or bold span couldn't be edited by just clicking into it (#156). Its show/hide decision now lives in a pure, exported `shouldShowFloatingToolbar(state)` predicate: a caret (empty selection) surfaces the toolbar when it sits inside one of the toolbar's marks (`bold` / `italic` / `strike` / `code` / `link`), non-empty ranges behave as before, and the callout/alert suppression from #155 still holds. Pinned by `floatingToolbarVisibility.dom.test.ts` against the real schema.
10
+
11
+ ## 3.19.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 75875da: Slash menu: remove the Align left/center/right, Lead, Small, and Clear formatting
16
+ entries. These remain available as toolbar buttons; dropping them from the `/`
17
+ menu keeps it focused on real content blocks (closes #151, #152).
18
+
19
+ ### Patch Changes
20
+
21
+ - 4b82d23: Don't show the inline mark toolbar inside the callout (alert) block.
22
+
23
+ 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.
24
+
25
+ - 4013010: Also hide the inline mark toolbar when the whole callout block is "picked".
26
+
27
+ 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).
28
+
29
+ - d09373b: Fix double-Enter trapping an empty node inside landmark blocks (`keyTakeaways` / `summary` / `intro`).
30
+
31
+ 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.
32
+
3
33
  ## 3.18.0
4
34
 
5
35
  ### 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,6 @@ 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
+ export { shouldShowFloatingToolbar, TOOLBAR_MARKS } from './react/floatingToolbarVisibility.js';
15
16
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -16,4 +16,5 @@ 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';
20
+ export { shouldShowFloatingToolbar, TOOLBAR_MARKS } from './react/floatingToolbarVisibility.js';
@@ -3,10 +3,12 @@ interface FloatingToolbarProps {
3
3
  editor: Editor;
4
4
  }
5
5
  /**
6
- * Selection-based formatting toolbar. Visible whenever the editor has a
7
- * non-empty range selection inside text content. Inline marks (B/I/S/Code)
8
- * are grouped together; Link sits after a separator since it's a different
9
- * kind of action.
6
+ * Selection-based formatting toolbar. Visible on a non-empty range inside
7
+ * text content, AND on a bare caret sitting inside a formatting mark (so a
8
+ * link / bold span can be edited without selecting it first — #156). Inline
9
+ * marks (B/I/S/Code) are grouped together; Link sits after a separator since
10
+ * it's a different kind of action. Visibility is decided by the pure
11
+ * `shouldShowFloatingToolbar` predicate; this component only positions.
10
12
  */
11
13
  export declare function FloatingToolbar({ editor }: FloatingToolbarProps): import("react").JSX.Element;
12
14
  export {};
@@ -2,11 +2,14 @@ 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 { shouldShowFloatingToolbar } from './floatingToolbarVisibility.js';
5
6
  /**
6
- * Selection-based formatting toolbar. Visible whenever the editor has a
7
- * non-empty range selection inside text content. Inline marks (B/I/S/Code)
8
- * are grouped together; Link sits after a separator since it's a different
9
- * kind of action.
7
+ * Selection-based formatting toolbar. Visible on a non-empty range inside
8
+ * text content, AND on a bare caret sitting inside a formatting mark (so a
9
+ * link / bold span can be edited without selecting it first — #156). Inline
10
+ * marks (B/I/S/Code) are grouped together; Link sits after a separator since
11
+ * it's a different kind of action. Visibility is decided by the pure
12
+ * `shouldShowFloatingToolbar` predicate; this component only positions.
10
13
  */
11
14
  export function FloatingToolbar({ editor }) {
12
15
  const [pos, setPos] = useState(null);
@@ -14,17 +17,13 @@ export function FloatingToolbar({ editor }) {
14
17
  const [linkUrl, setLinkUrl] = useState('');
15
18
  useEffect(() => {
16
19
  const update = () => {
17
- const { from, to, empty } = editor.state.selection;
18
- if (empty) {
19
- setPos(null);
20
- return;
21
- }
22
- // Don't show on full-block selections (e.g. clicking a custom block).
23
- const slice = editor.state.doc.slice(from, to);
24
- if (slice.content.childCount === 0) {
20
+ if (!shouldShowFloatingToolbar(editor.state)) {
25
21
  setPos(null);
26
22
  return;
27
23
  }
24
+ const { from, to } = editor.state.selection;
25
+ // For a bare caret `from === to`, so both coords resolve to the caret
26
+ // point and the toolbar centers above it.
28
27
  const start = editor.view.coordsAtPos(from);
29
28
  const end = editor.view.coordsAtPos(to);
30
29
  // Viewport-relative — pair with `position: fixed` below. The wrapper
@@ -0,0 +1,20 @@
1
+ import type { EditorState } from '@tiptap/pm/state';
2
+ /**
3
+ * Inline marks the `FloatingToolbar` can toggle — bold / italic / strike /
4
+ * code / link. A caret (empty selection) sitting inside any of these surfaces
5
+ * the toolbar so the formatting can be edited in place, without first
6
+ * selecting the text (#156).
7
+ */
8
+ export declare const TOOLBAR_MARKS: readonly ["bold", "italic", "strike", "code", "link"];
9
+ /**
10
+ * Whether the selection-based `FloatingToolbar` should be visible. A PURE
11
+ * decision (no DOM / coords) so it's unit-testable against the real schema:
12
+ *
13
+ * - never inside a callout/alert block — it owns its own chrome (#155);
14
+ * - a caret (empty selection) shows ONLY when it sits inside a formatting
15
+ * mark (link / bold / …) so the mark can be edited without selecting (#156);
16
+ * - a non-empty range shows whenever it actually spans inline content (the
17
+ * `childCount === 0` guard skips degenerate full-block selections).
18
+ */
19
+ export declare function shouldShowFloatingToolbar(state: EditorState): boolean;
20
+ //# sourceMappingURL=floatingToolbarVisibility.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { isSelectionInAlert } from '../extensions/contentBlocks.js';
2
+ /**
3
+ * Inline marks the `FloatingToolbar` can toggle — bold / italic / strike /
4
+ * code / link. A caret (empty selection) sitting inside any of these surfaces
5
+ * the toolbar so the formatting can be edited in place, without first
6
+ * selecting the text (#156).
7
+ */
8
+ export const TOOLBAR_MARKS = ['bold', 'italic', 'strike', 'code', 'link'];
9
+ const TOOLBAR_MARK_SET = new Set(TOOLBAR_MARKS);
10
+ /**
11
+ * True when the caret (an EMPTY selection) sits inside one of the toolbar's
12
+ * formatting marks. Reads `storedMarks` first (so the active mark at a typed
13
+ * boundary still counts), then the marks resolved at the cursor.
14
+ */
15
+ function caretInToolbarMark(state) {
16
+ const sel = state.selection;
17
+ if (!sel.empty)
18
+ return false;
19
+ const marks = state.storedMarks ?? sel.$from.marks();
20
+ return marks.some((m) => TOOLBAR_MARK_SET.has(m.type.name));
21
+ }
22
+ /**
23
+ * Whether the selection-based `FloatingToolbar` should be visible. A PURE
24
+ * decision (no DOM / coords) so it's unit-testable against the real schema:
25
+ *
26
+ * - never inside a callout/alert block — it owns its own chrome (#155);
27
+ * - a caret (empty selection) shows ONLY when it sits inside a formatting
28
+ * mark (link / bold / …) so the mark can be edited without selecting (#156);
29
+ * - a non-empty range shows whenever it actually spans inline content (the
30
+ * `childCount === 0` guard skips degenerate full-block selections).
31
+ */
32
+ export function shouldShowFloatingToolbar(state) {
33
+ if (isSelectionInAlert(state.selection))
34
+ return false;
35
+ if (state.selection.empty)
36
+ return caretInToolbarMark(state);
37
+ const { from, to } = state.selection;
38
+ return state.doc.slice(from, to).content.childCount > 0;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.18.0",
3
+ "version": "3.19.1",
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": {