@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/blok.mjs +2 -2
- package/dist/chunks/{blok-1213fGsk.mjs → blok-CEVrVqlx.mjs} +5 -7
- package/dist/chunks/{constants-Cr7GEExc.mjs → constants-BURnHRy_.mjs} +1 -1
- package/dist/chunks/{tools-DBEfU2dP.mjs → tools-Dt2I14vP.mjs} +1029 -885
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +3 -6
- package/src/cli/commands/convert-gdocs/index.ts +24 -0
- package/src/cli/commands/convert-html/block-builder.ts +98 -88
- package/src/cli/commands/convert-html/preprocessor.ts +141 -67
- package/src/cli/commands/convert-html/sanitizer.ts +34 -35
- package/src/cli/index.ts +27 -1
- package/src/components/modules/toolbar/index.ts +5 -6
- package/src/tools/table/index.ts +136 -0
- package/src/tools/table/table-add-controls.ts +31 -2
- package/src/tools/table/table-cell-blocks.ts +13 -3
- package/src/tools/table/table-corner-drag.ts +247 -0
- package/src/types-internal/jsdom.d.ts +9 -0
- package/bin/blok.mjs +0 -10
- package/bin/convert-html.mjs +0 -3
- package/dist/convert-html.mjs +0 -631
- package/src/cli/commands/convert-html/standalone.ts +0 -20
package/dist/full.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { n as e, t } from "./chunks/blok-
|
|
2
|
-
import { $n as n } from "./chunks/constants-
|
|
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-
|
|
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-
|
|
2
|
-
import "./chunks/constants-
|
|
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-
|
|
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-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
71
|
+
if (bareDivs.length === 0) {
|
|
72
|
+
break;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
|
|
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
|
-
|
|
111
|
+
if (!isSpuriousBackgroundColor(el.style.backgroundColor)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
94
114
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
115
|
+
el.style.removeProperty('background-color');
|
|
116
|
+
|
|
117
|
+
if (el.getAttribute('style')?.trim() === '') {
|
|
118
|
+
el.removeAttribute('style');
|
|
119
|
+
}
|
|
98
120
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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() === ' ') {
|
|
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 (
|
|
275
|
-
currentList = null;
|
|
345
|
+
if (!isBulletParagraph) {
|
|
276
346
|
continue;
|
|
277
347
|
}
|
|
278
348
|
|
|
279
|
-
const
|
|
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 (
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
362
|
+
return groups;
|
|
363
|
+
}
|
|
292
364
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
371
|
+
if (!prev) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
300
374
|
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
387
|
+
for (;;) {
|
|
388
|
+
const node = element.lastChild;
|
|
313
389
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
390
|
+
if (!node) {
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
317
393
|
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
324
|
-
node = prev;
|
|
325
|
-
} else {
|
|
397
|
+
if (!isBr && !isBlankText) {
|
|
326
398
|
break;
|
|
327
399
|
}
|
|
400
|
+
|
|
401
|
+
node.remove();
|
|
328
402
|
}
|
|
329
403
|
}
|
|
330
404
|
|