refactor(terminal): rewrite terminal virtualization (#2916)

Signed-off-by: Evan Song <theevansong@gmail.com>
This commit is contained in:
Evan Song 2024-11-05 13:32:11 -07:00 committed by GitHub
parent d321843c02
commit d0efa44c9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -86,7 +86,7 @@
<ul <ul
class="m-0 list-none p-0" class="m-0 list-none p-0"
data-pyro-terminal-virtual-list data-pyro-terminal-virtual-list
:style="{ transform: `translateY(${offsetY}px)` }" :style="virtualListStyle"
aria-live="polite" aria-live="polite"
role="listbox" role="listbox"
> >
@ -120,7 +120,7 @@
<Transition name="scroll-to-bottom"> <Transition name="scroll-to-bottom">
<button <button
v-if="bottomThreshold > 0" v-if="bottomThreshold > 0 && !isScrolledToBottom"
data-pyro-scrolltobottom data-pyro-scrolltobottom
label="Scroll to bottom" label="Scroll to bottom"
class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95" class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
@ -146,11 +146,12 @@ const props = defineProps<{
}>(); }>();
const scrollContainer = ref<HTMLElement | null>(null); const scrollContainer = ref<HTMLElement | null>(null);
const itemRefs = ref<HTMLElement[]>([]);
const itemHeights = ref<number[]>([]); const itemHeights = ref<number[]>([]);
const averageItemHeight = ref(36); const averageItemHeight = ref(36);
const bottomThreshold = ref(0); const bottomThreshold = ref(0);
const bufferSize = 5; const bufferSize = 5;
const cachedHeights = ref<Map<string, number>>(new Map());
const isAutoScrolling = ref(false);
const progressiveBlurIterations = ref(8); const progressiveBlurIterations = ref(8);
@ -173,10 +174,12 @@ const totalHeight = computed(
); );
watch(totalHeight, () => { watch(totalHeight, () => {
if (!initial.value) { if (isScrolledToBottom.value) {
scrollToBottom(); scrollToBottom();
} }
initial.value = true; if (!initial.value) {
initial.value = true;
}
}); });
const lerp = (start: number, end: number, t: number) => start * (1 - t) + end * t; const lerp = (start: number, end: number, t: number) => start * (1 - t) + end * t;
@ -249,38 +252,37 @@ const visibleItems = computed(() =>
const offsetY = computed(() => getItemOffset(visibleStartIndex.value)); const offsetY = computed(() => getItemOffset(visibleStartIndex.value));
const handleListScroll = () => { const handleListScroll = () => {
if (scrollContainer.value) { if (!scrollContainer.value) return;
scrollTop.value = scrollContainer.value.scrollTop;
clientHeight.value = scrollContainer.value.clientHeight;
const scrollHeight = scrollContainer.value.scrollHeight; const container = scrollContainer.value;
isScrolledToBottom.value = scrollTop.value + clientHeight.value >= scrollHeight - 32; // threshold scrollTop.value = container.scrollTop;
clientHeight.value = container.clientHeight;
if (!isScrolledToBottom.value) { const scrollHeight = container.scrollHeight;
userHasScrolled.value = true; const threshold = 32;
}
isScrolledToBottom.value = scrollHeight - scrollTop.value - clientHeight.value <= threshold;
if (!isScrolledToBottom.value && !isAutoScrolling.value) {
userHasScrolled.value = true;
} }
const maxBottom = 256; bottomThreshold.value = Math.min(1, (scrollHeight - scrollTop.value - clientHeight.value) / 256);
bottomThreshold.value = Math.min(
1,
((scrollContainer.value?.scrollHeight || 1) - scrollTop.value - clientHeight.value) / maxBottom,
);
}; };
const updateItemHeights = () => { const updateItemHeights = async () => {
nextTick(() => { if (!scrollContainer.value) return;
itemRefs.value.forEach((el, index) => {
if (el) {
const actualIndex = visibleStartIndex.value + index;
itemHeights.value[actualIndex] = el.offsetHeight;
}
});
const measuredHeights = itemHeights.value.filter((h) => h > 0); await nextTick();
if (measuredHeights.length > 0) { const items =
averageItemHeight.value = scrollContainer.value?.querySelectorAll("[data-pyro-terminal-virtual-list] li") || [];
measuredHeights.reduce((sum, height) => sum + height, 0) / measuredHeights.length; items.forEach((el, idx) => {
const index = visibleStartIndex.value + idx;
const height = el.getBoundingClientRect().height;
itemHeights.value[index] = height;
const content = props.consoleOutput[index];
if (content) {
cachedHeights.value.set(content, height);
} }
}); });
}; };
@ -292,16 +294,24 @@ const updateClientHeight = () => {
}; };
const scrollToBottom = () => { const scrollToBottom = () => {
if (scrollContainer.value) { if (!scrollContainer.value) return;
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight + 99999999;
userHasScrolled.value = false;
isScrolledToBottom.value = true;
}
};
const debouncedScrollToBottom = () => { isAutoScrolling.value = true;
requestAnimationFrame(() => { const container = scrollContainer.value;
setTimeout(scrollToBottom, 0);
nextTick(() => {
const maxScroll = container.scrollHeight - container.clientHeight;
container.scrollTop = maxScroll;
setTimeout(() => {
if (container.scrollTop < maxScroll) {
container.scrollTop = maxScroll;
}
isAutoScrolling.value = false;
userHasScrolled.value = false;
isScrolledToBottom.value = true;
handleListScroll();
}, 50);
}); });
}; };
@ -442,13 +452,30 @@ const handleKeydown = (event: KeyboardEvent) => {
} }
}; };
onMounted(() => { const initializeTerminal = async () => {
if (!scrollContainer.value) return;
updateClientHeight(); updateClientHeight();
updateItemHeights();
nextTick(() => { const initialHeights = props.consoleOutput.map(
updateItemHeights(); (content) => cachedHeights.value.get(content) || averageItemHeight.value,
setTimeout(scrollToBottom, 200); );
}); itemHeights.value = initialHeights;
await nextTick();
await updateItemHeights();
await nextTick();
const container = scrollContainer.value;
container.scrollTop = container.scrollHeight;
handleListScroll();
initial.value = true;
};
onMounted(async () => {
await initializeTerminal();
window.addEventListener("resize", updateClientHeight); window.addEventListener("resize", updateClientHeight);
window.addEventListener("keydown", handleKeydown); window.addEventListener("keydown", handleKeydown);
}); });
@ -461,21 +488,37 @@ onUnmounted(() => {
watch( watch(
() => props.consoleOutput, () => props.consoleOutput,
() => { async (newOutput) => {
const newItemsCount = props.consoleOutput.length - itemHeights.value.length; const newItemsCount = newOutput.length - itemHeights.value.length;
if (newItemsCount > 0) {
itemHeights.value.push(...Array(newItemsCount).fill(averageItemHeight.value));
}
nextTick(() => { if (newItemsCount > 0) {
updateItemHeights(); const shouldScroll = isScrolledToBottom.value || !userHasScrolled.value;
if (!userHasScrolled.value || isScrolledToBottom.value) {
debouncedScrollToBottom(); const newHeights = Array(newItemsCount)
.fill(0)
.map((_, i) => {
const index = itemHeights.value.length + i;
const content = newOutput[index];
return cachedHeights.value.get(content) || averageItemHeight.value;
});
itemHeights.value.push(...newHeights);
if (shouldScroll) {
await nextTick();
scrollToBottom();
await nextTick();
await updateItemHeights();
scrollToBottom();
} }
}); }
}, },
{ deep: true, immediate: true }, { deep: true },
); );
const virtualListStyle = computed(() => ({
transform: `translateY(${offsetY.value}px)`,
}));
watch([visibleStartIndex, visibleEndIndex], updateItemHeights); watch([visibleStartIndex, visibleEndIndex], updateItemHeights);
@ -496,6 +539,15 @@ watch(isFullScreen, () => {
updateItemHeights(); updateItemHeights();
}); });
}); });
watch(
itemHeights,
() => {
const totalHeight = itemHeights.value.reduce((sum, height) => sum + height, 0);
averageItemHeight.value = totalHeight / itemHeights.value.length || averageItemHeight.value;
},
{ deep: true },
);
</script> </script>
<style scoped> <style scoped>
@ -611,6 +663,13 @@ html.dark-mode .progressive-gradient {
overflow: hidden !important; overflow: hidden !important;
} }
[data-pyro-terminal-root] {
will-change: transform;
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: subpixel-antialiased;
}
[data-pyro-terminal-root] { [data-pyro-terminal-root] {
user-select: none; user-select: none;
} }