From 5ae09150fa8450624add2e1c2ddcaaaa2a9d1893 Mon Sep 17 00:00:00 2001 From: ly-wang19 Date: Mon, 8 Jun 2026 16:58:36 +0800 Subject: [PATCH] perf(editor): dedupe alignment snap-lines in O(n) instead of O(n^2) uniqAlignLines used Array.findIndex inside a forEach (O(n^2)) to merge alignment snap-lines. It runs on every drag/scale mousemove and the line count scales with nearby element edges, so it janks on element-dense canvases. Dedupe via a Map keyed on value (O(n)); a Map preserves first-occurrence order, so output order and range-merge semantics are unchanged. Closes #691 --- lib/utils/element.ts | 34 +++++++++++++-------- tests/utils/uniq-align-lines.test.ts | 45 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 tests/utils/uniq-align-lines.test.ts diff --git a/lib/utils/element.ts b/lib/utils/element.ts index 3990b36aa2..6351666e61 100644 --- a/lib/utils/element.ts +++ b/lib/utils/element.ts @@ -155,20 +155,28 @@ export interface AlignLine { * @param lines 一组对齐吸附线信息 */ export const uniqAlignLines = (lines: AlignLine[]) => { - const uniqLines: AlignLine[] = []; - lines.forEach((line) => { - const index = uniqLines.findIndex((_line) => _line.value === line.value); - if (index === -1) uniqLines.push(line); - else { - const uniqLine = uniqLines[index]; - const rangeMin = Math.min(uniqLine.range[0], line.range[0]); - const rangeMax = Math.max(uniqLine.range[1], line.range[1]); - const range: [number, number] = [rangeMin, rangeMax]; - const _line = { value: line.value, range }; - uniqLines[index] = _line; + // Dedupe by `value` in O(n) via a Map keyed on value, instead of an + // O(n²) `findIndex` over the accumulating result. This runs on every + // drag/scale mousemove with one snap line per nearby element edge, so the + // quadratic version janks on element-dense canvases. A Map preserves + // first-occurrence insertion order (re-`set`-ting an existing key keeps its + // position), so the output order and merge semantics are unchanged. + const byValue = new Map(); + for (const line of lines) { + const existing = byValue.get(line.value); + if (!existing) { + byValue.set(line.value, line); + } else { + byValue.set(line.value, { + value: line.value, + range: [ + Math.min(existing.range[0], line.range[0]), + Math.max(existing.range[1], line.range[1]), + ], + }); } - }); - return uniqLines; + } + return Array.from(byValue.values()); }; /** diff --git a/tests/utils/uniq-align-lines.test.ts b/tests/utils/uniq-align-lines.test.ts new file mode 100644 index 0000000000..388e6ba1a2 --- /dev/null +++ b/tests/utils/uniq-align-lines.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { uniqAlignLines, type AlignLine } from '@/lib/utils/element'; + +describe('uniqAlignLines', () => { + it('dedupes by value and merges ranges to the outer bounds', () => { + const lines: AlignLine[] = [ + { value: 10, range: [0, 5] }, + { value: 20, range: [2, 8] }, + { value: 10, range: [3, 12] }, + { value: 10, range: [-1, 4] }, + ]; + expect(uniqAlignLines(lines)).toEqual([ + { value: 10, range: [-1, 12] }, // min(0,3,-1) .. max(5,12,4) + { value: 20, range: [2, 8] }, + ]); + }); + + it('preserves first-occurrence order', () => { + const lines: AlignLine[] = [ + { value: 30, range: [0, 1] }, + { value: 10, range: [0, 1] }, + { value: 20, range: [0, 1] }, + { value: 10, range: [0, 1] }, + ]; + expect(uniqAlignLines(lines).map((l) => l.value)).toEqual([30, 10, 20]); + }); + + it('returns a single line unchanged', () => { + const lines: AlignLine[] = [{ value: 7, range: [1, 2] }]; + expect(uniqAlignLines(lines)).toEqual([{ value: 7, range: [1, 2] }]); + }); + + it('returns [] for empty input', () => { + expect(uniqAlignLines([])).toEqual([]); + }); + + it('collapses many duplicates to the unique values (linear)', () => { + const lines: AlignLine[] = Array.from({ length: 5000 }, (_, i) => ({ + value: i % 50, + range: [i, i + 1] as [number, number], + })); + expect(uniqAlignLines(lines)).toHaveLength(50); + }); +});