@lowdefy/blocks-tiptap 6.0.0 → 6.1.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.
@@ -15,12 +15,12 @@
15
15
  */ import React, { useEffect } from 'react';
16
16
  import { withBlockDefaults } from '@lowdefy/block-utils';
17
17
  import { type } from '@lowdefy/helpers';
18
- import { useEditor, EditorContent } from '@tiptap/react';
18
+ import { EditorContent } from '@tiptap/react';
19
19
  import Label from '@lowdefy/blocks-antd/blocks/Label/Label.js';
20
20
  import PopoverMenu from '../utils/PopoverMenu.js';
21
- import buildExtensions from '../utils/buildExtensions.js';
22
21
  import computeHeightStyle from '../utils/computeHeightStyle.js';
23
22
  import statusClass from '../utils/statusClass.js';
23
+ import useTiptapEditor from '../utils/useTiptapEditor.js';
24
24
  import useTiptapState from './useTiptapState.js';
25
25
  import './style.css';
26
26
  const TiptapInput = ({ blockId, components: { Icon, Link }, events, loading, methods, properties, required, validation, value })=>{
@@ -33,26 +33,18 @@ const TiptapInput = ({ blockId, components: { Icon, Link }, events, loading, met
33
33
  });
34
34
  const disabled = properties.disabled === true || loading;
35
35
  const uploadEnabled = !type.isNone(uploadPolicyRequestId);
36
- const extensions = buildExtensions({
37
- properties,
38
- insertImage,
39
- uploadEnabled
40
- });
41
36
  const heightStyle = computeHeightStyle({
42
37
  rows: properties.rows,
43
38
  autoSize: properties.autoSize
44
39
  });
45
- const editor = useEditor({
46
- extensions,
47
- content: value?.html || '',
48
- editable: ()=>!disabled,
49
- onUpdate ({ editor }) {
50
- // User-driven change. Emit the full derived value to Lowdefy.
51
- emit(editor);
52
- methods.triggerEvent({
53
- name: 'onChange'
54
- });
55
- }
40
+ const editor = useTiptapEditor({
41
+ disabled,
42
+ emit,
43
+ insertImage,
44
+ methods,
45
+ properties,
46
+ uploadEnabled,
47
+ value
56
48
  });
57
49
  // Register upload-policy and download-policy events once (if configured).
58
50
  useEffect(()=>{
@@ -87,62 +79,6 @@ const TiptapInput = ({ blockId, components: { Icon, Link }, events, loading, met
87
79
  });
88
80
  }
89
81
  }, []);
90
- // Register methods as soon as the editor is available.
91
- useEffect(()=>{
92
- if (!editor) return;
93
- methods.registerMethod('clear', ()=>{
94
- editor.commands.clearContent();
95
- emit(editor);
96
- });
97
- methods.registerMethod('setContent', (args)=>{
98
- editor.commands.setContent(args?.html ?? '');
99
- emit(editor);
100
- });
101
- methods.registerMethod('focus', ()=>{
102
- editor.commands.focus();
103
- });
104
- }, [
105
- editor
106
- ]);
107
- // External value.html → editor sync. One-way only: we read value.html and
108
- // push it into the editor with setContent(..., false) so tiptap's onUpdate
109
- // does not fire. No write-back via emit() — that would race with concurrent
110
- // SetState calls (child effects fire before parent effects, so a sibling's
111
- // onMount SetState could be overwritten by our derived emit). Derived
112
- // fields (text/markdown/fileList) are populated on user interaction via
113
- // onUpdate; downstream consumers of seeded content should read value.html
114
- // directly, or include the fields they need in their SetState payload.
115
- useEffect(()=>{
116
- if (!editor) return;
117
- const next = value?.html ?? '';
118
- const current = editor.getHTML();
119
- if (next !== current) {
120
- editor.commands.setContent(next, false);
121
- }
122
- }, [
123
- value?.html,
124
- editor
125
- ]);
126
- useEffect(()=>{
127
- if (!editor) return;
128
- editor.setOptions({
129
- editable: !disabled
130
- });
131
- }, [
132
- editor,
133
- disabled
134
- ]);
135
- useEffect(()=>{
136
- if (!editor) return;
137
- const placeholderExt = editor.extensionManager.extensions.find((extension)=>extension.name === 'placeholder');
138
- if (placeholderExt) {
139
- placeholderExt.options.placeholder = properties.placeholder ?? '';
140
- editor.view.dispatch(editor.state.tr);
141
- }
142
- }, [
143
- editor,
144
- properties.placeholder
145
- ]);
146
82
  const wrapperClass = [
147
83
  'tiptap-wrapper',
148
84
  properties.bordered === false ? 'tiptap-wrapper-borderless' : '',
@@ -15,14 +15,14 @@
15
15
  */ import React, { useEffect } from 'react';
16
16
  import { withBlockDefaults } from '@lowdefy/block-utils';
17
17
  import { type } from '@lowdefy/helpers';
18
- import { useEditor, EditorContent } from '@tiptap/react';
18
+ import { EditorContent } from '@tiptap/react';
19
19
  import { mergeAttributes } from '@tiptap/core';
20
20
  import Mention from '@tiptap/extension-mention';
21
21
  import Label from '@lowdefy/blocks-antd/blocks/Label/Label.js';
22
22
  import PopoverMenu from '../utils/PopoverMenu.js';
23
- import buildExtensions from '../utils/buildExtensions.js';
24
23
  import computeHeightStyle from '../utils/computeHeightStyle.js';
25
24
  import statusClass from '../utils/statusClass.js';
25
+ import useTiptapEditor from '../utils/useTiptapEditor.js';
26
26
  import suggestion from './suggestion.js';
27
27
  import useGroupMembersPopover from './useGroupMembersPopover.js';
28
28
  import useTiptapMentionState from './useTiptapMentionState.js';
@@ -147,29 +147,22 @@ const TiptapMentionInput = ({ blockId, components: { Icon, Link }, events, loadi
147
147
  limit
148
148
  })
149
149
  });
150
- const extensions = buildExtensions({
151
- properties,
152
- insertImage,
153
- mentionExtension,
154
- uploadEnabled
155
- });
156
150
  const heightStyle = computeHeightStyle({
157
151
  rows: properties.rows,
158
152
  autoSize: properties.autoSize
159
153
  });
160
- const editor = useEditor({
154
+ const editor = useTiptapEditor({
155
+ disabled,
161
156
  editorProps: {
162
157
  items: type.isArray(properties.mentions?.options) ? properties.mentions.options : []
163
158
  },
164
- extensions,
165
- content: value?.html || '',
166
- editable: ()=>!disabled,
167
- onUpdate: ({ editor })=>{
168
- emit(editor);
169
- methods.triggerEvent({
170
- name: 'onChange'
171
- });
172
- }
159
+ emit,
160
+ insertImage,
161
+ mentionExtension,
162
+ methods,
163
+ properties,
164
+ uploadEnabled,
165
+ value
173
166
  });
174
167
  useGroupMembersPopover({
175
168
  editor,
@@ -234,62 +227,6 @@ const TiptapMentionInput = ({ blockId, components: { Icon, Link }, events, loadi
234
227
  });
235
228
  }
236
229
  }, []);
237
- useEffect(()=>{
238
- if (!editor) return;
239
- methods.registerMethod('clear', ()=>{
240
- editor.commands.clearContent();
241
- emit(editor);
242
- });
243
- methods.registerMethod('setContent', (args)=>{
244
- editor.commands.setContent(args?.html ?? '');
245
- emit(editor);
246
- });
247
- methods.registerMethod('focus', ()=>{
248
- editor.commands.focus();
249
- });
250
- }, [
251
- editor
252
- ]);
253
- // External value.html → editor sync. One-way only: we read value.html and
254
- // push it into the editor with setContent(..., false) so tiptap's onUpdate
255
- // does not fire. No write-back via emit() — that would race with concurrent
256
- // SetState calls (child effects fire before parent effects, so a sibling's
257
- // onMount SetState could be overwritten by our derived emit). Derived
258
- // fields (text/markdown/fileList/mentions) are populated on user interaction
259
- // via onUpdate; downstream consumers of seeded content should read
260
- // value.html directly, or include the fields they need in their SetState
261
- // payload.
262
- useEffect(()=>{
263
- if (!editor) return;
264
- const next = value?.html ?? '';
265
- const current = editor.getHTML();
266
- if (next !== current) {
267
- editor.commands.setContent(next, false);
268
- }
269
- }, [
270
- value?.html,
271
- editor
272
- ]);
273
- useEffect(()=>{
274
- if (!editor) return;
275
- editor.setOptions({
276
- editable: !disabled
277
- });
278
- }, [
279
- editor,
280
- disabled
281
- ]);
282
- useEffect(()=>{
283
- if (!editor) return;
284
- const placeholderExt = editor.extensionManager.extensions.find((extension)=>extension.name === 'placeholder');
285
- if (placeholderExt) {
286
- placeholderExt.options.placeholder = properties.placeholder ?? '';
287
- editor.view.dispatch(editor.state.tr);
288
- }
289
- }, [
290
- editor,
291
- properties.placeholder
292
- ]);
293
230
  const wrapperClass = [
294
231
  'tiptap-wrapper',
295
232
  properties.bordered === false ? 'tiptap-wrapper-borderless' : '',
@@ -50,27 +50,67 @@ function suggestion({ methods, char = '@', allowSpaces = true, limit = 5 }) {
50
50
  render: ()=>{
51
51
  let component;
52
52
  let popup;
53
- return {
54
- onStart: (props)=>{
55
- component = new ReactRenderer(MentionList, {
56
- props: {
57
- ...props,
58
- methods
59
- },
60
- editor: props.editor
61
- });
62
- if (!props.clientRect) return;
63
- popup = tippy('body', {
64
- getReferenceClientRect: props.clientRect,
65
- appendTo: ()=>document.body,
66
- content: component.element,
67
- showOnCreate: true,
68
- interactive: true,
69
- trigger: 'manual',
70
- placement: 'bottom-start'
53
+ let current;
54
+ const start = (props)=>{
55
+ current = {
56
+ from: props.range.from,
57
+ query: props.query
58
+ };
59
+ component = new ReactRenderer(MentionList, {
60
+ props: {
61
+ ...props,
62
+ methods
63
+ },
64
+ editor: props.editor
65
+ });
66
+ if (!props.clientRect) return;
67
+ popup = tippy('body', {
68
+ getReferenceClientRect: props.clientRect,
69
+ appendTo: ()=>document.body,
70
+ content: component.element,
71
+ showOnCreate: true,
72
+ interactive: true,
73
+ trigger: 'manual',
74
+ placement: 'bottom-start'
75
+ });
76
+ };
77
+ const exit = ()=>{
78
+ // Tear down in this order — React portal first, tippy popup after —
79
+ // to avoid "NotFoundError: Failed to execute 'removeChild'" when the
80
+ // user navigates away (e.g. clicks another item) while the mention
81
+ // popup is open. MentionList is rendered as a React portal into
82
+ // component.element, which tippy relocates into document.body.
83
+ // Destroying tippy first detaches that container out from under
84
+ // React's portal teardown; destroying the component first lets React
85
+ // remove the MentionList DOM cleanly while the container is still
86
+ // attached. The tippy teardown is then deferred to a microtask so it
87
+ // never runs synchronously inside React's own unmount commit.
88
+ component?.destroy();
89
+ component = undefined;
90
+ const instance = popup?.[0];
91
+ popup = undefined;
92
+ if (instance && !instance.state.isDestroyed) {
93
+ queueMicrotask(()=>{
94
+ if (!instance.state.isDestroyed) instance.destroy();
71
95
  });
72
- },
96
+ }
97
+ };
98
+ return {
99
+ onStart: start,
73
100
  onUpdate (props) {
101
+ // A suggestion that moves to a new trigger with a new query (e.g. a
102
+ // second "@" typed while the first was open) restarted in TipTap v2,
103
+ // which also showed a popup that an outside click had hidden. v3
104
+ // reports it as an update, so restart it here.
105
+ if (props.range.from !== current?.from && props.query !== current?.query) {
106
+ exit();
107
+ start(props);
108
+ return;
109
+ }
110
+ current = {
111
+ from: props.range.from,
112
+ query: props.query
113
+ };
74
114
  component?.updateProps(props);
75
115
  if (!props.clientRect) return;
76
116
  popup?.[0]?.setProps({
@@ -84,27 +124,7 @@ function suggestion({ methods, char = '@', allowSpaces = true, limit = 5 }) {
84
124
  }
85
125
  return component?.ref?.onKeyDown(props) ?? false;
86
126
  },
87
- onExit () {
88
- // Tear down in this order — React portal first, tippy popup after —
89
- // to avoid "NotFoundError: Failed to execute 'removeChild'" when the
90
- // user navigates away (e.g. clicks another item) while the mention
91
- // popup is open. MentionList is rendered as a React portal into
92
- // component.element, which tippy relocates into document.body.
93
- // Destroying tippy first detaches that container out from under
94
- // React's portal teardown; destroying the component first lets React
95
- // remove the MentionList DOM cleanly while the container is still
96
- // attached. The tippy teardown is then deferred to a microtask so it
97
- // never runs synchronously inside React's own unmount commit.
98
- component?.destroy();
99
- component = undefined;
100
- const instance = popup?.[0];
101
- popup = undefined;
102
- if (instance && !instance.state.isDestroyed) {
103
- queueMicrotask(()=>{
104
- if (!instance.state.isDestroyed) instance.destroy();
105
- });
106
- }
107
- }
127
+ onExit: exit
108
128
  };
109
129
  }
110
130
  };
@@ -35,36 +35,69 @@ const HIGHLIGHT_SWATCHES = [
35
35
  fill: 'rgba(170, 0, 255, 0.5)'
36
36
  }
37
37
  ];
38
+ // Placement of the TipTap v2 (tippy.js) bubble menu: 10px above the selection,
39
+ // flipping below when that would leave less than 5px to the viewport edge, and
40
+ // kept 5px from the sides. v3 positions the menu with Floating UI and applies
41
+ // the offset after flip, so flip's padding includes the offset.
42
+ const FLOATING_OPTIONS = {
43
+ placement: 'top',
44
+ offset: 10,
45
+ flip: {
46
+ padding: 15
47
+ },
48
+ shift: {
49
+ padding: 5
50
+ }
51
+ };
38
52
  function hasExt(editor, name) {
39
53
  return editor.extensionManager.extensions.some((ext)=>ext.name === name);
40
54
  }
55
+ function createMenuElements() {
56
+ if (typeof document === 'undefined') return null;
57
+ // `root` is the element the plugin positions and mounts, like tippy's root
58
+ // in v2 (same z-index). `content` is the styled menu the buttons render into.
59
+ // The root is absolute from the start so the plugin measures its final size
60
+ // the first time it positions it.
61
+ const root = document.createElement('div');
62
+ root.style.position = 'absolute';
63
+ root.style.visibility = 'hidden';
64
+ root.style.zIndex = '9999';
65
+ const content = document.createElement('div');
66
+ content.className = 'tiptap-popover';
67
+ root.appendChild(content);
68
+ return {
69
+ root,
70
+ content
71
+ };
72
+ }
41
73
  // Custom bubble menu built on tiptap's BubbleMenuPlugin instead of the
42
- // `<BubbleMenu>` React wrapper. The wrapper renders its menu element with
43
- // React and then the plugin calls `element.remove()` on it (bubble-menu-plugin
44
- // detaches the menu from the DOM on construction). When the editor block later
45
- // unmounts — e.g. the surrounding page navigates away, or `disabled` flips and
46
- // unmounts this menu — React tries to removeChild an element that is no longer
47
- // where it rendered it, throwing "NotFoundError: Failed to execute
48
- // 'removeChild' on 'Node'".
74
+ // `<BubbleMenu>` React wrapper. The plugin moves its menu element in and out of
75
+ // the DOM itself. When the editor block later unmounts — e.g. the surrounding
76
+ // page navigates away, or `disabled` flips and unmounts this menu — React
77
+ // would try to removeChild an element that is no longer where it rendered it,
78
+ // throwing "NotFoundError: Failed to execute 'removeChild' on 'Node'".
49
79
  //
50
- // Here we own the container element ourselves and render the buttons into it
51
- // with a portal. React only ever removes the buttons from our container (always
52
- // their parent), never the plugin-detached element, so the teardown race is
53
- // gone.
80
+ // Here we own the menu elements ourselves and render the buttons into them
81
+ // with a portal. React only ever removes the buttons from our content element
82
+ // (always their parent), never the plugin-managed root, so the teardown race
83
+ // is gone.
54
84
  const PopoverMenu = ({ editor })=>{
55
- const [container] = useState(()=>typeof document === 'undefined' ? null : document.createElement('div'));
85
+ const [menu] = useState(createMenuElements);
56
86
  const showBold = hasExt(editor, 'bold');
57
87
  const showItalic = hasExt(editor, 'italic');
58
88
  const showStrike = hasExt(editor, 'strike');
59
89
  const showHighlight = hasExt(editor, 'highlight');
60
90
  const hasTools = showBold || showItalic || showStrike || showHighlight;
61
91
  useEffect(()=>{
62
- if (!container || !editor || editor.isDestroyed || !hasTools) return undefined;
63
- container.className = 'tiptap-popover';
92
+ if (!menu || !editor || editor.isDestroyed || !hasTools) return undefined;
64
93
  const plugin = BubbleMenuPlugin({
65
94
  pluginKey: 'bubbleMenu',
66
95
  editor,
67
- element: container,
96
+ element: menu.root,
97
+ // v2 mounted the menu beside the editor wrapper; v3 defaults to inside it.
98
+ // `options.element` is the EditorContent wrapper once it has mounted.
99
+ appendTo: ()=>editor.options.element?.parentElement,
100
+ options: FLOATING_OPTIONS,
68
101
  shouldShow: ({ editor: menuEditor, view, state, from, to })=>{
69
102
  if (menuEditor.isActive('image')) return false;
70
103
  const { selection } = state;
@@ -79,19 +112,19 @@ const PopoverMenu = ({ editor })=>{
79
112
  });
80
113
  editor.registerPlugin(plugin);
81
114
  return ()=>{
82
- // Tear the plugin (and its tippy instance) down first. React then
83
- // unmounts the portal, removing the buttons from our still-intact
84
- // container.
115
+ // Tear the plugin down first (it removes the root from the DOM). React
116
+ // then unmounts the portal, removing the buttons from our still-intact
117
+ // content element.
85
118
  if (!editor.isDestroyed) {
86
119
  editor.unregisterPlugin('bubbleMenu');
87
120
  }
88
121
  };
89
122
  }, [
90
123
  editor,
91
- container,
124
+ menu,
92
125
  hasTools
93
126
  ]);
94
- if (!container || !hasTools) return null;
127
+ if (!menu || !hasTools) return null;
95
128
  return /*#__PURE__*/ createPortal(/*#__PURE__*/ React.createElement(React.Fragment, null, showBold && /*#__PURE__*/ React.createElement(AiOutlineBold, {
96
129
  className: "tiptap-icon",
97
130
  onClick: ()=>editor.chain().focus().toggleBold().run()
@@ -110,6 +143,6 @@ const PopoverMenu = ({ editor })=>{
110
143
  onClick: ()=>editor.chain().focus().toggleHighlight({
111
144
  color: fill
112
145
  }).run()
113
- }))), container);
146
+ }))), menu.content);
114
147
  };
115
148
  export default PopoverMenu;
@@ -15,14 +15,11 @@
15
15
  */ import FileHandler from '@tiptap/extension-file-handler';
16
16
  import Highlight from '@tiptap/extension-highlight';
17
17
  import Image from '@tiptap/extension-image';
18
- import Placeholder from '@tiptap/extension-placeholder';
19
- import StarterKit from '@tiptap/starter-kit';
18
+ import { Placeholder } from '@tiptap/extensions';
20
19
  import LinkExtension from '@tiptap/extension-link';
21
- import Table from '@tiptap/extension-table';
22
- import TableCell from '@tiptap/extension-table-cell';
23
- import TableHeader from '@tiptap/extension-table-header';
24
- import TableRow from '@tiptap/extension-table-row';
20
+ import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table';
25
21
  import { type } from '@lowdefy/helpers';
22
+ import buildStarterKit from './buildStarterKit.js';
26
23
  const DEFAULT_IMAGE_MIME_TYPES = [
27
24
  'image/jpeg',
28
25
  'image/png',
@@ -58,15 +55,14 @@ function merge(defaults, overrides) {
58
55
  ...overrides
59
56
  };
60
57
  }
61
- function buildExtensions({ properties, insertImage, mentionExtension, uploadEnabled }) {
58
+ function buildExtensions({ properties, getPlaceholder, insertImage, mentionExtension, uploadEnabled }) {
62
59
  const image = merge(DEFAULTS.image, properties.image);
63
60
  const table = merge(DEFAULTS.table, properties.table);
64
61
  const link = merge(DEFAULTS.link, properties.link);
65
62
  const highlight = merge(DEFAULTS.highlight, properties.highlight);
66
- const starterKitOptions = type.isObject(properties.starterKit) ? properties.starterKit : {};
67
63
  const allowedMimeTypes = type.isArray(properties.allowedMimeTypes) ? properties.allowedMimeTypes : DEFAULT_IMAGE_MIME_TYPES;
68
64
  const extensions = [
69
- StarterKit.configure(starterKitOptions)
65
+ buildStarterKit(properties.starterKit)
70
66
  ];
71
67
  if (!table.disabled) {
72
68
  extensions.push(Table.configure({
@@ -84,9 +80,8 @@ function buildExtensions({ properties, insertImage, mentionExtension, uploadEnab
84
80
  }));
85
81
  }
86
82
  extensions.push(Placeholder.configure({
87
- placeholder: ()=>properties.placeholder ?? '',
88
- showOnlyWhenEditable: false,
89
- considerAnyAsEmpty: true
83
+ placeholder: getPlaceholder,
84
+ showOnlyWhenEditable: false
90
85
  }));
91
86
  if (!highlight.disabled) {
92
87
  extensions.push(Highlight.configure({
@@ -101,7 +96,10 @@ function buildExtensions({ properties, insertImage, mentionExtension, uploadEnab
101
96
  autolink: link.autolink,
102
97
  linkOnPaste: link.linkOnPaste,
103
98
  openOnClick: link.openOnClick,
104
- defaultProtocol: link.defaultProtocol
99
+ defaultProtocol: link.defaultProtocol,
100
+ // TipTap v2's default. v3's default also skips bare IP addresses and hosts without a TLD
101
+ // (e.g. localhost:3000), and applies to pasted URLs too.
102
+ shouldAutoLink: (url)=>!!url
105
103
  }));
106
104
  }
107
105
  if (mentionExtension) {
@@ -0,0 +1,48 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import StarterKit from '@tiptap/starter-kit';
16
+ import { type } from '@lowdefy/helpers';
17
+ // TipTap v3's StarterKit bundles extensions the v2 kit did not. They default to off so the
18
+ // editor keeps its v2 extension set: Underline would keep <u> in the saved html, TrailingNode
19
+ // appends an empty paragraph after a final table or image, and ListKeymap changes Backspace and
20
+ // Delete in lists.
21
+ const V3_KIT_DEFAULTS = {
22
+ listKeymap: false,
23
+ trailingNode: false,
24
+ underline: false
25
+ };
26
+ // Builds the StarterKit from the block's `starterKit` property, which is written against the v2
27
+ // kit, so that it enables the same extensions as v2 did.
28
+ function buildStarterKit(starterKit) {
29
+ const { history, ...options } = type.isObject(starterKit) ? starterKit : {};
30
+ // v3 renamed the History extension to UndoRedo.
31
+ if (!type.isUndefined(history) && type.isUndefined(options.undoRedo)) {
32
+ options.undoRedo = history;
33
+ }
34
+ // v3 CodeBlock inserts a paragraph above a code block at the top of the document on ArrowUp.
35
+ if (options.codeBlock !== false) {
36
+ options.codeBlock = {
37
+ exitOnArrowUp: false,
38
+ ...type.isObject(options.codeBlock) ? options.codeBlock : {}
39
+ };
40
+ }
41
+ return StarterKit.configure({
42
+ ...V3_KIT_DEFAULTS,
43
+ ...options,
44
+ // The Link extension is added by buildExtensions from the block's `link` property.
45
+ link: false
46
+ });
47
+ }
48
+ export default buildStarterKit;
@@ -264,3 +264,11 @@
264
264
  .tiptap-icon + .tiptap-icon {
265
265
  margin-left: 4px;
266
266
  }
267
+
268
+ /* TipTap v2 injected this rule into every page with an editor; v3 does not.
269
+ The mention suggestion and group-members popovers are tippy.js popups, which
270
+ fade in with it and only unmount a hidden popup once that fade-out ends, so
271
+ without it a popup hidden by an outside click would stay on screen. */
272
+ .tippy-box[data-animation='fade'][data-state='hidden'] {
273
+ opacity: 0;
274
+ }
@@ -0,0 +1,106 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { useEffect, useRef } from 'react';
16
+ import { useEditor } from '@tiptap/react';
17
+ import buildExtensions from './buildExtensions.js';
18
+ // The TipTap editor of the TiptapInput and TiptapMentionInput blocks: creates it, registers the
19
+ // block's clear, setContent and focus methods, and keeps it in sync with the block's value,
20
+ // disabled state and placeholder.
21
+ function useTiptapEditor({ disabled, editorProps = {}, emit, insertImage, mentionExtension, methods, properties, uploadEnabled, value }) {
22
+ // The Placeholder extension is created once, so it reads the current text from a ref.
23
+ const placeholderRef = useRef();
24
+ placeholderRef.current = properties.placeholder ?? '';
25
+ const extensions = buildExtensions({
26
+ properties,
27
+ getPlaceholder: ()=>placeholderRef.current,
28
+ insertImage,
29
+ mentionExtension,
30
+ uploadEnabled
31
+ });
32
+ const editor = useEditor({
33
+ editorProps,
34
+ extensions,
35
+ content: value?.html || '',
36
+ editable: ()=>!disabled,
37
+ onUpdate ({ editor }) {
38
+ // User-driven change. Emit the full derived value to Lowdefy.
39
+ emit(editor);
40
+ methods.triggerEvent({
41
+ name: 'onChange'
42
+ });
43
+ }
44
+ });
45
+ // Register methods as soon as the editor is available.
46
+ useEffect(()=>{
47
+ if (!editor) return;
48
+ // emitUpdate false: the methods emit the value themselves and must not trigger onChange.
49
+ methods.registerMethod('clear', ()=>{
50
+ editor.commands.clearContent(false);
51
+ emit(editor);
52
+ });
53
+ methods.registerMethod('setContent', (args)=>{
54
+ editor.commands.setContent(args?.html ?? '', {
55
+ emitUpdate: false
56
+ });
57
+ emit(editor);
58
+ });
59
+ methods.registerMethod('focus', ()=>{
60
+ editor.commands.focus();
61
+ });
62
+ }, [
63
+ editor
64
+ ]);
65
+ // External value.html → editor sync. One-way only: we read value.html and
66
+ // push it into the editor with emitUpdate false so tiptap's onUpdate
67
+ // does not fire. No write-back via emit() — that would race with concurrent
68
+ // SetState calls (child effects fire before parent effects, so a sibling's
69
+ // onMount SetState could be overwritten by our derived emit). Derived
70
+ // fields (text/markdown/fileList, and mentions for TiptapMentionInput) are
71
+ // populated on user interaction via onUpdate; downstream consumers of seeded
72
+ // content should read value.html directly, or include the fields they need
73
+ // in their SetState payload.
74
+ useEffect(()=>{
75
+ if (!editor) return;
76
+ const next = value?.html ?? '';
77
+ const current = editor.getHTML();
78
+ if (next !== current) {
79
+ editor.commands.setContent(next, {
80
+ emitUpdate: false
81
+ });
82
+ }
83
+ }, [
84
+ value?.html,
85
+ editor
86
+ ]);
87
+ useEffect(()=>{
88
+ if (!editor) return;
89
+ editor.setOptions({
90
+ editable: !disabled
91
+ });
92
+ }, [
93
+ editor,
94
+ disabled
95
+ ]);
96
+ // Redraw the placeholder when its text changes.
97
+ useEffect(()=>{
98
+ if (!editor) return;
99
+ editor.view.dispatch(editor.state.tr);
100
+ }, [
101
+ editor,
102
+ properties.placeholder
103
+ ]);
104
+ return editor;
105
+ }
106
+ export default useTiptapEditor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/blocks-tiptap",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy TipTap rich-text editor blocks.",
6
6
  "homepage": "https://lowdefy.com",
@@ -46,25 +46,23 @@
46
46
  "dist/*"
47
47
  ],
48
48
  "dependencies": {
49
- "@lowdefy/block-utils": "6.0.0",
50
- "@lowdefy/blocks-antd": "6.0.0",
51
- "@lowdefy/blocks-files": "6.0.0",
52
- "@lowdefy/helpers": "6.0.0",
53
- "@tiptap/core": "2.27.2",
54
- "@tiptap/extension-bubble-menu": "2.27.2",
55
- "@tiptap/extension-file-handler": "2.27.2",
56
- "@tiptap/extension-highlight": "2.27.2",
57
- "@tiptap/extension-image": "2.27.2",
58
- "@tiptap/extension-link": "2.27.2",
59
- "@tiptap/extension-mention": "2.27.2",
60
- "@tiptap/extension-placeholder": "2.27.2",
61
- "@tiptap/extension-table": "2.27.2",
62
- "@tiptap/extension-table-cell": "2.27.2",
63
- "@tiptap/extension-table-header": "2.27.2",
64
- "@tiptap/extension-table-row": "2.27.2",
65
- "@tiptap/pm": "2.27.2",
66
- "@tiptap/react": "2.27.2",
67
- "@tiptap/starter-kit": "2.27.2",
49
+ "@lowdefy/block-utils": "6.1.0",
50
+ "@lowdefy/blocks-antd": "6.1.0",
51
+ "@lowdefy/blocks-files": "6.1.0",
52
+ "@lowdefy/helpers": "6.1.0",
53
+ "@tiptap/core": "3.31.3",
54
+ "@tiptap/extension-bubble-menu": "3.31.3",
55
+ "@tiptap/extension-file-handler": "3.31.3",
56
+ "@tiptap/extension-highlight": "3.31.3",
57
+ "@tiptap/extension-image": "3.31.3",
58
+ "@tiptap/extension-link": "3.31.3",
59
+ "@tiptap/extension-mention": "3.31.3",
60
+ "@tiptap/extension-table": "3.31.3",
61
+ "@tiptap/extensions": "3.31.3",
62
+ "@tiptap/pm": "3.31.3",
63
+ "@tiptap/react": "3.31.3",
64
+ "@tiptap/starter-kit": "3.31.3",
65
+ "@tiptap/suggestion": "3.31.3",
68
66
  "react-icons": "5.6.0",
69
67
  "tippy.js": "6.3.7",
70
68
  "turndown": "7.2.0"
@@ -74,8 +72,8 @@
74
72
  "react-dom": ">=18"
75
73
  },
76
74
  "devDependencies": {
77
- "@lowdefy/block-dev-e2e": "6.0.0",
78
- "@lowdefy/e2e-utils": "6.0.0",
75
+ "@lowdefy/block-dev-e2e": "6.1.0",
76
+ "@lowdefy/e2e-utils": "6.1.0",
79
77
  "@playwright/test": "1.59.1",
80
78
  "@swc/cli": "0.8.1",
81
79
  "@swc/core": "1.15.32",