@pilotiq/tiptap 3.19.2 → 4.0.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 +56 -0
- package/dist/extensions/BlockNodeExtension.d.ts +0 -9
- package/dist/extensions/BlockNodeExtension.js +0 -20
- package/dist/extensions/{AiInlineDiffExtension.d.ts → InlineDiffExtension.d.ts} +23 -23
- package/dist/extensions/{AiInlineDiffExtension.js → InlineDiffExtension.js} +33 -33
- package/dist/extensions/{AiSuggestionExtension.d.ts → SuggestionChipExtension.d.ts} +29 -29
- package/dist/extensions/{AiSuggestionExtension.js → SuggestionChipExtension.js} +52 -52
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/react/BlockNodeView.d.ts +23 -12
- package/dist/react/BlockNodeView.js +55 -21
- package/dist/react/CollabTextRenderer.js +16 -16
- package/dist/react/MarkdownEditor.js +17 -17
- package/dist/react/{AiSuggestionBanner.d.ts → SuggestionBanner.d.ts} +6 -6
- package/dist/react/{AiSuggestionBanner.js → SuggestionBanner.js} +5 -5
- package/dist/react/TiptapEditor.js +24 -59
- package/dist/react/blockValues.d.ts +54 -0
- package/dist/react/blockValues.js +161 -0
- package/dist/react/floatingToolbarVisibility.d.ts +9 -2
- package/dist/react/floatingToolbarVisibility.js +12 -3
- package/dist/react/{useAiInlineDiff.d.ts → useInlineDiff.d.ts} +13 -13
- package/dist/react/{useAiInlineDiff.js → useInlineDiff.js} +36 -19
- package/dist/react/{useAiSuggestionBridge.d.ts → useSuggestionBridge.d.ts} +7 -7
- package/dist/react/{useAiSuggestionBridge.js → useSuggestionBridge.js} +10 -10
- package/dist/surgicalOps.d.ts +15 -2
- package/dist/surgicalOps.js +50 -3
- package/package.json +1 -1
- package/dist/react/BlockSidePanel.d.ts +0 -105
- package/dist/react/BlockSidePanel.js +0 -338
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
-
import { FormFields, parseFormDataToNested, clampPanelWidth as clampPanelWidthShared, useResizableWidth, } from '@pilotiq/pilotiq/react';
|
|
4
|
-
const PANEL_WIDTH_STORAGE_KEY = 'pilotiq.tiptap.sidePanel.width';
|
|
5
|
-
const PANEL_WIDTH_DEFAULT = 320;
|
|
6
|
-
const PANEL_WIDTH_MIN = 240;
|
|
7
|
-
const PANEL_WIDTH_MAX = 600;
|
|
8
|
-
// Keys that point at any tabbable / interactive element inside the panel.
|
|
9
|
-
// Same intent as Filament's focus-trap helper but kept inline — small one-off.
|
|
10
|
-
const FOCUSABLE_SELECTOR = [
|
|
11
|
-
'a[href]',
|
|
12
|
-
'button:not([disabled])',
|
|
13
|
-
'input:not([disabled]):not([type="hidden"])',
|
|
14
|
-
'select:not([disabled])',
|
|
15
|
-
'textarea:not([disabled])',
|
|
16
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
17
|
-
].join(',');
|
|
18
|
-
export function BlockSidePanel({ editor, initialPos, blockType, blocks, onClose, }) {
|
|
19
|
-
const meta = blocks.find((b) => b.name === blockType);
|
|
20
|
-
// Live-tracked position of the block we're editing. Starts at the
|
|
21
|
-
// open-time position; every editor transaction maps it forward so the
|
|
22
|
-
// panel keeps writing to the same node even as the user types text
|
|
23
|
-
// elsewhere in the document.
|
|
24
|
-
const [pos, setPos] = useState(initialPos);
|
|
25
|
-
const posRef = useRef(initialPos);
|
|
26
|
-
// Prefilled values seed the form's `defaultValue`s. We re-read once
|
|
27
|
-
// when the panel opens (and on hard re-mount via key prop); ongoing
|
|
28
|
-
// edits don't snapshot the doc — the form's uncontrolled inputs hold
|
|
29
|
-
// their own state until the user closes the panel.
|
|
30
|
-
const initialValuesRef = useRef(pos !== null ? readBlockData(editor, pos) : {});
|
|
31
|
-
const asideRef = useRef(null);
|
|
32
|
-
const formRef = useRef(null);
|
|
33
|
-
// Width memory — survives panel close/reopen and full reload via
|
|
34
|
-
// localStorage. The shared `useResizableWidth` hook from
|
|
35
|
-
// `@pilotiq/pilotiq/react` handles the localStorage round-trip + drag
|
|
36
|
-
// pipeline; we just bind it to this panel's per-key bounds.
|
|
37
|
-
const { width, onResizeStart } = useResizableWidth({
|
|
38
|
-
storageKey: PANEL_WIDTH_STORAGE_KEY,
|
|
39
|
-
min: PANEL_WIDTH_MIN,
|
|
40
|
-
max: PANEL_WIDTH_MAX,
|
|
41
|
-
defaultWidth: PANEL_WIDTH_DEFAULT,
|
|
42
|
-
edge: 'left',
|
|
43
|
-
});
|
|
44
|
-
// Save focus on mount, focus the first focusable inside the panel,
|
|
45
|
-
// restore previous focus on unmount. Mount-only effect — re-mounting
|
|
46
|
-
// the panel for a different block (key={pos:blockType}) re-runs this.
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
const previouslyFocused = (typeof document !== 'undefined'
|
|
49
|
-
? document.activeElement
|
|
50
|
-
: null);
|
|
51
|
-
const aside = asideRef.current;
|
|
52
|
-
if (aside) {
|
|
53
|
-
const first = aside.querySelector(FOCUSABLE_SELECTOR);
|
|
54
|
-
first?.focus();
|
|
55
|
-
}
|
|
56
|
-
return () => {
|
|
57
|
-
// Try/catch — the previously focused element may have been removed
|
|
58
|
-
// (e.g. an editor selection refresh nuked the surrounding NodeView).
|
|
59
|
-
try {
|
|
60
|
-
previouslyFocused?.focus?.();
|
|
61
|
-
}
|
|
62
|
-
catch { /* noop */ }
|
|
63
|
-
};
|
|
64
|
-
}, []);
|
|
65
|
-
// ESC closes; Tab / Shift+Tab cycles within the panel. Bubble-phase
|
|
66
|
-
// listener — slash and mention menus' capture-phase ESC handlers fire
|
|
67
|
-
// first and stopPropagation, so ESC inside an open slash menu only
|
|
68
|
-
// closes the menu. ESC anywhere else (panel inputs, editor) closes
|
|
69
|
-
// the panel.
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
const onKey = (e) => {
|
|
72
|
-
if (e.key === 'Escape') {
|
|
73
|
-
onClose();
|
|
74
|
-
e.preventDefault();
|
|
75
|
-
e.stopPropagation();
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
if (e.key !== 'Tab')
|
|
79
|
-
return;
|
|
80
|
-
const aside = asideRef.current;
|
|
81
|
-
if (!aside)
|
|
82
|
-
return;
|
|
83
|
-
const active = document.activeElement;
|
|
84
|
-
if (!active || !aside.contains(active))
|
|
85
|
-
return;
|
|
86
|
-
const focusables = Array.from(aside.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => !el.hasAttribute('disabled'));
|
|
87
|
-
if (focusables.length === 0)
|
|
88
|
-
return;
|
|
89
|
-
const first = focusables[0];
|
|
90
|
-
const last = focusables[focusables.length - 1];
|
|
91
|
-
if (e.shiftKey && active === first) {
|
|
92
|
-
e.preventDefault();
|
|
93
|
-
last.focus();
|
|
94
|
-
}
|
|
95
|
-
else if (!e.shiftKey && active === last) {
|
|
96
|
-
e.preventDefault();
|
|
97
|
-
first.focus();
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
document.addEventListener('keydown', onKey);
|
|
101
|
-
return () => document.removeEventListener('keydown', onKey);
|
|
102
|
-
}, [onClose]);
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
if (pos === null)
|
|
105
|
-
return;
|
|
106
|
-
const handler = ({ transaction }) => {
|
|
107
|
-
const current = posRef.current;
|
|
108
|
-
if (current === null)
|
|
109
|
-
return;
|
|
110
|
-
const mapped = transaction.mapping.map(current);
|
|
111
|
-
// The block was deleted — close the panel.
|
|
112
|
-
const nodeNow = nodeAt(editor, mapped);
|
|
113
|
-
if (!nodeNow || nodeNow.type.name !== 'pilotiqBlock' || String(nodeNow.attrs['blockType'] ?? '') !== blockType) {
|
|
114
|
-
posRef.current = null;
|
|
115
|
-
setPos(null);
|
|
116
|
-
onClose();
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
posRef.current = mapped;
|
|
120
|
-
setPos(mapped);
|
|
121
|
-
};
|
|
122
|
-
editor.on('transaction', handler);
|
|
123
|
-
return () => { editor.off('transaction', handler); };
|
|
124
|
-
}, [editor, blockType, pos, onClose]);
|
|
125
|
-
const writeBack = useCallback((nextValues) => {
|
|
126
|
-
const at = posRef.current;
|
|
127
|
-
if (at === null)
|
|
128
|
-
return;
|
|
129
|
-
// ProseMirror's `setNodeMarkup` lives on the transaction, not the
|
|
130
|
-
// ChainedCommands surface — go through `tr` directly. Pass `null`
|
|
131
|
-
// for the node-type arg to keep the existing type, just swap attrs.
|
|
132
|
-
const view = editor.view;
|
|
133
|
-
const state = editor.state;
|
|
134
|
-
const tr = state.tr.setNodeMarkup(at, null, { blockType, blockData: nextValues });
|
|
135
|
-
view.dispatch(tr);
|
|
136
|
-
}, [editor, blockType]);
|
|
137
|
-
const handleChange = useCallback(() => {
|
|
138
|
-
const formEl = formRef.current;
|
|
139
|
-
if (!formEl || !meta)
|
|
140
|
-
return;
|
|
141
|
-
// Snapshot the full form: nested arrays / objects materialize from
|
|
142
|
-
// dotted-path names (`items.0.title`), JSON-encoded hidden inputs
|
|
143
|
-
// (TagsInput / KeyValue / FileUpload-multi) sit as JSON strings,
|
|
144
|
-
// toggle / checkbox hidden inputs sit as `'true' | 'false'`. The
|
|
145
|
-
// coerce pass below normalizes those to canonical shapes.
|
|
146
|
-
const raw = parseFormDataToNested(new FormData(formEl));
|
|
147
|
-
const coerced = coerceBlockValues(raw, meta.schema);
|
|
148
|
-
writeBack(coerced);
|
|
149
|
-
}, [meta, writeBack]);
|
|
150
|
-
if (!meta || pos === null)
|
|
151
|
-
return null;
|
|
152
|
-
return (_jsxs("aside", { ref: asideRef, role: "dialog", "aria-label": `Edit ${meta.label}`, style: { width }, className: "absolute top-0 left-full ml-4 max-h-[calc(100vh-2rem)] overflow-y-auto rounded-lg border bg-background shadow-lg z-30", children: [_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize panel", onPointerDown: onResizeStart, className: "absolute left-0 top-0 h-full w-1 cursor-ew-resize hover:bg-border/80" }), _jsxs("header", { className: "sticky top-0 z-10 flex items-center justify-between gap-2 border-b bg-background px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [meta.icon && _jsx("span", { "aria-hidden": "true", children: meta.icon }), _jsx("span", { className: "text-sm font-medium truncate", children: meta.label })] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close panel", className: "text-muted-foreground hover:text-foreground text-sm", children: "\u00D7" })] }), _jsx("form", { ref: formRef, onInput: handleChange, onChange: handleChange, onSubmit: (e) => { e.preventDefault(); }, className: "flex flex-col gap-3 px-3 py-3", children: _jsx(FormFields, { elements: meta.schema, values: initialValuesRef.current }) })] }));
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Clamp + sanitize a candidate panel width against this panel's bounds
|
|
156
|
-
* (`[PANEL_WIDTH_MIN, PANEL_WIDTH_MAX]`, default `PANEL_WIDTH_DEFAULT`).
|
|
157
|
-
* Thin wrapper around the shared `clampPanelWidth` helper from
|
|
158
|
-
* `@pilotiq/pilotiq/react` — kept exported with the panel-specific
|
|
159
|
-
* defaults baked in so existing tests + downstream callers don't have
|
|
160
|
-
* to plumb the bounds themselves.
|
|
161
|
-
*/
|
|
162
|
-
export function clampPanelWidth(value) {
|
|
163
|
-
return clampPanelWidthShared(value, {
|
|
164
|
-
min: PANEL_WIDTH_MIN,
|
|
165
|
-
max: PANEL_WIDTH_MAX,
|
|
166
|
-
defaultWidth: PANEL_WIDTH_DEFAULT,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Per-fieldType coerce of a nested values map (built by
|
|
171
|
-
* `parseFormDataToNested`) against the block's schema. Mirrors the
|
|
172
|
-
* server-side `coerceFormValues` at a small subset suitable for the
|
|
173
|
-
* side panel — we only run on top-level block fields plus the immediate
|
|
174
|
-
* children of any Repeater rows / Builder rows.data, which is all the
|
|
175
|
-
* V2 surface needs.
|
|
176
|
-
*
|
|
177
|
-
* Non-coerce passthrough for: text, textarea, select, radio, date,
|
|
178
|
-
* dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
|
|
179
|
-
* is already a plain string / array of strings.)
|
|
180
|
-
*
|
|
181
|
-
* Coerce branches:
|
|
182
|
-
* - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
|
|
183
|
-
* - `number` / `slider`: parse to Number, null on empty, raw string
|
|
184
|
-
* passthrough on NaN (so a half-typed value isn't lost).
|
|
185
|
-
* - `tagsInput`: JSON-encoded string → string[].
|
|
186
|
-
* - `checkboxList`: JSON-encoded string OR array → string[].
|
|
187
|
-
* - `keyValue`: JSON-encoded string → Record<string, unknown>.
|
|
188
|
-
* - `fileUpload`: single → URL string passthrough; multiple →
|
|
189
|
-
* JSON-encoded string → string[].
|
|
190
|
-
* - `repeater`: each row in the array gets recursive coerce against
|
|
191
|
-
* the field's `template` (the inner field schema definition).
|
|
192
|
-
* - `builder`: each row's `data` gets recursive coerce against the
|
|
193
|
-
* block matching `row.type` from `field.blocks[]`. Unknown block
|
|
194
|
-
* types pass through verbatim — the renderer shows a placeholder
|
|
195
|
-
* and the data round-trips intact across config rollbacks.
|
|
196
|
-
*
|
|
197
|
-
* Exported for unit tests. Pure — no React, no DOM, no editor.
|
|
198
|
-
*/
|
|
199
|
-
export function coerceBlockValues(raw, schema) {
|
|
200
|
-
const out = { ...raw };
|
|
201
|
-
for (const field of schema) {
|
|
202
|
-
const name = String(field['name'] ?? '');
|
|
203
|
-
if (!name)
|
|
204
|
-
continue;
|
|
205
|
-
const ft = String(field['fieldType'] ?? 'text');
|
|
206
|
-
const value = out[name];
|
|
207
|
-
out[name] = coerceField(value, ft, field);
|
|
208
|
-
}
|
|
209
|
-
return out;
|
|
210
|
-
}
|
|
211
|
-
function coerceField(value, ft, field) {
|
|
212
|
-
switch (ft) {
|
|
213
|
-
case 'toggle':
|
|
214
|
-
case 'checkbox':
|
|
215
|
-
return value === 'true' || value === true;
|
|
216
|
-
case 'number':
|
|
217
|
-
case 'slider':
|
|
218
|
-
return coerceNumber(value);
|
|
219
|
-
case 'tagsInput':
|
|
220
|
-
return parseJsonArray(value);
|
|
221
|
-
case 'checkboxList':
|
|
222
|
-
return parseJsonArray(value);
|
|
223
|
-
case 'keyValue':
|
|
224
|
-
return parseJsonObject(value);
|
|
225
|
-
case 'fileUpload': {
|
|
226
|
-
const multiple = Boolean(field['multiple']);
|
|
227
|
-
if (multiple)
|
|
228
|
-
return parseJsonArray(value);
|
|
229
|
-
return typeof value === 'string' ? value : '';
|
|
230
|
-
}
|
|
231
|
-
case 'repeater': {
|
|
232
|
-
if (!Array.isArray(value))
|
|
233
|
-
return [];
|
|
234
|
-
const template = field['template'] ?? [];
|
|
235
|
-
return value.map((row) => {
|
|
236
|
-
if (!row || typeof row !== 'object')
|
|
237
|
-
return {};
|
|
238
|
-
return coerceBlockValues(row, template);
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
case 'builder': {
|
|
242
|
-
if (!Array.isArray(value))
|
|
243
|
-
return [];
|
|
244
|
-
const blockMetas = field['blocks'] ?? [];
|
|
245
|
-
return value.map((row) => {
|
|
246
|
-
if (!row || typeof row !== 'object')
|
|
247
|
-
return { type: '', data: {} };
|
|
248
|
-
const r = row;
|
|
249
|
-
const type = String(r['type'] ?? '');
|
|
250
|
-
const data = r['data'] ?? {};
|
|
251
|
-
const block = blockMetas.find((b) => String(b['name'] ?? '') === type);
|
|
252
|
-
if (!block)
|
|
253
|
-
return { type, data };
|
|
254
|
-
const tpl = block['template'] ?? [];
|
|
255
|
-
return { type, data: coerceBlockValues(data, tpl) };
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
default:
|
|
259
|
-
return value === undefined ? '' : value;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
function coerceNumber(value) {
|
|
263
|
-
if (value === '' || value === null || value === undefined)
|
|
264
|
-
return null;
|
|
265
|
-
if (typeof value === 'number')
|
|
266
|
-
return value;
|
|
267
|
-
const raw = String(value);
|
|
268
|
-
if (raw === '')
|
|
269
|
-
return null;
|
|
270
|
-
const n = Number(raw);
|
|
271
|
-
return Number.isNaN(n) ? raw : n;
|
|
272
|
-
}
|
|
273
|
-
function parseJsonArray(value) {
|
|
274
|
-
if (Array.isArray(value))
|
|
275
|
-
return value;
|
|
276
|
-
if (typeof value !== 'string' || value === '')
|
|
277
|
-
return [];
|
|
278
|
-
try {
|
|
279
|
-
const parsed = JSON.parse(value);
|
|
280
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
return [];
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
function parseJsonObject(value) {
|
|
287
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
288
|
-
return value;
|
|
289
|
-
}
|
|
290
|
-
if (typeof value !== 'string' || value === '')
|
|
291
|
-
return {};
|
|
292
|
-
try {
|
|
293
|
-
const parsed = JSON.parse(value);
|
|
294
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
295
|
-
return parsed;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
catch { /* fall through */ }
|
|
299
|
-
return {};
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Read the resolved field value for a given input event target. Kept
|
|
303
|
-
* for back-compat — V1 used this in the per-event handler. V2 reads
|
|
304
|
-
* the entire form via `FormData` instead, but this helper still maps
|
|
305
|
-
* cleanly onto a single-input read for testing and is exported.
|
|
306
|
-
*
|
|
307
|
-
* String passthrough for the common case; explicit coercion for
|
|
308
|
-
* booleans and numerics so the round-trip into the node attrs preserves
|
|
309
|
-
* shape.
|
|
310
|
-
*/
|
|
311
|
-
export function readBlockFieldValue(target, fieldMeta) {
|
|
312
|
-
const ft = String(fieldMeta.fieldType ?? 'text');
|
|
313
|
-
if (ft === 'toggle' || ft === 'checkbox') {
|
|
314
|
-
return target.checked === true;
|
|
315
|
-
}
|
|
316
|
-
if (ft === 'number' || ft === 'slider') {
|
|
317
|
-
const raw = target.value;
|
|
318
|
-
if (raw === '')
|
|
319
|
-
return null;
|
|
320
|
-
const n = Number(raw);
|
|
321
|
-
return Number.isNaN(n) ? raw : n;
|
|
322
|
-
}
|
|
323
|
-
return target.value;
|
|
324
|
-
}
|
|
325
|
-
function readBlockData(editor, pos) {
|
|
326
|
-
const node = nodeAt(editor, pos);
|
|
327
|
-
if (!node)
|
|
328
|
-
return {};
|
|
329
|
-
return node.attrs['blockData'] ?? {};
|
|
330
|
-
}
|
|
331
|
-
function nodeAt(editor, pos) {
|
|
332
|
-
try {
|
|
333
|
-
return editor.state.doc.nodeAt(pos);
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
336
|
-
return null;
|
|
337
|
-
}
|
|
338
|
-
}
|