testomatio-editor-blocks 0.4.0 → 0.4.6

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.
@@ -1,6 +1,8 @@
1
1
  import OverType, { type OverType as OverTypeInstance } from "overtype";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import type { ReactNode, ChangeEvent } from "react";
4
+ import { useComponentsContext } from "@blocknote/react";
5
+ import { EditLinkMenuItems } from "@blocknote/react";
4
6
  import { useStepAutocomplete, type StepSuggestion } from "../stepAutocomplete";
5
7
  import { type SnippetSuggestion } from "../snippetAutocomplete";
6
8
  import { useStepImageUpload } from "../stepImageUpload";
@@ -11,10 +13,13 @@ type Suggestion = StepSuggestion | SnippetSuggestion;
11
13
 
12
14
  type StepFieldProps = {
13
15
  label: string;
16
+ showLabel?: boolean;
14
17
  labelToggle?: {
15
18
  onClick: () => void;
16
19
  expanded: boolean;
17
20
  };
21
+ labelAction?: ReactNode;
22
+ placeholder?: string;
18
23
  value: string;
19
24
  onChange: (nextValue: string) => void;
20
25
  autoFocus?: boolean;
@@ -46,6 +51,25 @@ const AUTOCOMPLETE_TRIGGER_KEYS = new Set([" ", "Space"]);
46
51
 
47
52
  const markdownParser = (OverType as { MarkdownParser?: { parse: (markdown: string) => string } }).MarkdownParser;
48
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
+
49
73
  type ExtractedImage = {
50
74
  id: string;
51
75
  url: string;
@@ -55,6 +79,377 @@ type ExtractedImage = {
55
79
  markdown: string;
56
80
  };
57
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 ![alt](url) — 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
+
58
453
  function markdownToPlainText(markdown: string): string {
59
454
  if (!markdown) {
60
455
  return "";
@@ -76,7 +471,10 @@ function markdownToPlainText(markdown: string): string {
76
471
 
77
472
  export function StepField({
78
473
  label,
474
+ showLabel = true,
79
475
  labelToggle,
476
+ labelAction,
477
+ placeholder,
80
478
  value,
81
479
  onChange,
82
480
  autoFocus,
@@ -113,17 +511,44 @@ export function StepField({
113
511
  const [showAllSuggestions, setShowAllSuggestions] = useState(false);
114
512
  const [isUploading, setIsUploading] = useState(false);
115
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 ?? "";
116
524
 
117
525
  useEffect(() => {
118
526
  onChangeRef.current = onChange;
119
527
  }, [onChange]);
120
528
 
121
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++;
540
+ }
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);
122
547
  setPlainTextValue((prev) => {
123
- const normalized = markdownToPlainText(nextValue);
548
+ const normalized = markdownToPlainText(markdown);
124
549
  return prev === normalized ? prev : normalized;
125
550
  });
126
- onChangeRef.current?.(nextValue);
551
+ onChangeRef.current?.(markdown);
127
552
  }, []);
128
553
 
129
554
  useEffect(() => {
@@ -132,8 +557,14 @@ export function StepField({
132
557
  return;
133
558
  }
134
559
 
560
+ const { plainText, links, formatting } = stripInlineMarkdown(initialValueRef.current);
561
+ linksRef.current = links;
562
+ formattingRef.current = formatting;
563
+ prevTextRef.current = plainText;
564
+
135
565
  const [instance] = OverType.init(container, {
136
- value: initialValueRef.current,
566
+ value: plainText,
567
+ placeholder: resolvedPlaceholder,
137
568
  autoResize: multiline,
138
569
  minHeight: multiline ? "4rem" : "2.5rem",
139
570
  padding: "0.5rem 0.75rem",
@@ -141,15 +572,110 @@ export function StepField({
141
572
  onChange: handleEditorChange,
142
573
  });
143
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
+
144
592
  editorInstanceRef.current = instance;
145
593
  setTextareaNode(instance.textarea);
146
594
 
147
595
  return () => {
596
+ caretRef.current = null;
148
597
  instance.destroy();
149
598
  editorInstanceRef.current = null;
150
599
  setTextareaNode(null);
151
600
  };
152
- }, [handleEditorChange, multiline]);
601
+ }, [handleEditorChange, multiline, resolvedPlaceholder]);
602
+
603
+ // Custom caret: position based on preview text metrics (handles bold/italic width differences)
604
+ useEffect(() => {
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]);
153
679
 
154
680
  useEffect(() => {
155
681
  if (pendingFocusRef.current && textareaNode) {
@@ -175,8 +701,19 @@ export function StepField({
175
701
  return;
176
702
  }
177
703
 
178
- if (instance.getValue() !== value) {
179
- instance.setValue(value);
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;
713
+ } else {
714
+ // Even if text didn't change, formatting/links might have — re-apply highlights
715
+ applyFormattingHighlights(instance.preview, formatting);
716
+ applyLinkHighlights(instance.preview, links);
180
717
  }
181
718
 
182
719
  setPlainTextValue((prev) => {
@@ -239,6 +776,30 @@ export function StepField({
239
776
  };
240
777
  }, [enableAutocomplete, onFieldFocus, showSuggestionsOnFocus, textareaNode]);
241
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
+
242
803
  useEffect(() => {
243
804
  if (!autoFocus || autoFocusRef.current || !textareaNode) {
244
805
  return;
@@ -279,9 +840,8 @@ export function StepField({
279
840
  const insertText = `${needsBeforeNewline ? "\n" : ""}![](${url})${needsAfterNewline ? "\n" : ""}`;
280
841
  const nextValue = `${before}${insertText}${after}`;
281
842
 
843
+ // setValue triggers updatePreview → handleEditorChange which reconstructs markdown with links
282
844
  instance.setValue(nextValue);
283
- setPlainTextValue(markdownToPlainText(nextValue));
284
- onChangeRef.current?.(nextValue);
285
845
 
286
846
  requestAnimationFrame(() => {
287
847
  textarea.selectionStart = start + insertText.length;
@@ -340,16 +900,133 @@ export function StepField({
340
900
 
341
901
  const handleToolbarAction = useCallback(
342
902
  (action: "toggleBold" | "toggleItalic") => {
343
- const shortcuts = editorInstanceRef.current?.shortcuts;
344
- if (!textareaNode || !shortcuts?.handleAction) {
903
+ const instance = editorInstanceRef.current;
904
+ if (!textareaNode || !instance) {
345
905
  return;
346
906
  }
347
907
  textareaNode.focus();
348
- shortcuts.handleAction(action);
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);
349
939
  },
350
940
  [textareaNode],
351
941
  );
352
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;
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]);
971
+
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
+
353
1030
  const suggestionPool = useMemo(() => {
354
1031
  if (!suggestionFilter) {
355
1032
  return suggestions;
@@ -495,6 +1172,23 @@ export function StepField({
495
1172
  }
496
1173
  }
497
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
+
498
1192
  if (enableAutocomplete && shouldShowAutocomplete) {
499
1193
  if (event.key === "ArrowDown") {
500
1194
  event.preventDefault();
@@ -538,7 +1232,7 @@ export function StepField({
538
1232
  }
539
1233
  }
540
1234
  };
541
- }, [activeSuggestionIndex, applySuggestion, enableAutocomplete, filteredSuggestions, focusAdjacentField, readOnly, shouldShowAutocomplete]);
1235
+ }, [activeSuggestionIndex, applySuggestion, enableAutocomplete, filteredSuggestions, focusAdjacentField, handleToolbarAction, readOnly, shouldShowAutocomplete]);
542
1236
 
543
1237
  useEffect(() => {
544
1238
  if (!textareaNode) {
@@ -565,91 +1259,224 @@ export function StepField({
565
1259
  .filter(Boolean)
566
1260
  .join(" ");
567
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
+
568
1274
  return (
569
1275
  <div className="bn-step-field">
570
- <div className="bn-step-field__top">
571
- <div className="bn-step-field__label-row">
572
- {labelToggle ? (
573
- <span
574
- className="bn-step-field__label bn-step-field__label--toggle"
575
- role="button"
576
- tabIndex={-1}
577
- onClick={labelToggle.onClick}
578
- onKeyDown={(event) => {
579
- if (event.key === "Enter" || event.key === " ") {
580
- event.preventDefault();
581
- labelToggle.onClick();
582
- }
583
- }}
584
- aria-expanded={labelToggle.expanded}
585
- >
586
- {label}
587
- </span>
588
- ) : (
589
- <span className="bn-step-field__label">{label}</span>
590
- )}
591
- {enableAutocomplete && (
592
- <button
593
- type="button"
594
- className="bn-step-suggestions-toggle"
595
- onMouseDown={(event) => {
596
- event.preventDefault();
597
- setShowAllSuggestions(true);
598
- textareaNode?.focus();
599
- }}
600
- aria-label="Show suggestions"
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"
601
1283
  tabIndex={-1}
602
- >
603
-
604
- </button>
605
- )}
606
- </div>
607
- <div className="bn-step-toolbar" aria-label={`${label} controls`}>
608
- {showFormattingButtons && (
609
- <>
610
- <button
611
- type="button"
612
- className="bn-step-toolbar__button"
613
- onMouseDown={(event) => {
614
- event.preventDefault();
615
- handleToolbarAction("toggleBold");
1284
+ onClick={labelToggle.onClick}
1285
+ onKeyDown={(event) => {
1286
+ if (event.key === "Enter" || event.key === " ") {
1287
+ event.preventDefault();
1288
+ labelToggle.onClick();
1289
+ }
616
1290
  }}
617
- aria-label="Bold"
618
- tabIndex={-1}
1291
+ aria-expanded={labelToggle.expanded}
619
1292
  >
620
- B
621
- </button>
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>
1323
+ <button
1324
+ type="button"
1325
+ className="bn-step-link-tooltip__btn"
1326
+ onMouseDown={(event) => {
1327
+ event.preventDefault();
1328
+ linkSelectionRef.current = { start: cursorLink.start, end: cursorLink.end, text: "" };
1329
+ setShowLinkPopover(true);
1330
+ }}
1331
+ tabIndex={-1}
1332
+ >
1333
+ Edit link
1334
+ </button>
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 && (
622
1398
  <button
623
1399
  type="button"
624
1400
  className="bn-step-toolbar__button"
625
1401
  onMouseDown={(event) => {
626
1402
  event.preventDefault();
627
- handleToolbarAction("toggleItalic");
1403
+ fileInputRef.current?.click();
628
1404
  }}
629
- aria-label="Italic"
630
- tabIndex={-1}
631
- >
632
- I
633
- </button>
634
- </>
635
- )}
636
- {enableImageUpload && uploadImage && showImageButton && (
637
- <button
638
- type="button"
639
- className="bn-step-toolbar__button"
640
- onMouseDown={(event) => {
641
- event.preventDefault();
642
- fileInputRef.current?.click();
643
- }}
644
1405
  aria-label="Insert image"
645
1406
  tabIndex={-1}
646
1407
  disabled={isUploading}
647
1408
  >
648
- Img
1409
+ <ImageUploadIcon />
649
1410
  </button>
650
1411
  )}
651
- {rightAction}
652
- </div>
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
+ )}
653
1480
  </div>
654
1481
  {enableImageUpload && (
655
1482
  <input
@@ -677,21 +1504,6 @@ export function StepField({
677
1504
  }}
678
1505
  />
679
1506
  )}
680
- <div
681
- ref={editorContainerRef}
682
- className={editorClassName}
683
- data-step-field={fieldName}
684
- tabIndex={-1}
685
- onFocus={(event) => {
686
- if (event.target === editorContainerRef.current) {
687
- if (textareaNode) {
688
- textareaNode.focus();
689
- } else {
690
- pendingFocusRef.current = true;
691
- }
692
- }
693
- }}
694
- />
695
1507
  {extractedImages.length > 0 && (
696
1508
  <div className="bn-step-images" role="list">
697
1509
  {extractedImages.map((image) => (