livebook/assets/js/hooks/virtualized_lines.js
2024-02-03 00:58:44 +08:00

145 lines
3.6 KiB
JavaScript

import HyperList from "hyperlist";
import { parseHookProps } from "../lib/attribute";
import {
findChildOrThrow,
getLineHeight,
isScrolledToEnd,
scrollToEnd,
} from "../lib/utils";
/**
* A hook used to render text lines as a virtual list, so that only
* the visible lines are actually in the DOM.
*
* ## Props
*
* * `max-height` - the maximum height of the element, exceeding
* this height enables scrolling
*
* * `follow` - whether to automatically scroll to the bottom as
* new lines appear
*
* * `max-lines` - the maximum number of lines to keep in the DOM.
* By default all lines are kept
*
* * `ignore-trailing-empty-line` - whether to ignore the last
* line if it is empty
*
* ## Children
*
* * `[data-template]` - a hidden container containing all the line
* elements, each with a data-line attribute
*
* * `[data-content]` - the target element to render the virtualized
* lines into, it should contain the styling relevant text styles
*/
const VirtualizedLines = {
mounted() {
this.props = this.getProps();
this.lineHeight = getLineHeight(this.el);
this.templateEl = findChildOrThrow(this.el, "[data-template]");
this.contentEl = findChildOrThrow(this.el, "[data-content]");
this.capLines();
const config = this.hyperListConfig();
this.virtualizedList = new HyperList(this.contentEl, config);
if (this.props.follow) {
scrollToEnd(this.contentEl);
}
},
updated() {
this.props = this.getProps();
this.capLines();
const shouldScrollToEnd =
this.props.follow && isScrolledToEnd(this.contentEl);
const config = this.hyperListConfig();
this.virtualizedList.refresh(this.contentEl, config);
if (shouldScrollToEnd) {
scrollToEnd(this.contentEl);
}
},
getProps() {
return parseHookProps(this.el, [
"max-height",
"follow",
"max-lines",
"ignore-trailing-empty-line",
]);
},
hyperListConfig() {
const lineEls = this.getLineElements();
const numberOfLines = lineEls.length;
const height = Math.min(
this.props.maxHeight,
this.lineHeight * numberOfLines,
);
return {
height,
total: numberOfLines,
itemHeight: this.lineHeight,
generate: (index) => {
const node = lineEls[index].cloneNode(true);
node.removeAttribute("id");
return node;
},
afterRender: () => {
// The content element has a fixed height and when the horizontal
// scrollbar appears, it's treated as part of the element's content.
// To accommodate for the scrollbar we dynamically add more height
// to the element.
if (this.contentEl.scrollWidth > this.contentEl.clientWidth) {
this.contentEl.style.height = `${height + 12}px`;
} else {
this.contentEl.style.height = `${height}px`;
}
},
};
},
getLineElements() {
const lineEls = Array.from(this.templateEl.querySelectorAll("[data-line]"));
if (lineEls.length === 0) {
return [];
}
const lastLineEl = lineEls[lineEls.length - 1];
if (this.props.ignoreTrailingEmptyLine && lastLineEl.innerText === "") {
return lineEls.slice(0, -1);
} else {
return lineEls;
}
},
capLines() {
if (this.props.maxLines) {
const lineEls = Array.from(
this.templateEl.querySelectorAll("[data-line]"),
);
const ignoredLineEls = lineEls.slice(0, -this.props.maxLines);
const [first, ...rest] = ignoredLineEls;
rest.forEach((lineEl) => lineEl.remove());
if (first) {
first.innerHTML = "...";
}
}
},
};
export default VirtualizedLines;