@lowdefy/blocks-tiptap 0.0.0-experimental-20260428103147

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.
@@ -0,0 +1,104 @@
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 { useRef } from 'react';
16
+ import TurndownService from 'turndown';
17
+ import s3FileUpload from '../utils/s3FileUpload.js';
18
+ // Ref-tracked controller for the editor's value. No useState — the only
19
+ // source of truth for `fileList` is the `value` prop from Lowdefy. We mirror
20
+ // it into a ref each render so callbacks (onUpdate, insertImage) captured in
21
+ // closures always read the current value instead of a stale render snapshot.
22
+ function useTiptapState({ value, methods }) {
23
+ const valueRef = useRef(value);
24
+ valueRef.current = value;
25
+ const turndownService = new TurndownService();
26
+ turndownService.addRule('encodeImgUrl', {
27
+ filter: 'img',
28
+ replacement: (content, node)=>{
29
+ const src = node.getAttribute('src');
30
+ return `![${content}](${encodeURI(src)})`;
31
+ }
32
+ });
33
+ // Emit a full `{fileList, html, text, markdown}` value to Lowdefy based on
34
+ // the editor's current document. When appendFile is supplied (an S3 upload
35
+ // result), it is added to the current fileList before filtering by
36
+ // in-document image urls.
37
+ const emit = (editor, appendFile)=>{
38
+ const html = editor.getHTML();
39
+ const markdown = turndownService.turndown(html);
40
+ const json = editor.getJSON();
41
+ const text = editor.getText().trim() === '' ? null : editor.getText();
42
+ const urls = (json.content ?? []).filter((c)=>c.type === 'image').map((c)=>c.attrs.src);
43
+ const base = valueRef.current?.fileList ?? [];
44
+ const next = appendFile ? [
45
+ ...base,
46
+ appendFile
47
+ ] : base;
48
+ const fileList = next.filter((f)=>urls.includes(f.url));
49
+ methods.setValue({
50
+ fileList,
51
+ html,
52
+ text,
53
+ markdown
54
+ });
55
+ };
56
+ const insertImage = async (editor, file, pos)=>{
57
+ const url = await s3FileUpload({
58
+ file,
59
+ methods
60
+ });
61
+ editor.chain().insertContentAt(pos, [
62
+ {
63
+ type: 'image',
64
+ attrs: {
65
+ src: url
66
+ }
67
+ },
68
+ {
69
+ type: 'paragraph',
70
+ content: [
71
+ {
72
+ type: 'text',
73
+ marks: [
74
+ {
75
+ type: 'link',
76
+ attrs: {
77
+ href: url,
78
+ target: '_blank'
79
+ }
80
+ }
81
+ ],
82
+ text: 'Enlarge Image'
83
+ }
84
+ ]
85
+ }
86
+ ]).focus().run();
87
+ const fileObj = {
88
+ bucket: file.bucket,
89
+ key: file.key,
90
+ lastModified: file.lastModified,
91
+ name: file.name,
92
+ size: file.size,
93
+ status: file.status,
94
+ type: file.type,
95
+ url
96
+ };
97
+ emit(editor, fileObj);
98
+ };
99
+ return {
100
+ emit,
101
+ insertImage
102
+ };
103
+ }
104
+ export default useTiptapState;
@@ -0,0 +1,69 @@
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 React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
16
+ import { renderHtml } from '@lowdefy/block-utils';
17
+ const MentionList = /*#__PURE__*/ forwardRef((props, ref)=>{
18
+ const [selectedIndex, setSelectedIndex] = useState(0);
19
+ const selectItem = (index)=>{
20
+ const item = props.items[index];
21
+ if (item) {
22
+ props.command({
23
+ id: item
24
+ });
25
+ }
26
+ };
27
+ const upHandler = ()=>{
28
+ setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
29
+ };
30
+ const downHandler = ()=>{
31
+ setSelectedIndex((selectedIndex + 1) % props.items.length);
32
+ };
33
+ const enterHandler = ()=>{
34
+ selectItem(selectedIndex);
35
+ };
36
+ useEffect(()=>setSelectedIndex(0), [
37
+ props.items
38
+ ]);
39
+ useImperativeHandle(ref, ()=>({
40
+ onKeyDown: ({ event })=>{
41
+ if (event.key === 'ArrowUp') {
42
+ upHandler();
43
+ return true;
44
+ }
45
+ if (event.key === 'ArrowDown') {
46
+ downHandler();
47
+ return true;
48
+ }
49
+ if (event.key === 'Enter') {
50
+ enterHandler();
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ }));
56
+ return /*#__PURE__*/ React.createElement("div", {
57
+ 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
65
+ }))) : /*#__PURE__*/ React.createElement("div", {
66
+ className: "tiptap-mention-item secondary"
67
+ }, "No matching results"));
68
+ });
69
+ export default MentionList;
@@ -0,0 +1,238 @@
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 React, { useEffect } from 'react';
16
+ import { withBlockDefaults } from '@lowdefy/block-utils';
17
+ import { type } from '@lowdefy/helpers';
18
+ import { useEditor, EditorContent } from '@tiptap/react';
19
+ import { mergeAttributes } from '@tiptap/core';
20
+ import Mention from '@tiptap/extension-mention';
21
+ import Label from '@lowdefy/blocks-antd/blocks/Label/Label.js';
22
+ import PopoverMenu from '../utils/PopoverMenu.js';
23
+ import buildExtensions from '../utils/buildExtensions.js';
24
+ import computeHeightStyle from '../utils/computeHeightStyle.js';
25
+ import statusClass from '../utils/statusClass.js';
26
+ import suggestion from './suggestion.js';
27
+ import useTiptapMentionState from './useTiptapMentionState.js';
28
+ import './style.module.css';
29
+ function mentionLabel(id) {
30
+ return id?.tag?.title ?? id?.label ?? id;
31
+ }
32
+ const TiptapMentionInput = ({ blockId, components: { Icon, Link }, events, loading, methods, properties, required, validation, value })=>{
33
+ const { emit, insertImage } = useTiptapMentionState({
34
+ value,
35
+ methods
36
+ });
37
+ const disabled = properties.disabled === true || loading;
38
+ const uploadEnabled = !type.isNone(properties.s3PostPolicyRequestId);
39
+ const char = properties.mentions?.char ?? '@';
40
+ const allowSpaces = properties.mentions?.allowSpaces !== false;
41
+ const mentionExtension = Mention.configure({
42
+ HTMLAttributes: {
43
+ class: 'tiptap-mention'
44
+ },
45
+ renderHTML ({ options, node }) {
46
+ const label = mentionLabel(node.attrs.id);
47
+ if (type.isFunction(properties.mentions?.getHref)) {
48
+ return [
49
+ 'a',
50
+ mergeAttributes({
51
+ href: properties.mentions.getHref(node.attrs.id),
52
+ 'data-id': node.attrs.id?._id
53
+ }, options.HTMLAttributes),
54
+ `${char}${label}`
55
+ ];
56
+ }
57
+ return [
58
+ 'span',
59
+ mergeAttributes(options.HTMLAttributes),
60
+ `${char}${label}`
61
+ ];
62
+ },
63
+ renderText ({ node }) {
64
+ return `${char}${mentionLabel(node.attrs.id)}`;
65
+ },
66
+ suggestion: suggestion({
67
+ methods,
68
+ char,
69
+ allowSpaces
70
+ })
71
+ });
72
+ const extensions = buildExtensions({
73
+ properties,
74
+ insertImage,
75
+ mentionExtension,
76
+ uploadEnabled
77
+ });
78
+ const heightStyle = computeHeightStyle({
79
+ rows: properties.rows,
80
+ autoSize: properties.autoSize
81
+ });
82
+ const editor = useEditor({
83
+ editorProps: {
84
+ items: type.isArray(properties.mentions?.options) ? properties.mentions.options : []
85
+ },
86
+ extensions,
87
+ content: value?.html || '',
88
+ editable: ()=>!disabled,
89
+ onUpdate: ({ editor })=>{
90
+ emit(editor);
91
+ methods.triggerEvent({
92
+ name: 'onChange'
93
+ });
94
+ }
95
+ });
96
+ useEffect(()=>{
97
+ if (editor) {
98
+ editor.setOptions({
99
+ editorProps: {
100
+ items: type.isArray(properties.mentions?.options) ? properties.mentions.options : []
101
+ }
102
+ });
103
+ }
104
+ }, [
105
+ properties.mentions?.options,
106
+ editor
107
+ ]);
108
+ useEffect(()=>{
109
+ if (uploadEnabled) {
110
+ methods.registerEvent({
111
+ name: '__getS3PostPolicy',
112
+ actions: [
113
+ {
114
+ id: '__getS3PostPolicy',
115
+ type: 'Request',
116
+ params: [
117
+ properties.s3PostPolicyRequestId
118
+ ]
119
+ }
120
+ ]
121
+ });
122
+ }
123
+ if (!type.isNone(properties.mentionsRequestId)) {
124
+ methods.registerEvent({
125
+ name: '__getTipTapMentions',
126
+ actions: [
127
+ {
128
+ id: '__getTipTapMentions',
129
+ type: 'Request',
130
+ params: [
131
+ properties.mentionsRequestId
132
+ ]
133
+ }
134
+ ]
135
+ });
136
+ }
137
+ }, []);
138
+ useEffect(()=>{
139
+ if (!editor) return;
140
+ methods.registerMethod('clear', ()=>{
141
+ editor.commands.clearContent();
142
+ emit(editor);
143
+ });
144
+ methods.registerMethod('setContent', (args)=>{
145
+ editor.commands.setContent(args?.html ?? '');
146
+ emit(editor);
147
+ });
148
+ methods.registerMethod('focus', ()=>{
149
+ editor.commands.focus();
150
+ });
151
+ }, [
152
+ editor
153
+ ]);
154
+ // External value.html → editor sync. One-way only: we read value.html and
155
+ // push it into the editor with setContent(..., false) so tiptap's onUpdate
156
+ // does not fire. No write-back via emit() — that would race with concurrent
157
+ // SetState calls (child effects fire before parent effects, so a sibling's
158
+ // onMount SetState could be overwritten by our derived emit). Derived
159
+ // fields (text/markdown/fileList/mentions) are populated on user interaction
160
+ // via onUpdate; downstream consumers of seeded content should read
161
+ // value.html directly, or include the fields they need in their SetState
162
+ // payload.
163
+ useEffect(()=>{
164
+ if (!editor) return;
165
+ const next = value?.html ?? '';
166
+ const current = editor.getHTML();
167
+ if (next !== current) {
168
+ editor.commands.setContent(next, false);
169
+ }
170
+ }, [
171
+ value?.html,
172
+ editor
173
+ ]);
174
+ useEffect(()=>{
175
+ if (!editor) return;
176
+ editor.setOptions({
177
+ editable: !disabled
178
+ });
179
+ }, [
180
+ editor,
181
+ disabled
182
+ ]);
183
+ useEffect(()=>{
184
+ if (!editor) return;
185
+ const placeholderExt = editor.extensionManager.extensions.find((extension)=>extension.name === 'placeholder');
186
+ if (placeholderExt) {
187
+ placeholderExt.options.placeholder = properties.placeholder ?? '';
188
+ editor.view.dispatch(editor.state.tr);
189
+ }
190
+ }, [
191
+ editor,
192
+ properties.placeholder
193
+ ]);
194
+ const wrapperClass = [
195
+ 'tiptap-wrapper',
196
+ properties.bordered === false ? 'tiptap-wrapper-borderless' : '',
197
+ disabled ? 'tiptap-wrapper-disabled' : '',
198
+ statusClass(validation?.status)
199
+ ].filter(Boolean).join(' ');
200
+ const wrapperStyle = {
201
+ padding: 0,
202
+ ...heightStyle,
203
+ ...properties.inputStyle,
204
+ ...properties.style
205
+ };
206
+ return /*#__PURE__*/ React.createElement(Label, {
207
+ blockId: blockId,
208
+ components: {
209
+ Icon,
210
+ Link
211
+ },
212
+ events: events,
213
+ properties: {
214
+ title: properties.title,
215
+ size: properties.size,
216
+ ...properties.label
217
+ },
218
+ required: required,
219
+ validation: validation,
220
+ content: {
221
+ content: ()=>{
222
+ if (!editor) {
223
+ return /*#__PURE__*/ React.createElement("div", null);
224
+ }
225
+ return /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(EditorContent, {
226
+ id: `${blockId}_input`,
227
+ editor: editor,
228
+ className: wrapperClass,
229
+ style: wrapperStyle
230
+ }), !disabled && /*#__PURE__*/ React.createElement(PopoverMenu, {
231
+ editor: editor,
232
+ Icon: Icon
233
+ }));
234
+ }
235
+ }
236
+ });
237
+ };
238
+ export default withBlockDefaults(TiptapMentionInput);
@@ -0,0 +1,25 @@
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 { createBlockHelper, escapeId } from '@lowdefy/e2e-utils';
16
+ import { expect } from '@playwright/test';
17
+ const locator = (page, blockId)=>page.locator(`#${escapeId(blockId)}_input .ProseMirror`).first();
18
+ export default createBlockHelper({
19
+ locator,
20
+ expect: {
21
+ containsText: (page, blockId, text)=>expect(locator(page, blockId)).toContainText(text),
22
+ containsHtml: (page, blockId, selector)=>expect(locator(page, blockId).locator(selector)).toBeVisible(),
23
+ isEmpty: (page, blockId)=>expect(locator(page, blockId)).toHaveText('')
24
+ }
25
+ });
@@ -0,0 +1,231 @@
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
+ */ export default {
16
+ category: 'input',
17
+ icons: [],
18
+ valueType: 'object',
19
+ initValue: {
20
+ html: null,
21
+ text: null,
22
+ markdown: null,
23
+ fileList: [],
24
+ mentions: []
25
+ },
26
+ events: {
27
+ onChange: 'Trigger action when the editor content is changed.'
28
+ },
29
+ properties: {
30
+ type: 'object',
31
+ additionalProperties: false,
32
+ properties: {
33
+ allowedMimeTypes: {
34
+ type: 'array',
35
+ items: {
36
+ type: 'string'
37
+ },
38
+ description: 'Mime-types accepted by the drag/drop and paste file handler. Defaults to common image types. Only used when s3PostPolicyRequestId is set.'
39
+ },
40
+ autoSize: {
41
+ oneOf: [
42
+ {
43
+ type: 'boolean',
44
+ default: false,
45
+ description: 'When true the editor grows with its content and has no max height.'
46
+ },
47
+ {
48
+ type: 'object',
49
+ description: 'Constrain the editor height with a minimum and/or maximum row count.',
50
+ properties: {
51
+ minRows: {
52
+ type: 'integer',
53
+ minimum: 1
54
+ },
55
+ maxRows: {
56
+ type: 'integer',
57
+ minimum: 1
58
+ }
59
+ }
60
+ }
61
+ ],
62
+ description: 'Either a boolean (true to auto-grow without a cap) or an object with minRows/maxRows. Ignored when `rows` is set.'
63
+ },
64
+ bordered: {
65
+ type: 'boolean',
66
+ default: true,
67
+ description: 'Whether the editor renders with a border.'
68
+ },
69
+ disabled: {
70
+ type: 'boolean',
71
+ default: false,
72
+ description: 'Render the editor as read-only.'
73
+ },
74
+ highlight: {
75
+ type: 'object',
76
+ description: 'Text highlight extension settings.',
77
+ additionalProperties: false,
78
+ properties: {
79
+ disabled: {
80
+ type: 'boolean',
81
+ default: false
82
+ },
83
+ multicolor: {
84
+ type: 'boolean',
85
+ default: true
86
+ }
87
+ }
88
+ },
89
+ image: {
90
+ type: 'object',
91
+ description: 'Image extension settings.',
92
+ additionalProperties: false,
93
+ properties: {
94
+ disabled: {
95
+ type: 'boolean',
96
+ default: false
97
+ },
98
+ maxWidth: {
99
+ type: 'string',
100
+ default: '80%'
101
+ },
102
+ zoom: {
103
+ type: 'number',
104
+ default: 0.6
105
+ }
106
+ }
107
+ },
108
+ inputStyle: {
109
+ type: 'object',
110
+ description: 'Inline style applied to the editable area of the editor.',
111
+ docs: {
112
+ displayType: 'yaml'
113
+ }
114
+ },
115
+ label: {
116
+ type: 'object',
117
+ description: 'Label configuration forwarded to the Lowdefy Label block.',
118
+ docs: {
119
+ displayType: 'yaml'
120
+ }
121
+ },
122
+ link: {
123
+ type: 'object',
124
+ description: 'Link extension settings.',
125
+ additionalProperties: false,
126
+ properties: {
127
+ disabled: {
128
+ type: 'boolean',
129
+ default: false
130
+ },
131
+ autolink: {
132
+ type: 'boolean',
133
+ default: true
134
+ },
135
+ linkOnPaste: {
136
+ type: 'boolean',
137
+ default: true
138
+ },
139
+ openOnClick: {
140
+ type: 'boolean',
141
+ default: true
142
+ },
143
+ defaultProtocol: {
144
+ type: 'string',
145
+ default: 'https'
146
+ }
147
+ }
148
+ },
149
+ mentions: {
150
+ type: 'object',
151
+ description: 'Configure the set of mention targets and how they render.',
152
+ additionalProperties: false,
153
+ properties: {
154
+ char: {
155
+ type: 'string',
156
+ default: '@',
157
+ description: 'Trigger character that opens the mention dropdown. Change to "#" for hashtags, etc.'
158
+ },
159
+ allowSpaces: {
160
+ type: 'boolean',
161
+ default: true,
162
+ description: 'Allow spaces inside a mention query before it is committed.'
163
+ },
164
+ options: {
165
+ type: 'array',
166
+ description: 'Array of mention items. Each item may be a string, or an object with a "label" (matched against user input) and a "value" (stored on the node).'
167
+ },
168
+ getHref: {
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.',
171
+ docs: {
172
+ displayType: 'yaml'
173
+ }
174
+ }
175
+ }
176
+ },
177
+ mentionsRequestId: {
178
+ type: 'string',
179
+ description: 'Id of a request used to populate mention options. When set, the block registers a __getTipTapMentions event that calls that request.'
180
+ },
181
+ placeholder: {
182
+ type: 'string',
183
+ description: 'Placeholder shown when the editor is empty.'
184
+ },
185
+ rows: {
186
+ type: 'integer',
187
+ minimum: 1,
188
+ description: 'Fix the editor height to exactly this many rows. Takes precedence over autoSize.'
189
+ },
190
+ s3PostPolicyRequestId: {
191
+ type: 'string',
192
+ description: 'Id of a request that returns an S3 presigned POST policy. When set, images dragged or pasted into the editor are uploaded via that policy.'
193
+ },
194
+ size: {
195
+ type: 'string',
196
+ enum: [
197
+ 'small',
198
+ 'middle',
199
+ 'large'
200
+ ],
201
+ description: 'Label size forwarded to the Label block.'
202
+ },
203
+ starterKit: {
204
+ type: 'object',
205
+ description: 'Options forwarded to TipTap StarterKit. Use to disable bundled extensions (e.g. {heading: false}).',
206
+ docs: {
207
+ displayType: 'yaml'
208
+ }
209
+ },
210
+ table: {
211
+ type: 'object',
212
+ description: 'Table extension settings.',
213
+ additionalProperties: false,
214
+ properties: {
215
+ disabled: {
216
+ type: 'boolean',
217
+ default: false
218
+ },
219
+ resizable: {
220
+ type: 'boolean',
221
+ default: true
222
+ }
223
+ }
224
+ },
225
+ title: {
226
+ type: 'string',
227
+ description: 'Label title shown above the editor.'
228
+ }
229
+ }
230
+ }
231
+ };