Browse Source

Merge be33fd6da7 into f06484c6ab

pull/10383/merge
DANIEL NUNES DUARTE 2 days ago committed by GitHub
parent
commit
548ca64990
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 34
      CLAUDE.md
  2. 47
      packages/excalidraw/components/SearchMenu.tsx
  3. 25
      packages/excalidraw/components/SearchMenu.utils.ts
  4. 146
      packages/excalidraw/components/__tests__/SearchMenu.integration.test.ts
  5. 96
      packages/excalidraw/components/__tests__/SearchMenu.utils.test.ts

34
CLAUDE.md

@ -1,34 +0,0 @@ @@ -1,34 +0,0 @@
# CLAUDE.md
## Project Structure
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
- **`examples/`** - Integration examples (NextJS, browser script)
## Development Workflow
1. **Package Development**: Work in `packages/*` for editor features
2. **App Development**: Work in `excalidraw-app/` for app-specific features
3. **Testing**: Always run `yarn test:update` before committing
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
## Development Commands
```bash
yarn test:typecheck # TypeScript type checking
yarn test:update # Run all tests (with snapshot updates)
yarn fix # Auto-fix formatting and linting issues
```
## Architecture Notes
### Package System
- Uses Yarn workspaces for monorepo management
- Internal packages use path aliases (see `vitest.config.mts`)
- Build system uses esbuild for packages, Vite for the app
- TypeScript throughout with strict configuration

47
packages/excalidraw/components/SearchMenu.tsx

@ -4,11 +4,11 @@ import debounce from "lodash.debounce"; @@ -4,11 +4,11 @@ import debounce from "lodash.debounce";
import { Fragment, memo, useEffect, useMemo, useRef, useState } from "react";
import {
CLASSES,
EVENT,
FONT_FAMILY,
FRAME_STYLE,
getLineHeight,
CLASSES,
EVENT,
FONT_FAMILY,
FRAME_STYLE,
getLineHeight,
} from "@excalidraw/common";
import { isElementCompletelyInViewport } from "@excalidraw/element";
@ -16,20 +16,19 @@ import { isElementCompletelyInViewport } from "@excalidraw/element"; @@ -16,20 +16,19 @@ import { isElementCompletelyInViewport } from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
import {
KEYS,
randomInteger,
addEventListener,
getFontString,
addEventListener,
getFontString,
KEYS,
randomInteger,
} from "@excalidraw/common";
import { newTextElement } from "@excalidraw/element";
import { isTextElement, isFrameLikeElement } from "@excalidraw/element";
import { isFrameLikeElement, isTextElement, newTextElement } from "@excalidraw/element";
import { getDefaultFrameName } from "@excalidraw/element/frame";
import type {
ExcalidrawFrameLikeElement,
ExcalidrawTextElement,
ExcalidrawFrameLikeElement,
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import { atom, useAtom } from "../editor-jotai";
@ -41,13 +40,15 @@ import { useApp, useExcalidrawSetAppState } from "./App"; @@ -41,13 +40,15 @@ import { useApp, useExcalidrawSetAppState } from "./App";
import { Button } from "./Button";
import { TextField } from "./TextField";
import {
collapseDownIcon,
upIcon,
searchIcon,
frameToolIcon,
TextIcon,
collapseDownIcon,
frameToolIcon,
searchIcon,
TextIcon,
upIcon,
} from "./icons";
import { getStableMatches } from "./SearchMenu.utils";
import "./SearchMenu.scss";
import type { AppClassProperties, SearchMatch } from "../types";
@ -70,6 +71,8 @@ type SearchMatchItem = { @@ -70,6 +71,8 @@ type SearchMatchItem = {
matchedLines: SearchMatch["matchedLines"];
};
export type { SearchMatchItem };
type SearchMatches = {
nonce: number | null;
items: SearchMatchItem[];
@ -110,7 +113,7 @@ export const SearchMenu = () => { @@ -110,7 +113,7 @@ export const SearchMenu = () => {
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
items: getStableMatches(matchItems),
});
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
@ -363,7 +366,7 @@ export const SearchMenu = () => { @@ -363,7 +366,7 @@ export const SearchMenu = () => {
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
items: getStableMatches(matchItems),
});
setFocusIndex(index);
searchedQueryRef.current = searchQuery;
@ -506,7 +509,7 @@ const MatchListBase = (props: MatchListProps) => { @@ -506,7 +509,7 @@ const MatchListBase = (props: MatchListProps) => {
</div>
{frameNameMatches.map((searchMatch, index) => (
<ListItem
key={searchMatch.element.id + searchMatch.index}
key={`${searchMatch.element.id}_${searchMatch.index ?? 0}`}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index === props.focusIndex}
@ -526,7 +529,7 @@ const MatchListBase = (props: MatchListProps) => { @@ -526,7 +529,7 @@ const MatchListBase = (props: MatchListProps) => {
</div>
{textMatches.map((searchMatch, index) => (
<ListItem
key={searchMatch.element.id + searchMatch.index}
key={`${searchMatch.element.id}_${searchMatch.index ?? 0}`}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index + frameNameMatches.length === props.focusIndex}

25
packages/excalidraw/components/SearchMenu.utils.ts

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
/**
* Utilities for stable search results ordering
* Ensures search results remain in consistent order despite scene updates
*/
import type { SearchMatchItem } from "./SearchMenu";
/**
* Sorts search matches by element.id and then by match index
* Ensures stable ordering regardless of scene changes or element repositioning
*
* @param items - Array of search match items to sort
* @returns - New sorted array maintaining stable order
*/
export const getStableMatches = (items: SearchMatchItem[]): SearchMatchItem[] => {
return [...items].sort((a, b) => {
// First, sort by element ID (stable identifier)
const idComparison = a.element.id.localeCompare(b.element.id);
if (idComparison !== 0) {
return idComparison;
}
// If same element, sort by match index within the text
return (a.index ?? 0) - (b.index ?? 0);
});
};

146
packages/excalidraw/components/__tests__/SearchMenu.integration.test.ts

@ -0,0 +1,146 @@ @@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import type { SearchMatchItem } from "../SearchMenu";
import { getStableMatches } from "../SearchMenu.utils";
/**
* Integration tests for SearchMenu MatchList stable ordering
* Verifies that results maintain consistent order despite scene updates
*/
describe("SearchMenu - Stable Ordering Integration", () => {
const createMockSearchMatchItem = (
elementId: string,
matchIndex: number = 0,
previewText: string = elementId,
): SearchMatchItem => ({
element: {
id: elementId,
type: "text",
text: previewText,
} as any,
searchQuery: "test" as any,
index: matchIndex,
preview: {
indexInSearchQuery: 0,
previewText: previewText,
moreBefore: false,
moreAfter: false,
},
matchedLines: [],
});
it("getStableMatches produz ordem consistente após múltiplas reordenações de entrada", () => {
const items1 = [
createMockSearchMatchItem("element-3"),
createMockSearchMatchItem("element-1"),
createMockSearchMatchItem("element-2"),
];
const items2 = [
createMockSearchMatchItem("element-2"),
createMockSearchMatchItem("element-3"),
createMockSearchMatchItem("element-1"),
];
const sorted1 = getStableMatches(items1);
const sorted2 = getStableMatches(items2);
const ids1 = sorted1.map((m) => m.element.id);
const ids2 = sorted2.map((m) => m.element.id);
expect(ids1).toEqual(ids2);
expect(ids1).toEqual(["element-1", "element-2", "element-3"]);
});
it("getStableMatches mantém ordem quando múltiplos matches no mesmo elemento existem", () => {
const items = [
createMockSearchMatchItem("text-b", 2),
createMockSearchMatchItem("text-b", 0),
createMockSearchMatchItem("text-a", 0),
createMockSearchMatchItem("text-b", 1),
];
const sorted = getStableMatches(items);
const result = sorted.map((m) => `${m.element.id}[${m.index}]`);
expect(result).toEqual([
"text-a[0]",
"text-b[0]",
"text-b[1]",
"text-b[2]",
]);
});
it("simula cenário: elemento é movido no canvas (scene update) enquanto busca está ativa", () => {
// Estado inicial: busca encontra A, B, C
const initialMatches = [
createMockSearchMatchItem("element-a", 0, "test alpha"),
createMockSearchMatchItem("element-b", 0, "test beta"),
createMockSearchMatchItem("element-c", 0, "test gamma"),
];
const sortedInitial = getStableMatches(initialMatches);
const initialOrder = sortedInitial.map((m) => m.element.id);
// Simulate: element B é movido (x, y muda) na scene
// Callbacks de search disparam novamente com mesmos elementos mas potencialmente reordenados
const afterElementMove = [
createMockSearchMatchItem("element-c", 0, "test gamma"), // pode aparecer em ordem diferente
createMockSearchMatchItem("element-a", 0, "test alpha"),
createMockSearchMatchItem("element-b", 0, "test beta"),
];
const sortedAfterMove = getStableMatches(afterElementMove);
const orderAfterMove = sortedAfterMove.map((m) => m.element.id);
// A ordem deve permanecer a mesma, apesar da reordenação de entrada
expect(orderAfterMove).toEqual(initialOrder);
expect(orderAfterMove).toEqual(["element-a", "element-b", "element-c"]);
});
it("verifica que IDs são chaves únicas estáveis sem colisão de concatenação", () => {
// Teste para evitar colisões do tipo: "elem1" + "2" == "elem" + "12"
const items = [
createMockSearchMatchItem("elem1", 2), // sem separador: "elem12"
createMockSearchMatchItem("elem", 12), // sem separador: "elem12" (colisão!)
];
const sorted = getStableMatches(items);
// Com ordenação por ID e index, nunca haverá confusão
expect(sorted[0].element.id).toBe("elem");
expect(sorted[1].element.id).toBe("elem1");
});
it("trata edge case: nenhum match", () => {
const matches: SearchMatchItem[] = [];
const sorted = getStableMatches(matches);
expect(sorted).toEqual([]);
});
it("trata edge case: um único match", () => {
const matches = [createMockSearchMatchItem("single-element", 0)];
const sorted = getStableMatches(matches);
expect(sorted).toHaveLength(1);
expect(sorted[0].element.id).toBe("single-element");
});
it("preserva ordem idêntica em chamadas sucessivas (idempotência)", () => {
const original = [
createMockSearchMatchItem("z-element", 0),
createMockSearchMatchItem("a-element", 1),
createMockSearchMatchItem("m-element", 0),
];
const round1 = getStableMatches(original);
const round2 = getStableMatches(round1);
const round3 = getStableMatches(round2);
const ids1 = round1.map((m) => `${m.element.id}[${m.index}]`).join(",");
const ids2 = round2.map((m) => `${m.element.id}[${m.index}]`).join(",");
const ids3 = round3.map((m) => `${m.element.id}[${m.index}]`).join(",");
expect(ids1).toBe(ids2);
expect(ids2).toBe(ids3);
});
});

96
packages/excalidraw/components/__tests__/SearchMenu.utils.test.ts

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import type { SearchMatchItem } from "../SearchMenu";
import { getStableMatches } from "../SearchMenu.utils";
describe("getStableMatches", () => {
const createMockMatch = (
elementId: string,
matchIndex: number = 0,
): SearchMatchItem => ({
element: {
id: elementId,
type: "text",
} as any,
searchQuery: "test" as any,
index: matchIndex,
preview: {
indexInSearchQuery: 0,
previewText: "test",
moreBefore: false,
moreAfter: false,
},
matchedLines: [],
});
it("ordena consistentemente por element.id quando os matches chegam em qualquer ordem", () => {
const matches = [
createMockMatch("C", 0),
createMockMatch("A", 0),
createMockMatch("B", 0),
];
const sorted = getStableMatches(matches);
expect(sorted.map((m) => m.element.id)).toEqual(["A", "B", "C"]);
});
it("ordena por element.id e depois por index dentro do mesmo elemento", () => {
const matches = [
createMockMatch("B", 0),
createMockMatch("A", 1),
createMockMatch("A", 0),
createMockMatch("B", 1),
];
const sorted = getStableMatches(matches);
const result = sorted.map((m) => `${m.element.id}_${m.index}`);
expect(result).toEqual(["A_0", "A_1", "B_0", "B_1"]);
});
it("retorna cópia não-mutante do array original", () => {
const original = [
createMockMatch("B", 0),
createMockMatch("A", 0),
];
const originalOrder = original.map((m) => m.element.id);
const sorted = getStableMatches(original);
// Verifica se original não foi modificado
expect(original.map((m) => m.element.id)).toEqual(originalOrder);
// Verifica se resultado está ordenado
expect(sorted.map((m) => m.element.id)).toEqual(["A", "B"]);
});
it("mantém ordem estável mesmo se reordenado múltiplas vezes", () => {
const originalMatches = [
createMockMatch("C", 0),
createMockMatch("B", 0),
createMockMatch("A", 0),
];
const sorted1 = getStableMatches(originalMatches);
const sorted2 = getStableMatches(sorted1);
const sorted3 = getStableMatches(sorted2);
const result1 = sorted1.map((m) => m.element.id);
const result2 = sorted2.map((m) => m.element.id);
const result3 = sorted3.map((m) => m.element.id);
expect(result1).toEqual(["A", "B", "C"]);
expect(result2).toEqual(["A", "B", "C"]);
expect(result3).toEqual(["A", "B", "C"]);
});
it("trata array vazio", () => {
const sorted = getStableMatches([]);
expect(sorted).toEqual([]);
});
it("trata array com um único elemento", () => {
const matches = [createMockMatch("A", 0)];
const sorted = getStableMatches(matches);
expect(sorted).toEqual(matches);
});
});
Loading…
Cancel
Save