testomatio-editor-blocks 0.4.52 → 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,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
 
@@ -83,6 +83,31 @@ export type LinkMeta = { start: number; end: number; url: string };
83
83
  export type FormattingMeta = { start: number; end: number; type: "bold" | "italic" | "code" };
84
84
  type FormatType = "bold" | "italic" | "code";
85
85
 
86
+ /**
87
+ * Remove formatting and link entries that conflict with applying `fmtType`
88
+ * over the half-open range [start, end). Exclusion rules:
89
+ * - code: exclusive with everything — drops any overlapping formatting and links
90
+ * - bold/italic: coexist with each other, but exclusive with code and links
91
+ * — drops overlapping same-type, overlapping code, and overlapping links
92
+ */
93
+ export function applyInlineExclusion(
94
+ formatting: FormattingMeta[],
95
+ links: LinkMeta[],
96
+ start: number,
97
+ end: number,
98
+ fmtType: FormatType,
99
+ ): { formatting: FormattingMeta[]; links: LinkMeta[] } {
100
+ const overlaps = (a: { start: number; end: number }) => !(a.start >= end || a.end <= start);
101
+ const nextFormatting = formatting.filter((f) => {
102
+ if (!overlaps(f)) return true;
103
+ if (fmtType === "code") return false;
104
+ if (f.type === "code") return false;
105
+ return f.type !== fmtType;
106
+ });
107
+ const nextLinks = links.filter((l) => !overlaps(l));
108
+ return { formatting: nextFormatting, links: nextLinks };
109
+ }
110
+
86
111
  type EditorSnapshot = {
87
112
  text: string;
88
113
  formatting: FormattingMeta[];
@@ -920,6 +945,15 @@ export function StepField({
920
945
  textareaNode.focus();
921
946
  }, [focusSignal, textareaNode]);
922
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
+
923
957
  useEffect(() => {
924
958
  const instance = editorInstanceRef.current;
925
959
  if (!instance) {
@@ -987,13 +1021,6 @@ export function StepField({
987
1021
  textareaNode.readOnly = readOnly;
988
1022
  }, [readOnly, textareaNode]);
989
1023
 
990
- useAutoResize({
991
- textarea: textareaNode,
992
- multiline,
993
- minRows: 3,
994
- maxRows: 16,
995
- });
996
-
997
1024
  useEffect(() => {
998
1025
  if (!textareaNode) {
999
1026
  return;
@@ -1188,14 +1215,15 @@ export function StepField({
1188
1215
  // Remove formatting
1189
1216
  formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
1190
1217
  } else if (start !== end) {
1191
- // Remove overlapping formatting:
1192
- // - Code: remove ALL overlapping formatting (code replaces bold/italic)
1193
- // - Bold/Italic: remove only overlapping formatting of the SAME type
1194
- formattingRef.current = formattingRef.current.filter(
1195
- (f) => f.start >= end || f.end <= start || (fmtType !== "code" && f.type !== fmtType),
1218
+ const cleaned = applyInlineExclusion(
1219
+ formattingRef.current,
1220
+ linksRef.current,
1221
+ start,
1222
+ end,
1223
+ fmtType,
1196
1224
  );
1197
- // Add formatting for selection
1198
- formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
1225
+ formattingRef.current = [...cleaned.formatting, { start, end, type: fmtType }];
1226
+ linksRef.current = cleaned.links;
1199
1227
  } else {
1200
1228
  // No selection — nothing to format
1201
1229
  return;
@@ -1314,7 +1342,12 @@ export function StepField({
1314
1342
  );
1315
1343
  const newLink: LinkMeta = { start: sel.start, end: sel.start + linkText.length, url };
1316
1344
  linksRef.current = [...adjustedLinks, newLink];
1317
- formattingRef.current = adjustFormattingForEdit(formattingRef.current, sel.start, delta);
1345
+ // Links are exclusive with bold/italic/code: strip any formatting that
1346
+ // overlaps the original selection before shifting positions.
1347
+ const keptFormatting = formattingRef.current.filter(
1348
+ (f) => f.start >= sel.end || f.end <= sel.start,
1349
+ );
1350
+ formattingRef.current = adjustFormattingForEdit(keptFormatting, sel.start, delta);
1318
1351
  prevTextRef.current = nextValue;
1319
1352
 
1320
1353
  isSyncingRef.current = true;
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { buildFullMarkdown, type FormattingMeta, type LinkMeta } from "./stepField";
2
+ import {
3
+ applyInlineExclusion,
4
+ buildFullMarkdown,
5
+ type FormattingMeta,
6
+ type LinkMeta,
7
+ } from "./stepField";
3
8
 
4
9
  describe("buildFullMarkdown formatting combinations", () => {
5
10
  const noLinks: LinkMeta[] = [];
@@ -42,3 +47,59 @@ describe("buildFullMarkdown formatting combinations", () => {
42
47
  expect(buildFullMarkdown("hello", noLinks, formatting)).toBe("`hello`");
43
48
  });
44
49
  });
50
+
51
+ describe("applyInlineExclusion mutual-exclusion rules", () => {
52
+ it("applying code strips overlapping bold/italic", () => {
53
+ const formatting: FormattingMeta[] = [
54
+ { start: 0, end: 5, type: "bold" },
55
+ { start: 0, end: 5, type: "italic" },
56
+ ];
57
+ const result = applyInlineExclusion(formatting, [], 0, 5, "code");
58
+ expect(result.formatting).toEqual([]);
59
+ expect(result.links).toEqual([]);
60
+ });
61
+
62
+ it("applying code strips overlapping links", () => {
63
+ const links: LinkMeta[] = [{ start: 0, end: 5, url: "https://a" }];
64
+ const result = applyInlineExclusion([], links, 0, 5, "code");
65
+ expect(result.links).toEqual([]);
66
+ });
67
+
68
+ it("applying bold over a code range strips the code", () => {
69
+ const formatting: FormattingMeta[] = [{ start: 0, end: 5, type: "code" }];
70
+ const result = applyInlineExclusion(formatting, [], 0, 5, "bold");
71
+ expect(result.formatting).toEqual([]);
72
+ });
73
+
74
+ it("applying italic over a linked range strips the link", () => {
75
+ const links: LinkMeta[] = [{ start: 0, end: 5, url: "https://a" }];
76
+ const result = applyInlineExclusion([], links, 0, 5, "italic");
77
+ expect(result.links).toEqual([]);
78
+ });
79
+
80
+ it("applying bold preserves non-overlapping italic and non-overlapping links", () => {
81
+ const formatting: FormattingMeta[] = [
82
+ { start: 10, end: 20, type: "italic" },
83
+ ];
84
+ const links: LinkMeta[] = [{ start: 30, end: 40, url: "https://a" }];
85
+ const result = applyInlineExclusion(formatting, links, 0, 5, "bold");
86
+ expect(result.formatting).toEqual(formatting);
87
+ expect(result.links).toEqual(links);
88
+ });
89
+
90
+ it("applying bold preserves overlapping italic (bold and italic coexist)", () => {
91
+ const formatting: FormattingMeta[] = [
92
+ { start: 0, end: 10, type: "italic" },
93
+ ];
94
+ const result = applyInlineExclusion(formatting, [], 0, 10, "bold");
95
+ expect(result.formatting).toEqual(formatting);
96
+ });
97
+
98
+ it("applying italic drops overlapping italic (same-type replacement)", () => {
99
+ const formatting: FormattingMeta[] = [
100
+ { start: 0, end: 10, type: "italic" },
101
+ ];
102
+ const result = applyInlineExclusion(formatting, [], 2, 8, "italic");
103
+ expect(result.formatting).toEqual([]);
104
+ });
105
+ });
@@ -1,46 +1,89 @@
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) {
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;
13
+
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
+
32
+ export function useAutoResize({ textarea, enabled = true, onResize }: Options) {
11
33
  const frameRef = useRef<number>(0);
34
+ const onResizeRef = useRef(onResize);
12
35
 
13
36
  useEffect(() => {
14
- if (!textarea || !multiline) {
15
- return;
16
- }
17
-
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
- };
37
+ onResizeRef.current = onResize;
38
+ }, [onResize]);
28
39
 
29
- const observer = new MutationObserver(resize);
30
- observer.observe(textarea, { childList: true, characterData: true, subtree: true });
40
+ useEffect(() => {
41
+ if (!textarea || !enabled) return;
31
42
 
32
- const handleInput = () => {
33
- cancelAnimationFrame(frameRef.current ?? 0);
34
- frameRef.current = requestAnimationFrame(resize);
43
+ let cancelled = false;
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;
50
+ frameRef.current = requestAnimationFrame(() => {
51
+ frameRef.current = 0;
52
+ if (!cancelled) onResizeRef.current();
53
+ });
35
54
  };
36
55
 
37
- textarea.addEventListener("input", handleInput);
38
- resize();
56
+ // Initial pass after layout.
57
+ schedule();
58
+
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.
62
+ const intersectionObserver = new IntersectionObserver((entries) => {
63
+ for (const entry of entries) {
64
+ if (entry.isIntersecting) {
65
+ schedule();
66
+ intersectionObserver.disconnect();
67
+ break;
68
+ }
69
+ }
70
+ });
71
+ intersectionObserver.observe(textarea);
72
+
73
+ const unregisterFonts = registerFontsReady(schedule);
39
74
 
40
75
  return () => {
41
- observer.disconnect();
42
- textarea.removeEventListener("input", handleInput);
43
- cancelAnimationFrame(frameRef.current ?? 0);
76
+ cancelled = true;
77
+ intersectionObserver.disconnect();
78
+ unregisterFonts();
79
+ if (frameRef.current) {
80
+ cancelAnimationFrame(frameRef.current);
81
+ frameRef.current = 0;
82
+ }
44
83
  };
45
- }, [textarea, multiline, minRows, maxRows]);
84
+ }, [textarea, enabled]);
85
+
86
+ return useCallback(() => {
87
+ onResizeRef.current();
88
+ }, []);
46
89
  }
@@ -197,9 +197,9 @@ describe("blocksToMarkdown", () => {
197
197
  expect(blocksToMarkdown(blocks)).toBe(
198
198
  [
199
199
  "* Open the Login page.",
200
- " *Expected*: The Login page loads successfully.",
200
+ " *Expected result*: The Login page loads successfully.",
201
201
  "* Enter a valid username.",
202
- " *Expected*: The username is accepted.",
202
+ " *Expected result*: The username is accepted.",
203
203
  ].join("\n"),
204
204
  );
205
205
  });
@@ -242,7 +242,7 @@ describe("blocksToMarkdown", () => {
242
242
  ];
243
243
 
244
244
  expect(blocksToMarkdown(blocks)).toBe(
245
- ["* ", " *Expected*: Login form visible"].join("\n"),
245
+ ["* ", " *Expected result*: Login form visible"].join("\n"),
246
246
  );
247
247
  });
248
248
 
@@ -355,7 +355,7 @@ describe("blocksToMarkdown", () => {
355
355
  expect(blocksToMarkdown(blocks)).toBe(
356
356
  [
357
357
  "* **Click** the _Login_ button",
358
- " *Expected*: **Success** is shown",
358
+ " *Expected result*: **Success** is shown",
359
359
  " Second line with <u>underline</u>",
360
360
  ].join("\n"),
361
361
  );
@@ -382,7 +382,7 @@ describe("blocksToMarkdown", () => {
382
382
  "* Navigate to login",
383
383
  " Open browser",
384
384
  " Go to login page",
385
- " *Expected*: Login form visible",
385
+ " *Expected result*: Login form visible",
386
386
  ].join("\n"),
387
387
  );
388
388
  });
@@ -429,7 +429,7 @@ describe("blocksToMarkdown", () => {
429
429
  " asdsadas",
430
430
  " ```",
431
431
  " ![](/attachments/HMhkVtlDrO.png)",
432
- " *Expected*: The user receives a real-time notification for the order update.",
432
+ " *Expected result*: The user receives a real-time notification for the order update.",
433
433
  ].join("\n"),
434
434
  );
435
435
  });
@@ -1073,7 +1073,7 @@ describe("markdownToBlocks", () => {
1073
1073
  " asdsadas",
1074
1074
  " ```",
1075
1075
  " ![](/attachments/HMhkVtlDrO.png)",
1076
- " *Expected*: The user receives a real-time notification for the order update.",
1076
+ " *Expected result*: The user receives a real-time notification for the order update.",
1077
1077
  ].join("\n"),
1078
1078
  );
1079
1079
  });
@@ -1307,7 +1307,7 @@ describe("markdownToBlocks", () => {
1307
1307
  expect(markdownRoundTrip).toBe(
1308
1308
  [
1309
1309
  "* Display the generated report.",
1310
- " *Expected*: ![](/attachments/report.png)",
1310
+ " *Expected result*: ![](/attachments/report.png)",
1311
1311
  ].join("\n"),
1312
1312
  );
1313
1313
  });
@@ -1354,7 +1354,7 @@ describe("markdownToBlocks", () => {
1354
1354
  expect(roundTrip).toBe(
1355
1355
  [
1356
1356
  "* Should open login screen",
1357
- " *Expected*: Login should look like this",
1357
+ " *Expected result*: Login should look like this",
1358
1358
  " ![](/login.png)",
1359
1359
  ].join("\n"),
1360
1360
  );
@@ -1493,9 +1493,9 @@ describe("markdownToBlocks", () => {
1493
1493
  expect(roundTrip).toBe(
1494
1494
  [
1495
1495
  "* Existing email + invalid password",
1496
- " *Expected*: 'Oops, wrong email or password' is displayed",
1496
+ " *Expected result*: 'Oops, wrong email or password' is displayed",
1497
1497
  "* Not existing email + valid password",
1498
- " *Expected*: 'Oops, wrong email or password' is displayed",
1498
+ " *Expected result*: 'Oops, wrong email or password' is displayed",
1499
1499
  ].join("\n"),
1500
1500
  );
1501
1501
  });
@@ -2667,6 +2667,27 @@ describe("blank line <-> empty paragraph mapping", () => {
2667
2667
  expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2668
2668
  });
2669
2669
 
2670
+ it("preserves a blank line between a bullet list and the next heading", () => {
2671
+ const markdown = [
2672
+ "### Requirements",
2673
+ "* first requirement",
2674
+ "* second requirement",
2675
+ "",
2676
+ "### Steps",
2677
+ "",
2678
+ "* do the thing",
2679
+ " *Expected result*: it works",
2680
+ ].join("\n");
2681
+ const blocks = markdownToBlocks(markdown) as CustomEditorBlock[];
2682
+ const types = blocks.map((b) => b.type);
2683
+ // Blank line between the last bullet and the next heading must survive
2684
+ // parseList and become an empty paragraph.
2685
+ const lastBulletIdx = types.lastIndexOf("bulletListItem");
2686
+ expect(blocks[lastBulletIdx + 1].type).toBe("paragraph");
2687
+ expect((blocks[lastBulletIdx + 1].content as any[]).length).toBe(0);
2688
+ expect(blocksToMarkdown(blocks)).toBe(markdown);
2689
+ });
2690
+
2670
2691
  it("preserves blank lines across the user-reported screenshot example", () => {
2671
2692
  const markdown = [
2672
2693
  "### Requirements",