UNPKG

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
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