fix(sidebar): cache tooltip overflow measurement

This commit is contained in:
t8y2 2026-07-02 11:35:18 +08:00
parent de7f2fe6b6
commit 6ee535de34
3 changed files with 80 additions and 3 deletions

View File

@ -113,6 +113,7 @@ import { focusSidebarRenameInput } from "@/lib/sidebarRenameFocus";
import { hasTreeNodeDatabaseContext } from "@/lib/treeNodeContext";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/tableClipboard";
import { sidebarDisplayTableName } from "@/lib/sidebarTableNameDisplay";
import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebarLabelTooltip";
import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebarTreeSelection";
import { selectedConnectionDeleteTargets } from "@/lib/sidebarConnectionSelection";
import { supportsDatabaseUserAdmin } from "@/lib/databaseUserAdmin";
@ -144,13 +145,55 @@ import { createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharset
const { t } = useI18n();
const labelRef = ref<HTMLElement>();
const rowRef = ref<HTMLElement>();
function isLabelTruncated(): boolean {
const labelOverflowing = ref(false);
let labelResizeObserver: ResizeObserver | null = null;
let labelMeasureFrame = 0;
function cancelLabelOverflowMeasure() {
if (!labelMeasureFrame) return;
window.cancelAnimationFrame(labelMeasureFrame);
labelMeasureFrame = 0;
}
function measureLabelOverflow(): boolean {
const el = labelRef.value;
if (!el) return false;
if (!el || !shouldMeasureLabelOverflow()) return false;
const style = window.getComputedStyle(el);
if (style.overflowX === "visible" || style.textOverflow !== "ellipsis") return false;
return el.scrollWidth - el.clientWidth > 2;
}
function updateLabelOverflow() {
labelOverflowing.value = measureLabelOverflow();
}
function scheduleLabelOverflowMeasure() {
if (typeof window === "undefined") {
updateLabelOverflow();
return;
}
cancelLabelOverflowMeasure();
// Keep synchronous layout reads out of the hover path; they are expensive in
// large virtualized sidebar trees, especially on Linux WebKitGTK without GPU help.
labelMeasureFrame = window.requestAnimationFrame(() => {
labelMeasureFrame = 0;
updateLabelOverflow();
});
}
function observeLabelOverflow() {
labelResizeObserver?.disconnect();
labelResizeObserver = null;
if (!shouldMeasureLabelOverflow()) {
labelOverflowing.value = false;
return;
}
if (typeof ResizeObserver !== "undefined" && labelRef.value) {
labelResizeObserver = new ResizeObserver(scheduleLabelOverflowMeasure);
labelResizeObserver.observe(labelRef.value);
}
scheduleLabelOverflowMeasure();
}
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
@ -439,7 +482,7 @@ const detailTooltip = computed(() => connectionInfoTooltip.value ?? objectCommen
function isTooltipDisabled(): boolean {
if (detailTooltip.value?.rows.length) return isRenamingGroup.value;
return isRenamingGroup.value || !isLabelTruncated();
return isRenamingGroup.value || !labelOverflowing.value;
}
async function toggle() {
@ -3478,6 +3521,14 @@ const isRenamingGroup = ref(false);
const renameInput = ref("");
const renameInputRef = ref<HTMLInputElement>();
function shouldMeasureLabelOverflow(): boolean {
return shouldMeasureSidebarLabelOverflow({
hasDetailTooltip: !!detailTooltip.value?.rows.length,
isRenaming: isRenamingGroup.value,
usesFullWidthLabel: usesFullWidthLabel.value,
});
}
function startRenameGroup() {
renameInput.value = props.node.label;
isRenamingGroup.value = true;
@ -3497,6 +3548,14 @@ watch(
{ immediate: true },
);
watch(
[() => props.node.id, () => visibleLabel(props.node), () => usesFullWidthLabel.value, () => detailTooltip.value?.rows.length ?? 0, isRenamingGroup],
() => {
nextTick(observeLabelOverflow);
},
{ flush: "post", immediate: true },
);
function finishRenameGroup() {
// Guard against double invocation: pressing Enter sets isRenamingGroup=false
// and unmounts the input, which then fires @blur -> finishRenameGroup again.
@ -3718,10 +3777,14 @@ function onRowMouseDown(event: MouseEvent) {
}
onMounted(() => {
observeLabelOverflow();
window.addEventListener("dbx:sidebar-request-paste-table", onSidebarRequestPasteTable);
});
onBeforeUnmount(() => {
labelResizeObserver?.disconnect();
labelResizeObserver = null;
cancelLabelOverflowMeasure();
window.removeEventListener("dbx:sidebar-request-paste-table", onSidebarRequestPasteTable);
finishTableReferenceDrag();
});

View File

@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebarLabelTooltip";
describe("shouldMeasureSidebarLabelOverflow", () => {
it("measures only plain truncated-label tooltip candidates", () => {
expect(shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: false, isRenaming: false, usesFullWidthLabel: false })).toBe(true);
expect(shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: true, isRenaming: false, usesFullWidthLabel: false })).toBe(false);
expect(shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: false, isRenaming: true, usesFullWidthLabel: false })).toBe(false);
expect(shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: false, isRenaming: false, usesFullWidthLabel: true })).toBe(false);
});
});

View File

@ -0,0 +1,3 @@
export function shouldMeasureSidebarLabelOverflow(options: { hasDetailTooltip: boolean; isRenaming: boolean; usesFullWidthLabel: boolean }): boolean {
return !options.isRenaming && !options.hasDetailTooltip && !options.usesFullWidthLabel;
}