@bigbinary/neetoui-rn 3.4.0 → 3.5.1

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.
Files changed (85) hide show
  1. package/lib/typescript/src/components/ChatInput/AttachmentsView.d.ts +2 -1
  2. package/lib/typescript/src/components/ChatInput/AttachmentsView.d.ts.map +1 -1
  3. package/lib/typescript/src/components/ChatInput/ChatInput.d.ts +76 -0
  4. package/lib/typescript/src/components/ChatInput/ChatInput.d.ts.map +1 -1
  5. package/lib/typescript/src/components/ChatInput/MentionSuggestionList.d.ts +9 -0
  6. package/lib/typescript/src/components/ChatInput/MentionSuggestionList.d.ts.map +1 -0
  7. package/lib/typescript/src/components/ChatInput/MentionsInput.d.ts +3 -1
  8. package/lib/typescript/src/components/ChatInput/MentionsInput.d.ts.map +1 -1
  9. package/lib/typescript/src/components/ChatInput/SegmentedOptions.d.ts +10 -0
  10. package/lib/typescript/src/components/ChatInput/SegmentedOptions.d.ts.map +1 -0
  11. package/lib/typescript/src/components/ChatInput/constants.d.ts +3 -0
  12. package/lib/typescript/src/components/ChatInput/constants.d.ts.map +1 -1
  13. package/lib/typescript/src/components/ChatInput/index.d.ts +1 -1
  14. package/lib/typescript/src/components/ChatInput/index.d.ts.map +1 -1
  15. package/lib/typescript/src/components/ChatInput/mentions.d.ts +2 -0
  16. package/lib/typescript/src/components/ChatInput/mentions.d.ts.map +1 -1
  17. package/lib/typescript/src/components/RichTextEditor/LazyRichTextEditor.d.ts +3 -0
  18. package/lib/typescript/src/components/RichTextEditor/LazyRichTextEditor.d.ts.map +1 -0
  19. package/lib/typescript/src/components/RichTextEditor/MentionSuggestionBar.d.ts +8 -0
  20. package/lib/typescript/src/components/RichTextEditor/MentionSuggestionBar.d.ts.map +1 -0
  21. package/lib/typescript/src/components/RichTextEditor/RichTextEditor.d.ts +39 -0
  22. package/lib/typescript/src/components/RichTextEditor/RichTextEditor.d.ts.map +1 -0
  23. package/lib/typescript/src/components/RichTextEditor/RichTextToolbar.d.ts +8 -0
  24. package/lib/typescript/src/components/RichTextEditor/RichTextToolbar.d.ts.map +1 -0
  25. package/lib/typescript/src/components/RichTextEditor/bridges/CodeBlockBridge.d.ts +13 -0
  26. package/lib/typescript/src/components/RichTextEditor/bridges/CodeBlockBridge.d.ts.map +1 -0
  27. package/lib/typescript/src/components/RichTextEditor/bridges/ComposerBridge.d.ts +27 -0
  28. package/lib/typescript/src/components/RichTextEditor/bridges/ComposerBridge.d.ts.map +1 -0
  29. package/lib/typescript/src/components/RichTextEditor/bridges/FigureBridge.d.ts +3 -0
  30. package/lib/typescript/src/components/RichTextEditor/bridges/FigureBridge.d.ts.map +1 -0
  31. package/lib/typescript/src/components/RichTextEditor/bridges/MentionBridge.d.ts +35 -0
  32. package/lib/typescript/src/components/RichTextEditor/bridges/MentionBridge.d.ts.map +1 -0
  33. package/lib/typescript/src/components/RichTextEditor/bridges/VariableBridge.d.ts +3 -0
  34. package/lib/typescript/src/components/RichTextEditor/bridges/VariableBridge.d.ts.map +1 -0
  35. package/lib/typescript/src/components/RichTextEditor/bridges/index.d.ts +8 -0
  36. package/lib/typescript/src/components/RichTextEditor/bridges/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Figure.d.ts +3 -0
  38. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Figure.d.ts.map +1 -0
  39. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Mention.d.ts +3 -0
  40. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Mention.d.ts.map +1 -0
  41. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Variable.d.ts +3 -0
  42. package/lib/typescript/src/components/RichTextEditor/bridges/nodes/Variable.d.ts.map +1 -0
  43. package/lib/typescript/src/components/RichTextEditor/bridges/types.d.ts +7 -0
  44. package/lib/typescript/src/components/RichTextEditor/bridges/types.d.ts.map +1 -0
  45. package/lib/typescript/src/components/RichTextEditor/editorCss.d.ts +21 -0
  46. package/lib/typescript/src/components/RichTextEditor/editorCss.d.ts.map +1 -0
  47. package/lib/typescript/src/components/RichTextEditor/icons/index.d.ts +9 -0
  48. package/lib/typescript/src/components/RichTextEditor/icons/index.d.ts.map +1 -0
  49. package/lib/typescript/src/components/RichTextEditor/index.d.ts +3 -0
  50. package/lib/typescript/src/components/RichTextEditor/index.d.ts.map +1 -0
  51. package/lib/typescript/src/components/RichTextEditor/injectableText.d.ts +2 -0
  52. package/lib/typescript/src/components/RichTextEditor/injectableText.d.ts.map +1 -0
  53. package/lib/typescript/src/components/RichTextEditor/web/build/editorHtml.d.ts +2 -0
  54. package/lib/typescript/src/components/RichTextEditor/web/build/editorHtml.d.ts.map +1 -0
  55. package/lib/typescript/src/index.d.ts +2 -1
  56. package/lib/typescript/src/index.d.ts.map +1 -1
  57. package/package.json +30 -7
  58. package/src/components/ChatInput/AttachmentsView.tsx +6 -0
  59. package/src/components/ChatInput/ChatInput.tsx +557 -91
  60. package/src/components/ChatInput/MentionSuggestionList.tsx +71 -0
  61. package/src/components/ChatInput/MentionsInput.tsx +34 -46
  62. package/src/components/ChatInput/SegmentedOptions.tsx +84 -0
  63. package/src/components/ChatInput/constants.ts +10 -0
  64. package/src/components/ChatInput/index.ts +3 -0
  65. package/src/components/ChatInput/mentions.ts +22 -0
  66. package/src/components/RichTextEditor/LazyRichTextEditor.tsx +19 -0
  67. package/src/components/RichTextEditor/MentionSuggestionBar.tsx +79 -0
  68. package/src/components/RichTextEditor/RichTextEditor.tsx +213 -0
  69. package/src/components/RichTextEditor/RichTextToolbar.tsx +452 -0
  70. package/src/components/RichTextEditor/bridges/CodeBlockBridge.ts +29 -0
  71. package/src/components/RichTextEditor/bridges/ComposerBridge.ts +107 -0
  72. package/src/components/RichTextEditor/bridges/FigureBridge.ts +7 -0
  73. package/src/components/RichTextEditor/bridges/MentionBridge.ts +79 -0
  74. package/src/components/RichTextEditor/bridges/VariableBridge.ts +7 -0
  75. package/src/components/RichTextEditor/bridges/index.ts +66 -0
  76. package/src/components/RichTextEditor/bridges/nodes/Figure.ts +127 -0
  77. package/src/components/RichTextEditor/bridges/nodes/Mention.ts +48 -0
  78. package/src/components/RichTextEditor/bridges/nodes/Variable.ts +68 -0
  79. package/src/components/RichTextEditor/bridges/types.ts +24 -0
  80. package/src/components/RichTextEditor/editorCss.ts +69 -0
  81. package/src/components/RichTextEditor/icons/index.tsx +41 -0
  82. package/src/components/RichTextEditor/index.ts +2 -0
  83. package/src/components/RichTextEditor/injectableText.ts +11 -0
  84. package/src/components/RichTextEditor/web/build/editorHtml.ts +3 -0
  85. package/src/index.ts +8 -0
@@ -0,0 +1,452 @@
1
+ import { useRef, useState, type ComponentType } from "react";
2
+ import {
3
+ Platform,
4
+ ScrollView,
5
+ TextInput,
6
+ View,
7
+ useWindowDimensions,
8
+ } from "react-native";
9
+
10
+ import { useBridgeState } from "@10play/tentap-editor";
11
+ import Close from "@bigbinary/neeto-icons-rn/icons/Close";
12
+ import Code from "@bigbinary/neeto-icons-rn/icons/Code";
13
+ import CodeBlock from "@bigbinary/neeto-icons-rn/icons/CodeBlock";
14
+ import Link from "@bigbinary/neeto-icons-rn/icons/Link";
15
+ import ListDot from "@bigbinary/neeto-icons-rn/icons/ListDot";
16
+ import ListNumber from "@bigbinary/neeto-icons-rn/icons/ListNumber";
17
+ import Quote from "@bigbinary/neeto-icons-rn/icons/Quote";
18
+ import TextBold from "@bigbinary/neeto-icons-rn/icons/TextBold";
19
+ import TextColor from "@bigbinary/neeto-icons-rn/icons/TextColor";
20
+ import TextCross from "@bigbinary/neeto-icons-rn/icons/TextCross";
21
+ import TextItalic from "@bigbinary/neeto-icons-rn/icons/TextItalic";
22
+ import Underline from "@bigbinary/neeto-icons-rn/icons/Underline";
23
+ import { StyleSheet, useUnistyles } from "react-native-unistyles";
24
+
25
+ import { scaleFor } from "../../utils/scale";
26
+ import { CheckBox } from "../CheckBox";
27
+ import { IconButton } from "../ChatInput/IconButton";
28
+ import { Touchable } from "../Touchable";
29
+ import { Typography } from "../Typography";
30
+
31
+ import { KeyboardDismiss, Pilcrow } from "./icons";
32
+ import type { NeetoBridgeState, NeetoEditorBridge } from "./bridges/types";
33
+
34
+ type RichTextToolbarProps = {
35
+ editor: NeetoEditorBridge;
36
+ colors: string[];
37
+ };
38
+
39
+ type ToolbarItem = {
40
+ label: string;
41
+ Icon: ComponentType<{ size?: number; color?: string }>;
42
+ isActive?: boolean;
43
+ disabled?: boolean;
44
+ onPress: () => void;
45
+ };
46
+
47
+ // iPads have a keyboard-hide key; iPhones have none, and tentap hides the
48
+ // WebView's accessory bar that would otherwise offer Done.
49
+ const IS_IPHONE = Platform.OS === "ios" && !Platform.isPad;
50
+
51
+ const HEADING_LEVELS = [1, 2, 3, 4, 5] as const;
52
+
53
+ type HeadingLevel = Parameters<NeetoEditorBridge["toggleHeading"]>[0];
54
+
55
+ type Panel = "main" | "style" | "color" | "link";
56
+
57
+ const escapeHtml = (value: string) =>
58
+ value
59
+ .replace(/&/g, "&amp;")
60
+ .replace(/"/g, "&quot;")
61
+ .replace(/</g, "&lt;")
62
+ .replace(/>/g, "&gt;");
63
+
64
+ // Anything but these (javascript:, data:) becomes part of an https URL.
65
+ const withScheme = (url: string) =>
66
+ /^(https?|mailto|tel):/i.test(url) ? url : `https://${url}`;
67
+
68
+ export const RichTextToolbar = ({ editor, colors }: RichTextToolbarProps) => {
69
+ const state = useBridgeState(editor) as NeetoBridgeState;
70
+ const { theme } = useUnistyles();
71
+ const { width } = useWindowDimensions();
72
+ const isWide = width >= 600;
73
+ const [panel, setPanel] = useState<Panel>("main");
74
+ const [linkText, setLinkText] = useState("");
75
+ const [linkUrl, setLinkUrl] = useState("");
76
+ const [opensInNewTab, setOpensInNewTab] = useState(true);
77
+ // The form's inputs take focus from the WebView, and Android collapses the
78
+ // editor's selection on blur, so the range is kept from when Link opened.
79
+ const linkRange = useRef({ from: 0, to: 0 });
80
+
81
+ const choose = (apply: () => void) => () => {
82
+ apply();
83
+ setPanel("main");
84
+ };
85
+
86
+ const openLinkForm = () => {
87
+ linkRange.current = state.linkRange ??
88
+ state.selection ?? { from: 0, to: 0 };
89
+ setLinkText(state.linkText ?? "");
90
+ setLinkUrl(state.activeLink ?? "");
91
+ setOpensInNewTab(state.linkOpensInNewTab ?? true);
92
+ setPanel("link");
93
+ };
94
+
95
+ const items: ToolbarItem[] = [
96
+ {
97
+ label: "Text style",
98
+ Icon: Pilcrow,
99
+ isActive: !!state.headingLevel,
100
+ onPress: () => setPanel("style"),
101
+ },
102
+ {
103
+ label: "Bold",
104
+ Icon: TextBold,
105
+ isActive: state.isBoldActive,
106
+ onPress: editor.toggleBold,
107
+ },
108
+ {
109
+ label: "Italic",
110
+ Icon: TextItalic,
111
+ isActive: state.isItalicActive,
112
+ onPress: editor.toggleItalic,
113
+ },
114
+ {
115
+ label: "Link",
116
+ Icon: Link,
117
+ isActive: !!state.activeLink,
118
+ onPress: openLinkForm,
119
+ },
120
+ {
121
+ label: "Strikethrough",
122
+ Icon: TextCross,
123
+ isActive: state.isStrikeActive,
124
+ onPress: editor.toggleStrike,
125
+ },
126
+ {
127
+ label: "Bulleted list",
128
+ Icon: ListDot,
129
+ isActive: state.isBulletListActive,
130
+ onPress: editor.toggleBulletList,
131
+ },
132
+ {
133
+ label: "Numbered list",
134
+ Icon: ListNumber,
135
+ isActive: state.isOrderedListActive,
136
+ onPress: editor.toggleOrderedList,
137
+ },
138
+ {
139
+ label: "Blockquote",
140
+ Icon: Quote,
141
+ isActive: state.isBlockquoteActive,
142
+ onPress: editor.toggleBlockquote,
143
+ },
144
+ {
145
+ label: "Text colour",
146
+ Icon: TextColor,
147
+ isActive: !!state.activeColor,
148
+ onPress: () => setPanel("color"),
149
+ },
150
+ {
151
+ label: "Inline code",
152
+ Icon: Code,
153
+ isActive: state.isCodeActive,
154
+ onPress: editor.toggleCode,
155
+ },
156
+ {
157
+ label: "Code block",
158
+ Icon: CodeBlock,
159
+ isActive: state.isCodeBlockActive,
160
+ onPress: editor.toggleCodeBlock,
161
+ },
162
+ {
163
+ label: "Underline",
164
+ Icon: Underline,
165
+ isActive: state.isUnderlineActive,
166
+ onPress: editor.toggleUnderline,
167
+ },
168
+ ];
169
+
170
+ const styleOptions = [
171
+ {
172
+ label: "Normal text",
173
+ text: "Normal",
174
+ isActive: !state.headingLevel,
175
+ apply: () => {
176
+ if (state.headingLevel) {
177
+ editor.toggleHeading(state.headingLevel as HeadingLevel);
178
+ }
179
+ },
180
+ },
181
+ ...HEADING_LEVELS.map(level => ({
182
+ label: `Heading ${level}`,
183
+ text: `H${level}`,
184
+ isActive: state.headingLevel === level,
185
+ apply: () => editor.toggleHeading(level),
186
+ })),
187
+ ];
188
+
189
+ // Replaces the range with the link, as desk-web's LinkAddPopOver does, so
190
+ // edited text and the tab target land together.
191
+ const applyLink = () => {
192
+ const url = linkUrl.trim();
193
+ setPanel("main");
194
+ if (!url) return;
195
+
196
+ const href = escapeHtml(withScheme(url));
197
+ const text = escapeHtml(linkText.trim() || url);
198
+ const target = opensInNewTab ? "_blank" : "_self";
199
+ const { from, to } = linkRange.current;
200
+ editor.setSelection(from, to);
201
+ editor.insertHTML(
202
+ `<a href="${href}" target="${target}" rel="noopener noreferrer nofollow">${text}</a>`
203
+ );
204
+ };
205
+
206
+ const removeLink = () => {
207
+ const { from, to } = linkRange.current;
208
+ editor.setSelection(from, to);
209
+ editor.setLink(null);
210
+ setPanel("main");
211
+ };
212
+
213
+ const renderLinkForm = () => (
214
+ <View style={[styles.linkForm, isWide && styles.linkFormWide]}>
215
+ <View style={[styles.linkInputs, isWide && styles.linkInputsWide]}>
216
+ <TextInput
217
+ accessibilityLabel="Link text"
218
+ placeholder="Text"
219
+ placeholderTextColor={theme.colors.text.muted}
220
+ style={styles.linkInput}
221
+ value={linkText}
222
+ onChangeText={setLinkText}
223
+ />
224
+ <TextInput
225
+ autoFocus
226
+ accessibilityLabel="Link URL"
227
+ autoCapitalize="none"
228
+ autoCorrect={false}
229
+ keyboardType="url"
230
+ placeholder="Link"
231
+ placeholderTextColor={theme.colors.text.muted}
232
+ returnKeyType="done"
233
+ style={styles.linkInput}
234
+ value={linkUrl}
235
+ onChangeText={setLinkUrl}
236
+ onSubmitEditing={applyLink}
237
+ />
238
+ </View>
239
+ <View style={[styles.linkActions, isWide && styles.linkActionsWide]}>
240
+ <CheckBox
241
+ checked={opensInNewTab}
242
+ label="Open in new tab"
243
+ labelStyle={styles.linkCheckLabel}
244
+ onSelect={() => setOpensInNewTab(current => !current)}
245
+ />
246
+ <View style={styles.linkButtons}>
247
+ {!!state.activeLink && (
248
+ <Touchable
249
+ accessibilityLabel="Remove link"
250
+ accessibilityRole="button"
251
+ style={[styles.item, styles.textItem]}
252
+ onPress={removeLink}
253
+ >
254
+ <Typography size="s">Remove</Typography>
255
+ </Touchable>
256
+ )}
257
+ <Touchable
258
+ accessibilityLabel="Apply link"
259
+ accessibilityRole="button"
260
+ style={[styles.item, styles.textItem]}
261
+ onPress={applyLink}
262
+ >
263
+ <Typography size="s" weight="semibold">
264
+ Apply
265
+ </Typography>
266
+ </Touchable>
267
+ </View>
268
+ </View>
269
+ </View>
270
+ );
271
+
272
+ const renderPanel = () => {
273
+ if (panel === "style") {
274
+ return styleOptions.map(({ label, text, isActive, apply }) => (
275
+ <Touchable
276
+ accessibilityLabel={label}
277
+ accessibilityRole="button"
278
+ key={label}
279
+ style={[styles.item, styles.textItem, isActive && styles.activeItem]}
280
+ onPress={choose(apply)}
281
+ >
282
+ <Typography size="s" weight={isActive ? "semibold" : "regular"}>
283
+ {text}
284
+ </Typography>
285
+ </Touchable>
286
+ ));
287
+ }
288
+
289
+ if (panel === "color") {
290
+ return [
291
+ ...[...new Set(colors)].map(color => (
292
+ <Touchable
293
+ accessibilityLabel={color}
294
+ accessibilityRole="button"
295
+ key={color}
296
+ style={[
297
+ styles.item,
298
+ state.activeColor === color && styles.activeItem,
299
+ ]}
300
+ onPress={choose(() => editor.setColor(color))}
301
+ >
302
+ <View style={[styles.swatch, { backgroundColor: color }]} />
303
+ </Touchable>
304
+ )),
305
+ <Touchable
306
+ accessibilityLabel="Default colour"
307
+ accessibilityRole="button"
308
+ key="default"
309
+ style={[styles.item, styles.textItem]}
310
+ onPress={choose(editor.unsetColor)}
311
+ >
312
+ <Typography size="s">Default</Typography>
313
+ </Touchable>,
314
+ ];
315
+ }
316
+
317
+ return items.map(({ label, Icon, isActive, disabled, onPress }) => (
318
+ <IconButton
319
+ accessibilityLabel={label}
320
+ disabled={disabled}
321
+ Icon={Icon}
322
+ key={label}
323
+ style={[styles.item, isActive && styles.activeItem]}
324
+ onPress={onPress}
325
+ />
326
+ ));
327
+ };
328
+
329
+ return (
330
+ <View style={styles.toolbar}>
331
+ {panel !== "main" && (
332
+ <IconButton
333
+ accessibilityLabel="Close"
334
+ Icon={Close}
335
+ style={styles.item}
336
+ onPress={() => setPanel("main")}
337
+ />
338
+ )}
339
+ {panel === "link" ? (
340
+ renderLinkForm()
341
+ ) : (
342
+ <ScrollView
343
+ horizontal
344
+ contentContainerStyle={styles.items}
345
+ keyboardShouldPersistTaps="always"
346
+ showsHorizontalScrollIndicator={false}
347
+ style={styles.scroller}
348
+ >
349
+ {renderPanel()}
350
+ </ScrollView>
351
+ )}
352
+ {IS_IPHONE && (
353
+ <IconButton
354
+ accessibilityLabel="Hide keyboard"
355
+ Icon={KeyboardDismiss}
356
+ style={styles.item}
357
+ onPress={editor.blur}
358
+ />
359
+ )}
360
+ </View>
361
+ );
362
+ };
363
+
364
+ const styles = StyleSheet.create((theme, rt) => {
365
+ const moderateScale = scaleFor(rt.screen);
366
+
367
+ return {
368
+ toolbar: {
369
+ alignItems: "center",
370
+ borderBottomColor: theme.colors.border.primary,
371
+ borderBottomWidth: 1,
372
+ borderTopColor: theme.colors.border.primary,
373
+ borderTopWidth: 1,
374
+ flexDirection: "row",
375
+ },
376
+ scroller: {
377
+ flexGrow: 1,
378
+ flexShrink: 1,
379
+ },
380
+ items: {
381
+ paddingHorizontal: moderateScale(4),
382
+ },
383
+ item: {
384
+ alignItems: "center",
385
+ borderRadius: moderateScale(6),
386
+ justifyContent: "center",
387
+ minHeight: 44,
388
+ minWidth: 44,
389
+ },
390
+ activeItem: {
391
+ backgroundColor: theme.colors.background.secondary,
392
+ },
393
+ linkForm: {
394
+ flex: 1,
395
+ gap: moderateScale(4),
396
+ paddingRight: moderateScale(8),
397
+ paddingVertical: moderateScale(6),
398
+ },
399
+ linkFormWide: {
400
+ alignItems: "center",
401
+ flex: 1,
402
+ flexDirection: "row",
403
+ gap: moderateScale(8),
404
+ },
405
+ linkInputs: {
406
+ flexDirection: "row",
407
+ gap: moderateScale(6),
408
+ },
409
+ // Only in the single-row layout: in the stacked phone layout flex:1 is a
410
+ // zero vertical basis and the action row lands on top of the inputs.
411
+ linkInputsWide: {
412
+ flex: 1,
413
+ },
414
+ linkActions: {
415
+ alignItems: "center",
416
+ flexDirection: "row",
417
+ justifyContent: "space-between",
418
+ },
419
+ linkActionsWide: {
420
+ flexShrink: 0,
421
+ gap: moderateScale(8),
422
+ },
423
+ linkCheckLabel: {
424
+ flex: 0,
425
+ },
426
+ linkButtons: {
427
+ flexDirection: "row",
428
+ },
429
+ linkInput: {
430
+ backgroundColor: theme.colors.background.primary,
431
+ borderColor: theme.colors.border.secondary,
432
+ borderRadius: moderateScale(6),
433
+ borderWidth: 1,
434
+ color: theme.colors.text.primary,
435
+ flex: 1,
436
+ fontSize: theme.fontSizes.s,
437
+ minHeight: moderateScale(36),
438
+ paddingHorizontal: moderateScale(10),
439
+ paddingVertical: 0,
440
+ },
441
+ textItem: {
442
+ alignItems: "center",
443
+ justifyContent: "center",
444
+ paddingHorizontal: moderateScale(10),
445
+ },
446
+ swatch: {
447
+ borderRadius: 999,
448
+ height: moderateScale(22),
449
+ width: moderateScale(22),
450
+ },
451
+ };
452
+ });
@@ -0,0 +1,29 @@
1
+ import { BridgeExtension } from "@10play/tentap-editor";
2
+ import CodeBlock from "@tiptap/extension-code-block";
3
+
4
+ export type CodeBlockEditorState = { isCodeBlockActive: boolean };
5
+
6
+ export type CodeBlockEditorInstance = { toggleCodeBlock: () => void };
7
+
8
+ type CodeBlockMessage = { type: "toggle-code-block" };
9
+
10
+ export const CodeBlockBridge = new BridgeExtension<
11
+ CodeBlockEditorState,
12
+ CodeBlockEditorInstance,
13
+ CodeBlockMessage
14
+ >({
15
+ tiptapExtension: CodeBlock.configure({ HTMLAttributes: {} }),
16
+ onBridgeMessage: (editor, message) => {
17
+ if (message.type !== "toggle-code-block") return false;
18
+
19
+ editor.chain().focus().toggleCodeBlock().run();
20
+
21
+ return true;
22
+ },
23
+ extendEditorInstance: sendBridgeMessage => ({
24
+ toggleCodeBlock: () => sendBridgeMessage({ type: "toggle-code-block" }),
25
+ }),
26
+ extendEditorState: editor => ({
27
+ isCodeBlockActive: editor.isActive("codeBlock"),
28
+ }),
29
+ });
@@ -0,0 +1,107 @@
1
+ import { BridgeExtension } from "@10play/tentap-editor";
2
+ import { getMarkRange, type Editor } from "@tiptap/core";
3
+
4
+ export type ComposerEditorState = {
5
+ /** The selection, widened to the whole link when the caret is inside one. */
6
+ linkRange: { from: number; to: number };
7
+ linkText: string;
8
+ linkOpensInNewTab: boolean;
9
+ };
10
+
11
+ export type ComposerEditorInstance = {
12
+ focusEmptyParagraph: () => void;
13
+ insertHTML: (html: string) => void;
14
+ focusBeforeTrailing: (html: string) => void;
15
+ };
16
+
17
+ type ComposerMessage =
18
+ | { type: "focus-empty-paragraph" }
19
+ | { type: "insert-html"; payload: string }
20
+ | { type: "focus-before-trailing"; payload: string };
21
+
22
+ // desk-web's useResponseList: the cursor lands at the end of the content
23
+ // before the trailing blocks (the signature), not inside them.
24
+ const positionBeforeTrailing = (editor: Editor, html: string) => {
25
+ const { doc } = editor.state;
26
+ const trailingBlocks = Math.min(
27
+ new DOMParser().parseFromString(html, "text/html").body.children.length,
28
+ doc.childCount
29
+ );
30
+
31
+ let trailingSize = 0;
32
+ for (
33
+ let index = doc.childCount - trailingBlocks;
34
+ index < doc.childCount;
35
+ index++
36
+ ) {
37
+ trailingSize += doc.child(index).nodeSize;
38
+ }
39
+
40
+ return Math.max(1, doc.content.size - trailingSize - 1);
41
+ };
42
+
43
+ const linkState = (editor: Editor): ComposerEditorState => {
44
+ const { state } = editor;
45
+ const { from, to, empty, $from } = state.selection;
46
+ const linkMark = state.schema.marks.link;
47
+ const range = (empty && linkMark && getMarkRange($from, linkMark)) || {
48
+ from,
49
+ to,
50
+ };
51
+
52
+ return {
53
+ linkRange: { from: range.from, to: range.to },
54
+ linkText: state.doc.textBetween(range.from, range.to, " "),
55
+ linkOpensInNewTab: editor.getAttributes("link").target !== "_self",
56
+ };
57
+ };
58
+
59
+ export const ComposerBridge = new BridgeExtension<
60
+ ComposerEditorState,
61
+ ComposerEditorInstance,
62
+ ComposerMessage
63
+ >({
64
+ forceName: "composer",
65
+ onBridgeMessage: (editor, message) => {
66
+ if (message.type === "insert-html") {
67
+ editor.chain().focus().insertContent(message.payload).run();
68
+
69
+ return true;
70
+ }
71
+
72
+ if (message.type === "focus-before-trailing") {
73
+ editor
74
+ .chain()
75
+ .focus()
76
+ .setTextSelection(positionBeforeTrailing(editor, message.payload))
77
+ .run();
78
+
79
+ return true;
80
+ }
81
+
82
+ if (message.type !== "focus-empty-paragraph") return false;
83
+
84
+ let emptyParagraphPos: number | undefined;
85
+ editor.state.doc.descendants((node, pos) => {
86
+ if (emptyParagraphPos !== undefined) return false;
87
+
88
+ if (node.type.name === "paragraph" && node.content.size === 0) {
89
+ emptyParagraphPos = pos + 1;
90
+ }
91
+
92
+ return true;
93
+ });
94
+ editor.commands.focus(emptyParagraphPos ?? "end");
95
+
96
+ return true;
97
+ },
98
+ extendEditorState: linkState,
99
+ extendEditorInstance: sendBridgeMessage => ({
100
+ focusEmptyParagraph: () =>
101
+ sendBridgeMessage({ type: "focus-empty-paragraph" }),
102
+ insertHTML: html =>
103
+ sendBridgeMessage({ type: "insert-html", payload: html }),
104
+ focusBeforeTrailing: html =>
105
+ sendBridgeMessage({ type: "focus-before-trailing", payload: html }),
106
+ }),
107
+ });
@@ -0,0 +1,7 @@
1
+ import { BridgeExtension } from "@10play/tentap-editor";
2
+
3
+ import { Figure } from "./nodes/Figure";
4
+
5
+ export const FigureBridge = new BridgeExtension<object, object, unknown>({
6
+ tiptapExtension: Figure,
7
+ });
@@ -0,0 +1,79 @@
1
+ import { BridgeExtension } from "@10play/tentap-editor";
2
+ import type { Editor } from "@tiptap/core";
3
+
4
+ import { Mention } from "./nodes/Mention";
5
+
6
+ export type MentionQuery = { query: string; from: number; to: number };
7
+
8
+ export type MentionEditorState = {
9
+ /** The "@word" being typed at the caret, while there is one. */
10
+ mentionQuery: MentionQuery | null;
11
+ };
12
+
13
+ export type MentionEditorInstance = {
14
+ insertMention: (
15
+ mention: { id: string; label: string },
16
+ range: { from: number; to: number }
17
+ ) => void;
18
+ };
19
+
20
+ type MentionMessage = {
21
+ type: "insert-mention";
22
+ payload: {
23
+ id: string;
24
+ label: string;
25
+ range: { from: number; to: number };
26
+ };
27
+ };
28
+
29
+ const TRIGGER = /(?:^|\s)@([^\s@]*)$/;
30
+
31
+ export const findMentionQuery = (editor: Editor): MentionQuery | null => {
32
+ const { empty, $from } = editor.state.selection;
33
+ if (!empty || !$from.parent.isTextblock) return null;
34
+
35
+ const textBefore = $from.parent.textBetween(
36
+ 0,
37
+ $from.parentOffset,
38
+ undefined,
39
+ ""
40
+ );
41
+ const match = TRIGGER.exec(textBefore);
42
+ if (!match) return null;
43
+
44
+ const query = match[1] ?? "";
45
+
46
+ return { query, from: $from.pos - query.length - 1, to: $from.pos };
47
+ };
48
+
49
+ export const MentionBridge = new BridgeExtension<
50
+ MentionEditorState,
51
+ MentionEditorInstance,
52
+ MentionMessage
53
+ >({
54
+ forceName: "mention",
55
+ tiptapExtension: Mention,
56
+ onBridgeMessage: (editor, message) => {
57
+ if (message.type !== "insert-mention") return false;
58
+
59
+ const { id, label, range } = message.payload;
60
+ editor
61
+ .chain()
62
+ .focus()
63
+ .insertContentAt(range, [
64
+ { type: "mention", attrs: { id, label } },
65
+ { type: "text", text: " " },
66
+ ])
67
+ .run();
68
+
69
+ return true;
70
+ },
71
+ extendEditorState: editor => ({ mentionQuery: findMentionQuery(editor) }),
72
+ extendEditorInstance: sendBridgeMessage => ({
73
+ insertMention: ({ id, label }, range) =>
74
+ sendBridgeMessage({
75
+ type: "insert-mention",
76
+ payload: { id, label, range },
77
+ }),
78
+ }),
79
+ });
@@ -0,0 +1,7 @@
1
+ import { BridgeExtension } from "@10play/tentap-editor";
2
+
3
+ import { Variable } from "./nodes/Variable";
4
+
5
+ export const VariableBridge = new BridgeExtension<object, object, unknown>({
6
+ tiptapExtension: Variable,
7
+ });