@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.
@@ -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 = props.items[index];
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 + props.items.length - 1) % props.items.length);
54
+ setSelectedIndex((selectedIndex + flatItems.length - 1) % flatItems.length);
29
55
  };
30
56
  const downHandler = ()=>{
31
- setSelectedIndex((selectedIndex + 1) % props.items.length);
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
- }, props.items.length ? props.items.map((item, index)=>/*#__PURE__*/ React.createElement("button", {
59
- className: `tiptap-mention-item ${index === selectedIndex ? 'is-selected' : ''}`,
60
- key: index,
61
- onClick: ()=>selectItem(index)
62
- }, renderHtml({
63
- html: item?.label ?? item,
64
- methods: props.methods
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 label = mentionLabel(node.attrs.id);
47
- if (type.isFunction(properties.mentions?.getHref)) {
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: properties.mentions.getHref(node.attrs.id),
52
- 'data-id': node.attrs.id?._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 function (_function operator) that receives a selected mention id and returns an href. When provided, mentions render as <a> tags.',
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
- function suggestion({ methods, char = '@', allowSpaces = true }) {
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
- return itemsList.filter((item)=>{
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
- }).slice(0, 5);
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.updateProps(props);
74
+ component?.updateProps(props);
62
75
  if (!props.clientRect) return;
63
- popup[0].setProps({
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].hide();
82
+ popup?.[0]?.hide();
70
83
  return true;
71
84
  }
72
- return component.ref?.onKeyDown(props);
85
+ return component?.ref?.onKeyDown(props) ?? false;
73
86
  },
74
87
  onExit () {
75
- popup[0].destroy();
76
- component.destroy();
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 { BubbleMenu } from '@tiptap/react';
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
- if (!showBold && !showItalic && !showStrike && !showHighlight) {
46
- return null;
47
- }
48
- return /*#__PURE__*/ React.createElement(BubbleMenu, {
49
- className: "tiptap-popover",
50
- editor: editor,
51
- shouldShow: ({ editor, view, state, from, to })=>{
52
- if (editor.isActive('image')) return false;
53
- const { doc, selection } = state;
54
- const { empty } = selection;
55
- const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(state.selection);
56
- const hasEditorFocus = view.hasFocus();
57
- if (!hasEditorFocus || empty || isEmptyTextBlock || !editor.isEditable) {
58
- return false;
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
- return true;
61
- }
62
- }, showBold && /*#__PURE__*/ React.createElement(AiOutlineBold, {
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
- file.url = `${url}/${key}`;
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.0",
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.3.0",
45
- "@lowdefy/blocks-antd": "5.3.0",
46
- "@lowdefy/helpers": "5.3.0",
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.3.0",
71
- "@lowdefy/e2e-utils": "5.3.0",
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",