@jackuait/blok 0.10.0-beta.15 → 0.10.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/full.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { n as e, t } from "./chunks/blok-1213fGsk.mjs";
2
- import { $n as n } from "./chunks/constants-Cr7GEExc.mjs";
1
+ import { n as e, t } from "./chunks/blok-CEVrVqlx.mjs";
2
+ import { $n as n } from "./chunks/constants-BURnHRy_.mjs";
3
3
  import { t as r } from "./chunks/objectSpread2-CWwMYL_U.mjs";
4
- import { _ as i, a, c as o, g as s, i as c, l, m as u, n as d, o as f, s as p, t as m, v as h } from "./chunks/tools-DBEfU2dP.mjs";
4
+ import { _ as i, a, c as o, g as s, i as c, l, m as u, n as d, o as f, s as p, t as m, v as h } from "./chunks/tools-Dt2I14vP.mjs";
5
5
  //#region src/full.ts
6
6
  var g = {
7
7
  paragraph: {
package/dist/react.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { t as e } from "./chunks/blok-1213fGsk.mjs";
2
- import "./chunks/constants-Cr7GEExc.mjs";
1
+ import { t as e } from "./chunks/blok-CEVrVqlx.mjs";
2
+ import "./chunks/constants-BURnHRy_.mjs";
3
3
  import { t } from "./chunks/objectSpread2-CWwMYL_U.mjs";
4
4
  import { t as n } from "./chunks/objectWithoutProperties-D0XxKB4n.mjs";
5
5
  import { forwardRef as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
package/dist/tools.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { m as e } from "./chunks/constants-Cr7GEExc.mjs";
2
- import { _ as t, a as n, c as r, d as i, f as a, g as o, h as s, i as c, l, m as u, n as d, o as f, p, r as m, s as h, t as g, u as _, v } from "./chunks/tools-DBEfU2dP.mjs";
1
+ import { m as e } from "./chunks/constants-BURnHRy_.mjs";
2
+ import { _ as t, a as n, c as r, d as i, f as a, g as o, h as s, i as c, l, m as u, n as d, o as f, p, r as m, s as h, t as g, u as _, v } from "./chunks/tools-Dt2I14vP.mjs";
3
3
  export { l as Bold, p as Callout, _ as Code, e as Convert, a as Divider, t as Header, m as InlineCode, r as Italic, h as Link, o as List, f as Marker, v as Paragraph, i as Quote, c as Strikethrough, s as Table, u as Toggle, n as Underline, g as defaultBlockTools, d as defaultInlineTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackuait/blok",
3
- "version": "0.10.0-beta.15",
3
+ "version": "0.10.0-beta.16",
4
4
  "description": "Blok — headless, highly extensible rich text editor built for developers who need to implement a block-based editing experience (similar to Notion) without building it from scratch",
5
5
  "module": "dist/blok.mjs",
6
6
  "types": "./types/index.d.ts",
@@ -50,16 +50,13 @@
50
50
  }
51
51
  },
52
52
  "bin": {
53
- "blok": "./bin/blok.mjs",
54
- "blok-convert-html": "./bin/convert-html.mjs",
55
53
  "migrate-from-editorjs": "./codemod/migrate-editorjs-to-blok.js"
56
54
  },
57
55
  "files": [
58
56
  "dist",
59
57
  "types",
60
58
  "src",
61
- "codemod",
62
- "bin"
59
+ "codemod"
63
60
  ],
64
61
  "publishConfig": {
65
62
  "access": "public"
@@ -84,7 +81,7 @@
84
81
  "scripts": {
85
82
  "serve": "vite --no-open",
86
83
  "serve:docs": "cd docs && vite --port 8080",
87
- "build": "vite build --mode production && node scripts/build-locales.mjs && node scripts/build-cli.mjs && node scripts/build-convert-html.mjs",
84
+ "build": "vite build --mode production && node scripts/build-locales.mjs",
88
85
  "build:cli": "node scripts/build-cli.mjs",
89
86
  "build:test": "vite build --mode test && node scripts/build-locales.mjs test && node scripts/build-react-vendor.mjs",
90
87
  "lint": "sh -c 'eslint .; ESLINT_EXIT=$?; tsc --noEmit; TSC_EXIT=$?; if [ $ESLINT_EXIT -ne 0 ] || [ $TSC_EXIT -ne 0 ]; then exit 1; fi'",
@@ -0,0 +1,24 @@
1
+ import { preprocessGoogleDocsHtml } from '../../../components/modules/paste/google-docs-preprocessor';
2
+ import { preprocess } from '../convert-html/preprocessor';
3
+ import { sanitize } from '../convert-html/sanitizer';
4
+ import { buildBlocks } from '../convert-html/block-builder';
5
+ import type { OutputData } from '../convert-html/types';
6
+
7
+ /**
8
+ * Convert Google Docs HTML to Blok JSON.
9
+ * Runs: Google Docs preprocess -> general preprocess -> sanitize -> build blocks -> serialize.
10
+ */
11
+ export function convertGdocs(html: string): string {
12
+ const preprocessed = preprocessGoogleDocsHtml(html);
13
+
14
+ const dom = new DOMParser().parseFromString(preprocessed, 'text/html');
15
+ const wrapper = dom.body;
16
+
17
+ preprocess(wrapper);
18
+ sanitize(wrapper);
19
+
20
+ const blocks = buildBlocks(wrapper);
21
+ const output: OutputData = { version: '2.31.0', blocks };
22
+
23
+ return JSON.stringify(output);
24
+ }
@@ -85,16 +85,7 @@ function convertNode(
85
85
 
86
86
  if (tag === 'IMG') {
87
87
  const src = el.getAttribute('src') ?? '';
88
- const widthStyle = parseCssProperty(el, 'width');
89
- let width: number | null = null;
90
-
91
- if (widthStyle) {
92
- const parsed = parseInt(widthStyle, 10);
93
-
94
- if (!isNaN(parsed)) {
95
- width = parsed;
96
- }
97
- }
88
+ const width = parseIntFromStyle(el, 'width');
98
89
 
99
90
  blocks.push({
100
91
  id: nextId('image'),
@@ -156,13 +147,9 @@ function flattenList(
156
147
  ): void {
157
148
  const startAttr = listEl.getAttribute('start');
158
149
  const startValue = startAttr ? Number(startAttr) : null;
159
- let isFirstItem = true;
160
-
161
- for (const child of Array.from(listEl.children)) {
162
- if (child.tagName !== 'LI') {
163
- continue;
164
- }
150
+ const listItems = Array.from(listEl.children).filter((child) => child.tagName === 'LI');
165
151
 
152
+ for (const [index, child] of listItems.entries()) {
166
153
  // Clone the li so we can remove nested lists without mutating DOM
167
154
  const clone = child.cloneNode(true) as HTMLElement;
168
155
  const nestedLists: HTMLElement[] = [];
@@ -176,11 +163,9 @@ function flattenList(
176
163
 
177
164
  // Use aria-level if present (1-based → 0-based), otherwise use nesting depth
178
165
  const ariaLevel = (child as HTMLElement).getAttribute('aria-level');
179
- let itemDepth = depth;
180
-
181
- if (ariaLevel) {
182
- itemDepth = Math.max(0, parseInt(ariaLevel, 10) - 1);
183
- }
166
+ const itemDepth = ariaLevel
167
+ ? Math.max(0, parseInt(ariaLevel, 10) - 1)
168
+ : depth;
184
169
 
185
170
  blocks.push({
186
171
  id: nextId('list'),
@@ -190,12 +175,10 @@ function flattenList(
190
175
  style,
191
176
  depth: itemDepth === 0 ? null : itemDepth,
192
177
  checked: null,
193
- start: isFirstItem && startValue !== null ? startValue : null,
178
+ start: index === 0 && startValue !== null ? startValue : null,
194
179
  },
195
180
  });
196
181
 
197
- isFirstItem = false;
198
-
199
182
  // Recursively process nested lists
200
183
  for (const nested of nestedLists) {
201
184
  const nestedStyle = nested.tagName === 'OL' ? 'ordered' : 'unordered';
@@ -216,53 +199,18 @@ function convertTable(
216
199
  ): void {
217
200
  const tableId = nextId('table');
218
201
  const rows = Array.from(tableEl.querySelectorAll('tr'));
219
-
220
- let withHeadings = false;
221
202
  const content: Record<string, unknown>[][] = [];
222
203
 
223
- for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
224
- const row = rows[rowIdx];
204
+ for (const row of rows) {
225
205
  const cells = Array.from(row.querySelectorAll('td, th'));
226
-
227
- if (rowIdx === 0 && cells.some((c) => c.tagName === 'TH')) {
228
- withHeadings = true;
229
- }
230
-
231
- const rowData: Record<string, unknown>[] = [];
232
-
233
- for (const cell of cells) {
234
- const cellEl = cell as HTMLElement;
235
- const cellText = cellEl.innerHTML.trim();
236
-
237
- if (cellText) {
238
- const childId = nextId('paragraph');
239
-
240
- blocks.push({
241
- id: childId,
242
- type: 'paragraph',
243
- parent: tableId,
244
- data: { text: cellText },
245
- });
246
-
247
- // Parse cell colors
248
- const bgColor = parseCssProperty(cellEl, 'background-color');
249
- const textColor = parseCssProperty(cellEl, 'color');
250
-
251
- rowData.push({
252
- blocks: [childId],
253
- color: bgColor ? mapToNearestPresetName(bgColor, 'bg') : null,
254
- textColor: textColor ? mapToNearestPresetName(textColor, 'text') : null,
255
- });
256
- } else {
257
- rowData.push({ blocks: [], color: null, textColor: null });
258
- }
259
- }
206
+ const rowData = cells.map((cell) => convertTableCell(cell as HTMLElement, tableId, blocks, nextId));
260
207
 
261
208
  content.push(rowData);
262
209
  }
263
210
 
264
- // Parse column widths from first row cells
211
+ // Parse column widths and headings from first row cells
265
212
  const firstRowCells = rows[0] ? Array.from(rows[0].querySelectorAll('td, th')) : [];
213
+ const withHeadings = firstRowCells.some((c) => c.tagName === 'TH');
266
214
  const colWidths = firstRowCells.map((cell) => {
267
215
  const width = parseCssProperty(cell as HTMLElement, 'width');
268
216
 
@@ -314,33 +262,10 @@ function convertCallout(
314
262
  const childIds: string[] = [];
315
263
 
316
264
  for (const child of Array.from(asideEl.childNodes)) {
317
- if (child.nodeType === Node.ELEMENT_NODE) {
318
- const childEl = child as HTMLElement;
319
- const childId = nextId('paragraph');
320
-
321
- blocks.push({
322
- id: childId,
323
- type: 'paragraph',
324
- parent: calloutId,
325
- data: { text: childEl.innerHTML },
326
- });
265
+ const childId = convertCalloutChild(child, calloutId, blocks, nextId);
327
266
 
267
+ if (childId) {
328
268
  childIds.push(childId);
329
- } else if (child.nodeType === Node.TEXT_NODE) {
330
- const text = child.textContent?.trim() ?? '';
331
-
332
- if (text) {
333
- const childId = nextId('paragraph');
334
-
335
- blocks.push({
336
- id: childId,
337
- type: 'paragraph',
338
- parent: calloutId,
339
- data: { text },
340
- });
341
-
342
- childIds.push(childId);
343
- }
344
269
  }
345
270
  }
346
271
 
@@ -368,6 +293,91 @@ function convertCallout(
368
293
  // Helpers
369
294
  // ---------------------------------------------------------------------------
370
295
 
296
+ function convertTableCell(
297
+ cellEl: HTMLElement,
298
+ tableId: string,
299
+ blocks: OutputBlockData[],
300
+ nextId: (prefix: string) => string
301
+ ): Record<string, unknown> {
302
+ const cellText = cellEl.innerHTML.trim();
303
+
304
+ if (!cellText) {
305
+ return { blocks: [], color: null, textColor: null };
306
+ }
307
+
308
+ const childId = nextId('paragraph');
309
+
310
+ blocks.push({
311
+ id: childId,
312
+ type: 'paragraph',
313
+ parent: tableId,
314
+ data: { text: cellText },
315
+ });
316
+
317
+ const bgColor = parseCssProperty(cellEl, 'background-color');
318
+ const textColor = parseCssProperty(cellEl, 'color');
319
+
320
+ return {
321
+ blocks: [childId],
322
+ color: bgColor ? mapToNearestPresetName(bgColor, 'bg') : null,
323
+ textColor: textColor ? mapToNearestPresetName(textColor, 'text') : null,
324
+ };
325
+ }
326
+
327
+ function convertCalloutChild(
328
+ child: ChildNode,
329
+ calloutId: string,
330
+ blocks: OutputBlockData[],
331
+ nextId: (prefix: string) => string
332
+ ): string | null {
333
+ if (child.nodeType === Node.ELEMENT_NODE) {
334
+ const childEl = child as HTMLElement;
335
+ const childId = nextId('paragraph');
336
+
337
+ blocks.push({
338
+ id: childId,
339
+ type: 'paragraph',
340
+ parent: calloutId,
341
+ data: { text: childEl.innerHTML },
342
+ });
343
+
344
+ return childId;
345
+ }
346
+
347
+ if (child.nodeType === Node.TEXT_NODE) {
348
+ const text = child.textContent?.trim() ?? '';
349
+
350
+ if (!text) {
351
+ return null;
352
+ }
353
+
354
+ const childId = nextId('paragraph');
355
+
356
+ blocks.push({
357
+ id: childId,
358
+ type: 'paragraph',
359
+ parent: calloutId,
360
+ data: { text },
361
+ });
362
+
363
+ return childId;
364
+ }
365
+
366
+ return null;
367
+ }
368
+
369
+ function parseIntFromStyle(el: HTMLElement, property: string): number | null {
370
+ const value = parseCssProperty(el, property);
371
+
372
+ if (!value) {
373
+ return null;
374
+ }
375
+
376
+ const parsed = parseInt(value, 10);
377
+
378
+ return isNaN(parsed) ? null : parsed;
379
+ }
380
+
371
381
  function parseCssProperty(el: HTMLElement, property: string): string | null {
372
382
  const style = el.getAttribute('style');
373
383
 
@@ -49,26 +49,45 @@ function convertBackgroundDivsToCallouts(wrapper: HTMLElement): void {
49
49
 
50
50
  // Unwrap non-semantic <div> wrappers so the aside's direct children are
51
51
  // the content elements (<p>, <a>, etc.), not intermediate <div> shells.
52
- let bareDivs: HTMLElement[];
53
-
54
- while ((bareDivs = Array.from(aside.querySelectorAll<HTMLElement>(':scope > div'))
55
- .filter((d) => !d.getAttribute('style') && !d.getAttribute('class'))).length > 0) {
56
- for (const child of bareDivs) {
57
- child.replaceWith(...Array.from(child.childNodes));
58
- }
59
- }
52
+ unwrapBareDivs(aside);
60
53
 
61
54
  // Strip trailing <br> inside paragraphs — the paste handler splits
62
55
  // content at <br> boundaries, so a trailing one creates an empty block.
63
- for (const p of Array.from(aside.querySelectorAll('p'))) {
64
- const lastChild = p.lastElementChild;
56
+ stripTrailingBrInParagraphs(aside);
57
+
58
+ div.replaceWith(aside);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Repeatedly unwrap non-semantic `<div>` wrappers (no style or class) that are
64
+ * direct children of the given element, replacing them with their child nodes.
65
+ */
66
+ function unwrapBareDivs(parent: HTMLElement): void {
67
+ for (;;) {
68
+ const bareDivs = Array.from(parent.querySelectorAll<HTMLElement>(':scope > div'))
69
+ .filter((d) => !d.getAttribute('style') && !d.getAttribute('class'));
65
70
 
66
- if (lastChild?.tagName === 'BR') {
67
- lastChild.remove();
68
- }
71
+ if (bareDivs.length === 0) {
72
+ break;
69
73
  }
70
74
 
71
- div.replaceWith(aside);
75
+ for (const child of bareDivs) {
76
+ child.replaceWith(...Array.from(child.childNodes));
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Remove trailing `<br>` elements from paragraphs inside the given element.
83
+ */
84
+ function stripTrailingBrInParagraphs(parent: HTMLElement): void {
85
+ for (const p of Array.from(parent.querySelectorAll('p'))) {
86
+ const lastChild = p.lastElementChild;
87
+
88
+ if (lastChild?.tagName === 'BR') {
89
+ lastChild.remove();
90
+ }
72
91
  }
73
92
  }
74
93
 
@@ -89,16 +108,18 @@ function stripSpuriousBackgroundColors(wrapper: HTMLElement): void {
89
108
  const candidates = wrapper.querySelectorAll<HTMLElement>('[style*="background-color"]');
90
109
 
91
110
  for (const el of Array.from(candidates)) {
92
- if (isSpuriousBackgroundColor(el.style.backgroundColor)) {
93
- el.style.removeProperty('background-color');
111
+ if (!isSpuriousBackgroundColor(el.style.backgroundColor)) {
112
+ continue;
113
+ }
94
114
 
95
- if (el.getAttribute('style')?.trim() === '') {
96
- el.removeAttribute('style');
97
- }
115
+ el.style.removeProperty('background-color');
116
+
117
+ if (el.getAttribute('style')?.trim() === '') {
118
+ el.removeAttribute('style');
119
+ }
98
120
 
99
- if (isEmptyWrapper(el)) {
100
- el.replaceWith(...Array.from(el.childNodes));
101
- }
121
+ if (isEmptyWrapper(el)) {
122
+ el.replaceWith(...Array.from(el.childNodes));
102
123
  }
103
124
  }
104
125
  }
@@ -187,8 +208,6 @@ function getBackgroundColor(el: HTMLElement): string {
187
208
  * Only targets `<td>` and `<th>` — top-level `<p>` tags are left intact.
188
209
  */
189
210
  function convertTableCellParagraphs(wrapper: HTMLElement): void {
190
- const doc = wrapper.ownerDocument;
191
-
192
211
  for (const cell of Array.from(wrapper.querySelectorAll('td, th'))) {
193
212
  const paragraphs = cell.querySelectorAll('p');
194
213
 
@@ -197,22 +216,32 @@ function convertTableCellParagraphs(wrapper: HTMLElement): void {
197
216
  }
198
217
 
199
218
  for (const p of Array.from(paragraphs)) {
200
- if (p.innerHTML.trim() === '' || p.innerHTML.trim() === '&nbsp;') {
201
- p.remove();
202
- continue;
203
- }
204
-
205
- const fragment = doc.createDocumentFragment();
206
-
207
- fragment.append(...Array.from(p.childNodes));
208
- fragment.append(doc.createElement('br'));
209
- p.replaceWith(fragment);
219
+ replaceParagraphWithBr(p);
210
220
  }
211
221
 
212
222
  stripTrailingBreaks(cell);
213
223
  }
214
224
  }
215
225
 
226
+ /**
227
+ * Replace a `<p>` element with its child nodes followed by a `<br>`,
228
+ * or remove it entirely if it is empty / nbsp-only.
229
+ */
230
+ function replaceParagraphWithBr(p: HTMLParagraphElement): void {
231
+ if (p.innerHTML.trim() === '' || p.innerHTML.trim() === '&nbsp;') {
232
+ p.remove();
233
+
234
+ return;
235
+ }
236
+
237
+ const doc = p.ownerDocument;
238
+ const fragment = doc.createDocumentFragment();
239
+
240
+ fragment.append(...Array.from(p.childNodes));
241
+ fragment.append(doc.createElement('br'));
242
+ p.replaceWith(fragment);
243
+ }
244
+
216
245
  /**
217
246
  * Remove paragraphs whose only content is non-breaking spaces or whitespace.
218
247
  */
@@ -261,8 +290,49 @@ const BULLET_PREFIX = /^[\u2022\u00B7][\s\u00A0]*|^-\s/;
261
290
  */
262
291
  function convertBulletParagraphsToLists(wrapper: HTMLElement): void {
263
292
  const doc = wrapper.ownerDocument;
293
+
294
+ // Collect runs of consecutive bullet paragraphs. Each run is a group
295
+ // that will become a single <ul>.
296
+ const groups = collectBulletGroups(wrapper);
297
+
298
+ for (const group of groups) {
299
+ const ul = doc.createElement('ul');
300
+
301
+ group[0].before(ul);
302
+
303
+ for (const p of group) {
304
+ convertBulletParagraphToListItem(p, ul);
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Strip the bullet prefix from a paragraph and append its content as
311
+ * a `<li>` to the given list.
312
+ */
313
+ function convertBulletParagraphToListItem(p: HTMLParagraphElement, ul: HTMLUListElement): void {
314
+ const li = p.ownerDocument.createElement('li');
315
+
316
+ // Strip the bullet character and any leading nbsp/whitespace from
317
+ // the first text node, preserving any inline HTML that follows.
318
+ const firstTextNode = findFirstTextNode(p);
319
+
320
+ if (firstTextNode) {
321
+ firstTextNode.textContent = (firstTextNode.textContent ?? '').replace(BULLET_PREFIX, '');
322
+ }
323
+
324
+ li.append(...Array.from(p.childNodes));
325
+ ul.appendChild(li);
326
+ p.remove();
327
+ }
328
+
329
+ /**
330
+ * Walk direct children of an element and return groups of consecutive `<p>`
331
+ * elements whose text starts with a bullet character.
332
+ */
333
+ function collectBulletGroups(wrapper: HTMLElement): HTMLParagraphElement[][] {
334
+ const groups: HTMLParagraphElement[][] = [];
264
335
  const children = Array.from(wrapper.childNodes);
265
- let currentList: HTMLUListElement | null = null;
266
336
 
267
337
  for (const child of children) {
268
338
  if (child.nodeType !== Node.ELEMENT_NODE) {
@@ -270,38 +340,43 @@ function convertBulletParagraphsToLists(wrapper: HTMLElement): void {
270
340
  }
271
341
 
272
342
  const el = child as HTMLElement;
343
+ const isBulletParagraph = el.tagName === 'P' && BULLET_PREFIX.test(el.textContent ?? '');
273
344
 
274
- if (el.tagName !== 'P') {
275
- currentList = null;
345
+ if (!isBulletParagraph) {
276
346
  continue;
277
347
  }
278
348
 
279
- const textContent = el.textContent ?? '';
349
+ const lastGroup = groups[groups.length - 1];
350
+ const previousSibling = findPreviousElementSibling(el);
351
+ const belongsToCurrentGroup = lastGroup
352
+ && previousSibling !== null
353
+ && lastGroup[lastGroup.length - 1] === previousSibling;
280
354
 
281
- if (!BULLET_PREFIX.test(textContent)) {
282
- currentList = null;
283
- continue;
284
- }
285
-
286
- if (!currentList) {
287
- currentList = doc.createElement('ul');
288
- el.before(currentList);
355
+ if (belongsToCurrentGroup) {
356
+ lastGroup.push(el as HTMLParagraphElement);
357
+ } else {
358
+ groups.push([el as HTMLParagraphElement]);
289
359
  }
360
+ }
290
361
 
291
- const li = doc.createElement('li');
362
+ return groups;
363
+ }
292
364
 
293
- // Strip the bullet character and any leading nbsp/whitespace from
294
- // the first text node, preserving any inline HTML that follows.
295
- const firstTextNode = findFirstTextNode(el);
365
+ /**
366
+ * Find the previous sibling that is an element, skipping non-element nodes.
367
+ */
368
+ function findPreviousElementSibling(el: HTMLElement): Element | null {
369
+ const prev = el.previousSibling;
296
370
 
297
- if (firstTextNode) {
298
- firstTextNode.textContent = (firstTextNode.textContent ?? '').replace(BULLET_PREFIX, '');
299
- }
371
+ if (!prev) {
372
+ return null;
373
+ }
300
374
 
301
- li.append(...Array.from(el.childNodes));
302
- currentList.appendChild(li);
303
- el.remove();
375
+ if (prev.nodeType === Node.ELEMENT_NODE) {
376
+ return prev as Element;
304
377
  }
378
+
379
+ return null;
305
380
  }
306
381
 
307
382
  /**
@@ -309,22 +384,21 @@ function convertBulletParagraphsToLists(wrapper: HTMLElement): void {
309
384
  * of an element.
310
385
  */
311
386
  function stripTrailingBreaks(element: Element): void {
312
- let node = element.lastChild;
387
+ for (;;) {
388
+ const node = element.lastChild;
313
389
 
314
- while (node) {
315
- if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'BR') {
316
- const prev = node.previousSibling;
390
+ if (!node) {
391
+ break;
392
+ }
317
393
 
318
- node.remove();
319
- node = prev;
320
- } else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
321
- const prev = node.previousSibling;
394
+ const isBr = node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'BR';
395
+ const isBlankText = node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '';
322
396
 
323
- node.remove();
324
- node = prev;
325
- } else {
397
+ if (!isBr && !isBlankText) {
326
398
  break;
327
399
  }
400
+
401
+ node.remove();
328
402
  }
329
403
  }
330
404