UNPKG

eripusisu

Version:
327 lines (326 loc) 11.6 kB
export default class Eripusisu { container; lines; options; originalNodes = document.createDocumentFragment(); targetNodes = []; linesMemo = []; rects = []; rectsMemo = []; expanded = true; constructor(container, lines = 3, options = {}) { this.container = container; this.lines = lines; this.options = options; this.expanded = options.expanded ?? false; options.ellipsisText = options.ellipsisText ?? "…"; this.handleClick = this.handleClick.bind(this); this.prepareAttributes(); this.bindEvents(); this.rebuild(); this.refresh(); } prepareAttributes() { const randomId = "eripusisu-" + randomString(); this.container.id = randomId; this.options.toggleButton?.setAttribute("aria-controls", randomId); this.updateAttributes(); } bindEvents() { this.options.toggleButton?.addEventListener("click", this.handleClick); } unbindEvents() { this.options.toggleButton?.removeEventListener("click", this.handleClick); } handleClick(e) { e.preventDefault(); this.toggle(); } updateAttributes() { const attrToAdd = this.expanded ? "eripusisu-expanded" : "eripusisu-collapsed"; const attrToRemove = this.expanded ? "eripusisu-collapsed" : "eripusisu-expanded"; this.container.setAttribute(attrToAdd, ""); this.container.removeAttribute(attrToRemove); this.options.toggleButton?.setAttribute("aria-expanded", String(this.expanded)); } dispatchToggleEvent() { const event = document.createEvent("CustomEvent"); event.initCustomEvent("eripusisu-toggle", true, false, this.expanded); this.container.dispatchEvent(event); } emptyTarget() { while (this.container.firstChild) { this.container.removeChild(this.container.firstChild); } } revertToOriginalNodes() { this.emptyTarget(); this.container.appendChild(this.originalNodes.cloneNode(true)); this.targetNodes = collectNodes(this.container, (node) => { return ((node instanceof HTMLElement && ["IMG", "PICTURE", "SVG"].indexOf(node.tagName) >= 0) || (node instanceof Text && node.textContent.trim() !== "")); }); } prepareRects() { this.rects = []; this.rectsMemo = [0]; this.linesMemo = []; let currentLine = 0; const rectTraverser = new RectTraverser((rect, index, lineCount) => { if (currentLine < lineCount) { currentLine = lineCount; this.linesMemo.push(index); } if (lineCount > this.lines + 1) { return true; } }, [], this.options.rtl); for (let i = 0; i < this.targetNodes.length; i += 1) { const node = this.targetNodes[i]; const range = document.createRange(); range.selectNode(node); const rects = this.getRectsFromRange(range, node); this.rectsMemo.push(this.rectsMemo[this.rectsMemo.length - 1] + rects.length); for (let j = 0; j < rects.length; j += 1) { const rect = rects[j]; rect.nodeIndex = i; this.rects.push(rect); const pushResult = rectTraverser.push(rect); if (pushResult) { return; } } } } getRectsFromRange(range, node) { const rects = []; const domRects = range.getClientRects(); const nearestScrollTop = getNearestScrollTop(node); for (let i = 0; i < domRects.length; i += 1) { const { top, right, bottom, left, width, height } = domRects[i]; rects.push({ top: top + nearestScrollTop, right, bottom: bottom + nearestScrollTop, left, width, height, }); } return rects; } isRectsWithinLines(rects, lines) { let flag = true; new RectTraverser((rect, index, lineCount) => { if (lines < lineCount) { flag = false; return false; } }, rects, this.options.rtl); return flag; } truncate() { // 試行中のパフォーマンス向上 // @ts-ignore this.container.style.contain = "strict"; this.container.style.height = "0"; const teardown = () => { // @ts-ignore this.container.style.contain = ""; this.container.style.height = ""; }; // 文字長を調整する対象の DOMRect のインデックス const targetRectIndex = Math.max(0, this.linesMemo[this.lines]); let targetNode; let targetText; let resultLength = -1; // 対象ノードを遡りながらテキストの truncate を試みる for (let targetNodeIndex = this.rects[targetRectIndex].nodeIndex; targetNodeIndex >= 0; targetNodeIndex -= 1) { targetNode = this.targetNodes[targetNodeIndex]; // テキストノードでなければテキスト処理ができないので、ここで終了 if (!(targetNode instanceof Text)) { if (targetNodeIndex === this.rects[targetRectIndex].nodeIndex) { continue; } else { break; } } targetText = targetNode.textContent; // 対象ノードより手前の DOMRect は再利用する const rects = this.rects.slice(0, this.rectsMemo[targetNodeIndex]); const isRectsWithinLines = (textPost) => { targetNode.textContent = targetText.slice(0, textPost) + this.options.ellipsisText; const range = document.createRange(); range.setStart(targetNode, 0); range.setEnd(targetNode, targetNode.textContent.length); const testRects = []; testRects.push.apply(testRects, rects); testRects.push.apply(testRects, this.getRectsFromRange(range, targetNode)); return this.isRectsWithinLines(testRects, this.lines); }; // 省略文字のみにしても収まらない場合、ここで終了 if (!isRectsWithinLines(0)) { continue; } // 二分探索法で行内に収まるテキスト量を探る resultLength = binarySearchBetween(0, targetText.length, (pos) => { return isRectsWithinLines(pos) ? -1 : 1; }); // 行内におさまればその時点で終了 if (resultLength >= 0) { break; } } const resultRange = document.createRange(); resultRange.setStartBefore(this.container.firstChild); if (targetNode instanceof Text) { if (resultLength < 0) { return teardown(); } targetNode.textContent = targetText.slice(0, resultLength) + this.options.ellipsisText; resultRange.setEnd(targetNode, targetNode.textContent.length); } else { resultRange.setEndAfter(targetNode); } const resultContents = resultRange.cloneContents(); this.emptyTarget(); this.container.appendChild(resultContents); teardown(); } get visuallyCollapsed() { return this.expanded ? false : this.needsCollapse; } get needsCollapse() { return this.linesMemo.length > this.lines; } rebuild() { this.originalNodes = document.createDocumentFragment(); while (this.container.firstChild) { this.originalNodes.appendChild(this.container.firstChild); } this.revertToOriginalNodes(); } refresh() { this.revertToOriginalNodes(); if (!this.expanded) { this.prepareRects(); if (this.visuallyCollapsed) { this.truncate(); } } this.updateAttributes(); } toggle(mode) { const expand = mode ?? !this.expanded; expand ? this.expand() : this.collapse(); } expand() { this.revertToOriginalNodes(); this.expanded = true; this.updateAttributes(); this.dispatchToggleEvent(); } collapse() { this.revertToOriginalNodes(); this.prepareRects(); this.expanded = false; if (this.visuallyCollapsed) { this.truncate(); } this.updateAttributes(); this.dispatchToggleEvent(); } destroy() { this.revertToOriginalNodes(); this.unbindEvents(); } } class RectTraverser { callback; rects; rtl; lineCount = 0; lastBlockStart = -Infinity; lastInlineEnd = Infinity; constructor(callback, rects = [], rtl = false) { this.callback = callback; this.rects = rects; this.rtl = rtl; if (this.rtl) { this.lastInlineEnd = -Infinity; } for (let i = 0; i < rects.length; i += 1) { const rect = rects[i]; const result = this.process(rect, i); if (result === false) { break; } } } push(rect) { this.rects.push(rect); return this.process(rect, this.rects.length - 1); } process(rect, i) { // 改行があったとみなす条件 if (this.lastBlockStart + 10 < rect.top && (this.rtl ? this.lastInlineEnd < rect.right : this.lastInlineEnd > rect.left)) { this.lineCount += 1; } this.lastBlockStart = rect.top; this.lastInlineEnd = this.rtl ? rect.left : rect.right; return this.callback(rect, i, this.lineCount); } } function binarySearchBetween(from, to, compareFn) { let temp = -1; while (from <= to) { const middle = (from + to) >> 1; const result = compareFn(middle); if (result === 0) { return middle; } if (result > 0) { to = middle - 1; } else { temp = middle; from = middle + 1; } } return temp; } function collectNodes(node, predicate) { const length = node.childNodes.length; if (predicate(node)) { return [node]; } const collection = []; for (let i = 0; i < length; i += 1) { const deep = collectNodes(node.childNodes[i], predicate); collection.push.apply(collection, deep); } return collection; } function getNearestScrollTop(targetEl) { for (let currentEl = targetEl; currentEl; currentEl = currentEl.parentNode) { if (currentEl.scrollTop > 0) { return currentEl.scrollTop; } } return 0; } function randomString() { return Math.random().toString(36).slice(2); }