forked from akai/readest
ebbbf104b2
* feat(warichu): support warichu (割注) inline annotation layout
- Add warichu HTML transformer that converts <span class="warichu"> and
<warichu> elements into .warichu-pending placeholders during content load
- Implement runtime layout (layoutWarichu / relayoutWarichu) that measures
column position and splits text into small inline-block chunks (2 chars
each) so CSS column boundaries can break between them, preventing large
blank gaps in vertical-rl pagination
- Use column stride (column-width + column-gap) for accurate position
measurement across column boundaries
- Hook into stabilized event for initial layout and relayout on resize
- Add warichu CSS styles (inline-block chunks, half-size font, vertical align)
* fix(warichu): correct HTML slicing edge cases
- sliceHtml: re-emit tags that were already open before the slice start
so the result stays well-formed. Previously a slice past an opening
tag produced an orphan closing tag (e.g. "<b>Hello</b>"[3,5] →
"lo</b>" instead of "<b>lo</b>").
- sliceHtml / removeFirstVisibleChar / removeLastVisibleChar: treat HTML
entities (e.g. &) as one visible character so they aren't split or
truncated mid-entity (e.g. removeFirstVisibleChar("&rest") →
"amp;rest").
- buildNodes: drop a duplicate chunk.appendChild(l2).
- Add unit tests covering the above for the three pure helpers.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
608 lines
19 KiB
TypeScript
608 lines
19 KiB
TypeScript
/**
|
||
* Warichu (割注/夹注) — Runtime Layout
|
||
*
|
||
* Each chunk is placed directly in the parent container (not nested in
|
||
* a wrapper). All nodes belonging to the same warichu share a unique
|
||
* data-warichu-id. The first node (.warichu-head) also carries the
|
||
* full text and paren data for relayout.
|
||
*/
|
||
|
||
let warichuIdCounter = 0;
|
||
|
||
// Per-document cache: skip relayout when column-width hasn't changed.
|
||
const envCache = new WeakMap<Document, { columnSize: number; columnStride: number }>();
|
||
|
||
export function layoutWarichu(doc: Document): void {
|
||
const pendingElements = Array.from(doc.querySelectorAll('.warichu-pending'));
|
||
if (!pendingElements.length) return;
|
||
|
||
const env = getEnv(doc);
|
||
if (!env) return;
|
||
|
||
// Update cache so subsequent relayout calls can detect changes
|
||
envCache.set(doc, { columnSize: env.columnSize, columnStride: env.columnStride });
|
||
|
||
for (const el of pendingElements) {
|
||
const span = el as HTMLElement;
|
||
const { text, html, openParen, closeParen } = getData(span);
|
||
if (!text) continue;
|
||
|
||
const wid = 'w' + warichuIdCounter++;
|
||
|
||
// Measure at the pending span's current visible position
|
||
const firstChunkChars = measureFirstChunk(span, env, openParen);
|
||
|
||
// Build nodes
|
||
const nodes = buildNodes(
|
||
doc,
|
||
wid,
|
||
text,
|
||
html,
|
||
openParen,
|
||
closeParen,
|
||
firstChunkChars,
|
||
env.fullLineChars,
|
||
);
|
||
|
||
// Replace the pending span with the array of nodes
|
||
span.replaceWith(...nodes);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-layout warichu after resize — but only if column-width actually changed.
|
||
*/
|
||
export function relayoutWarichu(doc: Document): void {
|
||
const heads = Array.from(doc.querySelectorAll('.warichu-head'));
|
||
if (!heads.length) return;
|
||
|
||
const env = getEnv(doc);
|
||
if (!env) return;
|
||
|
||
// Skip relayout if column-width hasn't changed since last layout
|
||
const cached = envCache.get(doc);
|
||
if (cached && cached.columnSize === env.columnSize && cached.columnStride === env.columnStride) {
|
||
return;
|
||
}
|
||
envCache.set(doc, { columnSize: env.columnSize, columnStride: env.columnStride });
|
||
|
||
for (const h of heads) {
|
||
const head = h as HTMLElement;
|
||
const wid = head.dataset['warichuId'] || '';
|
||
const text = head.dataset['text'] || '';
|
||
const html = head.dataset['html'] || '';
|
||
const openParen = head.dataset['open'] || '';
|
||
const closeParen = head.dataset['close'] || '';
|
||
if (!wid || !text) continue;
|
||
|
||
const parentEl = head.parentElement;
|
||
if (!parentEl) continue;
|
||
|
||
// Find all nodes with this warichu id
|
||
const allNodes = Array.from(parentEl.querySelectorAll(`[data-warichu-id="${wid}"]`));
|
||
|
||
// Keep the head node, remove the rest. We'll re-insert after the head.
|
||
for (let i = 1; i < allNodes.length; i++) {
|
||
allNodes[i]!.remove();
|
||
}
|
||
|
||
// Clear head content to make it a tiny inline probe for measurement
|
||
head.className = 'warichu-head';
|
||
head.removeAttribute('style');
|
||
head.textContent = '\u200B';
|
||
head.style.display = 'inline';
|
||
head.style.fontSize = '0.5em';
|
||
head.style.lineHeight = '1.1';
|
||
|
||
const firstChunkChars = measureFirstChunk(head, env, openParen);
|
||
|
||
// Build new nodes (the first will replace the head)
|
||
const nodes = buildNodes(
|
||
doc,
|
||
wid,
|
||
text,
|
||
html,
|
||
openParen,
|
||
closeParen,
|
||
firstChunkChars,
|
||
env.fullLineChars,
|
||
);
|
||
|
||
// Insert all new nodes after the head, then remove the head
|
||
const refNode = head.nextSibling;
|
||
for (const node of nodes) {
|
||
parentEl.insertBefore(node, refNode);
|
||
}
|
||
head.remove();
|
||
}
|
||
}
|
||
|
||
// ── Core measurement ──────────────────────────────────────────────────
|
||
|
||
function measureFirstChunk(el: HTMLElement, env: LayoutEnv, _openParen: string): number {
|
||
const { isVertical } = env;
|
||
const doc = el.ownerDocument;
|
||
const parent = el.parentNode;
|
||
if (!parent) return env.fullLineChars;
|
||
|
||
// Insert warichu-chunk probes one by one before the warichu element.
|
||
// When a chunk lands in a different column (its cross-axis position shifts),
|
||
// we know the previous chunk was the last that fit in the current column.
|
||
const testChunks: HTMLElement[] = [];
|
||
let fitChars = 0;
|
||
let firstCrossPos: number | null = null;
|
||
|
||
for (let n = 1; n <= Math.ceil(env.fullLineChars / CHARS_PER_CHUNK) + 2; n++) {
|
||
const chunk = doc.createElement('span');
|
||
chunk.className = 'warichu-chunk';
|
||
chunk.style.visibility = 'hidden';
|
||
const l1 = doc.createElement('span');
|
||
l1.className = 'warichu-line';
|
||
l1.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||
chunk.appendChild(l1);
|
||
chunk.appendChild(doc.createElement('br'));
|
||
const l2 = doc.createElement('span');
|
||
l2.className = 'warichu-line';
|
||
l2.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||
chunk.appendChild(l2);
|
||
|
||
parent.insertBefore(chunk, el);
|
||
testChunks.push(chunk);
|
||
|
||
const rect = chunk.getBoundingClientRect();
|
||
// Cross-axis: in vertical-rl columns are side by side horizontally,
|
||
// so cross-axis = left. In horizontal-tb, cross-axis = top.
|
||
const crossPos = isVertical ? rect.left : rect.top;
|
||
|
||
if (firstCrossPos === null) {
|
||
firstCrossPos = crossPos;
|
||
} else if (Math.abs(crossPos - firstCrossPos) > 2) {
|
||
// Jumped to a different column
|
||
fitChars = (n - 1) * CHARS_PER_CHUNK;
|
||
break;
|
||
}
|
||
fitChars = n * CHARS_PER_CHUNK;
|
||
}
|
||
|
||
// Clean up
|
||
for (const c of testChunks) c.remove();
|
||
|
||
return Math.max(2, fitChars);
|
||
}
|
||
|
||
// ── Build nodes ───────────────────────────────────────────────────────
|
||
|
||
// Maximum characters per line in a single physical chunk. Keeping this
|
||
// small ensures each inline-block chunk has a tiny inline-axis size,
|
||
// allowing CSS column boundaries to break between adjacent chunks
|
||
// instead of pushing an oversized block to the next column.
|
||
const CHARS_PER_CHUNK = 2;
|
||
|
||
// Characters that must not appear at the start of a line (kinsoku).
|
||
const KINSOKU_NO_START = new Set([
|
||
',',
|
||
'。',
|
||
'、',
|
||
')',
|
||
')',
|
||
'」',
|
||
'』',
|
||
'】',
|
||
'〕',
|
||
']',
|
||
'!',
|
||
'?',
|
||
':',
|
||
';',
|
||
'.',
|
||
'‥',
|
||
'…',
|
||
'・',
|
||
]);
|
||
|
||
function buildNodes(
|
||
doc: Document,
|
||
wid: string,
|
||
text: string,
|
||
html: string,
|
||
openParen: string,
|
||
closeParen: string,
|
||
firstChunkChars: number,
|
||
fullLineChars: number,
|
||
): Node[] {
|
||
const nodes: Node[] = [];
|
||
const totalChars = text.length;
|
||
const useHtml = html.length > 0;
|
||
|
||
// Open paren
|
||
if (openParen) {
|
||
const op = doc.createElement('span');
|
||
op.className = 'warichu-open';
|
||
op.dataset['warichuId'] = wid;
|
||
op.textContent = openParen;
|
||
nodes.push(op);
|
||
}
|
||
|
||
// ── Phase 1: generate (t1, t2) pairs grouped by segment (= column) ──
|
||
// Track both plain text positions and corresponding HTML slices.
|
||
const allPairs: { t1: string; t2: string; h1: string; h2: string }[] = [];
|
||
const segBounds: { start: number; end: number }[] = [];
|
||
|
||
let off = 0;
|
||
let isFirstSeg = true;
|
||
while (off < totalChars) {
|
||
const maxPerLine = isFirstSeg ? firstChunkChars : fullLineChars;
|
||
isFirstSeg = false;
|
||
const remaining = totalChars - off;
|
||
const segLen = remaining <= maxPerLine * 2 ? remaining : maxPerLine * 2;
|
||
const segText = text.slice(off, off + segLen);
|
||
|
||
const mid = Math.ceil(segText.length / 2);
|
||
const topLine = segText.slice(0, mid);
|
||
const botLine = segText.slice(mid);
|
||
|
||
// Get corresponding HTML slices
|
||
const topHtml = useHtml ? sliceHtml(html, off, off + mid) : '';
|
||
const botHtml = useHtml ? sliceHtml(html, off + mid, off + segLen) : '';
|
||
|
||
off += segLen;
|
||
|
||
const segStart = allPairs.length;
|
||
const pairCount = Math.max(topLine.length, botLine.length);
|
||
for (let ci = 0; ci < pairCount; ci += CHARS_PER_CHUNK) {
|
||
const t1 = topLine.slice(ci, ci + CHARS_PER_CHUNK);
|
||
const t2 = botLine.slice(ci, ci + CHARS_PER_CHUNK);
|
||
const h1 = useHtml ? sliceHtmlRelative(topHtml, topLine, ci, ci + CHARS_PER_CHUNK) : '';
|
||
const h2 = useHtml ? sliceHtmlRelative(botHtml, botLine, ci, ci + CHARS_PER_CHUNK) : '';
|
||
if (t1 || t2) {
|
||
allPairs.push({ t1, t2, h1, h2 });
|
||
}
|
||
}
|
||
segBounds.push({ start: segStart, end: allPairs.length - 1 });
|
||
}
|
||
|
||
// ── Phase 2: kinsoku at column (segment) boundaries ──
|
||
for (let si = 0; si < segBounds.length; si++) {
|
||
const { start, end } = segBounds[si]!;
|
||
const first = allPairs[start];
|
||
if (!first) continue;
|
||
|
||
if (first.t1.length > 0 && KINSOKU_NO_START.has(first.t1[0]!) && si > 0) {
|
||
const prevEnd = segBounds[si - 1]!.end;
|
||
const ch = first.t1[0]!;
|
||
allPairs[prevEnd]!.t2 += ch;
|
||
allPairs[prevEnd]!.h2 += ch;
|
||
first.t1 = first.t1.slice(1);
|
||
first.h1 = removeFirstVisibleChar(first.h1);
|
||
}
|
||
|
||
if (first.t2.length > 0 && KINSOKU_NO_START.has(first.t2[0]!)) {
|
||
if (start === end && first.t1.length > 1) {
|
||
// Single-chunk segment: moving the kinsoku char to the end of t1
|
||
// would leave t2 empty (e.g. "長東"/"。" → "長東。"/"").
|
||
// Instead pull the last char of t1 down so the kinsoku char is
|
||
// no longer at the start of t2. e.g. "長東"/"。" → "長"/"東。".
|
||
const lastChar = first.t1[first.t1.length - 1]!;
|
||
first.t1 = first.t1.slice(0, -1);
|
||
first.t2 = lastChar + first.t2;
|
||
if (first.h1) {
|
||
first.h1 = removeLastVisibleChar(first.h1);
|
||
}
|
||
first.h2 = lastChar + first.h2;
|
||
} else {
|
||
// Multi-chunk segment or t1 has only 1 char: push the kinsoku
|
||
// char to the end of the last chunk's top line.
|
||
const ch = first.t2[0]!;
|
||
allPairs[end]!.t1 += ch;
|
||
allPairs[end]!.h1 += ch;
|
||
first.t2 = first.t2.slice(1);
|
||
first.h2 = removeFirstVisibleChar(first.h2);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Phase 3: create DOM nodes ──
|
||
let isFirstHead = true;
|
||
for (const { t1, t2, h1, h2 } of allPairs) {
|
||
if (!t1 && !t2) continue;
|
||
|
||
const chunk = doc.createElement('span');
|
||
chunk.className = 'warichu-chunk';
|
||
chunk.dataset['warichuId'] = wid;
|
||
|
||
if (isFirstHead) {
|
||
chunk.classList.add('warichu-head');
|
||
chunk.dataset['text'] = text;
|
||
chunk.dataset['html'] = html;
|
||
chunk.dataset['open'] = openParen;
|
||
chunk.dataset['close'] = closeParen;
|
||
isFirstHead = false;
|
||
}
|
||
|
||
const l1 = doc.createElement('span');
|
||
l1.className = 'warichu-line';
|
||
if (useHtml && h1) {
|
||
l1.innerHTML = h1;
|
||
} else {
|
||
l1.textContent = t1 || '\u200B';
|
||
}
|
||
chunk.appendChild(l1);
|
||
|
||
chunk.appendChild(doc.createElement('br'));
|
||
const l2 = doc.createElement('span');
|
||
l2.className = 'warichu-line';
|
||
if (useHtml && h2) {
|
||
l2.innerHTML = h2;
|
||
} else {
|
||
l2.textContent = t2 || '\u200B';
|
||
}
|
||
chunk.appendChild(l2);
|
||
|
||
nodes.push(chunk);
|
||
}
|
||
|
||
// Close paren
|
||
if (closeParen) {
|
||
const cp = doc.createElement('span');
|
||
cp.className = 'warichu-close';
|
||
cp.dataset['warichuId'] = wid;
|
||
cp.textContent = closeParen;
|
||
nodes.push(cp);
|
||
}
|
||
|
||
return nodes;
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────
|
||
|
||
interface LayoutEnv {
|
||
isVertical: boolean;
|
||
oneCharInline: number;
|
||
columnSize: number;
|
||
columnStride: number; // column-width + column-gap
|
||
fullLineChars: number;
|
||
marginStart: number;
|
||
}
|
||
|
||
function getData(el: HTMLElement) {
|
||
return {
|
||
text: el.dataset['text'] || '',
|
||
html: el.dataset['html'] || '',
|
||
openParen: el.dataset['open'] || '',
|
||
closeParen: el.dataset['close'] || '',
|
||
};
|
||
}
|
||
|
||
function getEnv(doc: Document): LayoutEnv | null {
|
||
const win = doc.defaultView;
|
||
if (!win) return null;
|
||
|
||
const bodyStyle = win.getComputedStyle(doc.body);
|
||
const htmlStyle = win.getComputedStyle(doc.documentElement);
|
||
const writingMode = bodyStyle.writingMode || htmlStyle.writingMode || 'horizontal-tb';
|
||
const isVertical = writingMode.includes('vertical');
|
||
|
||
const probe = doc.createElement('span');
|
||
probe.style.fontSize = '0.5em';
|
||
probe.style.lineHeight = '1.1';
|
||
probe.style.visibility = 'hidden';
|
||
probe.style.position = 'absolute';
|
||
probe.style.whiteSpace = 'nowrap';
|
||
probe.textContent = '国';
|
||
doc.body.appendChild(probe);
|
||
const probeRect = probe.getBoundingClientRect();
|
||
const oneCharInline = isVertical ? probeRect.height : probeRect.width;
|
||
doc.body.removeChild(probe);
|
||
|
||
const rootStyle = win.getComputedStyle(doc.documentElement);
|
||
let columnSize = 0;
|
||
|
||
const cwProp = rootStyle.getPropertyValue('column-width');
|
||
if (cwProp && cwProp !== 'auto') {
|
||
columnSize = parseFloat(cwProp);
|
||
}
|
||
if (!columnSize || columnSize <= 0) {
|
||
if (isVertical) {
|
||
const v = rootStyle.getPropertyValue('--available-height');
|
||
if (v) columnSize = parseFloat(v);
|
||
} else {
|
||
const v = rootStyle.getPropertyValue('--available-width');
|
||
if (v) columnSize = parseFloat(v);
|
||
}
|
||
}
|
||
if (!columnSize || columnSize <= 0) {
|
||
return null;
|
||
}
|
||
|
||
// Read column-gap for stride calculation
|
||
const cgProp = rootStyle.getPropertyValue('column-gap');
|
||
const columnGap = cgProp ? parseFloat(cgProp) : 0;
|
||
const columnStride = columnSize + (columnGap > 0 ? columnGap : 0);
|
||
|
||
let marginStart = 0;
|
||
if (isVertical) {
|
||
const v = rootStyle.getPropertyValue('--page-margin-top');
|
||
if (v) marginStart = parseFloat(v);
|
||
} else {
|
||
const v = rootStyle.getPropertyValue('--page-margin-left');
|
||
if (v) marginStart = parseFloat(v);
|
||
}
|
||
|
||
// Measure fullLineChars by placing actual warichu-chunk inline-blocks
|
||
// and detecting when one jumps to a different column (cross-axis shift).
|
||
const testContainer = doc.createElement('div');
|
||
testContainer.style.visibility = 'hidden';
|
||
doc.body.appendChild(testContainer);
|
||
|
||
let fullLineChars = 2;
|
||
const maxChunks = Math.ceil(columnSize / oneCharInline / CHARS_PER_CHUNK) + 2;
|
||
let firstCrossPos: number | null = null;
|
||
|
||
for (let n = 1; n <= maxChunks; n++) {
|
||
const chunk = doc.createElement('span');
|
||
chunk.className = 'warichu-chunk';
|
||
const l1 = doc.createElement('span');
|
||
l1.className = 'warichu-line';
|
||
l1.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||
chunk.appendChild(l1);
|
||
chunk.appendChild(doc.createElement('br'));
|
||
const l2 = doc.createElement('span');
|
||
l2.className = 'warichu-line';
|
||
l2.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||
chunk.appendChild(l2);
|
||
testContainer.appendChild(chunk);
|
||
|
||
const rect = chunk.getBoundingClientRect();
|
||
const crossPos = isVertical ? rect.left : rect.top;
|
||
|
||
if (firstCrossPos === null) {
|
||
firstCrossPos = crossPos;
|
||
} else if (Math.abs(crossPos - firstCrossPos) > 2) {
|
||
fullLineChars = Math.max(2, (n - 1) * CHARS_PER_CHUNK);
|
||
break;
|
||
}
|
||
fullLineChars = n * CHARS_PER_CHUNK;
|
||
}
|
||
|
||
testContainer.remove();
|
||
|
||
return { isVertical, oneCharInline, columnSize, columnStride, fullLineChars, marginStart };
|
||
}
|
||
|
||
// ── HTML slicing helpers ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Slice an HTML string by visible character positions [start, end).
|
||
* Tags are preserved and properly opened/closed. Tags that were already open
|
||
* before `start` are re-emitted at the beginning of the slice so the result
|
||
* stays well-formed. HTML entities (e.g. `&`) are treated as one visible
|
||
* character.
|
||
*/
|
||
export function sliceHtml(html: string, start: number, end: number): string {
|
||
let visibleIdx = 0;
|
||
let i = 0;
|
||
let result = '';
|
||
const openTags: string[] = [];
|
||
let entered = false;
|
||
|
||
const enterSlice = () => {
|
||
if (entered) return;
|
||
entered = true;
|
||
for (const t of openTags) result += `<${t}>`;
|
||
};
|
||
|
||
while (i < html.length && visibleIdx < end) {
|
||
if (html[i] === '<') {
|
||
const tagEnd = html.indexOf('>', i);
|
||
if (tagEnd === -1) break;
|
||
const tag = html.slice(i, tagEnd + 1);
|
||
const isClosing = tag.startsWith('</');
|
||
const isSelfClosing = tag.endsWith('/>');
|
||
if (visibleIdx >= start) {
|
||
enterSlice();
|
||
result += tag;
|
||
}
|
||
if (!isClosing && !isSelfClosing) {
|
||
const tagName = tag.match(/^<(\w+)/)?.[1] || '';
|
||
if (tagName) openTags.push(tagName);
|
||
} else if (isClosing) {
|
||
const tagName = tag.match(/^<\/(\w+)/)?.[1] || '';
|
||
const lastOpen = openTags.lastIndexOf(tagName);
|
||
if (lastOpen !== -1) openTags.splice(lastOpen, 1);
|
||
}
|
||
i = tagEnd + 1;
|
||
} else {
|
||
const charLen = entityLengthAt(html, i);
|
||
if (visibleIdx >= start && visibleIdx < end) {
|
||
enterSlice();
|
||
result += html.slice(i, i + charLen);
|
||
}
|
||
visibleIdx++;
|
||
i += charLen;
|
||
}
|
||
}
|
||
|
||
// Close any unclosed tags
|
||
for (let t = openTags.length - 1; t >= 0; t--) {
|
||
result += `</${openTags[t]}>`;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Length of the visible character starting at `i` — 1 for normal chars,
|
||
* or the entity length (e.g. 5 for `&`) when an entity starts here.
|
||
*/
|
||
function entityLengthAt(html: string, i: number): number {
|
||
if (html[i] !== '&') return 1;
|
||
const semi = html.indexOf(';', i + 1);
|
||
if (semi === -1 || semi - i >= 10) return 1;
|
||
// Validate entity body to avoid swallowing arbitrary `&...;` text
|
||
const body = html.slice(i + 1, semi);
|
||
return /^#?[a-zA-Z0-9]+$/.test(body) ? semi - i + 1 : 1;
|
||
}
|
||
|
||
/**
|
||
* Slice a sub-HTML string relative to a plain text line.
|
||
* `lineHtml` is the HTML for the full line, `lineText` is the plain text.
|
||
* Returns the HTML corresponding to lineText[start:end].
|
||
*/
|
||
function sliceHtmlRelative(lineHtml: string, lineText: string, start: number, end: number): string {
|
||
if (!lineHtml) return lineText.slice(start, end);
|
||
// Map from the line's text positions to the full HTML
|
||
const actualEnd = Math.min(end, lineText.length);
|
||
return sliceHtml(lineHtml, start, actualEnd);
|
||
}
|
||
|
||
/**
|
||
* Remove the first visible (non-tag) character from an HTML string.
|
||
* HTML entities (e.g. `&`) are treated as one visible character and
|
||
* removed in their entirety.
|
||
*/
|
||
export function removeFirstVisibleChar(html: string): string {
|
||
let i = 0;
|
||
while (i < html.length) {
|
||
if (html[i] === '<') {
|
||
const end = html.indexOf('>', i);
|
||
if (end === -1) break;
|
||
i = end + 1;
|
||
} else {
|
||
// Found first visible char — remove it (full entity if applicable)
|
||
const charLen = entityLengthAt(html, i);
|
||
return html.slice(0, i) + html.slice(i + charLen);
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Remove the last visible (non-tag) character from an HTML string.
|
||
* HTML entities (e.g. `&`) are treated as one visible character and
|
||
* removed in their entirety.
|
||
*/
|
||
export function removeLastVisibleChar(html: string): string {
|
||
let i = html.length - 1;
|
||
while (i >= 0) {
|
||
if (html[i] === '>') {
|
||
const start = html.lastIndexOf('<', i);
|
||
if (start === -1) break;
|
||
i = start - 1;
|
||
} else if (html[i] === ';') {
|
||
// Possible entity end — look back for `&` and validate the body.
|
||
const amp = html.lastIndexOf('&', i);
|
||
if (amp !== -1 && i - amp < 10) {
|
||
const body = html.slice(amp + 1, i);
|
||
if (/^#?[a-zA-Z0-9]+$/.test(body)) {
|
||
return html.slice(0, amp) + html.slice(i + 1);
|
||
}
|
||
}
|
||
return html.slice(0, i) + html.slice(i + 1);
|
||
} else {
|
||
// Found last visible char — remove it
|
||
return html.slice(0, i) + html.slice(i + 1);
|
||
}
|
||
}
|
||
return html;
|
||
}
|