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