Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)

This commit is contained in:
InnovA AI
2026-03-16 21:47:37 +00:00
commit 42772a31ed
365 changed files with 103572 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
/**
* Virtual Scrolling Composable
*
* Renders only visible items plus a buffer for smooth scrolling.
* Critical for handling long transcriptions (4500+ segments) without UI lag.
*
* Usage:
* const { visibleItems, spacerBefore, spacerAfter, onScroll, scrollToIndex } = useVirtualScroll({
* items: segmentsRef,
* itemHeight: 48,
* containerRef: scrollContainerRef,
* overscan: 5
* });
*/
export function useVirtualScroll(options) {
const { ref, computed, watch, onMounted, onUnmounted } = Vue;
const {
items, // Ref to the full array of items
itemHeight = 48, // Height of each item in pixels (fixed height mode)
containerRef, // Ref to the scrollable container element
overscan = 5, // Number of items to render outside viewport
keyField = null // Optional field to use for unique keys
} = options;
// Internal state
const scrollTop = ref(0);
const containerHeight = ref(0);
const isInitialized = ref(false);
// Calculate visible range based on scroll position
const visibleRange = computed(() => {
if (!isInitialized.value || !items.value) {
return { start: 0, end: Math.min(20, items.value?.length || 0) };
}
const totalItems = items.value.length;
if (totalItems === 0) {
return { start: 0, end: 0 };
}
// Calculate first visible item
const firstVisible = Math.floor(scrollTop.value / itemHeight);
// Calculate number of items that fit in viewport
const visibleCount = Math.ceil(containerHeight.value / itemHeight);
// Add overscan for smooth scrolling
const start = Math.max(0, firstVisible - overscan);
const end = Math.min(totalItems, firstVisible + visibleCount + overscan);
return { start, end };
});
// Slice of items to actually render
const visibleItems = computed(() => {
if (!items.value || items.value.length === 0) {
return [];
}
const { start, end } = visibleRange.value;
// Map items with their original indices for proper data binding
return items.value.slice(start, end).map((item, localIndex) => ({
...item,
_virtualIndex: start + localIndex,
_originalIndex: start + localIndex
}));
});
// Spacer height before visible items (for scroll position)
const spacerBefore = computed(() => {
return visibleRange.value.start * itemHeight;
});
// Spacer height after visible items
const spacerAfter = computed(() => {
if (!items.value) return 0;
const remainingItems = items.value.length - visibleRange.value.end;
return Math.max(0, remainingItems * itemHeight);
});
// Total height of all items (for scroll container)
const totalHeight = computed(() => {
if (!items.value) return 0;
return items.value.length * itemHeight;
});
// Handle scroll events
const onScroll = (event) => {
scrollTop.value = event.target.scrollTop;
};
// Initialize container height observer
let resizeObserver = null;
const initializeContainer = () => {
if (!containerRef.value) return;
// Get initial height
containerHeight.value = containerRef.value.clientHeight;
isInitialized.value = true;
// Watch for container size changes
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight.value = entry.contentRect.height;
}
});
resizeObserver.observe(containerRef.value);
};
// Scroll to a specific index
const scrollToIndex = (index, behavior = 'smooth') => {
if (!containerRef.value || !items.value) return;
const targetIndex = Math.max(0, Math.min(index, items.value.length - 1));
const targetScrollTop = targetIndex * itemHeight;
containerRef.value.scrollTo({
top: targetScrollTop,
behavior
});
};
// Scroll to make an index visible (centered if possible)
const scrollToIndexIfNeeded = (index) => {
if (!containerRef.value || !items.value) return;
const { start, end } = visibleRange.value;
// Check if index is already visible (with some margin)
if (index >= start + overscan && index < end - overscan) {
return; // Already visible
}
// Center the index in the viewport
const targetIndex = Math.max(0, index - Math.floor(containerHeight.value / itemHeight / 2));
scrollToIndex(targetIndex, 'smooth');
};
// Reset scroll state (call when modal opens or items change completely)
const reset = () => {
scrollTop.value = 0;
isInitialized.value = false;
// Re-initialize after a tick to allow DOM to render
Vue.nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = 0;
initializeContainer();
}
});
};
// Watch for containerRef changes and initialize
watch(containerRef, (newRef) => {
if (newRef) {
initializeContainer();
}
}, { immediate: true });
// Cleanup on unmount
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
return {
// Data
visibleItems,
visibleRange,
// Spacer heights for virtual scroll container
spacerBefore,
spacerAfter,
totalHeight,
// Event handlers
onScroll,
// Navigation
scrollToIndex,
scrollToIndexIfNeeded,
// Control
reset,
// State (for debugging/testing)
scrollTop,
containerHeight,
isInitialized
};
}
/**
* Helper to generate a unique key for virtual scroll items
*/
export function getVirtualItemKey(item, prefix = 'vs') {
const index = item._originalIndex ?? item._virtualIndex ?? 0;
const time = item.startTime ?? item.start_time ?? '';
return `${prefix}-${index}-${time}`;
}