eripusisu
Version:
Text truncation library
327 lines (326 loc) • 11.6 kB
JavaScript
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);
}