@lowdefy/blocks-tiptap 5.3.0 → 5.5.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/useTiptapState.js +4 -0
- package/dist/blocks/TiptapMentionInput/MentionList.js +43 -10
- package/dist/blocks/TiptapMentionInput/TiptapMentionInput.js +26 -7
- package/dist/blocks/TiptapMentionInput/itemSection.js +18 -0
- package/dist/blocks/TiptapMentionInput/meta.js +25 -1
- package/dist/blocks/TiptapMentionInput/style.module.css +52 -0
- package/dist/blocks/TiptapMentionInput/suggestion.js +39 -9
- package/dist/blocks/TiptapMentionInput/useGroupMembersPopover.js +116 -0
- package/dist/blocks/TiptapMentionInput/useTiptapMentionState.js +4 -0
- package/dist/blocks/utils/PopoverMenu.js +53 -20
- package/dist/blocks/utils/s3FileUpload.js +3 -1
- package/dist/blocks/utils/style.module.css +133 -1
- package/package.json +12 -6
|
@@ -58,6 +58,10 @@ function useTiptapState({ value, methods }) {
|
|
|
58
58
|
file,
|
|
59
59
|
methods
|
|
60
60
|
});
|
|
61
|
+
// The upload is async: bail if the editor was torn down meanwhile (e.g. the
|
|
62
|
+
// surrounding page navigated away before the upload resolved), otherwise the
|
|
63
|
+
// chained commands run against a destroyed editor and throw.
|
|
64
|
+
if (editor.isDestroyed) return;
|
|
61
65
|
editor.chain().insertContentAt(pos, [
|
|
62
66
|
{
|
|
63
67
|
type: 'image',
|
|
@@ -14,10 +14,36 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
|
16
16
|
import { renderHtml } from '@lowdefy/block-utils';
|
|
17
|
+
import { type } from '@lowdefy/helpers';
|
|
18
|
+
import itemSection from './itemSection.js';
|
|
19
|
+
function groupItems(items) {
|
|
20
|
+
const groups = [];
|
|
21
|
+
const bySection = new Map();
|
|
22
|
+
items.forEach((item)=>{
|
|
23
|
+
const section = itemSection(item);
|
|
24
|
+
if (!bySection.has(section)) {
|
|
25
|
+
const group = {
|
|
26
|
+
section,
|
|
27
|
+
items: []
|
|
28
|
+
};
|
|
29
|
+
bySection.set(section, group);
|
|
30
|
+
groups.push(group);
|
|
31
|
+
}
|
|
32
|
+
bySection.get(section).items.push(item);
|
|
33
|
+
});
|
|
34
|
+
let startIndex = 0;
|
|
35
|
+
groups.forEach((group)=>{
|
|
36
|
+
group.startIndex = startIndex;
|
|
37
|
+
startIndex += group.items.length;
|
|
38
|
+
});
|
|
39
|
+
return groups;
|
|
40
|
+
}
|
|
17
41
|
const MentionList = /*#__PURE__*/ forwardRef((props, ref)=>{
|
|
18
42
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
43
|
+
const groups = groupItems(props.items);
|
|
44
|
+
const flatItems = groups.flatMap((group)=>group.items);
|
|
19
45
|
const selectItem = (index)=>{
|
|
20
|
-
const item =
|
|
46
|
+
const item = flatItems[index];
|
|
21
47
|
if (item) {
|
|
22
48
|
props.command({
|
|
23
49
|
id: item
|
|
@@ -25,10 +51,10 @@ const MentionList = /*#__PURE__*/ forwardRef((props, ref)=>{
|
|
|
25
51
|
}
|
|
26
52
|
};
|
|
27
53
|
const upHandler = ()=>{
|
|
28
|
-
setSelectedIndex((selectedIndex +
|
|
54
|
+
setSelectedIndex((selectedIndex + flatItems.length - 1) % flatItems.length);
|
|
29
55
|
};
|
|
30
56
|
const downHandler = ()=>{
|
|
31
|
-
setSelectedIndex((selectedIndex + 1) %
|
|
57
|
+
setSelectedIndex((selectedIndex + 1) % flatItems.length);
|
|
32
58
|
};
|
|
33
59
|
const enterHandler = ()=>{
|
|
34
60
|
selectItem(selectedIndex);
|
|
@@ -55,13 +81,20 @@ const MentionList = /*#__PURE__*/ forwardRef((props, ref)=>{
|
|
|
55
81
|
}));
|
|
56
82
|
return /*#__PURE__*/ React.createElement("div", {
|
|
57
83
|
className: "tiptap-mention-items"
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
|
|
84
|
+
}, flatItems.length ? groups.map((group)=>/*#__PURE__*/ React.createElement(React.Fragment, {
|
|
85
|
+
key: group.startIndex
|
|
86
|
+
}, !type.isNone(group.section) && /*#__PURE__*/ React.createElement("div", {
|
|
87
|
+
className: "tiptap-mention-section"
|
|
88
|
+
}, group.section), group.items.map((item, itemIndex)=>{
|
|
89
|
+
const index = group.startIndex + itemIndex;
|
|
90
|
+
return /*#__PURE__*/ React.createElement("button", {
|
|
91
|
+
className: `tiptap-mention-item ${index === selectedIndex ? 'is-selected' : ''}`,
|
|
92
|
+
key: index,
|
|
93
|
+
onClick: ()=>selectItem(index)
|
|
94
|
+
}, renderHtml({
|
|
95
|
+
html: item?.label ?? item,
|
|
96
|
+
methods: props.methods
|
|
97
|
+
}));
|
|
65
98
|
}))) : /*#__PURE__*/ React.createElement("div", {
|
|
66
99
|
className: "tiptap-mention-item secondary"
|
|
67
100
|
}, "No matching results"));
|
|
@@ -24,6 +24,7 @@ import buildExtensions from '../utils/buildExtensions.js';
|
|
|
24
24
|
import computeHeightStyle from '../utils/computeHeightStyle.js';
|
|
25
25
|
import statusClass from '../utils/statusClass.js';
|
|
26
26
|
import suggestion from './suggestion.js';
|
|
27
|
+
import useGroupMembersPopover from './useGroupMembersPopover.js';
|
|
27
28
|
import useTiptapMentionState from './useTiptapMentionState.js';
|
|
28
29
|
import './style.module.css';
|
|
29
30
|
function mentionLabel(id) {
|
|
@@ -38,25 +39,38 @@ const TiptapMentionInput = ({ blockId, components: { Icon, Link }, events, loadi
|
|
|
38
39
|
const uploadEnabled = !type.isNone(properties.s3PostPolicyRequestId);
|
|
39
40
|
const char = properties.mentions?.char ?? '@';
|
|
40
41
|
const allowSpaces = properties.mentions?.allowSpaces !== false;
|
|
42
|
+
const limit = properties.mentions?.limit ?? 5;
|
|
41
43
|
const mentionExtension = Mention.configure({
|
|
42
44
|
HTMLAttributes: {
|
|
43
45
|
class: 'tiptap-mention'
|
|
44
46
|
},
|
|
45
47
|
renderHTML ({ options, node }) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
+
const id = node.attrs.id;
|
|
49
|
+
const label = mentionLabel(id);
|
|
50
|
+
const group = id?.tag?.group;
|
|
51
|
+
const modifier = {};
|
|
52
|
+
if (!type.isNone(group)) {
|
|
53
|
+
modifier.class = 'tiptap-mention-group';
|
|
54
|
+
modifier['data-mention-group'] = group;
|
|
55
|
+
}
|
|
56
|
+
const color = id?.tag?.color;
|
|
57
|
+
if (!type.isNone(color)) {
|
|
58
|
+
modifier.style = `color: ${color}`;
|
|
59
|
+
}
|
|
60
|
+
const href = type.isFunction(properties.mentions?.getHref) ? properties.mentions.getHref(id) : undefined;
|
|
61
|
+
if (!type.isNone(href)) {
|
|
48
62
|
return [
|
|
49
63
|
'a',
|
|
50
64
|
mergeAttributes({
|
|
51
|
-
href
|
|
52
|
-
'data-id':
|
|
53
|
-
}, options.HTMLAttributes),
|
|
65
|
+
href,
|
|
66
|
+
'data-id': id?._id
|
|
67
|
+
}, options.HTMLAttributes, modifier),
|
|
54
68
|
`${char}${label}`
|
|
55
69
|
];
|
|
56
70
|
}
|
|
57
71
|
return [
|
|
58
72
|
'span',
|
|
59
|
-
mergeAttributes(options.HTMLAttributes),
|
|
73
|
+
mergeAttributes(options.HTMLAttributes, modifier),
|
|
60
74
|
`${char}${label}`
|
|
61
75
|
];
|
|
62
76
|
},
|
|
@@ -66,7 +80,8 @@ const TiptapMentionInput = ({ blockId, components: { Icon, Link }, events, loadi
|
|
|
66
80
|
suggestion: suggestion({
|
|
67
81
|
methods,
|
|
68
82
|
char,
|
|
69
|
-
allowSpaces
|
|
83
|
+
allowSpaces,
|
|
84
|
+
limit
|
|
70
85
|
})
|
|
71
86
|
});
|
|
72
87
|
const extensions = buildExtensions({
|
|
@@ -93,6 +108,10 @@ const TiptapMentionInput = ({ blockId, components: { Icon, Link }, events, loadi
|
|
|
93
108
|
});
|
|
94
109
|
}
|
|
95
110
|
});
|
|
111
|
+
useGroupMembersPopover({
|
|
112
|
+
editor,
|
|
113
|
+
properties
|
|
114
|
+
});
|
|
96
115
|
useEffect(()=>{
|
|
97
116
|
if (editor) {
|
|
98
117
|
editor.setOptions({
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
*/ function itemSection(item) {
|
|
16
|
+
return item?.tag?.section;
|
|
17
|
+
}
|
|
18
|
+
export default itemSection;
|
|
@@ -167,10 +167,34 @@
|
|
|
167
167
|
},
|
|
168
168
|
getHref: {
|
|
169
169
|
type: 'object',
|
|
170
|
-
description: 'Optional
|
|
170
|
+
description: 'Optional _function that receives the selected mention option and returns an href. A non-nullish return renders the mention as an <a>; a nullish return renders a plain <span> (use this for options with no link, e.g. group mentions).',
|
|
171
171
|
docs: {
|
|
172
172
|
displayType: 'yaml'
|
|
173
173
|
}
|
|
174
|
+
},
|
|
175
|
+
limit: {
|
|
176
|
+
type: 'integer',
|
|
177
|
+
minimum: 1,
|
|
178
|
+
default: 5,
|
|
179
|
+
description: 'Maximum suggestions shown — per section when options declare sections, otherwise across the flat list.'
|
|
180
|
+
},
|
|
181
|
+
groupMembers: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
additionalProperties: {
|
|
184
|
+
type: 'array',
|
|
185
|
+
items: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: {
|
|
188
|
+
name: {
|
|
189
|
+
type: 'string'
|
|
190
|
+
},
|
|
191
|
+
email: {
|
|
192
|
+
type: 'string'
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
description: "Map of group key → array of { name, email } shown in a hover popover on that group's chips (live editor only)."
|
|
174
198
|
}
|
|
175
199
|
}
|
|
176
200
|
},
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
color: var(--ant-color-link);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
:global(.tiptap-mention-group) {
|
|
10
|
+
box-decoration-break: clone;
|
|
11
|
+
font-weight: 600;
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
:global(.tiptap-mention-items) {
|
|
10
15
|
background: var(--ant-color-bg-container);
|
|
11
16
|
border-radius: var(--ant-border-radius, 8px);
|
|
@@ -36,6 +41,15 @@
|
|
|
36
41
|
background: var(--ant-control-item-bg-hover);
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
:global(.tiptap-mention-section) {
|
|
45
|
+
color: var(--ant-color-text-secondary);
|
|
46
|
+
font-size: var(--ant-font-size-sm, 12px);
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
letter-spacing: 0.02em;
|
|
49
|
+
text-transform: uppercase;
|
|
50
|
+
padding: 0.3rem 0.4rem 0.1rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
:global(.tableWrapper),
|
|
40
54
|
:global(.tiptap-table) {
|
|
41
55
|
display: block;
|
|
@@ -94,3 +108,41 @@
|
|
|
94
108
|
:global(.tiptap-table) :global(th) :global(p) {
|
|
95
109
|
margin: 0;
|
|
96
110
|
}
|
|
111
|
+
|
|
112
|
+
:global(.tiptap-mention-group-popover) {
|
|
113
|
+
background: var(--ant-color-bg-container);
|
|
114
|
+
border-radius: var(--ant-border-radius, 8px);
|
|
115
|
+
box-shadow:
|
|
116
|
+
0 0 0 1px rgba(0, 0, 0, 0.05),
|
|
117
|
+
0px 10px 20px rgba(0, 0, 0, 0.1);
|
|
118
|
+
color: var(--ant-color-text);
|
|
119
|
+
font-size: var(--ant-font-size-sm, 12px);
|
|
120
|
+
max-width: 240px;
|
|
121
|
+
padding: 0.3rem 0.6rem;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
:global(.tiptap-mention-group-popover-header) {
|
|
125
|
+
font-weight: 600;
|
|
126
|
+
padding: 0.1rem 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
:global(.tiptap-mention-group-popover-item) {
|
|
130
|
+
padding: 0.1rem 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
:global(.tiptap-mention-group-popover-item) > div {
|
|
134
|
+
overflow: hidden;
|
|
135
|
+
text-overflow: ellipsis;
|
|
136
|
+
white-space: nowrap;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
:global(.tiptap-mention-group-popover-email) {
|
|
140
|
+
color: var(--ant-color-text-secondary);
|
|
141
|
+
font-size: var(--ant-font-size-sm, 12px);
|
|
142
|
+
line-height: 1.3;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
:global(.tiptap-mention-group-popover-empty) {
|
|
146
|
+
color: var(--ant-color-text-secondary);
|
|
147
|
+
padding: 0.1rem 0;
|
|
148
|
+
}
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { type } from '@lowdefy/helpers';
|
|
17
17
|
import tippy from 'tippy.js';
|
|
18
18
|
import MentionList from './MentionList.js';
|
|
19
|
-
|
|
19
|
+
import itemSection from './itemSection.js';
|
|
20
|
+
function suggestion({ methods, char = '@', allowSpaces = true, limit = 5 }) {
|
|
20
21
|
return {
|
|
21
22
|
// `char` must be set explicitly: Mention.configure does a shallow merge
|
|
22
23
|
// on its options.suggestion, replacing the extension's default `@`.
|
|
@@ -24,7 +25,7 @@ function suggestion({ methods, char = '@', allowSpaces = true }) {
|
|
|
24
25
|
allowSpaces,
|
|
25
26
|
items: ({ query, editor })=>{
|
|
26
27
|
const itemsList = editor.options.editorProps.items ?? [];
|
|
27
|
-
|
|
28
|
+
const filtered = itemsList.filter((item)=>{
|
|
28
29
|
if (type.isString(item)) {
|
|
29
30
|
return item.toLowerCase().includes(query.toLowerCase());
|
|
30
31
|
}
|
|
@@ -32,7 +33,19 @@ function suggestion({ methods, char = '@', allowSpaces = true }) {
|
|
|
32
33
|
return item.label.toLowerCase().includes(query.toLowerCase());
|
|
33
34
|
}
|
|
34
35
|
return false;
|
|
35
|
-
})
|
|
36
|
+
});
|
|
37
|
+
// no option declares a section → flat list capped at limit (today's behaviour)
|
|
38
|
+
if (!filtered.some((item)=>!type.isNone(itemSection(item)))) {
|
|
39
|
+
return filtered.slice(0, limit);
|
|
40
|
+
}
|
|
41
|
+
const counts = new Map();
|
|
42
|
+
return filtered.filter((item)=>{
|
|
43
|
+
const section = itemSection(item);
|
|
44
|
+
const n = counts.get(section) ?? 0;
|
|
45
|
+
if (n >= limit) return false;
|
|
46
|
+
counts.set(section, n + 1);
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
36
49
|
},
|
|
37
50
|
render: ()=>{
|
|
38
51
|
let component;
|
|
@@ -58,22 +71,39 @@ function suggestion({ methods, char = '@', allowSpaces = true }) {
|
|
|
58
71
|
});
|
|
59
72
|
},
|
|
60
73
|
onUpdate (props) {
|
|
61
|
-
component
|
|
74
|
+
component?.updateProps(props);
|
|
62
75
|
if (!props.clientRect) return;
|
|
63
|
-
popup[0]
|
|
76
|
+
popup?.[0]?.setProps({
|
|
64
77
|
getReferenceClientRect: props.clientRect
|
|
65
78
|
});
|
|
66
79
|
},
|
|
67
80
|
onKeyDown (props) {
|
|
68
81
|
if (props.event.key === 'Escape') {
|
|
69
|
-
popup[0]
|
|
82
|
+
popup?.[0]?.hide();
|
|
70
83
|
return true;
|
|
71
84
|
}
|
|
72
|
-
return component
|
|
85
|
+
return component?.ref?.onKeyDown(props) ?? false;
|
|
73
86
|
},
|
|
74
87
|
onExit () {
|
|
75
|
-
popup
|
|
76
|
-
|
|
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
|
+
}
|
|
77
107
|
}
|
|
78
108
|
};
|
|
79
109
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { type } from '@lowdefy/helpers';
|
|
17
|
+
import tippy from 'tippy.js';
|
|
18
|
+
function buildPopoverContent({ chip, group, groupMembers, char }) {
|
|
19
|
+
const members = groupMembers[group] ?? [];
|
|
20
|
+
const root = document.createElement('div');
|
|
21
|
+
root.className = 'tiptap-mention-group-popover';
|
|
22
|
+
let title = chip.textContent ?? '';
|
|
23
|
+
if (title.startsWith(char)) {
|
|
24
|
+
title = title.slice(char.length);
|
|
25
|
+
}
|
|
26
|
+
const header = document.createElement('div');
|
|
27
|
+
header.className = 'tiptap-mention-group-popover-header';
|
|
28
|
+
header.textContent = `${title} — ${members.length}`;
|
|
29
|
+
root.appendChild(header);
|
|
30
|
+
if (members.length === 0) {
|
|
31
|
+
const empty = document.createElement('div');
|
|
32
|
+
empty.className = 'tiptap-mention-group-popover-empty';
|
|
33
|
+
empty.textContent = 'No members';
|
|
34
|
+
root.appendChild(empty);
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
members.forEach((member)=>{
|
|
38
|
+
const item = document.createElement('div');
|
|
39
|
+
item.className = 'tiptap-mention-group-popover-item';
|
|
40
|
+
const name = document.createElement('div');
|
|
41
|
+
name.textContent = member?.name ?? '';
|
|
42
|
+
item.appendChild(name);
|
|
43
|
+
if (!type.isNone(member?.email)) {
|
|
44
|
+
const email = document.createElement('div');
|
|
45
|
+
email.className = 'tiptap-mention-group-popover-email';
|
|
46
|
+
email.textContent = member.email;
|
|
47
|
+
item.appendChild(email);
|
|
48
|
+
}
|
|
49
|
+
root.appendChild(item);
|
|
50
|
+
});
|
|
51
|
+
return root;
|
|
52
|
+
}
|
|
53
|
+
function useGroupMembersPopover({ editor, properties }) {
|
|
54
|
+
// Updated every render so late-arriving groupMembers (typically from a
|
|
55
|
+
// request) is read at hover time, not captured at mount.
|
|
56
|
+
const mentionsRef = useRef(properties.mentions);
|
|
57
|
+
mentionsRef.current = properties.mentions;
|
|
58
|
+
const popoverRef = useRef(null);
|
|
59
|
+
const activeChipRef = useRef(null);
|
|
60
|
+
useEffect(()=>{
|
|
61
|
+
if (!editor || editor.isDestroyed) return undefined;
|
|
62
|
+
const dom = editor.view.dom;
|
|
63
|
+
const hide = ()=>{
|
|
64
|
+
if (popoverRef.current) {
|
|
65
|
+
popoverRef.current.destroy();
|
|
66
|
+
popoverRef.current = null;
|
|
67
|
+
}
|
|
68
|
+
activeChipRef.current = null;
|
|
69
|
+
};
|
|
70
|
+
const handleMouseOver = (event)=>{
|
|
71
|
+
const chip = event.target?.closest?.('.tiptap-mention-group[data-mention-group]');
|
|
72
|
+
if (!chip) return;
|
|
73
|
+
if (!dom.contains(chip)) return;
|
|
74
|
+
if (chip === activeChipRef.current) return;
|
|
75
|
+
const groupMembers = mentionsRef.current?.groupMembers;
|
|
76
|
+
if (type.isNone(groupMembers)) return;
|
|
77
|
+
const group = chip.getAttribute('data-mention-group');
|
|
78
|
+
if (!Object.prototype.hasOwnProperty.call(groupMembers, group)) return;
|
|
79
|
+
hide();
|
|
80
|
+
const char = mentionsRef.current?.char ?? '@';
|
|
81
|
+
const content = buildPopoverContent({
|
|
82
|
+
chip,
|
|
83
|
+
group,
|
|
84
|
+
groupMembers,
|
|
85
|
+
char
|
|
86
|
+
});
|
|
87
|
+
const instance = tippy(chip, {
|
|
88
|
+
content,
|
|
89
|
+
trigger: 'manual',
|
|
90
|
+
appendTo: ()=>document.body,
|
|
91
|
+
placement: 'top-start',
|
|
92
|
+
interactive: false
|
|
93
|
+
});
|
|
94
|
+
instance.show();
|
|
95
|
+
popoverRef.current = instance;
|
|
96
|
+
activeChipRef.current = chip;
|
|
97
|
+
};
|
|
98
|
+
const handleMouseOut = (event)=>{
|
|
99
|
+
if (activeChipRef.current && !activeChipRef.current.contains(event.relatedTarget)) {
|
|
100
|
+
hide();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
dom.addEventListener('mouseover', handleMouseOver);
|
|
104
|
+
dom.addEventListener('mouseout', handleMouseOut);
|
|
105
|
+
editor.on('update', hide);
|
|
106
|
+
return ()=>{
|
|
107
|
+
dom.removeEventListener('mouseover', handleMouseOver);
|
|
108
|
+
dom.removeEventListener('mouseout', handleMouseOut);
|
|
109
|
+
editor.off('update', hide);
|
|
110
|
+
hide();
|
|
111
|
+
};
|
|
112
|
+
}, [
|
|
113
|
+
editor
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
export default useGroupMembersPopover;
|
|
@@ -62,6 +62,10 @@ function useTiptapMentionState({ value, methods }) {
|
|
|
62
62
|
file,
|
|
63
63
|
methods
|
|
64
64
|
});
|
|
65
|
+
// The upload is async: bail if the editor was torn down meanwhile (e.g. the
|
|
66
|
+
// surrounding page navigated away before the upload resolved), otherwise the
|
|
67
|
+
// chained commands run against a destroyed editor and throw.
|
|
68
|
+
if (editor.isDestroyed) return;
|
|
65
69
|
editor.chain().insertContentAt(pos, [
|
|
66
70
|
{
|
|
67
71
|
type: 'image',
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
|
-
*/ import React from 'react';
|
|
16
|
-
import {
|
|
15
|
+
*/ import React, { useEffect, useState } from 'react';
|
|
16
|
+
import { createPortal } from 'react-dom';
|
|
17
|
+
import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu';
|
|
17
18
|
import { AiOutlineBold, AiOutlineItalic, AiOutlineStrikethrough, AiOutlineHighlight } from 'react-icons/ai';
|
|
18
19
|
import { isTextSelection } from '@tiptap/core';
|
|
19
20
|
const HIGHLIGHT_SWATCHES = [
|
|
@@ -37,29 +38,61 @@ const HIGHLIGHT_SWATCHES = [
|
|
|
37
38
|
function hasExt(editor, name) {
|
|
38
39
|
return editor.extensionManager.extensions.some((ext)=>ext.name === name);
|
|
39
40
|
}
|
|
41
|
+
// 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'".
|
|
49
|
+
//
|
|
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.
|
|
40
54
|
const PopoverMenu = ({ editor })=>{
|
|
55
|
+
const [container] = useState(()=>typeof document === 'undefined' ? null : document.createElement('div'));
|
|
41
56
|
const showBold = hasExt(editor, 'bold');
|
|
42
57
|
const showItalic = hasExt(editor, 'italic');
|
|
43
58
|
const showStrike = hasExt(editor, 'strike');
|
|
44
59
|
const showHighlight = hasExt(editor, 'highlight');
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
const hasTools = showBold || showItalic || showStrike || showHighlight;
|
|
61
|
+
useEffect(()=>{
|
|
62
|
+
if (!container || !editor || editor.isDestroyed || !hasTools) return undefined;
|
|
63
|
+
container.className = 'tiptap-popover';
|
|
64
|
+
const plugin = BubbleMenuPlugin({
|
|
65
|
+
pluginKey: 'bubbleMenu',
|
|
66
|
+
editor,
|
|
67
|
+
element: container,
|
|
68
|
+
shouldShow: ({ editor: menuEditor, view, state, from, to })=>{
|
|
69
|
+
if (menuEditor.isActive('image')) return false;
|
|
70
|
+
const { selection } = state;
|
|
71
|
+
const { empty } = selection;
|
|
72
|
+
const isEmptyTextBlock = !state.doc.textBetween(from, to).length && isTextSelection(state.selection);
|
|
73
|
+
const hasEditorFocus = view.hasFocus();
|
|
74
|
+
if (!hasEditorFocus || empty || isEmptyTextBlock || !menuEditor.isEditable) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
editor.registerPlugin(plugin);
|
|
81
|
+
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.
|
|
85
|
+
if (!editor.isDestroyed) {
|
|
86
|
+
editor.unregisterPlugin('bubbleMenu');
|
|
59
87
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
};
|
|
89
|
+
}, [
|
|
90
|
+
editor,
|
|
91
|
+
container,
|
|
92
|
+
hasTools
|
|
93
|
+
]);
|
|
94
|
+
if (!container || !hasTools) return null;
|
|
95
|
+
return /*#__PURE__*/ createPortal(/*#__PURE__*/ React.createElement(React.Fragment, null, showBold && /*#__PURE__*/ React.createElement(AiOutlineBold, {
|
|
63
96
|
className: "tiptap-icon",
|
|
64
97
|
onClick: ()=>editor.chain().focus().toggleBold().run()
|
|
65
98
|
}), showItalic && /*#__PURE__*/ React.createElement(AiOutlineItalic, {
|
|
@@ -77,6 +110,6 @@ const PopoverMenu = ({ editor })=>{
|
|
|
77
110
|
onClick: ()=>editor.chain().focus().toggleHighlight({
|
|
78
111
|
color: fill
|
|
79
112
|
}).run()
|
|
80
|
-
})));
|
|
113
|
+
}))), container);
|
|
81
114
|
};
|
|
82
115
|
export default PopoverMenu;
|
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
method: 'POST',
|
|
46
46
|
body: formData
|
|
47
47
|
});
|
|
48
|
-
|
|
48
|
+
// createPresignedPost returns the bucket endpoint with a trailing slash, so only
|
|
49
|
+
// add a separator when it is missing to avoid a double slash before the key.
|
|
50
|
+
file.url = url.endsWith('/') ? `${url}${key}` : `${url}/${key}`;
|
|
49
51
|
return file.url;
|
|
50
52
|
}
|
|
51
53
|
export default s3FileUpload;
|
|
@@ -82,8 +82,140 @@
|
|
|
82
82
|
outline: none;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/* Editor content styling.
|
|
86
|
+
|
|
87
|
+
The antd v6 reset.css is loaded globally by every Lowdefy app and strips or
|
|
88
|
+
flattens the user-agent styles for the block-level and inline elements that
|
|
89
|
+
TipTap (StarterKit + configured extensions) produces: lists lose their
|
|
90
|
+
markers and indent, headings collapse to a single weight/size, blockquotes
|
|
91
|
+
lose their indent, <hr> renders at zero height, and inline/block code loses
|
|
92
|
+
its background. The tags are still in getHTML() — they just render unstyled,
|
|
93
|
+
which reads as "formatting stripped". We restore each one here, scoped to
|
|
94
|
+
.tiptap so it only affects editor content, using antd theme variables so it
|
|
95
|
+
tracks the active theme.
|
|
96
|
+
|
|
97
|
+
Nodes covered: paragraph, bulletList, orderedList, listItem, blockquote,
|
|
98
|
+
heading (h1-h6), horizontalRule, code (inline), codeBlock. Marks bold,
|
|
99
|
+
italic, strike and link survive the reset unchanged. */
|
|
100
|
+
|
|
101
|
+
/* Paragraphs: a small gap so successive lines (each a new <p> from pressing
|
|
102
|
+
Enter) read as separate lines instead of merging. */
|
|
85
103
|
:global(.tiptap) :global(p) {
|
|
86
|
-
margin: 0;
|
|
104
|
+
margin: 0 0 0.5em;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
:global(.tiptap) > :global(p):last-child {
|
|
108
|
+
margin-bottom: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Lists: restore the markers and indent the antd reset removes. */
|
|
112
|
+
:global(.tiptap) :global(ul),
|
|
113
|
+
:global(.tiptap) :global(ol) {
|
|
114
|
+
margin: 0 0 0.5em;
|
|
115
|
+
padding-left: 1.5em;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
:global(.tiptap) :global(ul) {
|
|
119
|
+
list-style: disc;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
:global(.tiptap) :global(ol) {
|
|
123
|
+
list-style: decimal;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Keep paragraphs inside list items and nested lists tight. */
|
|
127
|
+
:global(.tiptap) :global(li) :global(p),
|
|
128
|
+
:global(.tiptap) :global(li) :global(ul),
|
|
129
|
+
:global(.tiptap) :global(li) :global(ol) {
|
|
130
|
+
margin-bottom: 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Blockquote: restore the indent and add a quote bar. */
|
|
134
|
+
:global(.tiptap) :global(blockquote) {
|
|
135
|
+
margin: 0 0 0.5em;
|
|
136
|
+
padding-left: 1em;
|
|
137
|
+
border-left: 3px solid var(--ant-color-border, #d9d9d9);
|
|
138
|
+
color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.65));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Headings: the reset flattens all six to weight 500 with no size scale.
|
|
142
|
+
Restore a distinct, descending scale so heading levels are visible. */
|
|
143
|
+
:global(.tiptap) :global(h1),
|
|
144
|
+
:global(.tiptap) :global(h2),
|
|
145
|
+
:global(.tiptap) :global(h3),
|
|
146
|
+
:global(.tiptap) :global(h4),
|
|
147
|
+
:global(.tiptap) :global(h5),
|
|
148
|
+
:global(.tiptap) :global(h6) {
|
|
149
|
+
margin: 0 0 0.5em;
|
|
150
|
+
font-weight: 600;
|
|
151
|
+
line-height: 1.3;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
:global(.tiptap) :global(h1) {
|
|
155
|
+
font-size: 2em;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
:global(.tiptap) :global(h2) {
|
|
159
|
+
font-size: 1.5em;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
:global(.tiptap) :global(h3) {
|
|
163
|
+
font-size: 1.25em;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
:global(.tiptap) :global(h4) {
|
|
167
|
+
font-size: 1.1em;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
:global(.tiptap) :global(h5) {
|
|
171
|
+
font-size: 1em;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
:global(.tiptap) :global(h6) {
|
|
175
|
+
font-size: 0.9em;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* Horizontal rule: the reset sets height 0 with no border, making it invisible.
|
|
179
|
+
Render it as a themed divider. */
|
|
180
|
+
:global(.tiptap) :global(hr) {
|
|
181
|
+
height: 0;
|
|
182
|
+
margin: 0.5em 0;
|
|
183
|
+
border: none;
|
|
184
|
+
border-top: 1px solid var(--ant-color-border, #d9d9d9);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Inline code: monospace from the reset, but no background — give it the
|
|
188
|
+
code chip look. */
|
|
189
|
+
:global(.tiptap) :global(code) {
|
|
190
|
+
padding: 0.1em 0.3em;
|
|
191
|
+
border-radius: var(--ant-border-radius-sm, 4px);
|
|
192
|
+
background: var(--ant-color-fill-tertiary, rgba(0, 0, 0, 0.04));
|
|
193
|
+
font-size: 0.9em;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* Code block: a padded, scrollable monospace box. The nested <code> resets to
|
|
197
|
+
plain so it doesn't inherit the inline-code chip styling above. */
|
|
198
|
+
:global(.tiptap) :global(pre) {
|
|
199
|
+
margin: 0 0 0.5em;
|
|
200
|
+
padding: 8px 12px;
|
|
201
|
+
border-radius: var(--ant-border-radius, 6px);
|
|
202
|
+
background: var(--ant-color-fill-tertiary, rgba(0, 0, 0, 0.04));
|
|
203
|
+
overflow-x: auto;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
:global(.tiptap) :global(pre) :global(code) {
|
|
207
|
+
padding: 0;
|
|
208
|
+
border-radius: 0;
|
|
209
|
+
background: none;
|
|
210
|
+
font-size: inherit;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Highlight marks normally carry an inline background-color from the extension;
|
|
214
|
+
this is a fallback so a color-less <mark> (e.g. pasted or seeded HTML) stays
|
|
215
|
+
visible instead of blending into the text. */
|
|
216
|
+
:global(.tiptap) :global(mark) {
|
|
217
|
+
background-color: var(--ant-color-warning-bg, rgba(255, 229, 143, 0.5));
|
|
218
|
+
color: inherit;
|
|
87
219
|
}
|
|
88
220
|
|
|
89
221
|
:global(.tiptap) :global(p.is-editor-empty):first-child::before {
|
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lowdefy/blocks-tiptap",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Lowdefy TipTap rich-text editor blocks.",
|
|
6
6
|
"homepage": "https://lowdefy.com",
|
|
7
|
+
"lowdefy": {
|
|
8
|
+
"serverExternalPackages": [
|
|
9
|
+
"turndown"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
7
12
|
"keywords": [
|
|
8
13
|
"lowdefy",
|
|
9
14
|
"lowdefy blocks",
|
|
@@ -41,10 +46,11 @@
|
|
|
41
46
|
"dist/*"
|
|
42
47
|
],
|
|
43
48
|
"dependencies": {
|
|
44
|
-
"@lowdefy/block-utils": "5.
|
|
45
|
-
"@lowdefy/blocks-antd": "5.
|
|
46
|
-
"@lowdefy/helpers": "5.
|
|
49
|
+
"@lowdefy/block-utils": "5.5.0",
|
|
50
|
+
"@lowdefy/blocks-antd": "5.5.0",
|
|
51
|
+
"@lowdefy/helpers": "5.5.0",
|
|
47
52
|
"@tiptap/core": "2.27.2",
|
|
53
|
+
"@tiptap/extension-bubble-menu": "2.27.2",
|
|
48
54
|
"@tiptap/extension-file-handler": "2.27.2",
|
|
49
55
|
"@tiptap/extension-highlight": "2.27.2",
|
|
50
56
|
"@tiptap/extension-image": "2.27.2",
|
|
@@ -67,8 +73,8 @@
|
|
|
67
73
|
"react-dom": ">=18"
|
|
68
74
|
},
|
|
69
75
|
"devDependencies": {
|
|
70
|
-
"@lowdefy/block-dev-e2e": "5.
|
|
71
|
-
"@lowdefy/e2e-utils": "5.
|
|
76
|
+
"@lowdefy/block-dev-e2e": "5.5.0",
|
|
77
|
+
"@lowdefy/e2e-utils": "5.5.0",
|
|
72
78
|
"@playwright/test": "1.50.1",
|
|
73
79
|
"@swc/cli": "0.8.0",
|
|
74
80
|
"@swc/core": "1.15.18",
|