@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.
- package/dist/blocks/TiptapInput/TiptapInput.js +10 -74
- package/dist/blocks/TiptapMentionInput/TiptapMentionInput.js +11 -74
- package/dist/blocks/TiptapMentionInput/suggestion.js +60 -40
- package/dist/blocks/utils/PopoverMenu.js +54 -21
- package/dist/blocks/utils/buildExtensions.js +11 -13
- package/dist/blocks/utils/buildStarterKit.js +48 -0
- package/dist/blocks/utils/style.css +8 -0
- package/dist/blocks/utils/useTiptapEditor.js +106 -0
- package/package.json +20 -22
|
@@ -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 {
|
|
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 =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
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 =
|
|
154
|
+
const editor = useTiptapEditor({
|
|
155
|
+
disabled,
|
|
161
156
|
editorProps: {
|
|
162
157
|
items: type.isArray(properties.mentions?.options) ? properties.mentions.options : []
|
|
163
158
|
},
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
|
51
|
-
// with a portal. React only ever removes the buttons from our
|
|
52
|
-
// their parent), never the plugin-
|
|
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 [
|
|
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 (!
|
|
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:
|
|
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 (
|
|
83
|
-
// unmounts the portal, removing the buttons from our still-intact
|
|
84
|
-
//
|
|
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
|
-
|
|
124
|
+
menu,
|
|
92
125
|
hasTools
|
|
93
126
|
]);
|
|
94
|
-
if (!
|
|
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
|
-
}))),
|
|
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/
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
50
|
-
"@lowdefy/blocks-antd": "6.
|
|
51
|
-
"@lowdefy/blocks-files": "6.
|
|
52
|
-
"@lowdefy/helpers": "6.
|
|
53
|
-
"@tiptap/core": "
|
|
54
|
-
"@tiptap/extension-bubble-menu": "
|
|
55
|
-
"@tiptap/extension-file-handler": "
|
|
56
|
-
"@tiptap/extension-highlight": "
|
|
57
|
-
"@tiptap/extension-image": "
|
|
58
|
-
"@tiptap/extension-link": "
|
|
59
|
-
"@tiptap/extension-mention": "
|
|
60
|
-
"@tiptap/extension-
|
|
61
|
-
"@tiptap/
|
|
62
|
-
"@tiptap/
|
|
63
|
-
"@tiptap/
|
|
64
|
-
"@tiptap/
|
|
65
|
-
"@tiptap/
|
|
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.
|
|
78
|
-
"@lowdefy/e2e-utils": "6.
|
|
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",
|