@pilotiq/tiptap 3.19.0 → 3.19.2
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 +16 -0
- package/dist/extensions/AiInlineDiffExtension.js +98 -33
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/react/FloatingToolbar.d.ts +6 -4
- package/dist/react/FloatingToolbar.js +11 -20
- package/dist/react/floatingToolbarVisibility.d.ts +20 -0
- package/dist/react/floatingToolbarVisibility.js +39 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @pilotiq/tiptap
|
|
2
2
|
|
|
3
|
+
## 3.19.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6064202: AI inline diff: the removed/deleted side now keeps its original block formatting (#91).
|
|
8
|
+
|
|
9
|
+
Previously the deleted content was flattened with `textBetween()` / `node.textContent`, so a removed heading, list, FAQ, or other formatted block rendered as plain text next to the (correctly-formatted) inserted side. The deleted widget now serializes the baseline's real nodes via the schema's `DOMSerializer` — a removed `<h2>` stays an `<h2>`, a list keeps its `<ul><li>`, a FAQ keeps its wrappers. A single plain top-level paragraph edit still renders as the inline word-level diff (no regression). Applies to both inline and `lines` display modes.
|
|
10
|
+
|
|
11
|
+
## 3.19.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- ff44b8d: Show the inline mark toolbar on a bare caret inside a formatting mark.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
3
19
|
## 3.19.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import { Extension } from '@tiptap/core';
|
|
35
35
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
36
36
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
37
|
+
import { DOMSerializer, Fragment } from '@tiptap/pm/model';
|
|
37
38
|
import { ChangeSet } from 'prosemirror-changeset';
|
|
38
39
|
export const aiInlineDiffPluginKey = new PluginKey('pilotiqAiInlineDiff');
|
|
39
40
|
/** Read the active diff state, if any. Public for hosts that want to
|
|
@@ -73,6 +74,21 @@ export const AiInlineDiffExtension = Extension.create({
|
|
|
73
74
|
color: rgb(153, 27, 27);
|
|
74
75
|
padding: 0 0.125em;
|
|
75
76
|
}
|
|
77
|
+
/* Structure-preserving deleted block (heading / list / faq / …):
|
|
78
|
+
keep the removed node's own formatting, tinted red + struck so it
|
|
79
|
+
still reads as "deleted". Block display so an <h2>/<ul>/faq lays
|
|
80
|
+
out as itself rather than collapsing inline. */
|
|
81
|
+
.${prefix}-deleted-block {
|
|
82
|
+
display: block;
|
|
83
|
+
background-color: rgba(254, 226, 226, 0.55);
|
|
84
|
+
color: rgb(153, 27, 27);
|
|
85
|
+
text-decoration: line-through;
|
|
86
|
+
text-decoration-color: rgba(220, 38, 38, 0.7);
|
|
87
|
+
border-radius: 2px;
|
|
88
|
+
padding: 0 0.25em;
|
|
89
|
+
opacity: 0.9;
|
|
90
|
+
}
|
|
91
|
+
.${prefix}-deleted-block > * { margin-top: 0; margin-bottom: 0; }
|
|
76
92
|
.${prefix}-inserted-line {
|
|
77
93
|
display: block;
|
|
78
94
|
background-color: rgba(187, 247, 208, 0.45);
|
|
@@ -107,6 +123,7 @@ export const AiInlineDiffExtension = Extension.create({
|
|
|
107
123
|
left: 0.25em;
|
|
108
124
|
opacity: 0.7;
|
|
109
125
|
}
|
|
126
|
+
.${prefix}-deleted-line > * { margin-top: 0; margin-bottom: 0; }
|
|
110
127
|
`;
|
|
111
128
|
document.head.appendChild(style);
|
|
112
129
|
},
|
|
@@ -232,32 +249,72 @@ function buildDiffDecorations(state, ds, prefix) {
|
|
|
232
249
|
'data-pilotiq-ai-diff-id': ds.id,
|
|
233
250
|
}));
|
|
234
251
|
}
|
|
235
|
-
// Deleted
|
|
236
|
-
// Render via a widget at the change's insert-point
|
|
237
|
-
//
|
|
238
|
-
//
|
|
252
|
+
// Deleted content — pull from the baseline using the `fromA..toA`
|
|
253
|
+
// range. Render via a widget at the change's insert-point so the
|
|
254
|
+
// deleted content appears immediately before the new run. Empty
|
|
255
|
+
// deletions (pure inserts) skip the widget.
|
|
239
256
|
if (change.toA > change.fromA) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
key: `pilotiq-ai-diff:deleted:${change.fromA}:${change.toA}`,
|
|
246
|
-
}));
|
|
247
|
-
}
|
|
257
|
+
decos.push(Decoration.widget(fromB, () => buildDeletedWidget(ds.baseline, change.fromA, change.toA, prefix, ds.id), {
|
|
258
|
+
side: -1,
|
|
259
|
+
ignoreSelection: true,
|
|
260
|
+
key: `pilotiq-ai-diff:deleted:${change.fromA}:${change.toA}`,
|
|
261
|
+
}));
|
|
248
262
|
}
|
|
249
263
|
}
|
|
250
264
|
return DecorationSet.create(state.doc, decos);
|
|
251
265
|
}
|
|
252
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Render the deleted side of a change preserving its ORIGINAL block
|
|
268
|
+
* formatting (heading / list / faq / alert / blockquote …) — bug #91.
|
|
269
|
+
*
|
|
270
|
+
* Strategy: collect the BASELINE top-level blocks the deleted range
|
|
271
|
+
* `fromA..toA` overlaps, each CUT to the overlapping span, and serialize
|
|
272
|
+
* those real nodes via the schema's `DOMSerializer`. A removed heading
|
|
273
|
+
* stays an `<h2>`, a removed list keeps its `<ul><li>`, a faq keeps its
|
|
274
|
+
* wrappers — because we serialize the node itself, not flattened text.
|
|
275
|
+
*
|
|
276
|
+
* The one exception is a single plain top-level paragraph: there the inline
|
|
277
|
+
* word-diff look (red strike-through text) reads better, and wrapping a
|
|
278
|
+
* one-word change in its `<p>` would stack it as a block and break the
|
|
279
|
+
* inline diff — so paragraphs stay text.
|
|
280
|
+
*/
|
|
281
|
+
function buildDeletedWidget(baseline, fromA, toA, prefix, id) {
|
|
253
282
|
const root = document.createElement('span');
|
|
254
283
|
root.className = `${prefix}-deleted`;
|
|
255
284
|
root.setAttribute('data-pilotiq-ai-diff-id', id);
|
|
256
285
|
root.contentEditable = 'false';
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
286
|
+
// Walk the baseline's top-level blocks; for each one the deleted range
|
|
287
|
+
// touches, keep the whole node when fully covered, else cut it to the
|
|
288
|
+
// overlapping slice (preserving the node's own wrapper either way).
|
|
289
|
+
const pieces = [];
|
|
290
|
+
baseline.forEach((child, offset) => {
|
|
291
|
+
if (Math.min(toA, offset + child.nodeSize) <= Math.max(fromA, offset))
|
|
292
|
+
return;
|
|
293
|
+
if (child.isAtom) {
|
|
294
|
+
pieces.push(child);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const localFrom = Math.max(0, fromA - (offset + 1));
|
|
298
|
+
const localTo = Math.min(child.content.size, toA - (offset + 1));
|
|
299
|
+
if (localFrom <= 0 && localTo >= child.content.size) {
|
|
300
|
+
pieces.push(child);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (localTo <= localFrom)
|
|
304
|
+
return;
|
|
305
|
+
pieces.push(child.cut(localFrom, localTo));
|
|
306
|
+
});
|
|
307
|
+
if (pieces.length === 1 && pieces[0].type.name === 'paragraph') {
|
|
308
|
+
const inner = document.createElement('span');
|
|
309
|
+
inner.className = `${prefix}-deleted-text`;
|
|
310
|
+
inner.textContent = baseline.textBetween(fromA, toA, '\n', ' ');
|
|
311
|
+
root.appendChild(inner);
|
|
312
|
+
return root;
|
|
313
|
+
}
|
|
314
|
+
const block = document.createElement('span');
|
|
315
|
+
block.className = `${prefix}-deleted-block`;
|
|
316
|
+
block.appendChild(DOMSerializer.fromSchema(baseline.type.schema).serializeFragment(Fragment.fromArray(pieces)));
|
|
317
|
+
root.appendChild(block);
|
|
261
318
|
return root;
|
|
262
319
|
}
|
|
263
320
|
/**
|
|
@@ -277,32 +334,36 @@ function buildDeletedWidget(text, prefix, id) {
|
|
|
277
334
|
*/
|
|
278
335
|
function buildLineDiffDecorations(state, ds, prefix) {
|
|
279
336
|
const decos = [];
|
|
280
|
-
const
|
|
337
|
+
const baseBlocks = topLevelBlocks(ds.baseline);
|
|
281
338
|
const current = [];
|
|
282
339
|
state.doc.forEach((node, pos) => {
|
|
283
340
|
current.push({ text: node.textContent, pos, nodeSize: node.nodeSize });
|
|
284
341
|
});
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
342
|
+
const schema = ds.baseline.type.schema;
|
|
343
|
+
// Walk tokens with a pointer into the CURRENT doc's blocks (`j`) and the
|
|
344
|
+
// BASELINE blocks (`bi`). Removed baseline BLOCKS (the real nodes, not
|
|
345
|
+
// their flattened text — bug #91) accumulate and flush as ONE widget
|
|
346
|
+
// anchored before the next current block (or at doc end), so consecutive
|
|
347
|
+
// deletions render as a contiguous red row group with their formatting.
|
|
289
348
|
let j = 0;
|
|
349
|
+
let bi = 0;
|
|
290
350
|
let pendingRemoved = [];
|
|
291
351
|
const flushRemoved = (anchor) => {
|
|
292
352
|
if (pendingRemoved.length === 0)
|
|
293
353
|
return;
|
|
294
|
-
const
|
|
354
|
+
const nodes = pendingRemoved;
|
|
295
355
|
pendingRemoved = [];
|
|
296
|
-
decos.push(Decoration.widget(anchor, () => buildDeletedLinesWidget(
|
|
356
|
+
decos.push(Decoration.widget(anchor, () => buildDeletedLinesWidget(nodes, schema, prefix, ds.id), {
|
|
297
357
|
side: -1,
|
|
298
358
|
ignoreSelection: true,
|
|
299
|
-
key: `pilotiq-ai-diff:deleted-lines:${anchor}:${
|
|
359
|
+
key: `pilotiq-ai-diff:deleted-lines:${anchor}:${nodes.length}`,
|
|
300
360
|
}));
|
|
301
361
|
};
|
|
302
|
-
for (const tok of lcsBlockDiffTokens(
|
|
362
|
+
for (const tok of lcsBlockDiffTokens(baseBlocks.map(b => b.text), current.map(c => c.text))) {
|
|
303
363
|
if (tok.kind === 'kept') {
|
|
304
364
|
flushRemoved(current[j].pos);
|
|
305
365
|
j++;
|
|
366
|
+
bi++;
|
|
306
367
|
continue;
|
|
307
368
|
}
|
|
308
369
|
if (tok.kind === 'added') {
|
|
@@ -314,15 +375,16 @@ function buildLineDiffDecorations(state, ds, prefix) {
|
|
|
314
375
|
j++;
|
|
315
376
|
continue;
|
|
316
377
|
}
|
|
317
|
-
// removed — baseline-only
|
|
318
|
-
pendingRemoved.push(
|
|
378
|
+
// removed — baseline-only block; accumulate the real node for the flush.
|
|
379
|
+
pendingRemoved.push(baseBlocks[bi].node);
|
|
380
|
+
bi++;
|
|
319
381
|
}
|
|
320
382
|
flushRemoved(state.doc.content.size);
|
|
321
383
|
return DecorationSet.create(state.doc, decos);
|
|
322
384
|
}
|
|
323
|
-
function
|
|
385
|
+
function topLevelBlocks(doc) {
|
|
324
386
|
const out = [];
|
|
325
|
-
doc.forEach((node) => { out.push(node.textContent); });
|
|
387
|
+
doc.forEach((node) => { out.push({ text: node.textContent, node }); });
|
|
326
388
|
return out;
|
|
327
389
|
}
|
|
328
390
|
/** Standard LCS walk over two block-text arrays, emitting tokens in
|
|
@@ -370,15 +432,18 @@ function lcsBlockDiffTokens(a, b) {
|
|
|
370
432
|
out.reverse();
|
|
371
433
|
return out;
|
|
372
434
|
}
|
|
373
|
-
function buildDeletedLinesWidget(
|
|
435
|
+
function buildDeletedLinesWidget(nodes, schema, prefix, id) {
|
|
374
436
|
const root = document.createElement('div');
|
|
375
437
|
root.className = `${prefix}-deleted-lines`;
|
|
376
438
|
root.setAttribute('data-pilotiq-ai-diff-id', id);
|
|
377
439
|
root.contentEditable = 'false';
|
|
378
|
-
|
|
440
|
+
const serializer = DOMSerializer.fromSchema(schema);
|
|
441
|
+
for (const node of nodes) {
|
|
379
442
|
const row = document.createElement('div');
|
|
380
443
|
row.className = `${prefix}-deleted-line`;
|
|
381
|
-
|
|
444
|
+
// Serialize the real node so a removed heading / list / faq keeps its
|
|
445
|
+
// structure (bug #91), instead of collapsing to one plain text row.
|
|
446
|
+
row.appendChild(serializer.serializeFragment(Fragment.from(node)));
|
|
382
447
|
root.appendChild(row);
|
|
383
448
|
}
|
|
384
449
|
return root;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,4 +12,5 @@ export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, typ
|
|
|
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
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
|
@@ -17,3 +17,4 @@ export { renderRichTextToHtml, isRichTextValue, } from './render.js';
|
|
|
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
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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,12 +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 {
|
|
5
|
+
import { shouldShowFloatingToolbar } from './floatingToolbarVisibility.js';
|
|
6
6
|
/**
|
|
7
|
-
* Selection-based formatting toolbar. Visible
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
11
13
|
*/
|
|
12
14
|
export function FloatingToolbar({ editor }) {
|
|
13
15
|
const [pos, setPos] = useState(null);
|
|
@@ -15,24 +17,13 @@ export function FloatingToolbar({ editor }) {
|
|
|
15
17
|
const [linkUrl, setLinkUrl] = useState('');
|
|
16
18
|
useEffect(() => {
|
|
17
19
|
const update = () => {
|
|
18
|
-
|
|
19
|
-
if (empty) {
|
|
20
|
-
setPos(null);
|
|
21
|
-
return;
|
|
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
|
-
}
|
|
30
|
-
// Don't show on full-block selections (e.g. clicking a custom block).
|
|
31
|
-
const slice = editor.state.doc.slice(from, to);
|
|
32
|
-
if (slice.content.childCount === 0) {
|
|
20
|
+
if (!shouldShowFloatingToolbar(editor.state)) {
|
|
33
21
|
setPos(null);
|
|
34
22
|
return;
|
|
35
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.
|
|
36
27
|
const start = editor.view.coordsAtPos(from);
|
|
37
28
|
const end = editor.view.coordsAtPos(to);
|
|
38
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
|
+
}
|