refactor(terminal): rewrite terminal virtualization (#2916)
Signed-off-by: Evan Song <theevansong@gmail.com>
This commit is contained in:
parent
d321843c02
commit
d0efa44c9e
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user