diff --git a/packages/diffs/src/editor/css.ts b/packages/diffs/src/editor/css.ts index f12cf2ef0..4f4fcaf52 100644 --- a/packages/diffs/src/editor/css.ts +++ b/packages/diffs/src/editor/css.ts @@ -7,9 +7,9 @@ export const editorCSS: string = /* CSS */ ` 50% { opacity: 0; } 100% { opacity: 1; } } - :host, /* for jump anchor */ + :host, /* for scroll anchor */ [data-code], /* for editor overlay */ - [data-content] /* for wrap line */ + [data-content] /* for wrap line measurement */ { position: relative; } @@ -18,13 +18,16 @@ export const editorCSS: string = /* CSS */ ` caret-color: var(--diffs-bg-caret); outline: none; } - @media (min-width: 480px) { - [data-content] { - caret-color: transparent; - } - [data-quick-edit] { - caret-color: currentColor; - } + [data-column-number] { + color: var(--diffs-editor-line-number-fg); + } + [data-column-number]:is([data-selected-line]), + [data-gutter-buffer]:is([data-selected-line]) { + background-color: var(--diffs-editor-line-number-active-bg); + color: var(--diffs-editor-line-number-active-fg); + } + [data-column-number]:is([data-active]) { + color: var(--diffs-editor-line-number-active-fg); } [data-line] { cursor: text; @@ -38,32 +41,25 @@ export const editorCSS: string = /* CSS */ ` [data-line-annotation]:is([data-selected-line]) { background-color: var(--diffs-editor-line-highlight-bg); } - [data-column-number] { - color: var(--diffs-editor-line-number-fg); - } - [data-column-number]:is([data-selected-line]), - [data-gutter-buffer]:is([data-selected-line]) { - background-color: var(--diffs-editor-line-number-active-bg); - color: var(--diffs-editor-line-number-active-fg); - } - [data-column-number]:is([data-active]) { - color: var(--diffs-editor-line-number-active-fg); - } [data-line]:is([data-line-type='change-deletion']) { background-color: var(--diffs-line-bg); -webkit-user-select: none; user-select: none; } - [data-caret], [data-selection-range] { + + [data-editor-overlay] { + display: contents; + } + [data-caret], [data-selection-range], [data-match-range] { position: absolute; top: 0; left: 0; + height: 1lh; line-height: var(--diffs-line-height); pointer-events: none; } [data-caret] { width: 2px; - height: 1lh; background-color: var(--diffs-bg-caret-override, var(--diffs-editor-cursor-fg, light-dark( color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg)), @@ -75,7 +71,6 @@ export const editorCSS: string = /* CSS */ ` visibility: hidden; } [data-selection-range] { - height: 1lh; z-index: -10; background-color: var(--diffs-editor-selection-bg); } @@ -84,6 +79,17 @@ export const editorCSS: string = /* CSS */ ` height: 100%; background-color: var(--diffs-bg); } + [data-match-range] { + background-color: var(--diffs-editor-find-match-bg, + var(--diffs-editor-selection-bg) + ); + z-index: -10; + } + [data-match-range]:not([data-focus]) { + background-color: var(--diffs-editor-find-match-highlight-bg, + light-dark(#FF963288, #FF963266) + ); + } [data-rtl] { border-top-left-radius: 3px; } @@ -96,10 +102,13 @@ export const editorCSS: string = /* CSS */ ` [data-rbr] { border-bottom-right-radius: 3px; } - [data-editor-overlay] { - display: contents; - } @media (min-width: 480px) { + [data-content] { + caret-color: transparent; + } + [data-quick-edit] { + caret-color: currentColor; + } [data-content]:focus ~ [data-editor-overlay] [data-caret] { visibility: visible; } diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 4ed93814b..1a93906cd 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -24,7 +24,7 @@ import { editorCSS, editorGlobalCSS } from './css'; import { applyDocumentChangeToLineAnnotations } from './lineAnnotations'; import { isMoveCursorShortcut, isPrimaryModifier, isSafari } from './platform'; import { type QuickEditContext, QuickEditWidget } from './quickEdit'; -import { SearchPanelWidget } from './searchPanel'; +import { type MatchRange, SearchPanelWidget } from './searchPanel'; import type { EditorSelection } from './selection'; import { applyDeleteHardLineForwardToSelections, @@ -140,8 +140,10 @@ export class Editor implements DiffsEditor { #reservedSelections?: EditorSelection[]; #selections?: EditorSelection[]; #initSelections?: DiffsEditorSelection[]; + #matches?: MatchRange[]; #scrollingToLine?: number; #scrollingToLineChar?: number; + #scrollingToLineNoFocus = false; #retainSearchPanelFocus = false; #emitChange = debounce( @@ -497,7 +499,7 @@ export class Editor implements DiffsEditor { ? primarySelection.end : primarySelection.start; // fix the window selection for shift mode - this.#updateWindowSelection({ + this.#setWindowSelection({ start: pos, end: pos, direction: DirectionNone, @@ -778,15 +780,23 @@ export class Editor implements DiffsEditor { } if (this.#scrollingToLine !== undefined) { - this.#scrollToLine(this.#scrollingToLine, this.#scrollingToLineChar); + this.#scrollToLine( + this.#scrollingToLine, + this.#scrollingToLineChar, + this.#scrollingToLineNoFocus + ); this.#scrollingToLine = undefined; this.#scrollingToLineChar = undefined; - } else if (this.#selections !== undefined && this.#selections.length > 0) { + this.#scrollingToLineNoFocus = false; + } else if ( + this.#selections !== undefined && + this.#selections.length > 0 && + !this.#retainSearchPanelFocus + ) { this.focus({ preventScroll: true }); } if (this.#retainSearchPanelFocus) { - this.#retainSearchPanelFocus = false; requestAnimationFrame(() => { this.#searchPanel?.focus(); }); @@ -830,6 +840,17 @@ export class Editor implements DiffsEditor { return; } + // Native selection only tracks one range. focus() and DOM updates while + // typing mirror the primary caret there, so selectionchange must not + // overwrite multi-cursor editor state outside an active pointer gesture. + if ( + this.#selections !== undefined && + this.#selections.length > 1 && + !this.#isContentMouseDown + ) { + return; + } + const selectionRaw = document.getSelection(); const composedRange = selectionRaw?.getComposedRanges({ shadowRoots: [shadowRoot], @@ -1304,57 +1325,91 @@ export class Editor implements DiffsEditor { } #updateSelections(selections: EditorSelection[]) { - if (selections.length === 0) { - return; - } const gutterBuffer = this.#contentElement?.previousElementSibling; - const normalizedSelections = mergeOverlappingSelections(selections); - const primarySelection = normalizedSelections.at(-1)!; - this.#selections = normalizedSelections; this.#primaryCaretElement = undefined; this.#component?.setSelectedLines(null); gutterBuffer ?.querySelectorAll('[data-active]') .forEach((el) => el.removeAttribute('data-active')); - if (isCollapsedSelection(primarySelection)) { - const line = primarySelection.start.line + 1; - this.#component?.setSelectedLines({ - start: line, - end: line, - }); - } else { - if (gutterBuffer !== undefined && gutterBuffer instanceof HTMLElement) { - const pos = getCaretPosition(primarySelection); - gutterBuffer - .querySelector(`[data-column-number="${pos.line + 1}"]`) - ?.setAttribute('data-active', ''); - } + + if (selections.length === 0 && this.#matches === undefined) { + this.#selections = undefined; + this.#matches = undefined; + this.#selectionElements?.forEach((el) => el.remove()); + this.#selectionElements?.clear(); + return; } + const fragment = document.createDocumentFragment(); const renderCtx = { fragment, elements: new Map(), }; - for (const selection of normalizedSelections) { - if (!isCollapsedSelection(selection)) { - this.#renderSelection(renderCtx, selection); + + if (selections.length > 0) { + const normalizedSelections = mergeOverlappingSelections(selections); + const primarySelection = normalizedSelections.at(-1)!; + this.#selections = normalizedSelections; + if (isCollapsedSelection(primarySelection)) { + const line = primarySelection.start.line + 1; + this.#component?.setSelectedLines({ + start: line, + end: line, + }); + } else { + if (gutterBuffer !== undefined && gutterBuffer instanceof HTMLElement) { + const pos = getCaretPosition(primarySelection); + gutterBuffer + .querySelector(`[data-column-number="${pos.line + 1}"]`) + ?.setAttribute('data-active', ''); + } + } + + for (const selection of normalizedSelections) { + if (!isCollapsedSelection(selection)) { + this.#renderSelection(renderCtx, selection, 'selection'); + } + this.#renderCaret(renderCtx, selection, selection === primarySelection); + } + if ( + this.#options.enabledQuickEdit === true && + !isCollapsedSelection(primarySelection) + ) { + this.#renderQuickEditIcon(renderCtx, primarySelection); } - this.#renderCaret(renderCtx, selection, selection === primarySelection); } - if ( - this.#options.enabledQuickEdit === true && - !isCollapsedSelection(primarySelection) - ) { - this.#renderQuickEditIcon(renderCtx, primarySelection); + + const textDocument = this.#textDocument; + if (this.#matches !== undefined && textDocument !== undefined) { + const primarySelection = this.#selections?.at(-1); + const primaryStartOffset = + primarySelection !== undefined + ? textDocument.offsetAt(primarySelection.start) + : -1; + const primaryEndOffset = + primarySelection !== undefined + ? textDocument.offsetAt(primarySelection.end) + : -1; + for (const [startOffset, endOffset] of this.#matches) { + const selection: EditorSelection = { + start: textDocument.positionAt(startOffset), + end: textDocument.positionAt(endOffset), + direction: DirectionNone, + }; + const isFocused = + primaryStartOffset === startOffset && primaryEndOffset === endOffset; + this.#renderSelection(renderCtx, selection, 'match', isFocused); + } } + this.#overlayElement?.appendChild(fragment); this.#selectionElements?.forEach((el) => el.remove()); this.#selectionElements?.clear(); this.#selectionElements = renderCtx.elements; } - // update window native selection to match the selection - #updateWindowSelection(selection: EditorSelection) { + // set window native selection to match the selection + #setWindowSelection(selection: EditorSelection) { const winSelection = window.getSelection(); if (winSelection === null) { return; @@ -1399,7 +1454,7 @@ export class Editor implements DiffsEditor { #focus(position?: Position, preventScroll = true) { if (position !== undefined) { this.#shouldIgnoreSelectionChange = true; - this.#updateWindowSelection({ + this.#setWindowSelection({ start: position, end: position, direction: DirectionNone, @@ -1417,7 +1472,7 @@ export class Editor implements DiffsEditor { } } - #scrollToPrimaryCaret() { + #scrollToPrimaryCaret(noFocus = false) { const primaryCaretElement = this.#primaryCaretElement; const primarySelection = this.#selections?.at(-1); if (primarySelection === undefined) { @@ -1428,14 +1483,16 @@ export class Editor implements DiffsEditor { block: 'nearest', inline: 'nearest', }); - this.#focus( - primarySelection.direction === DirectionBackward - ? primarySelection.end - : primarySelection.start - ); + if (!noFocus) { + this.#focus( + primarySelection.direction === DirectionBackward + ? primarySelection.end + : primarySelection.start + ); + } } else { const pos = getCaretPosition(primarySelection); - this.#scrollToLine(pos.line, pos.character); + this.#scrollToLine(pos.line, pos.character, noFocus); } } @@ -1449,7 +1506,7 @@ export class Editor implements DiffsEditor { return `${componentTop + top}px ${end}px 0 ${start}px`; } - #scrollToLine(line: number, char = 0) { + #scrollToLine(line: number, char = 0, noFocus = false) { const virtualCaret = h('div', { style: { position: 'absolute', @@ -1466,7 +1523,9 @@ export class Editor implements DiffsEditor { virtualCaret.style.left = left + 'px'; this.#overlayElement?.appendChild(virtualCaret); virtualCaret.scrollIntoView({ block: 'center', inline: 'nearest' }); - this.#focus({ line, character: char }); + if (!noFocus) { + this.#focus({ line, character: char }); + } requestAnimationFrame(() => virtualCaret.remove()); } // if the line is not rendered yet(virtualized), @@ -1483,6 +1542,7 @@ export class Editor implements DiffsEditor { this.#componentContainer?.shadowRoot?.appendChild(virtualCaret); this.#scrollingToLine = line; this.#scrollingToLineChar = char; + this.#scrollingToLineNoFocus = noFocus; virtualCaret.scrollIntoView({ block: 'center', inline: 'nearest' }); requestAnimationFrame(() => virtualCaret.remove()); } @@ -1493,7 +1553,9 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment; elements: Map; }, - selection: EditorSelection + selection: EditorSelection, + type: 'selection' | 'match', + isFocused?: boolean ) { if (this.#textDocument === undefined) { return; @@ -1521,7 +1583,9 @@ export class Editor implements DiffsEditor { lineText, startChar, endChar, - isLastLine + isLastLine, + type, + isFocused ); continue; } @@ -1542,7 +1606,15 @@ export class Editor implements DiffsEditor { left + (isLastLine ? 0 : this.#metrics.ch); } - this.#renderSelectionLine(renderCtx, line, 0, left, width); + this.#renderSelectionBlock( + renderCtx, + line, + 0, + left, + width, + type, + isFocused + ); } } @@ -1561,7 +1633,9 @@ export class Editor implements DiffsEditor { lineText: string, startChar: number, endChar: number, - isLastLine: boolean + isLastLine: boolean, + type: 'selection' | 'match', + isFocused: boolean = false ) { const wrapOffsets = this.#wrapLineText(line); const segmentCount = wrapOffsets.length - 1; @@ -1611,17 +1685,20 @@ export class Editor implements DiffsEditor { } } - this.#renderSelectionLine( + this.#renderSelectionBlock( renderCtx, line, wrapLine, segmentLeft, - segmentWidth + segmentWidth, + type, + isFocused ); } } - #renderSelectionLine( + // Render one selection block for a single visual line. + #renderSelectionBlock( renderCtx: { fragment: DocumentFragment; elements: Map; @@ -1636,7 +1713,9 @@ export class Editor implements DiffsEditor { line: number, wrapLine: number, left: number, - width: number + width: number, + type: 'selection' | 'match', + isFocused: boolean = false ) { if (width === 0) { return; @@ -1645,10 +1724,11 @@ export class Editor implements DiffsEditor { const { ch, lineHeight } = this.#metrics; const y = this.#getLineY(line) + wrapLine * lineHeight; const css = `width:${width}px;transform:translateX(${left}px) translateY(${y}px);`; - const cacheKey = `selection-line-${left}-${y}-${width}`; + const cacheKey = `${type}-block-${left}-${y}-${width}-${isFocused ? 'f' : ''}`; const selectionEls = this.#selectionElements; - const rounded = this.#options.roundedSelection ?? true; + const rounded = + (this.#options.roundedSelection ?? true) && type === 'selection'; const addRoundedCorner = ( line: number, wrapLine: number, @@ -1661,7 +1741,7 @@ export class Editor implements DiffsEditor { selectionCorner: '', [radius]: '', }; - const cacheKeyPrefix = `selection-line-${left}-${top}-1ch`; + const cacheKeyPrefix = `${type}-block-${left}-${top}-1ch`; let cacheKey = cacheKeyPrefix + '-' + radius; if (radius === 'rbl') { const prevCornerKey = cacheKeyPrefix + '-rtl'; @@ -1766,11 +1846,14 @@ export class Editor implements DiffsEditor { rangeEl = h( 'div', { - dataset: 'selectionRange', + dataset: type + 'Range', style: { cssText: css }, }, renderCtx.fragment ); + if (type === 'match' && isFocused === true) { + rangeEl.dataset.focus = ''; + } } if (rounded) { @@ -1928,6 +2011,7 @@ export class Editor implements DiffsEditor { const textDocument = this.#textDocument; const preElement = this.#componentContainer?.shadowRoot?.querySelector('pre'); + const selections = this.#selections; if (textDocument === undefined || preElement == null) { return; } @@ -1935,7 +2019,6 @@ export class Editor implements DiffsEditor { let defaultQuery = ''; let initialMatch: [number, number] | undefined = undefined; - const selections = this.#selections; if (selections !== undefined && selections.length > 0) { let primarySelection = selections.at(-1)!; if (isCollapsedSelection(primarySelection)) { @@ -1945,7 +2028,7 @@ export class Editor implements DiffsEditor { ); this.#updateSelections([...selections.slice(0, -1), primarySelection]); const selectionText = textDocument.getText(primarySelection); - if (!selectionText.includes('\n')) { + if (selectionText !== '' && !selectionText.includes('\n')) { defaultQuery = selectionText; initialMatch = [ textDocument.offsetAt(primarySelection.start), @@ -1955,41 +2038,62 @@ export class Editor implements DiffsEditor { } } - this.#searchPanel = new SearchPanelWidget({ + const scrollToMatch = ( + [startOffset, endOffset]: MatchRange, + retainFocus: boolean + ) => { + const nextSelection = createSelectionFromAnchorAndFocusOffsets( + textDocument, + startOffset, + endOffset + ); + this.#updateSelections([nextSelection]); + this.#scrollToPrimaryCaret(true); // scroll to the primary caret and don't focus + this.#retainSearchPanelFocus = retainFocus; + }; + + const searchPanel = new SearchPanelWidget({ textDocument, containerElement: preElement, defaultQuery, initialMatch, - getCurrentSearchRange: () => this.#selections?.at(-1), - postSearch: (kind, [startOffset, endOffset], retainFocus) => { - if ( - kind === 'findNext' || - kind === 'findPrevious' || - kind === 'replace' - ) { - const nextSelection = createSelectionFromAnchorAndFocusOffsets( - textDocument, - startOffset, - endOffset - ); - this.#updateSelections([nextSelection]); - this.#scrollToPrimaryCaret(); - if (retainFocus === true) { - this.#retainSearchPanelFocus = true; - requestAnimationFrame(() => { - this.#searchPanel?.focus(); - }); + scrollToMatch, + onUpdate: (allMatches: MatchRange[]): MatchRange | undefined => { + if (allMatches.length === 0) { + this.#matches = undefined; + this.#updateSelections(this.#selections ?? []); + return; + } + + this.#matches = allMatches; + const primarySelection = this.#selections?.at(-1); + let searchOffset = 0; + let nextMatch: MatchRange | undefined; + if (primarySelection !== undefined) { + searchOffset = textDocument.offsetAt(primarySelection.start); + } + for (const m of allMatches) { + if (m[0] >= searchOffset) { + nextMatch = m; + break; } - } else if (kind === 'findAll' || kind === 'replaceAll') { - const { line, character } = textDocument.positionAt(startOffset); - this.#scrollToLine(line, character); } + if (nextMatch !== undefined) { + scrollToMatch(nextMatch, true); + } + this.#matches = allMatches; + this.#updateSelections(this.#selections ?? []); + return nextMatch; }, onClose: () => { this.#searchPanel = undefined; this.#retainSearchPanelFocus = false; + this.#matches = undefined; + this.#updateSelections(this.#selections ?? []); }, }); + + this.#searchPanel = searchPanel; this.#retainSearchPanelFocus = false; } diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 06b534a37..bf9a727ba 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -237,14 +237,11 @@ export class PieceTable { return foundOffset ?? wrappedOffset; } - search( - kind: 'findNext' | 'findPrevious' | 'findAll' | 'replace' | 'replaceAll', - searchParams: SearchParams, - range?: Range - ): [start: number, end: number][] { + search(searchParams: SearchParams): [start: number, end: number][] { if (searchParams.text.length === 0 || this.#length === 0) { return []; } + // Search currently operates line-by-line, so newline-spanning patterns are unsupported. if ( searchParams.text.includes('\n') || @@ -267,50 +264,11 @@ export class PieceTable { return []; } - const matches = this.#collectSearchMatchesLineByLine( + return this.#collectSearchMatchesLineByLine( pattern, searchParams.wholeWord, MAX_FIND_MATCHES ); - - if (kind === 'findAll' || kind === 'replaceAll') { - return matches; - } - - const caretOffset = - range === undefined - ? 0 - : kind === 'findPrevious' - ? this.offsetAt(range?.start) - : this.offsetAt(range?.end); - - if (kind === 'findPrevious') { - const refOffset = getSearchFindPreviousReferenceOffset(range, (p) => - this.offsetAt(p) - ); - let best: [number, number] | undefined; - for (const m of matches) { - if (m[1] <= refOffset) { - best = m; - } else { - break; - } - } - if (best !== undefined) { - return [best]; - } - const last = matches[matches.length - 1]; - return last !== undefined ? [last] : []; - } - - // findNext, replace — forward from caret with wrap - for (const m of matches) { - if (m[0] >= caretOffset) { - return [m]; - } - } - const first = matches[0]; - return first !== undefined ? [first] : []; } #collectSearchMatchesLineByLine( @@ -992,17 +950,3 @@ function advancePastEmptyMatch(text: string, index: number): number { } return index + 1; } - -// Returns the leftmost UTF-16 offset of the selection; used for find-previous -// so we skip the current match. -function getSearchFindPreviousReferenceOffset( - selection: Range | undefined, - offsetAt: (p: Position) => number -): number { - if (selection === undefined) { - return 0; - } - const a = offsetAt(selection.start); - const b = offsetAt(selection.end); - return Math.min(a, b); -} diff --git a/packages/diffs/src/editor/searchPanel.ts b/packages/diffs/src/editor/searchPanel.ts index 617f63b30..3052710ea 100644 --- a/packages/diffs/src/editor/searchPanel.ts +++ b/packages/diffs/src/editor/searchPanel.ts @@ -1,13 +1,8 @@ import { isPrimaryModifier } from './platform'; -import type { Range, TextDocument } from './textDocument'; +import type { TextDocument } from './textDocument'; import { h } from './utils'; -export type SearchKind = - | 'findNext' - | 'findPrevious' - | 'findAll' - | 'replace' - | 'replaceAll'; +export type MatchRange = [startOffset: number, endOffset: number]; export interface SearchParams { text: string; @@ -21,29 +16,16 @@ export interface SearchPanelOptions { textDocument: TextDocument; containerElement: HTMLElement; defaultQuery: string; - initialMatch?: [number, number]; - postSearch: ( - kind: SearchKind, - match: [number, number], - retainFocus?: boolean - ) => void; - getCurrentSearchRange: () => Range | undefined; + initialMatch?: MatchRange; + scrollToMatch: (nextMatch: MatchRange, retainFocus: boolean) => void; + onUpdate: (matches: MatchRange[]) => MatchRange | undefined; onClose: () => void; } export class SearchPanelWidget { - #textDocument: TextDocument; #container: HTMLDivElement; #inputElement: HTMLInputElement; - #matchesElement: HTMLDivElement; - #searchParams: SearchParams = { - text: '', - replaceText: '', - caseSensitive: false, - wholeWord: false, - regex: false, - }; - #allMatches: [number, number][] = []; + #closeSettingsPanelTimeout: ReturnType | undefined; constructor(options: SearchPanelOptions) { const { @@ -51,63 +33,125 @@ export class SearchPanelWidget { containerElement, defaultQuery, initialMatch, - postSearch, - getCurrentSearchRange, + scrollToMatch, + onUpdate, onClose, } = options; - const close = () => { - this.cleanup(); - onClose(); + const searchParams: SearchParams = { + text: defaultQuery, + replaceText: '', + caseSensitive: false, + wholeWord: false, + regex: false, }; - const updateSearchParam = ( - key: K, - value: SearchParams[K] - ) => { - this.#searchParams[key] = value; - updateAllMatches(); - this.updateMatches(); + const matches = { + all: [] as MatchRange[], + current: undefined as MatchRange | undefined, }; - const updateAllMatches = () => { - this.#allMatches = - this.#searchParams.text !== '' - ? this.#textDocument.search('findAll', this.#searchParams) - : []; + const matchResultElement = h('div', { dataset: 'matches' }); + const updateMatches = () => { + matches.all = + searchParams.text !== '' ? textDocument.search(searchParams) : []; this.#container .querySelectorAll('[data-disabled]') .forEach((element) => { - element.dataset.disabled = String(this.#allMatches.length === 0); + element.dataset.disabled = String(matches.all.length === 0); }); - }; - const search = (kind: SearchKind, retainFocus?: boolean) => { - const matches = this.#textDocument.search( - kind, - this.#searchParams, - getCurrentSearchRange() - ); - if (matches.length === 0) { + if (searchParams.text === '') { + matchResultElement.textContent = ''; + delete matchResultElement.dataset.noMatches; + return; + } + + if (matches.all.length === 0) { + matchResultElement.textContent = 'No results'; + matchResultElement.dataset.noMatches = ''; + } else { + delete matchResultElement.dataset.noMatches; + updateCurrentMatch(onUpdate(matches.all)); return; } - const firstMatch = matches[0]; - this.updateMatches(firstMatch); - postSearch(kind, firstMatch, retainFocus); + + matches.current = undefined; + onUpdate([]); + }; + + const updateCurrentMatch = (currentMatch: MatchRange | undefined) => { + if (currentMatch === undefined) { + matchResultElement.textContent = `${matches.all.length} results`; + } else { + const [start, end] = currentMatch; + const index = matches.all.findIndex( + (m) => m[0] === start && m[1] === end + ); + matchResultElement.textContent = `${index + 1} of ${matches.all.length}`; + } + matches.current = currentMatch; + }; + + const updateSearchParam = ( + key: K, + value: SearchParams[K] + ) => { + searchParams[key] = value; + updateMatches(); + }; + + const findNextMatch = ( + findPrevious: boolean = false, + retainFocus: boolean = false + ) => { + const allMatches = matches.all; + let nextMatch: MatchRange | undefined = allMatches[0]; + if (allMatches.length > 0) { + if (findPrevious) { + const searchOffset = matches.current?.[0] ?? 0; + nextMatch = allMatches.at(-1); + for (const m of allMatches) { + if (m[1] <= searchOffset) { + nextMatch = m; + } else { + break; + } + } + } else { + const searchOffset = matches.current?.[1] ?? 0; + for (const m of allMatches) { + if (m[0] >= searchOffset) { + nextMatch = m; + break; + } + } + } + } + if (nextMatch !== undefined) { + updateCurrentMatch(nextMatch); + scrollToMatch(nextMatch, retainFocus); + } + matches.current = nextMatch; + }; + + const close = () => { + this.cleanup(); + onClose(); }; const settingsSwitch = h('div', { dataset: { icon: 'settings' }, title: 'Settings', innerHTML: ` - - - - - - - - `, + + + + + + + + `, onclick: () => { settingsSwitch.replaceWith(settingsPanel); }, @@ -120,7 +164,7 @@ export class SearchPanelWidget { children: [ h('input', { type: 'checkbox', - checked: this.#searchParams.caseSensitive, + checked: searchParams.caseSensitive, onchange: (e: Event) => { updateSearchParam( 'caseSensitive', @@ -136,7 +180,7 @@ export class SearchPanelWidget { children: [ h('input', { type: 'checkbox', - checked: this.#searchParams.wholeWord, + checked: searchParams.wholeWord, onchange: (e: Event) => { updateSearchParam( 'wholeWord', @@ -152,7 +196,7 @@ export class SearchPanelWidget { children: [ h('input', { type: 'checkbox', - checked: this.#searchParams.regex, + checked: searchParams.regex, onchange: (e: Event) => { updateSearchParam( 'regex', @@ -165,30 +209,26 @@ export class SearchPanelWidget { }), ], onmouseleave: () => { - closeSettingsPanelTimeout = setTimeout(() => { + this.#closeSettingsPanelTimeout = setTimeout(() => { + this.#closeSettingsPanelTimeout = undefined; settingsPanel.replaceWith(settingsSwitch); }, 500); }, onmouseenter: () => { - clearTimeout(closeSettingsPanelTimeout); - closeSettingsPanelTimeout = undefined; + clearTimeout(this.#closeSettingsPanelTimeout); + this.#closeSettingsPanelTimeout = undefined; }, }); - let closeSettingsPanelTimeout: ReturnType | undefined; - - this.#textDocument = textDocument; - this.#searchParams.text = defaultQuery; - this.#inputElement = h('input', { type: 'text', placeholder: 'Search', dataset: 'search', value: defaultQuery, oninput: (e: Event) => { - this.#searchParams.text = (e.target as HTMLInputElement).value; - updateAllMatches(); - this.updateMatches(); + searchParams.text = (e.target as HTMLInputElement).value; + matches.current = undefined; + updateMatches(); }, onkeydown: (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -196,14 +236,14 @@ export class SearchPanelWidget { close(); } else if (e.key === 'Enter') { e.preventDefault(); - search('findNext', true); + findNextMatch(false, true); } else if (e.key === 'f' && isPrimaryModifier(e)) { // prevent the default browser search panel open behavior e.preventDefault(); } }, }); - this.#matchesElement = h('div', { dataset: 'matches' }); + this.#container = h('div', { dataset: 'searchPanel', children: [ @@ -215,20 +255,20 @@ export class SearchPanelWidget { innerHTML: ` - + `, }), this.#inputElement, - this.#matchesElement, + matchResultElement, h('div', { dataset: { icon: 'arrow-up', disabled: 'true' }, title: 'Previous', innerHTML: ` - + `, onclick: () => { - search('findPrevious'); + findNextMatch(true); }, }), h('div', { @@ -236,10 +276,10 @@ export class SearchPanelWidget { title: 'Next', innerHTML: ` - + `, onclick: () => { - search('findNext'); + findNextMatch(); }, }), h('div', { dataset: 'spacer' }), @@ -250,7 +290,7 @@ export class SearchPanelWidget { innerHTML: ` - + `, onclick: close, }), @@ -258,45 +298,29 @@ export class SearchPanelWidget { }), ], }); + + matches.current = initialMatch; containerElement.before(this.#container); requestAnimationFrame(() => { if (initialMatch !== undefined) { - updateAllMatches(); - this.updateMatches(initialMatch); + updateMatches(); + } else { + onUpdate([]); } this.#inputElement.select(); }); } - updateMatches(currentMatch: [number, number] = this.#allMatches[0]): void { - const allMatches = this.#allMatches; - const searchText = this.#searchParams.text; - - if (searchText === '') { - this.#matchesElement.textContent = ''; - delete this.#matchesElement.dataset.noMatches; - return; - } - - if (allMatches.length === 0) { - this.#matchesElement.textContent = 'No results'; - this.#matchesElement.dataset.noMatches = ''; - } else { - delete this.#matchesElement.dataset.noMatches; - const index = allMatches.findIndex( - (m) => m[0] === currentMatch[0] && m[1] === currentMatch[1] - ); - this.#matchesElement.textContent = - index !== -1 ? `${index + 1} of ${allMatches.length}` : 'No results'; - } - } - focus(): void { - this.#inputElement.select(); + this.#inputElement.focus(); } cleanup(): void { + if (this.#closeSettingsPanelTimeout !== undefined) { + clearTimeout(this.#closeSettingsPanelTimeout); + this.#closeSettingsPanelTimeout = undefined; + } this.#container.remove(); } } diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index d8ff9e87b..758300d42 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -204,12 +204,8 @@ export class TextDocument { return this.#pieceTable.findNextNonOverlappingSubstring(needle, occupied); } - search( - kind: 'findNext' | 'findPrevious' | 'findAll' | 'replace' | 'replaceAll', - searchParams: SearchParams, - selection?: Range - ): [start: number, end: number][] { - return this.#pieceTable.search(kind, searchParams, selection); + search(searchParams: SearchParams): [start: number, end: number][] { + return this.#pieceTable.search(searchParams); } applyEdits( diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index 57875c591..c6f160ce1 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -93,6 +93,9 @@ export class EditorTokenizer { const gutterForeground = colors['editorLineNumber.foreground']; const gutterActiveForeground = colors['editorLineNumber.activeForeground']; const cursorForeground = colors['editorCursor.foreground']; + const findMatchBackground = colors['editor.findMatchBackground']; + const findMatchHighlightBackground = + colors['editor.findMatchHighlightBackground']; this.#setStyle(`:host { --diffs-editor-selection-bg: ${selectionBackground ?? 'var(--diffs-line-bg)'}; --diffs-editor-line-highlight-bg: ${lineHighlightBackground ?? 'var(--diffs-line-bg)'}; @@ -100,6 +103,8 @@ export class EditorTokenizer { --diffs-editor-line-number-active-bg: ${lineHighlightBackground ?? 'var(--diffs-line-bg, var(--diffs-bg))'}; --diffs-editor-line-number-active-fg: ${gutterActiveForeground ?? 'var(--diffs-selection-number-fg)'}; ${cursorForeground !== undefined ? '--diffs-editor-cursor-fg: ' + cursorForeground : ''}; + ${findMatchBackground !== undefined ? '--diffs-editor-find-match-bg: ' + findMatchBackground : ''}; + ${findMatchHighlightBackground !== undefined ? '--diffs-editor-find-match-highlight-bg: ' + findMatchHighlightBackground : ''}; }`); }; diff --git a/packages/diffs/test/editorPieceTable.test.ts b/packages/diffs/test/editorPieceTable.test.ts index 5c2c0091a..9725a074b 100644 --- a/packages/diffs/test/editorPieceTable.test.ts +++ b/packages/diffs/test/editorPieceTable.test.ts @@ -291,8 +291,7 @@ describe('PieceTable', () => { regex: false, }; - expect(table.search('findAll', searchParams)).toEqual([]); - expect(table.search('findNext', searchParams)).toEqual([]); + expect(table.search(searchParams)).toEqual([]); }); test('search does not match literal newline regex patterns', () => { @@ -305,7 +304,7 @@ describe('PieceTable', () => { regex: true, }; - expect(table.search('findAll', searchParams)).toEqual([]); + expect(table.search(searchParams)).toEqual([]); }); test('tracks trailing newline as an empty final line', () => {