@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/extensions/BlockNodeExtension.d.ts +0 -9
  3. package/dist/extensions/BlockNodeExtension.js +0 -20
  4. package/dist/extensions/{AiInlineDiffExtension.d.ts → InlineDiffExtension.d.ts} +23 -23
  5. package/dist/extensions/{AiInlineDiffExtension.js → InlineDiffExtension.js} +33 -33
  6. package/dist/extensions/{AiSuggestionExtension.d.ts → SuggestionChipExtension.d.ts} +29 -29
  7. package/dist/extensions/{AiSuggestionExtension.js → SuggestionChipExtension.js} +52 -52
  8. package/dist/index.d.ts +4 -4
  9. package/dist/index.js +4 -4
  10. package/dist/react/BlockNodeView.d.ts +23 -12
  11. package/dist/react/BlockNodeView.js +55 -21
  12. package/dist/react/CollabTextRenderer.js +16 -16
  13. package/dist/react/MarkdownEditor.js +17 -17
  14. package/dist/react/{AiSuggestionBanner.d.ts → SuggestionBanner.d.ts} +6 -6
  15. package/dist/react/{AiSuggestionBanner.js → SuggestionBanner.js} +5 -5
  16. package/dist/react/TiptapEditor.js +24 -59
  17. package/dist/react/blockValues.d.ts +54 -0
  18. package/dist/react/blockValues.js +161 -0
  19. package/dist/react/floatingToolbarVisibility.d.ts +9 -2
  20. package/dist/react/floatingToolbarVisibility.js +12 -3
  21. package/dist/react/{useAiInlineDiff.d.ts → useInlineDiff.d.ts} +13 -13
  22. package/dist/react/{useAiInlineDiff.js → useInlineDiff.js} +36 -19
  23. package/dist/react/{useAiSuggestionBridge.d.ts → useSuggestionBridge.d.ts} +7 -7
  24. package/dist/react/{useAiSuggestionBridge.js → useSuggestionBridge.js} +10 -10
  25. package/dist/surgicalOps.d.ts +15 -2
  26. package/dist/surgicalOps.js +50 -3
  27. package/package.json +1 -1
  28. package/dist/react/BlockSidePanel.d.ts +0 -105
  29. package/dist/react/BlockSidePanel.js +0 -338
@@ -1,7 +1,7 @@
1
1
  import { Extension } from '@tiptap/core';
2
2
  import { Plugin, PluginKey } from '@tiptap/pm/state';
3
3
  import { Decoration, DecorationSet } from '@tiptap/pm/view';
4
- export const aiSuggestionPluginKey = new PluginKey('pilotiqAiSuggestion');
4
+ export const suggestionChipPluginKey = new PluginKey('pilotiqSuggestionChip');
5
5
  /**
6
6
  * Append or replace by id. Pure — exported for tests and so the same dedupe
7
7
  * shape can drive consumer-side mirror state.
@@ -55,7 +55,7 @@ export function sortForApproveAll(suggestions) {
55
55
  *
56
56
  * Usage:
57
57
  * ```ts
58
- * editor.commands.addAiSuggestion({
58
+ * editor.commands.addSuggestion({
59
59
  * id: 'seo-1',
60
60
  * from: 12,
61
61
  * to: 18,
@@ -63,30 +63,30 @@ export function sortForApproveAll(suggestions) {
63
63
  * source: { agentLabel: 'SEO' },
64
64
  * })
65
65
  * // …user clicks ✓ on the chip, or:
66
- * editor.commands.approveAiSuggestion('seo-1')
66
+ * editor.commands.approveSuggestion('seo-1')
67
67
  * ```
68
68
  *
69
69
  * Mounted by default inside `TiptapEditor`; consumer code reaches it through
70
70
  * the editor's command surface.
71
71
  */
72
- export const AiSuggestionExtension = Extension.create({
73
- name: 'pilotiqAiSuggestion',
72
+ export const SuggestionChipExtension = Extension.create({
73
+ name: 'pilotiqSuggestionChip',
74
74
  addOptions() {
75
75
  return {
76
- classPrefix: 'pilotiq-ai-suggestion',
76
+ classPrefix: 'pilotiq-suggestion',
77
77
  };
78
78
  },
79
79
  onCreate() {
80
80
  // Inject minimal default styles for the chip + strikethrough on first
81
81
  // mount so consumers see the visualization without wiring CSS. Idempotent
82
- // via the `data-pilotiq-ai-suggestion-styles` sentinel; consumers who
82
+ // via the `data-pilotiq-suggestion-styles` sentinel; consumers who
83
83
  // want full control just add their own `<style>` with the same class
84
84
  // names (last wins — the cascade picks user overrides over our defaults
85
85
  // since the user stylesheet appears AFTER our injected one in `<head>`
86
86
  // when imported via Vite/Webpack, OR via higher specificity).
87
87
  if (typeof document === 'undefined')
88
88
  return;
89
- const SENTINEL = 'data-pilotiq-ai-suggestion-styles';
89
+ const SENTINEL = 'data-pilotiq-suggestion-styles';
90
90
  if (document.head.querySelector(`style[${SENTINEL}]`))
91
91
  return;
92
92
  const prefix = this.options.classPrefix;
@@ -134,10 +134,10 @@ export const AiSuggestionExtension = Extension.create({
134
134
  /* Banner — bottom-of-editor strip for whole-field suggestions on rich
135
135
  surfaces (markdown / richtext). Sibling to the chip styles above;
136
136
  lives here so both ship via the same extension-mount sentinel.
137
- Class names live under \`pilotiq-ai-banner-*\` (not \`-suggestion-\`)
137
+ Class names live under \`pilotiq-suggestion-banner-*\` (not \`-suggestion-\`)
138
138
  since the banner is a host-mounted React component, not a PM
139
139
  decoration. */
140
- .pilotiq-ai-banner {
140
+ .pilotiq-suggestion-banner {
141
141
  display: flex;
142
142
  align-items: center;
143
143
  gap: 0.5rem;
@@ -150,15 +150,15 @@ export const AiSuggestionExtension = Extension.create({
150
150
  font-size: 0.875rem;
151
151
  line-height: 1.4;
152
152
  }
153
- .pilotiq-ai-banner-icon { flex: 0 0 auto; }
154
- .pilotiq-ai-banner-label { flex: 1 1 auto; }
155
- .pilotiq-ai-banner-actions {
153
+ .pilotiq-suggestion-banner-icon { flex: 0 0 auto; }
154
+ .pilotiq-suggestion-banner-label { flex: 1 1 auto; }
155
+ .pilotiq-suggestion-banner-actions {
156
156
  display: inline-flex;
157
157
  gap: 0.375rem;
158
158
  flex: 0 0 auto;
159
159
  }
160
- .pilotiq-ai-banner-reject,
161
- .pilotiq-ai-banner-accept {
160
+ .pilotiq-suggestion-banner-reject,
161
+ .pilotiq-suggestion-banner-accept {
162
162
  appearance: none;
163
163
  cursor: pointer;
164
164
  font-size: 0.8125rem;
@@ -168,50 +168,50 @@ export const AiSuggestionExtension = Extension.create({
168
168
  border-radius: 0.25rem;
169
169
  border: 1px solid transparent;
170
170
  }
171
- .pilotiq-ai-banner-reject {
171
+ .pilotiq-suggestion-banner-reject {
172
172
  background-color: transparent;
173
173
  color: rgb(120, 53, 15);
174
174
  border-color: rgba(180, 83, 9, 0.4);
175
175
  }
176
- .pilotiq-ai-banner-reject:hover {
176
+ .pilotiq-suggestion-banner-reject:hover {
177
177
  background-color: rgba(254, 215, 170, 0.4);
178
178
  }
179
- .pilotiq-ai-banner-accept {
179
+ .pilotiq-suggestion-banner-accept {
180
180
  background-color: rgb(22, 101, 52);
181
181
  color: white;
182
182
  }
183
- .pilotiq-ai-banner-accept:hover { background-color: rgb(21, 128, 61); }
183
+ .pilotiq-suggestion-banner-accept:hover { background-color: rgb(21, 128, 61); }
184
184
  `;
185
185
  document.head.appendChild(style);
186
186
  },
187
187
  addCommands() {
188
188
  return {
189
- addAiSuggestion: (suggestion) => ({ tr, state, dispatch }) => {
190
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? [];
189
+ addSuggestion: (suggestion) => ({ tr, state, dispatch }) => {
190
+ const current = suggestionChipPluginKey.getState(state)?.suggestions ?? [];
191
191
  const next = upsertSuggestion(current, suggestion);
192
192
  if (dispatch) {
193
- tr.setMeta(aiSuggestionPluginKey, { type: 'set', next });
193
+ tr.setMeta(suggestionChipPluginKey, { type: 'set', next });
194
194
  dispatch(tr);
195
195
  }
196
196
  return true;
197
197
  },
198
- addAiSuggestions: (suggestions) => ({ tr, state, dispatch }) => {
199
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? [];
198
+ addSuggestions: (suggestions) => ({ tr, state, dispatch }) => {
199
+ const current = suggestionChipPluginKey.getState(state)?.suggestions ?? [];
200
200
  const next = upsertSuggestions(current, suggestions);
201
201
  if (dispatch) {
202
- tr.setMeta(aiSuggestionPluginKey, { type: 'set', next });
202
+ tr.setMeta(suggestionChipPluginKey, { type: 'set', next });
203
203
  dispatch(tr);
204
204
  }
205
205
  return true;
206
206
  },
207
- approveAiSuggestion: (id) => ({ tr, state, dispatch }) => {
208
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? [];
207
+ approveSuggestion: (id) => ({ tr, state, dispatch }) => {
208
+ const current = suggestionChipPluginKey.getState(state)?.suggestions ?? [];
209
209
  const target = current.find(s => s.id === id);
210
210
  if (!target)
211
211
  return false;
212
212
  if (dispatch) {
213
213
  applyApprove(tr, state, target);
214
- tr.setMeta(aiSuggestionPluginKey, {
214
+ tr.setMeta(suggestionChipPluginKey, {
215
215
  type: 'set',
216
216
  next: removeSuggestion(current, id),
217
217
  });
@@ -219,13 +219,13 @@ export const AiSuggestionExtension = Extension.create({
219
219
  }
220
220
  return true;
221
221
  },
222
- rejectAiSuggestion: (id) => ({ tr, state, dispatch }) => {
223
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? [];
222
+ rejectSuggestion: (id) => ({ tr, state, dispatch }) => {
223
+ const current = suggestionChipPluginKey.getState(state)?.suggestions ?? [];
224
224
  const target = current.find(s => s.id === id);
225
225
  if (!target)
226
226
  return false;
227
227
  if (dispatch) {
228
- tr.setMeta(aiSuggestionPluginKey, {
228
+ tr.setMeta(suggestionChipPluginKey, {
229
229
  type: 'set',
230
230
  next: removeSuggestion(current, id),
231
231
  });
@@ -233,14 +233,14 @@ export const AiSuggestionExtension = Extension.create({
233
233
  }
234
234
  return true;
235
235
  },
236
- approveAllAiSuggestions: () => ({ tr, state, dispatch }) => {
237
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? [];
236
+ approveAllSuggestions: () => ({ tr, state, dispatch }) => {
237
+ const current = suggestionChipPluginKey.getState(state)?.suggestions ?? [];
238
238
  if (current.length === 0)
239
239
  return false;
240
240
  if (dispatch) {
241
241
  for (const s of sortForApproveAll(current))
242
242
  applyApprove(tr, state, s);
243
- tr.setMeta(aiSuggestionPluginKey, {
243
+ tr.setMeta(suggestionChipPluginKey, {
244
244
  type: 'set',
245
245
  next: [],
246
246
  });
@@ -248,12 +248,12 @@ export const AiSuggestionExtension = Extension.create({
248
248
  }
249
249
  return true;
250
250
  },
251
- rejectAllAiSuggestions: () => ({ tr, state, dispatch }) => {
252
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? [];
251
+ rejectAllSuggestions: () => ({ tr, state, dispatch }) => {
252
+ const current = suggestionChipPluginKey.getState(state)?.suggestions ?? [];
253
253
  if (current.length === 0)
254
254
  return false;
255
255
  if (dispatch) {
256
- tr.setMeta(aiSuggestionPluginKey, {
256
+ tr.setMeta(suggestionChipPluginKey, {
257
257
  type: 'set',
258
258
  next: [],
259
259
  });
@@ -261,12 +261,12 @@ export const AiSuggestionExtension = Extension.create({
261
261
  }
262
262
  return true;
263
263
  },
264
- clearAiSuggestions: () => ({ tr, state, dispatch }) => {
265
- const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? [];
264
+ clearSuggestions: () => ({ tr, state, dispatch }) => {
265
+ const current = suggestionChipPluginKey.getState(state)?.suggestions ?? [];
266
266
  if (current.length === 0)
267
267
  return false;
268
268
  if (dispatch) {
269
- tr.setMeta(aiSuggestionPluginKey, {
269
+ tr.setMeta(suggestionChipPluginKey, {
270
270
  type: 'set',
271
271
  next: [],
272
272
  });
@@ -280,11 +280,11 @@ export const AiSuggestionExtension = Extension.create({
280
280
  const ext = this;
281
281
  return [
282
282
  new Plugin({
283
- key: aiSuggestionPluginKey,
283
+ key: suggestionChipPluginKey,
284
284
  state: {
285
285
  init: () => ({ suggestions: [] }),
286
286
  apply(tr, prev) {
287
- const meta = tr.getMeta(aiSuggestionPluginKey);
287
+ const meta = tr.getMeta(suggestionChipPluginKey);
288
288
  const base = meta?.type === 'set' ? meta.next : prev.suggestions;
289
289
  if (!tr.docChanged)
290
290
  return { suggestions: base };
@@ -295,17 +295,17 @@ export const AiSuggestionExtension = Extension.create({
295
295
  },
296
296
  props: {
297
297
  decorations(state) {
298
- const ps = aiSuggestionPluginKey.getState(state);
298
+ const ps = suggestionChipPluginKey.getState(state);
299
299
  if (!ps || ps.suggestions.length === 0)
300
300
  return DecorationSet.empty;
301
301
  return buildDecorations(state, ps.suggestions, ext.options.classPrefix, ext.editor);
302
302
  },
303
303
  },
304
304
  view(view) {
305
- let last = aiSuggestionPluginKey.getState(view.state)?.suggestions;
305
+ let last = suggestionChipPluginKey.getState(view.state)?.suggestions;
306
306
  return {
307
307
  update(updated) {
308
- const next = aiSuggestionPluginKey.getState(updated.state)?.suggestions;
308
+ const next = suggestionChipPluginKey.getState(updated.state)?.suggestions;
309
309
  if (next === last)
310
310
  return;
311
311
  last = next;
@@ -344,13 +344,13 @@ function buildDecorations(state, suggestions, prefix, editor) {
344
344
  if (from < to) {
345
345
  decos.push(Decoration.inline(from, to, {
346
346
  class: `${prefix}-original`,
347
- 'data-pilotiq-ai-suggestion-id': s.id,
347
+ 'data-pilotiq-suggestion-id': s.id,
348
348
  }));
349
349
  }
350
350
  decos.push(Decoration.widget(to, () => buildChip(s, prefix, editor), {
351
351
  side: 1,
352
352
  ignoreSelection: true,
353
- key: `pilotiq-ai-suggestion:${s.id}`,
353
+ key: `pilotiq-suggestion:${s.id}`,
354
354
  }));
355
355
  }
356
356
  return DecorationSet.create(state.doc, decos);
@@ -368,7 +368,7 @@ export function clampPos(pos, max) {
368
368
  function buildChip(s, prefix, editor) {
369
369
  const root = document.createElement('span');
370
370
  root.className = `${prefix}-chip`;
371
- root.setAttribute('data-pilotiq-ai-suggestion-id', s.id);
371
+ root.setAttribute('data-pilotiq-suggestion-id', s.id);
372
372
  root.contentEditable = 'false';
373
373
  if (s.replacement.length > 0) {
374
374
  const insert = document.createElement('span');
@@ -377,16 +377,16 @@ function buildChip(s, prefix, editor) {
377
377
  root.appendChild(insert);
378
378
  }
379
379
  if (s.source?.agentLabel) {
380
- root.setAttribute('data-pilotiq-ai-suggestion-source', s.source.agentLabel);
380
+ root.setAttribute('data-pilotiq-suggestion-source', s.source.agentLabel);
381
381
  }
382
382
  if (s.source?.agentSlug) {
383
- root.setAttribute('data-pilotiq-ai-suggestion-source-slug', s.source.agentSlug);
383
+ root.setAttribute('data-pilotiq-suggestion-source-slug', s.source.agentSlug);
384
384
  }
385
385
  root.appendChild(buildButton(prefix, 'accept', '✓', 'Accept suggestion', () => {
386
- editor.chain().focus().approveAiSuggestion(s.id).run();
386
+ editor.chain().focus().approveSuggestion(s.id).run();
387
387
  }));
388
388
  root.appendChild(buildButton(prefix, 'reject', '✕', 'Reject suggestion', () => {
389
- editor.chain().focus().rejectAiSuggestion(s.id).run();
389
+ editor.chain().focus().rejectSuggestion(s.id).run();
390
390
  }));
391
391
  return root;
392
392
  }
package/dist/index.d.ts CHANGED
@@ -6,10 +6,10 @@ export { registerTiptap } from './register.js';
6
6
  export { createPlainTextEditor, plainTextOf, plainTextToDoc, type PlainTextEditorOptions, } from './PlainTextEditor.js';
7
7
  export { tiptap } from './plugin.js';
8
8
  export { TiptapEditor } from './react/TiptapEditor.js';
9
- export { AiSuggestionExtension, aiSuggestionPluginKey, upsertSuggestion, upsertSuggestions, removeSuggestion, remapSuggestions, sortForApproveAll, clampPos, type AiSuggestion, type AiSuggestionExtensionOptions, } from './extensions/AiSuggestionExtension.js';
10
- export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js';
11
- export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, type AiInlineDiffExtensionOptions, } from './extensions/AiInlineDiffExtension.js';
12
- export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planWrapBlocks, planUpdateBlockMark, summarizeBlockStructure, type BlockMarkRange, type TransactionModifier, } from './surgicalOps.js';
9
+ export { SuggestionChipExtension, suggestionChipPluginKey, upsertSuggestion, upsertSuggestions, removeSuggestion, remapSuggestions, sortForApproveAll, clampPos, type InlineSuggestion, type SuggestionChipExtensionOptions, } from './extensions/SuggestionChipExtension.js';
10
+ export { useSuggestionBridge } from './react/useSuggestionBridge.js';
11
+ export { InlineDiffExtension, inlineDiffPluginKey, getInlineDiffState, type InlineDiffExtensionOptions, } from './extensions/InlineDiffExtension.js';
12
+ export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planWrapBlocks, planUpdateBlockMark, planReplaceText, 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
15
  export { shouldShowFloatingToolbar, TOOLBAR_MARKS } from './react/floatingToolbarVisibility.js';
package/dist/index.js CHANGED
@@ -6,10 +6,10 @@ export { registerTiptap } from './register.js';
6
6
  export { createPlainTextEditor, plainTextOf, plainTextToDoc, } from './PlainTextEditor.js';
7
7
  export { tiptap } from './plugin.js';
8
8
  export { TiptapEditor } from './react/TiptapEditor.js';
9
- export { AiSuggestionExtension, aiSuggestionPluginKey, upsertSuggestion, upsertSuggestions, removeSuggestion, remapSuggestions, sortForApproveAll, clampPos, } from './extensions/AiSuggestionExtension.js';
10
- export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js';
11
- export { AiInlineDiffExtension, aiInlineDiffPluginKey, getAiInlineDiffState, } from './extensions/AiInlineDiffExtension.js';
12
- export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planWrapBlocks, planUpdateBlockMark, summarizeBlockStructure, } from './surgicalOps.js';
9
+ export { SuggestionChipExtension, suggestionChipPluginKey, upsertSuggestion, upsertSuggestions, removeSuggestion, remapSuggestions, sortForApproveAll, clampPos, } from './extensions/SuggestionChipExtension.js';
10
+ export { useSuggestionBridge } from './react/useSuggestionBridge.js';
11
+ export { InlineDiffExtension, inlineDiffPluginKey, getInlineDiffState, } from './extensions/InlineDiffExtension.js';
12
+ export { planReplaceBlock, planInsertBlockBefore, planDeleteBlock, planWrapBlocks, planUpdateBlockMark, planReplaceText, summarizeBlockStructure, } from './surgicalOps.js';
13
13
  export { renderRichTextToHtml, isRichTextValue, } from './render.js';
14
14
  // Default content-block node specs (Intro / FAQ / Alert / Summary / Key takeaways /
15
15
  // Pros & cons). `contentBlockNodes` is the exact array `TiptapEditor` registers,
@@ -1,19 +1,30 @@
1
1
  import { type NodeViewProps } from '@tiptap/react';
2
2
  /**
3
- * Generic React NodeView for the `pilotiqBlock` ProseMirror node. Reads
4
- * the block type from `node.attrs.blockType`, looks up its `BlockMeta`
5
- * in `BlockNodeExtension.options.blocks`, and renders a compact inline
6
- * summary card with an "Edit" button.
3
+ * React NodeView for the `pilotiqBlock` ProseMirror node. Reads the block
4
+ * type from `node.attrs.blockType`, looks up its `BlockMeta` in
5
+ * `BlockNodeExtension.options.blocks`, and renders a compact summary card.
7
6
  *
8
- * Editing happens in a side panel hosted by `TiptapEditor`, NOT inline.
9
- * The NodeView fires `BlockNodeExtension.options.onEdit(getPos())` when
10
- * the Edit button is clicked; the host opens its panel anchored to the
11
- * editor wrapper. NodeViews live in a separate React tree from the host
12
- * editor, so the bridge has to go through extension options context
13
- * doesn't cross trees.
7
+ * Editing is **inline** (accordion): clicking the card (or the Edit chevron)
8
+ * expands a panel below the summary that hosts the block's `Block.schema([…])`
9
+ * as a real pilotiq form via `<FormFields>`. Edits write straight back onto
10
+ * the node with `updateAttributes({ blockData })` on every change the
11
+ * NodeView already owns the node, so there's no host bridge / side panel /
12
+ * position-remapping to thread through.
14
13
  *
15
- * If no `onEdit` is wired (e.g. a consumer that uses `BlockNodeExtension`
16
- * standalone without `TiptapEditor`'s panel), the Edit button is hidden.
14
+ * The form is rendered in a `contentEditable={false}` region and every input
15
+ * event is stopped from bubbling into ProseMirror, so the editor never treats
16
+ * the form inputs as document content or hijacks their focus/selection.
17
+ *
18
+ * Reads: each field's `defaultValue` is overridden from the block's stored
19
+ * `blockData`, snapshotted once per expand into `initialValuesRef`. Inputs are
20
+ * uncontrolled (outside a `FormStateProvider`, pilotiq's renderers fall back to
21
+ * `defaultValue`), so write-back transactions re-rendering the NodeView never
22
+ * reset the user's in-progress typing.
23
+ *
24
+ * Writes: container-level `onInput` / `onChange` delegation. Every change
25
+ * snapshots the whole form via `new FormData(formEl)` → `parseFormDataToNested`
26
+ * (rebuilds nested arrays/objects from dotted-path inputs like `items.0.title`)
27
+ * → `coerceBlockValues` (per-fieldType JSON parse / boolean / number coerce).
17
28
  */
18
29
  export declare function BlockNodeView(props: NodeViewProps): import("react").JSX.Element | null;
19
30
  //# sourceMappingURL=BlockNodeView.d.ts.map
@@ -1,33 +1,52 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { NodeViewWrapper } from '@tiptap/react';
4
+ import { FormFields, parseFormDataToNested } from '@pilotiq/pilotiq/react';
5
+ import { coerceBlockValues } from './blockValues.js';
4
6
  /**
5
- * Generic React NodeView for the `pilotiqBlock` ProseMirror node. Reads
6
- * the block type from `node.attrs.blockType`, looks up its `BlockMeta`
7
- * in `BlockNodeExtension.options.blocks`, and renders a compact inline
8
- * summary card with an "Edit" button.
7
+ * React NodeView for the `pilotiqBlock` ProseMirror node. Reads the block
8
+ * type from `node.attrs.blockType`, looks up its `BlockMeta` in
9
+ * `BlockNodeExtension.options.blocks`, and renders a compact summary card.
9
10
  *
10
- * Editing happens in a side panel hosted by `TiptapEditor`, NOT inline.
11
- * The NodeView fires `BlockNodeExtension.options.onEdit(getPos())` when
12
- * the Edit button is clicked; the host opens its panel anchored to the
13
- * editor wrapper. NodeViews live in a separate React tree from the host
14
- * editor, so the bridge has to go through extension options context
15
- * doesn't cross trees.
11
+ * Editing is **inline** (accordion): clicking the card (or the Edit chevron)
12
+ * expands a panel below the summary that hosts the block's `Block.schema([…])`
13
+ * as a real pilotiq form via `<FormFields>`. Edits write straight back onto
14
+ * the node with `updateAttributes({ blockData })` on every change the
15
+ * NodeView already owns the node, so there's no host bridge / side panel /
16
+ * position-remapping to thread through.
16
17
  *
17
- * If no `onEdit` is wired (e.g. a consumer that uses `BlockNodeExtension`
18
- * standalone without `TiptapEditor`'s panel), the Edit button is hidden.
18
+ * The form is rendered in a `contentEditable={false}` region and every input
19
+ * event is stopped from bubbling into ProseMirror, so the editor never treats
20
+ * the form inputs as document content or hijacks their focus/selection.
21
+ *
22
+ * Reads: each field's `defaultValue` is overridden from the block's stored
23
+ * `blockData`, snapshotted once per expand into `initialValuesRef`. Inputs are
24
+ * uncontrolled (outside a `FormStateProvider`, pilotiq's renderers fall back to
25
+ * `defaultValue`), so write-back transactions re-rendering the NodeView never
26
+ * reset the user's in-progress typing.
27
+ *
28
+ * Writes: container-level `onInput` / `onChange` delegation. Every change
29
+ * snapshots the whole form via `new FormData(formEl)` → `parseFormDataToNested`
30
+ * (rebuilds nested arrays/objects from dotted-path inputs like `items.0.title`)
31
+ * → `coerceBlockValues` (per-fieldType JSON parse / boolean / number coerce).
19
32
  */
20
33
  export function BlockNodeView(props) {
21
- const { editor, node, getPos, deleteNode } = props;
34
+ const { editor, node, deleteNode, updateAttributes } = props;
22
35
  const blockType = String(node.attrs['blockType'] ?? '');
23
36
  const blockData = node.attrs['blockData'] ?? {};
37
+ const editable = editor.isEditable;
24
38
  // Tiptap mounts NodeViews in a separate React tree, so we can't read the
25
39
  // block registry through context. Pull it off the extension's options
26
40
  // instead — set by RichTextField via BlockNodeExtension.configure({ blocks }).
27
41
  const blockExt = editor.extensionManager.extensions.find((e) => e.name === 'pilotiqBlock');
28
42
  const blocks = blockExt?.options['blocks'] ?? [];
29
- const onEdit = blockExt?.options['onEdit'];
30
43
  const meta = blocks.find((b) => b.name === blockType);
44
+ const [expanded, setExpanded] = useState(false);
45
+ // Seeds the form's `defaultValue`s. Re-snapshotted from the live node each
46
+ // time the panel opens; not updated mid-edit (uncontrolled inputs hold their
47
+ // own state while open).
48
+ const initialValuesRef = useRef(blockData);
49
+ const formRef = useRef(null);
31
50
  // Self-heal: a block with no `blockType` is malformed — almost always
32
51
  // means a stale node from a prior buggy insert. Delete it on mount so
33
52
  // the editor doesn't get stuck in an unrecoverable state.
@@ -47,13 +66,28 @@ export function BlockNodeView(props) {
47
66
  })
48
67
  .filter(Boolean)
49
68
  .join(' · ') || meta.label;
50
- const handleEdit = () => {
51
- if (!onEdit)
69
+ const toggleExpanded = () => {
70
+ if (!editable)
52
71
  return;
53
- const pos = getPos();
54
- if (typeof pos !== 'number')
72
+ setExpanded((prev) => {
73
+ const next = !prev;
74
+ if (next) {
75
+ initialValuesRef.current =
76
+ node.attrs['blockData'] ?? {};
77
+ }
78
+ return next;
79
+ });
80
+ };
81
+ const handleChange = () => {
82
+ const formEl = formRef.current;
83
+ if (!formEl)
55
84
  return;
56
- onEdit(pos);
85
+ const raw = parseFormDataToNested(new FormData(formEl));
86
+ const coerced = coerceBlockValues(raw, meta.schema);
87
+ updateAttributes({ blockData: coerced });
57
88
  };
58
- return (_jsx(NodeViewWrapper, { className: "pilotiq-block my-3 rounded-lg border bg-muted/30", children: _jsxs("div", { className: "flex items-start justify-between gap-2 px-3 py-2", children: [_jsxs("button", { type: "button", onClick: handleEdit, disabled: !onEdit, className: "flex items-center gap-2 text-left text-sm disabled:cursor-default", children: [meta.icon && _jsx("span", { "aria-hidden": "true", children: meta.icon }), _jsx("span", { className: "font-medium", children: meta.label }), _jsx("span", { className: "text-xs text-muted-foreground line-clamp-1", children: summary })] }), _jsxs("div", { className: "flex items-center gap-2", children: [onEdit && (_jsx("button", { type: "button", onClick: handleEdit, className: "text-xs text-muted-foreground hover:text-foreground", children: "Edit" })), _jsx("button", { type: "button", onClick: () => deleteNode(), className: "text-xs text-destructive hover:underline", children: "Remove" })] })] }) }));
89
+ return (_jsxs(NodeViewWrapper, { className: "pilotiq-block my-3 rounded-lg border bg-muted/30", children: [_jsxs("div", { className: "flex items-start justify-between gap-2 px-3 py-2", children: [_jsxs("button", { type: "button", onClick: toggleExpanded, disabled: !editable, className: "flex min-w-0 items-center gap-2 text-left text-sm disabled:cursor-default", children: [meta.icon && _jsx("span", { "aria-hidden": "true", children: meta.icon }), _jsx("span", { className: "font-medium", children: meta.label }), _jsx("span", { className: "line-clamp-1 text-xs text-muted-foreground", children: summary })] }), editable && (_jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [_jsxs("button", { type: "button", onClick: toggleExpanded, "aria-expanded": expanded, className: "flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground", children: [expanded ? 'Done' : 'Edit', _jsx("svg", { viewBox: "0 0 24 24", className: 'size-3.5 transition-transform ' + (expanded ? 'rotate-180' : ''), fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }), _jsx("button", { type: "button", onClick: () => deleteNode(), className: "text-xs text-destructive hover:underline", children: "Remove" })] }))] }), expanded && editable && (
90
+ // contentEditable=false + event guards keep ProseMirror from treating
91
+ // the form inputs as document content or stealing their focus/caret.
92
+ _jsx("div", { contentEditable: false, className: "border-t px-3 py-3", onMouseDown: (e) => e.stopPropagation(), onPointerDown: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), onKeyUp: (e) => e.stopPropagation(), onPaste: (e) => e.stopPropagation(), onDrop: (e) => e.stopPropagation(), children: _jsx("form", { ref: formRef, onInput: handleChange, onChange: handleChange, onSubmit: (e) => e.preventDefault(), className: "flex flex-col gap-3", children: _jsx(FormFields, { elements: meta.schema, values: initialValuesRef.current }) }) }))] }));
59
93
  }
@@ -5,11 +5,11 @@ import { Slice } from '@tiptap/pm/model';
5
5
  import { useCollabRoom, getCollabExtensions, } from '@pilotiq/pilotiq/react';
6
6
  import { useCollabSeed } from '@rudderjs/sync/react';
7
7
  import { createPlainTextEditor, plainTextOf, plainTextToDoc } from '../PlainTextEditor.js';
8
- import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
9
- import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
10
- import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
11
- import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
12
- import { AiSuggestionBanner } from './AiSuggestionBanner.js';
8
+ import { SuggestionChipExtension } from '../extensions/SuggestionChipExtension.js';
9
+ import { InlineDiffExtension } from '../extensions/InlineDiffExtension.js';
10
+ import { useSuggestionBridge } from './useSuggestionBridge.js';
11
+ import { useInlineDiff, useIsInlineDiffActive, readDiffViewMarker } from './useInlineDiff.js';
12
+ import { SuggestionBanner } from './SuggestionBanner.js';
13
13
  /**
14
14
  * Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
15
15
  * / similar single-line / multi-line text fields when collab is on.
@@ -100,11 +100,11 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
100
100
  // AI suggestions — chip extension (producer-supplied range
101
101
  // suggestions) + inline-diff extension (whole-field suggestions:
102
102
  // red strikethrough on removed runs, green on inserted, with the
103
- // `<AiSuggestionBanner>` Accept / Reject below). Both idle until
103
+ // `<SuggestionBanner>` Accept / Reject below). Both idle until
104
104
  // a suggestion arrives via the bridges below. Matches the
105
105
  // `TiptapEditor` wiring so the review surface reads identically
106
106
  // across RichTextField / MarkdownField / TextField+TextareaField.
107
- extensions: [...collabExtensions, AiSuggestionExtension, AiInlineDiffExtension],
107
+ extensions: [...collabExtensions, SuggestionChipExtension, InlineDiffExtension],
108
108
  onUpdate: (text) => onChange(text),
109
109
  ...(onSubmit ? { onSubmit: () => { onSubmit(); return false; } } : {}),
110
110
  ...(className || editorAttributes
@@ -128,11 +128,11 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
128
128
  editor.setEditable(!disabled);
129
129
  }, [editor, disabled]);
130
130
  // Cross-package suggestion bridge — sync the host's
131
- // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
131
+ // `<PendingSuggestionsContext>` queue with the editor's `InlineSuggestion`
132
132
  // extension. No-op when no provider is mounted (default no-op context).
133
133
  //
134
134
  // Whole-field suggestions do NOT synthesize a chip range anymore —
135
- // they render through `useAiInlineDiff` below (same red/green inline
135
+ // they render through `useInlineDiff` below (same red/green inline
136
136
  // diff + banner as `TiptapEditor`), replacing the old green-pill chip
137
137
  // that read differently from the rich-text surface. The bridge stays
138
138
  // mounted for producer-supplied `meta.editorRange` suggestions (precise
@@ -143,12 +143,12 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
143
143
  return;
144
144
  editor.commands.setContent(plainTextToDoc(value, !!multiline));
145
145
  };
146
- useAiSuggestionBridge(editor ?? null, name, {
146
+ useSuggestionBridge(editor ?? null, name, {
147
147
  onApplyWholeField: applyWholeField,
148
148
  });
149
149
  // Inline diff for whole-field suggestions — plain-text shape: each
150
150
  // line wraps in a `paragraph` node, mirroring `plainTextToDoc`.
151
- useAiInlineDiff(editor ?? null, name, {
151
+ useInlineDiff(editor ?? null, name, {
152
152
  parseSuggestion: (ed, value) => {
153
153
  try {
154
154
  const node = ed.schema.nodeFromJSON(plainTextToDoc(value, !!multiline));
@@ -158,9 +158,9 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
158
158
  return null;
159
159
  }
160
160
  },
161
- resolveDisplayMode: () => readAiDiffViewMarker(name),
161
+ resolveDisplayMode: () => readDiffViewMarker(name),
162
162
  });
163
- const isDiffActive = useIsAiInlineDiffActive(editor ?? null);
163
+ const isDiffActive = useIsInlineDiffActive(editor ?? null);
164
164
  // First-load seed when collab is active. Collaboration starts the editor
165
165
  // empty regardless of `defaultValue`; once the room's first sync
166
166
  // resolves, `useCollabSeed` runs the callback inside `ydoc.transact`.
@@ -216,10 +216,10 @@ export function CollabTextRenderer({ name, fragmentKey, multiline, defaultValue,
216
216
  // Banner mounts below the editor exactly like `TiptapEditor`'s — it
217
217
  // renders nothing while no suggestion is pending for this field, so
218
218
  // the single-line text surface keeps its normal footprint.
219
- return (_jsxs(_Fragment, { children: [_jsx(EditorContent, { editor: editor }), _jsx(AiSuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
219
+ return (_jsxs(_Fragment, { children: [_jsx(EditorContent, { editor: editor }), _jsx(SuggestionBanner, { fieldName: name, onApplyWholeField: applyWholeField, ...(isDiffActive && editor
220
220
  ? {
221
- onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
222
- onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
221
+ onAcceptViaEditor: () => editor.commands.acceptInlineDiff(),
222
+ onRejectViaEditor: () => editor.commands.rejectInlineDiff(),
223
223
  }
224
224
  : {}) })] }));
225
225
  }