testomatio-editor-blocks 0.4.53 → 0.4.54

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,5 @@
1
1
  const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+?)(?:\s+=\d+x(?:\d+|\*))?\)/g;
2
2
  const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
3
- const INLINE_SEGMENT_REGEX = /(\*\*\*[^*]+\*\*\*|___[^_]+___|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/;
4
3
  export function escapeHtml(text) {
5
4
  return text
6
5
  .replace(/&/g, "&amp;")
@@ -12,46 +11,108 @@ export function escapeHtml(text) {
12
11
  function restoreEscapes(text) {
13
12
  return text.replace(/\uE000/g, "\\");
14
13
  }
14
+ function findItalicClose(text, start, marker) {
15
+ let j = start;
16
+ while (j < text.length) {
17
+ const ch = text[j];
18
+ if (ch === "\uE000") {
19
+ j += 2;
20
+ continue;
21
+ }
22
+ if ((ch === "*" || ch === "_") && text[j + 1] === ch) {
23
+ const close = text.indexOf(ch + ch, j + 2);
24
+ if (close === -1) {
25
+ return -1;
26
+ }
27
+ j = close + 2;
28
+ continue;
29
+ }
30
+ if (ch === marker) {
31
+ return j;
32
+ }
33
+ j += 1;
34
+ }
35
+ return -1;
36
+ }
37
+ function parseInlineSegments(normalized, outer) {
38
+ const result = [];
39
+ let buffer = "";
40
+ const pushPlain = () => {
41
+ if (!buffer)
42
+ return;
43
+ result.push({ text: restoreEscapes(buffer), styles: { ...outer } });
44
+ buffer = "";
45
+ };
46
+ const wrap = (inner, add) => {
47
+ pushPlain();
48
+ result.push(...parseInlineSegments(inner, { ...outer, ...add }));
49
+ };
50
+ let i = 0;
51
+ while (i < normalized.length) {
52
+ if (normalized.startsWith("***", i)) {
53
+ const end = normalized.indexOf("***", i + 3);
54
+ if (end !== -1) {
55
+ wrap(normalized.slice(i + 3, end), { bold: true, italic: true });
56
+ i = end + 3;
57
+ continue;
58
+ }
59
+ }
60
+ if (normalized.startsWith("___", i)) {
61
+ const end = normalized.indexOf("___", i + 3);
62
+ if (end !== -1) {
63
+ wrap(normalized.slice(i + 3, end), { bold: true, italic: true });
64
+ i = end + 3;
65
+ continue;
66
+ }
67
+ }
68
+ if (normalized.startsWith("**", i)) {
69
+ const end = normalized.indexOf("**", i + 2);
70
+ if (end !== -1) {
71
+ wrap(normalized.slice(i + 2, end), { bold: true });
72
+ i = end + 2;
73
+ continue;
74
+ }
75
+ }
76
+ if (normalized.startsWith("__", i)) {
77
+ const end = normalized.indexOf("__", i + 2);
78
+ if (end !== -1) {
79
+ wrap(normalized.slice(i + 2, end), { bold: true });
80
+ i = end + 2;
81
+ continue;
82
+ }
83
+ }
84
+ if (normalized.startsWith("<u>", i)) {
85
+ const end = normalized.indexOf("</u>", i + 3);
86
+ if (end !== -1) {
87
+ wrap(normalized.slice(i + 3, end), { underline: true });
88
+ i = end + 4;
89
+ continue;
90
+ }
91
+ }
92
+ if (normalized[i] === "*" || normalized[i] === "_") {
93
+ const marker = normalized[i];
94
+ const end = findItalicClose(normalized, i + 1, marker);
95
+ if (end !== -1) {
96
+ wrap(normalized.slice(i + 1, end), { italic: true });
97
+ i = end + 1;
98
+ continue;
99
+ }
100
+ }
101
+ buffer += normalized[i];
102
+ i += 1;
103
+ }
104
+ pushPlain();
105
+ return result;
106
+ }
15
107
  function parseInlineMarkdown(text) {
16
108
  if (!text) {
17
109
  return [];
18
110
  }
19
111
  const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
20
- const rawSegments = normalized.split(INLINE_SEGMENT_REGEX).filter(Boolean);
21
- return rawSegments.map((segment) => {
22
- const baseStyles = { bold: false, italic: false, underline: false };
23
- if (/^\*\*\*(.+)\*\*\*$/.test(segment) || /^___(.+)___$/.test(segment)) {
24
- const content = segment.slice(3, -3);
25
- return {
26
- text: restoreEscapes(content),
27
- styles: { bold: true, italic: true, underline: false },
28
- };
29
- }
30
- if (/^\*\*(.+)\*\*$/.test(segment) || /^__(.+)__$/.test(segment)) {
31
- const content = segment.slice(2, -2);
32
- return {
33
- text: restoreEscapes(content),
34
- styles: { ...baseStyles, bold: true },
35
- };
36
- }
37
- if (/^\*(.+)\*$/.test(segment) || /^_(.+)_$/.test(segment)) {
38
- const content = segment.slice(1, -1);
39
- return {
40
- text: restoreEscapes(content),
41
- styles: { ...baseStyles, italic: true },
42
- };
43
- }
44
- if (/^<u>(.+)<\/u>$/.test(segment)) {
45
- const content = segment.slice(3, -4);
46
- return {
47
- text: restoreEscapes(content),
48
- styles: { ...baseStyles, underline: true },
49
- };
50
- }
51
- return {
52
- text: restoreEscapes(segment),
53
- styles: { ...baseStyles },
54
- };
112
+ return parseInlineSegments(normalized, {
113
+ bold: false,
114
+ italic: false,
115
+ underline: false,
55
116
  });
56
117
  }
57
118
  function inlineToHtml(inline) {
@@ -753,6 +753,15 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
753
753
  }
754
754
  textareaNode.focus();
755
755
  }, [focusSignal, textareaNode]);
756
+ useAutoResize({
757
+ textarea: textareaNode,
758
+ enabled: multiline,
759
+ onResize: useCallback(() => {
760
+ var _a;
761
+ const instance = editorInstanceRef.current;
762
+ (_a = instance === null || instance === void 0 ? void 0 : instance._updateAutoHeight) === null || _a === void 0 ? void 0 : _a.call(instance);
763
+ }, []),
764
+ });
756
765
  useEffect(() => {
757
766
  var _a;
758
767
  const instance = editorInstanceRef.current;
@@ -814,12 +823,6 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
814
823
  }
815
824
  textareaNode.readOnly = readOnly;
816
825
  }, [readOnly, textareaNode]);
817
- useAutoResize({
818
- textarea: textareaNode,
819
- multiline,
820
- minRows: 3,
821
- maxRows: 16,
822
- });
823
826
  useEffect(() => {
824
827
  if (!textareaNode) {
825
828
  return;
@@ -1,8 +1,7 @@
1
1
  type Options = {
2
2
  textarea: HTMLTextAreaElement | null;
3
- multiline?: boolean;
4
- minRows?: number;
5
- maxRows?: number;
3
+ enabled?: boolean;
4
+ onResize: () => void;
6
5
  };
7
- export declare function useAutoResize({ textarea, multiline, minRows, maxRows }: Options): void;
6
+ export declare function useAutoResize({ textarea, enabled, onResize }: Options): () => void;
8
7
  export {};
@@ -1,65 +1,77 @@
1
- import { useEffect, useRef } from "react";
2
- export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRows = 12 }) {
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ // Shared across all hook instances: one fonts.ready promise per document,
3
+ // fanned out to every registered callback so N fields cost 1 .then().
4
+ const fontsReadyCallbacks = new Set();
5
+ let fontsReadyAttached = false;
6
+ function registerFontsReady(cb) {
7
+ var _a;
8
+ if (typeof document === "undefined" || !((_a = document.fonts) === null || _a === void 0 ? void 0 : _a.ready)) {
9
+ return () => { };
10
+ }
11
+ fontsReadyCallbacks.add(cb);
12
+ if (!fontsReadyAttached) {
13
+ fontsReadyAttached = true;
14
+ document.fonts.ready
15
+ .then(() => {
16
+ for (const fn of fontsReadyCallbacks)
17
+ fn();
18
+ })
19
+ .catch(() => { });
20
+ }
21
+ return () => {
22
+ fontsReadyCallbacks.delete(cb);
23
+ };
24
+ }
25
+ export function useAutoResize({ textarea, enabled = true, onResize }) {
3
26
  const frameRef = useRef(0);
27
+ const onResizeRef = useRef(onResize);
28
+ useEffect(() => {
29
+ onResizeRef.current = onResize;
30
+ }, [onResize]);
4
31
  useEffect(() => {
5
- var _a;
6
- if (!textarea || !multiline) {
32
+ if (!textarea || !enabled)
7
33
  return;
8
- }
9
- const resize = () => {
10
- textarea.style.height = "auto";
11
- const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight || "16");
12
- const minHeight = lineHeight * minRows;
13
- const maxHeight = lineHeight * maxRows;
14
- const clampedHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
15
- textarea.style.height = `${clampedHeight}px`;
16
- textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
17
- };
18
- const mutationObserver = new MutationObserver(resize);
19
- mutationObserver.observe(textarea, { childList: true, characterData: true, subtree: true });
20
- const resizeObserver = new ResizeObserver(resize);
21
- resizeObserver.observe(textarea);
22
- const handleInput = () => {
23
- var _a;
24
- cancelAnimationFrame((_a = frameRef.current) !== null && _a !== void 0 ? _a : 0);
25
- frameRef.current = requestAnimationFrame(resize);
26
- };
27
- textarea.addEventListener("input", handleInput);
28
34
  let cancelled = false;
29
- const initialFrame = requestAnimationFrame(() => {
35
+ // All callers go through this coalescing scheduler: repeated pings in a
36
+ // single frame collapse to one reflow.
37
+ const schedule = () => {
38
+ if (cancelled)
39
+ return;
40
+ if (frameRef.current)
41
+ return;
30
42
  frameRef.current = requestAnimationFrame(() => {
43
+ frameRef.current = 0;
31
44
  if (!cancelled)
32
- resize();
45
+ onResizeRef.current();
33
46
  });
34
- });
35
- // Re-run resize once the textarea is actually laid out. During drag-drop
36
- // remounts the element can be briefly detached, so the initial RAF resize
37
- // sees scrollHeight === 0 and clamps to minRows.
47
+ };
48
+ // Initial pass after layout.
49
+ schedule();
50
+ // One-shot: re-run once the textarea actually enters the layout tree.
51
+ // This is the piece that fixes the drag-drop remount and
52
+ // snippet-insert-while-hidden cases OverType itself cannot recover from.
38
53
  const intersectionObserver = new IntersectionObserver((entries) => {
39
54
  for (const entry of entries) {
40
- if (entry.isIntersecting && !cancelled) {
41
- resize();
55
+ if (entry.isIntersecting) {
56
+ schedule();
42
57
  intersectionObserver.disconnect();
43
58
  break;
44
59
  }
45
60
  }
46
61
  });
47
62
  intersectionObserver.observe(textarea);
48
- if (typeof document !== "undefined" && ((_a = document.fonts) === null || _a === void 0 ? void 0 : _a.ready)) {
49
- document.fonts.ready.then(() => {
50
- if (!cancelled)
51
- resize();
52
- }).catch(() => { });
53
- }
63
+ const unregisterFonts = registerFontsReady(schedule);
54
64
  return () => {
55
- var _a;
56
65
  cancelled = true;
57
- mutationObserver.disconnect();
58
- resizeObserver.disconnect();
59
66
  intersectionObserver.disconnect();
60
- textarea.removeEventListener("input", handleInput);
61
- cancelAnimationFrame(initialFrame);
62
- cancelAnimationFrame((_a = frameRef.current) !== null && _a !== void 0 ? _a : 0);
67
+ unregisterFonts();
68
+ if (frameRef.current) {
69
+ cancelAnimationFrame(frameRef.current);
70
+ frameRef.current = 0;
71
+ }
63
72
  };
64
- }, [textarea, multiline, minRows, maxRows]);
73
+ }, [textarea, enabled]);
74
+ return useCallback(() => {
75
+ onResizeRef.current();
76
+ }, []);
65
77
  }
@@ -521,28 +521,80 @@ export function blocksToMarkdown(blocks) {
521
521
  return cleaned;
522
522
  }
523
523
  function parseInlineMarkdown(text) {
524
- const cleaned = stripHtmlWrappers(text);
524
+ return parseInlineSegments(stripHtmlWrappers(text), {});
525
+ }
526
+ function findItalicClose(text, start, marker) {
527
+ let j = start;
528
+ while (j < text.length) {
529
+ const ch = text[j];
530
+ if (ch === "\\") {
531
+ j += 2;
532
+ continue;
533
+ }
534
+ if ((ch === "*" || ch === "_") && text[j + 1] === ch) {
535
+ const close = text.indexOf(ch + ch, j + 2);
536
+ if (close === -1) {
537
+ return -1;
538
+ }
539
+ j = close + 2;
540
+ continue;
541
+ }
542
+ if (ch === marker) {
543
+ return j;
544
+ }
545
+ j += 1;
546
+ }
547
+ return -1;
548
+ }
549
+ function parseInlineSegments(cleaned, outerStyles) {
525
550
  const result = [];
526
551
  let buffer = "";
527
552
  const pushPlain = () => {
528
553
  if (buffer.length === 0) {
529
554
  return;
530
555
  }
531
- result.push({ type: "text", text: unescapeMarkdown(buffer), styles: {} });
556
+ result.push({
557
+ type: "text",
558
+ text: unescapeMarkdown(buffer),
559
+ styles: { ...outerStyles },
560
+ });
532
561
  buffer = "";
533
562
  };
563
+ const wrap = (inner, add) => {
564
+ pushPlain();
565
+ const nested = parseInlineSegments(inner, { ...outerStyles, ...add });
566
+ result.push(...nested);
567
+ };
534
568
  let i = 0;
535
569
  while (i < cleaned.length) {
570
+ if (cleaned.startsWith("***", i)) {
571
+ const end = cleaned.indexOf("***", i + 3);
572
+ if (end !== -1) {
573
+ wrap(cleaned.slice(i + 3, end), { bold: true, italic: true });
574
+ i = end + 3;
575
+ continue;
576
+ }
577
+ }
578
+ if (cleaned.startsWith("___", i)) {
579
+ const end = cleaned.indexOf("___", i + 3);
580
+ if (end !== -1) {
581
+ wrap(cleaned.slice(i + 3, end), { bold: true, italic: true });
582
+ i = end + 3;
583
+ continue;
584
+ }
585
+ }
536
586
  if (cleaned.startsWith("**", i)) {
537
587
  const end = cleaned.indexOf("**", i + 2);
538
588
  if (end !== -1) {
539
- pushPlain();
540
- const inner = cleaned.slice(i + 2, end);
541
- result.push({
542
- type: "text",
543
- text: unescapeMarkdown(inner),
544
- styles: { bold: true },
545
- });
589
+ wrap(cleaned.slice(i + 2, end), { bold: true });
590
+ i = end + 2;
591
+ continue;
592
+ }
593
+ }
594
+ if (cleaned.startsWith("__", i)) {
595
+ const end = cleaned.indexOf("__", i + 2);
596
+ if (end !== -1) {
597
+ wrap(cleaned.slice(i + 2, end), { bold: true });
546
598
  i = end + 2;
547
599
  continue;
548
600
  }
@@ -550,13 +602,7 @@ function parseInlineMarkdown(text) {
550
602
  if (cleaned.startsWith("~~", i)) {
551
603
  const end = cleaned.indexOf("~~", i + 2);
552
604
  if (end !== -1) {
553
- pushPlain();
554
- const inner = cleaned.slice(i + 2, end);
555
- result.push({
556
- type: "text",
557
- text: unescapeMarkdown(inner),
558
- styles: { strike: true },
559
- });
605
+ wrap(cleaned.slice(i + 2, end), { strike: true });
560
606
  i = end + 2;
561
607
  continue;
562
608
  }
@@ -569,7 +615,7 @@ function parseInlineMarkdown(text) {
569
615
  result.push({
570
616
  type: "text",
571
617
  text: unescapeMarkdown(inner),
572
- styles: { code: true },
618
+ styles: { ...outerStyles, code: true },
573
619
  });
574
620
  i = end + 1;
575
621
  continue;
@@ -583,7 +629,7 @@ function parseInlineMarkdown(text) {
583
629
  pushPlain();
584
630
  const label = cleaned.slice(i + 1, endLabel);
585
631
  const href = cleaned.slice(startLink + 1, endLink);
586
- const parsedLabel = parseInlineMarkdown(label);
632
+ const parsedLabel = parseInlineSegments(label, {});
587
633
  // Ensure link content is never undefined - if empty, add empty text
588
634
  const linkContent = parsedLabel.length > 0 ? parsedLabel : [{ type: "text", text: "", styles: {} }];
589
635
  result.push({
@@ -597,15 +643,9 @@ function parseInlineMarkdown(text) {
597
643
  }
598
644
  if (cleaned[i] === "*" || cleaned[i] === "_") {
599
645
  const marker = cleaned[i];
600
- const end = cleaned.indexOf(marker, i + 1);
646
+ const end = findItalicClose(cleaned, i + 1, marker);
601
647
  if (end !== -1) {
602
- pushPlain();
603
- const inner = cleaned.slice(i + 1, end);
604
- result.push({
605
- type: "text",
606
- text: unescapeMarkdown(inner),
607
- styles: { italic: true },
608
- });
648
+ wrap(cleaned.slice(i + 1, end), { italic: true });
609
649
  i = end + 1;
610
650
  continue;
611
651
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.53",
3
+ "version": "0.4.54",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
@@ -1,7 +1,5 @@
1
1
  const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+?)(?:\s+=\d+x(?:\d+|\*))?\)/g;
2
2
  const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
3
- const INLINE_SEGMENT_REGEX =
4
- /(\*\*\*[^*]+\*\*\*|___[^_]+___|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/;
5
3
 
6
4
  export function escapeHtml(text: string): string {
7
5
  return text
@@ -25,53 +23,125 @@ function restoreEscapes(text: string): string {
25
23
  return text.replace(/\uE000/g, "\\");
26
24
  }
27
25
 
28
- function parseInlineMarkdown(text: string): InlineSegment[] {
29
- if (!text) {
30
- return [];
26
+ function findItalicClose(
27
+ text: string,
28
+ start: number,
29
+ marker: "*" | "_",
30
+ ): number {
31
+ let j = start;
32
+ while (j < text.length) {
33
+ const ch = text[j];
34
+ if (ch === "\uE000") {
35
+ j += 2;
36
+ continue;
37
+ }
38
+ if ((ch === "*" || ch === "_") && text[j + 1] === ch) {
39
+ const close = text.indexOf(ch + ch, j + 2);
40
+ if (close === -1) {
41
+ return -1;
42
+ }
43
+ j = close + 2;
44
+ continue;
45
+ }
46
+ if (ch === marker) {
47
+ return j;
48
+ }
49
+ j += 1;
31
50
  }
51
+ return -1;
52
+ }
32
53
 
33
- const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
34
- const rawSegments = normalized.split(INLINE_SEGMENT_REGEX).filter(Boolean);
54
+ function parseInlineSegments(
55
+ normalized: string,
56
+ outer: { bold: boolean; italic: boolean; underline: boolean },
57
+ ): InlineSegment[] {
58
+ const result: InlineSegment[] = [];
59
+ let buffer = "";
60
+
61
+ const pushPlain = () => {
62
+ if (!buffer) return;
63
+ result.push({ text: restoreEscapes(buffer), styles: { ...outer } });
64
+ buffer = "";
65
+ };
35
66
 
36
- return rawSegments.map((segment) => {
37
- const baseStyles = { bold: false, italic: false, underline: false };
67
+ const wrap = (
68
+ inner: string,
69
+ add: Partial<{ bold: boolean; italic: boolean; underline: boolean }>,
70
+ ) => {
71
+ pushPlain();
72
+ result.push(...parseInlineSegments(inner, { ...outer, ...add }));
73
+ };
38
74
 
39
- if (/^\*\*\*(.+)\*\*\*$/.test(segment) || /^___(.+)___$/.test(segment)) {
40
- const content = segment.slice(3, -3);
41
- return {
42
- text: restoreEscapes(content),
43
- styles: { bold: true, italic: true, underline: false },
44
- };
75
+ let i = 0;
76
+ while (i < normalized.length) {
77
+ if (normalized.startsWith("***", i)) {
78
+ const end = normalized.indexOf("***", i + 3);
79
+ if (end !== -1) {
80
+ wrap(normalized.slice(i + 3, end), { bold: true, italic: true });
81
+ i = end + 3;
82
+ continue;
83
+ }
45
84
  }
46
-
47
- if (/^\*\*(.+)\*\*$/.test(segment) || /^__(.+)__$/.test(segment)) {
48
- const content = segment.slice(2, -2);
49
- return {
50
- text: restoreEscapes(content),
51
- styles: { ...baseStyles, bold: true },
52
- };
85
+ if (normalized.startsWith("___", i)) {
86
+ const end = normalized.indexOf("___", i + 3);
87
+ if (end !== -1) {
88
+ wrap(normalized.slice(i + 3, end), { bold: true, italic: true });
89
+ i = end + 3;
90
+ continue;
91
+ }
53
92
  }
54
-
55
- if (/^\*(.+)\*$/.test(segment) || /^_(.+)_$/.test(segment)) {
56
- const content = segment.slice(1, -1);
57
- return {
58
- text: restoreEscapes(content),
59
- styles: { ...baseStyles, italic: true },
60
- };
93
+ if (normalized.startsWith("**", i)) {
94
+ const end = normalized.indexOf("**", i + 2);
95
+ if (end !== -1) {
96
+ wrap(normalized.slice(i + 2, end), { bold: true });
97
+ i = end + 2;
98
+ continue;
99
+ }
61
100
  }
62
-
63
- if (/^<u>(.+)<\/u>$/.test(segment)) {
64
- const content = segment.slice(3, -4);
65
- return {
66
- text: restoreEscapes(content),
67
- styles: { ...baseStyles, underline: true },
68
- };
101
+ if (normalized.startsWith("__", i)) {
102
+ const end = normalized.indexOf("__", i + 2);
103
+ if (end !== -1) {
104
+ wrap(normalized.slice(i + 2, end), { bold: true });
105
+ i = end + 2;
106
+ continue;
107
+ }
108
+ }
109
+ if (normalized.startsWith("<u>", i)) {
110
+ const end = normalized.indexOf("</u>", i + 3);
111
+ if (end !== -1) {
112
+ wrap(normalized.slice(i + 3, end), { underline: true });
113
+ i = end + 4;
114
+ continue;
115
+ }
69
116
  }
117
+ if (normalized[i] === "*" || normalized[i] === "_") {
118
+ const marker = normalized[i] as "*" | "_";
119
+ const end = findItalicClose(normalized, i + 1, marker);
120
+ if (end !== -1) {
121
+ wrap(normalized.slice(i + 1, end), { italic: true });
122
+ i = end + 1;
123
+ continue;
124
+ }
125
+ }
126
+
127
+ buffer += normalized[i];
128
+ i += 1;
129
+ }
70
130
 
71
- return {
72
- text: restoreEscapes(segment),
73
- styles: { ...baseStyles },
74
- };
131
+ pushPlain();
132
+ return result;
133
+ }
134
+
135
+ function parseInlineMarkdown(text: string): InlineSegment[] {
136
+ if (!text) {
137
+ return [];
138
+ }
139
+
140
+ const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
141
+ return parseInlineSegments(normalized, {
142
+ bold: false,
143
+ italic: false,
144
+ underline: false,
75
145
  });
76
146
  }
77
147
 
@@ -945,6 +945,15 @@ export function StepField({
945
945
  textareaNode.focus();
946
946
  }, [focusSignal, textareaNode]);
947
947
 
948
+ useAutoResize({
949
+ textarea: textareaNode,
950
+ enabled: multiline,
951
+ onResize: useCallback(() => {
952
+ const instance = editorInstanceRef.current as (OverTypeInstance & { _updateAutoHeight?: () => void }) | null;
953
+ instance?._updateAutoHeight?.();
954
+ }, []),
955
+ });
956
+
948
957
  useEffect(() => {
949
958
  const instance = editorInstanceRef.current;
950
959
  if (!instance) {
@@ -1012,13 +1021,6 @@ export function StepField({
1012
1021
  textareaNode.readOnly = readOnly;
1013
1022
  }, [readOnly, textareaNode]);
1014
1023
 
1015
- useAutoResize({
1016
- textarea: textareaNode,
1017
- multiline,
1018
- minRows: 3,
1019
- maxRows: 16,
1020
- });
1021
-
1022
1024
  useEffect(() => {
1023
1025
  if (!textareaNode) {
1024
1026
  return;
@@ -1,58 +1,68 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useCallback, useEffect, useRef } from "react";
2
2
 
3
3
  type Options = {
4
4
  textarea: HTMLTextAreaElement | null;
5
- multiline?: boolean;
6
- minRows?: number;
7
- maxRows?: number;
5
+ enabled?: boolean;
6
+ onResize: () => void;
8
7
  };
9
8
 
10
- export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRows = 12 }: Options) {
11
- const frameRef = useRef<number>(0);
12
-
13
- useEffect(() => {
14
- if (!textarea || !multiline) {
15
- return;
16
- }
9
+ // Shared across all hook instances: one fonts.ready promise per document,
10
+ // fanned out to every registered callback so N fields cost 1 .then().
11
+ const fontsReadyCallbacks = new Set<() => void>();
12
+ let fontsReadyAttached = false;
17
13
 
18
- const resize = () => {
19
- textarea.style.height = "auto";
20
- const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight || "16");
21
- const minHeight = lineHeight * minRows;
22
- const maxHeight = lineHeight * maxRows;
23
-
24
- const clampedHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
25
- textarea.style.height = `${clampedHeight}px`;
26
- textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
27
- };
28
-
29
- const mutationObserver = new MutationObserver(resize);
30
- mutationObserver.observe(textarea, { childList: true, characterData: true, subtree: true });
14
+ function registerFontsReady(cb: () => void): () => void {
15
+ if (typeof document === "undefined" || !document.fonts?.ready) {
16
+ return () => {};
17
+ }
18
+ fontsReadyCallbacks.add(cb);
19
+ if (!fontsReadyAttached) {
20
+ fontsReadyAttached = true;
21
+ document.fonts.ready
22
+ .then(() => {
23
+ for (const fn of fontsReadyCallbacks) fn();
24
+ })
25
+ .catch(() => {});
26
+ }
27
+ return () => {
28
+ fontsReadyCallbacks.delete(cb);
29
+ };
30
+ }
31
31
 
32
- const resizeObserver = new ResizeObserver(resize);
33
- resizeObserver.observe(textarea);
32
+ export function useAutoResize({ textarea, enabled = true, onResize }: Options) {
33
+ const frameRef = useRef<number>(0);
34
+ const onResizeRef = useRef(onResize);
34
35
 
35
- const handleInput = () => {
36
- cancelAnimationFrame(frameRef.current ?? 0);
37
- frameRef.current = requestAnimationFrame(resize);
38
- };
36
+ useEffect(() => {
37
+ onResizeRef.current = onResize;
38
+ }, [onResize]);
39
39
 
40
- textarea.addEventListener("input", handleInput);
40
+ useEffect(() => {
41
+ if (!textarea || !enabled) return;
41
42
 
42
43
  let cancelled = false;
43
- const initialFrame = requestAnimationFrame(() => {
44
+
45
+ // All callers go through this coalescing scheduler: repeated pings in a
46
+ // single frame collapse to one reflow.
47
+ const schedule = () => {
48
+ if (cancelled) return;
49
+ if (frameRef.current) return;
44
50
  frameRef.current = requestAnimationFrame(() => {
45
- if (!cancelled) resize();
51
+ frameRef.current = 0;
52
+ if (!cancelled) onResizeRef.current();
46
53
  });
47
- });
54
+ };
55
+
56
+ // Initial pass after layout.
57
+ schedule();
48
58
 
49
- // Re-run resize once the textarea is actually laid out. During drag-drop
50
- // remounts the element can be briefly detached, so the initial RAF resize
51
- // sees scrollHeight === 0 and clamps to minRows.
59
+ // One-shot: re-run once the textarea actually enters the layout tree.
60
+ // This is the piece that fixes the drag-drop remount and
61
+ // snippet-insert-while-hidden cases OverType itself cannot recover from.
52
62
  const intersectionObserver = new IntersectionObserver((entries) => {
53
63
  for (const entry of entries) {
54
- if (entry.isIntersecting && !cancelled) {
55
- resize();
64
+ if (entry.isIntersecting) {
65
+ schedule();
56
66
  intersectionObserver.disconnect();
57
67
  break;
58
68
  }
@@ -60,20 +70,20 @@ export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRow
60
70
  });
61
71
  intersectionObserver.observe(textarea);
62
72
 
63
- if (typeof document !== "undefined" && document.fonts?.ready) {
64
- document.fonts.ready.then(() => {
65
- if (!cancelled) resize();
66
- }).catch(() => {});
67
- }
73
+ const unregisterFonts = registerFontsReady(schedule);
68
74
 
69
75
  return () => {
70
76
  cancelled = true;
71
- mutationObserver.disconnect();
72
- resizeObserver.disconnect();
73
77
  intersectionObserver.disconnect();
74
- textarea.removeEventListener("input", handleInput);
75
- cancelAnimationFrame(initialFrame);
76
- cancelAnimationFrame(frameRef.current ?? 0);
78
+ unregisterFonts();
79
+ if (frameRef.current) {
80
+ cancelAnimationFrame(frameRef.current);
81
+ frameRef.current = 0;
82
+ }
77
83
  };
78
- }, [textarea, multiline, minRows, maxRows]);
84
+ }, [textarea, enabled]);
85
+
86
+ return useCallback(() => {
87
+ onResizeRef.current();
88
+ }, []);
79
89
  }
@@ -630,7 +630,41 @@ export function blocksToMarkdown(blocks: CustomEditorBlock[]): string {
630
630
  }
631
631
 
632
632
  function parseInlineMarkdown(text: string): EditorInline[] {
633
- const cleaned = stripHtmlWrappers(text);
633
+ return parseInlineSegments(stripHtmlWrappers(text), {});
634
+ }
635
+
636
+ function findItalicClose(
637
+ text: string,
638
+ start: number,
639
+ marker: "*" | "_",
640
+ ): number {
641
+ let j = start;
642
+ while (j < text.length) {
643
+ const ch = text[j];
644
+ if (ch === "\\") {
645
+ j += 2;
646
+ continue;
647
+ }
648
+ if ((ch === "*" || ch === "_") && text[j + 1] === ch) {
649
+ const close = text.indexOf(ch + ch, j + 2);
650
+ if (close === -1) {
651
+ return -1;
652
+ }
653
+ j = close + 2;
654
+ continue;
655
+ }
656
+ if (ch === marker) {
657
+ return j;
658
+ }
659
+ j += 1;
660
+ }
661
+ return -1;
662
+ }
663
+
664
+ function parseInlineSegments(
665
+ cleaned: string,
666
+ outerStyles: Record<string, boolean>,
667
+ ): EditorInline[] {
634
668
  const result: EditorInline[] = [];
635
669
  let buffer = "";
636
670
 
@@ -638,22 +672,53 @@ function parseInlineMarkdown(text: string): EditorInline[] {
638
672
  if (buffer.length === 0) {
639
673
  return;
640
674
  }
641
- result.push({ type: "text", text: unescapeMarkdown(buffer), styles: {} });
675
+ result.push({
676
+ type: "text",
677
+ text: unescapeMarkdown(buffer),
678
+ styles: { ...outerStyles } as EditorStyles,
679
+ });
642
680
  buffer = "";
643
681
  };
644
682
 
683
+ const wrap = (inner: string, add: Record<string, boolean>) => {
684
+ pushPlain();
685
+ const nested = parseInlineSegments(inner, { ...outerStyles, ...add });
686
+ result.push(...nested);
687
+ };
688
+
645
689
  let i = 0;
646
690
  while (i < cleaned.length) {
691
+ if (cleaned.startsWith("***", i)) {
692
+ const end = cleaned.indexOf("***", i + 3);
693
+ if (end !== -1) {
694
+ wrap(cleaned.slice(i + 3, end), { bold: true, italic: true });
695
+ i = end + 3;
696
+ continue;
697
+ }
698
+ }
699
+
700
+ if (cleaned.startsWith("___", i)) {
701
+ const end = cleaned.indexOf("___", i + 3);
702
+ if (end !== -1) {
703
+ wrap(cleaned.slice(i + 3, end), { bold: true, italic: true });
704
+ i = end + 3;
705
+ continue;
706
+ }
707
+ }
708
+
647
709
  if (cleaned.startsWith("**", i)) {
648
710
  const end = cleaned.indexOf("**", i + 2);
649
711
  if (end !== -1) {
650
- pushPlain();
651
- const inner = cleaned.slice(i + 2, end);
652
- result.push({
653
- type: "text",
654
- text: unescapeMarkdown(inner),
655
- styles: { bold: true },
656
- });
712
+ wrap(cleaned.slice(i + 2, end), { bold: true });
713
+ i = end + 2;
714
+ continue;
715
+ }
716
+ }
717
+
718
+ if (cleaned.startsWith("__", i)) {
719
+ const end = cleaned.indexOf("__", i + 2);
720
+ if (end !== -1) {
721
+ wrap(cleaned.slice(i + 2, end), { bold: true });
657
722
  i = end + 2;
658
723
  continue;
659
724
  }
@@ -662,13 +727,7 @@ function parseInlineMarkdown(text: string): EditorInline[] {
662
727
  if (cleaned.startsWith("~~", i)) {
663
728
  const end = cleaned.indexOf("~~", i + 2);
664
729
  if (end !== -1) {
665
- pushPlain();
666
- const inner = cleaned.slice(i + 2, end);
667
- result.push({
668
- type: "text",
669
- text: unescapeMarkdown(inner),
670
- styles: { strike: true },
671
- });
730
+ wrap(cleaned.slice(i + 2, end), { strike: true });
672
731
  i = end + 2;
673
732
  continue;
674
733
  }
@@ -682,7 +741,7 @@ function parseInlineMarkdown(text: string): EditorInline[] {
682
741
  result.push({
683
742
  type: "text",
684
743
  text: unescapeMarkdown(inner),
685
- styles: { code: true },
744
+ styles: { ...outerStyles, code: true } as EditorStyles,
686
745
  });
687
746
  i = end + 1;
688
747
  continue;
@@ -697,7 +756,7 @@ function parseInlineMarkdown(text: string): EditorInline[] {
697
756
  pushPlain();
698
757
  const label = cleaned.slice(i + 1, endLabel);
699
758
  const href = cleaned.slice(startLink + 1, endLink);
700
- const parsedLabel = parseInlineMarkdown(label);
759
+ const parsedLabel = parseInlineSegments(label, {});
701
760
  // Ensure link content is never undefined - if empty, add empty text
702
761
  const linkContent = parsedLabel.length > 0 ? parsedLabel : [{ type: "text", text: "", styles: {} }];
703
762
  result.push({
@@ -711,16 +770,10 @@ function parseInlineMarkdown(text: string): EditorInline[] {
711
770
  }
712
771
 
713
772
  if (cleaned[i] === "*" || cleaned[i] === "_") {
714
- const marker = cleaned[i];
715
- const end = cleaned.indexOf(marker, i + 1);
773
+ const marker = cleaned[i] as "*" | "_";
774
+ const end = findItalicClose(cleaned, i + 1, marker);
716
775
  if (end !== -1) {
717
- pushPlain();
718
- const inner = cleaned.slice(i + 1, end);
719
- result.push({
720
- type: "text",
721
- text: unescapeMarkdown(inner),
722
- styles: { italic: true },
723
- });
776
+ wrap(cleaned.slice(i + 1, end), { italic: true });
724
777
  i = end + 1;
725
778
  continue;
726
779
  }
@@ -130,4 +130,43 @@ describe("markdownToBlocks", () => {
130
130
  children: [],
131
131
  });
132
132
  });
133
+
134
+ it("parses combined bold+italic using nested delimiters", () => {
135
+ const blocks = markdownToBlocks(
136
+ "The _**Username**_ and **_Password_** fields and ***both*** and ___both___.",
137
+ );
138
+
139
+ expect(blocks).toHaveLength(1);
140
+ expect(blocks[0]).toEqual({
141
+ type: "paragraph",
142
+ props: baseProps,
143
+ content: [
144
+ { type: "text", text: "The ", styles: {} },
145
+ { type: "text", text: "Username", styles: { italic: true, bold: true } },
146
+ { type: "text", text: " and ", styles: {} },
147
+ { type: "text", text: "Password", styles: { bold: true, italic: true } },
148
+ { type: "text", text: " fields and ", styles: {} },
149
+ { type: "text", text: "both", styles: { bold: true, italic: true } },
150
+ { type: "text", text: " and ", styles: {} },
151
+ { type: "text", text: "both", styles: { bold: true, italic: true } },
152
+ { type: "text", text: ".", styles: {} },
153
+ ],
154
+ children: [],
155
+ });
156
+ });
157
+
158
+ it("parses bold with nested italic keeping both styles", () => {
159
+ const blocks = markdownToBlocks("**foo _bar_ baz**");
160
+
161
+ expect(blocks[0]).toEqual({
162
+ type: "paragraph",
163
+ props: baseProps,
164
+ content: [
165
+ { type: "text", text: "foo ", styles: { bold: true } },
166
+ { type: "text", text: "bar", styles: { bold: true, italic: true } },
167
+ { type: "text", text: " baz", styles: { bold: true } },
168
+ ],
169
+ children: [],
170
+ });
171
+ });
133
172
  });