shells.
unwrapBareDivs(aside);
// Strip trailing
inside paragraphs — the paste handler splits
// content at
boundaries, so a trailing one creates an empty block.
stripTrailingBrInParagraphs(aside);
div.replaceWith(aside);
}
}
/**
* Repeatedly unwrap non-semantic `
` wrappers (no style or class) that are
* direct children of the given element, replacing them with their child nodes.
*/
function unwrapBareDivs(parent: HTMLElement): void {
for (;;) {
const bareDivs = Array.from(parent.querySelectorAll
(':scope > div'))
.filter((d) => !d.getAttribute('style') && !d.getAttribute('class'));
if (bareDivs.length === 0) {
break;
}
for (const child of bareDivs) {
child.replaceWith(...Array.from(child.childNodes));
}
}
}
/**
* Remove trailing `
` elements from paragraphs inside the given element.
*/
function stripTrailingBrInParagraphs(parent: HTMLElement): void {
for (const p of Array.from(parent.querySelectorAll('p'))) {
const lastChild = p.lastElementChild;
if (lastChild?.tagName === 'BR') {
lastChild.remove();
}
}
}
/**
* Minimum channel brightness for a colour to be considered "near-white".
*
* Any `rgb(r, g, b)` where all three channels are >= this value is stripped.
*/
const NEAR_WHITE_MIN_CHANNEL = 250;
/**
* Remove white/transparent `background-color` from inline elements.
*
* After stripping the property, empty wrapper elements (no remaining styles
* and no text) are unwrapped.
*/
function stripSpuriousBackgroundColors(wrapper: HTMLElement): void {
const candidates = wrapper.querySelectorAll('[style*="background-color"]');
for (const el of Array.from(candidates)) {
if (!isSpuriousBackgroundColor(el.style.backgroundColor)) {
continue;
}
el.style.removeProperty('background-color');
if (el.getAttribute('style')?.trim() === '') {
el.removeAttribute('style');
}
if (isEmptyWrapper(el)) {
el.replaceWith(...Array.from(el.childNodes));
}
}
}
/**
* Check whether a computed `background-color` value is visually invisible
* (white, near-white, or transparent).
*/
function isSpuriousBackgroundColor(value: string): boolean {
if (!value) {
return false;
}
const normalised = value.replace(/\s/g, '').toLowerCase();
if (normalised === 'transparent') {
return true;
}
const rgbaMatch = normalised.match(/^rgba?\((\d+),(\d+),(\d+)(?:,([^)]+))?\)$/);
if (!rgbaMatch) {
return false;
}
const alpha = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
if (alpha === 0) {
return true;
}
const r = parseInt(rgbaMatch[1], 10);
const g = parseInt(rgbaMatch[2], 10);
const b = parseInt(rgbaMatch[3], 10);
return r >= NEAR_WHITE_MIN_CHANNEL && g >= NEAR_WHITE_MIN_CHANNEL && b >= NEAR_WHITE_MIN_CHANNEL;
}
/**
* Check whether an element is a pure wrapper with no semantic value
* (no attributes and no text content, or just whitespace).
*/
function isEmptyWrapper(el: HTMLElement): boolean {
if (el.attributes.length > 0) {
return false;
}
const text = (el.textContent ?? '').replace(/[\s\u00A0]/g, '');
return text.length === 0;
}
/**
* Extract `background-color` from an element's style attribute.
*
* Handles both `background-color:` and `background:` (shorthand) properties.
* Uses the element's computed style property first, falling back to manual
* parsing of the style attribute for shorthand notation.
*/
function getBackgroundColor(el: HTMLElement): string {
// Try the direct property first
if (el.style.backgroundColor) {
return el.style.backgroundColor;
}
// Fall back to parsing the style attribute for shorthand `background:`
const styleAttr = el.getAttribute('style') ?? '';
const match = styleAttr.match(/background:\s*([^;]+)/i);
if (match) {
const value = match[1].trim();
// Only return color values, not url() or other background sub-properties
const colorMatch = value.match(/^(rgb[a]?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-z]+)$/i);
if (colorMatch) {
return colorMatch[1];
}
}
return '';
}
/**
* Convert `` boundaries to `
` line breaks inside table cells.
*
* Only targets `
` and ` | ` — top-level ` ` tags are left intact.
*/
function convertTableCellParagraphs(wrapper: HTMLElement): void {
for (const cell of Array.from(wrapper.querySelectorAll('td, th'))) {
const paragraphs = cell.querySelectorAll('p');
if (paragraphs.length === 0) {
continue;
}
for (const p of Array.from(paragraphs)) {
replaceParagraphWithBr(p);
}
stripTrailingBreaks(cell);
}
}
/**
* Replace a ` ` element with its child nodes followed by a ` `,
* or remove it entirely if it is empty / nbsp-only.
*/
function replaceParagraphWithBr(p: HTMLParagraphElement): void {
if (p.innerHTML.trim() === '' || p.innerHTML.trim() === ' ') {
p.remove();
return;
}
const doc = p.ownerDocument;
const fragment = doc.createDocumentFragment();
fragment.append(...Array.from(p.childNodes));
fragment.append(doc.createElement('br'));
p.replaceWith(fragment);
}
/**
* Remove paragraphs that are nothing but a visual spacer.
*
* The test is `isSpacerParagraph`, not bare `textContent`: ` ![]() ` has no
* text but holds the image, and removing it destroyed the media.
*/
function stripSpacerParagraphs(wrapper: HTMLElement): void {
for (const p of Array.from(wrapper.querySelectorAll('p'))) {
// Skip paragraphs inside table cells — those are handled by convertTableCellParagraphs
if (p.closest('td') || p.closest('th')) {
continue;
}
if (isSpacerParagraph(p, { lineBreaksAreContent: false })) {
p.remove();
}
}
}
/**
* Convert `` and `` elements to ``.
*/
function convertStrikethroughTags(wrapper: HTMLElement): void {
const doc = wrapper.ownerDocument;
for (const el of Array.from(wrapper.querySelectorAll('del, strike'))) {
const replacement = doc.createElement('s');
replacement.append(...Array.from(el.childNodes));
el.replaceWith(replacement);
}
}
/**
* Bullet characters that indicate a pseudo-list paragraph.
*
* Matches: `\u2022` (bullet), `\u00B7` (middle dot), or `- ` (hyphen + space).
*/
const BULLET_PREFIX = /^[\u2022\u00B7][\s\u00A0]*|^-\s/;
/**
* Convert `• text ` pseudo-lists into proper `- ` markup.
*
* Groups consecutive bullet paragraphs into a single `
` and strips
* the bullet prefix. Only processes direct children of the wrapper.
*/
function convertBulletParagraphsToLists(wrapper: HTMLElement): void {
const doc = wrapper.ownerDocument;
// Collect runs of consecutive bullet paragraphs. Each run is a group
// that will become a single
|