testomatio-editor-blocks 0.3.0 → 0.4.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.
- package/package/editor/blocks/snippet.js +42 -23
- package/package/editor/blocks/step.js +166 -35
- package/package/editor/blocks/stepField.d.ts +9 -1
- package/package/editor/blocks/stepField.js +664 -34
- package/package/editor/blocks/stepHorizontalView.d.ts +14 -0
- package/package/editor/blocks/stepHorizontalView.js +7 -0
- package/package/editor/blocks/useAutoResize.d.ts +8 -0
- package/package/editor/blocks/useAutoResize.js +31 -0
- package/package/editor/customMarkdownConverter.d.ts +1 -0
- package/package/editor/customMarkdownConverter.js +260 -31
- package/package/styles.css +706 -130
- package/package.json +9 -2
- package/src/App.tsx +1 -1
- package/src/editor/blocks/markdown.ts +27 -7
- package/src/editor/blocks/snippet.tsx +117 -61
- package/src/editor/blocks/step.tsx +325 -87
- package/src/editor/blocks/stepField.tsx +1396 -299
- package/src/editor/blocks/stepHorizontalView.tsx +90 -0
- package/src/editor/blocks/useAutoResize.ts +44 -0
- package/src/editor/customMarkdownConverter.test.ts +542 -3
- package/src/editor/customMarkdownConverter.ts +310 -36
- package/src/editor/customSchema.test.ts +35 -0
- package/src/editor/markdownToBlocks.test.ts +119 -0
- package/src/editor/styles.css +827 -128
|
@@ -1,24 +1,29 @@
|
|
|
1
|
+
import OverType, { type OverType as OverTypeInstance } from "overtype";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { ReactNode, ChangeEvent } from "react";
|
|
4
|
+
import { useComponentsContext } from "@blocknote/react";
|
|
5
|
+
import { EditLinkMenuItems } from "@blocknote/react";
|
|
1
6
|
import { useStepAutocomplete, type StepSuggestion } from "../stepAutocomplete";
|
|
2
7
|
import { type SnippetSuggestion } from "../snippetAutocomplete";
|
|
3
8
|
import { useStepImageUpload } from "../stepImageUpload";
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
escapeHtml,
|
|
8
|
-
escapeMarkdownText,
|
|
9
|
-
htmlToMarkdown,
|
|
10
|
-
markdownToHtml,
|
|
11
|
-
normalizePlainText,
|
|
12
|
-
} from "./markdown";
|
|
9
|
+
import { escapeMarkdownText, normalizePlainText } from "./markdown";
|
|
10
|
+
import { useAutoResize } from "./useAutoResize";
|
|
13
11
|
|
|
14
12
|
type Suggestion = StepSuggestion | SnippetSuggestion;
|
|
15
13
|
|
|
16
14
|
type StepFieldProps = {
|
|
17
15
|
label: string;
|
|
16
|
+
showLabel?: boolean;
|
|
17
|
+
labelToggle?: {
|
|
18
|
+
onClick: () => void;
|
|
19
|
+
expanded: boolean;
|
|
20
|
+
};
|
|
21
|
+
labelAction?: ReactNode;
|
|
22
|
+
placeholder?: string;
|
|
18
23
|
value: string;
|
|
19
|
-
placeholder: string;
|
|
20
24
|
onChange: (nextValue: string) => void;
|
|
21
25
|
autoFocus?: boolean;
|
|
26
|
+
focusSignal?: number;
|
|
22
27
|
multiline?: boolean;
|
|
23
28
|
enableAutocomplete?: boolean;
|
|
24
29
|
fieldName?: string;
|
|
@@ -35,12 +40,445 @@ type StepFieldProps = {
|
|
|
35
40
|
onFieldFocus?: () => void;
|
|
36
41
|
};
|
|
37
42
|
|
|
43
|
+
const READ_ONLY_ALLOWED_KEYS = new Set([
|
|
44
|
+
"ArrowDown",
|
|
45
|
+
"ArrowUp",
|
|
46
|
+
"Enter",
|
|
47
|
+
"Tab",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const AUTOCOMPLETE_TRIGGER_KEYS = new Set([" ", "Space"]);
|
|
51
|
+
|
|
52
|
+
const markdownParser = (OverType as { MarkdownParser?: { parse: (markdown: string) => string } }).MarkdownParser;
|
|
53
|
+
|
|
54
|
+
function ImageUploadIcon() {
|
|
55
|
+
return (
|
|
56
|
+
<svg
|
|
57
|
+
width="16"
|
|
58
|
+
height="16"
|
|
59
|
+
viewBox="0 0 16 16"
|
|
60
|
+
fill="none"
|
|
61
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
focusable="false"
|
|
64
|
+
>
|
|
65
|
+
<path
|
|
66
|
+
d="M12.667 2C13.0335 2.00008 13.3474 2.13057 13.6084 2.3916C13.8694 2.65264 13.9999 2.96648 14 3.33301V12.667C13.9999 13.0335 13.8694 13.3474 13.6084 13.6084C13.3474 13.8694 13.0335 13.9999 12.667 14H3.33301C2.96648 13.9999 2.65264 13.8694 2.3916 13.6084C2.13057 13.3474 2.00008 13.0335 2 12.667V3.33301C2.00008 2.96648 2.13057 2.65264 2.3916 2.3916C2.65264 2.13057 2.96648 2.00008 3.33301 2H12.667ZM3.33301 12.667H12.667V3.33301H3.33301V12.667ZM12 11.333H4L6 8.66699L7.5 10.667L9.5 8L12 11.333ZM5.66699 4.66699C5.94455 4.66707 6.18066 4.76375 6.375 4.95801C6.56944 5.15245 6.66699 5.38921 6.66699 5.66699C6.66692 5.94463 6.56937 6.18063 6.375 6.375C6.18063 6.56937 5.94463 6.66692 5.66699 6.66699C5.38921 6.66699 5.15245 6.56944 4.95801 6.375C4.76375 6.18066 4.66707 5.94455 4.66699 5.66699C4.66699 5.38921 4.76356 5.15245 4.95801 4.95801C5.15245 4.76356 5.38921 4.66699 5.66699 4.66699Z"
|
|
67
|
+
fill="currentColor"
|
|
68
|
+
/>
|
|
69
|
+
</svg>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type ExtractedImage = {
|
|
74
|
+
id: string;
|
|
75
|
+
url: string;
|
|
76
|
+
alt: string;
|
|
77
|
+
start: number;
|
|
78
|
+
end: number;
|
|
79
|
+
markdown: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type LinkMeta = { start: number; end: number; url: string };
|
|
83
|
+
type FormattingMeta = { start: number; end: number; type: "bold" | "italic" };
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
function stripInlineMarkdown(markdown: string): {
|
|
87
|
+
plainText: string;
|
|
88
|
+
links: LinkMeta[];
|
|
89
|
+
formatting: FormattingMeta[];
|
|
90
|
+
} {
|
|
91
|
+
const links: LinkMeta[] = [];
|
|
92
|
+
const formatting: FormattingMeta[] = [];
|
|
93
|
+
let plainText = "";
|
|
94
|
+
let i = 0;
|
|
95
|
+
|
|
96
|
+
while (i < markdown.length) {
|
|
97
|
+
// Skip image syntax  — keep as-is
|
|
98
|
+
if (markdown[i] === "!" && markdown[i + 1] === "[") {
|
|
99
|
+
const endBracket = markdown.indexOf("]", i + 2);
|
|
100
|
+
if (endBracket !== -1 && markdown[endBracket + 1] === "(") {
|
|
101
|
+
const endParen = markdown.indexOf(")", endBracket + 2);
|
|
102
|
+
if (endParen !== -1) {
|
|
103
|
+
plainText += markdown.slice(i, endParen + 1);
|
|
104
|
+
i = endParen + 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Links: [text](url)
|
|
111
|
+
if (markdown[i] === "[") {
|
|
112
|
+
const endBracket = markdown.indexOf("]", i + 1);
|
|
113
|
+
if (endBracket !== -1 && markdown[endBracket + 1] === "(") {
|
|
114
|
+
const endParen = markdown.indexOf(")", endBracket + 2);
|
|
115
|
+
if (endParen !== -1) {
|
|
116
|
+
const text = markdown.slice(i + 1, endBracket);
|
|
117
|
+
const url = markdown.slice(endBracket + 2, endParen);
|
|
118
|
+
links.push({ start: plainText.length, end: plainText.length + text.length, url });
|
|
119
|
+
plainText += text;
|
|
120
|
+
i = endParen + 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Bold+Italic: *** or ___
|
|
127
|
+
if (
|
|
128
|
+
(markdown[i] === "*" && markdown[i + 1] === "*" && markdown[i + 2] === "*") ||
|
|
129
|
+
(markdown[i] === "_" && markdown[i + 1] === "_" && markdown[i + 2] === "_")
|
|
130
|
+
) {
|
|
131
|
+
const marker = markdown.slice(i, i + 3);
|
|
132
|
+
const closeIdx = markdown.indexOf(marker, i + 3);
|
|
133
|
+
if (closeIdx !== -1) {
|
|
134
|
+
const inner = markdown.slice(i + 3, closeIdx);
|
|
135
|
+
const start = plainText.length;
|
|
136
|
+
const innerResult = stripInlineMarkdown(inner);
|
|
137
|
+
plainText += innerResult.plainText;
|
|
138
|
+
for (const link of innerResult.links) {
|
|
139
|
+
links.push({ start: start + link.start, end: start + link.end, url: link.url });
|
|
140
|
+
}
|
|
141
|
+
for (const fmt of innerResult.formatting) {
|
|
142
|
+
formatting.push({ start: start + fmt.start, end: start + fmt.end, type: fmt.type });
|
|
143
|
+
}
|
|
144
|
+
formatting.push({ start, end: plainText.length, type: "bold" });
|
|
145
|
+
formatting.push({ start, end: plainText.length, type: "italic" });
|
|
146
|
+
i = closeIdx + 3;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Bold: ** or __
|
|
152
|
+
if (
|
|
153
|
+
markdown[i] === "*" && markdown[i + 1] === "*" && markdown[i + 2] !== "*" ||
|
|
154
|
+
markdown[i] === "_" && markdown[i + 1] === "_" && markdown[i + 2] !== "_"
|
|
155
|
+
) {
|
|
156
|
+
const marker = markdown.slice(i, i + 2);
|
|
157
|
+
// Find closing ** that isn't part of ***
|
|
158
|
+
let closeIdx = markdown.indexOf(marker, i + 2);
|
|
159
|
+
while (closeIdx !== -1 && markdown[closeIdx + 2] === marker[0]) {
|
|
160
|
+
closeIdx = markdown.indexOf(marker, closeIdx + 2);
|
|
161
|
+
}
|
|
162
|
+
if (closeIdx !== -1) {
|
|
163
|
+
const inner = markdown.slice(i + 2, closeIdx);
|
|
164
|
+
const start = plainText.length;
|
|
165
|
+
const innerResult = stripInlineMarkdown(inner);
|
|
166
|
+
plainText += innerResult.plainText;
|
|
167
|
+
for (const link of innerResult.links) {
|
|
168
|
+
links.push({ start: start + link.start, end: start + link.end, url: link.url });
|
|
169
|
+
}
|
|
170
|
+
for (const fmt of innerResult.formatting) {
|
|
171
|
+
formatting.push({ start: start + fmt.start, end: start + fmt.end, type: fmt.type });
|
|
172
|
+
}
|
|
173
|
+
formatting.push({ start, end: plainText.length, type: "bold" });
|
|
174
|
+
i = closeIdx + 2;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Italic: single * or _
|
|
180
|
+
if (
|
|
181
|
+
(markdown[i] === "*" && markdown[i + 1] !== "*") ||
|
|
182
|
+
(markdown[i] === "_" && markdown[i + 1] !== "_")
|
|
183
|
+
) {
|
|
184
|
+
const marker = markdown[i];
|
|
185
|
+
// Find closing marker that isn't doubled
|
|
186
|
+
let closeIdx = i + 1;
|
|
187
|
+
while (closeIdx < markdown.length) {
|
|
188
|
+
closeIdx = markdown.indexOf(marker, closeIdx);
|
|
189
|
+
if (closeIdx === -1) break;
|
|
190
|
+
if (markdown[closeIdx + 1] !== marker && markdown[closeIdx - 1] !== marker) break;
|
|
191
|
+
closeIdx++;
|
|
192
|
+
}
|
|
193
|
+
if (closeIdx !== -1 && closeIdx > i + 1) {
|
|
194
|
+
const inner = markdown.slice(i + 1, closeIdx);
|
|
195
|
+
const start = plainText.length;
|
|
196
|
+
const innerResult = stripInlineMarkdown(inner);
|
|
197
|
+
plainText += innerResult.plainText;
|
|
198
|
+
for (const link of innerResult.links) {
|
|
199
|
+
links.push({ start: start + link.start, end: start + link.end, url: link.url });
|
|
200
|
+
}
|
|
201
|
+
for (const fmt of innerResult.formatting) {
|
|
202
|
+
formatting.push({ start: start + fmt.start, end: start + fmt.end, type: fmt.type });
|
|
203
|
+
}
|
|
204
|
+
formatting.push({ start, end: plainText.length, type: "italic" });
|
|
205
|
+
i = closeIdx + 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
plainText += markdown[i];
|
|
211
|
+
i++;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { plainText, links, formatting };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: FormattingMeta[]): string {
|
|
218
|
+
if (links.length === 0 && formatting.length === 0) return plainText;
|
|
219
|
+
|
|
220
|
+
// Collect all marker insertions at each position in plainText space.
|
|
221
|
+
// Each entry: { pos, text, order } where order controls insertion sequence
|
|
222
|
+
// at the same position (lower order = inserted first = ends up leftmost).
|
|
223
|
+
type Marker = { pos: number; text: string; order: number };
|
|
224
|
+
const markers: Marker[] = [];
|
|
225
|
+
|
|
226
|
+
for (const fmt of formatting) {
|
|
227
|
+
const marker = fmt.type === "bold" ? "**" : "*";
|
|
228
|
+
// Opening: outer markers (bold) before inner (italic) → bold order=0, italic order=1
|
|
229
|
+
// Closing: inner markers (italic) before outer (bold) → italic order=0, bold order=1
|
|
230
|
+
const openOrder = fmt.type === "bold" ? 0 : 1;
|
|
231
|
+
const closeOrder = fmt.type === "bold" ? 1 : 0;
|
|
232
|
+
markers.push({ pos: fmt.start, text: marker, order: openOrder });
|
|
233
|
+
markers.push({ pos: fmt.end, text: marker, order: closeOrder });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const link of links) {
|
|
237
|
+
// Link brackets go outside formatting markers
|
|
238
|
+
markers.push({ pos: link.start, text: "[", order: -1 });
|
|
239
|
+
markers.push({ pos: link.end, text: `](${link.url})`, order: 2 });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Sort by position descending so we insert from end to start (preserving earlier positions).
|
|
243
|
+
// At the same position, sort by order ascending.
|
|
244
|
+
markers.sort((a, b) => b.pos - a.pos || a.order - b.order);
|
|
245
|
+
|
|
246
|
+
let result = plainText;
|
|
247
|
+
for (const m of markers) {
|
|
248
|
+
result = result.slice(0, m.pos) + m.text + result.slice(m.pos);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function adjustFormattingForEdit(formatting: FormattingMeta[], editPos: number, delta: number): FormattingMeta[] {
|
|
255
|
+
return formatting
|
|
256
|
+
.map((fmt) => {
|
|
257
|
+
if (editPos <= fmt.start) {
|
|
258
|
+
return { ...fmt, start: fmt.start + delta, end: fmt.end + delta };
|
|
259
|
+
}
|
|
260
|
+
if (editPos >= fmt.end) {
|
|
261
|
+
return fmt;
|
|
262
|
+
}
|
|
263
|
+
return { ...fmt, end: fmt.end + delta };
|
|
264
|
+
})
|
|
265
|
+
.filter((fmt) => fmt.end > fmt.start);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getCaretRectInPreview(preview: HTMLElement, offset: number): { top: number; left: number; height: number } | null {
|
|
269
|
+
const walker = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT);
|
|
270
|
+
let currentOffset = 0;
|
|
271
|
+
|
|
272
|
+
while (walker.nextNode()) {
|
|
273
|
+
const textNode = walker.currentNode as Text;
|
|
274
|
+
const nodeLen = textNode.length;
|
|
275
|
+
|
|
276
|
+
if (offset <= currentOffset + nodeLen) {
|
|
277
|
+
const localOffset = offset - currentOffset;
|
|
278
|
+
try {
|
|
279
|
+
const range = document.createRange();
|
|
280
|
+
range.setStart(textNode, localOffset);
|
|
281
|
+
range.collapse(true);
|
|
282
|
+
const rect = range.getBoundingClientRect();
|
|
283
|
+
const previewRect = preview.getBoundingClientRect();
|
|
284
|
+
return {
|
|
285
|
+
top: rect.top - previewRect.top + preview.scrollTop,
|
|
286
|
+
left: rect.left - previewRect.left + preview.scrollLeft,
|
|
287
|
+
height: rect.height || parseFloat(getComputedStyle(preview).lineHeight) || 20,
|
|
288
|
+
};
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
currentOffset += nodeLen;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingMeta[]) {
|
|
301
|
+
if (formatting.length === 0) return;
|
|
302
|
+
|
|
303
|
+
// Remove previous formatting highlights
|
|
304
|
+
const existingBold = preview.querySelectorAll("strong.step-preview-bold");
|
|
305
|
+
for (let i = 0; i < existingBold.length; i++) {
|
|
306
|
+
const el = existingBold[i];
|
|
307
|
+
const parent = el.parentNode;
|
|
308
|
+
if (parent) {
|
|
309
|
+
while (el.firstChild) {
|
|
310
|
+
parent.insertBefore(el.firstChild, el);
|
|
311
|
+
}
|
|
312
|
+
parent.removeChild(el);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const existingItalic = preview.querySelectorAll("em.step-preview-italic");
|
|
316
|
+
for (let i = 0; i < existingItalic.length; i++) {
|
|
317
|
+
const el = existingItalic[i];
|
|
318
|
+
const parent = el.parentNode;
|
|
319
|
+
if (parent) {
|
|
320
|
+
while (el.firstChild) {
|
|
321
|
+
parent.insertBefore(el.firstChild, el);
|
|
322
|
+
}
|
|
323
|
+
parent.removeChild(el);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const sorted = [...formatting].sort((a, b) => b.start - a.start);
|
|
328
|
+
|
|
329
|
+
for (const fmt of sorted) {
|
|
330
|
+
const walker = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT);
|
|
331
|
+
let currentOffset = 0;
|
|
332
|
+
let startNode: Text | null = null;
|
|
333
|
+
let startLocalOffset = 0;
|
|
334
|
+
let endNode: Text | null = null;
|
|
335
|
+
let endLocalOffset = 0;
|
|
336
|
+
|
|
337
|
+
while (walker.nextNode()) {
|
|
338
|
+
const textNode = walker.currentNode as Text;
|
|
339
|
+
const nodeStart = currentOffset;
|
|
340
|
+
const nodeEnd = currentOffset + textNode.length;
|
|
341
|
+
|
|
342
|
+
if (!startNode && fmt.start >= nodeStart && fmt.start < nodeEnd) {
|
|
343
|
+
startNode = textNode;
|
|
344
|
+
startLocalOffset = fmt.start - nodeStart;
|
|
345
|
+
}
|
|
346
|
+
if (!endNode && fmt.end > nodeStart && fmt.end <= nodeEnd) {
|
|
347
|
+
endNode = textNode;
|
|
348
|
+
endLocalOffset = fmt.end - nodeStart;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
currentOffset = nodeEnd;
|
|
352
|
+
if (startNode && endNode) break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!startNode || !endNode) continue;
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const range = document.createRange();
|
|
359
|
+
range.setStart(startNode, startLocalOffset);
|
|
360
|
+
range.setEnd(endNode, endLocalOffset);
|
|
361
|
+
|
|
362
|
+
const wrapper = document.createElement(fmt.type === "bold" ? "strong" : "em");
|
|
363
|
+
wrapper.className = fmt.type === "bold" ? "step-preview-bold" : "step-preview-italic";
|
|
364
|
+
|
|
365
|
+
const fragment = range.extractContents();
|
|
366
|
+
wrapper.appendChild(fragment);
|
|
367
|
+
range.insertNode(wrapper);
|
|
368
|
+
} catch {
|
|
369
|
+
// DOM manipulation can fail if range crosses element boundaries
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
function adjustLinksForEdit(links: LinkMeta[], editPos: number, delta: number): LinkMeta[] {
|
|
376
|
+
return links
|
|
377
|
+
.map((link) => {
|
|
378
|
+
if (editPos <= link.start) {
|
|
379
|
+
return { ...link, start: link.start + delta, end: link.end + delta };
|
|
380
|
+
}
|
|
381
|
+
if (editPos >= link.end) {
|
|
382
|
+
return link;
|
|
383
|
+
}
|
|
384
|
+
return { ...link, end: link.end + delta };
|
|
385
|
+
})
|
|
386
|
+
.filter((link) => link.end > link.start);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function applyLinkHighlights(preview: HTMLElement, links: LinkMeta[]) {
|
|
390
|
+
if (links.length === 0) return;
|
|
391
|
+
|
|
392
|
+
// Remove previous link highlights
|
|
393
|
+
const existing = preview.querySelectorAll("a.step-preview-link");
|
|
394
|
+
for (let i = 0; i < existing.length; i++) {
|
|
395
|
+
const el = existing[i];
|
|
396
|
+
const parent = el.parentNode;
|
|
397
|
+
if (parent) {
|
|
398
|
+
while (el.firstChild) {
|
|
399
|
+
parent.insertBefore(el.firstChild, el);
|
|
400
|
+
}
|
|
401
|
+
parent.removeChild(el);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const sorted = [...links].sort((a, b) => b.start - a.start);
|
|
406
|
+
|
|
407
|
+
for (const link of sorted) {
|
|
408
|
+
const walker = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT);
|
|
409
|
+
let currentOffset = 0;
|
|
410
|
+
let startNode: Text | null = null;
|
|
411
|
+
let startLocalOffset = 0;
|
|
412
|
+
let endNode: Text | null = null;
|
|
413
|
+
let endLocalOffset = 0;
|
|
414
|
+
|
|
415
|
+
while (walker.nextNode()) {
|
|
416
|
+
const textNode = walker.currentNode as Text;
|
|
417
|
+
const nodeStart = currentOffset;
|
|
418
|
+
const nodeEnd = currentOffset + textNode.length;
|
|
419
|
+
|
|
420
|
+
if (!startNode && link.start >= nodeStart && link.start < nodeEnd) {
|
|
421
|
+
startNode = textNode;
|
|
422
|
+
startLocalOffset = link.start - nodeStart;
|
|
423
|
+
}
|
|
424
|
+
if (!endNode && link.end > nodeStart && link.end <= nodeEnd) {
|
|
425
|
+
endNode = textNode;
|
|
426
|
+
endLocalOffset = link.end - nodeStart;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
currentOffset = nodeEnd;
|
|
430
|
+
if (startNode && endNode) break;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!startNode || !endNode) continue;
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const range = document.createRange();
|
|
437
|
+
range.setStart(startNode, startLocalOffset);
|
|
438
|
+
range.setEnd(endNode, endLocalOffset);
|
|
439
|
+
|
|
440
|
+
const anchor = document.createElement("a");
|
|
441
|
+
anchor.href = link.url;
|
|
442
|
+
anchor.className = "step-preview-link";
|
|
443
|
+
|
|
444
|
+
const fragment = range.extractContents();
|
|
445
|
+
anchor.appendChild(fragment);
|
|
446
|
+
range.insertNode(anchor);
|
|
447
|
+
} catch {
|
|
448
|
+
// DOM manipulation can fail if range crosses element boundaries unexpectedly
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function markdownToPlainText(markdown: string): string {
|
|
454
|
+
if (!markdown) {
|
|
455
|
+
return "";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const html = markdownParser?.parse ? markdownParser.parse(markdown) : markdown;
|
|
460
|
+
if (typeof document === "undefined") {
|
|
461
|
+
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const temp = document.createElement("div");
|
|
465
|
+
temp.innerHTML = html;
|
|
466
|
+
return (temp.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
467
|
+
} catch {
|
|
468
|
+
return markdown.replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/\[[^\]]*]\([^)]+\)/g, "").replace(/[*_`~]/g, "").replace(/\s+/g, " ").trim();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
38
472
|
export function StepField({
|
|
39
473
|
label,
|
|
40
|
-
|
|
474
|
+
showLabel = true,
|
|
475
|
+
labelToggle,
|
|
476
|
+
labelAction,
|
|
41
477
|
placeholder,
|
|
478
|
+
value,
|
|
42
479
|
onChange,
|
|
43
480
|
autoFocus,
|
|
481
|
+
focusSignal,
|
|
44
482
|
multiline = false,
|
|
45
483
|
enableAutocomplete = false,
|
|
46
484
|
fieldName,
|
|
@@ -56,277 +494,989 @@ export function StepField({
|
|
|
56
494
|
showImageButton = false,
|
|
57
495
|
onFieldFocus,
|
|
58
496
|
}: StepFieldProps) {
|
|
59
|
-
const editorRef = useRef<HTMLDivElement>(null);
|
|
60
|
-
const [isFocused, setIsFocused] = useState(false);
|
|
61
|
-
const autoFocusRef = useRef(false);
|
|
62
|
-
const [plainTextValue, setPlainTextValue] = useState("");
|
|
63
|
-
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
|
|
64
|
-
const [showAllSuggestions, setShowAllSuggestions] = useState(false);
|
|
65
497
|
const stepSuggestions = useStepAutocomplete();
|
|
66
498
|
const suggestions = suggestionsOverride ?? stepSuggestions;
|
|
67
499
|
const uploadImage = useStepImageUpload();
|
|
68
500
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
501
|
+
const editorContainerRef = useRef<HTMLDivElement>(null);
|
|
502
|
+
const editorInstanceRef = useRef<OverTypeInstance | null>(null);
|
|
503
|
+
const [textareaNode, setTextareaNode] = useState<HTMLTextAreaElement | null>(null);
|
|
504
|
+
const autoFocusRef = useRef(false);
|
|
505
|
+
const pendingFocusRef = useRef(false);
|
|
506
|
+
const initialValueRef = useRef(value);
|
|
507
|
+
const onChangeRef = useRef(onChange);
|
|
508
|
+
const [plainTextValue, setPlainTextValue] = useState(() => markdownToPlainText(value));
|
|
509
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
510
|
+
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
|
|
511
|
+
const [showAllSuggestions, setShowAllSuggestions] = useState(false);
|
|
69
512
|
const [isUploading, setIsUploading] = useState(false);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
513
|
+
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
|
514
|
+
const [showLinkPopover, setShowLinkPopover] = useState(false);
|
|
515
|
+
const linkSelectionRef = useRef<{ start: number; end: number; text: string } | null>(null);
|
|
516
|
+
const linksRef = useRef<LinkMeta[]>([]);
|
|
517
|
+
const formattingRef = useRef<FormattingMeta[]>([]);
|
|
518
|
+
const caretRef = useRef<HTMLDivElement | null>(null);
|
|
519
|
+
const prevTextRef = useRef("");
|
|
520
|
+
const isSyncingRef = useRef(false);
|
|
521
|
+
const [cursorLink, setCursorLink] = useState<LinkMeta | null>(null);
|
|
522
|
+
const Components = useComponentsContext();
|
|
523
|
+
const resolvedPlaceholder = placeholder ?? "";
|
|
524
|
+
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
onChangeRef.current = onChange;
|
|
527
|
+
}, [onChange]);
|
|
528
|
+
|
|
529
|
+
const handleEditorChange = useCallback((nextValue: string) => {
|
|
530
|
+
if (isSyncingRef.current) return;
|
|
531
|
+
|
|
532
|
+
const prevText = prevTextRef.current;
|
|
533
|
+
const delta = nextValue.length - prevText.length;
|
|
534
|
+
|
|
535
|
+
// Find where the edit happened by comparing old and new text
|
|
536
|
+
let editPos = 0;
|
|
537
|
+
const minLen = Math.min(prevText.length, nextValue.length);
|
|
538
|
+
while (editPos < minLen && prevText[editPos] === nextValue[editPos]) {
|
|
539
|
+
editPos++;
|
|
74
540
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
541
|
+
|
|
542
|
+
linksRef.current = adjustLinksForEdit(linksRef.current, editPos, delta);
|
|
543
|
+
formattingRef.current = adjustFormattingForEdit(formattingRef.current, editPos, delta);
|
|
544
|
+
prevTextRef.current = nextValue;
|
|
545
|
+
|
|
546
|
+
const markdown = buildFullMarkdown(nextValue, linksRef.current, formattingRef.current);
|
|
547
|
+
setPlainTextValue((prev) => {
|
|
548
|
+
const normalized = markdownToPlainText(markdown);
|
|
549
|
+
return prev === normalized ? prev : normalized;
|
|
550
|
+
});
|
|
551
|
+
onChangeRef.current?.(markdown);
|
|
552
|
+
}, []);
|
|
553
|
+
|
|
554
|
+
useEffect(() => {
|
|
555
|
+
const container = editorContainerRef.current;
|
|
556
|
+
if (!container) {
|
|
557
|
+
return;
|
|
81
558
|
}
|
|
82
559
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
560
|
+
const { plainText, links, formatting } = stripInlineMarkdown(initialValueRef.current);
|
|
561
|
+
linksRef.current = links;
|
|
562
|
+
formattingRef.current = formatting;
|
|
563
|
+
prevTextRef.current = plainText;
|
|
86
564
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
565
|
+
const [instance] = OverType.init(container, {
|
|
566
|
+
value: plainText,
|
|
567
|
+
placeholder: resolvedPlaceholder,
|
|
568
|
+
autoResize: multiline,
|
|
569
|
+
minHeight: multiline ? "4rem" : "2.5rem",
|
|
570
|
+
padding: "0.5rem 0.75rem",
|
|
571
|
+
fontSize: "0.95rem",
|
|
572
|
+
onChange: handleEditorChange,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Monkey-patch updatePreview to add link highlights
|
|
576
|
+
const originalUpdatePreview = instance.updatePreview.bind(instance);
|
|
577
|
+
instance.updatePreview = function () {
|
|
578
|
+
originalUpdatePreview();
|
|
579
|
+
applyFormattingHighlights(this.preview, formattingRef.current);
|
|
580
|
+
applyLinkHighlights(this.preview, linksRef.current);
|
|
581
|
+
};
|
|
582
|
+
// Apply initial highlights
|
|
583
|
+
applyFormattingHighlights(instance.preview, formattingRef.current);
|
|
584
|
+
applyLinkHighlights(instance.preview, linksRef.current);
|
|
585
|
+
|
|
586
|
+
// Create custom caret element inside the wrapper
|
|
587
|
+
const caretEl = document.createElement("div");
|
|
588
|
+
caretEl.className = "bn-step-custom-caret";
|
|
589
|
+
instance.wrapper.appendChild(caretEl);
|
|
590
|
+
caretRef.current = caretEl;
|
|
591
|
+
|
|
592
|
+
editorInstanceRef.current = instance;
|
|
593
|
+
setTextareaNode(instance.textarea);
|
|
594
|
+
|
|
595
|
+
return () => {
|
|
596
|
+
caretRef.current = null;
|
|
597
|
+
instance.destroy();
|
|
598
|
+
editorInstanceRef.current = null;
|
|
599
|
+
setTextareaNode(null);
|
|
600
|
+
};
|
|
601
|
+
}, [handleEditorChange, multiline, resolvedPlaceholder]);
|
|
602
|
+
|
|
603
|
+
// Custom caret: position based on preview text metrics (handles bold/italic width differences)
|
|
98
604
|
useEffect(() => {
|
|
99
|
-
|
|
100
|
-
|
|
605
|
+
const instance = editorInstanceRef.current;
|
|
606
|
+
const caret = caretRef.current;
|
|
607
|
+
if (!textareaNode || !instance || !caret) return;
|
|
608
|
+
|
|
609
|
+
const updateCaret = () => {
|
|
610
|
+
const hasFormatting = formattingRef.current.length > 0;
|
|
611
|
+
|
|
612
|
+
if (!hasFormatting) {
|
|
613
|
+
caret.style.display = "none";
|
|
614
|
+
textareaNode.classList.remove("bn-step-caret-hidden");
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Always hide native caret when formatting exists
|
|
619
|
+
textareaNode.classList.add("bn-step-caret-hidden");
|
|
620
|
+
|
|
621
|
+
const isFocused = document.activeElement === textareaNode;
|
|
622
|
+
if (!isFocused) {
|
|
623
|
+
caret.style.display = "none";
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const pos = textareaNode.selectionStart ?? 0;
|
|
628
|
+
const selEnd = textareaNode.selectionEnd ?? 0;
|
|
629
|
+
|
|
630
|
+
// Hide custom caret when there's a selection range
|
|
631
|
+
if (pos !== selEnd) {
|
|
632
|
+
caret.style.display = "none";
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const rect = getCaretRectInPreview(instance.preview, pos);
|
|
637
|
+
if (rect) {
|
|
638
|
+
caret.style.display = "block";
|
|
639
|
+
caret.style.top = `${rect.top}px`;
|
|
640
|
+
caret.style.left = `${rect.left}px`;
|
|
641
|
+
caret.style.height = `${rect.height}px`;
|
|
642
|
+
} else {
|
|
643
|
+
caret.style.display = "none";
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const onSelectionChange = () => {
|
|
648
|
+
if (document.activeElement === textareaNode) {
|
|
649
|
+
updateCaret();
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const onBlur = () => {
|
|
654
|
+
caret.style.display = "none";
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const onFocus = () => {
|
|
658
|
+
updateCaret();
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const deferredUpdate = () => requestAnimationFrame(updateCaret);
|
|
662
|
+
|
|
663
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
664
|
+
textareaNode.addEventListener("input", deferredUpdate);
|
|
665
|
+
textareaNode.addEventListener("focus", onFocus);
|
|
666
|
+
textareaNode.addEventListener("blur", onBlur);
|
|
667
|
+
|
|
668
|
+
// Initial update
|
|
669
|
+
updateCaret();
|
|
670
|
+
|
|
671
|
+
return () => {
|
|
672
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
673
|
+
textareaNode.removeEventListener("input", deferredUpdate);
|
|
674
|
+
textareaNode.removeEventListener("focus", onFocus);
|
|
675
|
+
textareaNode.removeEventListener("blur", onBlur);
|
|
676
|
+
textareaNode.classList.remove("bn-step-caret-hidden");
|
|
677
|
+
};
|
|
678
|
+
}, [textareaNode]);
|
|
101
679
|
|
|
102
680
|
useEffect(() => {
|
|
103
|
-
if (
|
|
104
|
-
|
|
681
|
+
if (pendingFocusRef.current && textareaNode) {
|
|
682
|
+
pendingFocusRef.current = false;
|
|
683
|
+
textareaNode.focus();
|
|
105
684
|
}
|
|
106
|
-
}, [
|
|
685
|
+
}, [textareaNode]);
|
|
686
|
+
|
|
687
|
+
useEffect(() => {
|
|
688
|
+
if (!textareaNode || !focusSignal) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
textareaNode.focus();
|
|
692
|
+
}, [focusSignal, textareaNode]);
|
|
107
693
|
|
|
108
694
|
useEffect(() => {
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
695
|
+
const instance = editorInstanceRef.current;
|
|
696
|
+
if (!instance) {
|
|
697
|
+
setPlainTextValue((prev) => {
|
|
698
|
+
const normalized = markdownToPlainText(value);
|
|
699
|
+
return prev === normalized ? prev : normalized;
|
|
700
|
+
});
|
|
111
701
|
return;
|
|
112
702
|
}
|
|
113
703
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
704
|
+
const { plainText, links, formatting } = stripInlineMarkdown(value);
|
|
705
|
+
linksRef.current = links;
|
|
706
|
+
formattingRef.current = formatting;
|
|
707
|
+
prevTextRef.current = plainText;
|
|
708
|
+
|
|
709
|
+
if (instance.getValue() !== plainText) {
|
|
710
|
+
isSyncingRef.current = true;
|
|
711
|
+
instance.setValue(plainText);
|
|
712
|
+
isSyncingRef.current = false;
|
|
117
713
|
} else {
|
|
118
|
-
|
|
119
|
-
|
|
714
|
+
// Even if text didn't change, formatting/links might have — re-apply highlights
|
|
715
|
+
applyFormattingHighlights(instance.preview, formatting);
|
|
716
|
+
applyLinkHighlights(instance.preview, links);
|
|
120
717
|
}
|
|
121
|
-
}, [value, isFocused]);
|
|
122
718
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
719
|
+
setPlainTextValue((prev) => {
|
|
720
|
+
const normalized = markdownToPlainText(value);
|
|
721
|
+
return prev === normalized ? prev : normalized;
|
|
722
|
+
});
|
|
723
|
+
}, [value]);
|
|
724
|
+
|
|
725
|
+
useEffect(() => {
|
|
726
|
+
if (!textareaNode) {
|
|
126
727
|
return;
|
|
127
728
|
}
|
|
128
729
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
730
|
+
if (fieldName) {
|
|
731
|
+
textareaNode.dataset.stepField = fieldName;
|
|
732
|
+
} else {
|
|
733
|
+
delete textareaNode.dataset.stepField;
|
|
132
734
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
735
|
+
}, [fieldName, textareaNode]);
|
|
736
|
+
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
if (!textareaNode) {
|
|
739
|
+
return;
|
|
136
740
|
}
|
|
137
|
-
|
|
741
|
+
|
|
742
|
+
textareaNode.readOnly = readOnly;
|
|
743
|
+
}, [readOnly, textareaNode]);
|
|
744
|
+
|
|
745
|
+
useAutoResize({
|
|
746
|
+
textarea: textareaNode,
|
|
747
|
+
multiline,
|
|
748
|
+
minRows: 3,
|
|
749
|
+
maxRows: 16,
|
|
750
|
+
});
|
|
138
751
|
|
|
139
752
|
useEffect(() => {
|
|
140
|
-
if (!
|
|
753
|
+
if (!textareaNode) {
|
|
141
754
|
return;
|
|
142
755
|
}
|
|
143
756
|
|
|
144
|
-
|
|
145
|
-
const element = editorRef.current;
|
|
146
|
-
const focusElement = () => {
|
|
147
|
-
element.focus();
|
|
757
|
+
const handleFocus = () => {
|
|
148
758
|
setIsFocused(true);
|
|
149
759
|
if (showSuggestionsOnFocus && enableAutocomplete) {
|
|
150
760
|
setShowAllSuggestions(true);
|
|
151
761
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
762
|
+
onFieldFocus?.();
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const handleBlur = () => {
|
|
766
|
+
setIsFocused(false);
|
|
767
|
+
setShowAllSuggestions(false);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
textareaNode.addEventListener("focus", handleFocus);
|
|
771
|
+
textareaNode.addEventListener("blur", handleBlur);
|
|
772
|
+
|
|
773
|
+
return () => {
|
|
774
|
+
textareaNode.removeEventListener("focus", handleFocus);
|
|
775
|
+
textareaNode.removeEventListener("blur", handleBlur);
|
|
776
|
+
};
|
|
777
|
+
}, [enableAutocomplete, onFieldFocus, showSuggestionsOnFocus, textareaNode]);
|
|
778
|
+
|
|
779
|
+
// Detect when cursor is inside a link for showing edit tooltip
|
|
780
|
+
useEffect(() => {
|
|
781
|
+
if (!textareaNode) return;
|
|
782
|
+
|
|
783
|
+
const checkCursorInLink = () => {
|
|
784
|
+
const pos = textareaNode.selectionStart;
|
|
785
|
+
const isCollapsed = textareaNode.selectionStart === textareaNode.selectionEnd;
|
|
786
|
+
if (!isCollapsed) {
|
|
787
|
+
setCursorLink(null);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const found = linksRef.current.find((l) => pos >= l.start && pos <= l.end);
|
|
791
|
+
setCursorLink(found ?? null);
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
textareaNode.addEventListener("click", checkCursorInLink);
|
|
795
|
+
textareaNode.addEventListener("keyup", checkCursorInLink);
|
|
796
|
+
textareaNode.addEventListener("blur", () => setCursorLink(null));
|
|
797
|
+
return () => {
|
|
798
|
+
textareaNode.removeEventListener("click", checkCursorInLink);
|
|
799
|
+
textareaNode.removeEventListener("keyup", checkCursorInLink);
|
|
800
|
+
};
|
|
801
|
+
}, [textareaNode]);
|
|
802
|
+
|
|
803
|
+
useEffect(() => {
|
|
804
|
+
if (!autoFocus || autoFocusRef.current || !textareaNode) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
autoFocusRef.current = true;
|
|
809
|
+
const focus = () => {
|
|
810
|
+
textareaNode.focus();
|
|
811
|
+
if (showSuggestionsOnFocus && enableAutocomplete) {
|
|
812
|
+
setShowAllSuggestions(true);
|
|
156
813
|
}
|
|
157
814
|
};
|
|
158
815
|
|
|
159
816
|
if (typeof requestAnimationFrame === "function") {
|
|
160
|
-
const frame = requestAnimationFrame(
|
|
817
|
+
const frame = requestAnimationFrame(focus);
|
|
161
818
|
return () => cancelAnimationFrame(frame);
|
|
162
819
|
}
|
|
163
820
|
|
|
164
|
-
const timeout = setTimeout(
|
|
821
|
+
const timeout = setTimeout(focus, 0);
|
|
165
822
|
return () => clearTimeout(timeout);
|
|
166
|
-
}, [autoFocus, enableAutocomplete, showSuggestionsOnFocus]);
|
|
823
|
+
}, [autoFocus, enableAutocomplete, showSuggestionsOnFocus, textareaNode]);
|
|
824
|
+
|
|
825
|
+
const insertImageMarkdown = useCallback(
|
|
826
|
+
(url: string) => {
|
|
827
|
+
const instance = editorInstanceRef.current;
|
|
828
|
+
const textarea = textareaNode;
|
|
829
|
+
if (!instance || !textarea) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const currentValue = instance.getValue();
|
|
834
|
+
const start = textarea.selectionStart ?? currentValue.length;
|
|
835
|
+
const end = textarea.selectionEnd ?? currentValue.length;
|
|
836
|
+
const before = currentValue.slice(0, start);
|
|
837
|
+
const after = currentValue.slice(end);
|
|
838
|
+
const needsBeforeNewline = before.length > 0 && !before.endsWith("\n");
|
|
839
|
+
const needsAfterNewline = after.length > 0 && !after.startsWith("\n");
|
|
840
|
+
const insertText = `${needsBeforeNewline ? "\n" : ""}${needsAfterNewline ? "\n" : ""}`;
|
|
841
|
+
const nextValue = `${before}${insertText}${after}`;
|
|
842
|
+
|
|
843
|
+
// setValue triggers updatePreview → handleEditorChange which reconstructs markdown with links
|
|
844
|
+
instance.setValue(nextValue);
|
|
845
|
+
|
|
846
|
+
requestAnimationFrame(() => {
|
|
847
|
+
textarea.selectionStart = start + insertText.length;
|
|
848
|
+
textarea.selectionEnd = start + insertText.length;
|
|
849
|
+
textarea.focus();
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
[textareaNode],
|
|
853
|
+
);
|
|
167
854
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return false;
|
|
855
|
+
useEffect(() => {
|
|
856
|
+
if (!textareaNode) {
|
|
857
|
+
return;
|
|
172
858
|
}
|
|
173
859
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
860
|
+
const handlePaste = async (event: ClipboardEvent) => {
|
|
861
|
+
if (!onImageFile && !(enableImageUpload && uploadImage)) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const items = Array.from(event.clipboardData?.items ?? []);
|
|
866
|
+
const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
|
|
867
|
+
const file = imageItem?.getAsFile();
|
|
868
|
+
if (!file) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
event.preventDefault();
|
|
873
|
+
|
|
874
|
+
if (onImageFile) {
|
|
875
|
+
await onImageFile(file);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (enableImageUpload && uploadImage) {
|
|
880
|
+
try {
|
|
881
|
+
const result = await uploadImage(file);
|
|
882
|
+
if (result?.url) {
|
|
883
|
+
insertImageMarkdown(result.url);
|
|
884
|
+
}
|
|
885
|
+
} catch (error) {
|
|
886
|
+
console.error("Failed to upload pasted image", error);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const listener = (event: ClipboardEvent) => {
|
|
892
|
+
void handlePaste(event);
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
textareaNode.addEventListener("paste", listener);
|
|
896
|
+
return () => {
|
|
897
|
+
textareaNode.removeEventListener("paste", listener);
|
|
898
|
+
};
|
|
899
|
+
}, [enableImageUpload, insertImageMarkdown, onImageFile, textareaNode, uploadImage]);
|
|
900
|
+
|
|
901
|
+
const handleToolbarAction = useCallback(
|
|
902
|
+
(action: "toggleBold" | "toggleItalic") => {
|
|
903
|
+
const instance = editorInstanceRef.current;
|
|
904
|
+
if (!textareaNode || !instance) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
textareaNode.focus();
|
|
908
|
+
|
|
909
|
+
const fmtType: "bold" | "italic" = action === "toggleBold" ? "bold" : "italic";
|
|
910
|
+
const start = textareaNode.selectionStart ?? 0;
|
|
911
|
+
const end = textareaNode.selectionEnd ?? 0;
|
|
912
|
+
|
|
913
|
+
// Check if selection is already formatted
|
|
914
|
+
const existingIdx = formattingRef.current.findIndex(
|
|
915
|
+
(f) => f.type === fmtType && f.start <= start && f.end >= end,
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
if (existingIdx !== -1) {
|
|
919
|
+
// Remove formatting
|
|
920
|
+
formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
|
|
921
|
+
} else if (start !== end) {
|
|
922
|
+
// Add formatting for selection
|
|
923
|
+
formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
|
|
924
|
+
} else {
|
|
925
|
+
// No selection — nothing to format
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const currentValue = instance.getValue();
|
|
930
|
+
prevTextRef.current = currentValue;
|
|
931
|
+
|
|
932
|
+
const markdown = buildFullMarkdown(currentValue, linksRef.current, formattingRef.current);
|
|
933
|
+
onChangeRef.current?.(markdown);
|
|
934
|
+
setPlainTextValue(markdownToPlainText(markdown));
|
|
935
|
+
|
|
936
|
+
// Re-apply highlights
|
|
937
|
+
applyFormattingHighlights(instance.preview, formattingRef.current);
|
|
938
|
+
applyLinkHighlights(instance.preview, linksRef.current);
|
|
939
|
+
},
|
|
940
|
+
[textareaNode],
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
const linkPopoverRef = useRef<HTMLDivElement>(null);
|
|
944
|
+
|
|
945
|
+
// Close link popover on outside click
|
|
946
|
+
useEffect(() => {
|
|
947
|
+
if (!showLinkPopover) return;
|
|
948
|
+
|
|
949
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
950
|
+
const popover = linkPopoverRef.current;
|
|
951
|
+
if (popover && !popover.contains(event.target as Node)) {
|
|
952
|
+
setShowLinkPopover(false);
|
|
953
|
+
linkSelectionRef.current = null;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
958
|
+
return () => document.removeEventListener("mousedown", handleMouseDown);
|
|
959
|
+
}, [showLinkPopover]);
|
|
960
|
+
|
|
961
|
+
const handleOpenLinkPopover = useCallback(() => {
|
|
962
|
+
if (!textareaNode) {
|
|
963
|
+
return;
|
|
177
964
|
}
|
|
965
|
+
const start = textareaNode.selectionStart ?? 0;
|
|
966
|
+
const end = textareaNode.selectionEnd ?? 0;
|
|
967
|
+
const text = textareaNode.value.slice(start, end);
|
|
968
|
+
linkSelectionRef.current = { start, end, text };
|
|
969
|
+
setShowLinkPopover(true);
|
|
970
|
+
}, [textareaNode]);
|
|
178
971
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
972
|
+
const handleEditLink = useCallback(
|
|
973
|
+
(url: string, text: string) => {
|
|
974
|
+
const instance = editorInstanceRef.current;
|
|
975
|
+
const sel = linkSelectionRef.current;
|
|
976
|
+
if (!instance || !sel || !url) {
|
|
977
|
+
setShowLinkPopover(false);
|
|
978
|
+
linkSelectionRef.current = null;
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const currentValue = instance.getValue();
|
|
982
|
+
const linkText = text || sel.text || url;
|
|
983
|
+
|
|
984
|
+
// Replace selected text with link display text (no markdown syntax in textarea)
|
|
985
|
+
const before = currentValue.slice(0, sel.start);
|
|
986
|
+
const after = currentValue.slice(sel.end);
|
|
987
|
+
const nextValue = `${before}${linkText}${after}`;
|
|
988
|
+
|
|
989
|
+
// Remove any existing link that overlaps this selection, then add the new one
|
|
990
|
+
const delta = linkText.length - (sel.end - sel.start);
|
|
991
|
+
const adjustedLinks = adjustLinksForEdit(
|
|
992
|
+
linksRef.current.filter((l) => !(l.start < sel.end && l.end > sel.start)),
|
|
993
|
+
sel.start,
|
|
994
|
+
delta,
|
|
995
|
+
);
|
|
996
|
+
const newLink: LinkMeta = { start: sel.start, end: sel.start + linkText.length, url };
|
|
997
|
+
linksRef.current = [...adjustedLinks, newLink];
|
|
998
|
+
formattingRef.current = adjustFormattingForEdit(formattingRef.current, sel.start, delta);
|
|
999
|
+
prevTextRef.current = nextValue;
|
|
1000
|
+
|
|
1001
|
+
isSyncingRef.current = true;
|
|
1002
|
+
instance.setValue(nextValue);
|
|
1003
|
+
isSyncingRef.current = false;
|
|
1004
|
+
|
|
1005
|
+
const markdown = buildFullMarkdown(nextValue, linksRef.current, formattingRef.current);
|
|
1006
|
+
onChangeRef.current?.(markdown);
|
|
1007
|
+
setPlainTextValue(markdownToPlainText(markdown));
|
|
1008
|
+
setShowLinkPopover(false);
|
|
1009
|
+
linkSelectionRef.current = null;
|
|
1010
|
+
setCursorLink(null);
|
|
1011
|
+
requestAnimationFrame(() => textareaNode?.focus());
|
|
1012
|
+
},
|
|
1013
|
+
[textareaNode],
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
const handleRemoveLink = useCallback(() => {
|
|
1017
|
+
linksRef.current = linksRef.current.filter((l) => l !== cursorLink);
|
|
1018
|
+
setCursorLink(null);
|
|
1019
|
+
|
|
1020
|
+
const instance = editorInstanceRef.current;
|
|
1021
|
+
if (instance) {
|
|
1022
|
+
const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
|
|
1023
|
+
onChangeRef.current?.(markdown);
|
|
1024
|
+
// Re-apply highlights since links changed
|
|
1025
|
+
applyFormattingHighlights(instance.preview, formattingRef.current);
|
|
1026
|
+
applyLinkHighlights(instance.preview, linksRef.current);
|
|
1027
|
+
}
|
|
1028
|
+
}, [cursorLink]);
|
|
1029
|
+
|
|
1030
|
+
const suggestionPool = useMemo(() => {
|
|
1031
|
+
if (!suggestionFilter) {
|
|
1032
|
+
return suggestions;
|
|
1033
|
+
}
|
|
1034
|
+
const filtered = suggestions.filter(suggestionFilter);
|
|
1035
|
+
return filtered.length > 0 ? filtered : suggestions;
|
|
1036
|
+
}, [suggestionFilter, suggestions]);
|
|
1037
|
+
|
|
1038
|
+
const normalizedQuery = normalizePlainText(plainTextValue);
|
|
1039
|
+
|
|
1040
|
+
useEffect(() => {
|
|
1041
|
+
if (normalizedQuery.length > 0) {
|
|
1042
|
+
setShowAllSuggestions(false);
|
|
185
1043
|
}
|
|
186
|
-
|
|
187
|
-
|
|
1044
|
+
}, [normalizedQuery]);
|
|
1045
|
+
|
|
1046
|
+
const filteredSuggestions = useMemo(() => {
|
|
1047
|
+
if (!enableAutocomplete) {
|
|
1048
|
+
return [];
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const pool = showAllSuggestions || !normalizedQuery
|
|
1052
|
+
? suggestionPool
|
|
1053
|
+
: suggestionPool.filter((item) => normalizePlainText(item.title).startsWith(normalizedQuery));
|
|
1054
|
+
|
|
1055
|
+
return pool.slice(0, 8);
|
|
1056
|
+
}, [enableAutocomplete, normalizedQuery, showAllSuggestions, suggestionPool]);
|
|
1057
|
+
|
|
1058
|
+
const hasExactMatch = filteredSuggestions.some(
|
|
1059
|
+
(item) => normalizePlainText(item.title) === normalizedQuery,
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
const shouldShowAutocomplete =
|
|
1063
|
+
enableAutocomplete &&
|
|
1064
|
+
isFocused &&
|
|
1065
|
+
filteredSuggestions.length > 0 &&
|
|
1066
|
+
(!hasExactMatch || showAllSuggestions) &&
|
|
1067
|
+
(showAllSuggestions || normalizedQuery.length >= 1);
|
|
1068
|
+
|
|
1069
|
+
useEffect(() => {
|
|
1070
|
+
setActiveSuggestionIndex(0);
|
|
1071
|
+
}, [normalizedQuery, filteredSuggestions.length, showAllSuggestions]);
|
|
1072
|
+
|
|
1073
|
+
const extractedImages = useMemo<ExtractedImage[]>(() => {
|
|
1074
|
+
if (!value) {
|
|
1075
|
+
return [];
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const regex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
1079
|
+
const results: ExtractedImage[] = [];
|
|
1080
|
+
let match: RegExpExecArray | null;
|
|
1081
|
+
while ((match = regex.exec(value)) !== null) {
|
|
1082
|
+
const [, alt = "", url = ""] = match;
|
|
1083
|
+
results.push({
|
|
1084
|
+
id: `${match.index}-${url}-${results.length}`,
|
|
1085
|
+
url,
|
|
1086
|
+
alt,
|
|
1087
|
+
start: match.index,
|
|
1088
|
+
end: match.index + match[0].length,
|
|
1089
|
+
markdown: match[0],
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
return results;
|
|
1093
|
+
}, [value]);
|
|
1094
|
+
|
|
1095
|
+
const handleRemoveImage = useCallback(
|
|
1096
|
+
(image: ExtractedImage) => {
|
|
1097
|
+
const before = value.slice(0, image.start);
|
|
1098
|
+
const after = value.slice(image.end);
|
|
1099
|
+
const nextValue = `${before}${after}`.replace(/\n{3,}/g, "\n\n");
|
|
1100
|
+
if (editorInstanceRef.current) {
|
|
1101
|
+
editorInstanceRef.current.setValue(nextValue);
|
|
1102
|
+
}
|
|
1103
|
+
onChangeRef.current?.(nextValue);
|
|
1104
|
+
setPlainTextValue(markdownToPlainText(nextValue));
|
|
1105
|
+
setPreviewImageUrl((prev) => (prev === image.url ? null : prev));
|
|
1106
|
+
},
|
|
1107
|
+
[value],
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
const handleImageClick = useCallback((url: string) => {
|
|
1111
|
+
setPreviewImageUrl(url);
|
|
188
1112
|
}, []);
|
|
189
1113
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
|
|
195
|
-
const file = imageItem?.getAsFile();
|
|
196
|
-
if (file) {
|
|
197
|
-
event.preventDefault();
|
|
198
|
-
if (onImageFile) {
|
|
199
|
-
await onImageFile(file);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
if (enableImageUpload && uploadImage) {
|
|
203
|
-
try {
|
|
204
|
-
const result = await uploadImage(file);
|
|
205
|
-
if (result?.url) {
|
|
206
|
-
ensureCaretInEditor();
|
|
207
|
-
const needsBreak = (editorRef.current?.innerHTML ?? "").trim().length > 0;
|
|
208
|
-
const imgHtml =
|
|
209
|
-
(needsBreak ? "<br />" : "") +
|
|
210
|
-
`<img src="${escapeHtml(result.url)}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
|
|
211
|
-
document.execCommand("insertHTML", false, imgHtml);
|
|
212
|
-
syncValue();
|
|
213
|
-
}
|
|
214
|
-
} catch (error) {
|
|
215
|
-
console.error("Failed to upload image from paste", error);
|
|
216
|
-
}
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
1114
|
+
const focusAdjacentField = useCallback(
|
|
1115
|
+
(direction: 1 | -1) => {
|
|
1116
|
+
if (!textareaNode || typeof document === "undefined") {
|
|
1117
|
+
return false;
|
|
220
1118
|
}
|
|
221
1119
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1120
|
+
const selector =
|
|
1121
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"], [data-step-field]';
|
|
1122
|
+
const focusable = Array.from(document.querySelectorAll<HTMLElement>(selector)).filter((element) => {
|
|
1123
|
+
if (element.getAttribute("aria-hidden") === "true" || element.tabIndex === -1 || element.hasAttribute("disabled")) {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
const isVisible = element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0;
|
|
1127
|
+
return isVisible;
|
|
1128
|
+
});
|
|
1129
|
+
const currentIndex = focusable.findIndex((element) => element === textareaNode);
|
|
1130
|
+
const target = currentIndex === -1 ? null : focusable[currentIndex + direction];
|
|
1131
|
+
if (!target) {
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
target.focus();
|
|
1135
|
+
return true;
|
|
228
1136
|
},
|
|
229
|
-
[
|
|
1137
|
+
[textareaNode],
|
|
230
1138
|
);
|
|
231
1139
|
|
|
232
1140
|
const applySuggestion = useCallback(
|
|
233
1141
|
(suggestion: Suggestion) => {
|
|
234
1142
|
const escaped = escapeMarkdownText(suggestion.title);
|
|
235
|
-
|
|
236
|
-
|
|
1143
|
+
const instance = editorInstanceRef.current;
|
|
1144
|
+
if (instance) {
|
|
1145
|
+
instance.setValue(escaped);
|
|
1146
|
+
}
|
|
237
1147
|
setPlainTextValue(suggestion.title);
|
|
1148
|
+
onChangeRef.current?.(escaped);
|
|
1149
|
+
onSuggestionSelect?.(suggestion);
|
|
238
1150
|
setActiveSuggestionIndex(0);
|
|
239
1151
|
setShowAllSuggestions(false);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const range = document.createRange();
|
|
246
|
-
range.selectNodeContents(editorRef.current);
|
|
247
|
-
range.collapse(false);
|
|
248
|
-
selection.removeAllRanges();
|
|
249
|
-
selection.addRange(range);
|
|
1152
|
+
requestAnimationFrame(() => {
|
|
1153
|
+
textareaNode?.focus();
|
|
1154
|
+
if (textareaNode) {
|
|
1155
|
+
textareaNode.selectionStart = escaped.length;
|
|
1156
|
+
textareaNode.selectionEnd = escaped.length;
|
|
250
1157
|
}
|
|
251
|
-
}
|
|
1158
|
+
});
|
|
252
1159
|
},
|
|
253
|
-
[
|
|
1160
|
+
[onSuggestionSelect, textareaNode],
|
|
254
1161
|
);
|
|
255
1162
|
|
|
1163
|
+
const keydownHandlerRef = useRef<((event: KeyboardEvent) => void) | null>(null);
|
|
1164
|
+
|
|
1165
|
+
useEffect(() => {
|
|
1166
|
+
keydownHandlerRef.current = (event: KeyboardEvent) => {
|
|
1167
|
+
if (readOnly) {
|
|
1168
|
+
const openKeys = enableAutocomplete && (event.metaKey || event.ctrlKey) && AUTOCOMPLETE_TRIGGER_KEYS.has(event.code);
|
|
1169
|
+
if (!READ_ONLY_ALLOWED_KEYS.has(event.key) && !openKeys) {
|
|
1170
|
+
event.preventDefault();
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Intercept Ctrl+B / Ctrl+I to use our formatting system instead of OverType's
|
|
1176
|
+
const modKey = navigator.platform?.toLowerCase().includes("mac") ? event.metaKey : event.ctrlKey;
|
|
1177
|
+
if (modKey && !event.shiftKey) {
|
|
1178
|
+
if (event.key === "b" || event.key === "B") {
|
|
1179
|
+
event.preventDefault();
|
|
1180
|
+
event.stopImmediatePropagation();
|
|
1181
|
+
handleToolbarAction("toggleBold");
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (event.key === "i" || event.key === "I") {
|
|
1185
|
+
event.preventDefault();
|
|
1186
|
+
event.stopImmediatePropagation();
|
|
1187
|
+
handleToolbarAction("toggleItalic");
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (enableAutocomplete && shouldShowAutocomplete) {
|
|
1193
|
+
if (event.key === "ArrowDown") {
|
|
1194
|
+
event.preventDefault();
|
|
1195
|
+
setActiveSuggestionIndex((prev) =>
|
|
1196
|
+
prev + 1 >= filteredSuggestions.length ? 0 : prev + 1,
|
|
1197
|
+
);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (event.key === "ArrowUp") {
|
|
1201
|
+
event.preventDefault();
|
|
1202
|
+
setActiveSuggestionIndex((prev) =>
|
|
1203
|
+
prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1,
|
|
1204
|
+
);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
1208
|
+
event.preventDefault();
|
|
1209
|
+
const suggestion = filteredSuggestions[activeSuggestionIndex] ?? filteredSuggestions[0];
|
|
1210
|
+
if (suggestion) {
|
|
1211
|
+
applySuggestion(suggestion);
|
|
1212
|
+
}
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (
|
|
1218
|
+
enableAutocomplete &&
|
|
1219
|
+
(event.metaKey || event.ctrlKey) &&
|
|
1220
|
+
(AUTOCOMPLETE_TRIGGER_KEYS.has(event.code) || AUTOCOMPLETE_TRIGGER_KEYS.has(event.key))
|
|
1221
|
+
) {
|
|
1222
|
+
event.preventDefault();
|
|
1223
|
+
setShowAllSuggestions(true);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (event.key === "Tab") {
|
|
1228
|
+
const moved = focusAdjacentField(event.shiftKey ? -1 : 1);
|
|
1229
|
+
if (moved) {
|
|
1230
|
+
event.preventDefault();
|
|
1231
|
+
event.stopImmediatePropagation();
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
}, [activeSuggestionIndex, applySuggestion, enableAutocomplete, filteredSuggestions, focusAdjacentField, handleToolbarAction, readOnly, shouldShowAutocomplete]);
|
|
1236
|
+
|
|
1237
|
+
useEffect(() => {
|
|
1238
|
+
if (!textareaNode) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const listener = (event: KeyboardEvent) => {
|
|
1243
|
+
keydownHandlerRef.current?.(event);
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
const keydownOptions: AddEventListenerOptions = { capture: true };
|
|
1247
|
+
textareaNode.addEventListener("keydown", listener, keydownOptions);
|
|
1248
|
+
return () => {
|
|
1249
|
+
textareaNode.removeEventListener("keydown", listener, keydownOptions);
|
|
1250
|
+
};
|
|
1251
|
+
}, [textareaNode]);
|
|
1252
|
+
|
|
1253
|
+
const editorClassName = [
|
|
1254
|
+
"bn-step-editor",
|
|
1255
|
+
multiline ? "bn-step-editor--multiline" : "",
|
|
1256
|
+
isFocused ? "bn-step-editor--focused" : "",
|
|
1257
|
+
readOnly ? "bn-step-editor--readonly" : "",
|
|
1258
|
+
]
|
|
1259
|
+
.filter(Boolean)
|
|
1260
|
+
.join(" ");
|
|
1261
|
+
|
|
1262
|
+
const inputClassName = [
|
|
1263
|
+
"bn-step-field__input",
|
|
1264
|
+
multiline ? "bn-step-field__input--multiline" : "",
|
|
1265
|
+
isFocused ? "bn-step-field__input--focused" : "",
|
|
1266
|
+
readOnly ? "bn-step-field__input--readonly" : "",
|
|
1267
|
+
]
|
|
1268
|
+
.filter(Boolean)
|
|
1269
|
+
.join(" ");
|
|
1270
|
+
|
|
1271
|
+
const showToolbar =
|
|
1272
|
+
showFormattingButtons || (enableImageUpload && uploadImage && showImageButton) || Boolean(rightAction) || enableAutocomplete;
|
|
1273
|
+
|
|
256
1274
|
return (
|
|
257
1275
|
<div className="bn-step-field">
|
|
258
|
-
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
1276
|
+
{showLabel && (
|
|
1277
|
+
<div className="bn-step-field__top">
|
|
1278
|
+
<div className="bn-step-field__label-row">
|
|
1279
|
+
{labelToggle ? (
|
|
1280
|
+
<span
|
|
1281
|
+
className="bn-step-field__label bn-step-field__label--toggle"
|
|
1282
|
+
role="button"
|
|
1283
|
+
tabIndex={-1}
|
|
1284
|
+
onClick={labelToggle.onClick}
|
|
1285
|
+
onKeyDown={(event) => {
|
|
1286
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1287
|
+
event.preventDefault();
|
|
1288
|
+
labelToggle.onClick();
|
|
1289
|
+
}
|
|
1290
|
+
}}
|
|
1291
|
+
aria-expanded={labelToggle.expanded}
|
|
1292
|
+
>
|
|
1293
|
+
{label}
|
|
1294
|
+
</span>
|
|
1295
|
+
) : (
|
|
1296
|
+
<span className="bn-step-field__label">{label}</span>
|
|
1297
|
+
)}
|
|
1298
|
+
</div>
|
|
1299
|
+
{labelAction && <div className="bn-step-field__label-action">{labelAction}</div>}
|
|
1300
|
+
</div>
|
|
1301
|
+
)}
|
|
1302
|
+
<div className={inputClassName} aria-label={`${label} input`}>
|
|
1303
|
+
<div
|
|
1304
|
+
ref={editorContainerRef}
|
|
1305
|
+
className={editorClassName}
|
|
1306
|
+
data-step-field={fieldName}
|
|
1307
|
+
tabIndex={-1}
|
|
1308
|
+
onFocus={(event) => {
|
|
1309
|
+
if (event.target === editorContainerRef.current) {
|
|
1310
|
+
if (textareaNode) {
|
|
1311
|
+
textareaNode.focus();
|
|
1312
|
+
} else {
|
|
1313
|
+
pendingFocusRef.current = true;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}}
|
|
1317
|
+
/>
|
|
1318
|
+
{cursorLink && isFocused && (
|
|
1319
|
+
<div className="bn-step-link-tooltip">
|
|
1320
|
+
<span className="bn-step-link-tooltip__url" title={cursorLink.url}>
|
|
1321
|
+
{cursorLink.url.length > 40 ? `${cursorLink.url.slice(0, 40)}...` : cursorLink.url}
|
|
1322
|
+
</span>
|
|
262
1323
|
<button
|
|
263
1324
|
type="button"
|
|
264
|
-
className="bn-step-
|
|
1325
|
+
className="bn-step-link-tooltip__btn"
|
|
265
1326
|
onMouseDown={(event) => {
|
|
266
1327
|
event.preventDefault();
|
|
267
|
-
|
|
268
|
-
|
|
1328
|
+
linkSelectionRef.current = { start: cursorLink.start, end: cursorLink.end, text: "" };
|
|
1329
|
+
setShowLinkPopover(true);
|
|
269
1330
|
}}
|
|
270
|
-
aria-label="Show suggestions"
|
|
271
1331
|
tabIndex={-1}
|
|
272
1332
|
>
|
|
273
|
-
|
|
1333
|
+
Edit link
|
|
274
1334
|
</button>
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1335
|
+
<a
|
|
1336
|
+
className="bn-step-link-tooltip__btn"
|
|
1337
|
+
href={cursorLink.url}
|
|
1338
|
+
target="_blank"
|
|
1339
|
+
rel="noopener noreferrer"
|
|
1340
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
1341
|
+
tabIndex={-1}
|
|
1342
|
+
>
|
|
1343
|
+
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
1344
|
+
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
|
1345
|
+
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
|
1346
|
+
</svg>
|
|
1347
|
+
</a>
|
|
1348
|
+
<button
|
|
1349
|
+
type="button"
|
|
1350
|
+
className="bn-step-link-tooltip__btn bn-step-link-tooltip__btn--danger"
|
|
1351
|
+
onMouseDown={(event) => {
|
|
1352
|
+
event.preventDefault();
|
|
1353
|
+
handleRemoveLink();
|
|
1354
|
+
}}
|
|
1355
|
+
tabIndex={-1}
|
|
1356
|
+
>
|
|
1357
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
1358
|
+
<path d="M7 3h2a1 1 0 0 0-2 0ZM6 3a2 2 0 1 1 4 0h4a.5.5 0 0 1 0 1h-.564l-1.205 8.838A2.5 2.5 0 0 1 9.754 15H6.246a2.5 2.5 0 0 1-2.477-2.162L2.564 4H2a.5.5 0 0 1 0-1h4Zm1 3.5a.5.5 0 0 0-1 0v5a.5.5 0 0 0 1 0v-5ZM9.5 6a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 1 0v-5a.5.5 0 0 0-.5-.5Z" />
|
|
1359
|
+
</svg>
|
|
1360
|
+
</button>
|
|
1361
|
+
</div>
|
|
1362
|
+
)}
|
|
1363
|
+
{showToolbar && (
|
|
1364
|
+
<div className="bn-step-toolbar" aria-label={`${label} controls`}>
|
|
1365
|
+
{showFormattingButtons && (
|
|
1366
|
+
<>
|
|
1367
|
+
<button
|
|
1368
|
+
type="button"
|
|
1369
|
+
className="bn-step-toolbar__button"
|
|
1370
|
+
onMouseDown={(event) => {
|
|
1371
|
+
event.preventDefault();
|
|
1372
|
+
handleToolbarAction("toggleBold");
|
|
1373
|
+
}}
|
|
1374
|
+
aria-label="Bold"
|
|
1375
|
+
tabIndex={-1}
|
|
1376
|
+
>
|
|
1377
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
1378
|
+
<path d="M4 2.66675H8.33333C8.92064 2.66677 9.49502 2.83918 9.98525 3.1626C10.4755 3.48602 10.86 3.94622 11.0911 4.48613C11.3223 5.02604 11.3898 5.62192 11.2855 6.19988C11.1811 6.77783 10.9094 7.31244 10.504 7.73741C11.0752 8.06825 11.5213 8.57823 11.7733 9.18833C12.0252 9.79844 12.0689 10.4746 11.8976 11.1121C11.7263 11.7495 11.3495 12.3127 10.8256 12.7143C10.3018 13.1159 9.66008 13.3335 9 13.3334H4V12.0001H4.66667V4.00008H4V2.66675ZM6 7.33341H8.33333C8.77536 7.33341 9.19928 7.15782 9.51184 6.84526C9.8244 6.5327 10 6.10878 10 5.66675C10 5.22472 9.8244 4.8008 9.51184 4.48824C9.19928 4.17568 8.77536 4.00008 8.33333 4.00008H6V7.33341ZM6 8.66675V12.0001H9C9.44203 12.0001 9.86595 11.8245 10.1785 11.5119C10.4911 11.1994 10.6667 10.7754 10.6667 10.3334C10.6667 9.89139 10.4911 9.46746 10.1785 9.1549C9.86595 8.84234 9.44203 8.66675 9 8.66675H6Z" fill="currentColor"/>
|
|
1379
|
+
</svg>
|
|
1380
|
+
</button>
|
|
1381
|
+
<button
|
|
1382
|
+
type="button"
|
|
1383
|
+
className="bn-step-toolbar__button"
|
|
1384
|
+
onMouseDown={(event) => {
|
|
1385
|
+
event.preventDefault();
|
|
1386
|
+
handleToolbarAction("toggleItalic");
|
|
1387
|
+
}}
|
|
1388
|
+
aria-label="Italic"
|
|
1389
|
+
tabIndex={-1}
|
|
1390
|
+
>
|
|
1391
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
1392
|
+
<path d="M8.66699 13.3334H4.66699V12.0001H5.95166L8.69566 4.00008H7.33366V2.66675H11.3337V4.00008H10.049L7.30499 12.0001H8.66699V13.3334Z" fill="currentColor"/>
|
|
1393
|
+
</svg>
|
|
1394
|
+
</button>
|
|
1395
|
+
</>
|
|
1396
|
+
)}
|
|
1397
|
+
{enableImageUpload && uploadImage && showImageButton && (
|
|
294
1398
|
<button
|
|
295
1399
|
type="button"
|
|
296
1400
|
className="bn-step-toolbar__button"
|
|
297
1401
|
onMouseDown={(event) => {
|
|
298
1402
|
event.preventDefault();
|
|
299
|
-
|
|
300
|
-
document.execCommand("italic");
|
|
301
|
-
syncValue();
|
|
1403
|
+
fileInputRef.current?.click();
|
|
302
1404
|
}}
|
|
303
|
-
aria-label="Italic"
|
|
304
|
-
tabIndex={-1}
|
|
305
|
-
>
|
|
306
|
-
I
|
|
307
|
-
</button>
|
|
308
|
-
</>
|
|
309
|
-
)}
|
|
310
|
-
{enableImageUpload && uploadImage && showImageButton && (
|
|
311
|
-
<button
|
|
312
|
-
type="button"
|
|
313
|
-
className="bn-step-toolbar__button"
|
|
314
|
-
onMouseDown={(event) => {
|
|
315
|
-
event.preventDefault();
|
|
316
|
-
const input = fileInputRef.current;
|
|
317
|
-
if (input) {
|
|
318
|
-
input.click();
|
|
319
|
-
}
|
|
320
|
-
}}
|
|
321
1405
|
aria-label="Insert image"
|
|
322
1406
|
tabIndex={-1}
|
|
323
1407
|
disabled={isUploading}
|
|
324
1408
|
>
|
|
325
|
-
|
|
1409
|
+
<ImageUploadIcon />
|
|
326
1410
|
</button>
|
|
327
1411
|
)}
|
|
328
|
-
|
|
329
|
-
|
|
1412
|
+
{showFormattingButtons && Components && (
|
|
1413
|
+
<Components.Generic.Popover.Root
|
|
1414
|
+
opened={showLinkPopover}
|
|
1415
|
+
position="top"
|
|
1416
|
+
>
|
|
1417
|
+
<Components.Generic.Popover.Trigger>
|
|
1418
|
+
<button
|
|
1419
|
+
type="button"
|
|
1420
|
+
className="bn-step-toolbar__button"
|
|
1421
|
+
onMouseDown={(event) => {
|
|
1422
|
+
event.preventDefault();
|
|
1423
|
+
if (showLinkPopover) {
|
|
1424
|
+
setShowLinkPopover(false);
|
|
1425
|
+
linkSelectionRef.current = null;
|
|
1426
|
+
} else {
|
|
1427
|
+
handleOpenLinkPopover();
|
|
1428
|
+
}
|
|
1429
|
+
}}
|
|
1430
|
+
aria-label="Insert link"
|
|
1431
|
+
tabIndex={-1}
|
|
1432
|
+
>
|
|
1433
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
1434
|
+
<path d="M6.66699 4.66699C6.85574 4.66707 7.0139 4.73069 7.1416 4.8584C7.26931 4.9861 7.33293 5.14426 7.33301 5.33301C7.33301 5.5219 7.26938 5.68082 7.1416 5.80859C7.01393 5.93619 6.85566 5.99993 6.66699 6H4.66699C4.11151 6 3.63886 6.19423 3.25 6.58301C2.86111 6.9719 2.66699 7.44444 2.66699 8C2.66699 8.55556 2.86111 9.0281 3.25 9.41699C3.63886 9.80577 4.11151 10 4.66699 10H6.66699C6.85566 10.0001 7.01393 10.0638 7.1416 10.1914C7.26938 10.3192 7.33301 10.4781 7.33301 10.667C7.33293 10.8557 7.26931 11.0139 7.1416 11.1416C7.0139 11.2693 6.85574 11.3329 6.66699 11.333H4.66699C3.74485 11.333 2.95856 11.0083 2.30859 10.3584C1.65859 9.7084 1.33301 8.92222 1.33301 8C1.33301 7.07778 1.65859 6.2916 2.30859 5.6416C2.95856 4.99171 3.74485 4.66699 4.66699 4.66699H6.66699ZM11.333 4.66699C12.2552 4.66699 13.0414 4.99171 13.6914 5.6416C14.3414 6.2916 14.667 7.07778 14.667 8C14.667 8.92222 14.3414 9.7084 13.6914 10.3584C13.0414 11.0083 12.2552 11.333 11.333 11.333H9.33301C9.14426 11.3329 8.9861 11.2693 8.8584 11.1416C8.73069 11.0139 8.66707 10.8557 8.66699 10.667C8.66699 10.4781 8.73062 10.3192 8.8584 10.1914C8.98607 10.0638 9.14434 10.0001 9.33301 10H11.333C11.8885 10 12.3611 9.80577 12.75 9.41699C13.1389 9.0281 13.333 8.55556 13.333 8C13.333 7.44444 13.1389 6.9719 12.75 6.58301C12.3611 6.19423 11.8885 6 11.333 6H9.33301C9.14434 5.99993 8.98607 5.93619 8.8584 5.80859C8.73062 5.68082 8.66699 5.5219 8.66699 5.33301C8.66707 5.14426 8.73069 4.9861 8.8584 4.8584C8.9861 4.73069 9.14426 4.66707 9.33301 4.66699H11.333ZM10 7.33301C10.1889 7.33301 10.3468 7.39761 10.4746 7.52539C10.6024 7.65317 10.667 7.81111 10.667 8C10.667 8.18889 10.6024 8.34683 10.4746 8.47461C10.3468 8.60239 10.1889 8.66699 10 8.66699H6C5.81111 8.66699 5.65317 8.60239 5.52539 8.47461C5.39761 8.34683 5.33301 8.18889 5.33301 8C5.33301 7.81111 5.39761 7.65317 5.52539 7.52539C5.65317 7.39761 5.81111 7.33301 6 7.33301H10Z" fill="currentColor"/>
|
|
1435
|
+
</svg>
|
|
1436
|
+
</button>
|
|
1437
|
+
</Components.Generic.Popover.Trigger>
|
|
1438
|
+
<Components.Generic.Popover.Content
|
|
1439
|
+
className="bn-popover-content bn-form-popover"
|
|
1440
|
+
variant="form-popover"
|
|
1441
|
+
>
|
|
1442
|
+
<div ref={linkPopoverRef}>
|
|
1443
|
+
<EditLinkMenuItems
|
|
1444
|
+
url={(() => {
|
|
1445
|
+
const sel = linkSelectionRef.current;
|
|
1446
|
+
if (!sel) return "";
|
|
1447
|
+
const existing = linksRef.current.find((l) => l.start < sel.end && l.end > sel.start);
|
|
1448
|
+
return existing?.url ?? "";
|
|
1449
|
+
})()}
|
|
1450
|
+
text={linkSelectionRef.current?.text ?? ""}
|
|
1451
|
+
editLink={handleEditLink}
|
|
1452
|
+
/>
|
|
1453
|
+
</div>
|
|
1454
|
+
</Components.Generic.Popover.Content>
|
|
1455
|
+
</Components.Generic.Popover.Root>
|
|
1456
|
+
)}
|
|
1457
|
+
{enableAutocomplete && (
|
|
1458
|
+
<>
|
|
1459
|
+
<div className="bn-step-toolbar__divider" />
|
|
1460
|
+
<button
|
|
1461
|
+
type="button"
|
|
1462
|
+
className="bn-step-toolbar__button"
|
|
1463
|
+
onMouseDown={(event) => {
|
|
1464
|
+
event.preventDefault();
|
|
1465
|
+
setShowAllSuggestions(true);
|
|
1466
|
+
textareaNode?.focus();
|
|
1467
|
+
}}
|
|
1468
|
+
aria-label="Show suggestions"
|
|
1469
|
+
tabIndex={-1}
|
|
1470
|
+
>
|
|
1471
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
1472
|
+
<path d="M12 10.667H14V12H12V14H10.667V12H8.66699V10.667H10.667V8.66699H12V10.667ZM12 1.33301C12.74 1.33301 13.333 1.92699 13.333 2.66699V7.86621C12.9265 7.63301 12.4798 7.46669 12 7.38672V2.66699H2.66699V12H7.38672C7.46669 12.4798 7.63301 12.9265 7.86621 13.333H2.66699C1.92699 13.333 1.33301 12.74 1.33301 12V2.66699C1.33301 1.92699 1.92699 1.33301 2.66699 1.33301H12ZM7.33301 10.667H4V9.33301H7.33301V10.667ZM10.667 7.38672C10.1004 7.48005 9.5801 7.69336 9.12012 8H4V6.66699H10.667V7.38672ZM10.667 5.33301H4V4H10.667V5.33301Z" fill="currentColor"/>
|
|
1473
|
+
</svg>
|
|
1474
|
+
</button>
|
|
1475
|
+
</>
|
|
1476
|
+
)}
|
|
1477
|
+
{rightAction}
|
|
1478
|
+
</div>
|
|
1479
|
+
)}
|
|
330
1480
|
</div>
|
|
331
1481
|
{enableImageUpload && (
|
|
332
1482
|
<input
|
|
@@ -343,18 +1493,7 @@ export function StepField({
|
|
|
343
1493
|
setIsUploading(true);
|
|
344
1494
|
const response = await uploadImage(file);
|
|
345
1495
|
if (response?.url) {
|
|
346
|
-
|
|
347
|
-
if (element) {
|
|
348
|
-
const escapedUrl = escapeHtml(response.url);
|
|
349
|
-
const needsBreak = element.innerHTML.trim().length > 0;
|
|
350
|
-
const imgHtml =
|
|
351
|
-
(needsBreak ? "<br />" : "") +
|
|
352
|
-
`<img src="${escapedUrl}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
|
|
353
|
-
element.focus();
|
|
354
|
-
ensureCaretInEditor();
|
|
355
|
-
document.execCommand("insertHTML", false, imgHtml);
|
|
356
|
-
syncValue();
|
|
357
|
-
}
|
|
1496
|
+
insertImageMarkdown(response.url);
|
|
358
1497
|
}
|
|
359
1498
|
} catch (error) {
|
|
360
1499
|
console.error("Failed to upload image", error);
|
|
@@ -365,99 +1504,33 @@ export function StepField({
|
|
|
365
1504
|
}}
|
|
366
1505
|
/>
|
|
367
1506
|
)}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
"Enter",
|
|
396
|
-
"Tab",
|
|
397
|
-
]);
|
|
398
|
-
const openKeys =
|
|
399
|
-
enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ");
|
|
400
|
-
if (!allowedKeys.has(event.key) && !openKeys) {
|
|
401
|
-
event.preventDefault();
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
if ((event.key === "a" || event.key === "A") && (event.metaKey || event.ctrlKey)) {
|
|
406
|
-
event.preventDefault();
|
|
407
|
-
const selection = window.getSelection?.();
|
|
408
|
-
const node = editorRef.current;
|
|
409
|
-
if (selection && node) {
|
|
410
|
-
const range = document.createRange();
|
|
411
|
-
range.selectNodeContents(node);
|
|
412
|
-
selection.removeAllRanges();
|
|
413
|
-
selection.addRange(range);
|
|
414
|
-
}
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (enableAutocomplete && shouldShowAutocomplete) {
|
|
419
|
-
if (event.key === "ArrowDown") {
|
|
420
|
-
event.preventDefault();
|
|
421
|
-
setActiveSuggestionIndex((prev) =>
|
|
422
|
-
prev + 1 >= filteredSuggestions.length ? 0 : prev + 1,
|
|
423
|
-
);
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
if (event.key === "ArrowUp") {
|
|
427
|
-
event.preventDefault();
|
|
428
|
-
setActiveSuggestionIndex((prev) =>
|
|
429
|
-
prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1,
|
|
430
|
-
);
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
if (event.key === "Enter" || event.key === "Tab") {
|
|
434
|
-
event.preventDefault();
|
|
435
|
-
const suggestion = filteredSuggestions[activeSuggestionIndex] ?? filteredSuggestions[0];
|
|
436
|
-
if (suggestion) {
|
|
437
|
-
applySuggestion(suggestion);
|
|
438
|
-
}
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ")) {
|
|
444
|
-
event.preventDefault();
|
|
445
|
-
setShowAllSuggestions(true);
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (event.key === "Enter") {
|
|
450
|
-
event.preventDefault();
|
|
451
|
-
if (multiline && event.shiftKey) {
|
|
452
|
-
document.execCommand("insertLineBreak");
|
|
453
|
-
document.execCommand("insertLineBreak");
|
|
454
|
-
} else {
|
|
455
|
-
document.execCommand("insertLineBreak");
|
|
456
|
-
}
|
|
457
|
-
syncValue();
|
|
458
|
-
}
|
|
459
|
-
}}
|
|
460
|
-
/>
|
|
1507
|
+
{extractedImages.length > 0 && (
|
|
1508
|
+
<div className="bn-step-images" role="list">
|
|
1509
|
+
{extractedImages.map((image) => (
|
|
1510
|
+
<div key={image.id} className="bn-step-image-thumb" role="listitem">
|
|
1511
|
+
<button
|
|
1512
|
+
type="button"
|
|
1513
|
+
className="bn-step-image-thumb__button"
|
|
1514
|
+
onClick={() => handleImageClick(image.url)}
|
|
1515
|
+
aria-label="Preview image"
|
|
1516
|
+
>
|
|
1517
|
+
<img src={image.url} alt={image.alt || "Step image"} />
|
|
1518
|
+
</button>
|
|
1519
|
+
<button
|
|
1520
|
+
type="button"
|
|
1521
|
+
className="bn-step-image-thumb__remove"
|
|
1522
|
+
onClick={(event) => {
|
|
1523
|
+
event.stopPropagation();
|
|
1524
|
+
handleRemoveImage(image);
|
|
1525
|
+
}}
|
|
1526
|
+
aria-label="Remove image"
|
|
1527
|
+
>
|
|
1528
|
+
×
|
|
1529
|
+
</button>
|
|
1530
|
+
</div>
|
|
1531
|
+
))}
|
|
1532
|
+
</div>
|
|
1533
|
+
)}
|
|
461
1534
|
{shouldShowAutocomplete && (
|
|
462
1535
|
<div className="bn-step-suggestions" role="listbox" aria-label={`${label} suggestions`}>
|
|
463
1536
|
{filteredSuggestions.map((suggestion, index) => (
|
|
@@ -485,6 +1558,30 @@ export function StepField({
|
|
|
485
1558
|
))}
|
|
486
1559
|
</div>
|
|
487
1560
|
)}
|
|
1561
|
+
{previewImageUrl && (
|
|
1562
|
+
<div
|
|
1563
|
+
className="bn-step-image-preview"
|
|
1564
|
+
role="dialog"
|
|
1565
|
+
aria-label="Image preview"
|
|
1566
|
+
onClick={() => setPreviewImageUrl(null)}
|
|
1567
|
+
>
|
|
1568
|
+
<div
|
|
1569
|
+
className="bn-step-image-preview__content"
|
|
1570
|
+
role="document"
|
|
1571
|
+
onClick={(event) => event.stopPropagation()}
|
|
1572
|
+
>
|
|
1573
|
+
<img src={previewImageUrl} alt="Full size step" />
|
|
1574
|
+
<button
|
|
1575
|
+
type="button"
|
|
1576
|
+
className="bn-step-image-preview__close"
|
|
1577
|
+
onClick={() => setPreviewImageUrl(null)}
|
|
1578
|
+
aria-label="Close preview"
|
|
1579
|
+
>
|
|
1580
|
+
×
|
|
1581
|
+
</button>
|
|
1582
|
+
</div>
|
|
1583
|
+
</div>
|
|
1584
|
+
)}
|
|
488
1585
|
</div>
|
|
489
1586
|
);
|
|
490
1587
|
}
|