UNPKG

@pierre/diffs

Version:

515 lines (513 loc) 22.6 kB
import { DEFAULT_VIRTUAL_FILE_METRICS } from "../constants.js"; import { areObjectsEqual } from "../utils/areObjectsEqual.js"; import { areOptionsEqual } from "../utils/areOptionsEqual.js"; import { getVirtualFileHeaderRegion, getVirtualFilePaddingBottom } from "../utils/computeVirtualFileMetrics.js"; import { FILE_ANNOTATION_DOM_KEY, FILE_ANNOTATION_LINE_NUMBER, includesFileAnnotations, shouldRenderFileAnnotations } from "../utils/includesFileAnnotations.js"; import { iterateOverFile } from "../utils/iterateOverFile.js"; import { File } from "./File.js"; //#region src/components/VirtualizedFile.ts const LAYOUT_CHECKPOINT_INTERVAL = 5e3; let instanceId = -1; function hasFileLayoutOptionChanged(previousOptions, nextOptions) { return (previousOptions.overflow ?? "scroll") !== (nextOptions.overflow ?? "scroll") || (previousOptions.collapsed ?? false) !== (nextOptions.collapsed ?? false) || (previousOptions.disableLineNumbers ?? false) !== (nextOptions.disableLineNumbers ?? false) || (previousOptions.disableFileHeader ?? false) !== (nextOptions.disableFileHeader ?? false) || previousOptions.unsafeCSS !== nextOptions.unsafeCSS; } var VirtualizedFile = class extends File { __id = `virtualized-file:${++instanceId}`; top; height = 0; cache = { heights: /* @__PURE__ */ new Map(), checkpoints: [], fileAnnotationHeight: 0 }; isVisible = false; isSetup = false; layoutDirty = true; forceRenderOverride; currentCollapsed; constructor(options, virtualizer, metrics = DEFAULT_VIRTUAL_FILE_METRICS, workerManager, isContainerManaged = false) { super(options, workerManager, isContainerManaged); this.virtualizer = virtualizer; this.metrics = metrics; } setMetrics(metrics, force = false) { if (!force && areObjectsEqual(this.metrics, metrics)) return; this.metrics = metrics; this.resetLayoutCache(); } setLineAnnotations(lineAnnotations) { if (this.syncLineAnnotations(lineAnnotations)) this.resetLayoutCache(); } syncLineAnnotations(lineAnnotations) { if (lineAnnotations == null || lineAnnotations === this.lineAnnotations) return false; if (lineAnnotations.length === 0 && this.lineAnnotations.length === 0) return false; super.setLineAnnotations(lineAnnotations); return true; } hasLineAnnotations() { return this.lineAnnotations.some((annotation) => annotation.lineNumber > FILE_ANNOTATION_LINE_NUMBER); } getLineHeight(lineIndex, hasMetadataLine = false) { const cached = this.cache.heights.get(lineIndex); if (cached != null) return cached; const multiplier = hasMetadataLine ? 2 : 1; return this.metrics.lineHeight * multiplier; } setOptions(options) { if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setOptions cannot be used inside CodeView. Update CodeView options instead."); if (options == null) return; const { options: previousOptions } = this; const optionsChanged = !areOptionsEqual(previousOptions, options); const layoutChanged = hasFileLayoutOptionChanged(previousOptions, options); super.setOptions(options); if (layoutChanged) this.resetLayoutCache(true); if (optionsChanged) this.forceRenderOverride = true; if (optionsChanged) this.virtualizer.instanceChanged(this, layoutChanged); } setThemeType(themeType) { if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); super.setThemeType(themeType); } resetLayoutCache(recompute = false) { this.layoutDirty = true; this.cache.fileAnnotationHeight = 0; if (this.cache.heights.size > 0) this.cache.heights.clear(); if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; if (this.renderRange != null) this.renderRange = void 0; if (recompute && this.isSimpleMode()) this.computeApproximateSize(); } reconcileHeights() { let hasHeightChange = false; if (this.fileContainer == null || this.file == null) { if (this.height !== 0) hasHeightChange = true; this.height = 0; return hasHeightChange; } const { overflow = "scroll" } = this.options; this.top = this.getVirtualizedTop(); if (overflow === "scroll" && this.lineAnnotations.length === 0 && !this.isResizeDebuggingEnabled()) return hasHeightChange; if (this.code == null) return hasHeightChange; const content = this.code.children[1]; if (!(content instanceof HTMLElement)) return hasHeightChange; const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations); if (this.renderRange != null && hasFileAnnotations && shouldRenderFileAnnotations(this.renderRange)) { const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { this.cache.fileAnnotationHeight = nextFileAnnotationHeight; hasHeightChange = true; } } else if (!hasFileAnnotations && this.cache.fileAnnotationHeight !== 0) { this.cache.fileAnnotationHeight = 0; hasHeightChange = true; } for (const line of content.children) { if (!(line instanceof HTMLElement)) continue; const lineIndexAttr = line.dataset.lineIndex; if (lineIndexAttr == null) continue; const lineIndex = Number(lineIndexAttr); let measuredHeight = line.getBoundingClientRect().height; let hasMetadata = false; if (line.nextElementSibling instanceof HTMLElement && ("lineAnnotation" in line.nextElementSibling.dataset || "noNewline" in line.nextElementSibling.dataset)) { if ("noNewline" in line.nextElementSibling.dataset) hasMetadata = true; measuredHeight += line.nextElementSibling.getBoundingClientRect().height; } const expectedHeight = this.getLineHeight(lineIndex, hasMetadata); if (measuredHeight === expectedHeight) continue; hasHeightChange = true; if (measuredHeight === this.metrics.lineHeight * (hasMetadata ? 2 : 1)) this.cache.heights.delete(lineIndex); else this.cache.heights.set(lineIndex, measuredHeight); } if (hasHeightChange || this.isResizeDebuggingEnabled()) this.computeApproximateSize(true); return hasHeightChange; } onRender = (dirty) => { if (this.fileContainer == null || this.file == null) return false; if (dirty) this.top = this.getVirtualizedTop(); return this.render({ file: this.file }); }; prepareCodeViewItem(file, top, reset, lineAnnotations) { const annotationsChanged = this.syncLineAnnotations(lineAnnotations); let shouldResetLayoutCache = reset?.resetFileLayoutCache === true || annotationsChanged; if (reset?.metrics != null) { this.metrics = reset.metrics; shouldResetLayoutCache = true; } const { collapsed = false } = this.options; if (this.currentCollapsed !== collapsed) { this.currentCollapsed = collapsed; shouldResetLayoutCache = true; } if (shouldResetLayoutCache) this.resetLayoutCache(); if (this.file !== file) this.layoutDirty = true; this.file = file; this.top = top; this.computeApproximateSize(); return this.height; } getLinePosition(lineNumber) { if (this.file == null || lineNumber < 1) return; const { disableFileHeader = false, collapsed = false } = this.options; const lastLineIndex = getLastVisibleLineIndex(this.getOrCreateLineCache(this.file)); let top = getVirtualFileHeaderRegion(this.metrics, disableFileHeader); if (collapsed || lastLineIndex < 0) return { top, height: 0 }; const clampedLineIndex = Math.min(Math.max(lineNumber - 1, 0), lastLineIndex); const { overflow = "scroll" } = this.options; const { lineHeight } = this.metrics; top += this.cache.fileAnnotationHeight; if (overflow === "scroll" && !this.hasLineAnnotations()) return { top: top + clampedLineIndex * lineHeight, height: lineHeight }; const checkpoint = this.getLayoutCheckpointBeforeLineIndex(clampedLineIndex); top = checkpoint?.top ?? top; for (let lineIndex = checkpoint?.lineIndex ?? 0; lineIndex < clampedLineIndex; lineIndex++) top += this.getLineHeight(lineIndex, false); return { top, height: this.getLineHeight(clampedLineIndex, false) }; } getNumericScrollAnchor(localViewportTop) { if (this.file == null || this.renderRange == null) return; const { disableFileHeader = false, collapsed = false, overflow = "scroll" } = this.options; if (collapsed || this.renderRange.totalLines <= 0) return; const lastLineIndex = getLastVisibleLineIndex(this.getOrCreateLineCache(this.file)); if (lastLineIndex < 0) return; const headerRegion = getVirtualFileHeaderRegion(this.metrics, disableFileHeader); const firstRenderedLineIndex = Math.min(this.renderRange.startingLine, lastLineIndex); const lastRenderedLineIndex = Math.min(firstRenderedLineIndex + this.renderRange.totalLines - 1, lastLineIndex); if (lastRenderedLineIndex < firstRenderedLineIndex) return; const { fileAnnotationHeight } = this.cache; if (overflow === "scroll" && !this.hasLineAnnotations()) { const { lineHeight } = this.metrics; const firstRenderedLineTop = headerRegion + (firstRenderedLineIndex === 0 ? fileAnnotationHeight : this.renderRange.bufferBefore); const lineIndex = firstRenderedLineIndex + Math.max(Math.ceil((localViewportTop - firstRenderedLineTop) / lineHeight), 0); if (lineIndex > lastRenderedLineIndex) return; return { lineNumber: lineIndex + 1, top: headerRegion + fileAnnotationHeight + lineIndex * lineHeight }; } let top = headerRegion + (firstRenderedLineIndex === 0 ? fileAnnotationHeight : this.renderRange.bufferBefore); for (let lineIndex = firstRenderedLineIndex; lineIndex <= lastRenderedLineIndex; lineIndex++) { if (top >= localViewportTop) return { lineNumber: lineIndex + 1, top }; top += this.getLineHeight(lineIndex); } } getVirtualizedHeight() { return this.height; } getAdvancedStickySpecs(windowSpecs) { if (this.top == null || this.file == null) return; if (this.options.collapsed === true) return { topOffset: this.top, height: this.height }; const renderRange = windowSpecs != null ? this.computeRenderRangeFromWindow(this.file, this.top, windowSpecs) : this.renderRange; if (renderRange == null) return; const { bufferBefore, bufferAfter, totalLines } = renderRange; let headerOnlyOffset = 0; if (totalLines === 0) { const activeWindow = windowSpecs ?? this.virtualizer.getWindowSpecs(); if (this.top < activeWindow.top) headerOnlyOffset = bufferAfter; } return { topOffset: this.top + bufferBefore + headerOnlyOffset, height: this.height - (bufferBefore + bufferAfter) }; } cleanUp(recycle = false) { if (this.fileContainer != null && this.isSimpleMode()) this.getSimpleVirtualizer()?.disconnect(this.fileContainer); if (!recycle) this.resetLayoutCache(); this.isSetup = false; super.cleanUp(recycle); } computeApproximateSize(force = false) { const shouldValidateSize = this.isResizeDebuggingEnabled(); if (!force && !this.layoutDirty && !shouldValidateSize) return; const isFirstCompute = this.height === 0; this.height = 0; this.cache.checkpoints = []; if (this.file == null) { this.layoutDirty = false; return; } const { disableFileHeader = false, collapsed = false, overflow = "scroll" } = this.options; const { lineHeight } = this.metrics; const lines = this.getOrCreateLineCache(this.file); const headerRegion = getVirtualFileHeaderRegion(this.metrics, disableFileHeader); const paddingBottom = getVirtualFilePaddingBottom(this.metrics); this.height += headerRegion; if (collapsed) { this.layoutDirty = false; return; } this.height += this.cache.fileAnnotationHeight; if (overflow === "scroll" && !this.hasLineAnnotations()) this.height += this.getOrCreateLineCache(this.file).length * lineHeight; else iterateOverFile({ lines, callback: ({ lineIndex }) => { this.addLayoutCheckpoint(lineIndex, this.height); this.height += this.getLineHeight(lineIndex, false); } }); if (lines.length > 0) this.height += paddingBottom; if (this.fileContainer != null && shouldValidateSize && !isFirstCompute) { const rect = this.fileContainer.getBoundingClientRect(); if (rect.height !== this.height) console.log("VirtualizedFile.computeApproximateSize: computed height doesnt match", { name: this.file.name, elementHeight: rect.height, computedHeight: this.height }); else console.log("VirtualizedFile.computeApproximateSize: computed height IS CORRECT"); } this.layoutDirty = false; } setVisibility(visible) { if (this.isAdvancedMode() || this.fileContainer == null) return; if (visible && !this.isVisible) { this.top = this.getVirtualizedTop(); this.isVisible = true; } else if (!visible && this.isVisible) { this.isVisible = false; this.rerender(); } } rerender() { if (!this.enabled || this.file == null) return; this.forceRenderOverride = true; this.virtualizer.instanceChanged(this, false); } render({ fileContainer, file, forceRender = false, lineAnnotations,...props }) { const { forceRenderOverride, isSetup } = this; this.forceRenderOverride = void 0; const annotationsChanged = this.syncLineAnnotations(lineAnnotations); if (annotationsChanged) this.resetLayoutCache(); this.file ??= file; fileContainer = this.getOrCreateFileContainerNode(fileContainer); if (this.file == null) { console.error("VirtualizedFile.render: attempting to virtually render when we dont have file"); return false; } if (!isSetup) { this.computeApproximateSize(); const virtualizer = this.getSimpleVirtualizer(); this.top ??= this.getVirtualizedTop(); if (this.isAdvancedMode()) this.isVisible = true; else { if (virtualizer == null) throw new Error("VirtualizedFile.render: simple virtualizer is not available"); virtualizer.connect(fileContainer, this); this.isVisible = virtualizer.isInstanceVisible(this.top ?? 0, this.height); } this.isSetup = true; } else this.top ??= this.getVirtualizedTop(); if (!this.isVisible && this.isSimpleMode()) return this.renderPlaceholder(this.height); const windowSpecs = this.virtualizer.getWindowSpecs(); const fileTop = this.top ?? 0; const renderRange = this.computeRenderRangeFromWindow(this.file, fileTop, windowSpecs); return super.render({ file: this.file, fileContainer, renderRange, lineAnnotations, forceRender: (forceRenderOverride ?? forceRender) || annotationsChanged, ...props }); } syncVirtualizedTop() { this.top = this.getVirtualizedTop(); } shouldDisableVirtualizationBuffers() { return this.isAdvancedMode() || super.shouldDisableVirtualizationBuffers(); } isSimpleMode() { return this.virtualizer.type === "simple"; } isAdvancedMode() { return this.virtualizer.type === "advanced"; } addLayoutCheckpoint(lineIndex, top) { if (lineIndex % LAYOUT_CHECKPOINT_INTERVAL !== 0) return; this.cache.checkpoints.push({ lineIndex, top }); } getLayoutCheckpointBeforeLineIndex(lineIndex) { if (lineIndex <= 0 || this.cache.checkpoints.length === 0) return; let low = 0; let high = this.cache.checkpoints.length - 1; let result; while (low <= high) { const mid = low + high >> 1; const checkpoint = this.cache.checkpoints[mid]; if (checkpoint == null) throw new Error("VirtualizedFile: invalid checkpoint index"); if (checkpoint.lineIndex <= lineIndex) { result = checkpoint; low = mid + 1; } else high = mid - 1; } return result; } getLayoutCheckpointBeforeTop(top, hunkLineCount) { let low = 0; let high = this.cache.checkpoints.length - 1; let resultIndex = -1; while (low <= high) { const mid = low + high >> 1; const checkpoint = this.cache.checkpoints[mid]; if (checkpoint == null) throw new Error("VirtualizedFile: invalid checkpoint index"); if (checkpoint.top <= top) { resultIndex = mid; low = mid + 1; } else high = mid - 1; } if (hunkLineCount == null) return resultIndex >= 0 ? this.cache.checkpoints[resultIndex] : void 0; for (let index = resultIndex; index >= 0; index--) { const checkpoint = this.cache.checkpoints[index]; if (checkpoint == null) throw new Error("VirtualizedFile: invalid checkpoint index"); if (checkpoint.lineIndex % hunkLineCount === 0) return checkpoint; } } getVirtualizedTop() { if (this.virtualizer.type === "advanced") return this.virtualizer.getLocalTopForInstance(this); return this.fileContainer != null ? this.virtualizer.getOffsetInScrollContainer(this.fileContainer) : 0; } getSimpleVirtualizer() { return this.virtualizer.type === "simple" ? this.virtualizer : void 0; } isResizeDebuggingEnabled() { return this.getSimpleVirtualizer()?.config.resizeDebugging ?? false; } computeRenderRangeFromWindow(file, fileTop, { top, bottom }) { const { disableFileHeader = false, overflow = "scroll" } = this.options; const { hunkLineCount, lineHeight } = this.metrics; const lines = this.getOrCreateLineCache(file); const lineCount = lines.length; const fileHeight = this.height; const headerRegion = getVirtualFileHeaderRegion(this.metrics, disableFileHeader); const paddingBottom = lineCount > 0 ? getVirtualFilePaddingBottom(this.metrics) : 0; const { fileAnnotationHeight } = this.cache; const codeRegionTop = headerRegion + fileAnnotationHeight; const codeRowsHeight = Math.max(0, fileHeight - headerRegion - fileAnnotationHeight - paddingBottom); const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations); const fileAnnotationTop = fileTop + headerRegion; const measuredFileAnnotationVisible = fileAnnotationHeight > 0 && hasFileAnnotations && fileAnnotationTop < bottom && fileAnnotationTop + fileAnnotationHeight > top; if (fileTop < top - fileHeight || fileTop > bottom) return { startingLine: 0, totalLines: 0, bufferBefore: 0, bufferAfter: fileHeight - headerRegion - paddingBottom }; if (lineCount <= hunkLineCount) return { startingLine: 0, totalLines: hunkLineCount, bufferBefore: 0, bufferAfter: 0 }; const estimatedTargetLines = Math.ceil(Math.max(bottom - top, 0) / lineHeight); const totalLines = Math.ceil(estimatedTargetLines / hunkLineCount) * hunkLineCount + hunkLineCount * 2; const totalHunks = totalLines / hunkLineCount; const viewportCenter = (top + bottom) / 2; if (overflow === "scroll" && !this.hasLineAnnotations()) { const sourceRowsTop = fileTop + codeRegionTop; const sourceRowsBottom = sourceRowsTop + codeRowsHeight; if (!measuredFileAnnotationVisible && !(sourceRowsTop < bottom && sourceRowsBottom > top)) return { startingLine: 0, totalLines: 0, bufferBefore: 0, bufferAfter: fileHeight - headerRegion - paddingBottom }; const centerLine = Math.floor(measuredFileAnnotationVisible && viewportCenter < fileTop + codeRegionTop ? 0 : (viewportCenter - (fileTop + codeRegionTop)) / lineHeight); const idealStartHunk$1 = Math.floor(centerLine / hunkLineCount) - Math.floor(totalHunks / 2); const totalHunksInFile = Math.ceil(lineCount / hunkLineCount); const startingLine$1 = Math.max(0, Math.min(idealStartHunk$1, totalHunksInFile)) * hunkLineCount; const clampedTotalLines$1 = idealStartHunk$1 < 0 ? totalLines + idealStartHunk$1 * hunkLineCount : totalLines; const bufferBefore$1 = startingLine$1 === 0 ? 0 : fileAnnotationHeight + startingLine$1 * lineHeight; const renderedLines = Math.min(clampedTotalLines$1, lineCount - startingLine$1); return { startingLine: startingLine$1, totalLines: clampedTotalLines$1, bufferBefore: bufferBefore$1, bufferAfter: Math.max(0, (lineCount - startingLine$1 - renderedLines) * lineHeight) }; } const overflowHunks = totalHunks; const hunkOffsets = []; const checkpoint = this.getLayoutCheckpointBeforeTop(Math.max(0, top - fileTop - totalLines * lineHeight * 2), hunkLineCount); let absoluteLineTop = fileTop + (checkpoint?.top ?? codeRegionTop); let currentLine = checkpoint?.lineIndex ?? 0; let firstVisibleHunk; let centerHunk; let overflowCounter; iterateOverFile({ lines, startingLine: checkpoint?.lineIndex ?? 0, callback: ({ lineIndex }) => { const isAtHunkBoundary = currentLine % hunkLineCount === 0; const currentHunk = Math.floor(currentLine / hunkLineCount); if (isAtHunkBoundary) { hunkOffsets[currentHunk] = absoluteLineTop - (fileTop + codeRegionTop); if (overflowCounter != null) { if (overflowCounter <= 0) return true; overflowCounter--; } } const lineHeight$1 = this.getLineHeight(lineIndex, false); if (absoluteLineTop > top - lineHeight$1 && absoluteLineTop < bottom) firstVisibleHunk ??= currentHunk; if (absoluteLineTop + lineHeight$1 > viewportCenter) centerHunk ??= currentHunk; if (overflowCounter == null && absoluteLineTop >= bottom && isAtHunkBoundary) overflowCounter = overflowHunks; currentLine++; absoluteLineTop += lineHeight$1; return false; } }); if (firstVisibleHunk == null) if (measuredFileAnnotationVisible) { firstVisibleHunk = 0; centerHunk = 0; } else return { startingLine: 0, totalLines: 0, bufferBefore: 0, bufferAfter: fileHeight - headerRegion - paddingBottom }; centerHunk ??= firstVisibleHunk; const idealStartHunk = Math.round(centerHunk - totalHunks / 2); const maxStartHunk = Math.max(0, Math.ceil(lineCount / hunkLineCount) - totalHunks); const startHunk = Math.max(0, Math.min(idealStartHunk, maxStartHunk)); const startingLine = startHunk * hunkLineCount; const clampedTotalLines = idealStartHunk < 0 ? totalLines + idealStartHunk * hunkLineCount : totalLines; const codeBufferBefore = hunkOffsets[startHunk] ?? 0; const bufferBefore = startingLine === 0 ? 0 : fileAnnotationHeight + codeBufferBefore; const finalHunkIndex = startHunk + clampedTotalLines / hunkLineCount; const bufferAfter = finalHunkIndex < hunkOffsets.length ? codeRowsHeight - hunkOffsets[finalHunkIndex] : codeRowsHeight - (absoluteLineTop - fileTop - codeRegionTop); return { startingLine, totalLines: clampedTotalLines, bufferBefore, bufferAfter: Math.max(0, bufferAfter) }; } }; function measureFileAnnotationHeight(content) { let height; for (const child of content.children) { if (!(child instanceof HTMLElement)) continue; if (child.dataset.lineAnnotation !== FILE_ANNOTATION_DOM_KEY) continue; height = Math.max(height ?? 0, child.getBoundingClientRect().height); } return height; } function getLastVisibleLineIndex(lines) { const lastLine = lines.at(-1); if (lastLine == null || lastLine === "" || lastLine === "\n" || lastLine === "\r\n" || lastLine === "\r") return lines.length - 2; return lines.length - 1; } //#endregion export { VirtualizedFile }; //# sourceMappingURL=VirtualizedFile.js.map