UNPKG

pangu

Version:

Paranoid text spacing for good readability, to automatically insert whitespace between CJK (Chinese, Japanese, Korean) and half-width characters (alphabetical letters, numerical digits and symbols).

637 lines (636 loc) 22.3 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { Pangu } from "../shared/index.js"; class DomWalker { static collectTextNodes(contextNode, reverse = false) { const nodes = []; if (!contextNode || contextNode instanceof DocumentFragment) { return nodes; } const walker = document.createTreeWalker(contextNode, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { if (!node.nodeValue || !/\S/.test(node.nodeValue)) { return NodeFilter.FILTER_REJECT; } let currentNode = node; while (currentNode) { if (currentNode instanceof Element) { if (this.ignoredTags.test(currentNode.nodeName)) { return NodeFilter.FILTER_REJECT; } if (this.isContentEditable(currentNode)) { return NodeFilter.FILTER_REJECT; } if (currentNode.classList.contains(this.ignoredClass)) { return NodeFilter.FILTER_REJECT; } } currentNode = currentNode.parentNode; } return NodeFilter.FILTER_ACCEPT; } }); while (walker.nextNode()) { nodes.push(walker.currentNode); } return reverse ? nodes.reverse() : nodes; } static canIgnoreNode(node) { let currentNode = node; if (currentNode && (this.isSpecificTag(currentNode, this.ignoredTags) || this.isContentEditable(currentNode) || this.hasIgnoredClass(currentNode))) { return true; } while (currentNode.parentNode) { currentNode = currentNode.parentNode; if (currentNode && (this.isSpecificTag(currentNode, this.ignoredTags) || this.isContentEditable(currentNode))) { return true; } } return false; } static isFirstTextChild(parentNode, targetNode) { const { childNodes } = parentNode; for (let i = 0; i < childNodes.length; i++) { const childNode = childNodes[i]; if (childNode.nodeType !== Node.COMMENT_NODE && childNode.textContent) { return childNode === targetNode; } } return false; } static isLastTextChild(parentNode, targetNode) { const { childNodes } = parentNode; for (let i = childNodes.length - 1; i > -1; i--) { const childNode = childNodes[i]; if (childNode.nodeType !== Node.COMMENT_NODE && childNode.textContent) { return childNode === targetNode; } } return false; } static isSpecificTag(node, tagRegex) { return !!(node && node.nodeName && tagRegex.test(node.nodeName)); } static isContentEditable(node) { return node instanceof HTMLElement && (node.isContentEditable || node.getAttribute("g_editable") === "true"); } static hasIgnoredClass(node) { if (node instanceof Element && node.classList.contains(this.ignoredClass)) { return true; } if (node.parentNode && node.parentNode instanceof Element && node.parentNode.classList.contains(this.ignoredClass)) { return true; } return false; } } __publicField(DomWalker, "blockTags", /^(div|p|h1|h2|h3|h4|h5|h6)$/i); __publicField(DomWalker, "ignoredTags", /^(code|pre|script|style|textarea|iframe|input)$/i); __publicField(DomWalker, "presentationalTags", /^(b|code|del|em|i|s|strong|kbd)$/i); __publicField(DomWalker, "spaceLikeTags", /^(br|hr|i|img|pangu)$/i); __publicField(DomWalker, "spaceSensitiveTags", /^(a|del|pre|s|strike|u)$/i); __publicField(DomWalker, "ignoredClass", "no-pangu-spacing"); class TaskQueue { constructor() { __publicField(this, "queue", []); __publicField(this, "isProcessing", false); __publicField(this, "onComplete"); } add(task) { this.queue.push(task); this.scheduleProcessing(); } clear() { this.queue.length = 0; this.onComplete = void 0; } setOnComplete(onComplete) { this.onComplete = onComplete; } get length() { return this.queue.length; } scheduleProcessing() { if (!this.isProcessing && this.queue.length > 0) { this.isProcessing = true; requestIdleCallback((deadline) => this.process(deadline), { timeout: 5e3 }); } } process(deadline) { var _a; while (deadline.timeRemaining() > 0 && this.queue.length > 0) { const task = this.queue.shift(); task == null ? void 0 : task(); } this.isProcessing = false; if (this.queue.length > 0) { this.scheduleProcessing(); } else { (_a = this.onComplete) == null ? void 0 : _a.call(this); } } } class TaskScheduler { constructor() { __publicField(this, "config", { enabled: true, chunkSize: 40, // Process 40 text nodes per idle cycle timeout: 2e3 // 2 second timeout for idle processing }); __publicField(this, "taskQueue", new TaskQueue()); } get queue() { return this.taskQueue; } processInChunks(items, processor, onComplete) { if (!this.config.enabled) { processor(items); onComplete == null ? void 0 : onComplete(); return; } if (items.length === 0) { onComplete == null ? void 0 : onComplete(); return; } if (onComplete) { this.taskQueue.setOnComplete(onComplete); } const chunks = []; for (let i = 0; i < items.length; i += this.config.chunkSize) { chunks.push(items.slice(i, i + this.config.chunkSize)); } for (const chunk of chunks) { this.taskQueue.add(() => { processor(chunk); }); } } clear() { this.taskQueue.clear(); } updateConfig(config) { Object.assign(this.config, config); } } class VisibilityDetector { constructor() { __publicField(this, "config", { enabled: true, commonHiddenPatterns: { clipRect: true, // clip: rect(1px, 1px, 1px, 1px) patterns displayNone: true, // display: none visibilityHidden: true, // visibility: hidden opacityZero: true, // opacity: 0 heightWidth1px: true // height: 1px; width: 1px } }); } isElementVisuallyHidden(element) { if (!this.config.enabled) { return false; } const style = getComputedStyle(element); const patterns = this.config.commonHiddenPatterns; if (patterns.displayNone && style.display === "none") { return true; } if (patterns.visibilityHidden && style.visibility === "hidden") { return true; } if (patterns.opacityZero && parseFloat(style.opacity) === 0) { return true; } if (patterns.clipRect) { const clip = style.clip; if (clip && (clip.includes("rect(1px, 1px, 1px, 1px)") || clip.includes("rect(0px, 0px, 0px, 0px)") || clip.includes("rect(0, 0, 0, 0)"))) { return true; } } if (patterns.heightWidth1px) { const height = parseInt(style.height, 10); const width = parseInt(style.width, 10); if (height === 1 && width === 1) { const overflow = style.overflow; const position = style.position; if (overflow === "hidden" && position === "absolute") { return true; } } } return false; } shouldSkipSpacingAfterNode(node) { if (!this.config.enabled) { return false; } let elementToCheck = null; if (node instanceof Element) { elementToCheck = node; } else if (node.parentElement) { elementToCheck = node.parentElement; } if (elementToCheck && this.isElementVisuallyHidden(elementToCheck)) { return true; } let currentElement = elementToCheck == null ? void 0 : elementToCheck.parentElement; while (currentElement) { if (this.isElementVisuallyHidden(currentElement)) { return true; } currentElement = currentElement.parentElement; } return false; } shouldSkipSpacingBeforeNode(node) { if (!this.config.enabled) { return false; } let previousNode = node.previousSibling; if (!previousNode && node.parentElement) { let parent = node.parentElement; while (parent && !previousNode) { previousNode = parent.previousSibling; if (!previousNode) { parent = parent.parentElement; } } } if (previousNode) { if (previousNode instanceof Element && this.isElementVisuallyHidden(previousNode)) { return true; } else if (previousNode instanceof Text && previousNode.parentElement && this.isElementVisuallyHidden(previousNode.parentElement)) { return true; } } return false; } updateConfig(config) { Object.assign(this.config, config); if (config.commonHiddenPatterns) { Object.assign(this.config.commonHiddenPatterns, config.commonHiddenPatterns); } } } function once(func) { let executed = false; return function(...args) { if (executed) { return void 0; } executed = true; return func(...args); }; } function debounce(func, delay, mustRunDelay = Infinity) { let timer = null; let startTime = null; return function(...args) { const currentTime = Date.now(); if (timer) { clearTimeout(timer); } if (!startTime) { startTime = currentTime; } if (currentTime - startTime >= mustRunDelay) { func(...args); startTime = currentTime; } else { timer = window.setTimeout(() => { func(...args); }, delay); } }; } class BrowserPangu extends Pangu { constructor() { super(...arguments); __publicField(this, "isAutoSpacingPageExecuted", false); __publicField(this, "autoSpacingPageObserver", null); __publicField(this, "taskScheduler", new TaskScheduler()); __publicField(this, "visibilityDetector", new VisibilityDetector()); } // PUBLIC autoSpacingPage({ pageDelayMs = 1e3, nodeDelayMs = 500, nodeMaxWaitMs = 2e3 } = {}) { if (!(document.body instanceof Node)) { return; } if (this.isAutoSpacingPageExecuted) { return; } this.isAutoSpacingPageExecuted = true; this.waitForVideosToLoad(pageDelayMs, once(() => this.spacingPage())); this.setupAutoSpacingPageObserver(nodeDelayMs, nodeMaxWaitMs); } spacingPage() { const title = document.querySelector("head > title"); if (title) { this.spacingNode(title); } this.spacingNode(document.body); } spacingNode(contextNode) { const textNodes = DomWalker.collectTextNodes(contextNode, true); if (this.taskScheduler.config.enabled) { this.spacingTextNodesInQueue(textNodes); } else { this.spacingTextNodes(textNodes); } } stopAutoSpacingPage() { if (this.autoSpacingPageObserver) { this.autoSpacingPageObserver.disconnect(); this.autoSpacingPageObserver = null; } this.isAutoSpacingPageExecuted = false; } isElementVisuallyHidden(element) { return this.visibilityDetector.isElementVisuallyHidden(element); } // INTERNAL // TODO: Refactor this method - it's too large and handles too many responsibilities spacingTextNodes(textNodes) { let currentTextNode; let nextTextNode = null; for (let i = 0; i < textNodes.length; i++) { currentTextNode = textNodes[i]; if (!currentTextNode) { continue; } if (DomWalker.canIgnoreNode(currentTextNode)) { nextTextNode = currentTextNode; continue; } if (currentTextNode instanceof Text) { if (this.visibilityDetector.config.enabled && currentTextNode.data.startsWith(" ") && this.visibilityDetector.shouldSkipSpacingBeforeNode(currentTextNode)) { currentTextNode.data = currentTextNode.data.substring(1); } if (currentTextNode.data.length === 1 && /["\u201c\u201d]/.test(currentTextNode.data)) { if (currentTextNode.previousSibling) { const prevNode = currentTextNode.previousSibling; if (prevNode.nodeType === Node.ELEMENT_NODE && prevNode.textContent) { const lastChar = prevNode.textContent.slice(-1); if (/[\u4e00-\u9fff]/.test(lastChar)) { currentTextNode.data = ` ${currentTextNode.data}`; } } } } else { const newText = this.spacingText(currentTextNode.data); if (currentTextNode.data !== newText) { currentTextNode.data = newText; } } } if (nextTextNode) { if (currentTextNode.nextSibling && DomWalker.spaceLikeTags.test(currentTextNode.nextSibling.nodeName)) { nextTextNode = currentTextNode; continue; } if (!(currentTextNode instanceof Text) || !(nextTextNode instanceof Text)) { continue; } const currentEndsWithSpace = currentTextNode.data.endsWith(" "); const nextStartsWithSpace = nextTextNode.data.startsWith(" "); let hasWhitespaceBetween = false; let currentAncestor = currentTextNode; while (currentAncestor.parentNode && DomWalker.isLastTextChild(currentAncestor.parentNode, currentAncestor) && !DomWalker.spaceSensitiveTags.test(currentAncestor.parentNode.nodeName)) { currentAncestor = currentAncestor.parentNode; } let nextAncestor = nextTextNode; while (nextAncestor.parentNode && DomWalker.isFirstTextChild(nextAncestor.parentNode, nextAncestor) && !DomWalker.spaceSensitiveTags.test(nextAncestor.parentNode.nodeName)) { nextAncestor = nextAncestor.parentNode; } let nodeBetween = currentAncestor.nextSibling; while (nodeBetween && nodeBetween !== nextAncestor) { if (nodeBetween.nodeType === Node.TEXT_NODE && nodeBetween.textContent && /\s/.test(nodeBetween.textContent)) { hasWhitespaceBetween = true; break; } nodeBetween = nodeBetween.nextSibling; } if (currentEndsWithSpace || nextStartsWithSpace || hasWhitespaceBetween) { nextTextNode = currentTextNode; continue; } const testText = currentTextNode.data.slice(-1) + nextTextNode.data.slice(0, 1); const testNewText = this.spacingText(testText); const currentLast = currentTextNode.data.slice(-1); const nextFirst = nextTextNode.data.slice(0, 1); const isQuote = (char) => /["\u201c\u201d]/.test(char); const isCJK = (char) => /[\u4e00-\u9fff]/.test(char); const skipSpacing = isQuote(currentLast) && isCJK(nextFirst) || isCJK(currentLast) && isQuote(nextFirst); if (testNewText !== testText && !skipSpacing) { let nextNode = nextTextNode; while (nextNode.parentNode && !DomWalker.spaceSensitiveTags.test(nextNode.nodeName) && DomWalker.isFirstTextChild(nextNode.parentNode, nextNode)) { nextNode = nextNode.parentNode; } let currentNode = currentTextNode; while (currentNode.parentNode && !DomWalker.spaceSensitiveTags.test(currentNode.nodeName) && DomWalker.isLastTextChild(currentNode.parentNode, currentNode)) { currentNode = currentNode.parentNode; } if (currentNode.nextSibling) { if (DomWalker.spaceLikeTags.test(currentNode.nextSibling.nodeName)) { nextTextNode = currentTextNode; continue; } } if (!DomWalker.blockTags.test(currentNode.nodeName)) { if (!DomWalker.spaceSensitiveTags.test(nextNode.nodeName)) { if (!DomWalker.ignoredTags.test(nextNode.nodeName) && !DomWalker.blockTags.test(nextNode.nodeName)) { if (nextTextNode.previousSibling) { if (!DomWalker.spaceLikeTags.test(nextTextNode.previousSibling.nodeName)) { if (nextTextNode instanceof Text && !nextTextNode.data.startsWith(" ")) { if (!this.visibilityDetector.shouldSkipSpacingBeforeNode(nextTextNode)) { nextTextNode.data = ` ${nextTextNode.data}`; } } } } else { if (!DomWalker.canIgnoreNode(nextTextNode)) { if (nextTextNode instanceof Text && !nextTextNode.data.startsWith(" ")) { if (!this.visibilityDetector.shouldSkipSpacingBeforeNode(nextTextNode)) { nextTextNode.data = ` ${nextTextNode.data}`; } } } } } } else if (!DomWalker.spaceSensitiveTags.test(currentNode.nodeName)) { if (currentTextNode instanceof Text && !currentTextNode.data.endsWith(" ")) { if (!this.visibilityDetector.shouldSkipSpacingAfterNode(currentTextNode)) { currentTextNode.data = `${currentTextNode.data} `; } } } else { if (!this.visibilityDetector.shouldSkipSpacingAfterNode(currentTextNode)) { const panguSpace = document.createElement("pangu"); panguSpace.innerHTML = " "; if (nextNode.parentNode) { if (nextNode.previousSibling) { if (!DomWalker.spaceLikeTags.test(nextNode.previousSibling.nodeName)) { nextNode.parentNode.insertBefore(panguSpace, nextNode); } } else { nextNode.parentNode.insertBefore(panguSpace, nextNode); } } if (!panguSpace.previousElementSibling) { if (panguSpace.parentNode) { panguSpace.parentNode.removeChild(panguSpace); } } } } } } } nextTextNode = currentTextNode; } } spacingTextNodesInQueue(textNodes, onComplete) { if (this.visibilityDetector.config.enabled) { if (this.taskScheduler.config.enabled) { this.taskScheduler.queue.add(() => { this.spacingTextNodes(textNodes); }); if (onComplete) { this.taskScheduler.queue.setOnComplete(onComplete); } } else { this.spacingTextNodes(textNodes); onComplete == null ? void 0 : onComplete(); } return; } const task = (chunkedTextNodes) => this.spacingTextNodes(chunkedTextNodes); this.taskScheduler.processInChunks(textNodes, task, onComplete); } waitForVideosToLoad(delayMs, onLoaded) { const videos = Array.from(document.getElementsByTagName("video")); if (videos.length === 0) { setTimeout(onLoaded, delayMs); } else { const allVideosLoaded = videos.every((video) => video.readyState >= 3); if (allVideosLoaded) { setTimeout(onLoaded, delayMs); } else { let loadedCount = 0; const videoCount = videos.length; const checkAllLoaded = () => { loadedCount++; if (loadedCount >= videoCount) { setTimeout(onLoaded, delayMs); } }; for (const video of videos) { if (video.readyState >= 3) { checkAllLoaded(); } else { video.addEventListener("loadeddata", checkAllLoaded, { once: true }); } } setTimeout(onLoaded, delayMs + 5e3); } } } setupAutoSpacingPageObserver(nodeDelayMs, nodeMaxWaitMs) { if (this.autoSpacingPageObserver) { this.autoSpacingPageObserver.disconnect(); this.autoSpacingPageObserver = null; } const queue = []; const debouncedSpacingTitle = debounce( () => { const titleElement = document.querySelector("head > title"); if (titleElement) { this.spacingNode(titleElement); } }, nodeDelayMs, nodeMaxWaitMs ); const debouncedSpacingNode = debounce( () => { if (this.taskScheduler.config.enabled) { const nodesToProcess = [...queue]; queue.length = 0; if (nodesToProcess.length > 0) { const allTextNodes = []; for (const node of nodesToProcess) { const textNodes = DomWalker.collectTextNodes(node, true); allTextNodes.push(...textNodes); } this.spacingTextNodesInQueue(allTextNodes); } } else { while (queue.length) { const node = queue.shift(); if (node) { this.spacingNode(node); } } } }, nodeDelayMs, nodeMaxWaitMs ); this.autoSpacingPageObserver = new MutationObserver((mutations) => { var _a; let titleChanged = false; for (const mutation of mutations) { if (((_a = mutation.target.parentNode) == null ? void 0 : _a.nodeName) === "TITLE" || mutation.target.nodeName === "TITLE") { titleChanged = true; continue; } switch (mutation.type) { case "characterData": const { target: node } = mutation; if (node.nodeType === Node.TEXT_NODE && node.parentNode) { queue.push(node.parentNode); } break; case "childList": for (const node2 of mutation.addedNodes) { if (node2.nodeType === Node.ELEMENT_NODE) { queue.push(node2); } else if (node2.nodeType === Node.TEXT_NODE && node2.parentNode) { queue.push(node2.parentNode); } } break; } } if (titleChanged) { debouncedSpacingTitle(); } debouncedSpacingNode(); }); this.autoSpacingPageObserver.observe(document.head, { characterData: true, childList: true, subtree: true // Need subtree to observe text node changes inside title }); this.autoSpacingPageObserver.observe(document.body, { characterData: true, childList: true, subtree: true }); } } const pangu = new BrowserPangu(); export { BrowserPangu, pangu as default, pangu }; //# sourceMappingURL=pangu.js.map