pangu
Version:
Opinionated paranoid text spacing: automatically inserts whitespace between CJK (Chinese, Japanese, Korean) and ANS (alphabetical letters, numerical digits and symbols)
351 lines (350 loc) • 13.9 kB
JavaScript
import { NAME_SUFFIX_AT_END, Pangu } from "../shared/index.js";
import { decideBoundarySpacing, decideTextNodeSpacing, respaceCurrentTail } from "./dom/boundary-spacing.js";
import { DomWalker } from "./dom/dom-walker.js";
import { VisibilityDetector } from "./dom/visibility-detector.js";
import { TaskQueue, TaskScheduler } from "./scheduling/task-scheduler.js";
import { debounce, once, waitForVideosToLoad } from "./scheduling/timing.js";
//#region src/browser/index.ts
var BrowserPangu = class BrowserPangu extends Pangu {
static trailingWhitespace = /\s$/;
static leadingWhitespace = /^\s/;
static maxSyncTextNodes = 256;
autoSpacePageObserver = null;
lastWrittenData = /* @__PURE__ */ new WeakMap();
lateFixedTextNodes = /* @__PURE__ */ new WeakSet();
taskScheduler = new TaskScheduler();
visibilityDetector = new VisibilityDetector();
onTextNodesSettled = null;
autoSpacePage({ pageDelayMs = 1e3, nodeDelayMs = 500, nodeMaxWaitMs = 2e3 } = {}) {
if (!(document.body instanceof Node)) return;
if (this.autoSpacePageObserver) return;
const observer = this.setupAutoSpacePageObserver(nodeDelayMs, nodeMaxWaitMs);
waitForVideosToLoad(pageDelayMs, once(() => {
if (this.autoSpacePageObserver === observer) this.spacePage();
}));
}
spacePage() {
const title = document.querySelector("head > title");
if (title) this.spaceNode(title);
this.spaceNode(document.body);
}
spaceNode(node) {
const textNodes = DomWalker.collectTextNodes(node, true);
this.schedule(() => this.spaceTextNodes(textNodes));
}
stopAutoSpacePage() {
if (this.autoSpacePageObserver) {
this.autoSpacePageObserver.disconnect();
this.autoSpacePageObserver = null;
}
}
applyLateFixes(lateFixes) {
this.schedule(() => {
for (const lateFix of lateFixes) {
if (!lateFix.node.isConnected || lateFix.node.data !== lateFix.settled || DomWalker.isIgnoredNode(lateFix.node)) continue;
lateFix.node.data = lateFix.data;
this.lastWrittenData.set(lateFix.node, lateFix.data);
this.lateFixedTextNodes.add(lateFix.node);
}
});
}
isGridOrFlexContainer(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const display = window.getComputedStyle(node).display;
return display === "grid" || display === "inline-grid" || display === "flex" || display === "inline-flex";
}
spaceTextNodes(textNodes) {
this.visibilityDetector.clearCache();
const unsettledTextNodes = [];
let nextTextNode = null;
for (const currentTextNode of textNodes) {
if (currentTextNode instanceof Text) if (this.holdsLateFix(currentTextNode)) {
if (this.onTextNodesSettled) unsettledTextNodes.push({
node: currentTextNode,
unspaced: currentTextNode.data
});
} else this.applyTextNodeSpacing(currentTextNode, unsettledTextNodes);
if (nextTextNode) {
if (!(currentTextNode instanceof Text) || !(nextTextNode instanceof Text)) continue;
const nextSuperscript = nextTextNode.parentElement?.closest("sup");
if (nextSuperscript && !nextSuperscript.contains(currentTextNode)) {
nextTextNode = currentTextNode;
continue;
}
const currentBoundaryNode = DomWalker.findBoundaryNode(currentTextNode, "last");
const nextBoundaryNode = DomWalker.findBoundaryNode(nextTextNode, "first");
const { whitespaceBetween, contentBetween, spaceLikeBetween, blockEdgeBetween } = this.scanBetweenTextNodes(currentTextNode, nextTextNode);
const currentNode = currentTextNode;
const nextNode = nextTextNode;
const currentTail = currentTextNode.data.match(/(?<![A-Za-z0-9])[A-Za-z0-9]{3,}[+-]$/)?.[0] ?? currentTextNode.data.slice(-3);
const nextFirst = nextTextNode.data.slice(0, 1);
const boundarySpacingDecision = decideBoundarySpacing({
currentTail,
nextFirst,
currentEndsWithSpace: BrowserPangu.trailingWhitespace.test(currentTextNode.data),
nextStartsWithSpace: BrowserPangu.leadingWhitespace.test(nextTextNode.data),
whitespaceBetween,
contentBetween,
spaceLikeBetween,
blockEdgeBetween,
currentBoundaryIsSpaceSensitive: DomWalker.spaceSensitiveTags.test(currentBoundaryNode.nodeName) || currentTextNode.parentElement?.closest("sup")?.contains(nextTextNode) === false,
nextBoundaryIsIgnored: DomWalker.ignoredTags.test(nextBoundaryNode.nodeName),
nextBoundaryIsSpaceSensitive: DomWalker.spaceSensitiveTags.test(nextBoundaryNode.nodeName),
precedingNodeHidden: () => this.isPrecedingNodeHidden(nextNode),
currentNodeHidden: () => this.isNodeHidden(currentNode),
nextNodeHidden: () => this.isNodeHidden(nextNode),
inGridOrFlexContainer: () => !!nextBoundaryNode.parentNode && this.isGridOrFlexContainer(nextBoundaryNode.parentNode)
});
if (boundarySpacingDecision !== "none" && !this.holdsLateFix(currentTextNode) && !NAME_SUFFIX_AT_END.test(currentTextNode.data)) {
const respacedTail = respaceCurrentTail(currentTail, nextFirst);
if (respacedTail !== null) {
currentTextNode.data = currentTextNode.data.slice(0, currentTextNode.data.length - currentTail.length) + respacedTail;
this.lastWrittenData.set(currentTextNode, currentTextNode.data);
}
}
switch (boundarySpacingDecision) {
case "prepend-next":
nextTextNode.data = ` ${nextTextNode.data}`;
this.lastWrittenData.set(nextTextNode, nextTextNode.data);
break;
case "append-current":
currentTextNode.data = `${currentTextNode.data} `;
this.lastWrittenData.set(currentTextNode, currentTextNode.data);
break;
case "insert-element":
this.insertPanguElement(nextBoundaryNode);
break;
case "none": break;
}
}
nextTextNode = currentTextNode;
}
this.emitTextNodesSettled(unsettledTextNodes);
}
holdsLateFix(textNode) {
return this.lateFixedTextNodes.has(textNode) && this.lastWrittenData.get(textNode) === textNode.data;
}
emitTextNodesSettled(unsettledTextNodes) {
if (unsettledTextNodes.length === 0) return;
const settledTextNodes = unsettledTextNodes.map(({ node, unspaced }) => ({
node,
unspaced,
settled: node.data
}));
this.onTextNodesSettled?.(settledTextNodes);
}
applyTextNodeSpacing(textNode, unsettledTextNodes) {
this.lateFixedTextNodes.delete(textNode);
const textNodeSpacingDecisions = decideTextNodeSpacing({
text: textNode.data,
previousElementLastChar: this.findPreviousElementLastChar(textNode),
precedingNodeHidden: () => this.isPrecedingNodeHidden(textNode)
});
for (const textNodeSpacingDecision of textNodeSpacingDecisions) switch (textNodeSpacingDecision) {
case "trim-leading-space":
textNode.data = textNode.data.substring(1);
this.lastWrittenData.set(textNode, textNode.data);
break;
case "prepend-space":
textNode.data = ` ${textNode.data}`;
this.lastWrittenData.set(textNode, textNode.data);
break;
case "apply-text-spacing": {
if (this.onTextNodesSettled) unsettledTextNodes.push({
node: textNode,
unspaced: textNode.data
});
const newText = this.spaceText(textNode.data);
if (textNode.data !== newText) {
textNode.data = newText;
this.lastWrittenData.set(textNode, textNode.data);
}
break;
}
}
}
spaceNodeSync(node, maxTextNodes) {
const textNodes = DomWalker.collectTextNodes(node);
if (textNodes.length > maxTextNodes) return false;
this.spaceTextNodes(this.withNeighborTextNodes(textNodes).reverse());
return true;
}
withNeighborTextNodes(textNodes) {
const firstTextNode = textNodes[0];
const lastTextNode = textNodes.at(-1);
if (!firstTextNode || !lastTextNode) return textNodes;
const previousTextNode = DomWalker.findAdjacentTextNode(firstTextNode, "previous");
const nextTextNode = DomWalker.findAdjacentTextNode(lastTextNode, "next");
return [
...previousTextNode ? [previousTextNode] : [],
...textNodes,
...nextTextNode ? [nextTextNode] : []
];
}
hasSpacedTextInSubtree(node) {
if (node instanceof Text) return this.lastWrittenData.has(node);
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) if (this.lastWrittenData.has(walker.currentNode)) return true;
return false;
}
insertPanguElement(nextBoundaryNode) {
const panguSpace = document.createElement("pangu");
panguSpace.innerHTML = " ";
if (nextBoundaryNode.parentNode) nextBoundaryNode.parentNode.insertBefore(panguSpace, nextBoundaryNode);
if (!panguSpace.previousElementSibling) {
if (panguSpace.parentNode) panguSpace.parentNode.removeChild(panguSpace);
}
}
findPreviousElementLastChar(textNode) {
const previousNode = textNode.previousSibling;
if (previousNode?.nodeType === Node.ELEMENT_NODE && previousNode.textContent) return previousNode.textContent.slice(-1);
return null;
}
scanBetweenTextNodes(currentTextNode, nextTextNode) {
let whitespaceBetween = false;
let contentBetween = false;
let spaceLikeBetween = false;
let blockEdgeBetween = false;
const scan = (node) => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
if (/\s/.test(node.textContent)) whitespaceBetween = true;
if (/\S/.test(node.textContent)) contentBetween = true;
} else if (node instanceof Element && DomWalker.spaceLikeTags.test(node.nodeName)) spaceLikeBetween = true;
else if (node instanceof Element && !DomWalker.isIgnoredElement(node)) for (let child = node.firstChild; child; child = child.nextSibling) scan(child);
};
let containerOfNext = null;
let node = currentTextNode;
while (node && !containerOfNext) {
if (DomWalker.blockTags.test(node.nodeName)) {
blockEdgeBetween = true;
break;
}
let sibling = node.nextSibling;
while (sibling && !sibling.contains(nextTextNode)) {
scan(sibling);
sibling = sibling.nextSibling;
}
containerOfNext = sibling;
node = node.parentNode;
}
while (containerOfNext && containerOfNext !== nextTextNode) {
if (DomWalker.blockTags.test(containerOfNext.nodeName)) {
blockEdgeBetween = true;
break;
}
let child = containerOfNext.firstChild;
while (child && !child.contains(nextTextNode)) {
scan(child);
child = child.nextSibling;
}
containerOfNext = child;
}
return {
whitespaceBetween,
contentBetween,
spaceLikeBetween,
blockEdgeBetween
};
}
isPrecedingNodeHidden(node) {
return this.visibilityDetector.shouldSkipSpacingBeforeNode(node);
}
isNodeHidden(node) {
return this.visibilityDetector.shouldSkipSpacingAfterNode(node);
}
schedule(task) {
if (!this.taskScheduler.config.enabled || typeof requestIdleCallback !== "function") {
task();
return;
}
this.taskScheduler.queue.add(task);
}
setupAutoSpacePageObserver(nodeDelayMs, nodeMaxWaitMs) {
const queue = [];
const spaceTitleDebounced = debounce(() => {
if (this.autoSpacePageObserver !== observer) return;
const titleElement = document.querySelector("head > title");
if (titleElement) this.spaceNode(titleElement);
}, nodeDelayMs, nodeMaxWaitMs);
const spaceQueuedNodesDebounced = debounce(() => {
if (this.autoSpacePageObserver !== observer) return;
const nodesToProcess = [...queue];
queue.length = 0;
if (nodesToProcess.length === 0) return;
nodesToProcess.sort((a, b) => {
if (a === b) return 0;
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
});
const seenTextNodes = /* @__PURE__ */ new Set();
const allTextNodes = [];
for (const node of nodesToProcess) {
const textNodes = DomWalker.collectTextNodes(node);
for (const textNode of this.withNeighborTextNodes(textNodes)) if (!seenTextNodes.has(textNode)) {
seenTextNodes.add(textNode);
allTextNodes.push(textNode);
}
}
allTextNodes.reverse();
this.schedule(() => this.spaceTextNodes(allTextNodes));
}, nodeDelayMs, nodeMaxWaitMs);
const observer = new MutationObserver((mutations) => {
let titleChanged = false;
let removedSpacedContent = false;
for (const mutation of mutations) {
for (const node of mutation.removedNodes) if (this.hasSpacedTextInSubtree(node)) {
removedSpacedContent = true;
break;
}
if (removedSpacedContent) break;
}
for (const mutation of mutations) {
if (mutation.target.parentNode?.nodeName === "TITLE" || mutation.target.nodeName === "TITLE") {
titleChanged = true;
continue;
}
switch (mutation.type) {
case "characterData": {
const { target: node } = mutation;
if (node instanceof Text && node.parentNode) {
const lastWritten = this.lastWrittenData.get(node);
if (lastWritten !== void 0) {
if (node.data === lastWritten) break;
if (this.spaceNodeSync(node.parentNode, BrowserPangu.maxSyncTextNodes)) break;
}
queue.push(node.parentNode);
}
break;
}
case "childList":
for (const node of mutation.addedNodes) if (node.nodeType === Node.ELEMENT_NODE) {
if (removedSpacedContent && this.spaceNodeSync(node, BrowserPangu.maxSyncTextNodes)) continue;
queue.push(node);
} else if (node.nodeType === Node.TEXT_NODE && node.parentNode) {
if (removedSpacedContent && this.spaceNodeSync(node.parentNode, BrowserPangu.maxSyncTextNodes)) continue;
queue.push(node.parentNode);
}
break;
default: break;
}
}
if (titleChanged) spaceTitleDebounced();
spaceQueuedNodesDebounced();
});
this.autoSpacePageObserver = observer;
observer.observe(document.head, {
characterData: true,
childList: true,
subtree: true
});
observer.observe(document.body, {
characterData: true,
childList: true,
subtree: true
});
return observer;
}
};
var pangu = new BrowserPangu();
//#endregion
export { BrowserPangu, DomWalker, TaskQueue, TaskScheduler, VisibilityDetector, pangu as default, pangu };
//# sourceMappingURL=index.js.map