diff --git a/apps/server/src/modules/canvas/canvas-search.test.ts b/apps/server/src/modules/canvas/canvas-search.test.ts index 298437413..d8041c4f0 100644 --- a/apps/server/src/modules/canvas/canvas-search.test.ts +++ b/apps/server/src/modules/canvas/canvas-search.test.ts @@ -140,6 +140,49 @@ async function collect( } describe('searchCanvas — tiered emission', () => { + it.each([ + ['needle', 2, 6], + ['i\u0307', 0, 1], + ] as const)( + 'preserves original UTF-16 source addresses after case folding: %s', + async (query, offset, length) => { + const events = await collect( + [mkNode('n1', 'note')], + [mkContent('n1', 'note', { content: '\u0130 needle' })], + { query, fields: ['content'] }, + ); + expect(events.find((event) => event.type === 'match')).toMatchObject({ + match: { source: { offset, length } }, + }); + }, + ); + it('numbers raw Markdown occurrences including a link URL before the next visible paragraph', async () => { + const source = '[needle](https://example.test/needle)\n\nParagraph needle'; + const events = await collect( + [mkNode('n1', 'note')], + [ + mkContent('n1', 'note', { + content: source, + }), + ], + { query: 'needle', fields: ['content'] }, + ); + const ordinals = events.flatMap((event) => + event.type === 'match' ? [event.match.occurrenceIndex] : [], + ); + expect(ordinals).toEqual([0, 1, 2]); + const addresses = events.flatMap((event) => + event.type === 'match' ? [event.match.source] : [], + ); + expect(addresses.map((address) => address?.offset)).toEqual([ + source.indexOf('needle'), + source.indexOf('needle', 10), + source.lastIndexOf('needle'), + ]); + expect(new Set(addresses.map((address) => address?.hash)).size).toBe(1); + expect(addresses[0]?.hash).toMatch(/^[a-f0-9]{64}$/); + }); + it('emits metadata-tier matches before content-tier matches', async () => { const nodes = [mkNode('n1', 'note'), mkNode('n2', 'text')]; const contents = [ @@ -648,6 +691,11 @@ describe('searchCanvas — conversation tier', () => { { query, fields: ['conversation'] }, ); expect(events.filter((event) => event.type === 'match')).toHaveLength(1); + expect(events.find((event) => event.type === 'match')).toMatchObject({ + match: { + source: { hash: expect.stringMatching(/^[a-f0-9]{64}$/), offset: 0 }, + }, + }); const contextEvents = await collect( [mkNode('quoted-question', 'question', 'quoted-thread')], [mkContent('quoted-question', 'question', { label: 'Q' })], diff --git a/apps/server/src/modules/canvas/canvas-search.ts b/apps/server/src/modules/canvas/canvas-search.ts index 2b586b276..4ba39b379 100644 --- a/apps/server/src/modules/canvas/canvas-search.ts +++ b/apps/server/src/modules/canvas/canvas-search.ts @@ -34,12 +34,16 @@ * scan testable without spinning up Fastify. */ +import { createHash } from 'node:crypto'; + import { META_SEARCH_FIELDS, chatInputText, + literalTextMatches, type CanvasSearchEvent, type CanvasSearchMatch, type CanvasSearchRequest, + type CanvasSearchSource, type SearchField, } from '@huabu/shared'; @@ -205,6 +209,7 @@ function scanNodeContent( nodeType: node.type, label: content?.label ?? null, tryEmit, + sourceAt: sourceLocator(body), }); } } @@ -221,16 +226,77 @@ function scanNodeContent( * user message and an unrelated reply, while `buildSnippet`'s * whitespace collapse still renders them on one row. */ +function sourceLocator( + text: string, +): (offset: number, length?: number) => CanvasSearchSource { + const hash = createHash('sha256').update(text).digest('hex'); + return (offset, length) => ({ hash, offset, length }); +} + function buildThreadHaystack( turns: readonly AgentTurn[], recoverInternalToolNames: boolean, -): string { +): { text: string; sourceAt: FieldEmitContext['sourceAt'] } { const segments: string[] = []; + const sources: { + start: number; + end: number; + locate: (offset: number, length?: number) => CanvasSearchSource; + }[] = []; + let length = 0; + const append = (text: string, searchable = true): number => { + const start = length + (segments.length ? 2 : 0); + segments.push(text); + length = start + text.length; + if (searchable) + sources.push({ start, end: length, locate: sourceLocator(text) }); + return start; + }; for (const turn of turns) { const user = chatEnvelopeFromSubmission(turn.request)?.user; const userText = user ? chatInputText(user) : undefined; if (typeof userText === 'string' && userText.length > 0) { - segments.push(userText); + const start = append(userText, false); + // Keep the existing searchable serialization while addressing only + // literal pieces the UI actually renders (not quote-prefix syntax). + let cursor = 0; + for (const comment of user?.quotedComments ?? []) { + const locate = sourceLocator(comment.quote); + let quoteOffset = 0; + for (const line of comment.quote.split(/\r?\n/)) { + const lineStart = userText.indexOf(`> ${line}`, cursor); + if (lineStart < 0) break; + const offset = quoteOffset; + sources.push({ + start: start + lineStart + 2, + end: start + lineStart + 2 + line.length, + locate: (position, matchLength) => + locate(offset + position, matchLength), + }); + cursor = lineStart + line.length + 2; + quoteOffset += + line.length + + (comment.quote.slice(quoteOffset + line.length).startsWith('\r\n') + ? 2 + : 1); + } + const commentStart = userText.indexOf(comment.comment, cursor); + if (comment.comment && commentStart >= 0) { + sources.push({ + start: start + commentStart, + end: start + commentStart + comment.comment.length, + locate: sourceLocator(comment.comment), + }); + cursor = commentStart + comment.comment.length; + } + } + if (user?.text && userText.endsWith(user.text)) { + sources.push({ + start: start + userText.length - user.text.length, + end: start + userText.length, + locate: sourceLocator(user.text), + }); + } } let interpretationIndexed = false; for (const msg of turn.transcript) { @@ -245,19 +311,26 @@ function buildThreadHaystack( ) interpretationIndexed = true; if (interpretation?.state === 'reported') { - segments.push(interpretation.text); - if (interpretation.explanation) - segments.push(interpretation.explanation); + append(interpretation.text); + if (interpretation.explanation) append(interpretation.explanation); } } // Only assistant prose contributes model speech; `tool_call`, // `thinking`, `plan`, and `error` fragments are excluded. if (msg.type !== 'text') continue; const text = msg.data.content; - if (typeof text === 'string' && text.length > 0) segments.push(text); + if (typeof text === 'string' && text.length > 0) append(text); } } - return segments.join('\n\n'); + return { + text: segments.join('\n\n'), + sourceAt: (offset, matchLength) => { + const source = sources.find( + (entry) => offset >= entry.start && offset + matchLength <= entry.end, + ); + return source?.locate(offset - source.start, matchLength); + }, + }; } /** @@ -283,12 +356,13 @@ async function scanNodeConversation( (await agenetes.record(namespace, node.threadId))?.spec.kind === INTERNAL_DRIVER_KIND; const haystack = buildThreadHaystack(turns, recoverInternalToolNames); - if (haystack.length === 0) return; - emitFieldHits('conversation', haystack, needleLower, needleLen, { + if (haystack.text.length === 0) return; + emitFieldHits('conversation', haystack.text, needleLower, needleLen, { nodeId: node.id, nodeType: node.type, label, tryEmit, + sourceAt: haystack.sourceAt, }); } @@ -308,15 +382,15 @@ async function scanNodeConversation( function scanOccurrences( haystack: string, needleLower: string, - needleLen: number, + _needleLen: number, tryEmit: (match: CanvasSearchMatch) => boolean, buildMatch: ( snippet: { text: string; matchStart: number; matchLength: number }, occurrenceIndex: number, + sourceOffset: number, + sourceLength: number, ) => CanvasSearchMatch, ): void { - const lower = haystack.toLowerCase(); - let from = 0; // 0-based ordinal of the hit *within this scan*. The wire contract // documents `occurrenceIndex` as monotonically increasing per // (entity, field), and every caller invokes `scanOccurrences` once @@ -325,14 +399,10 @@ function scanOccurrences( // already shipped are 0..k-1 and map cleanly to the n-th DOM // occurrence on the client. let occurrenceIndex = 0; - while (true) { - const idx = lower.indexOf(needleLower, from); - if (idx === -1) return; - const snippet = buildSnippet(haystack, idx, needleLen); - if (!tryEmit(buildMatch(snippet, occurrenceIndex))) return; - // `max(1, needleLen)` guards against a zero-width needle locking - // the loop — schema-bounded to >= 1 char today, but cheap insurance. - from = idx + Math.max(1, needleLen); + for (const { start, end } of literalTextMatches(haystack, needleLower)) { + const snippet = buildSnippet(haystack, start, end - start); + if (!tryEmit(buildMatch(snippet, occurrenceIndex, start, end - start))) + return; occurrenceIndex += 1; } } @@ -380,6 +450,7 @@ interface FieldEmitContext { label: string | null; /** Returns false when the global limit is exhausted. */ tryEmit: (match: CanvasSearchMatch) => boolean; + sourceAt?: (offset: number, length: number) => CanvasSearchSource | undefined; } /** @@ -406,7 +477,7 @@ function emitFieldHits( needleLower, needleLen, ctx.tryEmit, - (snippet, occurrenceIndex) => ({ + (snippet, occurrenceIndex, sourceOffset, sourceLength) => ({ nodeId: ctx.nodeId, nodeType: ctx.nodeType, label: ctx.label, @@ -419,6 +490,7 @@ function emitFieldHits( // whitespace runs (e.g. needle `"a b"`, snippet `"a b"`). matchLength: snippet.matchLength, occurrenceIndex, + source: ctx.sourceAt?.(sourceOffset, sourceLength), }), ); } diff --git a/apps/server/src/modules/remote_fs/rfs.route.test.ts b/apps/server/src/modules/remote_fs/rfs.route.test.ts index 6e00a96a3..ca9d9d37a 100644 --- a/apps/server/src/modules/remote_fs/rfs.route.test.ts +++ b/apps/server/src/modules/remote_fs/rfs.route.test.ts @@ -1081,7 +1081,19 @@ describe('POST /api/rfs/:canvasId/query', () => { result: { count: 1, truncated: false, - matches: [{ tier: 'content', match: { nodeId: 'node-1' } }], + matches: [ + { + tier: 'content', + match: { + nodeId: 'node-1', + source: { + hash: expect.stringMatching(/^[a-f0-9]{64}$/), + offset: 6, + length: 10, + }, + }, + }, + ], }, }); } finally { diff --git a/apps/web/package.json b/apps/web/package.json index dbc3aeac3..6a7f0146e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,6 +37,7 @@ "@milkdown/prose": "^7.21.1", "@milkdown/react": "^7.21.1", "@milkdown/utils": "^7.21.1", + "@noble/hashes": "^1.8.0", "@xyflow/react": "^12.10.0", "axios": "^1.13.4", "clsx": "^2.1.1", diff --git a/apps/web/src/components/CodeMirror/RawMarkdownEditor.test.tsx b/apps/web/src/components/CodeMirror/RawMarkdownEditor.test.tsx new file mode 100644 index 000000000..779ecf979 --- /dev/null +++ b/apps/web/src/components/CodeMirror/RawMarkdownEditor.test.tsx @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { EditorView } from '@codemirror/view'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { hashSearchSource } from '@/hooks/useSearchSource'; +import { usePreviewSearchStore } from '@/store/previewSearchStore'; + +import RawMarkdownEditor from './RawMarkdownEditor'; +import { InPreviewSearchBar } from '../Panels/ExpandedNodePanel/InPreviewSearchBar'; +import { PreviewSearchAdapterProvider } from '../Panels/ExpandedNodePanel/PreviewSearchAdapterContext'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue( + new DOMRect(10, 100, 80, 20), + ); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + usePreviewSearchStore.getState().close(); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it.each([false, true])( + 'reveals exact raw-source URL text without editing or focusing (%s)', + async (readOnly) => { + const value = '[needle](https://example.test/needle)\n\nParagraph needle'; + const onChange = vi.fn(); + const external = document.createElement('input'); + document.body.append(external); + external.focus(); + usePreviewSearchStore.getState().reveal('raw-note', 'needle', 1, { + hash: await hashSearchSource(value), + offset: value.indexOf('needle', value.indexOf('https')), + length: 6, + }); + act(() => + root.render( + + + + , + ), + ); + await act(async () => { + await vi.waitFor(() => + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(), + ); + }); + expect(host.textContent).toContain('2/3'); + const content = host.querySelector('.cm-content'); + if (!content) throw new Error('Missing CodeMirror document'); + const view = EditorView.findFromDOM(content); + expect(view?.state.doc.toString()).toBe(value); + expect(view?.state.selection.main.head).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(external); + external.remove(); + }, +); diff --git a/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx b/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx index e757f9aa3..2f99f9c98 100644 --- a/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx +++ b/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx @@ -35,9 +35,18 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { Compartment, EditorState, Prec } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; import { basicSetup } from 'codemirror'; -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { literalTextMatches } from '@huabu/shared'; + +import { scheduleScrollToMatch } from '@/hooks/searchDom'; +import { hashSearchSource, useSearchSource } from '@/hooks/useSearchSource'; +import { usePreviewSearchStore } from '@/store/previewSearchStore'; import { huabuLightTheme } from './huabuLightTheme'; +import { useRegisterPreviewSearchAdapter } from '../Panels/ExpandedNodePanel/PreviewSearchAdapterContext'; + +import type { PreviewSearchAdapter } from '../Panels/ExpandedNodePanel/PreviewSearchAdapterContext'; /** * How many spaces the Tab key inserts. Tweak here to switch between @@ -73,6 +82,7 @@ export interface RawMarkdownEditorProps { className?: string; /** Accessible label forwarded to CM's content DOM. */ ariaLabel?: string; + searchNodeId?: string; } export default function RawMarkdownEditor({ @@ -81,6 +91,7 @@ export default function RawMarkdownEditor({ readOnly, className, ariaLabel, + searchNodeId, }: RawMarkdownEditorProps): React.JSX.Element { const hostRef = useRef(null); const viewRef = useRef(null); @@ -90,6 +101,75 @@ export default function RawMarkdownEditor({ onChangeRef.current = onChange; /** Compartment that lets us hot-swap the read-only facet at runtime. */ const readOnlyCompartmentRef = useRef(new Compartment()); + const query = usePreviewSearchStore((state) => + state.isOpen && state.nodeId === searchNodeId ? state.query : '', + ); + const matches = useMemo( + () => Array.from(literalTextMatches(value, query)), + [value, query], + ); + const getSearchRange = useCallback( + (text: string, start: number, end: number): Range | null => { + const view = viewRef.current; + if ( + !view || + view.state.doc.toString() !== text || + !view.visibleRanges.some(({ from, to }) => start >= from && end <= to) + ) + return null; + const from = view.domAtPos(start); + const to = view.domAtPos(end); + const range = document.createRange(); + range.setStart(from.node, from.offset); + range.setEnd(to.node, to.offset); + return !range.collapsed && range.toString() === text.slice(start, end) + ? range + : null; + }, + [], + ); + const sourceResolver = useRef({ getSearchRange }); + useSearchSource(hostRef, value, 'text', sourceResolver); + const adapter = useMemo(() => { + if (!query) return null; + const getMatchRange = (index: number): Range | null => { + const match = matches[index]; + return match ? getSearchRange(value, match.start, match.end) : null; + }; + return { + query, + matchCount: matches.length, + isSearching: false, + canNavigate: true, + getMatchRange, + resolveSourceMatch: async (source) => { + if (source.hash !== (await hashSearchSource(value))) return null; + const index = matches.findIndex( + (match) => + match.start === source.offset && + match.end === source.offset + (source.length ?? query.length), + ); + return index < 0 ? null : index; + }, + navigateToMatch: (index, onResult) => { + const match = matches[index]; + const view = viewRef.current; + if (!match || !view) { + onResult(false); + return; + } + view.dispatch({ + effects: EditorView.scrollIntoView(match.start, { y: 'center' }), + }); + return scheduleScrollToMatch(() => hostRef.current, query, index, { + getMatchRange: () => getMatchRange(index), + onMatch: (range) => onResult(true, range), + onTimeout: () => onResult(false), + fallbackToFirst: false, + }); + }, + }; + }, [query, matches, value, getSearchRange]); // Mount the editor once. `readOnly` and `value` are reconciled by // dedicated effects below so we never tear the view down on prop @@ -120,6 +200,8 @@ export default function RawMarkdownEditor({ const view = new EditorView({ state, parent: host }); viewRef.current = view; + view.contentDOM.setAttribute('data-preview-search-content', ''); + host.setAttribute('data-search-source-ready', ''); if (ariaLabel) { view.contentDOM.setAttribute('aria-label', ariaLabel); @@ -128,6 +210,7 @@ export default function RawMarkdownEditor({ return () => { view.destroy(); viewRef.current = null; + host.removeAttribute('data-search-source-ready'); }; // Intentionally mount-only — see dedicated reconciliation effects. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -157,5 +240,7 @@ export default function RawMarkdownEditor({ }); }, [readOnly]); - return
; + useRegisterPreviewSearchAdapter(adapter); + + return
; } diff --git a/apps/web/src/components/Common/DropdownMenu.test.tsx b/apps/web/src/components/Common/DropdownMenu.test.tsx new file mode 100644 index 000000000..df77302a0 --- /dev/null +++ b/apps/web/src/components/Common/DropdownMenu.test.tsx @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Button } from './Button'; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuLink, + DropdownMenuSubmenu, +} from './DropdownMenu'; +import { TextInput } from './TextInput'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | undefined; +let container: HTMLDivElement | undefined; + +async function mount(content: ReactNode) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => root?.render(content)); +} + +function getElement(selector: string): HTMLElement { + const element = document.querySelector(selector); + if (!element) throw new Error(`Missing ${selector}`); + return element; +} + +async function press(element: HTMLElement, key: string) { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + await act(async () => element.dispatchEvent(event)); + return event; +} + +async function click(element: HTMLElement) { + await act(async () => element.click()); +} + +async function nextFrame() { + await act(async () => { + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + }); +} + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = undefined; + container = undefined; + vi.restoreAllMocks(); +}); + +describe('DropdownMenu keyboard contract', () => { + it('labels the menu, focuses its first enabled item, and navigates buttons and links', async () => { + await mount( + + Actions}> + Disabled + First +
+ + Spaces + + + , + ); + const trigger = getElement('[data-testid="trigger"]'); + await click(trigger); + const menu = getElement('[role="menu"]'); + const first = getElement('[data-testid="first"]'); + const last = getElement('[data-testid="last"]'); + expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); + expect(trigger.getAttribute('aria-controls')).toBe(menu.id); + expect(menu.getAttribute('aria-labelledby')).toBe(trigger.id); + expect(document.activeElement).toBe(first); + expect(last.tagName).toBe('A'); + expect(last.getAttribute('href')).toBe('/spaces'); + await press(first, 'ArrowDown'); + expect(document.activeElement).toBe(last); + await press(last, 'ArrowDown'); + expect(document.activeElement).toBe(first); + await press(first, 'ArrowUp'); + expect(document.activeElement).toBe(last); + await press(last, 'Home'); + expect(document.activeElement).toBe(first); + await press(first, 'End'); + expect(document.activeElement).toBe(last); + }); + + it.each(['ArrowDown', 'ArrowUp'])( + 'opens from %s at the expected edge', + async (key) => { + const scroll = vi.spyOn(HTMLElement.prototype, 'scrollIntoView'); + await mount( + Actions}> + First + Last + , + ); + const trigger = getElement('[data-testid="trigger"]'); + act(() => trigger.focus()); + await press(trigger, key); + const item = getElement( + key === 'ArrowUp' ? '[data-testid="last"]' : '[data-testid="first"]', + ); + expect(document.activeElement).toBe(item); + expect(scroll).toHaveBeenCalledWith({ block: 'nearest' }); + expect(scroll.mock.instances.at(-1)).toBe(item); + }, + ); + + it('respects a trigger handler that consumes an arrow key', async () => { + const onKeyDown = vi.fn((event: React.KeyboardEvent) => + event.preventDefault(), + ); + await mount( + + Actions + + } + > + First + , + ); + await press(getElement('[data-testid="trigger"]'), 'ArrowDown'); + expect(onKeyDown).toHaveBeenCalledOnce(); + expect(document.querySelector('[role="menu"]')).toBeNull(); + }); + + it('closes on Escape and restores trigger focus', async () => { + await mount( + Actions}> + First + , + ); + const trigger = getElement('[data-testid="trigger"]'); + await click(trigger); + await press(getElement('[role="menuitem"]'), 'Escape'); + await nextFrame(); + expect(document.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('dismisses on Tab without preventing native tab traversal', async () => { + await mount( + Actions}> + First + , + ); + const trigger = getElement('[data-testid="trigger"]'); + await click(trigger); + const event = await press(getElement('[role="menuitem"]'), 'Tab'); + expect(event.defaultPrevented).toBe(false); + expect(document.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('keeps outside-press focus outside the menu', async () => { + await mount( + <> + Actions}> + First + + + , + ); + await click(getElement('[data-testid="trigger"]')); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const outside = getElement('[data-testid="outside"]'); + act(() => { + outside.focus(); + outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + }); + await nextFrame(); + expect(document.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(outside); + }); + + it('opens a submenu with ArrowRight and returns with ArrowLeft or Escape', async () => { + await mount( + Actions}> + + Child + + Sibling + , + ); + await click(getElement('[data-testid="trigger"]')); + const submenuTrigger = getElement( + '[role="menuitem"][aria-haspopup="menu"]', + ); + await press(submenuTrigger, 'ArrowRight'); + const child = getElement('[data-testid="child"]'); + expect(document.activeElement).toBe(child); + await press(child, 'ArrowDown'); + expect(document.activeElement).toBe(child); + await press(child, 'ArrowLeft'); + await nextFrame(); + expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1); + expect(document.activeElement).toBe(submenuTrigger); + await press(submenuTrigger, 'ArrowRight'); + await press(getElement('[data-testid="child"]'), 'Escape'); + await nextFrame(); + expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1); + expect(document.activeElement).toBe(submenuTrigger); + }); + + it.each([ + ['ArrowDown', 'last'], + ['ArrowUp', 'first'], + ])( + 'keeps %s on a submenu trigger in the parent menu', + async (key, target) => { + await mount( + Actions}> + First + + Child + + Last + , + ); + await click(getElement('[data-testid="trigger"]')); + await press(getElement('[data-testid="first"]'), 'ArrowDown'); + const submenuTrigger = getElement( + '[role="menuitem"][aria-haspopup="menu"]', + ); + expect(document.activeElement).toBe(submenuTrigger); + await press(submenuTrigger, key); + expect(document.activeElement).toBe( + getElement(`[data-testid="${target}"]`), + ); + expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1); + }, + ); + + it('preserves hover focus and supports entering the open submenu with ArrowRight', async () => { + await mount( + Actions}> + First + + Child + + , + ); + await click(getElement('[data-testid="trigger"]')); + const first = getElement('[data-testid="first"]'); + const submenuTrigger = getElement( + '[role="menuitem"][aria-haspopup="menu"]', + ); + await act(async () => + submenuTrigger.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ), + ); + expect(document.querySelectorAll('[role="menu"]')).toHaveLength(2); + expect(document.activeElement).toBe(first); + await press(first, 'ArrowDown'); + expect(document.activeElement).toBe(submenuTrigger); + await press(submenuTrigger, 'ArrowRight'); + expect(document.activeElement).toBe(getElement('[data-testid="child"]')); + }); + + it('dismisses the whole menu chain when Tab leaves a submenu', async () => { + await mount( + Actions}> + + Child + + , + ); + const trigger = getElement('[data-testid="trigger"]'); + await click(trigger); + await press( + getElement('[role="menuitem"][aria-haspopup="menu"]'), + 'ArrowRight', + ); + const event = await press(getElement('[data-testid="child"]'), 'Tab'); + expect(event.defaultPrevented).toBe(false); + expect(document.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('preserves text editing and custom initial focus in dialog panels', async () => { + let input: HTMLInputElement | null = null; + await mount( + input?.focus()} + trigger={} + > + { + input = node; + }} + aria-label="Width" + defaultValue="120" + /> + , + ); + const trigger = getElement('[data-testid="trigger"]'); + await click(trigger); + expect(trigger.getAttribute('aria-haspopup')).toBe('dialog'); + expect(document.querySelector('[role="menu"]')).toBeNull(); + const field = getElement('input'); + expect(document.activeElement).toBe(field); + for (const key of ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter']) { + expect((await press(field, key)).defaultPrevented).toBe(false); + } + }); + + it('does not add a second menu or duplicate custom keyboard navigation', async () => { + const onKeyDown = vi.fn(); + await mount( + Custom menu} + > +
+ First +
+
, + ); + await click(getElement('[data-testid="trigger"]')); + await press(getElement('[role="menuitem"]'), 'ArrowDown'); + expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1); + expect(onKeyDown).toHaveBeenCalledOnce(); + }); + + it('preserves a custom trigger relationship while its context menu is closed', async () => { + await mount( + + Preview + + } + > +
+ Close +
+
, + ); + const trigger = getElement('[data-testid="trigger"]'); + expect(trigger.id).toBe('preview-tab'); + expect(trigger.getAttribute('aria-controls')).toBe('preview-panel'); + await click(trigger); + expect(trigger.getAttribute('aria-controls')).toBe('preview-panel'); + }); +}); diff --git a/apps/web/src/components/Common/DropdownMenu.tsx b/apps/web/src/components/Common/DropdownMenu.tsx index cc2b4e0ef..7ea086e37 100644 --- a/apps/web/src/components/Common/DropdownMenu.tsx +++ b/apps/web/src/components/Common/DropdownMenu.tsx @@ -6,6 +6,7 @@ import { cloneElement, useCallback, useEffect, + useId, useRef, useState, type ReactElement, @@ -13,6 +14,8 @@ import { } from 'react'; import { Link } from 'react-router-dom'; +import { isEditableTarget } from '@/hooks/shortcuts/isEditableTarget'; + import { Button } from './Button'; import { cn } from './cn'; import { @@ -28,7 +31,7 @@ import { type PopoverProps, } from './Popover'; -import type { ButtonHTMLAttributes } from 'react'; +import type { AriaAttributes, ButtonHTMLAttributes } from 'react'; import type { LinkProps } from 'react-router-dom'; // ─── DropdownMenuItem ───────────────────────────────────────────────────────── @@ -80,6 +83,7 @@ export const DropdownMenuItem: React.FC = ({ variant="ghost" tone="neutral" role="menuitem" + tabIndex={-1} className={cn(MENU_ITEM_CLASS, className)} {...props} > @@ -112,6 +116,7 @@ export const DropdownMenuLink: React.FC = ({ }) => ( `). Receives onClick and aria-expanded. */ trigger: ReactElement<{ onClick?: (e: React.MouseEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + id?: string; + 'aria-haspopup'?: AriaAttributes['aria-haspopup']; + 'aria-controls'?: string; 'aria-expanded'?: boolean; 'aria-describedby'?: string; }>; @@ -190,6 +201,7 @@ export const DropdownMenu: React.FC = ({ floating = false, placement, onOpenAutoFocus, + contentRole = 'menu', trigger, children, className, @@ -214,10 +226,35 @@ export const DropdownMenu: React.FC = ({ [isControlled, isOpen, onOpenChange], ); const triggerRef = useRef(null); + const menuRef = useRef(null); + const id = useId(); + const triggerId = trigger.props.id ?? `${id}-trigger`; + const menuId = `${id}-menu`; + const initialFocusRef = useRef<'first' | 'last'>('first'); const justDismissedRef = useRef(false); + const getMenuItems = () => + Array.from( + menuRef.current?.querySelectorAll( + '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]', + ) ?? [], + ).filter( + (item) => + item.closest('[role="menu"]') === menuRef.current && + !item.matches(':disabled, [aria-disabled="true"]') && + !item.closest('[hidden], [inert]'), + ); + + const focusMenuEdge = (edge: 'first' | 'last') => { + const items = getMenuItems(); + const item = edge === 'last' ? items.at(-1) : items[0]; + item?.focus({ preventScroll: true }); + item?.scrollIntoView({ block: 'nearest' }); + }; + const handleToggle = useCallback(() => { if (justDismissedRef.current) return; + initialFocusRef.current = 'first'; setIsOpen((prev) => !prev); }, [setIsOpen]); @@ -264,6 +301,12 @@ export const DropdownMenu: React.FC = ({ }, [align, isRight, isTop, opensSideways]); const clonedTrigger = cloneElement(trigger, { + id: triggerId, + 'aria-haspopup': + trigger.props['aria-haspopup'] ?? + (contentRole === 'presentation' ? undefined : contentRole), + 'aria-controls': + trigger.props['aria-controls'] ?? (isOpen ? menuId : undefined), ...(describedBy ? { 'aria-describedby': [trigger.props['aria-describedby'], describedBy] @@ -279,6 +322,24 @@ export const DropdownMenu: React.FC = ({ handleToggle(); } }, + onKeyDown: (event) => { + trigger.props.onKeyDown?.(event); + if ( + event.defaultPrevented || + event.target !== event.currentTarget || + contentRole !== 'menu' || + (opensSideways + ? event.key !== 'ArrowRight' + : event.key !== 'ArrowDown' && event.key !== 'ArrowUp') + ) + return; + event.preventDefault(); + event.stopPropagation(); + const edge = event.key === 'ArrowUp' ? 'last' : 'first'; + initialFocusRef.current = edge; + if (isOpen) focusMenuEdge(edge); + else setIsOpen(true); + }, 'aria-expanded': isOpen, }); @@ -289,7 +350,79 @@ export const DropdownMenu: React.FC = ({
{isOpen && ( { + if (onOpenAutoFocus) onOpenAutoFocus(); + else if (contentRole === 'menu') + focusMenuEdge(initialFocusRef.current); + else if (contentRole === 'dialog') + menuRef.current + ?.querySelector( + 'input:not(:disabled), button:not(:disabled), select:not(:disabled), textarea:not(:disabled)', + ) + ?.focus({ preventScroll: true }); + }} + onKeyDown={(event) => { + if ( + contentRole !== 'menu' || + event.defaultPrevented || + !(event.target instanceof Element) || + isEditableTarget(event.target) + ) + return; + if (event.key === 'Tab') { + setIsOpen(false); + triggerRef.current + ?.querySelector('[role="tab"], button') + ?.focus({ preventScroll: true }); + return; + } + if (event.target.closest('[role="menu"]') !== event.currentTarget) + return; + if (event.key === 'ArrowLeft' && opensSideways) { + event.preventDefault(); + event.stopPropagation(); + handleDismiss('escape'); + return; + } + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.stopPropagation(); + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.stopPropagation(); + return; + } + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) + return; + event.preventDefault(); + event.stopPropagation(); + const items = getMenuItems(); + if (!items.length) return; + const index = items.findIndex( + (item) => item === document.activeElement, + ); + const next = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? items.length - 1 + : index < 0 + ? event.key === 'ArrowUp' + ? items.length - 1 + : 0 + : (index + + (event.key === 'ArrowDown' ? 1 : -1) + + items.length) % + items.length; + items[next]?.focus({ preventScroll: true }); + items[next]?.scrollIntoView({ block: 'nearest' }); + }} reference={floating ? triggerRef.current : undefined} placement={placement ?? (isRight ? 'bottom-end' : 'bottom-start')} position={computePosition()} @@ -329,6 +462,8 @@ export const DropdownMenuSubmenu: React.FC = ({ className, }) => { const [open, setOpen] = useState(false); + const keyboardOpenRef = useRef(false); + const contentRef = useRef(null); const closeTimerRef = useRef | null>(null); const cancelClose = useCallback(() => { @@ -338,7 +473,9 @@ export const DropdownMenuSubmenu: React.FC = ({ const scheduleClose = useCallback(() => { cancelClose(); - closeTimerRef.current = setTimeout(() => setOpen(false), 120); + closeTimerRef.current = setTimeout(() => { + if (!contentRef.current?.contains(document.activeElement)) setOpen(false); + }, 120); }, [cancelClose]); const openSubmenu = useCallback(() => { @@ -349,13 +486,20 @@ export const DropdownMenuSubmenu: React.FC = ({ useEffect(() => cancelClose, [cancelClose]); return ( -
+
{ + keyboardOpenRef.current = false; + openSubmenu(); + }} + onPointerLeave={scheduleClose} + > undefined} className={className} trigger={ = ({ /> } onKeyDown={(event) => { - if (event.key === 'ArrowRight') { - event.preventDefault(); - openSubmenu(); - } + if (['ArrowRight', 'Enter', ' '].includes(event.key)) + keyboardOpenRef.current = true; + }} + onClick={() => { + keyboardOpenRef.current = true; }} > {label} } > -
+
{children}
diff --git a/apps/web/src/components/Common/FloatingToolbar.tsx b/apps/web/src/components/Common/FloatingToolbar.tsx index b3bd9a219..20e93d13f 100644 --- a/apps/web/src/components/Common/FloatingToolbar.tsx +++ b/apps/web/src/components/Common/FloatingToolbar.tsx @@ -984,6 +984,7 @@ function ToolbarAlignPicker({ floating placement="bottom" open={isOpen} + contentRole="dialog" onOpenChange={setIsOpen} className={cn(FLOATING_TOOLBAR_POPOVER_CLASS, panelClassName)} trigger={ diff --git a/apps/web/src/components/Common/Popover.tsx b/apps/web/src/components/Common/Popover.tsx index 2e7f262db..9695a5e18 100644 --- a/apps/web/src/components/Common/Popover.tsx +++ b/apps/web/src/components/Common/Popover.tsx @@ -18,6 +18,7 @@ import { useLayoutEffect, useMemo, type CSSProperties, + type HTMLAttributes, type MutableRefObject, type Ref, useRef, @@ -126,7 +127,10 @@ function assignRef(ref: Ref | undefined, value: T | null) { (ref as MutableRefObject).current = value; } -export type PopoverProps = { +export type PopoverProps = Pick< + HTMLAttributes, + 'id' | 'role' | 'aria-labelledby' | 'onKeyDown' +> & { reference?: Element | null; placement?: Placement; /** @@ -214,6 +218,10 @@ export type PopoverProps = { * - Provides a styled card container (border + shadow + bg) by default. */ export const Popover: FC = ({ + id, + role, + 'aria-labelledby': labelledBy, + onKeyDown, reference, placement = 'bottom', position: positionProp, @@ -461,7 +469,13 @@ export const Popover: FC = ({ const panel = ( + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- Callers supply the role and its keyboard contract together. */}
{ containerRef.current = node; floating.refs.setFloating(node); diff --git a/apps/web/src/components/Common/SplitSelect.test.tsx b/apps/web/src/components/Common/SplitSelect.test.tsx index c2f222d05..fb158cdea 100644 --- a/apps/web/src/components/Common/SplitSelect.test.tsx +++ b/apps/web/src/components/Common/SplitSelect.test.tsx @@ -18,6 +18,34 @@ afterEach(() => { }); describe('SplitSelect', () => { + it.each([undefined, false, true])( + 'exposes primary pressed state %s without turning its menu trigger into a toggle', + (primaryPressed) => { + const onPrimaryAction = vi.fn(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render( + , + ), + ); + const buttons = container.querySelectorAll('button'); + expect(buttons[0].getAttribute('aria-pressed')).toBe( + primaryPressed === undefined ? null : String(primaryPressed), + ); + expect(buttons[1].hasAttribute('aria-pressed')).toBe(false); + act(() => buttons[0].click()); + expect(onPrimaryAction).toHaveBeenCalledExactlyOnceWith('select'); + }, + ); + it('hides the menu button without disabling the primary action', () => { const onPrimaryAction = vi.fn(); container = document.createElement('div'); diff --git a/apps/web/src/components/Common/SplitSelect.tsx b/apps/web/src/components/Common/SplitSelect.tsx index 80657bae9..70f484f79 100644 --- a/apps/web/src/components/Common/SplitSelect.tsx +++ b/apps/web/src/components/Common/SplitSelect.tsx @@ -49,6 +49,7 @@ type SplitSelectProps = { align?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'; iconOnly?: boolean; primaryTitle?: string; + primaryPressed?: boolean; menuTitle?: string; /** * Optional single-character keyboard hint rendered as a tiny subscript @@ -104,6 +105,7 @@ export function SplitSelect({ align = 'bottom-left', iconOnly = false, primaryTitle, + primaryPressed, menuTitle, primaryShortcutBadge, primaryShortcutBadgeActive, @@ -182,6 +184,7 @@ export function SplitSelect({ disabled={disabled} onClick={handlePrimaryAction} title={primaryTitle} + aria-pressed={primaryPressed} aria-label={ iconOnly ? (primaryTitle ?? diff --git a/apps/web/src/components/Messages/MessageList.test.tsx b/apps/web/src/components/Messages/MessageList.test.tsx index e17e5c695..df1decff2 100644 --- a/apps/web/src/components/Messages/MessageList.test.tsx +++ b/apps/web/src/components/Messages/MessageList.test.tsx @@ -1116,6 +1116,86 @@ describe('MessageList opening position', () => { }); describe('MessageList older history', () => { + it('reveals cached turns and pages older history only during an explicit search jump', () => { + const onLoadOlderHistory = vi.fn(); + const recent: ChatMessage[] = [ + { id: 'user-2', role: 'user', content: 'Recent needle' }, + { id: 'user-3', role: 'user', content: 'Newest needle' }, + ]; + const renderList = (messages: ChatMessage[], loading = false) => ( + + ); + mount(renderList(recent)); + expect( + container?.querySelector('[data-chat-message-id="user-2"]'), + ).toBeNull(); + expect(onLoadOlderHistory).not.toHaveBeenCalled(); + const thread = container?.querySelector( + '[data-chat-thread-root]', + ); + act(() => + thread?.dispatchEvent( + new Event('chat-reveal-search', { cancelable: true }), + ), + ); + expect( + container?.querySelector('[data-chat-message-id="user-2"]'), + ).not.toBeNull(); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + act(() => root?.render(renderList(recent, true))); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + const older: ChatMessage[] = [ + { id: 'user-1', role: 'user', content: 'Older needle' }, + ...recent, + ]; + act(() => root?.render(renderList(older))); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + act(() => root?.render(renderList(older))); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + act(() => thread?.dispatchEvent(new Event('chat-search-finished'))); + act(() => + root?.render( + renderList([ + { id: 'user-0', role: 'user', content: 'Oldest needle' }, + ...older, + ]), + ), + ); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + }); + + it('does not automatically retry a failed older-history page during search', () => { + const onLoadOlderHistory = vi.fn(); + mount( + , + ); + const thread = container?.querySelector( + '[data-chat-thread-root]', + ); + act(() => + thread?.dispatchEvent( + new Event('chat-reveal-search', { cancelable: true }), + ), + ); + expect(onLoadOlderHistory).not.toHaveBeenCalled(); + expect(container?.querySelector('[role="alert"]')?.textContent).toBe( + 'History unavailable', + ); + }); + it('exposes an accessible explicit expansion control', () => { const onLoadOlderHistory = vi.fn(); mount( diff --git a/apps/web/src/components/Messages/MessageList.tsx b/apps/web/src/components/Messages/MessageList.tsx index 68f16f2e2..62a8f04b2 100644 --- a/apps/web/src/components/Messages/MessageList.tsx +++ b/apps/web/src/components/Messages/MessageList.tsx @@ -132,6 +132,8 @@ export const MessageList = memo(function MessageList({ const positionedActivationRef = useRef(activationId); const observedCompletionRef = useRef(completedTurnId); const explicitReadingRef = useRef(false); + const [revealingSearch, setRevealingSearch] = useState(false); + const requestedSearchPageRef = useRef(undefined); const handledOpenRequestRef = useRef(undefined); const prependAnchorRef = useRef<{ messageId: string; @@ -496,6 +498,35 @@ export const MessageList = memo(function MessageList({ } }, [completedTurnId, canScroll, isLoadingOlderHistory, displayWindow]); + useLayoutEffect(() => { + setRevealingSearch(false); + requestedSearchPageRef.current = undefined; + }, [viewKey, activationId, isActive]); + + useEffect(() => { + if ( + !revealingSearch || + !canScroll() || + !hasOlderHistory || + isLoadingOlderHistory || + olderHistoryError || + !onLoadOlderHistory + ) + return; + const page = cachedMessages[0]?.id ?? ''; + if (requestedSearchPageRef.current === page) return; + requestedSearchPageRef.current = page; + onLoadOlderHistory(); + }, [ + revealingSearch, + canScroll, + hasOlderHistory, + isLoadingOlderHistory, + olderHistoryError, + onLoadOlderHistory, + cachedMessages, + ]); + useLayoutEffect(() => { const container = containerRef.current; if (!container || !canScroll()) return; @@ -504,11 +535,17 @@ export const MessageList = memo(function MessageList({ explicitReadingRef.current = true; isAtBottomRef.current = false; finishPrependAnchoring(); + requestedSearchPageRef.current = undefined; + setRevealingSearch(true); displayWindow.showAll(); }; + const finishSearch = () => setRevealingSearch(false); container.addEventListener('chat-reveal-search', revealForSearch); - return () => + container.addEventListener('chat-search-finished', finishSearch); + return () => { container.removeEventListener('chat-reveal-search', revealForSearch); + container.removeEventListener('chat-search-finished', finishSearch); + }; }, [canScroll, finishPrependAnchoring, displayWindow]); useEffect(() => finishPrependAnchoring, [finishPrependAnchoring]); @@ -572,6 +609,10 @@ export const MessageList = memo(function MessageList({ data-chat-thread-root data-chat-thread-id={threadId} data-chat-cached-earlier={displayWindow.hasCachedEarlier || undefined} + data-chat-has-older={hasOlderHistory || undefined} + data-chat-history-loading={ + isHistoryLoading || isLoadingOlderHistory || undefined + } tabIndex={-1} className="flex-1 overflow-x-visible overflow-y-auto px-3" > diff --git a/apps/web/src/components/Messages/QuotedCommentCard.tsx b/apps/web/src/components/Messages/QuotedCommentCard.tsx index 171da54ac..6642d0739 100644 --- a/apps/web/src/components/Messages/QuotedCommentCard.tsx +++ b/apps/web/src/components/Messages/QuotedCommentCard.tsx @@ -1,10 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/Common/Button'; import { Tooltip } from '@/components/Common/Tooltip'; +import { useSearchSource } from '@/hooks/useSearchSource'; import type { QuotedComment } from '@huabu/shared'; import type { ReactNode } from 'react'; @@ -19,6 +21,10 @@ export function QuotedCommentCard({ children?: ReactNode; }) { const { t } = useTranslation(); + const quoteRef = useRef(null); + const commentRef = useRef(null); + useSearchSource(quoteRef, comment.quote, 'text'); + useSearchSource(commentRef, comment.comment, 'text'); return (
) : null} diff --git a/apps/web/src/components/Milkdown/MilkdownEditor.tsx b/apps/web/src/components/Milkdown/MilkdownEditor.tsx index 88c4fba74..f8e0b7ab8 100644 --- a/apps/web/src/components/Milkdown/MilkdownEditor.tsx +++ b/apps/web/src/components/Milkdown/MilkdownEditor.tsx @@ -32,6 +32,7 @@ import { resolveArtifactUrl, uploadImage as uploadImageApi, } from '@/api/artifact'; +import { useSearchSource } from '@/hooks/useSearchSource'; import { attachBlockDragListeners } from './blockDrag'; import { createMilkdown, type MilkdownInstance } from './createMilkdown'; @@ -122,6 +123,7 @@ export function MilkdownEditor(props: MilkdownEditorProps): React.JSX.Element { const rootRef = useRef(null); const instanceRef = useRef(null); + useSearchSource(rootRef, markdown, 'markdown', instanceRef); const [popoverInstance, setPopoverInstance] = useState(null); /** Most recent value either set on or emitted from the editor. */ @@ -207,6 +209,7 @@ export function MilkdownEditor(props: MilkdownEditorProps): React.JSX.Element { }); instanceRef.current = instance; + root.setAttribute('data-search-source-ready', ''); setPopoverInstance(instance); onReadyRef.current?.(instance); @@ -224,6 +227,7 @@ export function MilkdownEditor(props: MilkdownEditorProps): React.JSX.Element { detachDrag(); const instance = instanceRef.current; instanceRef.current = null; + root.removeAttribute('data-search-source-ready'); setPopoverInstance(null); onReadyRef.current?.(null); if (instance) void instance.destroy(); @@ -301,7 +305,7 @@ export function MilkdownEditor(props: MilkdownEditorProps): React.JSX.Element { return ( <> -
+
{editable && popoverInstance ? ( ) : null} diff --git a/apps/web/src/components/Milkdown/MilkdownPreview.tsx b/apps/web/src/components/Milkdown/MilkdownPreview.tsx index a3c667e05..23da5364c 100644 --- a/apps/web/src/components/Milkdown/MilkdownPreview.tsx +++ b/apps/web/src/components/Milkdown/MilkdownPreview.tsx @@ -26,6 +26,7 @@ import { useTranslation } from 'react-i18next'; import { resolveArtifactUrl } from '@/api/artifact'; import { useLinkFollowModifierHeld } from '@/hooks/useMultiSelectModifier'; +import { useSearchSource } from '@/hooks/useSearchSource'; import { attachBlockDragListeners } from './blockDrag'; import { @@ -124,6 +125,7 @@ export function MilkdownPreview( const containerRef = useRef(null); const instanceRef = useRef(null); + useSearchSource(containerRef, markdown, 'markdown', instanceRef); const lastSyncedRef = useRef(normalizeMarkdown(markdown)); const pendingMarkdownRef = useRef(null); const selectingWithMouseRef = useRef(false); @@ -208,6 +210,7 @@ export function MilkdownPreview( instance.setAriaLabel(ariaLabelRef.current); instanceRef.current = instance; + mountRoot.setAttribute('data-search-source-ready', ''); const pending = pendingMarkdownRef.current; pendingMarkdownRef.current = null; @@ -223,6 +226,7 @@ export function MilkdownPreview( mountRoot.classList.remove('milkdown-preview-host'); const instance = instanceRef.current; instanceRef.current = null; + mountRoot.removeAttribute('data-search-source-ready'); if (instance) void instance.destroy(); }; // Re-mount when drag mode toggles (rare, expected). @@ -450,6 +454,7 @@ export function MilkdownPreview( return (
{ + it('resolves a search source after a code node without counting node-view chrome or moving selection', async () => { + const source = '```txt\nunrelated code\n```\n\nParagraph needle'; + const editor = await mount(source); + const target = markdownSearchTarget( + source, + source.lastIndexOf('needle'), + 6, + ); + if (!target) throw new Error('Missing source projection'); + const before = editor.getSelectionRange(); + const range = editor.getSearchRange(target.text, target.start, target.end); + expect(range?.toString()).toBe('needle'); + expect(range?.startContainer.parentElement?.closest('p')?.textContent).toBe( + 'Paragraph needle', + ); + expect(editor.getSelectionRange()).toEqual(before); + expect(editor.getSearchRange('changed needle', 8, 14)).toBeNull(); + }); it('captures inline formatted text with its actual range and neighboring context', async () => { const editor = await mount('Before **selected** after.'); editor.__selectTextBetweenForTest?.('selected', 'selected'); diff --git a/apps/web/src/components/Milkdown/createMilkdown.ts b/apps/web/src/components/Milkdown/createMilkdown.ts index 8f862da91..cbd3da8d6 100644 --- a/apps/web/src/components/Milkdown/createMilkdown.ts +++ b/apps/web/src/components/Milkdown/createMilkdown.ts @@ -312,6 +312,8 @@ export interface MilkdownInlineMathState { export interface MilkdownInstance { /** Read the current document as markdown. */ getMarkdown(): string; + /** Resolve a verified visible-text projection through the editor model. */ + getSearchRange(text: string, start: number, end: number): Range | null; /** * Replace the entire document. Uses Milkdown's `replaceAll` macro so * undo history is preserved. @@ -1963,6 +1965,7 @@ export async function createMilkdown( : inheritedAttributes), ...(ariaLabel ? { 'aria-label': ariaLabel } : {}), 'data-link-activation': linkActivation, + 'data-preview-search-content': '', }), }; }); @@ -2572,6 +2575,31 @@ export async function createMilkdown( const instance: MilkdownInstance = { getMarkdown: () => crepe.getMarkdown(), + getSearchRange: (text, start, end) => + crepe.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + if (view.state.doc.textContent !== text) return null; + let offset = 0; + let from: number | undefined; + let to: number | undefined; + view.state.doc.descendants((node, position) => { + if (!node.isText) return; + const next = offset + node.nodeSize; + if (from === undefined && start >= offset && start < next) + from = position + start - offset; + if (end > offset && end <= next) to = position + end - offset; + offset = next; + }); + if (from === undefined || to === undefined) return null; + const first = view.domAtPos(from, 1); + const last = view.domAtPos(to, -1); + const range = document.createRange(); + range.setStart(first.node, first.offset); + range.setEnd(last.node, last.offset); + return !range.collapsed && range.toString() === text.slice(start, end) + ? range + : null; + }), setMarkdown: (markdown: string) => { crepe.editor.action(replaceAll(normalizeMathDelimiters(markdown))); }, diff --git a/apps/web/src/components/Nodes/frame/FrameNode.tsx b/apps/web/src/components/Nodes/frame/FrameNode.tsx index 1f8ff833c..d92415247 100644 --- a/apps/web/src/components/Nodes/frame/FrameNode.tsx +++ b/apps/web/src/components/Nodes/frame/FrameNode.tsx @@ -239,6 +239,7 @@ export const FrameNode = memo( diff --git a/apps/web/src/components/Nodes/note/NotePreview.tsx b/apps/web/src/components/Nodes/note/NotePreview.tsx index 53775fc22..32dcafd4e 100644 --- a/apps/web/src/components/Nodes/note/NotePreview.tsx +++ b/apps/web/src/components/Nodes/note/NotePreview.tsx @@ -595,6 +595,7 @@ export const NotePreview = ({
void; + onSourceError: () => void; onLoadSuccess: (document: { numPages: number }) => void; } const mocks = vi.hoisted(() => ({ registerSearch: vi.fn(), + toast: vi.fn(), index: vi.fn(), document: vi.fn<(props: DocumentProps) => void>(), page: vi.fn(), header: null as HTMLElement | null, intersection: null as IntersectionObserverCallback | null, resize: null as ResizeObserverCallback | null, + indexPages: new Map(), })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); +vi.mock('@/components/Common/Toast', () => ({ toast: mocks.toast })); vi.mock('@/api/artifact', () => ({ resolveArtifactUrl: (src: string) => src, uploadImage: vi.fn(), @@ -46,23 +57,28 @@ vi.mock('@/store/panelStore', () => ({ usePanelStore: {} })); vi.mock('@/store/previewWorkspace/store', () => ({ usePreviewWorkspaceStore: {}, })); -vi.mock('@/store/previewSearchStore', () => ({ - usePreviewSearchStore: (select: (state: unknown) => unknown) => - select({ nodeId: 'pdf-1', query: 'existing search', isOpen: true }), -})); vi.mock('@/components/Nodes/PreviewHeaderSlot', () => ({ usePreviewHeaderSlot: () => ({ el: mocks.header }), })); vi.mock( '@/components/Panels/ExpandedNodePanel/PreviewSearchAdapterContext', - () => ({ - useRegisterPreviewSearchAdapter: mocks.registerSearch, - }), + async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useRegisterPreviewSearchAdapter: ( + adapter: Parameters[0], + ) => { + mocks.registerSearch(adapter); + actual.useRegisterPreviewSearchAdapter(adapter); + }, + }; + }, ); vi.mock('./usePdfTextIndex', () => ({ usePdfTextIndex: (options: unknown) => { mocks.index(options); - return { pages: new Map(), isIndexing: false }; + return { pages: mocks.indexPages, isIndexing: false }; }, })); vi.mock('./pdfWorker', () => ({ PDF_DOCUMENT_OPTIONS: {} })); @@ -89,7 +105,13 @@ vi.mock('./PDFPageWithOverlay', () => ({ captureEnabled: boolean; }) => { mocks.page(props); - return
; + return ( +
+

+ {mocks.indexPages.get(props.pageIndex)?.text} +

+
+ ); }, })); @@ -102,6 +124,12 @@ let container: HTMLDivElement; beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue( + new DOMRect(10, 100, 80, 20), + ); + mocks.indexPages = new Map(); + usePreviewSearchStore.getState().open('pdf-1'); + usePreviewSearchStore.getState().setQuery('existing search'); mocks.intersection = null; mocks.resize = null; mocks.header = document.createElement('div'); @@ -135,6 +163,7 @@ afterEach(() => { act(() => root.unmount()); container.remove(); mocks.header?.remove(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -166,6 +195,182 @@ function load() { } describe('embedded PDF reader', () => { + it('does not let a failed previous source settle a replacement handoff', async () => { + const renderSource = (src: string) => + root.render( + + + + , + ); + usePreviewSearchStore.getState().reveal('pdf-1', 'needle', 0); + act(() => renderSource('old.pdf')); + const oldLoadError = mocks.document.mock.lastCall?.[0].onLoadError; + act(() => oldLoadError?.()); + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(); + mocks.toast.mockClear(); + mocks.indexPages = new Map([[0, { pageIndex: 0, text: 'needle' }]]); + act(() => { + usePreviewSearchStore.getState().reveal('pdf-1', 'needle', 0); + renderSource('replacement.pdf'); + }); + act(() => oldLoadError?.()); + expect(usePreviewSearchStore.getState().navigationRequest).not.toBeNull(); + expect(mocks.toast).not.toHaveBeenCalled(); + load(); + await act(async () => { + await vi.waitFor(() => + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(), + ); + }); + expect(container.textContent).toContain('1/1'); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it.each(['onLoadError', 'onSourceError'] as const)( + 'settles a failed cold PDF handoff on %s', + async (callback) => { + usePreviewSearchStore.getState().reveal('pdf-1', 'needle', 0); + act(() => + root.render( + + + + , + ), + ); + expect(usePreviewSearchStore.getState().navigationRequest).not.toBeNull(); + act(() => mocks.document.mock.lastCall?.[0][callback]()); + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(); + expect(mocks.toast).toHaveBeenCalledExactlyOnceWith( + 'search.previewMatchNotVisible', + { tone: 'info', duration: 4000 }, + ); + expect(container.textContent).not.toContain('…'); + }, + ); + + it.each(['alpha', 'missing'])( + 'waits for the new query adapter after an already indexed %s search', + async (previousQuery) => { + mocks.indexPages = new Map([ + [0, { pageIndex: 0, text: 'alpha alpha alpha' }], + [1, { pageIndex: 1, text: 'beta' }], + ]); + usePreviewSearchStore.getState().reveal('pdf-1', previousQuery, 0); + act(() => + root.render( + + + + , + ), + ); + load(); + mocks.toast.mockClear(); + const scroll = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(() => {}); + act(() => usePreviewSearchStore.getState().reveal('pdf-1', 'beta', 0)); + await act(async () => { + await vi.waitFor(() => + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(), + ); + }); + expect(scroll).toHaveBeenCalledTimes(1); + expect( + (scroll.mock.instances[0] as HTMLElement).dataset.pdfPageShell, + ).toBe('1'); + expect(container.textContent).toContain('1/1'); + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(); + expect(mocks.toast).not.toHaveBeenCalled(); + }, + ); + + it.each([-1, 1, 3])( + 'reports unavailable PDF occurrence %s without navigating to another one', + (occurrenceIndex) => { + mocks.indexPages = new Map([[0, { pageIndex: 0, text: 'beta' }]]); + usePreviewSearchStore.getState().reveal('pdf-1', 'beta', 0); + act(() => + root.render( + + + + , + ), + ); + load(); + mocks.toast.mockClear(); + const scroll = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(() => {}); + act(() => + usePreviewSearchStore + .getState() + .reveal('pdf-1', 'beta', occurrenceIndex), + ); + expect(scroll).not.toHaveBeenCalled(); + expect(mocks.toast).toHaveBeenCalledExactlyOnceWith( + 'search.previewMatchNotVisible', + { tone: 'info', duration: 4000 }, + ); + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(); + }, + ); + + it('retains a cold PDF handoff until the document, adapter, and text-layer range are ready', async () => { + usePreviewSearchStore.getState().reveal('pdf-1', 'needle', 3); + act(() => + root.render( + + +

needle

+ +
, + ), + ); + expect(usePreviewSearchStore.getState().navigationRequest).not.toBeNull(); + expect(mocks.registerSearch.mock.lastCall?.[0]).toMatchObject({ + matchCount: 0, + canNavigate: false, + isSearching: true, + }); + mocks.indexPages = new Map( + Array.from({ length: 4 }, (_, pageIndex) => [ + pageIndex, + { pageIndex, text: 'needle' }, + ]), + ); + load(); + await act(async () => { + await vi.waitFor(() => + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(), + ); + }); + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(); + expect(container.textContent).toContain('4/4'); + }); it('remembers resolved page dimensions so cold remounts do not shift custom zoom', () => { const key = 'node:pdf-preview-test:natural-size'; render(true, false, undefined, undefined, key); diff --git a/apps/web/src/components/Nodes/pdf/PDFPreview.tsx b/apps/web/src/components/Nodes/pdf/PDFPreview.tsx index 6742323ce..5896878f6 100644 --- a/apps/web/src/components/Nodes/pdf/PDFPreview.tsx +++ b/apps/web/src/components/Nodes/pdf/PDFPreview.tsx @@ -23,9 +23,10 @@ import { computeHighlightUpdate, mergeLineRects, } from '@/handler/pdfHighlight/highlight'; -import { scheduleScrollToMatch } from '@/hooks/searchDom'; +import { scheduleScrollToMatch, findProjectedRange } from '@/hooks/searchDom'; import { handleCanvasNavigationKey } from '@/hooks/shortcuts/handleCanvasNavigationKey'; import { usePreviewScrollMemory } from '@/hooks/usePreviewScrollMemory'; +import { hashSearchSource } from '@/hooks/useSearchSource'; import { loadDefaultAgentBinding } from '@/store/acpProfilesStore'; import useCanvasStore from '@/store/canvasStore'; import { useChatStore } from '@/store/chatStore'; @@ -47,7 +48,7 @@ import { updateVisiblePdfPages, } from './pdfPageRendering'; import { PDFPageWithOverlay } from './PDFPageWithOverlay'; -import { findPdfTextMatches } from './pdfTextIndex'; +import { findPdfTextMatches, findPdfSourceMatch } from './pdfTextIndex'; import { PDF_DOCUMENT_OPTIONS } from './pdfWorker'; import { PDFZoomControls } from './PDFZoomControls'; import { usePdfDocumentLifecycle } from './usePdfDocumentLifecycle'; @@ -110,7 +111,8 @@ export const PDFPreview = ({ const isPreviewSearchOpen = usePreviewSearchStore((s) => embedded ? false : s.isOpen, ); - const [loadError, setLoadError] = useState(false); + const [failedSource, setFailedSource] = useState(null); + const loadError = failedSource === src; const [loadAttempt, setLoadAttempt] = useState(0); const { document: pdfDocument, @@ -140,7 +142,7 @@ export const PDFPreview = ({ }); // Reset loading state when the PDF source changes. useEffect(() => { - setLoadError(false); + setFailedSource(null); setForcedPageIndex(null); setVisiblePageIndexes(new Set([0])); setRetainedPageIndexes(new Set([0])); @@ -394,11 +396,37 @@ export const PDFPreview = ({ [searchQuery, textIndex.pages], ); + const getSearchMatchRange = useCallback( + (matchIndex: number): Range | null => { + const match = searchMatches[matchIndex]; + if (!match) return null; + const page = textIndex.pages.get(match.pageIndex); + const shell = scrollContainerRef.current?.querySelector( + `[data-pdf-page-shell="${match.pageIndex}"]`, + ); + return page && shell + ? findProjectedRange( + shell.querySelector('.textLayer') ?? shell, + page.text, + match.start, + match.end, + ) + : null; + }, + [searchMatches, textIndex.pages], + ); + const navigateToSearchMatch = useCallback( - (matchIndex: number) => { - const match = searchMatches[matchIndex] ?? searchMatches[0]; + ( + matchIndex: number, + onResult: (visible: boolean, range?: Range) => void, + ) => { + const match = searchMatches[matchIndex]; const root = scrollContainerRef.current; - if (!match || !root) return; + if (!match || !root) { + onResult(false); + return; + } searchNavigationCancelRef.current?.(); setForcedPageIndex(match.pageIndex); @@ -407,12 +435,22 @@ export const PDFPreview = ({ ); pageShell?.scrollIntoView({ block: 'center', behavior: 'auto' }); searchNavigationCancelRef.current = scheduleScrollToMatch( - () => pageShell, + () => + root.querySelector( + `[data-pdf-page-shell="${match.pageIndex}"]`, + ), searchQuery.trim(), match.pageOccurrenceIndex, + { + fallbackToFirst: false, + getMatchRange: () => getSearchMatchRange(matchIndex), + onMatch: (range) => onResult(true, range), + onTimeout: () => onResult(false), + }, ); + return searchNavigationCancelRef.current; }, - [searchMatches, searchQuery], + [searchMatches, searchQuery, getSearchMatchRange], ); useEffect(() => { @@ -427,17 +465,48 @@ export const PDFPreview = ({ () => isPdfSearchActive ? { + query: searchQuery, matchCount: searchMatches.length, - isSearching: textIndex.isIndexing, - canNavigate: !textIndex.isIndexing, + isSearching: + !loadError && !!src && (!docLoaded || textIndex.isIndexing), + canNavigate: !loadError && docLoaded && !textIndex.isIndexing, + failed: loadError || !src, navigateToMatch: navigateToSearchMatch, + getMatchRange: getSearchMatchRange, + resolveSourceMatch: async ( + source: Parameters< + NonNullable + >[0], + ) => { + const markdown = + typeof data.content === 'string' ? data.content : ''; + if ( + !markdown || + (await hashSearchSource(markdown)) !== source.hash + ) + return null; + return findPdfSourceMatch( + textIndex.pages.values(), + searchQuery.trim(), + markdown, + source.offset, + source.length, + ); + }, } : null, [ isPdfSearchActive, + searchQuery, navigateToSearchMatch, + getSearchMatchRange, searchMatches.length, textIndex.isIndexing, + docLoaded, + loadError, + src, + data.content, + textIndex.pages, ], ); @@ -677,8 +746,8 @@ export const PDFPreview = ({ file={resolvedSrc} options={PDF_DOCUMENT_OPTIONS} onLoadSuccess={onDocumentLoadSuccess} - onLoadError={() => setLoadError(true)} - onSourceError={() => setLoadError(true)} + onLoadError={() => setFailedSource(src)} + onSourceError={() => setFailedSource(src)} loading="" error={
@@ -687,7 +756,7 @@ export const PDFPreview = ({ variant="ghost" size="sm" onClick={() => { - setLoadError(false); + setFailedSource(null); setLoadAttempt((attempt) => attempt + 1); }} > diff --git a/apps/web/src/components/Nodes/pdf/PDFZoomControls.tsx b/apps/web/src/components/Nodes/pdf/PDFZoomControls.tsx index 1518df991..4d51bcc8b 100644 --- a/apps/web/src/components/Nodes/pdf/PDFZoomControls.tsx +++ b/apps/web/src/components/Nodes/pdf/PDFZoomControls.tsx @@ -63,6 +63,7 @@ export function PDFZoomControls({ { + it('does not double explicit whitespace items between text-layer words', () => { + const text = textFromPdfItems([ + { str: 'Paragraph' }, + { str: ' ' }, + { str: 'needle' }, + { str: ' ' }, + { str: 'phrase' }, + ]); + expect(text).toBe('Paragraph needle phrase'); + expect( + findPdfTextMatches([{ pageIndex: 0, text }], 'needle phrase'), + ).toEqual([{ pageIndex: 0, pageOccurrenceIndex: 0, start: 10, end: 23 }]); + }); + it('maps a visible source address independently of hidden Markdown occurrences', () => { + const source = '[needle](https://example.test/needle)\n\nParagraph needle'; + const pages = [{ pageIndex: 0, text: 'needle\nParagraph needle' }]; + expect( + findPdfSourceMatch(pages, 'needle', source, source.indexOf('needle', 10)), + ).toBeNull(); + expect( + findPdfSourceMatch(pages, 'needle', source, source.lastIndexOf('needle')), + ).toBe(1); + expect( + findPdfSourceMatch( + [{ pageIndex: 0, text: 'Unrelated needle' }], + 'needle', + source, + source.lastIndexOf('needle'), + ), + ).toBeNull(); + }); it('preserves word and line boundaries from PDF text items', () => { expect( textFromPdfItems([ diff --git a/apps/web/src/components/Nodes/pdf/pdfTextIndex.ts b/apps/web/src/components/Nodes/pdf/pdfTextIndex.ts index 6afd72c97..5b4ba7b10 100644 --- a/apps/web/src/components/Nodes/pdf/pdfTextIndex.ts +++ b/apps/web/src/components/Nodes/pdf/pdfTextIndex.ts @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { literalTextMatches } from '@huabu/shared'; +import { markdownSearchTarget } from '@huabu/shared/canvas-engine'; + export type PdfTextItem = { str?: string; hasEOL?: boolean; @@ -23,7 +26,7 @@ export function textFromPdfItems(items: readonly PdfTextItem[]): string { for (const item of items) { const value = item.str ?? ''; if (!value) continue; - if (text && !text.endsWith(' ') && !text.endsWith('\n')) text += ' '; + if (text && !/\s$/.test(text) && !/^\s/.test(value)) text += ' '; text += value; if (item.hasEOL) text += '\n'; } @@ -40,21 +43,50 @@ export function findPdfTextMatches( const matches: PdfTextMatch[] = []; const sortedPages = [...pages].sort((a, b) => a.pageIndex - b.pageIndex); for (const page of sortedPages) { - const haystack = page.text.toLowerCase(); - let from = 0; let pageOccurrenceIndex = 0; - while (from < haystack.length) { - const start = haystack.indexOf(needle, from); - if (start === -1) break; + for (const { start, end } of literalTextMatches(page.text, needle)) { matches.push({ pageIndex: page.pageIndex, pageOccurrenceIndex, start, - end: start + needle.length, + end, }); pageOccurrenceIndex += 1; - from = start + Math.max(1, needle.length); } } return matches; } + +export function findPdfSourceMatch( + pages: Iterable, + query: string, + markdown: string, + sourceOffset: number, + sourceLength = query.length, +): number | null { + const target = markdownSearchTarget(markdown, sourceOffset, sourceLength); + if (!target) return null; + const compact = (text: string): string => text.replace(/\s/g, ''); + const sorted = [...pages].sort((a, b) => a.pageIndex - b.pageIndex); + if ( + compact(target.text) !== compact(sorted.map((page) => page.text).join('')) + ) + return null; + const offset = compact(target.text.slice(0, target.start)).length; + const pageOffsets = new Map(); + let length = 0; + for (const page of sorted) { + pageOffsets.set(page.pageIndex, length); + length += compact(page.text).length; + } + const index = findPdfTextMatches(sorted, query).findIndex((match) => { + const page = sorted.find((entry) => entry.pageIndex === match.pageIndex); + return ( + page && + (pageOffsets.get(page.pageIndex) ?? 0) + + compact(page.text.slice(0, match.start)).length === + offset + ); + }); + return index < 0 ? null : index; +} diff --git a/apps/web/src/components/Nodes/pdf/usePdfTextIndex.test.tsx b/apps/web/src/components/Nodes/pdf/usePdfTextIndex.test.tsx new file mode 100644 index 000000000..8d5f1b2e5 --- /dev/null +++ b/apps/web/src/components/Nodes/pdf/usePdfTextIndex.test.tsx @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { usePdfTextIndex, type PdfIndexDocument } from './usePdfTextIndex'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); +}); + +it('publishes readiness atomically with every extracted page and hides a replaced index', async () => { + const snapshots: Array<{ + searching: boolean; + pages: number; + processed: number; + }> = []; + function Harness({ pdf }: { pdf: PdfIndexDocument | null }) { + const index = usePdfTextIndex({ document: pdf, enabled: true }); + snapshots.push({ + searching: index.isIndexing, + pages: index.pages.size, + processed: index.indexedPageCount, + }); + return null; + } + const pdf: PdfIndexDocument = { + numPages: 4, + getPage: vi.fn(async () => ({ + getTextContent: async () => ({ items: [{ str: 'needle' }] }), + getViewport: () => ({ width: 612, height: 792 }), + })), + }; + await act(async () => root.render()); + expect(snapshots.at(-1)).toEqual({ + searching: false, + pages: 4, + processed: 4, + }); + expect( + snapshots + .filter((snapshot) => !snapshot.searching) + .every((snapshot) => snapshot.pages === 4 && snapshot.processed === 4), + ).toBe(true); + snapshots.length = 0; + act(() => root.render()); + expect(snapshots.every((snapshot) => snapshot.pages === 0)).toBe(true); +}); diff --git a/apps/web/src/components/Nodes/pdf/usePdfTextIndex.ts b/apps/web/src/components/Nodes/pdf/usePdfTextIndex.ts index 3482edcd0..478d37bd2 100644 --- a/apps/web/src/components/Nodes/pdf/usePdfTextIndex.ts +++ b/apps/web/src/components/Nodes/pdf/usePdfTextIndex.ts @@ -35,16 +35,16 @@ export function usePdfTextIndex({ enabled, onPageAspectRatio, }: UsePdfTextIndexOptions): PdfTextIndexSnapshot { - const [pages, setPages] = useState>( - () => new Map(), - ); - const [processedPageCount, setProcessedPageCount] = useState(0); + const [index, setIndex] = useState(() => ({ + document, + pages: new Map(), + processedPageCount: 0, + })); const processedPageIndexesRef = useRef(new Set()); useEffect(() => { processedPageIndexesRef.current = new Set(); - setPages(new Map()); - setProcessedPageCount(0); + setIndex({ document, pages: new Map(), processedPageCount: 0 }); }, [document]); useEffect(() => { @@ -60,6 +60,7 @@ export function usePdfTextIndex({ const pageIndex = pageNumber - 1; if (processedPageIndexesRef.current.has(pageIndex)) continue; + let pageText: PdfPageText | null = null; try { const page = await document.getPage(pageNumber); const [textContent, viewport] = await Promise.all([ @@ -71,20 +72,21 @@ export function usePdfTextIndex({ textContent.items as Array<{ str?: string; hasEOL?: boolean }>, ); onPageAspectRatio?.(pageIndex, viewport.width / viewport.height); - startTransition(() => { - setPages((current) => { - const next = new Map(current); - next.set(pageIndex, { pageIndex, text }); - return next; - }); - }); + pageText = { pageIndex, text }; } catch { // One malformed page must not prevent the remaining document from // becoming searchable. } finally { if (!cancelled) { processedPageIndexesRef.current.add(pageIndex); - setProcessedPageCount(processedPageIndexesRef.current.size); + const processedPageCount = processedPageIndexesRef.current.size; + startTransition(() => + setIndex((current) => { + const pages = new Map(current.pages); + if (pageText) pages.set(pageIndex, pageText); + return { document, pages, processedPageCount }; + }), + ); } } } @@ -100,9 +102,13 @@ export function usePdfTextIndex({ }; }, [document, enabled, onPageAspectRatio]); + const isCurrent = index.document === document; return { - pages, - isIndexing: !!document && enabled && processedPageCount < document.numPages, - indexedPageCount: processedPageCount, + pages: isCurrent ? index.pages : new Map(), + isIndexing: + !!document && + enabled && + (!isCurrent || index.processedPageCount < document.numPages), + indexedPageCount: isCurrent ? index.processedPageCount : 0, }; } diff --git a/apps/web/src/components/Panels/Canvas/CanvasToolbar.test.tsx b/apps/web/src/components/Panels/Canvas/CanvasToolbar.test.tsx index 02f8aefd2..f25239096 100644 --- a/apps/web/src/components/Panels/Canvas/CanvasToolbar.test.tsx +++ b/apps/web/src/components/Panels/Canvas/CanvasToolbar.test.tsx @@ -100,6 +100,35 @@ afterEach(() => { }); describe('NodeToolbar', () => { + it('updates pressed states when placement changes and navigation resumes', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + for (const pendingNodeType of ['note', 'text', null]) { + toolState.pendingNodeType = pendingNodeType; + act(() => + root?.render( + , + ), + ); + expect( + container + .querySelector('button[aria-label^="toolbar.tools.select"]') + ?.getAttribute('aria-pressed'), + ).toBe(String(pendingNodeType === null)); + for (const nodeType of ['note', 'text']) { + expect( + container + .querySelector(`button[aria-label^="toolbar.nodes.${nodeType}"]`) + ?.getAttribute('aria-pressed'), + ).toBe(String(pendingNodeType === nodeType)); + } + expect( + container.querySelectorAll('button[aria-pressed="true"]'), + ).toHaveLength(1); + } + }); + it('uses roomier primary buttons without enlarging icons or compact dropdowns', () => { container = document.createElement('div'); document.body.appendChild(container); @@ -178,6 +207,10 @@ describe('NodeToolbar', () => { expect(selected?.classList.contains('enabled:hover:bg-info-bg')).toBe( true, ); + expect(selected?.getAttribute('aria-pressed')).toBe('true'); + expect( + container.querySelectorAll('button[aria-pressed="true"]'), + ).toHaveLength(1); expect(container.querySelectorAll('button.bg-info-bg')).toHaveLength(1); }, ); @@ -261,9 +294,25 @@ describe('NodeToolbar', () => { true, ); expect(container.querySelectorAll('button.bg-info-bg')).toHaveLength(1); - if (nodeType === 'question') { - expect(selected?.getAttribute('aria-pressed')).toBe('true'); + expect(selected?.getAttribute('aria-pressed')).toBe('true'); + expect( + container.querySelectorAll('button[aria-pressed="true"]'), + ).toHaveLength(1); + for (const otherType of ['note', 'text', 'frame', 'sketch', 'addAgent']) { + const button = container.querySelector( + `button[aria-label^="toolbar.nodes.${otherType}"]`, + ); + expect(button?.getAttribute('aria-pressed')).toBe( + otherType === (nodeType === 'question' ? 'addAgent' : nodeType) + ? 'true' + : 'false', + ); } + expect( + container + .querySelector('button[aria-label^="toolbar.tools.select"]') + ?.getAttribute('aria-pressed'), + ).toBe('false'); }, ); diff --git a/apps/web/src/components/Panels/Canvas/CanvasToolbar.tsx b/apps/web/src/components/Panels/Canvas/CanvasToolbar.tsx index 027d7c9c9..8ef066112 100644 --- a/apps/web/src/components/Panels/Canvas/CanvasToolbar.tsx +++ b/apps/web/src/components/Panels/Canvas/CanvasToolbar.tsx @@ -447,6 +447,7 @@ export const NodeToolbar = ({ activeTool, onToolChange }: NodeToolbarProps) => { activeTool === 'lasso' ? 'L' : activeTool === 'pan' ? 'P' : 'S' } primaryShortcutBadgeActive={!pendingNodeType} + primaryPressed={!pendingNodeType} primaryButtonClassName={clsx( MAIN_TOOL_BUTTON_CLASS, !pendingNodeType && @@ -467,6 +468,7 @@ export const NodeToolbar = ({ activeTool, onToolChange }: NodeToolbarProps) => { title={toolTitle(t('toolbar.nodes.note'), '1')} shortcutBadge={isNotMouse ? undefined : '1'} shortcutBadgeActive={pendingNodeType === 'note'} + aria-pressed={pendingNodeType === 'note'} className={clsx( MAIN_TOOL_BUTTON_CLASS, pendingNodeType === 'note' && @@ -485,6 +487,7 @@ export const NodeToolbar = ({ activeTool, onToolChange }: NodeToolbarProps) => { title={toolTitle(t('toolbar.nodes.text'), '2')} shortcutBadge={isNotMouse ? undefined : '2'} shortcutBadgeActive={pendingNodeType === 'text'} + aria-pressed={pendingNodeType === 'text'} className={clsx( MAIN_TOOL_BUTTON_CLASS, pendingNodeType === 'text' && @@ -503,6 +506,7 @@ export const NodeToolbar = ({ activeTool, onToolChange }: NodeToolbarProps) => { title={toolTitle(t('toolbar.nodes.frame'), '3')} shortcutBadge={isNotMouse ? undefined : '3'} shortcutBadgeActive={pendingNodeType === 'frame'} + aria-pressed={pendingNodeType === 'frame'} className={clsx( MAIN_TOOL_BUTTON_CLASS, pendingNodeType === 'frame' && @@ -538,6 +542,7 @@ export const NodeToolbar = ({ activeTool, onToolChange }: NodeToolbarProps) => { title={`${t('toolbar.nodes.sketch')} (4)`} shortcutBadge="4" shortcutBadgeActive={pendingNodeType === 'sketch'} + aria-pressed={pendingNodeType === 'sketch'} className={clsx( MAIN_TOOL_BUTTON_CLASS, pendingNodeType === 'sketch' && diff --git a/apps/web/src/components/Panels/Canvas/CanvasZoomMenu.test.tsx b/apps/web/src/components/Panels/Canvas/CanvasZoomMenu.test.tsx new file mode 100644 index 000000000..54e26be35 --- /dev/null +++ b/apps/web/src/components/Panels/Canvas/CanvasZoomMenu.test.tsx @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { CanvasZoomMenu } from './CanvasZoomMenu'; + +const zoomState = vi.hoisted(() => ({ + transform: [0, 0, 1], + nodes: [{ hidden: false, selected: true }], +})); + +vi.mock('@xyflow/react', () => ({ + Panel: ({ children }: { children: ReactNode }) =>
{children}
, + useReactFlow: () => ({ getZoom: () => 1 }), + useStore: (select: (state: typeof zoomState) => unknown) => select(zoomState), +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('../CanvasLayerPanel/focusNodesOnCanvas', () => ({ + fitCanvasContent: vi.fn(), +})); + +let root: Root; +let host: HTMLDivElement; + +afterEach(() => { + act(() => root.unmount()); + host.remove(); +}); + +it('retains native Tab stops for dialog actions and dismisses at either boundary', async () => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(async () => root.render()); + const trigger = host.querySelector('button'); + await act(async () => trigger?.click()); + const dialog = document.querySelector('[role="dialog"]'); + const input = dialog?.querySelector('input'); + const actions = Array.from( + dialog?.querySelectorAll('[role="menuitem"]') ?? [], + ); + expect(document.activeElement).toBe(input); + expect(actions).toHaveLength(5); + expect( + actions.every((action) => !action.disabled && action.tabIndex === 0), + ).toBe(true); + const middleTab = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + act(() => input?.dispatchEvent(middleTab)); + expect(middleTab.defaultPrevented).toBe(false); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + act(() => actions.at(-1)?.focus()); + const lastTab = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + await act(async () => actions.at(-1)?.dispatchEvent(lastTab)); + expect(lastTab.defaultPrevented).toBe(true); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + await act(async () => trigger?.click()); + const firstInput = document.querySelector('[role="dialog"] input'); + const firstTab = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }); + await act(async () => firstInput?.dispatchEvent(firstTab)); + expect(firstTab.defaultPrevented).toBe(true); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); +}); diff --git a/apps/web/src/components/Panels/Canvas/CanvasZoomMenu.tsx b/apps/web/src/components/Panels/Canvas/CanvasZoomMenu.tsx index 229e45f16..0186b62cf 100644 --- a/apps/web/src/components/Panels/Canvas/CanvasZoomMenu.tsx +++ b/apps/web/src/components/Panels/Canvas/CanvasZoomMenu.tsx @@ -72,6 +72,7 @@ export function CanvasZoomMenu() { >
= MAX_ZOOM} onClick={() => run(() => void instance.zoomIn())} @@ -192,6 +194,7 @@ export function CanvasZoomMenu() { {t('canvasControls.zoomIn')} run(() => void instance.zoomOut())} @@ -199,6 +202,7 @@ export function CanvasZoomMenu() { {t('canvasControls.zoomOut')} run(() => void instance.zoomTo(1))} > @@ -206,6 +210,7 @@ export function CanvasZoomMenu() {
@@ -215,6 +220,7 @@ export function CanvasZoomMenu() { {t('canvasControls.fitAll')} diff --git a/apps/web/src/components/Panels/Canvas/FloatingToolbars/InkAgentDestinationPicker.tsx b/apps/web/src/components/Panels/Canvas/FloatingToolbars/InkAgentDestinationPicker.tsx index 0abc54a51..1112b91c0 100644 --- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/InkAgentDestinationPicker.tsx +++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/InkAgentDestinationPicker.tsx @@ -164,6 +164,7 @@ export function InkAgentDestinationPicker({ {/* Coordinate positioning clamps vertically even when neither side fits. */} { diff --git a/apps/web/src/components/Panels/Canvas/FloatingToolbars/NodeFloatingToolbar.test.tsx b/apps/web/src/components/Panels/Canvas/FloatingToolbars/NodeFloatingToolbar.test.tsx index 7d37bd868..e83dd15ec 100644 --- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/NodeFloatingToolbar.test.tsx +++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/NodeFloatingToolbar.test.tsx @@ -172,6 +172,36 @@ describe('NodeFloatingToolbar type-icon drag surface', () => { act(() => trigger.click()); } + it('lets the shared menu move focus through production overflow actions', async () => { + render(); + await act(async () => openPanel('toolbar.more')); + const items = Array.from( + document.querySelectorAll('[role="menuitem"]'), + ); + expect(items).toHaveLength(3); + expect(document.activeElement).toBe(items[0]); + act(() => + items[0].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ), + ); + expect(document.activeElement).toBe(items[1]); + act(() => + items[1].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'End', + bubbles: true, + cancelable: true, + }), + ), + ); + expect(document.activeElement).toBe(items[2]); + }); + it('anchors Move to the persistent overflow trigger rather than its disappearing menu item', () => { render(); const trigger = container.querySelector( diff --git a/apps/web/src/components/Panels/Canvas/FloatingToolbars/NodeFloatingToolbar.tsx b/apps/web/src/components/Panels/Canvas/FloatingToolbars/NodeFloatingToolbar.tsx index 866b6b3bd..85bb0e86e 100644 --- a/apps/web/src/components/Panels/Canvas/FloatingToolbars/NodeFloatingToolbar.tsx +++ b/apps/web/src/components/Panels/Canvas/FloatingToolbars/NodeFloatingToolbar.tsx @@ -454,6 +454,7 @@ export const NodeFloatingToolbar = memo(
{ event.stopPropagation(); const item = (event.target as Element).closest( diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.keyboard.test.tsx b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.keyboard.test.tsx new file mode 100644 index 000000000..958f910af --- /dev/null +++ b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.keyboard.test.tsx @@ -0,0 +1,631 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createInstance } from 'i18next'; +import { + act, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type Ref, +} from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nextProvider } from 'react-i18next'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import enCommon from '@/i18n/resources/en/common.json'; +import zhCNCommon from '@/i18n/resources/zh-CN/common.json'; +import { usePreviewSearchStore } from '@/store/previewSearchStore'; +import { useSearchStore, type SearchResultRow } from '@/store/searchStore'; + +import { CanvasSearchResults } from './CanvasSearchResults'; + +import type * as SearchDom from '@/hooks/searchDom'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { + selectNodes, + openPreviewNode, + canvasState, + scheduleScrollToMatch, + toast, + virtualization, +} = vi.hoisted(() => { + const selectNodes = vi.fn(); + const nodes: { id: string; data: { threadId?: string; content?: string } }[] = + []; + return { + selectNodes, + openPreviewNode: vi.fn(), + canvasState: { + selectNodes, + nodes, + rfInstance: null, + canvasWrapper: null, + }, + scheduleScrollToMatch: vi.fn(), + toast: vi.fn(), + virtualization: { + enabled: false, + beforeDone: undefined as (() => void) | undefined, + }, + }; +}); + +vi.mock('@/store/canvasStore', () => ({ + default: Object.assign( + (select: (state: typeof canvasState) => unknown) => select(canvasState), + { getState: () => canvasState }, + ), +})); +vi.mock('@/store/previewWorkspace/store', () => ({ + selectActiveNodeId: () => null, + usePreviewWorkspaceStore: () => null, +})); +vi.mock('@/store/previewWorkspace/actions', () => ({ openPreviewNode })); +vi.mock('@/hooks/useTextHighlight', () => ({ useTextHighlight: vi.fn() })); +vi.mock('@/hooks/searchDom', () => ({ scheduleScrollToMatch })); +vi.mock('../../Common/Toast', () => ({ toast })); +vi.mock('./LayerNodeIcon', () => ({ CanvasSearchNodeIcon: () => null })); +vi.mock('./focusNodesOnCanvas', () => ({ revealNodesOnCanvas: vi.fn() })); +vi.mock('../../Nodes/previews', () => ({ hasNodePreview: () => true })); +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ + ref, + data, + itemContent, + computeItemKey, + }: { + ref: Ref<{ + scrollIntoView: (location: { index: number; done?: () => void }) => void; + }>; + data: unknown[]; + itemContent: (index: number, item: unknown) => ReactNode; + computeItemKey: (index: number, item: unknown) => string; + }) => { + const [target, setTarget] = useState(0); + const pendingDone = useRef<(() => void) | undefined>(undefined); + useImperativeHandle(ref, () => ({ + scrollIntoView: ({ index, done }) => { + if (!virtualization.enabled) { + done?.(); + return; + } + pendingDone.current = done; + setTarget(index); + }, + })); + useLayoutEffect(() => { + virtualization.beforeDone?.(); + pendingDone.current?.(); + pendingDone.current = undefined; + }, [target]); + return ( +
+ {data.map( + (item, index) => + (!virtualization.enabled || index === target || index === 0) && ( +
+ {itemContent(index, item)} +
+ ), + )} +
+ ); + }, +})); + +const results: SearchResultRow[] = ['first', 'second'].map((nodeId) => ({ + key: `${nodeId}:content:0`, + tier: 'content', + match: { + nodeId, + nodeType: 'note', + label: nodeId, + field: 'content', + snippet: 'test content', + matchStart: 0, + matchLength: 4, + occurrenceIndex: 0, + }, +})); + +let root: Root | undefined; +let container: HTMLDivElement | undefined; +let searchInput: HTMLInputElement | undefined; +const testI18n = createInstance(); + +function getRow(index: number): HTMLButtonElement { + const row = container?.querySelector( + `[data-search-row-index="${index}"]`, + ); + if (!row) throw new Error(`Missing search row ${index}`); + return row; +} + +function press(element: HTMLElement, key: string) { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + act(() => element.dispatchEvent(event)); + return event; +} + +beforeEach(async () => { + virtualization.enabled = false; + virtualization.beforeDone = undefined; + await testI18n.init({ + lng: 'en', + fallbackLng: 'en', + defaultNS: 'common', + resources: { + en: { common: enCommon }, + 'zh-CN': { common: zhCNCommon }, + }, + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + }); + canvasState.nodes.splice(0); + useSearchStore.setState({ + query: 'test', + results, + scope: { kind: 'canvas', canvasId: 'canvas' }, + collapsedNodeIds: new Set(), + isStreaming: false, + error: null, + truncated: false, + contentPhase: false, + }); + container = document.createElement('div'); + searchInput = document.createElement('input'); + searchInput.dataset.canvasSearchInput = ''; + document.body.append(searchInput, container); + root = createRoot(container); + act(() => + root?.render( + + + , + ), + ); + vi.clearAllMocks(); +}); + +describe('virtualized result focus ownership', () => { + it.each(['append', 'regroup'] as const)( + 'retains the focused result by stable key during streaming (%s)', + (change) => { + act(() => getRow(3).focus()); + const focused = document.activeElement; + const row: SearchResultRow = { + ...results[0], + key: 'first:summary:0', + tier: 'meta', + match: { ...results[0].match, field: 'summary' }, + }; + act(() => + useSearchStore.setState({ + isStreaming: true, + results: change === 'append' ? [...results, row] : [row, ...results], + }), + ); + const index = 4; + expect(document.activeElement).toBe(getRow(index)); + expect(document.activeElement?.textContent).toContain('test content'); + expect(document.activeElement).not.toBe(getRow(0)); + expect(document.activeElement).toBe(focused); + press(getRow(index), 'Enter'); + expect(openPreviewNode).toHaveBeenLastCalledWith( + 'second', + expect.objectContaining({ focusEditor: false }), + ); + }, + ); + + it.each([false, true])( + 'restores an unmounted result without overriding outside focus (%s)', + (moveOutside) => { + virtualization.enabled = true; + const rows = Array.from({ length: 80 }, (_, i) => ({ + ...results[0], + key: `first:content:${i}`, + match: { ...results[0].match, occurrenceIndex: i }, + })); + act(() => useSearchStore.setState({ results: rows })); + act(() => getRow(0).focus()); + for (let i = 0; i < 80; i++) { + const focused = document.activeElement; + if (!(focused instanceof HTMLElement)) + throw new Error('Missing focused row'); + press(focused, 'ArrowDown'); + } + const last = getRow(80); + expect(document.activeElement).toBe(last); + if (moveOutside) virtualization.beforeDone = () => searchInput?.focus(); + press(last, 'ArrowLeft'); + expect(last.isConnected).toBe(false); + expect(document.activeElement).toBe( + moveOutside ? searchInput : getRow(0), + ); + }, + ); +}); + +describe('Canvas search localization', () => { + it.each([ + { + language: 'en', + empty: 'No matches', + streaming: 'Searching note contents…', + truncated: + 'The result set only contains a subset of all matches. Be more specific in your search to narrow down the results.', + }, + { + language: 'zh-CN', + empty: '没有匹配结果', + streaming: '正在搜索笔记正文…', + truncated: '当前仅显示部分匹配结果,请使用更具体的搜索内容缩小范围。', + }, + ])( + 'localizes empty, streaming, and truncated states in $language', + async ({ language, empty, streaming, truncated }) => { + await act(async () => { + await testI18n.changeLanguage(language); + }); + act(() => useSearchStore.setState({ results: [] })); + expect(container?.textContent).toBe(empty); + act(() => + useSearchStore.setState({ isStreaming: true, contentPhase: true }), + ); + expect(container?.textContent).toBe(streaming); + act(() => useSearchStore.setState({ truncated: true })); + expect(container?.textContent).toContain(truncated); + }, + ); + + it('localizes every result field and switches language without resetting results', async () => { + const fields = [ + 'label', + 'summary', + 'keywords', + 'content', + 'conversation', + ] as const; + const fieldResults: SearchResultRow[] = fields.map((field) => ({ + ...results[0], + key: `first:${field}:0`, + match: { ...results[0].match, field }, + })); + act(() => useSearchStore.setState({ results: fieldResults })); + const getLabels = () => + Array.from( + container?.querySelectorAll('[data-search-field] .uppercase') ?? [], + (element) => element.textContent, + ); + expect(getLabels()).toEqual([ + 'Title', + 'Summary', + 'Keywords', + 'Content', + 'Conversation', + ]); + await act(async () => { + await testI18n.changeLanguage('zh-CN'); + }); + expect(getLabels()).toEqual(['标题', '摘要', '关键词', '正文', '对话']); + expect(useSearchStore.getState().results).toBe(fieldResults); + expect(useSearchStore.getState().query).toBe('test'); + }); + + it.each([ + { + language: 'en', + message: + 'Cannot precisely locate this match in the conversation. It may only exist in source text, or the content may have changed.', + }, + { + language: 'zh-CN', + message: + '无法在当前对话中精确定位此匹配;它可能仅存在于源码中,或内容已更新。', + }, + ])( + 'localizes conversation match timeout feedback in $language', + async ({ language, message }) => { + await act(async () => { + await testI18n.changeLanguage(language); + }); + canvasState.nodes.push({ id: 'first', data: { threadId: 'thread' } }); + act(() => + useSearchStore.setState({ + results: [ + { + ...results[0], + match: { ...results[0].match, field: 'conversation' }, + }, + ], + }), + ); + act(() => getRow(1).click()); + const options = scheduleScrollToMatch.mock.calls[0]?.[3]; + expect(options?.onTimeout).toBeTypeOf('function'); + options?.onTimeout?.(); + expect(toast).toHaveBeenCalledExactlyOnceWith(message, { + tone: 'info', + duration: 4000, + }); + }, + ); +}); + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + searchInput?.remove(); + root = undefined; + useSearchStore.getState().close(); + usePreviewSearchStore.getState().close(); +}); + +describe('Canvas search field groups and preview navigation', () => { + it('does not activate a previous-query row during the next query debounce', () => { + vi.useFakeTimers(); + try { + const previousRow = getRow(1); + act(() => useSearchStore.getState().setQuery('replacement')); + expect( + container?.querySelector('[data-search-row-index="1"]'), + ).toBeNull(); + act(() => previousRow.click()); + expect(openPreviewNode).not.toHaveBeenCalled(); + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(); + } finally { + act(() => useSearchStore.getState().close()); + vi.useRealTimers(); + } + }); + + it.each([' test', 'test ', ' test '])( + 'uses the server-normalized query for content and conversation handoffs (%j)', + (query) => { + act(() => useSearchStore.setState({ query })); + act(() => getRow(1).click()); + const previewQuery = usePreviewSearchStore.getState().query; + canvasState.nodes.push({ id: 'first', data: { threadId: 'thread' } }); + act(() => + useSearchStore.setState({ + results: [ + { + ...results[0], + match: { + ...results[0].match, + field: 'conversation', + nodeType: 'question', + }, + }, + ], + }), + ); + act(() => getRow(1).click()); + expect({ + previewQuery, + conversationQuery: scheduleScrollToMatch.mock.lastCall?.[1], + }).toEqual({ previewQuery: 'test', conversationQuery: 'test' }); + }, + ); + + it('opens Question prompt-content matches in their conversation rather than a document preview', () => { + canvasState.nodes.push({ id: 'first', data: { threadId: 'thread' } }); + act(() => + useSearchStore.setState({ + results: [ + { + ...results[0], + match: { + ...results[0].match, + nodeType: 'question', + occurrenceIndex: 1, + }, + }, + ], + }), + ); + act(() => getRow(1).click()); + expect(openPreviewNode).toHaveBeenCalledExactlyOnceWith('first'); + expect(scheduleScrollToMatch).toHaveBeenCalledWith( + expect.any(Function), + 'test', + 1, + expect.any(Object), + ); + expect(usePreviewSearchStore.getState().isOpen).toBe(false); + }); + + it('scopes Question body ordinals to a unique originating prompt, not the full thread or quoted content', () => { + canvasState.nodes.push({ + id: 'first', + data: { threadId: 'thread', content: ' recent test test ' }, + }); + act(() => + useSearchStore.setState({ + results: [ + { + ...results[0], + match: { + ...results[0].match, + nodeType: 'question', + occurrenceIndex: 1, + }, + }, + ], + }), + ); + act(() => getRow(1).click()); + const getMatchRoot = scheduleScrollToMatch.mock.lastCall?.[3]?.getMatchRoot; + expect(getMatchRoot).toBeTypeOf('function'); + const thread = document.createElement('div'); + thread.innerHTML = + '
earlier test
quoted testrecent test test
'; + const prompt = thread.querySelectorAll( + '[data-chat-prompt-content]', + )[1]; + expect(getMatchRoot?.(thread)).toBe(prompt); + thread.insertAdjacentHTML( + 'beforeend', + 'recent test test', + ); + expect(getMatchRoot?.(thread)).toBeNull(); + thread.innerHTML = 'earlier test'; + expect(getMatchRoot?.(thread)).toBeNull(); + }); + + it('groups interleaved matches by field and labels each field only once', () => { + const rows: SearchResultRow[] = [ + results[0], + { + ...results[0], + key: 'first:summary:0', + match: { ...results[0].match, field: 'summary' }, + }, + { + ...results[0], + key: 'first:content:1', + match: { ...results[0].match, occurrenceIndex: 1 }, + }, + ]; + act(() => useSearchStore.setState({ results: rows })); + expect( + Array.from( + container?.querySelectorAll('[data-search-field]') ?? [], + (element) => element.getAttribute('data-search-field'), + ), + ).toEqual(['content', 'content', 'summary']); + expect( + Array.from( + container?.querySelectorAll('.uppercase') ?? [], + (element) => element.textContent, + ), + ).toEqual(['Content', 'Summary']); + act(() => getRow(2).focus()); + press(getRow(2), 'Enter'); + expect(usePreviewSearchStore.getState()).toMatchObject({ + nodeId: 'first', + query: 'test', + isOpen: true, + focusInput: false, + navigationRequest: { occurrenceIndex: 1 }, + }); + }); + + it('hands the clicked content match to preview search without stealing result focus', () => { + act(() => getRow(3).focus()); + act(() => getRow(3).click()); + expect(openPreviewNode).toHaveBeenCalledExactlyOnceWith('second', { + transient: true, + focusEditor: false, + }); + expect(usePreviewSearchStore.getState()).toMatchObject({ + nodeId: 'second', + query: 'test', + isOpen: true, + focusInput: false, + navigationRequest: { occurrenceIndex: 0 }, + }); + expect(document.activeElement).toBe(getRow(3)); + }); + + it('cancels a pending conversation jump when activating another result or closing search', () => { + const cancel = vi.fn(); + scheduleScrollToMatch.mockReturnValue(cancel); + canvasState.nodes.push({ id: 'first', data: { threadId: 'thread' } }); + act(() => + useSearchStore.setState({ + results: [ + { + ...results[0], + match: { ...results[0].match, field: 'conversation' }, + }, + results[1], + ], + }), + ); + act(() => getRow(1).click()); + act(() => getRow(3).click()); + expect(cancel).toHaveBeenCalledTimes(1); + act(() => getRow(1).click()); + act(() => useSearchStore.getState().close()); + expect(cancel).toHaveBeenCalledTimes(2); + scheduleScrollToMatch.mockReset(); + }); +}); + +describe('Canvas search keyboard focus', () => { + it('activates the second group when its header receives Tab focus', () => { + const second = getRow(2); + act(() => second.focus()); + press(second, 'Enter'); + expect(useSearchStore.getState().collapsedNodeIds.has('second')).toBe(true); + expect(useSearchStore.getState().collapsedNodeIds.has('first')).toBe(false); + }); + + it('moves actual result focus with arrow navigation before Enter activation', () => { + act(() => getRow(0).focus()); + press(getRow(0), 'ArrowDown'); + expect(document.activeElement).toBe(getRow(1)); + press(getRow(1), 'Enter'); + expect(openPreviewNode).toHaveBeenCalledExactlyOnceWith('first', { + transient: true, + focusEditor: false, + }); + expect(useSearchStore.getState().collapsedNodeIds.size).toBe(0); + press(getRow(1), 'ArrowDown'); + expect(document.activeElement).toBe(getRow(2)); + press(getRow(2), 'Enter'); + expect(useSearchStore.getState().collapsedNodeIds.has('second')).toBe(true); + }); + + it('keeps virtual result navigation in the search input without moving DOM focus', () => { + if (!searchInput) throw new Error('Missing search input'); + act(() => searchInput?.focus()); + press(searchInput, 'ArrowDown'); + expect(document.activeElement).toBe(searchInput); + press(searchInput, 'Enter'); + expect(openPreviewNode).toHaveBeenCalledExactlyOnceWith('first', { + transient: true, + focusEditor: false, + }); + }); + + it('continues arrow navigation from a clicked match', () => { + act(() => getRow(3).click()); + expect(openPreviewNode).toHaveBeenCalledWith('second', { + transient: true, + focusEditor: false, + }); + if (!searchInput) throw new Error('Missing search input'); + act(() => searchInput?.focus()); + press(searchInput, 'ArrowUp'); + press(searchInput, 'Enter'); + expect(useSearchStore.getState().collapsedNodeIds.has('second')).toBe(true); + expect(useSearchStore.getState().collapsedNodeIds.has('first')).toBe(false); + }); + + it('does not consume Enter from unrelated controls', () => { + const other = document.createElement('button'); + document.body.appendChild(other); + try { + act(() => other.focus()); + expect(press(other, 'Enter').defaultPrevented).toBe(false); + expect(useSearchStore.getState().collapsedNodeIds.size).toBe(0); + expect(selectNodes).not.toHaveBeenCalled(); + } finally { + other.remove(); + } + }); +}); diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx index 172d5e580..7890cf573 100644 --- a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx +++ b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx @@ -8,15 +8,13 @@ * toggle). * * Behaviour parity with the previous floating overlay: - * - Results stream in already grouped by node (per emission - * order). We bucket adjacent rows by `nodeId`, render a header - * row per group with the canonical node icon (Spline for edges), - * and indent match rows beneath their header. - * - ↑ / ↓ navigates the flat (header + visible match) list, live- - * follows on the canvas (minimal reveal + a preview open when the - * target has a real preview). + * - Results are grouped by node and then field in first-seen order, + * with one heading per field and the canonical node icon per node. + * - ↑ / ↓ navigates the flat (header + visible match) list and + * follows on the canvas without opening Preview Workspace tabs. * - Enter on a header toggles collapse; Enter on a match opens the - * owning node without injecting search state into its preview. + * owning node and hands content matches to preview-local search + * or reveals the matching Question conversation occurrence. * - ←/→ collapse / expand the current group. * - Highlight ranges are painted on the canvas DOM only; expanded * previews own an independent search layer. @@ -34,8 +32,11 @@ import { ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; +import { usePreviewSearchStore } from '@/store/previewSearchStore'; + import { shouldCanvasSearchOwnKeyboard } from './canvasSearchKeyboard'; import { revealNodesOnCanvas } from './focusNodesOnCanvas'; import { CanvasSearchNodeIcon } from './LayerNodeIcon'; @@ -62,10 +63,13 @@ import { cn } from '../../Common/cn'; import { toast } from '../../Common/Toast'; import { hasNodePreview } from '../../Nodes/previews'; -const ROW_HEIGHT = 40; +const ROW_HEIGHT = 28; export const CanvasSearchResults = (): React.JSX.Element => { + const { t } = useTranslation(); const query = useSearchStore((s) => s.query); + const normalizedQuery = query.trim(); + const nodeTypes = useSearchStore((s) => s.nodeTypes); const results = useSearchStore((s) => s.results); const isStreaming = useSearchStore((s) => s.isStreaming); const contentPhase = useSearchStore((s) => s.contentPhase); @@ -87,10 +91,17 @@ export const CanvasSearchResults = (): React.JSX.Element => { // re-query the chat DOM for the highlight + scroll roots. const virtuosoRef = useRef(null); - const [activeIdx, setActiveIdx] = useState(0); + const resultsRef = useRef(null); + const cancelNavigationRef = useRef<(() => void) | null>(null); + const [activeSelection, setActiveSelection] = useState<{ + key: string; + nodeId: string; + } | null>(null); const [canvasRoot, setCanvasRoot] = useState(null); const [chatThreadEl, setChatThreadEl] = useState(null); + useEffect(() => () => cancelNavigationRef.current?.(), [query, scope]); + // Resolve the canvas highlight root once on mount and whenever the // search scope flips back on (e.g. after `close()` + new query). // The element is owned by React Flow and is stable across renders, @@ -105,21 +116,6 @@ export const CanvasSearchResults = (): React.JSX.Element => { ); }, [scope]); - // Resolve the chat-thread scroll root whenever a question node's - // conversation is open. Only resolved while its preview tab - // is set so we never paint search highlights over the unrelated - // canvas chat; cleared otherwise. Re-runs on `activeIdx` so the - // element is re-read after the thread swaps to a different node. - useEffect(() => { - if (!expandedNodeId) { - setChatThreadEl(null); - return; - } - setChatThreadEl( - document.querySelector('[data-chat-thread-root]') ?? null, - ); - }, [expandedNodeId, activeIdx]); - // Paint the same `::highlight(huabu-search)` ranges over node labels // and Milkdown bodies visible on the canvas and, for conversation // matches, over the open question-node chat thread. Expanded previews @@ -129,14 +125,14 @@ export const CanvasSearchResults = (): React.JSX.Element => { // hydrated from history). useTextHighlight({ container: [canvasRoot, chatThreadEl], - query, + query: query.trim(), maxRanges: 800, }); // Reset selection when results identity changes (new query / scope). useEffect(() => { - setActiveIdx(0); - }, [results]); + setActiveSelection(null); + }, [normalizedQuery, scope, nodeTypes]); // ────────────────────────────────────────────────────────────────── // Per-node grouping (VS Code-style). @@ -193,24 +189,72 @@ export const CanvasSearchResults = (): React.JSX.Element => { for (const g of nodeGroups) { out.push({ kind: 'header', group: g }); if (!collapsedNodeIds.has(g.nodeId)) { - for (const r of g.rows) out.push({ kind: 'match', group: g, row: r }); + const fields = new Map< + SearchResultRow['match']['field'], + SearchResultRow[] + >(); + for (const row of g.rows) { + const matches = fields.get(row.match.field); + if (matches) matches.push(row); + else fields.set(row.match.field, [row]); + } + for (const matches of fields.values()) { + matches.forEach((row, index) => + out.push({ + kind: 'match', + group: g, + row, + firstInField: index === 0, + }), + ); + } } } return out; }, [nodeGroups, collapsedNodeIds]); - // Clamp activeIdx whenever `visibleRows` shrinks (user collapsed a - // group, streaming reset the list, etc.) so it can never dangle past - // the end and silently disable Enter / live-follow. + const selectedIndex = visibleRows.findIndex( + (row) => visibleRowKey(row) === activeSelection?.key, + ); + const activeIdx = Math.max( + 0, + selectedIndex >= 0 + ? selectedIndex + : visibleRows.findIndex( + (row) => + row.kind === 'header' && + row.group.nodeId === activeSelection?.nodeId, + ), + ); + const setActiveIdx = useCallback( + (index: number) => { + const row = visibleRows[index]; + setActiveSelection( + row ? { key: visibleRowKey(row), nodeId: row.group.nodeId } : null, + ); + }, + [visibleRows], + ); + + // Re-read the addressed thread after its row or preview tab changes. useEffect(() => { - if (visibleRows.length === 0) { - if (activeIdx !== 0) setActiveIdx(0); + if (!expandedNodeId) { + setChatThreadEl(null); return; } - if (activeIdx >= visibleRows.length) { - setActiveIdx(visibleRows.length - 1); - } - }, [visibleRows, activeIdx]); + const threadId = useCanvasStore + .getState() + .nodes.find((node) => node.id === expandedNodeId)?.data.threadId; + setChatThreadEl( + Array.from( + document.querySelectorAll('[data-chat-thread-root]'), + ).find( + (element) => + element.dataset.chatThreadId === threadId && + !element.closest('[data-preview-active="false"]'), + ) ?? null, + ); + }, [expandedNodeId, activeIdx]); const focusNodeOnCanvas = useCallback( (nodeId: string) => { @@ -266,6 +310,10 @@ export const CanvasSearchResults = (): React.JSX.Element => { const jumpToResult = useCallback( (row: SearchResultRow) => { + if (!useSearchStore.getState().results.includes(row)) return; + const navigationQuery = query.trim(); + cancelNavigationRef.current?.(); + cancelNavigationRef.current = null; const { nodeId, nodeType } = row.match; // Edge rows share the group focus path: fit on both endpoints, // never open the (non-existent) edge preview. @@ -290,13 +338,16 @@ export const CanvasSearchResults = (): React.JSX.Element => { // Open that thread in the right panel (instead of an expanded // preview); the dedicated effect below highlights + scrolls to // the matched message inside it. - if (row.match.field === 'conversation') { + if ( + row.match.field === 'conversation' || + (nodeType === 'question' && row.match.field === 'content') + ) { if (openConversationForNode(nodeId) && query) { const node = useCanvasStore .getState() .nodes.find((entry) => entry.id === nodeId); const threadId = node?.data.threadId; - scheduleScrollToMatch( + cancelNavigationRef.current = scheduleScrollToMatch( () => Array.from( document.querySelectorAll( @@ -307,14 +358,46 @@ export const CanvasSearchResults = (): React.JSX.Element => { element.dataset.chatThreadId === threadId && !element.closest('[data-preview-active="false"]'), ) ?? null, - query, + navigationQuery, row.match.occurrenceIndex, { + source: + row.match.field === 'conversation' + ? row.match.source + : undefined, + requireSource: row.match.field === 'conversation', + getMatchRoot: + row.match.field === 'content' + ? (root) => { + const content = useCanvasStore + .getState() + .nodes.find((entry) => entry.id === nodeId) + ?.data.content; + if (typeof content !== 'string' || !content.trim()) + return null; + const prompts = Array.from( + root.querySelectorAll( + '[data-chat-prompt-content]', + ), + ).filter( + (prompt) => + prompt.textContent?.trim() === content.trim(), + ); + return prompts.length === 1 ? (prompts[0] ?? null) : null; + } + : undefined, + onMatch: (range) => { + setChatThreadEl( + range.startContainer.parentElement?.closest( + '[data-chat-thread-root]', + ) ?? null, + ); + }, onTimeout: () => - toast( - 'Match not visible in the conversation — scroll manually to find it.', - { tone: 'info', duration: 4000 }, - ), + toast(t('search.conversationMatchNotVisible'), { + tone: 'info', + duration: 4000, + }), }, ); } @@ -325,11 +408,23 @@ export const CanvasSearchResults = (): React.JSX.Element => { // without a `NodePreviews` entry leave the user's current preview alone. if (hasNodePreview(nodeType)) { // Browsing results reuses the group's inspection slot (§9.2). - openPreviewNode(nodeId, { transient: true }); + openPreviewNode(nodeId, { transient: true, focusEditor: false }); + if (row.match.field === 'content') { + usePreviewSearchStore + .getState() + .reveal( + nodeId, + navigationQuery, + row.match.occurrenceIndex, + row.match.source, + ); + } + } else if (nodeType === 'question') { + openConversationForNode(nodeId); } focusNodeOnCanvas(nodeId); }, - [focusNodeOnCanvas, focusGroupOnCanvas, openConversationForNode, query], + [focusNodeOnCanvas, focusGroupOnCanvas, openConversationForNode, query, t], ); // Live-follow only on the Canvas. Browsing with ↑ / ↓ must not open, @@ -369,13 +464,13 @@ export const CanvasSearchResults = (): React.JSX.Element => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); - setActiveIdx((i) => Math.min(visibleRows.length - 1, i + 1)); + setActiveIdx(Math.min(visibleRows.length - 1, activeIdx + 1)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); - setActiveIdx((i) => Math.max(0, i - 1)); + setActiveIdx(Math.max(0, activeIdx - 1)); return; } if (e.key === 'ArrowLeft') { @@ -407,7 +502,7 @@ export const CanvasSearchResults = (): React.JSX.Element => { if (collapsedNodeIds.has(v.group.nodeId)) { toggleNodeCollapse(v.group.nodeId); } else if (v.group.rows.length > 0) { - setActiveIdx((i) => Math.min(visibleRows.length - 1, i + 1)); + setActiveIdx(Math.min(visibleRows.length - 1, activeIdx + 1)); } return; } @@ -420,17 +515,43 @@ export const CanvasSearchResults = (): React.JSX.Element => { collapsedNodeIds, toggleNodeCollapse, jumpToResult, + setActiveIdx, ]); // Keep the active row scrolled into view via Virtuoso's imperative // API. useEffect(() => { if (visibleRows.length === 0) return; + const root = resultsRef.current; + const focusedResult = root?.contains(document.activeElement) + ? document.activeElement + : null; + let cancelled = false; virtuosoRef.current?.scrollIntoView({ index: activeIdx, behavior: 'auto', align: 'center', + done: () => { + if (cancelled) return; + const activeElement = document.activeElement; + if ( + root && + (root.contains(activeElement) || + (focusedResult && + !focusedResult.isConnected && + activeElement === document.body)) + ) { + root + .querySelector( + `[data-search-row-index="${activeIdx}"]`, + ) + ?.focus({ preventScroll: true }); + } + }, }); + return () => { + cancelled = true; + }; }, [activeIdx, visibleRows.length]); const showEmpty = @@ -438,6 +559,7 @@ export const CanvasSearchResults = (): React.JSX.Element => { return (
@@ -451,10 +573,7 @@ export const CanvasSearchResults = (): React.JSX.Element => { className="text-warning mt-0.5 shrink-0" aria-hidden /> - - The result set only contains a subset of all matches. Be more - specific in your search to narrow down the results. - + {t('search.resultsTruncated')}
)} @@ -468,17 +587,17 @@ export const CanvasSearchResults = (): React.JSX.Element => { // SidebarPanel content) doesn't fight with Virtuoso's own. className="min-h-0 flex-1" data={visibleRows} - computeItemKey={(_, v) => - v.kind === 'header' ? `h:${v.group.nodeId}` : `m:${v.row.key}` - } + computeItemKey={(_, v) => visibleRowKey(v)} increaseViewportBy={200} itemContent={(index, v) => v.kind === 'header' ? ( 0} collapsed={collapsedNodeIds.has(v.group.nodeId)} active={index === activeIdx} + onFocus={() => setActiveIdx(index)} onClick={() => { setActiveIdx(index); toggleNodeCollapse(v.group.nodeId); @@ -486,8 +605,11 @@ export const CanvasSearchResults = (): React.JSX.Element => { /> ) : ( setActiveIdx(index)} onClick={() => { setActiveIdx(index); jumpToResult(v.row); @@ -500,13 +622,13 @@ export const CanvasSearchResults = (): React.JSX.Element => { {!error && nodeGroups.length === 0 && showEmpty && (
- No matches + {t('search.noMatches')}
)} {!error && isStreaming && contentPhase && (
- Searching note contents… + {t('search.searchingNoteContents')}
)}
@@ -529,22 +651,35 @@ interface NodeGroup { type VisibleRow = | { kind: 'header'; group: NodeGroup } - | { kind: 'match'; group: NodeGroup; row: SearchResultRow }; + | { + kind: 'match'; + group: NodeGroup; + row: SearchResultRow; + firstInField: boolean; + }; + +function visibleRowKey(row: VisibleRow): string { + return row.kind === 'header' ? `h:${row.group.nodeId}` : `m:${row.row.key}`; +} interface NodeHeaderItemProps { + index: number; group: NodeGroup; hasPreviousGroup: boolean; collapsed: boolean; active: boolean; onClick: () => void; + onFocus: () => void; } const NodeHeaderItem = ({ + index, group, hasPreviousGroup, collapsed, active, onClick, + onFocus, }: NodeHeaderItemProps): React.JSX.Element => { const displayLabel = group.label || group.nodeId; return ( @@ -556,6 +691,8 @@ const NodeHeaderItem = ({ variant="ghost" size="sm" onClick={onClick} + onFocus={onFocus} + data-search-row-index={index} aria-expanded={!collapsed} className={cn( 'flex h-7 w-full items-center justify-start gap-2 px-2 py-0 text-left text-xs font-normal', @@ -607,42 +744,54 @@ const NodeHeaderItem = ({ }; interface NodeMatchItemProps { + index: number; + firstInField: boolean; row: SearchResultRow; active: boolean; onClick: () => void; + onFocus: () => void; } const NodeMatchItem = ({ + index, + firstInField, row, active, onClick, -}: NodeMatchItemProps): React.JSX.Element => ( -
- -
-); + +
+ ); +}; /** * Max characters of leading context shown before the highlighted diff --git a/apps/web/src/components/Panels/ExpandedNodePanel/ExpandedNodePanel.tsx b/apps/web/src/components/Panels/ExpandedNodePanel/ExpandedNodePanel.tsx index f875cf4e7..9bfc5646f 100644 --- a/apps/web/src/components/Panels/ExpandedNodePanel/ExpandedNodePanel.tsx +++ b/apps/web/src/components/Panels/ExpandedNodePanel/ExpandedNodePanel.tsx @@ -156,6 +156,7 @@ const ConnectedNodeMenu = ({ return ( - + {/* In-preview find bar — renders nothing unless search scope is `'node'`. Wires the highlight walk to the body element (via state-as-ref) so only the visible preview gets `::highlight()` ranges. */} - + ({ useTranslation: () => ({ t: (key: string) => key }), })); -vi.mock('../../../hooks/useTextHighlight', () => ({ - useTextHighlight: () => ({ matchCount: 0 }), -})); +const { toast } = vi.hoisted(() => ({ toast: vi.fn() })); +vi.mock('../../Common/Toast', () => ({ toast })); -vi.mock('./PreviewSearchAdapterContext', () => ({ - usePreviewSearchAdapter: () => null, -})); +function RegisterAdapter({ adapter }: { adapter: PreviewSearchAdapter }) { + useRegisterPreviewSearchAdapter(adapter); + return null; +} ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -32,14 +37,218 @@ beforeEach(() => { document.body.appendChild(container); root = createRoot(container); usePreviewSearchStore.getState().close(); + toast.mockClear(); + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue( + new DOMRect(10, 100, 80, 20), + ); }); afterEach(() => { act(() => root.unmount()); container.remove(); + vi.restoreAllMocks(); }); describe('InPreviewSearchBar preview-search lifecycle', () => { + function mountDelayedAdapter() { + const pending: { + index: number; + complete: (visible: boolean) => void; + cancel: ReturnType; + }[] = []; + const adapter: PreviewSearchAdapter = { + query: 'needle', + matchCount: 3, + canNavigate: true, + isSearching: false, + navigateToMatch: (index, complete) => { + const cancel = vi.fn(); + pending.push({ index, complete, cancel }); + return cancel; + }, + }; + const renderAdapter = (next = adapter) => + root.render( + + + + , + ); + usePreviewSearchStore.getState().open('note-1'); + usePreviewSearchStore.getState().setQuery('needle'); + act(() => renderAdapter()); + const next = () => + act(() => container.querySelectorAll('button')[1]?.click()); + return { pending, adapter, renderAdapter, next }; + } + + it.each([ + 'query', + 'close', + 'reopen', + 'handoff', + 'adapter', + 'unmount', + ] as const)( + 'cancels manual navigation and ignores late completions after %s', + async (change) => { + const { pending, adapter, renderAdapter, next } = mountDelayedAdapter(); + next(); + const request = pending[0]; + expect(request?.index).toBe(1); + await act(async () => { + if (change === 'query') { + usePreviewSearchStore.getState().setQuery('replacement'); + expect(request.cancel).toHaveBeenCalledTimes(1); + } else if (change === 'close' || change === 'reopen') { + usePreviewSearchStore.getState().close(); + expect(request.cancel).toHaveBeenCalledTimes(1); + if (change === 'reopen') { + usePreviewSearchStore.getState().open('note-1'); + usePreviewSearchStore.getState().setQuery('needle'); + } + } else if (change === 'handoff') + usePreviewSearchStore.getState().reveal('note-1', 'needle', 0); + else if (change === 'adapter') renderAdapter({ ...adapter }); + else root.unmount(); + }); + expect(request.cancel).toHaveBeenCalledTimes(1); + const before = container.textContent; + act(() => { + request.complete(true); + request.complete(false); + }); + expect(container.textContent).toBe(before); + expect(toast).not.toHaveBeenCalled(); + }, + ); + + it('cancels a superseded manual jump while accepting only the latest result', () => { + const { pending, next } = mountDelayedAdapter(); + next(); + next(); + expect(pending).toHaveLength(2); + expect(pending[0].cancel).toHaveBeenCalledTimes(1); + act(() => { + pending[0].complete(true); + pending[0].complete(false); + }); + expect(container.textContent).toContain('1/3'); + expect(toast).not.toHaveBeenCalled(); + act(() => pending[1].complete(true)); + expect(container.textContent).toContain('2/3'); + expect(pending[1].cancel).not.toHaveBeenCalled(); + }); + + it('keeps a newer node search when the previous renderer unmounts', () => { + usePreviewSearchStore.getState().open('note-1'); + act(() => + root.render(), + ); + const focusedControl = document.createElement('button'); + container.append(focusedControl); + focusedControl.focus(); + act(() => { + usePreviewSearchStore.getState().reveal('note-2', 'needle', 1); + root.render(); + }); + expect(usePreviewSearchStore.getState()).toMatchObject({ + nodeId: 'note-2', + query: 'needle', + navigationRequest: { occurrenceIndex: 1 }, + }); + expect(document.activeElement).toBe(focusedControl); + }); + + it('waits for asynchronously mounted note content and scrolls to the requested occurrence', async () => { + const scope = document.createElement('div'); + const rootHost = document.createElement('div'); + act(() => root.unmount()); + container.append(scope, rootHost); + root = createRoot(rootHost); + const scroll = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(() => {}); + const focus = document.createElement('button'); + container.append(focus); + focus.focus(); + usePreviewSearchStore.getState().reveal('note-1', 'needle', 1); + act(() => + root.render(), + ); + expect(usePreviewSearchStore.getState().navigationRequest).not.toBeNull(); + scope.innerHTML = '

first needle

'; + await act(async () => { + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); + expect(scroll).not.toHaveBeenCalled(); + expect(usePreviewSearchStore.getState().navigationRequest).not.toBeNull(); + scope.insertAdjacentHTML( + 'beforeend', + '

second needle

', + ); + await act(async () => { + await vi.waitFor(() => + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(), + ); + }); + expect(container.textContent).toContain('2/2'); + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(); + expect(document.activeElement).toBe(focus); + scroll.mockRestore(); + }); + + it('uses the full count to navigate beyond the 500-range highlight cap', () => { + const scope = document.createElement('p'); + scope.dataset.previewSearchContent = ''; + scope.textContent = 'needle '.repeat(600); + container.append(scope); + usePreviewSearchStore.getState().reveal('note-1', 'needle', 500); + act(() => + root.render(), + ); + expect(container.textContent).toContain('501/600'); + const next = container.querySelectorAll('button')[1]; + act(() => next?.click()); + expect(container.textContent).toContain('502/600'); + }); + + it('keeps indexed navigation pending before an adapter registers even if DOM text matches', () => { + const scope = document.createElement('p'); + scope.textContent = 'needle'; + container.append(scope); + usePreviewSearchStore.getState().reveal('pdf-1', 'needle', 0); + act(() => + root.render( + , + ), + ); + expect(usePreviewSearchStore.getState().navigationRequest).not.toBeNull(); + expect(container.querySelector('button')?.disabled).toBe(true); + }); + + it('bounds the wait for a missing indexed adapter', () => { + vi.useFakeTimers(); + try { + usePreviewSearchStore.getState().reveal('pdf-1', 'needle', 0); + act(() => + root.render( + , + ), + ); + act(() => vi.advanceTimersByTime(8000)); + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it('is active while its node owns preview search', () => { usePreviewSearchStore.getState().open('pdf-1'); usePreviewSearchStore.getState().setQuery('needle'); @@ -51,14 +260,14 @@ describe('InPreviewSearchBar preview-search lifecycle', () => { expect(container.querySelector('input')?.value).toBe('needle'); }); - it('resets the shared store when unmounted so a stale query cannot resurface', () => { + it('resets the shared store when unmounted so a stale query cannot resurface', async () => { usePreviewSearchStore.getState().open('pdf-1'); usePreviewSearchStore.getState().setQuery('needle'); act(() => root.render(), ); - act(() => root.unmount()); + await act(async () => root.unmount()); expect(usePreviewSearchStore.getState()).toMatchObject({ nodeId: null, @@ -67,14 +276,14 @@ describe('InPreviewSearchBar preview-search lifecycle', () => { }); }); - it('resets the shared store when the owning node changes', () => { + it('resets the shared store when the owning node changes', async () => { usePreviewSearchStore.getState().open('pdf-1'); usePreviewSearchStore.getState().setQuery('needle'); act(() => root.render(), ); - act(() => + await act(async () => root.render(), ); diff --git a/apps/web/src/components/Panels/ExpandedNodePanel/InPreviewSearchBar.tsx b/apps/web/src/components/Panels/ExpandedNodePanel/InPreviewSearchBar.tsx index e920a9ea8..95ad6c8c6 100644 --- a/apps/web/src/components/Panels/ExpandedNodePanel/InPreviewSearchBar.tsx +++ b/apps/web/src/components/Panels/ExpandedNodePanel/InPreviewSearchBar.tsx @@ -25,25 +25,33 @@ */ import { ChevronDown, ChevronUp, X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { usePreviewSearchAdapter } from './PreviewSearchAdapterContext'; import { formatShortcutById } from '../../../config/shortcuts'; -import { findNthRange, scrollRangeIntoView } from '../../../hooks/searchDom'; +import { + findNthRange, + scheduleScrollToMatch, + scrollRangeIntoView, + getRangeMatchIndex, +} from '../../../hooks/searchDom'; import { useTextHighlight } from '../../../hooks/useTextHighlight'; import { usePreviewSearchStore } from '../../../store/previewSearchStore'; import { Button } from '../../Common/Button'; +import { toast } from '../../Common/Toast'; interface InPreviewSearchBarProps { /** Root of the preview content. Highlights paint over its descendants. */ scopeEl: HTMLElement | null; nodeId: string | null; + requiresAdapter?: boolean; } export const InPreviewSearchBar = ({ scopeEl, nodeId, + requiresAdapter = false, }: InPreviewSearchBarProps): React.JSX.Element | null => { const { t } = useTranslation(); const isOpen = usePreviewSearchStore((s) => s.isOpen); @@ -51,26 +59,64 @@ export const InPreviewSearchBar = ({ const query = usePreviewSearchStore((s) => s.query); const setQuery = usePreviewSearchStore((s) => s.setQuery); const close = usePreviewSearchStore((s) => s.close); + const focusInput = usePreviewSearchStore((s) => s.focusInput); + const navigationRequest = usePreviewSearchStore((s) => s.navigationRequest); + const rendererOwner = usePreviewSearchStore((s) => s.rendererOwner); + const [rendererId] = useState(() => Symbol('preview-search-renderer')); const inputRef = useRef(null); + const manualNavigationRef = useRef<{ + cancel?: () => void; + } | null>(null); + const cancelManualNavigation = useCallback(() => { + const pending = manualNavigationRef.current; + manualNavigationRef.current = null; + pending?.cancel?.(); + }, []); const [activeMatchIdx, setActiveMatchIdx] = useState(0); - const searchAdapter = usePreviewSearchAdapter(); - const isActive = isOpen && ownerNodeId === nodeId; + const [selectedRange, setSelectedRange] = useState<{ + query: string; + range: Range; + } | null>(null); + const registeredAdapter = usePreviewSearchAdapter(); + const searchAdapter = + registeredAdapter?.query === query ? registeredAdapter : null; + const isActive = + isOpen && ownerNodeId === nodeId && rendererOwner === rendererId; + const getActiveRange = useCallback( + () => searchAdapter?.getMatchRange?.(activeMatchIdx) ?? null, + [searchAdapter, activeMatchIdx], + ); useEffect(() => { - if (isActive) { + if (isActive && focusInput) { inputRef.current?.focus(); inputRef.current?.select(); } - }, [isActive]); + }, [isActive, focusInput]); + + useEffect(() => { + if (!nodeId) return; + return usePreviewSearchStore + .getState() + .registerRenderer(nodeId, rendererId); + }, [nodeId, rendererId]); - // Preview search is a per-node session. When the owning node changes or - // the panel unmounts, reset the shared store so reopening the same node - // does not resurrect a stale query and steal focus on mount. useEffect(() => { + const unsubscribe = usePreviewSearchStore.subscribe((state, previous) => { + if ( + state.query !== previous.query || + state.nodeId !== previous.nodeId || + state.isOpen !== previous.isOpen || + state.rendererOwner !== previous.rendererOwner || + state.navigationSequence !== previous.navigationSequence + ) + cancelManualNavigation(); + }); return () => { - usePreviewSearchStore.getState().close(); + unsubscribe(); + cancelManualNavigation(); }; - }, [nodeId]); + }, [cancelManualNavigation, isActive, query, nodeId, scopeEl, searchAdapter]); // Track whether the user has already navigated at least once. The // inline highlight visually implies the first match is "current", so @@ -91,32 +137,186 @@ export const InPreviewSearchBar = ({ const { matchCount: domMatchCount } = useTextHighlight({ container: isActive ? scopeEl : null, query, + activeMatchIndex: activeMatchIdx, + activeRange: selectedRange?.query === query ? selectedRange.range : null, + getActiveRange, }); - const matchCount = searchAdapter?.matchCount ?? domMatchCount; + const waitingForAdapter = requiresAdapter && !searchAdapter; + const matchCount = waitingForAdapter + ? 0 + : (searchAdapter?.matchCount ?? domMatchCount); + const isSearching = waitingForAdapter || searchAdapter?.isSearching; // Reset cursor whenever the query changes so Next/Prev starts from top. useEffect(() => { setActiveMatchIdx(0); setHasNavigated(false); + setSelectedRange(null); }, [query]); + useEffect(() => { + if (!isActive || !navigationRequest || (!waitingForAdapter && !isSearching)) + return; + const { nonce } = navigationRequest; + const timer = setTimeout(() => { + if (usePreviewSearchStore.getState().navigationRequest?.nonce !== nonce) + return; + toast(t('search.previewMatchNotVisible'), { + tone: 'info', + duration: 4000, + }); + usePreviewSearchStore.getState().consumeNavigation(nonce); + }, 8000); + return () => clearTimeout(timer); + }, [isActive, navigationRequest, waitingForAdapter, isSearching, t]); + + useEffect(() => { + if ( + !isActive || + !navigationRequest || + !scopeEl || + !query || + waitingForAdapter + ) + return; + const { occurrenceIndex, nonce } = navigationRequest; + const consume = () => + usePreviewSearchStore.getState().consumeNavigation(nonce); + if (searchAdapter) { + if (!searchAdapter.isCurrent()) return; + if ( + !searchAdapter.failed && + (!searchAdapter.canNavigate || searchAdapter.isSearching) + ) + return; + let cancelled = false; + let cancelNavigation: (() => void) | void; + const finish = (visible: boolean, index: number, range?: Range): void => { + if ( + cancelled || + !searchAdapter.isCurrent() || + usePreviewSearchStore.getState().navigationRequest?.nonce !== nonce + ) + return; + if (visible) { + setActiveMatchIdx(index); + setHasNavigated(true); + setSelectedRange(range ? { query, range } : null); + } else { + toast(t('search.previewMatchNotVisible'), { + tone: 'info', + duration: 4000, + }); + } + consume(); + }; + const navigate = (index: number | null): void => { + if (cancelled || !searchAdapter.isCurrent()) return; + if (index === null || index < 0 || index >= searchAdapter.matchCount) + finish(false, 0); + else + cancelNavigation = searchAdapter.navigateToMatch( + index, + (visible, range) => finish(visible, index, range), + ); + }; + if (searchAdapter.failed) finish(false, 0); + else if (navigationRequest.source) { + if (!searchAdapter.resolveSourceMatch) navigate(null); + else + void searchAdapter + .resolveSourceMatch(navigationRequest.source) + .then(navigate) + .catch((error: unknown) => { + console.error( + '[search] Cannot resolve indexed source match', + error, + ); + finish(false, 0); + }); + } else navigate(occurrenceIndex); + return () => { + cancelled = true; + cancelNavigation?.(); + }; + } + return scheduleScrollToMatch(() => scopeEl, query, occurrenceIndex, { + source: navigationRequest.source, + fallbackToFirst: false, + onMatch: (range) => { + setSelectedRange({ query, range }); + setActiveMatchIdx(getRangeMatchIndex(scopeEl, query, range) ?? 0); + setHasNavigated(true); + consume(); + }, + onTimeout: () => { + toast(t('search.previewMatchNotVisible'), { + tone: 'info', + duration: 4000, + }); + consume(); + }, + }); + }, [ + isActive, + navigationRequest, + scopeEl, + query, + searchAdapter, + waitingForAdapter, + t, + ]); + const jumpToMatch = (idx: number): void => { if ( !scopeEl || !query || matchCount === 0 || - (searchAdapter && !searchAdapter.canNavigate) + (searchAdapter && + (!searchAdapter.isCurrent() || !searchAdapter.canNavigate)) ) return; + cancelManualNavigation(); const normalised = ((idx % matchCount) + matchCount) % matchCount; - setActiveMatchIdx(normalised); - setHasNavigated(true); + if (navigationRequest) + usePreviewSearchStore + .getState() + .consumeNavigation(navigationRequest.nonce); + const pending: { cancel?: () => void } = {}; + manualNavigationRef.current = pending; + const sequence = usePreviewSearchStore.getState().navigationSequence; + const finish = (visible: boolean, range?: Range): void => { + const state = usePreviewSearchStore.getState(); + if ( + manualNavigationRef.current !== pending || + !state.isOpen || + state.nodeId !== nodeId || + state.query !== query || + state.rendererOwner !== rendererId || + state.navigationSequence !== sequence || + (searchAdapter && !searchAdapter.isCurrent()) + ) + return; + manualNavigationRef.current = null; + if (visible) { + setActiveMatchIdx(normalised); + setHasNavigated(true); + setSelectedRange(range ? { query, range } : null); + } else + toast(t('search.previewMatchNotVisible'), { + tone: 'info', + duration: 4000, + }); + }; if (searchAdapter) { - searchAdapter.navigateToMatch(normalised); + const cancel = searchAdapter.navigateToMatch(normalised, finish); + if (manualNavigationRef.current === pending) + pending.cancel = cancel || undefined; + else cancel?.(); return; } const range = findNthRange(scopeEl, query, normalised); - if (range) scrollRangeIntoView(range); + finish(!!range && scrollRangeIntoView(range), range ?? undefined); }; if (!isActive) return null; @@ -156,8 +356,8 @@ export const InPreviewSearchBar = ({ {query.length > 0 ? matchCount > 0 - ? `${activeMatchIdx + 1}/${matchCount}${searchAdapter?.isSearching ? '+' : ''}` - : searchAdapter?.isSearching + ? `${activeMatchIdx + 1}/${matchCount}${isSearching ? '+' : ''}` + : isSearching ? '…' : '0/0' : ''} diff --git a/apps/web/src/components/Panels/ExpandedNodePanel/PreviewSearchAdapterContext.tsx b/apps/web/src/components/Panels/ExpandedNodePanel/PreviewSearchAdapterContext.tsx index 0047e8bb1..25a0b4c60 100644 --- a/apps/web/src/components/Panels/ExpandedNodePanel/PreviewSearchAdapterContext.tsx +++ b/apps/web/src/components/Panels/ExpandedNodePanel/PreviewSearchAdapterContext.tsx @@ -5,19 +5,30 @@ import { createContext, useContext, useEffect, + useCallback, + useRef, useState, type ReactNode, } from 'react'; +import type { CanvasSearchSource } from '@huabu/shared'; + export type PreviewSearchAdapter = { + query: string; matchCount: number; isSearching: boolean; canNavigate: boolean; - navigateToMatch: (matchIndex: number) => void; + failed?: boolean; + resolveSourceMatch?: (source: CanvasSearchSource) => Promise; + getMatchRange?: (matchIndex: number) => Range | null; + navigateToMatch: ( + matchIndex: number, + onResult: (visible: boolean, range?: Range) => void, + ) => (() => void) | void; }; type PreviewSearchAdapterContextValue = { - adapter: PreviewSearchAdapter | null; + adapter: (PreviewSearchAdapter & { isCurrent: () => boolean }) | null; register: (adapter: PreviewSearchAdapter | null) => void; }; @@ -29,7 +40,18 @@ export function PreviewSearchAdapterProvider({ }: { children: ReactNode; }): React.JSX.Element { - const [adapter, register] = useState(null); + const [adapter, setAdapter] = + useState(null); + const current = useRef(null); + const register = useCallback((next: PreviewSearchAdapter | null) => { + const registration = next ? Symbol('preview-search-adapter') : null; + current.current = registration; + setAdapter( + next + ? { ...next, isCurrent: () => current.current === registration } + : null, + ); + }, []); return ( {children} @@ -37,7 +59,7 @@ export function PreviewSearchAdapterProvider({ ); } -export function usePreviewSearchAdapter(): PreviewSearchAdapter | null { +export function usePreviewSearchAdapter(): PreviewSearchAdapterContextValue['adapter'] { return useContext(PreviewSearchAdapterContext)?.adapter ?? null; } diff --git a/apps/web/src/components/Panels/PreviewWorkspace/PreviewTab.tsx b/apps/web/src/components/Panels/PreviewWorkspace/PreviewTab.tsx index 07b171616..bcd335db2 100644 --- a/apps/web/src/components/Panels/PreviewWorkspace/PreviewTab.tsx +++ b/apps/web/src/components/Panels/PreviewWorkspace/PreviewTab.tsx @@ -240,6 +240,7 @@ export function PreviewTab({ > { }); }); +describe('search-result preview opening', () => { + it('suppresses stale editor focus for search without changing normal Note opening', () => { + useCanvasStore.setState({ + canvasId: CANVAS_ID, + nodes: [canvasNode('a', 'Alpha')], + }); + const tabId = openPreviewNode('a'); + const originalRequest = store().nodeFocusRequest; + expect(originalRequest?.tabId).toBe(tabId); + expect(openPreviewNode('a', { transient: true, focusEditor: false })).toBe( + tabId, + ); + expect(store().nodeFocusRequest).toBeNull(); + openPreviewNode('a'); + expect(store().nodeFocusRequest?.nonce).toBeGreaterThan( + originalRequest?.nonce ?? 0, + ); + }); +}); + describe('tab-owned titles', () => { const titleInput = () => container?.querySelector( diff --git a/apps/web/src/components/Settings/agent-profiles/AgentIconPicker.tsx b/apps/web/src/components/Settings/agent-profiles/AgentIconPicker.tsx index 95907c502..4b9917898 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentIconPicker.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentIconPicker.tsx @@ -44,6 +44,7 @@ export function AgentIconPicker({ return ( { + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue( + new DOMRect(10, 100, 80, 20), + ); +}); +afterEach(() => vi.restoreAllMocks()); + describe('preview DOM search boundaries', () => { + it('reveals conversation history before resolving an ordinal inside a scoped message', async () => { + const root = rootWith('

recent needle needle

'); + root.setAttribute('data-chat-thread-root', ''); + root.setAttribute('data-chat-has-older', 'true'); + root.addEventListener('chat-reveal-search', (event) => + event.preventDefault(), + ); + const getMatchRoot = vi.fn(() => + root.querySelector('[data-id="recent"]'), + ); + const onMatch = vi.fn(); + const scroll = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(() => {}); + const cancel = scheduleScrollToMatch(() => root, 'needle', 1, { + getMatchRoot, + onMatch, + }); + expect(getMatchRoot).not.toHaveBeenCalled(); + root.insertAdjacentHTML( + 'afterbegin', + '

older needle needle

', + ); + root.removeAttribute('data-chat-has-older'); + await vi.waitFor(() => expect(onMatch).toHaveBeenCalledTimes(1)); + expect( + onMatch.mock.calls[0]?.[0].startContainer.parentElement?.dataset.id, + ).toBe('recent'); + cancel(); + scroll.mockRestore(); + root.remove(); + }); + + it('reports a missing or ambiguous originating prompt without using the full thread', async () => { + const root = rootWith('unrelated needle'); + root.setAttribute('data-chat-thread-root', ''); + root.addEventListener('chat-reveal-search', (event) => + event.preventDefault(), + ); + const onMatch = vi.fn(); + const onTimeout = vi.fn(); + const cancel = scheduleScrollToMatch(() => root, 'needle', 0, { + getMatchRoot: () => null, + onMatch, + onTimeout, + timeoutMs: 10, + }); + await vi.waitFor(() => expect(onTimeout).toHaveBeenCalledTimes(1)); + expect(onMatch).not.toHaveBeenCalled(); + cancel(); + root.remove(); + }); + it('scrolls a match hidden below an inner panel even when it is inside the browser viewport', () => { + const root = rootWith('needle'); + root.style.overflowY = 'auto'; + const range = findNthRange(root, 'needle', 0); + if (!range) throw new Error('Missing fixture match'); + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 100, 300, 200), + ); + vi.spyOn(range, 'getBoundingClientRect').mockImplementation( + () => new DOMRect(0, 350 - root.scrollTop, 100, 20), + ); + expect(scrollRangeIntoView(range)).toBe(true); + expect(root.scrollTop).toBe(160); + root.remove(); + }); + + it('waits for older server pages before resolving a conversation ordinal', async () => { + const root = rootWith( + '

needle recent

', + ); + root.setAttribute('data-chat-thread-root', ''); + root.setAttribute('data-chat-has-older', 'true'); + root.addEventListener('chat-reveal-search', (event) => + event.preventDefault(), + ); + const finished = vi.fn(); + root.addEventListener('chat-search-finished', finished); + const onMatch = vi.fn(); + const scroll = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(() => {}); + const cancel = scheduleScrollToMatch(() => root, 'needle', 0, { onMatch }); + expect(onMatch).not.toHaveBeenCalled(); + root.innerHTML = + '

needle older

needle recent

'; + root.removeAttribute('data-chat-has-older'); + expect(findRanges(root, 'needle')).toHaveLength(2); + await vi.waitFor(() => expect(onMatch).toHaveBeenCalledTimes(1)); + expect( + onMatch.mock.calls[0]?.[0].startContainer.parentElement?.dataset.id, + ).toBe('older'); + expect(finished).toHaveBeenCalledTimes(1); + cancel(); + scroll.mockRestore(); + root.remove(); + }); + + it('does not silently jump to the first conversation occurrence when the requested one is missing', async () => { + const root = rootWith('needle'); + root.setAttribute('data-chat-thread-root', ''); + root.addEventListener('chat-reveal-search', (event) => + event.preventDefault(), + ); + const onMatch = vi.fn(); + const onTimeout = vi.fn(); + const finished = vi.fn(); + root.addEventListener('chat-search-finished', finished); + const cancel = scheduleScrollToMatch(() => root, 'needle', 3, { + timeoutMs: 10, + onMatch, + onTimeout, + }); + await vi.waitFor(() => expect(onTimeout).toHaveBeenCalledTimes(1)); + expect(onMatch).not.toHaveBeenCalled(); + expect(finished).toHaveBeenCalledTimes(1); + cancel(); + root.remove(); + }); + it('searches marked document content instead of adjacent editor chrome', () => { const root = rootWith(`
Task List
@@ -24,6 +157,7 @@ describe('preview DOM search boundaries', () => {
`); + root.setAttribute('data-search-renderer', ''); expect(findRanges(root, 'task')).toHaveLength(0); expect(findRanges(root, 'report')).toHaveLength(1); @@ -35,12 +169,30 @@ describe('preview DOM search boundaries', () => {
Task List
first task and second task
`); + root.setAttribute('data-search-renderer', ''); expect(findRanges(root, 'task')).toHaveLength(2); expect(findNthRange(root, 'task', 1)?.toString()).toBe('task'); root.remove(); }); + it.each([ + '

needle label

needle menu

needle note

needle text

', + '

needle prompt

needle menu

needle assistant

needle quote
', + ])('preserves sibling Canvas and conversation surfaces: %s', (html) => { + const root = rootWith(html); + const ranges = findRanges(root, 'needle'); + expect(ranges).toHaveLength(3); + expect( + ranges.map((range) => range.startContainer.parentElement?.textContent), + ).not.toContain('needle menu'); + expect( + findNthRange(root, 'needle', 2)?.startContainer.parentElement + ?.textContent, + ).toMatch(/needle (text|quote)/); + root.remove(); + }); + it('reveals cached chat history before choosing a DOM occurrence', async () => { const root = rootWith('needle (recent)'); root.dataset.id = 'recent'; @@ -56,12 +208,10 @@ describe('preview DOM search boundaries', () => { }); root.addEventListener('chat-reveal-search', reveal); const targets: string[] = []; - const scroll = vi - .spyOn(HTMLElement.prototype, 'scrollIntoView') - .mockImplementation(function (this: HTMLElement) { - targets.push(this.dataset.id ?? ''); - }); - const cancel = scheduleScrollToMatch(() => root, 'needle', 0); + const cancel = scheduleScrollToMatch(() => root, 'needle', 0, { + onMatch: (range) => + targets.push(range.startContainer.parentElement?.dataset.id ?? ''), + }); expect(targets).toEqual([]); expect(reveal).toHaveBeenCalledTimes(1); await vi.waitFor(() => @@ -71,7 +221,6 @@ describe('preview DOM search boundaries', () => { await vi.waitFor(() => expect(targets).toEqual(['older'])); expect(reveal).toHaveBeenCalledTimes(1); cancel(); - scroll.mockRestore(); root.remove(); }); }); diff --git a/apps/web/src/hooks/searchDom.ts b/apps/web/src/hooks/searchDom.ts index 66c3a541a..f059fb2ed 100644 --- a/apps/web/src/hooks/searchDom.ts +++ b/apps/web/src/hooks/searchDom.ts @@ -1,6 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { literalTextMatches } from '@huabu/shared'; + +import { resolveSourceRange } from './useSearchSource'; + +import type { CanvasSearchSource } from '@huabu/shared'; + /** * Shared DOM-walk helpers for the search subsystem. * @@ -19,9 +25,8 @@ * inside `root`'s text nodes and return it as a `Range`, or `null` * if there aren't that many matches. * - * Walks in document order. When the preview declares one or more - * `[data-preview-search-content]` roots, only those document surfaces are - * searched; otherwise the whole root is used as a compatibility fallback. + * Walks in document order. Each `[data-search-renderer]` scopes its own + * searchable document without excluding sibling content in a larger root. * Hidden content and explicitly excluded chrome are always skipped. */ export function findNthRange( @@ -29,10 +34,18 @@ export function findNthRange( query: string, nth: number, ): Range | null { - const needle = query.toLowerCase(); - if (!needle) return null; - const ranges = findRanges(root, query, nth + 1); - return ranges[nth] ?? null; + let index = 0; + for (const { node, offset, endNode, endOffset } of walkTextMatches( + root, + query, + )) { + if (index++ !== nth) continue; + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(endNode, endOffset); + return range; + } + return null; } /** @@ -45,50 +58,145 @@ export function findRanges( query: string, maxRanges = Number.POSITIVE_INFINITY, ): Range[] { - const needle = query.toLowerCase(); - if (!needle || maxRanges <= 0) return []; - const ranges: Range[] = []; - for (const searchRoot of getSearchRoots(root)) { - const walker = document.createTreeWalker(searchRoot, NodeFilter.SHOW_TEXT, { - acceptNode: (node) => - isSearchableTextNode(node, searchRoot) - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT, - }); - let current = walker.nextNode(); - while (current && ranges.length < maxRanges) { - const text = (current.textContent ?? '').toLowerCase(); - let from = 0; - while (ranges.length < maxRanges) { - const idx = text.indexOf(needle, from); - if (idx === -1) break; - const range = document.createRange(); - range.setStart(current, idx); - range.setEnd(current, idx + needle.length); - ranges.push(range); - from = idx + Math.max(1, needle.length); - } - current = walker.nextNode(); - } + if (maxRanges <= 0) return ranges; + for (const { node, offset, endNode, endOffset } of walkTextMatches( + root, + query, + )) { + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(endNode, endOffset); + ranges.push(range); if (ranges.length >= maxRanges) break; } return ranges; } -function getSearchRoots(root: HTMLElement): HTMLElement[] { - if (root.matches('[data-preview-search-content]')) return [root]; - const candidates = Array.from( - root.querySelectorAll('[data-preview-search-content]'), - ); - const topLevelCandidates = candidates.filter( - (candidate) => - !candidate.parentElement?.closest('[data-preview-search-content]'), - ); - return topLevelCandidates.length > 0 ? topLevelCandidates : [root]; +/** Count all searchable occurrences without allocating DOM Range objects. */ +export function countMatches(root: HTMLElement, query: string): number { + let count = 0; + const matches = walkTextMatches(root, query); + while (!matches.next().done) count += 1; + return count; +} + +export function getRangeMatchIndex( + root: HTMLElement, + query: string, + range: Range, +): number | null { + let index = 0; + for (const match of walkTextMatches(root, query)) { + if ( + match.node === range.startContainer && + match.offset === range.startOffset && + match.endNode === range.endContainer && + match.endOffset === range.endOffset + ) + return index; + index++; + } + return null; +} + +function searchableTextNodes(root: HTMLElement): Node[] { + const nodes: Node[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ALL); + let current = walker.nextNode(); + while (current) { + if ( + current.nodeType === Node.TEXT_NODE && + isSearchableTextNode(current, root) + ) + nodes.push(current); + current = walker.nextNode(); + } + return nodes; +} + +export function findProjectedRange( + root: HTMLElement, + source: string, + start: number, + end: number, +): Range | null { + const nodes = searchableTextNodes(root); + const compact = (text: string): string => text.replace(/\s/g, ''); + if ( + compact(nodes.map((node) => node.textContent ?? '').join('')) !== + compact(source) + ) + return null; + const from = compact(source.slice(0, start)).length; + const to = compact(source.slice(0, end)).length; + if (from === to) return null; + const range = document.createRange(); + let index = 0; + let started = false; + for (const node of nodes) { + const text = node.textContent ?? ''; + for (let offset = 0; offset < text.length; offset++) { + if (/\s/.test(text[offset] ?? '')) continue; + if (index === from) { + range.setStart(node, offset); + started = true; + } + index++; + if (started && index === to) { + range.setEnd(node, offset + 1); + return range; + } + } + } + return null; +} + +function* walkTextMatches( + root: HTMLElement, + query: string, +): Generator<{ node: Node; offset: number; endNode: Node; endOffset: number }> { + const needle = query.toLowerCase(); + if (!needle) return; + const nodes = searchableTextNodes(root); + let text = ''; + let previousBlock: Element | null | undefined; + const spans = nodes.map((node) => { + const block = node.parentElement?.closest( + 'p,div,li,pre,td,th,h1,h2,h3,h4,h5,h6', + ); + if (previousBlock !== undefined && block !== previousBlock) text += '\0'; + previousBlock = block; + const start = text.length; + text += node.textContent ?? ''; + return { node, start, end: text.length }; + }); + let spanIndex = 0; + for (const { start: index, end: matchEnd } of literalTextMatches( + text, + needle, + )) { + while (spanIndex < spans.length && (spans[spanIndex]?.end ?? 0) <= index) + spanIndex++; + const start = spans[spanIndex]; + let endIndex = spanIndex; + while (endIndex < spans.length && (spans[endIndex]?.end ?? 0) < matchEnd) + endIndex++; + const end = spans[endIndex]; + if (start && end) + yield { + node: start.node, + offset: index - start.start, + endNode: end.node, + endOffset: matchEnd - end.start, + }; + } } function isSearchableTextNode(node: Node, boundary: HTMLElement): boolean { + const renderer = node.parentElement?.closest('[data-search-renderer]'); + const content = node.parentElement?.closest('[data-preview-search-content]'); + if (renderer && (!content || !renderer.contains(content))) return false; let element = node.parentElement; while (element) { const tag = element.tagName; @@ -96,6 +204,7 @@ function isSearchableTextNode(node: Node, boundary: HTMLElement): boolean { if ( element.hidden || element.getAttribute('aria-hidden') === 'true' || + element.getAttribute('data-preview-active') === 'false' || element.hasAttribute('data-search-exclude') ) { return false; @@ -119,24 +228,90 @@ function isSearchableTextNode(node: Node, boundary: HTMLElement): boolean { * already sits comfortably inside it. Uses instant (non-smooth) * scrolling so search navigation feels snappy. * - * Note: `scrollIntoView` walks up through every scrollable ancestor, - * so this works for nested scrollers (e.g. a preview body inside a - * panel inside the page). + * Scroll each ancestor by the Range geometry, never by the dimensions + * of its potentially much taller parent paragraph. */ -export function scrollRangeIntoView(range: Range): void { - const rect = range.getBoundingClientRect(); - const target = rect.top + rect.height / 2; - const viewport = window.innerHeight; - // `rect.width === 0` happens for ranges inside not-yet-laid-out - // text (e.g. pdf.js text-layer spans before their first paint); - // treat that as "needs scroll" so the parent element comes into - // view and the next observer pass can refine the position. - if (rect.width === 0 || target < 80 || target > viewport - 80) { - range.startContainer.parentElement?.scrollIntoView({ - block: 'center', - behavior: 'auto', +export function scrollRangeIntoView(range: Range): boolean { + let rect = range.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + for ( + let parent = range.startContainer.parentElement; + parent; + parent = parent.parentElement + ) { + const style = getComputedStyle(parent); + const bounds = parent.getBoundingClientRect(); + if (!bounds.height || !bounds.width) continue; + const inset = Math.min(80, bounds.height / 4); + if ( + /(auto|scroll|hidden|clip)/.test(style.overflowY) && + (rect.top < bounds.top + inset || rect.bottom > bounds.bottom - inset) + ) { + parent.scrollTop += + rect.top + rect.height / 2 - bounds.top - bounds.height / 2; + rect = range.getBoundingClientRect(); + } + if ( + /(auto|scroll|hidden|clip)/.test(style.overflowX) && + (rect.left < bounds.left || rect.right > bounds.right) + ) { + parent.scrollLeft += + rect.left + rect.width / 2 - bounds.left - bounds.width / 2; + rect = range.getBoundingClientRect(); + } + } + if ( + rect.top < 0 || + rect.bottom > window.innerHeight || + rect.left < 0 || + rect.right > window.innerWidth + ) { + window.scrollBy({ + top: rect.top + rect.height / 2 - window.innerHeight / 2, + left: + rect.left < 0 || rect.right > window.innerWidth + ? rect.left + rect.width / 2 - window.innerWidth / 2 + : 0, + behavior: 'instant', }); + rect = range.getBoundingClientRect(); } + let visibleTop = 0; + let visibleBottom = window.innerHeight; + let visibleLeft = 0; + let visibleRight = window.innerWidth; + for ( + let parent = range.startContainer.parentElement; + parent; + parent = parent.parentElement + ) { + const style = getComputedStyle(parent); + const bounds = parent.getBoundingClientRect(); + if (bounds.height === 0) continue; + if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) { + visibleTop = Math.max(visibleTop, bounds.top); + visibleBottom = Math.min(visibleBottom, bounds.bottom); + } + if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) { + visibleLeft = Math.max(visibleLeft, bounds.left); + visibleRight = Math.min(visibleRight, bounds.right); + } + } + const visible = ( + start: number, + end: number, + clipStart: number, + clipEnd: number, + ): boolean => + end - start <= clipEnd - clipStart + ? start >= clipStart - 0.5 && end <= clipEnd + 0.5 + : (start + end) / 2 >= clipStart && (start + end) / 2 <= clipEnd; + return ( + visibleBottom > visibleTop && + visibleRight > visibleLeft && + visible(rect.top, rect.bottom, visibleTop, visibleBottom) && + visible(rect.left, rect.right, visibleLeft, visibleRight) + ); } /** @@ -172,7 +347,16 @@ export function scheduleScrollToMatch( getRoot: () => HTMLElement | null, query: string, nth: number, - options?: { timeoutMs?: number; onTimeout?: () => void }, + options?: { + timeoutMs?: number; + onTimeout?: () => void; + onMatch?: (range: Range) => void; + fallbackToFirst?: boolean; + getMatchRoot?: (root: HTMLElement) => HTMLElement | null; + source?: CanvasSearchSource; + requireSource?: boolean; + getMatchRange?: (root: HTMLElement) => Range | null; + }, ): () => void { if (!query) return () => {}; const timeoutMs = options?.timeoutMs ?? 8000; @@ -184,9 +368,13 @@ export function scheduleScrollToMatch( let timer: ReturnType | null = null; let rafId = 0; let resolved = false; + let waitingForLayout = false; const preparedChatRoots = new WeakSet(); + let preparedChatRoot: HTMLElement | null = null; const cleanup = (): void => { + preparedChatRoot?.dispatchEvent(new Event('chat-search-finished')); + preparedChatRoot = null; observer?.disconnect(); observer = null; observedTarget = null; @@ -201,6 +389,7 @@ export function scheduleScrollToMatch( }; const tryScroll = (): boolean => { + waitingForLayout = false; if (cancelled) return true; const root = getRoot(); if (!root) return false; @@ -211,21 +400,54 @@ export function scheduleScrollToMatch( root.dispatchEvent(request); if (!request.defaultPrevented) return false; preparedChatRoots.add(root); + preparedChatRoot = root; } - if (root.hasAttribute('data-chat-cached-earlier')) return false; + if ( + root.hasAttribute('data-chat-cached-earlier') || + root.hasAttribute('data-chat-has-older') || + root.hasAttribute('data-chat-history-loading') + ) + return false; } - // Prefer the requested ordinal; fall back to the first match if - // the DOM has fewer occurrences than the indexed content (e.g. - // markdown source has matches in syntax tokens that the rendered - // view strips, or pdf.js hasn't paginated all hits yet). - const range = - findNthRange(root, query, nth) ?? findNthRange(root, query, 0); + const matchRoot = options?.getMatchRoot ? options.getMatchRoot(root) : root; + if (!matchRoot) return false; + if (options?.requireSource && !options.source) { + onTimeout?.(); + return true; + } + const sourceMatch = options?.source + ? resolveSourceRange( + matchRoot, + query, + options.source, + searchableTextNodes, + ) + : null; + if (sourceMatch?.unavailable) { + onTimeout?.(); + return true; + } + // Document source ordinals can differ from rendered Markdown. Conversation + // ordinals must never fall back to an unrelated, more recent occurrence. + const range = options?.getMatchRange + ? options.getMatchRange(matchRoot) + : sourceMatch + ? sourceMatch.range + : (findNthRange(matchRoot, query, nth) ?? + (root.matches('[data-chat-thread-root]') || + options?.fallbackToFirst === false + ? null + : findNthRange(matchRoot, query, 0))); if (!range) return false; - scrollRangeIntoView(range); + if (!scrollRangeIntoView(range)) { + waitingForLayout = true; + return false; + } + options?.onMatch?.(range); return true; }; - const onMutation: MutationCallback = () => { + const onMutation = (): void => { if (cancelled || rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; @@ -237,7 +459,7 @@ export function scheduleScrollToMatch( if (tryScroll()) { resolved = true; cleanup(); - } + } else if (waitingForLayout) onMutation(); }); }; @@ -252,18 +474,27 @@ export function scheduleScrollToMatch( subtree: true, characterData: true, attributes: true, - attributeFilter: ['data-chat-cached-earlier', 'data-preview-active'], + attributeFilter: [ + 'data-chat-cached-earlier', + 'data-chat-has-older', + 'data-chat-history-loading', + 'data-preview-active', + 'data-search-source', + 'data-search-source-ready', + ], }); observedTarget = desired; }; if (tryScroll()) { + cleanup(); return () => { cancelled = true; }; } ensureNarrowedObserver(); + if (waitingForLayout) onMutation(); timer = setTimeout(() => { cleanup(); diff --git a/apps/web/src/hooks/searchNavigation.audit.test.tsx b/apps/web/src/hooks/searchNavigation.audit.test.tsx new file mode 100644 index 000000000..4b0d34b1f --- /dev/null +++ b/apps/web/src/hooks/searchNavigation.audit.test.tsx @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createHash } from 'node:crypto'; + +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InPreviewSearchBar } from '@/components/Panels/ExpandedNodePanel/InPreviewSearchBar'; +import { usePreviewSearchStore } from '@/store/previewSearchStore'; + +import { + findNthRange, + scrollRangeIntoView, + scheduleScrollToMatch, +} from './searchDom'; +import { registerSearchSource, hashSearchSource } from './useSearchSource'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +let host: HTMLDivElement; +let scope: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + host = document.createElement('div'); + scope = document.createElement('div'); + document.body.append(host, scope); + root = createRoot(host); + usePreviewSearchStore.getState().close(); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + scope.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('search handoff lifecycle acceptance', () => { + it('keeps a new handoff issued after disposal but before its replacement mounts', async () => { + act(() => + root.render(), + ); + await act(async () => { + root.unmount(); + usePreviewSearchStore.getState().reveal('audit-node', 'replacement', 1); + await Promise.resolve(); + }); + expect(usePreviewSearchStore.getState()).toMatchObject({ + nodeId: 'audit-node', + query: 'replacement', + navigationRequest: { occurrenceIndex: 1 }, + }); + }); + it.each([false, true])( + 'preserves a cold asynchronous handoff under StrictMode (indexed: %s)', + (requiresAdapter) => { + usePreviewSearchStore.getState().reveal('audit-node', 'needle', 1); + act(() => + root.render( + + + , + ), + ); + expect(usePreviewSearchStore.getState()).toMatchObject({ + isOpen: true, + nodeId: 'audit-node', + navigationRequest: { occurrenceIndex: 1 }, + }); + }, + ); + + it('preserves the same cold handoff without StrictMode', () => { + usePreviewSearchStore.getState().reveal('audit-node', 'needle', 1); + act(() => + root.render(), + ); + expect(usePreviewSearchStore.getState().navigationRequest).not.toBeNull(); + }); + + it('accepts a warm handoff after StrictMode replay has completed', () => { + act(() => + root.render( + + + , + ), + ); + act(() => + usePreviewSearchStore.getState().reveal('audit-node', 'needle', 1), + ); + expect(usePreviewSearchStore.getState().navigationRequest).not.toBeNull(); + }); + + it('does not let an older renderer for the same node erase a newer pending handoff', async () => { + const previousHost = document.createElement('div'); + document.body.append(previousHost); + const previousRoot = createRoot(previousHost); + act(() => + previousRoot.render( + , + ), + ); + act(() => + root.render( + , + ), + ); + act(() => + usePreviewSearchStore.getState().reveal('audit-node', 'new query', 1), + ); + await act(async () => previousRoot.unmount()); + previousHost.remove(); + expect(usePreviewSearchStore.getState()).toMatchObject({ + nodeId: 'audit-node', + query: 'new query', + navigationRequest: { occurrenceIndex: 1 }, + }); + }); +}); + +describe('source-to-rendered addressing', () => { + const markdown = '[needle](https://example.test/needle)\n\nParagraph needle'; + const geometry = (): void => { + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue( + new DOMRect(10, 100, 80, 20), + ); + }; + + it.each(['', 'abc', '中文 \u0130 ΟΣ 😀\n'])( + 'hashes %j without crypto.subtle using the server digest', + async (text) => { + vi.stubGlobal('crypto', {}); + expect(await hashSearchSource(text)).toBe( + createHash('sha256').update(text).digest('hex'), + ); + }, + ); + + it('registers and reveals contextual Unicode matches on ordinary HTTP', async () => { + vi.stubGlobal('crypto', {}); + geometry(); + const text = '\u0130 ΟΣ'; + scope.textContent = text; + const unregister = registerSearchSource(scope, text, 'text'); + const onMatch = vi.fn(); + const cancel = scheduleScrollToMatch(() => scope, 'ς', 0, { + source: { + hash: createHash('sha256').update(text).digest('hex'), + offset: 3, + length: 1, + }, + onMatch, + }); + await vi.waitFor(() => expect(onMatch).toHaveBeenCalledTimes(1)); + expect(onMatch.mock.calls[0]?.[0].toString()).toBe('Σ'); + cancel(); + unregister(); + }); + + it('waits for an editor-model readiness signal even when its text DOM already exists', async () => { + geometry(); + scope.textContent = 'needle'; + let ready = false; + const unregister = registerSearchSource(scope, 'needle', 'markdown', () => + ready ? findNthRange(scope, 'needle', 0) : null, + ); + const onMatch = vi.fn(); + const cancel = scheduleScrollToMatch(() => scope, 'needle', 0, { + source: { hash: await hashSearchSource('needle'), offset: 0 }, + onMatch, + }); + expect(onMatch).not.toHaveBeenCalled(); + ready = true; + scope.setAttribute('data-search-source-ready', ''); + await vi.waitFor(() => expect(onMatch).toHaveBeenCalledTimes(1)); + cancel(); + unregister(); + }); + it('reports a hidden link destination instead of selecting a later visible occurrence', async () => { + geometry(); + scope.innerHTML = '

needle

Paragraph needle

'; + const unregister = registerSearchSource(scope, markdown, 'markdown'); + const onMatch = vi.fn(); + const onTimeout = vi.fn(); + const cancel = scheduleScrollToMatch(() => scope, 'needle', 1, { + source: { + hash: await hashSearchSource(markdown), + offset: markdown.indexOf('needle', markdown.indexOf('https')), + }, + onMatch, + onTimeout, + }); + await vi.waitFor(() => expect(onTimeout).toHaveBeenCalledExactlyOnceWith()); + expect(onMatch).not.toHaveBeenCalled(); + cancel(); + unregister(); + }); + + it('reveals the visible paragraph after a hidden link occurrence with its rendered counter', async () => { + geometry(); + scope.innerHTML = '

needle

Paragraph needle

'; + const unregister = registerSearchSource(scope, markdown, 'markdown'); + const source = { + hash: await hashSearchSource(markdown), + offset: markdown.lastIndexOf('needle'), + }; + act(() => { + usePreviewSearchStore + .getState() + .reveal('audit-node', 'needle', 2, source); + root.render(); + }); + await act(async () => { + await vi.waitFor(() => + expect(usePreviewSearchStore.getState().navigationRequest).toBeNull(), + ); + }); + expect(host.textContent).toContain('2/2'); + unregister(); + }); + + it('selects the originating assistant paragraph, never an unrelated later user message', async () => { + geometry(); + scope.innerHTML = + '

needle

Paragraph needle

Unrelated later needle'; + const assistant = scope.querySelector('[data-assistant]'); + const laterUser = scope.querySelector('[data-later-user]'); + if (!assistant || !laterUser) throw new Error('Missing source fixture'); + const unregister = registerSearchSource(assistant, markdown, 'markdown'); + const unregisterUser = registerSearchSource( + laterUser, + 'Unrelated later needle', + 'text', + ); + const onMatch = vi.fn(); + const cancel = scheduleScrollToMatch(() => scope, 'needle', 2, { + source: { + hash: await hashSearchSource(markdown), + offset: markdown.lastIndexOf('needle'), + }, + onMatch, + }); + await vi.waitFor(() => expect(onMatch).toHaveBeenCalledTimes(1)); + expect( + onMatch.mock.calls[0]?.[0].startContainer.parentElement?.hasAttribute( + 'data-target', + ), + ).toBe(true); + cancel(); + unregister(); + unregisterUser(); + }); + + it('rejects ambiguous identical sources rather than choosing one message', async () => { + geometry(); + scope.innerHTML = '

needle

needle

'; + const unregister = Array.from(scope.children, (element) => + registerSearchSource(element as HTMLElement, 'needle', 'text'), + ); + const onMatch = vi.fn(); + const onTimeout = vi.fn(); + const cancel = scheduleScrollToMatch(() => scope, 'needle', 1, { + source: { hash: await hashSearchSource('needle'), offset: 0 }, + onMatch, + onTimeout, + }); + await vi.waitFor(() => expect(onTimeout).toHaveBeenCalledTimes(1)); + expect(onMatch).not.toHaveBeenCalled(); + cancel(); + unregister.forEach((cleanup) => cleanup()); + }); + + it('maps a PDF phrase across separate text-layer spans without requiring DOM spaces', async () => { + const { findProjectedRange } = await import('./searchDom'); + scope.innerHTML = 'needlephrase'; + expect(findProjectedRange(scope, 'needle phrase', 0, 13)?.toString()).toBe( + 'needlephrase', + ); + expect(findProjectedRange(scope, 'different phrase', 0, 9)).toBeNull(); + }); +}); + +it('makes the matched range visible inside a paragraph taller than its scroll panel', () => { + scope.style.overflowY = 'auto'; + const paragraph = document.createElement('p'); + paragraph.textContent = 'filler '.repeat(1000) + 'needle'; + scope.append(paragraph); + const range = findNthRange(paragraph, 'needle', 0); + if (!range) throw new Error('Missing fixture occurrence'); + vi.spyOn(scope, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 300, 200), + ); + vi.spyOn(paragraph, 'getBoundingClientRect').mockImplementation( + () => new DOMRect(0, -scope.scrollTop, 300, 2000), + ); + vi.spyOn(range, 'getBoundingClientRect').mockImplementation( + () => new DOMRect(0, 1960 - scope.scrollTop, 80, 20), + ); + vi.spyOn(paragraph, 'scrollIntoView').mockImplementation(() => { + scope.scrollTop = 900; + }); + scrollRangeIntoView(range); + const after = range.getBoundingClientRect(); + expect(after.top).toBeGreaterThanOrEqual(0); + expect(after.bottom).toBeLessThanOrEqual(200); +}); diff --git a/apps/web/src/hooks/useSearchSource.ts b/apps/web/src/hooks/useSearchSource.ts new file mode 100644 index 000000000..2dfdcebe3 --- /dev/null +++ b/apps/web/src/hooks/useSearchSource.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useEffect } from 'react'; + +import { literalTextMatches } from '@huabu/shared'; +import { markdownSearchTarget } from '@huabu/shared/canvas-engine'; + +import type { CanvasSearchSource } from '@huabu/shared'; +import type { RefObject } from 'react'; + +interface SearchSource { + text: string; + format: 'markdown' | 'text'; + resolveRange?: (text: string, start: number, end: number) => Range | null; +} + +const sources = new WeakMap(); + +export async function hashSearchSource(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const digest = globalThis.crypto?.subtle + ? new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)) + : (await import('@noble/hashes/sha256')).sha256(bytes); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); +} + +export function registerSearchSource( + root: HTMLElement, + text: string, + format: SearchSource['format'], + resolveRange?: SearchSource['resolveRange'], +): () => void { + const source = { text, format, resolveRange }; + sources.set(root, source); + root.setAttribute('data-search-source', ''); + void Promise.resolve() + .then(() => hashSearchSource(text)) + .then((hash) => { + if (sources.get(root) !== source) return; + root.setAttribute('data-search-source', hash); + }) + .catch((error: unknown) => { + console.error('[search] Cannot identify rendered source', error); + }); + return () => { + if (sources.get(root) !== source) return; + sources.delete(root); + root.removeAttribute('data-search-source'); + }; +} + +export function useSearchSource( + root: RefObject, + text: string, + format: SearchSource['format'], + resolver?: RefObject<{ + getSearchRange: NonNullable; + } | null>, +): void { + useEffect(() => { + if (!root.current) return; + return registerSearchSource( + root.current, + text, + format, + resolver + ? (...args) => resolver.current?.getSearchRange(...args) ?? null + : undefined, + ); + }, [root, text, format, resolver]); +} + +export function resolveSourceRange( + root: HTMLElement, + query: string, + address: CanvasSearchSource, + getTextNodes: (root: HTMLElement) => Node[], +): { range: Range | null; unavailable: boolean } { + const candidates = [ + root, + ...root.querySelectorAll('[data-search-source]'), + ].filter( + (candidate) => + candidate.getAttribute('data-search-source') === address.hash && + !candidate.closest('[data-preview-active="false"]'), + ); + if (candidates.length !== 1) + return { range: null, unavailable: candidates.length > 1 }; + const candidate = candidates[0]; + const source = candidate && sources.get(candidate); + if (!candidate || !source) return { range: null, unavailable: false }; + const length = address.length ?? query.length; + let matchesAddress = false; + for (const match of literalTextMatches(source.text, query)) { + if (match.start < address.offset) continue; + matchesAddress = + match.start === address.offset && match.end === address.offset + length; + break; + } + if (!matchesAddress) { + return { range: null, unavailable: true }; + } + const target = + source.format === 'markdown' + ? markdownSearchTarget(source.text, address.offset, length) + : { + text: source.text, + start: address.offset, + end: address.offset + length, + }; + if (!target) return { range: null, unavailable: true }; + if (source.resolveRange) { + return { + range: source.resolveRange(target.text, target.start, target.end), + unavailable: false, + }; + } + const nodes = getTextNodes(candidate); + // Never guess an ordinal when a node view or normalization changes text. + if (nodes.map((node) => node.textContent ?? '').join('') !== target.text) { + return { range: null, unavailable: false }; + } + const range = document.createRange(); + let offset = 0; + let started = false; + for (const node of nodes) { + const end = offset + (node.textContent?.length ?? 0); + if (!started && target.start >= offset && target.start < end) { + range.setStart(node, target.start - offset); + started = true; + } + if (started && target.end > offset && target.end <= end) { + range.setEnd(node, target.end - offset); + return { range, unavailable: false }; + } + offset = end; + } + return { range: null, unavailable: true }; +} diff --git a/apps/web/src/hooks/useTextHighlight.test.tsx b/apps/web/src/hooks/useTextHighlight.test.tsx new file mode 100644 index 000000000..9cd666435 --- /dev/null +++ b/apps/web/src/hooks/useTextHighlight.test.tsx @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { findProjectedRange } from './searchDom'; +import { useTextHighlight } from './useTextHighlight'; + +let root: Root; +let host: HTMLDivElement; +let canvas: HTMLDivElement; +let preview: HTMLDivElement; +let highlights: Map>; + +function Surface({ + element, + query, + activeMatchIndex, + getActiveRange, +}: { + element: HTMLElement | null; + query: string; + activeMatchIndex?: number; + getActiveRange?: () => Range | null; +}) { + const { matchCount } = useTextHighlight({ + container: element, + query, + activeMatchIndex, + getActiveRange, + }); + return {matchCount}; +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + highlights = new Map(); + vi.stubGlobal( + 'Highlight', + class extends Set { + constructor(...ranges: Range[]) { + super(ranges); + } + }, + ); + vi.stubGlobal('CSS', { highlights }); + host = document.createElement('div'); + canvas = document.createElement('div'); + preview = document.createElement('div'); + canvas.textContent = 'canvas needle'; + preview.textContent = 'preview needle'; + document.body.append(host, canvas, preview); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + canvas.remove(); + preview.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it('refreshes an indexed phrase highlight when its text-layer nodes are replaced', async () => { + preview.innerHTML = 'needlephrase'; + const getActiveRange = () => + findProjectedRange(preview, 'needle phrase', 0, 13); + act(() => + root.render( + , + ), + ); + expect(highlights.get('huabu-search')?.size).toBe(1); + const previousRange = Array.from(highlights.get('huabu-search') ?? [])[0]; + preview.innerHTML = 'needlephrase'; + await act(async () => { + await vi.waitFor(() => { + const range = Array.from(highlights.get('huabu-search') ?? [])[0]; + expect(range).not.toBe(previousRange); + expect(range?.startContainer).toBe(preview.firstElementChild?.firstChild); + expect(range?.toString()).toBe('needlephrase'); + }); + }); + expect(highlights.get('huabu-search')?.size).toBe(1); +}); + +it('merges independent canvas and preview highlights and removes only the closing surface', () => { + act(() => + root.render( + <> + + + + , + ), + ); + expect(highlights.get('huabu-search')?.size).toBe(2); + act(() => + root.render( + <> + + + , + ), + ); + expect(highlights.get('huabu-search')?.size).toBe(1); + expect( + Array.from(highlights.get('huabu-search') ?? [])[0]?.startContainer, + ).toBe(canvas.firstChild); + act(() => root.render()); + expect(highlights.has('huabu-search')).toBe(false); +}); + +it('counts all matches while allocating only the bounded painted ranges', async () => { + canvas.textContent = 'needle '.repeat(600); + const createRange = vi.spyOn(document, 'createRange'); + act(() => root.render()); + expect(host.textContent).toBe('600'); + expect(highlights.get('huabu-search')?.size).toBe(500); + expect(createRange).toHaveBeenCalledTimes(500); + canvas.textContent += 'needle'; + await vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + }); + + expect(host.textContent).toBe('601'); + }); + expect(highlights.get('huabu-search')?.size).toBe(500); +}); + +it('keeps an occurrence beyond the cap highlighted without increasing the painted range count', () => { + canvas.textContent = 'needle '.repeat(600); + const createRange = vi.spyOn(document, 'createRange'); + act(() => + root.render( + , + ), + ); + expect(host.textContent).toBe('600'); + expect(highlights.get('huabu-search')?.size).toBe(500); + expect(createRange).toHaveBeenCalledTimes(501); + expect( + Array.from(highlights.get('huabu-search') ?? []).at(-1)?.startOffset, + ).toBe(3500); + act(() => + root.render( + , + ), + ); + expect(highlights.get('huabu-search')?.size).toBe(500); + expect( + Array.from(highlights.get('huabu-search') ?? []).at(-1)?.startOffset, + ).toBe(3507); +}); diff --git a/apps/web/src/hooks/useTextHighlight.ts b/apps/web/src/hooks/useTextHighlight.ts index 7d1ab672f..48319768b 100644 --- a/apps/web/src/hooks/useTextHighlight.ts +++ b/apps/web/src/hooks/useTextHighlight.ts @@ -27,10 +27,11 @@ import { useEffect, useState } from 'react'; -import { findRanges } from './searchDom'; +import { countMatches, findNthRange, findRanges } from './searchDom'; /** Global registered highlight name. CSS selector: `::highlight(huabu-search)`. */ const HIGHLIGHT_NAME = 'huabu-search'; +const surfaceRanges = new Map(); interface UseTextHighlightOptions { /** @@ -39,25 +40,27 @@ interface UseTextHighlightOptions { * `::highlight()` registration — used when the canvas-wide search * needs to paint matches on both the canvas root and the expanded * preview panel at the same time. `null` (or an array of only - * `null`s) clears the highlight set. + * `null`s) removes this surface's ranges without clearing other surfaces. */ container: HTMLElement | (HTMLElement | null)[] | null; /** Needle. Empty string clears the highlight set. */ query: string; /** Soft cap on Range objects we'll register (across all containers). */ maxRanges?: number; + /** Keep the navigated occurrence painted even when it lies beyond the cap. */ + activeMatchIndex?: number; + activeRange?: Range | null; + getActiveRange?: () => Range | null; } export interface UseTextHighlightResult { /** - * Current number of registered match ranges. Updates whenever the + * Total searchable occurrences, independent of the highlight cap. Updates whenever the * MutationObserver re-walks the container, so consumers showing a * `1/N` counter stay in sync with text that mounts asynchronously * (pdf.js text layer, Milkdown lazy editor, virtualised lists). * - * Capped by `maxRanges` — when the underlying document has more - * matches than the cap, this number reflects what was registered, - * not the true total. + * `maxRanges` bounds painted Range objects, not the navigation count. */ matchCount: number; } @@ -87,6 +90,9 @@ export function useTextHighlight({ container, query, maxRanges = 500, + activeMatchIndex, + activeRange: selectedRange, + getActiveRange, }: UseTextHighlightOptions): UseTextHighlightResult { const [matchCount, setMatchCount] = useState(0); @@ -110,6 +116,10 @@ export function useTextHighlight({ // Cheaper than a `useMemo` + array-equality check, and avoids the // pitfall of `[a, b]` deps being a fresh array each render. const depKey = liveContainers.map(elementId).join('|'); + const outlyingMatchIndex = + activeMatchIndex !== undefined && activeMatchIndex >= maxRanges + ? activeMatchIndex + : null; useEffect(() => { const HighlightCtor: typeof Highlight | undefined = ( @@ -122,47 +132,69 @@ export function useTextHighlight({ // No-container / no-query / no-API → make sure count is zeroed // even when the API isn't available (jsdom in tests). if (liveContainers.length === 0 || !query) { - highlights?.delete(HIGHLIGHT_NAME); setMatchCount(0); return; } - if (!HighlightCtor || !highlights) { - // API unavailable — still surface a count so the find bar can - // advance, since the fallback `findNthRange` walk uses the same - // DOM independently of the registered highlight. - let initial = 0; - for (const c of liveContainers) { - initial += findRanges(c, query, maxRanges - initial).length; - if (initial >= maxRanges) break; - } - setMatchCount(initial); - return; - } + const surface = Symbol('search-surface'); + const publish = (): void => { + if (!HighlightCtor || !highlights) return; + const ranges = Array.from(surfaceRanges.values()).flat(); + if (ranges.length === 0) highlights.delete(HIGHLIGHT_NAME); + else highlights.set(HIGHLIGHT_NAME, new HighlightCtor(...ranges)); + }; const apply = (): void => { const ranges: Range[] = []; + let count = 0; + const candidate = getActiveRange?.() ?? selectedRange; + let activeRange = + candidate && + !candidate.collapsed && + liveContainers.some( + (container) => + container.contains(candidate.startContainer) && + container.contains(candidate.endContainer), + ) + ? candidate + : null; for (const c of liveContainers) { + const containerCount = countMatches(c, query); + if ( + HighlightCtor && + highlights && + !activeRange && + outlyingMatchIndex !== null && + outlyingMatchIndex >= count && + outlyingMatchIndex < count + containerCount + ) { + activeRange = findNthRange(c, query, outlyingMatchIndex - count); + } + count += containerCount; const remaining = maxRanges - ranges.length; - if (remaining <= 0) break; + if (remaining <= 0 || !HighlightCtor || !highlights) continue; // `findRanges` checks `document.contains` implicitly via // `createTreeWalker`; a container that detached between // renders just yields zero ranges. ranges.push(...findRanges(c, query, remaining)); } + if ( + activeRange && + !ranges.some( + (range) => + range.startContainer === activeRange.startContainer && + range.startOffset === activeRange.startOffset && + range.endContainer === activeRange.endContainer && + range.endOffset === activeRange.endOffset, + ) + ) { + if (ranges.length < maxRanges) ranges.push(activeRange); + else if (ranges.length) ranges[ranges.length - 1] = activeRange; + } // Only update state when the count actually changes — avoids // a render storm during pdf.js's per-page text-layer mounts. - setMatchCount((prev) => (prev === ranges.length ? prev : ranges.length)); - if (ranges.length === 0) { - highlights.delete(HIGHLIGHT_NAME); - return; - } - // Highlight constructor accepts an iterable of Range. - highlights.set( - HIGHLIGHT_NAME, - new HighlightCtor( - ...(ranges as unknown as ConstructorParameters), - ), - ); + setMatchCount((prev) => (prev === count ? prev : count)); + if (HighlightCtor && highlights) surfaceRanges.set(surface, ranges); + publish(); }; apply(); @@ -198,13 +230,21 @@ export function useTextHighlight({ return () => { if (scheduled) cancelAnimationFrame(scheduled); for (const o of observers) o.disconnect(); - highlights.delete(HIGHLIGHT_NAME); + surfaceRanges.delete(surface); + publish(); }; // `liveContainers` is rebuilt every render but `depKey` collapses // it into a primitive identity string, so the effect only re-runs // when an actual element comes/goes. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [depKey, query, maxRanges]); + }, [ + depKey, + query, + maxRanges, + outlyingMatchIndex, + selectedRange, + getActiveRange, + ]); return { matchCount }; } diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 3d615c152..d1ba15282 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -530,7 +530,19 @@ "previousMatch": "Previous match", "nextMatch": "Next match", "clear": "Clear search", - "searching": "Searching" + "searching": "Searching", + "noMatches": "No matches", + "searchingNoteContents": "Searching note contents…", + "resultsTruncated": "The result set only contains a subset of all matches. Be more specific in your search to narrow down the results.", + "conversationMatchNotVisible": "Cannot precisely locate this match in the conversation. It may only exist in source text, or the content may have changed.", + "previewMatchNotVisible": "Cannot precisely locate this match in the preview. It may only exist in source text, or the content may have changed.", + "fields": { + "label": "Title", + "summary": "Summary", + "keywords": "Keywords", + "content": "Content", + "conversation": "Conversation" + } }, "editor": { "readOnlyContent": "Read-only content", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 3e4931ccb..37d9c3ddc 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -530,7 +530,19 @@ "previousMatch": "上一个匹配项", "nextMatch": "下一个匹配项", "clear": "清除搜索", - "searching": "正在搜索" + "searching": "正在搜索", + "noMatches": "没有匹配结果", + "searchingNoteContents": "正在搜索笔记正文…", + "resultsTruncated": "当前仅显示部分匹配结果,请使用更具体的搜索内容缩小范围。", + "conversationMatchNotVisible": "无法在当前对话中精确定位此匹配;它可能仅存在于源码中,或内容已更新。", + "previewMatchNotVisible": "无法在当前预览中精确定位此匹配;它可能仅存在于源码中,或内容已更新。", + "fields": { + "label": "标题", + "summary": "摘要", + "keywords": "关键词", + "content": "正文", + "conversation": "对话" + } }, "editor": { "readOnlyContent": "只读内容", diff --git a/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx b/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx index c2664f627..457f10383 100644 --- a/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx +++ b/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx @@ -462,6 +462,7 @@ function Specimen({ type: initialType }: { type: CanvasNodeType }) { { isOpen: false, }); }); + + it('addresses a result without changing canvas search and fences stale navigation completion', () => { + const state = usePreviewSearchStore.getState; + useSearchStore.setState({ query: 'canvas query' }); + state().reveal('note-1', 'needle', 2); + const first = state().navigationRequest; + expect(state()).toMatchObject({ + nodeId: 'note-1', + query: 'needle', + isOpen: true, + focusInput: false, + navigationRequest: { occurrenceIndex: 2 }, + }); + state().reveal('note-2', 'needle', 3); + const second = state().navigationRequest; + expect(second?.nonce).toBeGreaterThan(first?.nonce ?? 0); + state().consumeNavigation(first?.nonce ?? 0); + expect(state().navigationRequest).toBe(second); + state().consumeNavigation(second?.nonce ?? 0); + expect(state().navigationRequest).toBeNull(); + expect(useSearchStore.getState().query).toBe('canvas query'); + state().open('note-2'); + expect(state().focusInput).toBe(true); + }); }); diff --git a/apps/web/src/store/previewSearchStore.ts b/apps/web/src/store/previewSearchStore.ts index 50069083d..abe9b078e 100644 --- a/apps/web/src/store/previewSearchStore.ts +++ b/apps/web/src/store/previewSearchStore.ts @@ -3,20 +3,113 @@ import { create } from 'zustand'; +import type { CanvasSearchSource } from '@huabu/shared'; + +const renderers = new Map>(); + +function latestRenderer(nodeId: string): symbol | null { + return ( + Array.from(renderers.get(nodeId) ?? []) + .filter(([, entry]) => entry.active) + .at(-1)?.[0] ?? null + ); +} + interface PreviewSearchState { nodeId: string | null; query: string; isOpen: boolean; + focusInput: boolean; + rendererOwner: symbol | null; + navigationRequest: { + occurrenceIndex: number; + nonce: number; + source?: CanvasSearchSource; + } | null; + navigationSequence: number; open: (nodeId: string) => void; + reveal: ( + nodeId: string, + query: string, + occurrenceIndex: number, + source?: CanvasSearchSource, + ) => void; + registerRenderer: (nodeId: string, owner: symbol) => () => void; + consumeNavigation: (nonce: number) => void; close: () => void; setQuery: (query: string) => void; } -export const usePreviewSearchStore = create((set) => ({ +export const usePreviewSearchStore = create((set, get) => ({ nodeId: null, query: '', isOpen: false, - open: (nodeId) => set({ nodeId, query: '', isOpen: true }), - close: () => set({ nodeId: null, query: '', isOpen: false }), - setQuery: (query) => set({ query }), + focusInput: false, + navigationRequest: null, + navigationSequence: 0, + rendererOwner: null, + registerRenderer: (nodeId, owner) => { + let owners = renderers.get(nodeId); + if (!owners) { + owners = new Map(); + renderers.set(nodeId, owners); + } + const registration = { active: true }; + owners.delete(owner); + owners.set(owner, registration); + if (get().nodeId === nodeId) set({ rendererOwner: owner }); + return () => { + registration.active = false; + // Effect replay renews the registration before this disposal checkpoint. + queueMicrotask(() => { + const current = renderers.get(nodeId); + if (current?.get(owner) !== registration) return; + current.delete(owner); + if (current.size === 0) renderers.delete(nodeId); + const state = get(); + if (state.nodeId !== nodeId || state.rendererOwner !== owner) return; + const remaining = latestRenderer(nodeId); + if (remaining) set({ rendererOwner: remaining }); + else state.close(); + }); + }; + }, + open: (nodeId) => + set({ + nodeId, + query: '', + isOpen: true, + focusInput: true, + navigationRequest: null, + rendererOwner: latestRenderer(nodeId), + }), + reveal: (nodeId, query, occurrenceIndex, source) => + set((state) => { + const nonce = state.navigationSequence + 1; + return { + nodeId, + query: query.trim(), + isOpen: true, + focusInput: false, + navigationSequence: nonce, + navigationRequest: { occurrenceIndex, nonce, source }, + rendererOwner: latestRenderer(nodeId), + }; + }), + consumeNavigation: (nonce) => + set((state) => + state.navigationRequest?.nonce === nonce + ? { navigationRequest: null } + : {}, + ), + close: () => + set({ + nodeId: null, + query: '', + isOpen: false, + focusInput: false, + navigationRequest: null, + rendererOwner: null, + }), + setQuery: (query) => set({ query, navigationRequest: null }), })); diff --git a/apps/web/src/store/previewWorkspace/actions.ts b/apps/web/src/store/previewWorkspace/actions.ts index f15466c00..6e446ff00 100644 --- a/apps/web/src/store/previewWorkspace/actions.ts +++ b/apps/web/src/store/previewWorkspace/actions.ts @@ -16,7 +16,7 @@ import { export function openPreviewNode( nodeId: string, - options?: { transient?: boolean }, + options?: { transient?: boolean; focusEditor?: boolean }, ): string { const canvas = useCanvasStore.getState(); const previousNodeId = selectActiveNodeId( @@ -37,8 +37,14 @@ export function openPreviewNode( { transient: options?.transient ?? true }, ); const openedNode = canvas.nodes.find((node) => node.id === nodeId); - if (tabId && openedNode?.type === 'note') { + if (tabId && openedNode?.type === 'note' && options?.focusEditor !== false) { usePreviewWorkspaceStore.getState().requestNodeFocus(tabId); + } else if (tabId && options?.focusEditor === false) { + const preview = usePreviewWorkspaceStore.getState(); + const request = preview.nodeFocusRequest; + if (request?.tabId === tabId) { + preview.consumeNodeFocusRequest(tabId, request.nonce); + } } return tabId; } diff --git a/apps/web/src/store/searchStore.ts b/apps/web/src/store/searchStore.ts index cfc849f26..14e771d90 100644 --- a/apps/web/src/store/searchStore.ts +++ b/apps/web/src/store/searchStore.ts @@ -147,20 +147,23 @@ export const useSearchStore = create((set, get) => ({ }, setQuery: (query) => { - set({ query }); - const trimmed = query.trim(); - cancelInFlight(); - if (!trimmed) { - set({ - results: [], - isStreaming: false, - truncated: false, - contentPhase: false, - error: null, - collapsedNodeIds: new Set(), - }); + const changed = query.trim() !== get().query.trim(); + if (!changed) { + set({ query }); return; } + const trimmed = query.trim(); + cancelInFlight(); + set({ + query, + results: [], + isStreaming: false, + truncated: false, + contentPhase: false, + error: null, + collapsedNodeIds: new Set(), + }); + if (!trimmed) return; debounceHandle = setTimeout(() => { void runSearch(trimmed, set, get); }, DEBOUNCE_MS); @@ -175,7 +178,7 @@ export const useSearchStore = create((set, get) => ({ const sameLength = prev.length === nodeTypes.length; const sameMembers = sameLength && prev.every((t, i) => t === nodeTypes[i]); if (sameMembers) return; - set({ nodeTypes }); + set({ nodeTypes, results: [], isStreaming: false, truncated: false }); const trimmed = get().query.trim(); cancelInFlight(); if (!trimmed) return; diff --git a/docs/architecture/note-node.md b/docs/architecture/note-node.md index cd98ab530..f4cd0838f 100644 --- a/docs/architecture/note-node.md +++ b/docs/architecture/note-node.md @@ -33,7 +33,7 @@ The shared NoteContentViewport keeps its `h-full` constraint in both scrollbar m **Rendering** — the canvas card mounts the read-only `MilkdownPreview`; the expanded panel mounts the editable `MilkdownEditor`, which also offers a raw-Markdown source mode alongside WYSIWYG. See §4. -**Searching** — expanded-preview find marks the WYSIWYG document or raw-Markdown editor as its searchable content root. Editor chrome, including Crepe's mounted-but-hidden slash menu and Huabu's floating toolbar and provenance controls, is outside that boundary and does not contribute matches. The shared DOM walker also rejects hidden and explicitly excluded text so counting, highlighting, and next/previous navigation agree. +**Searching** — each renderer scopes its own searchable document without excluding sibling titles, plain text, user prompts, or quoted comments from a larger Canvas or conversation search. Editor chrome, including Crepe's mounted-but-hidden slash menu and Huabu's floating toolbar and provenance controls, does not contribute matches. Activating a Canvas content-search result seeds the preview-local find session with a normalized query and exact source address, waits for the editor to mount, and scrolls to the highlighted match without focusing the input or changing editing mode. WYSIWYG maps visible source positions through ProseMirror; raw Markdown uses CodeMirror's complete document for matching and scroll effects to render off-viewport positions before acknowledging visibility. Neither path changes the editor selection. Source-only syntax is unavailable in WYSIWYG but remains locatable when already in source mode; see the [Preview Workspace navigation contract](./preview-workspace.md#6-focus-and-opening-position). **Saving** — an edit calls `updateNodeData(id, { content })`, which dispatches `UPDATE_NODE_DATA` → `MERGE_NODE_DATA`. There is **no debounce**: every editor `onChange` writes through. Concurrency is handled at the server by rev-CAS, which rejects a stale write with `409 NODE_CONTENT_CONFLICT` rather than merging it. diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md index 279e04fe8..4818675e5 100644 --- a/docs/architecture/preview-workspace.md +++ b/docs/architecture/preview-workspace.md @@ -211,7 +211,37 @@ Single and batch tab closing share one removal primitive: resolve the removed ID Editor focus is a runtime-only `{ tabId, nonce }` request. Only the addressed active tab receives it, and its renderer consumes it after focus succeeds so remounting cannot replay stale intent. -Question conversation positioning is a runtime-only `{ tabId, position, nonce }` request where `position` is `last-user` or `bottom`. `MessageList` consumes an explicit request after history hydration and successful positioning. Ordinary Chat activation (warm switch, cold mount, close/reopen or opening after reload) defaults to the latest content in the recent-turn window, not an older saved offset. Each group supplies an activation identity that changes only when its active tab changes; repeated clicks on the active tab, other-group focus and ordinary rerenders do not reset the window. Explicit positioning and conversation-search navigation take precedence over the ordinary default. Search addresses the destination thread, reveals its cached history before DOM match navigation, and suspends following/compaction. +Question conversation positioning is a runtime-only `{ tabId, position, nonce }` request where `position` is `last-user` or `bottom`. `MessageList` consumes an explicit request after history hydration and successful positioning. Ordinary Chat activation (warm switch, cold mount, close/reopen or opening after reload) defaults to the latest content in the recent-turn window, not an older saved offset. Each group supplies an activation identity that changes only when its active tab changes; repeated clicks on the active tab, other-group focus and ordinary rerenders do not reset the window. Explicit positioning and conversation-search navigation take precedence over the ordinary default. Search addresses the destination thread, reveals its cached turns, and suspends following/compaction. An explicit jump also pages older server history before resolving its source address; a recent-only match must not stand in for an older result. Paging stops on completion, cancellation, timeout, page error, or tab deactivation. Unavailable matches retain the localized failure hint instead of silently jumping to the first occurrence. + +Activating a Canvas content match opens the node's transient preview and hands its normalized query and source address to the preview-local search store through a nonce-addressed navigation request. The input retains its raw spelling, but the streamed request, highlights, and handoff use the same trimmed query. Changing the effective query or filters invalidates old rows synchronously, before debounce, so an old row cannot activate under new input. Search opens previews with `focusEditor: false`, suppressing both a new Note editor-focus request and a stale request for the destination tab; ordinary Note opening retains its editor-focus behavior. This opens the find bar without focusing its input or stealing keyboard ownership from the Canvas search results. Canvas and preview queries remain independent after this initial handoff. + +Preview search registers individual renderer owners rather than disposing by node ID alone. Only the current owner processes a handoff; registration release settles at a microtask checkpoint so StrictMode effect replay can renew the same registration without destroying a cold request. Disposing an old renderer cannot close a newer same-node owner, while final real disposal and node changes still close abandoned sessions. Completion consumes only the matching nonce. + +Manual Next/Previous navigation also owns its adapter cancellation function. A replacement jump, query/session/renderer change, adapter replacement, or unmount cancels the old work; delayed completions must still match the active navigation identity, query, session sequence, renderer owner, and adapter registration before updating counters, highlights, or notifications. + +Streaming result updates preserve the active row by its stable match key, including field regrouping that changes row indices. Query, scope, and node-type filter changes reset selection; appended results do not move keyboard focus to the first header. Renderer-local document boundaries exclude editor chrome without excluding searchable sibling surfaces in the Canvas or conversation root. + +Content and conversation matches retain their raw-source ordinals for result ordering, but navigation uses `{ hash, offset, length? }`: a SHA-256 identity of the exact source segment and its original UTF-16 match span, including case-folding length changes. Server, DOM, and PDF scanners reuse the same literal matcher rather than treating lowercased-string offsets as original-source offsets. User prompts, quoted passages/comments, and assistant Markdown register their own sources; conversation navigation never treats a raw whole-thread ordinal as a rendered DOM ordinal. Markdown mapping reuses the shared GFM parser and projects visible leaf text. Milkdown verifies that projection against its ProseMirror document, maps text offsets through model positions into DOM positions, and verifies the resulting Range without changing the editor selection; node-view chrome cannot shift the address of a later paragraph. Other registered text surfaces require an exact DOM projection. Hidden link destinations, syntax, changed sources, ambiguous identical segments, and unsupported rendering transformations report unavailable rather than choosing another occurrence or switching to source mode. Note and Office Markdown use the same registration path. Sandboxed Web and media previews have no source-position bridge and therefore report unavailable for exact content navigation. + +The scheduler waits for both asynchronous content and a laid-out Range. Scrolling uses the actual Range coordinates in each nested scrollable or clipping ancestor, not the height of its parent paragraph, and rechecks the viewport intersection before reporting success. Zero-size or still-clipped targets remain pending until layout is ready or the localized timeout notification fires. + +Source hashing uses Web Crypto when available and a lazy-loaded SHA-256 implementation otherwise, preserving the server digest on supported ordinary-HTTP deployments. Original-span validation uses the shared whole-source lowercase matcher, including contextual Unicode mappings and complete-character expansion boundaries. Bounded Space `SEARCH` responses reuse the canonical Canvas match schema instead of duplicating the wire shape. + +The literal matcher advances by the full folded query length only after accepting a complete-source-boundary match. Rejected expansion-boundary candidates advance by one folded UTF-16 unit so a later valid candidate is not skipped; accepted matches remain non-overlapping. + +When a Note is already in raw Markdown mode, CodeMirror registers the unprojected source and a preview-local indexed adapter. Matching and counters use the complete editor document, including virtualized content. Navigation requests the exact source position through CodeMirror's scroll effect, then resolves and verifies the rendered Range without changing selection, content, focus, or editing mode. Markdown syntax and link URLs are visible matches in this mode; their unavailable behavior in rendered Markdown does not apply. + +PDF search handoff waits for its indexed adapter to register with the current query identity rather than treating a pending or previous-query adapter as a DOM-only renderer. The adapter remains non-navigable until the document has loaded and text indexing has settled. Extracted pages and processed-page progress publish atomically, so readiness cannot expose a partial match list and consume a pending ordinal too early. Canvas source addresses are accepted only when their source hash and the complete whitespace-normalized Markdown projection agree with the PDF text index; otherwise exact navigation reports unavailable. Indexed page offsets map across text-layer spans, including inferred whitespace, and the adapter acknowledges completion only after the actual Range is visible. Out-of-range requests never clamp, and missing page occurrences never fall back to the first match. + +Terminal PDF source/load errors settle the matching handoff immediately with the localized unavailable notification. Failures belong to their source, so a replacement source does not inherit an old terminal error. Adapter registrations expose a current-registration check; retired snapshots cannot process or complete a handoff while a replacement registration is propagating through React context. Waiting for adapter registration or indexing is also bounded by an eight-second readiness watchdog; unregistered or indefinitely loading adapters cannot leave the request pending forever. + +Question `content` matches carry ordinals local to the plain-text node body, not the complete conversation. After revealing history, navigation scopes these ordinals to the unique user prompt whose complete trimmed content equals that body; prompt text has a dedicated DOM marker excluding quoted comments, skills, interpretation text, and attachments. Missing or ambiguous source prompts use the existing unavailable-match notification rather than selecting a different message. Conversation-tier matches use source identities and offsets instead. + +DOM search handoffs wait for the requested occurrence instead of accepting the first match while asynchronous Markdown is still mounting. A missing requested occurrence uses the existing timeout notification rather than silently navigating to a different occurrence. + +Preview find counters and Next/Previous use the complete searchable occurrence count. The shared text walker counts matches without allocating a Range for each one; the highlight layer independently caps painted ranges at 500 per preview surface. A result beyond that cap still shows its correct ordinal and navigates across the full result set; its active range replaces the last painted range so the destination remains highlighted without increasing the cap. + +The shared CSS Custom Highlight registration merges ranges owned by all active search surfaces. Clearing or unmounting an inactive find bar removes only its own ranges, so a Canvas search or another preview cannot accidentally erase the current document's highlights. Every scrollable Preview renderer uses a runtime cache: Chat keys by conversation-owner Canvas plus `threadId`, while ordinary nodes key by target Canvas plus `nodeId`. Chat remembers an intentional reading message and viewport offset without overwriting them on programmatic bottom navigation. When available on activation, Return to previous reading position reveals the cached target and restores it best effort, outside the activation path. An unavailable target is reported explicitly; this action does not fetch history solely for recovery or persist across reload. Live-to-history ID replacement can make an old bookmark unavailable. Prepending earlier content anchors the first visible message and compensates for bounded late resizing unless user input takes control. Note, PDF, Office, and Web reader renderers keep automatic offset restoration through the shared hook because the outer panel is intentionally non-scrolling. Hidden retained tabs ignore Activity-driven scroll events and Effect cleanup. The cache removes an offset after the final target is closed, replaced, or invalidated, and clears unreferenced offsets when a Canvas layout is deleted or evicted from the persisted MRU index. diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index 0a08e7cc7..fd28c91a1 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -3,7 +3,7 @@ > Structure, dependency rules, and conventions for the frontend. The point of > this doc is the **layering rules** below — not an exhaustive file listing > (those rot fast; `ls` the dir for the current files). -> Last updated: 2026-10-09 +> Last updated: 2026-10-20 --- @@ -67,12 +67,32 @@ What stays in `apps/web/src/handler/canvasCommand/`: ## 4. Conventions +Common DropdownMenu action lists expose a labelled `menu` linked to their trigger. Opening focuses the first enabled item; ArrowDown/ArrowUp on the trigger open at the first/last enabled item, and ArrowDown/ArrowUp/Home/End inside the menu move focus while skipping disabled items. Tab dismisses without blocking native traversal, Escape returns focus to the trigger, and ArrowRight/ArrowLeft enter/leave submenus. Hover-opened submenus do not steal focus. Menu items retain native button/link activation. Form and layout panels declare `contentRole="dialog"` so text editing and ordinary tab order remain native; consumers that already own their menu/dialog semantics and keyboard navigation declare `contentRole="presentation"` to avoid nested menus or duplicate handlers. Popover forwards the root DOM semantics and keyboard handler without adding layout wrappers. + +Focusing the first or last enabled menu item also scrolls it into view, including ArrowUp opening a long, scrollable menu at its last option. + +CanvasZoomMenu owns a mixed input/action dialog rather than an arrow-only action menu. Its five action items explicitly retain native Tab stops; Tab traverses enabled controls and the dialog dismisses at its first/last boundary. + +On submenu triggers, ArrowDown and ArrowUp continue moving through the parent menu rather than opening the child. ArrowRight enters the child and focuses its first enabled item, including when hover has already opened it without moving focus. + +Canvas search synchronizes its active row when a result receives DOM focus. Arrow navigation from a result moves focus to the newly active row after Virtuoso reveals it, so Enter activates the same row that has focus. Navigation from the search input retains input focus and uses the active row for confirmation; browsing continues to reveal Canvas nodes without opening Preview tabs until explicit activation. + +Virtualized search navigation captures result-focus ownership before scrolling. When the focused row is unmounted, completion restores focus to the destination only if focus fell to the document body; it does not override focus deliberately moved to another control, and superseded scroll completions cannot restore stale focus. + +Canvas search groups matches by node and then by field in first-seen order. Each field has one localized heading above its contiguous matches, rather than a repeated label on every match. Field headings are not extra keyboard stops; each snippet remains independently focusable and activatable, with its field included in its accessible name. Node counts continue to count matches rather than fields. + +Canvas search uses the shared `search` translation keys for empty, streaming, truncated-result, and conversation-match timeout messages. Result-field identifiers remain unchanged in the wire data and map to localized display labels for title, summary, keywords, content, and conversation. Switching language updates result labels and status messages without resetting the search. + Common Select menus use Popover's element-anchored Floating UI positioning instead of snapshot coordinates. The panel remains hidden until positioning completes, follows its trigger during scrolling and layout changes, and flips to the opposite side when space is insufficient rather than clamping over the trigger. Opening refresh callbacks run in the event handler, not a React state updater, so Strict Mode does not duplicate discovery requests. Agent Settings retain the loaded Profile catalogue during refresh; only the initial load replaces it with a loading row, preventing selector-trigger layout shifts when opening the Utility Agent menu. The chat input surface uses 12px top and horizontal padding and 8px bottom padding to keep the selector/send row compact while aligning the source area and textarea to the same content edges. The Agent selector below the input omits its decorative chevron in both editable and locked states without changing menu interaction; session selectors inside the input retain their chevrons. Lucide UI icons use the shared `--icon-stroke-width: 2` token in `index.css`. A low-specificity base-layer rule targets only `svg.lucide[stroke-width='2']`, the attribute Lucide emits by default; ordinary callers omit `strokeWidth`. Non-default `strokeWidth` props, component-specific CSS, and inline styles remain overrides, and non-Lucide SVG artwork is unaffected. To explicitly retain Lucide's original width of 2 independently of future token changes, use `style={{ strokeWidth: 2 }}` because its DOM attribute is indistinguishable from the library default. Shared menus inherit this default rather than forcing a separate stroke width. Icon sizes and semantic foreground/hover colors remain owned by each surface. +Canvas toolbar icon strokes intentionally use two visual tiers: the main toolbar uses the shared 2px stroke, while node and selection context toolbars use the lighter 1.65px stroke defined in `NodeToolbar.css`. This is a deliberate hierarchy, not a consistency defect. Text-format controls retain their explicit 2px normal and 2.25px pressed strokes, and custom line-style previews retain strokes that illustrate the underlying content. + +Main Canvas navigation and node-placement controls expose `aria-pressed` from the same active-tool or pending-placement state that drives their visual highlight. SplitSelect accepts an optional `primaryPressed` state for its primary action without changing the menu trigger's disclosure semantics; ordinary SplitSelect consumers omit it. Touch and pen Sketch mode controls retain their existing draw/erase pressed states. + The Space-menu chevron and Layers disclosure icons in the Canvas header share 16px dimensions, the shared Lucide stroke width, and the existing `text-fg-subtle` theme color in both light and dark modes; hovering a control uses `fg-default`. Layers uses PanelLeft to expand and PanelLeftClose to collapse. No additional color token is introduced, and the existing text hierarchy and other icon surfaces are unchanged. Layers tree items and Canvas search-result headers share `LayerNodeIcon`: 14px canonical node icons, Office format-specific icons, Sketch stroke thumbnails (or Pencil for empty sketches), and Question lifecycle status dots. Search icons subscribe to their live Canvas node so format, strokes, status, and viewed updates appear without rerunning the query. Missing live nodes retain the generic search-result type icon; edge results always use Spline rather than a node icon. Lucide icons use the shared stroke width without local overrides; Sketch thumbnails retain their independent stroke-preview rendering. diff --git a/packages/shared/src/canvas-engine/index.ts b/packages/shared/src/canvas-engine/index.ts index 66e41099a..92bacf626 100644 --- a/packages/shared/src/canvas-engine/index.ts +++ b/packages/shared/src/canvas-engine/index.ts @@ -141,7 +141,7 @@ export { type FrameResponsiveMetrics, type FrameResponsiveTier, } from './frame/design.js'; -export { stripMarkdown } from './utils/markdown.js'; +export { stripMarkdown, markdownSearchTarget } from './utils/markdown.js'; export { structuredFrameResizeScale } from './frame/resize.js'; export { normalizeMathDelimiters } from './provenance/normalizeMathDelimiters.js'; export { diff --git a/packages/shared/src/canvas-engine/utils/markdown.test.ts b/packages/shared/src/canvas-engine/utils/markdown.test.ts index 803271430..a4856474a 100644 --- a/packages/shared/src/canvas-engine/utils/markdown.test.ts +++ b/packages/shared/src/canvas-engine/utils/markdown.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; -import { stripMarkdown } from './markdown.js'; +import { stripMarkdown, markdownSearchTarget } from './markdown.js'; describe('stripMarkdown', () => { it.each([ @@ -46,4 +46,45 @@ describe('stripMarkdown', () => { ])('projects %j without corrupting its content', (input, expected) => { expect(stripMarkdown(input)).toBe(expected); }); + + describe('markdownSearchTarget', () => { + it('addresses visible text without counting hidden link destinations', () => { + const source = + '[needle](https://example.test/needle)\n\nParagraph needle'; + expect( + markdownSearchTarget(source, source.indexOf('needle', 10), 6), + ).toBeNull(); + expect( + markdownSearchTarget(source, source.lastIndexOf('needle'), 6), + ).toEqual({ + text: 'needleParagraph needle', + start: 16, + end: 22, + }); + }); + + it.each([ + ['```needle\nneedle\n```', 3], + ['`needle`', 0], + ['![needle](image.png)', 2], + ['[label][ref]\n\n[ref]: https://needle.test', 27], + ['an & entity', 4], + ])( + 'does not treat hidden or transformed source syntax as visible: %s', + (source, offset) => { + expect(markdownSearchTarget(source, offset, 6)).toBeNull(); + }, + ); + + it('distinguishes a code fence language from its literal body', () => { + const source = '```needle\nneedle\n```'; + expect( + markdownSearchTarget(source, source.lastIndexOf('needle'), 6), + ).toEqual({ + text: 'needle', + start: 0, + end: 6, + }); + }); + }); }); diff --git a/packages/shared/src/canvas-engine/utils/markdown.ts b/packages/shared/src/canvas-engine/utils/markdown.ts index 273590018..6588d5c1c 100644 --- a/packages/shared/src/canvas-engine/utils/markdown.ts +++ b/packages/shared/src/canvas-engine/utils/markdown.ts @@ -11,7 +11,10 @@ interface MarkdownNode { alt?: string | null; checked?: boolean | null; children?: MarkdownNode[]; - position?: { start: { line: number }; end: { line: number } }; + position?: { + start: { line: number; offset?: number }; + end: { line: number; offset?: number }; + }; } const BLOCK_CONTAINERS = new Set(['root', 'blockquote', 'list', 'listItem']); @@ -71,3 +74,55 @@ export function stripMarkdown(input: string): string { }), ).trim(); } + +/** + * Resolve a literal source match into the parser's visible text projection. + * Hidden syntax and transformations without an exact source mapping fail closed. + */ +export function markdownSearchTarget( + source: string, + offset: number, + length: number, +): { text: string; start: number; end: number } | null { + const tree: MarkdownNode = fromMarkdown(source, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }); + let text = ''; + let start: number | undefined; + const visit = (node: MarkdownNode): void => { + if (['definition', 'html', 'image', 'imageReference'].includes(node.type)) + return; + if (node.value !== undefined) { + const from = node.position?.start.offset; + const to = node.position?.end.offset; + if (from !== undefined && to !== undefined && node.value) { + const raw = source.slice(from, to); + let contentOffset = raw === node.value ? 0 : -1; + if (node.type === 'inlineCode') { + const delimiter = raw.match(/^`+/)?.[0].length ?? 0; + if (raw.slice(delimiter, -delimiter) === node.value) + contentOffset = delimiter; + } else if (node.type === 'code' && /^(?:`{3,}|~{3,})/.test(raw)) { + const bodyStart = raw.indexOf('\n') + 1; + if ( + raw.slice(bodyStart, bodyStart + node.value.length) === node.value + ) + contentOffset = bodyStart; + } + const relative = offset - from - contentOffset; + if ( + contentOffset >= 0 && + relative >= 0 && + relative + length <= node.value.length + ) + start = text.length + relative; + } + text += node.value; + return; + } + for (const child of node.children ?? []) visit(child); + }; + visit(tree); + return start === undefined ? null : { text, start, end: start + length }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index db3668103..a32c8d30c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,7 @@ export * from './types/api/index.js'; export * from './types/agent/index.js'; export * from './utils/id.js'; export * from './utils/chat-input.js'; +export * from './utils/text-search.js'; export * from './utils/agent-icon.js'; export * from './utils/spatial/index.js'; export * from './utils/artifact-url.js'; diff --git a/packages/shared/src/types/api/canvas-search.ts b/packages/shared/src/types/api/canvas-search.ts index 95095b972..5b315fc51 100644 --- a/packages/shared/src/types/api/canvas-search.ts +++ b/packages/shared/src/types/api/canvas-search.ts @@ -106,7 +106,17 @@ export type CanvasSearchRequest = z.infer; * untruncated value should read it from a dedicated field instead * (e.g. the per-match `label` for node titles). */ -export interface CanvasSearchMatch { +export const canvasSearchSourceSchema = z.object({ + /** SHA-256 of the exact source segment, not its rendered text. */ + hash: z.string().regex(/^[a-f0-9]{64}$/), + /** UTF-16 offset within that source segment. */ + offset: z.number().int().nonnegative(), + /** Original-source length when Unicode case folding changes UTF-16 length. */ + length: z.number().int().positive().optional(), +}); +export type CanvasSearchSource = z.infer; + +export const canvasSearchMatchSchema = z.object({ /** * What kind of canvas entity this match belongs to. * @@ -121,21 +131,21 @@ export interface CanvasSearchMatch { * live in `sourceNodeId` / `targetNodeId` so the client can * `fitView` on both ends of the edge. */ - kind?: 'node' | 'edge'; - nodeId: string; - nodeType: string; + kind: z.enum(['node', 'edge']).optional(), + nodeId: z.string(), + nodeType: z.string(), /** The node's display label at scan time (handy for result rows). */ - label: string | null; - field: SearchField; - snippet: string; + label: z.string().nullable(), + field: searchFieldSchema, + snippet: z.string(), /** Match start offset within `snippet` (not within the original field). */ - matchStart: number; - matchLength: number; + matchStart: z.number().int().nonnegative(), + matchLength: z.number().int().positive(), /** * 0-based ordinal of this hit among all hits in the same * `(nodeId, field)`. Server-stamped at emission time so the client - * can address the n-th occurrence inside a preview without having - * to count `matchStart` values itself — which goes wrong as soon + * can order results without counting snippet-relative `matchStart` + * values itself — which goes wrong as soon * as results truncate (global `limit` hits before this node's * earlier hits ship) or stream out of order (a later-stream-arriving * hit with a smaller `matchStart` would otherwise retroactively @@ -145,12 +155,14 @@ export interface CanvasSearchMatch { * for this `(nodeId, field)` pair — i.e. it survives truncation and * is monotonically increasing per (nodeId, field). */ - occurrenceIndex: number; + occurrenceIndex: z.number().int().nonnegative(), + source: canvasSearchSourceSchema.optional(), /** Edge-only — source endpoint node id. Set iff `kind === 'edge'`. */ - sourceNodeId?: string; + sourceNodeId: z.string().optional(), /** Edge-only — target endpoint node id. Set iff `kind === 'edge'`. */ - targetNodeId?: string; -} + targetNodeId: z.string().optional(), +}); +export type CanvasSearchMatch = z.infer; /** NDJSON frame types emitted by the server. */ export type CanvasSearchEvent = diff --git a/packages/shared/src/types/api/space-operations.test.ts b/packages/shared/src/types/api/space-operations.test.ts index f976e99ef..31ecc6827 100644 --- a/packages/shared/src/types/api/space-operations.test.ts +++ b/packages/shared/src/types/api/space-operations.test.ts @@ -17,6 +17,47 @@ import { CANVAS_NODE_TYPES, } from '../canvas/index.js'; +it('shares Canvas source addresses with strict Space SEARCH responses', () => { + const match = { + nodeId: 'note', + nodeType: 'note', + label: 'Note', + field: 'content', + snippet: 'needle', + matchStart: 0, + matchLength: 6, + occurrenceIndex: 0, + source: { hash: 'a'.repeat(64), offset: 10, length: 6 }, + }; + const response = (value: object) => ({ + type: 'SEARCH', + result: { + count: 1, + truncated: false, + matches: [{ tier: 'content', match: value }], + }, + }); + expect(spaceQueryResponseSchema.parse(response(match))).toEqual( + response(match), + ); + expect( + spaceQueryResponseSchema.safeParse( + response({ + ...match, + source: { ...match.source, offset: -1 }, + }), + ).success, + ).toBe(false); + expect( + spaceQueryResponseSchema.safeParse( + response({ + ...match, + unexpected: true, + }), + ).success, + ).toBe(false); +}); + describe('agentCanvasCommandSchema', () => { it('rejects the retired Pin command at both agent boundaries', () => { const command = { diff --git a/packages/shared/src/types/api/space-operations.ts b/packages/shared/src/types/api/space-operations.ts index f1cdcecfe..1f8497a26 100644 --- a/packages/shared/src/types/api/space-operations.ts +++ b/packages/shared/src/types/api/space-operations.ts @@ -11,7 +11,10 @@ import { z } from 'zod'; -import { canvasSearchRequestSchema } from './canvas-search.js'; +import { + canvasSearchMatchSchema, + canvasSearchRequestSchema, +} from './canvas-search.js'; import { ACCENT_PALETTE, AGENT_CANVAS_COMMAND_TYPES, @@ -852,21 +855,7 @@ const inspectEdgesResultSchema = z }) .strict(); -const searchMatchSchema = z - .object({ - kind: z.enum(['node', 'edge']).optional(), - nodeId: z.string(), - nodeType: z.string(), - label: z.string().nullable(), - field: z.enum(['label', 'summary', 'keywords', 'content', 'conversation']), - snippet: z.string(), - matchStart: z.number().int().nonnegative(), - matchLength: z.number().int().nonnegative(), - occurrenceIndex: z.number().int().nonnegative(), - sourceNodeId: z.string().optional(), - targetNodeId: z.string().optional(), - }) - .strict(); +const searchMatchSchema = canvasSearchMatchSchema.strict(); const searchResultSchema = z .object({ diff --git a/packages/shared/src/utils/text-search.test.ts b/packages/shared/src/utils/text-search.test.ts new file mode 100644 index 000000000..1dff903f5 --- /dev/null +++ b/packages/shared/src/utils/text-search.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { literalTextMatches } from './text-search.js'; + +describe('literalTextMatches', () => { + it.each([ + [ + 'Needle NEEDLE', + 'needle', + [ + { start: 0, end: 6 }, + { start: 7, end: 13 }, + ], + ], + [ + 'aaaa', + 'aa', + [ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + ], + ], + ['\u0130 needle', 'needle', [{ start: 2, end: 8 }]], + ['\u0130 needle', 'i\u0307', [{ start: 0, end: 1 }]], + ['\u0130', 'i', []], + ['\u0130', '\u0307', []], + ['\u0130\u0130i', 'i\u0307i', [{ start: 1, end: 3 }]], + [ + '\u0130\u0130i \u0130\u0130i', + 'i\u0307i', + [ + { start: 1, end: 3 }, + { start: 5, end: 7 }, + ], + ], + ['ΟΣ', 'ς', [{ start: 1, end: 2 }]], + ['\u0130 ΟΣ', 'ς', [{ start: 3, end: 4 }]], + ['ΟΣ!', 'ς', [{ start: 1, end: 2 }]], + ['\ud83d\ude00 needle', 'needle', [{ start: 3, end: 9 }]], + ['text', '', []], + ] as const)('keeps source offsets for %j / %j', (text, query, expected) => { + expect([...literalTextMatches(text, query)]).toEqual(expected); + }); +}); diff --git a/packages/shared/src/utils/text-search.ts b/packages/shared/src/utils/text-search.ts new file mode 100644 index 000000000..449530ba0 --- /dev/null +++ b/packages/shared/src/utils/text-search.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** Literal, non-overlapping case-insensitive matches in original UTF-16 offsets. */ +export function* literalTextMatches( + text: string, + query: string, +): Generator<{ start: number; end: number }> { + const needle = query.toLowerCase(); + if (!needle) return; + const lower = text.toLowerCase(); + let offsets: number[] | undefined; + if (lower.length !== text.length) { + offsets = []; + let sourceOffset = 0; + for (const character of text) { + for (let index = 0; index < character.toLowerCase().length; index++) + offsets.push(sourceOffset); + sourceOffset += character.length; + } + offsets.push(text.length); + } + let from = 0; + while (true) { + const index = lower.indexOf(needle, from); + if (index < 0) return; + const start = offsets?.[index] ?? index; + const end = offsets?.[index + needle.length] ?? index + needle.length; + const accepted = + !offsets || + ((index === 0 || offsets[index - 1] !== start) && + offsets[index + needle.length - 1] !== end); + if (accepted) yield { start, end }; + from = index + (accepted ? needle.length : 1); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5008f7278..bd647488b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -386,6 +386,9 @@ importers: '@milkdown/utils': specifier: ^7.21.1 version: 7.21.2 + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@xyflow/react': specifier: ^12.10.0 version: 12.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)