From d4c49f9686a6398ed3a4fa5b46989575e58376e5 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 31 May 2026 15:43:37 +0800 Subject: [PATCH 01/13] Update types --- packages/diffs/src/types.ts | 38 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index c72327250..3199a4980 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -888,19 +888,7 @@ export interface StickySpecs { height: number; } -export interface DiffsEditor { - syncWithRender( - highlighter: DiffsHighlighter, - fileContainer: HTMLElement, - fileContents: FileContents, - lineAnnotations: LineAnnotation[] | undefined, - renderRange: RenderRange | undefined, - editMode?: 'simple' | 'advanced' - ): void; - cleanUp(): void; -} - -export interface DiffsEditorOptions extends BaseCodeOptions { +export interface DiffsComponentOptions extends BaseCodeOptions { enableGutterUtility?: boolean; enableLineSelection?: boolean; expandUnchanged?: boolean; @@ -909,8 +897,8 @@ export interface DiffsEditorOptions extends BaseCodeOptions { export interface DiffsBaseComponent { readonly top?: number; - readonly options: DiffsEditorOptions; - setOptions: (options: Partial) => void; + readonly options: DiffsComponentOptions; + setOptions: (options: Partial) => void; setSelectedLines: (range: { start: number; end: number } | null) => void; rerender(): void; cleanUp(): void; @@ -931,10 +919,16 @@ export interface DiffsEditableComponent< ) => void; } -export interface DiffsTextDocument { - lineCount: number; - getLineText: (lineNumber: number) => string; - getText: () => string; +export interface DiffsEditor { + syncWithRender( + highlighter: DiffsHighlighter, + fileContainer: HTMLElement, + fileContents: FileContents, + lineAnnotations: LineAnnotation[] | undefined, + renderRange: RenderRange | undefined, + editMode?: 'simple' | 'advanced' + ): void; + cleanUp(): void; } export interface DiffsEditorSelection { @@ -948,3 +942,9 @@ export interface DiffsEditorSelection { }; direction: 'none' | 'backward' | 'forward'; } + +export interface DiffsTextDocument { + readonly lineCount: number; + getLineText: (lineNumber: number) => string; + getText: () => string; +} From e805f027551524e8e936104b28ffaf3b567594b3 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 31 May 2026 16:00:49 +0800 Subject: [PATCH 02/13] Fix selection range rendering --- packages/diffs/src/editor/editor.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 589b7ea30..b914af625 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -1633,12 +1633,14 @@ export class Editor implements DiffsEditor { left: number, applyEolSpacing = true ) { - const spacing = - !applyEolSpacing || - selection.end.line === ln || - (startChar === endChar && ln !== selection.start.line) - ? 0 - : this.#metrics.ch; + let spacing = 0; + if (applyEolSpacing && ln < selection.end.line && startChar !== endChar) { + spacing = this.#metrics.ch; + } + if (width === 0 && spacing === 0) { + return; + } + const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#metrics.lineHeight}px) translateX(${left}px);`; const cacheKey = 'selection-range-' + css; const selectionEls = this.#selectionElements; From 6bd8a4e2829452ed72dee63d32de2e5d1cb1676e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 31 May 2026 16:54:46 +0800 Subject: [PATCH 03/13] Fix selection range rendering --- packages/diffs/src/editor/editor.ts | 105 ++++++++++------------------ 1 file changed, 35 insertions(+), 70 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index b914af625..becd57ccb 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -1478,10 +1478,9 @@ export class Editor implements DiffsEditor { const endChar = ln === end.line ? end.character : lineText.length; if (this.#wrap) { - const paddingInline = this.#metrics.ch; // 1ch, align to diff css: padding-inline: 1ch const contentWidth = this.#getContentWidth(); const textWidth = - 2 * paddingInline + this.#metrics.measureTextWidth(lineText); + 2 * this.#metrics.ch + this.#metrics.measureTextWidth(lineText); if (textWidth > contentWidth) { this.#renderWrappedSelection( renderCtx, @@ -1489,8 +1488,7 @@ export class Editor implements DiffsEditor { ln, lineText, startChar, - endChar, - paddingInline + endChar ); continue; } @@ -1498,24 +1496,18 @@ export class Editor implements DiffsEditor { let left = 0; let width = 0; - if (startChar === endChar && startChar === 0) { + if (startChar === 0) { left = this.#getGutterWidth() + this.#metrics.ch; // gutter width + inline padding (1ch) - width = ln === end.line ? 0 : this.#metrics.ch; } else { left = this.#getCharX(ln, startChar)[0]; + } + if (startChar === endChar) { + width = ln === end.line ? 0 : this.#metrics.ch; + } else { width = endChar === startChar ? 0 : this.#getCharX(ln, endChar)[0] - left; } - this.#renderSelectionRange( - renderCtx, - selection, - ln, - 0, - startChar, - endChar, - width, - left - ); + this.#renderSelectionRange(renderCtx, ln, 0, width, left); } } @@ -1534,17 +1526,15 @@ export class Editor implements DiffsEditor { line: number, lineText: string, startChar: number, - endChar: number, - paddingInline: number + endChar: number ) { const wrapOffsets = this.#wrapLineText(line); const segmentCount = wrapOffsets.length - 1; - const lastSegmentIndex = segmentCount - 1; - const offsetLeft = this.#getGutterWidth() + paddingInline; + const offsetLeft = this.#getGutterWidth() + this.#metrics.ch; - for (let w = 0; w < segmentCount; w++) { - const segmentStart = wrapOffsets[w]; - const segmentEnd = wrapOffsets[w + 1]; + for (let wrapLine = 0; wrapLine < segmentCount; wrapLine++) { + const segmentStart = wrapOffsets[wrapLine]; + const segmentEnd = wrapOffsets[wrapLine + 1]; const wrapStartChar = Math.max(startChar, segmentStart); const wrapEndChar = Math.min(endChar, segmentEnd); @@ -1553,26 +1543,10 @@ export class Editor implements DiffsEditor { continue; } - // Zero-width slices on segment boundaries can appear on two consecutive - // segments (end of one, start of the next). Only render at the natural - // anchor positions: the very beginning of the first visual line, or the - // very end of the last visual line. - if (wrapStartChar === wrapEndChar) { - const isAtLineStart = wrapStartChar === 0 && w === 0; - const isAtLineEnd = - wrapEndChar === lineText.length && w === lastSegmentIndex; - if (!isAtLineStart && !isAtLineEnd) { - continue; - } - } - let segmentLeft: number; let segmentWidth: number; - if (wrapStartChar === 0 && wrapEndChar === 0) { - // Empty range pinned to line start (e.g. multi-line selection ending - // with end.character === 0). Mirrors the non-wrap path. + if (wrapStartChar === 0) { segmentLeft = offsetLeft; - segmentWidth = line === selection.end.line ? 0 : paddingInline; } else { const prefixInSegment = lineText.slice(segmentStart, wrapStartChar); const prefixAsciiColumns = getExpandedAsciiTextColumns( @@ -1584,32 +1558,27 @@ export class Editor implements DiffsEditor { (prefixAsciiColumns !== -1 ? prefixAsciiColumns * this.#metrics.ch : this.#metrics.measureTextWidth(prefixInSegment)); - - if (wrapStartChar === wrapEndChar) { - segmentWidth = 0; - } else { - const selectionInSegment = lineText.slice(wrapStartChar, wrapEndChar); - const selectionAsciiWidth = getExpandedAsciiTextColumns( - selectionInSegment, - this.#metrics.tabSize - ); - segmentWidth = - selectionAsciiWidth !== -1 - ? selectionAsciiWidth * this.#metrics.ch - : this.#metrics.measureTextWidth(selectionInSegment); - } + } + if (wrapStartChar === wrapEndChar) { + segmentWidth = this.#metrics.ch; + } else { + const selectionInSegment = lineText.slice(wrapStartChar, wrapEndChar); + const selectionAsciiWidth = getExpandedAsciiTextColumns( + selectionInSegment, + this.#metrics.tabSize + ); + segmentWidth = + selectionAsciiWidth !== -1 + ? selectionAsciiWidth * this.#metrics.ch + : this.#metrics.measureTextWidth(selectionInSegment); } this.#renderSelectionRange( renderCtx, - selection, line, - w, - wrapStartChar, - wrapEndChar, + wrapLine, segmentWidth, - segmentLeft, - w === lastSegmentIndex + segmentLeft ); } } @@ -1624,24 +1593,17 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment; elements: Map; }, - selection: EditorSelection, ln: number, wrapLine: number, - startChar: number, - endChar: number, width: number, left: number, - applyEolSpacing = true + _rounded = true ) { - let spacing = 0; - if (applyEolSpacing && ln < selection.end.line && startChar !== endChar) { - spacing = this.#metrics.ch; - } - if (width === 0 && spacing === 0) { + if (width === 0) { return; } - const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#metrics.lineHeight}px) translateX(${left}px);`; + const css = `width:${width}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#metrics.lineHeight}px) translateX(${left}px);`; const cacheKey = 'selection-range-' + css; const selectionEls = this.#selectionElements; @@ -1664,6 +1626,9 @@ export class Editor implements DiffsEditor { ); } + // todo: when the `_rounded` parameter is true, + // based on the collapsed state, add the rounded-top-left/right or rounded-bottom-left/right dataset to the element + renderCtx.elements.set(cacheKey, rangeEl); } From a63cd64166d808e338babbb13748b459ba0590bc Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 31 May 2026 22:52:24 +0800 Subject: [PATCH 04/13] Add `roundedSelection` option --- packages/diffs/src/editor/css.ts | 17 +++ packages/diffs/src/editor/editor.ts | 189 +++++++++++++++++++++++----- 2 files changed, 172 insertions(+), 34 deletions(-) diff --git a/packages/diffs/src/editor/css.ts b/packages/diffs/src/editor/css.ts index e6d9b4bba..33b811b88 100644 --- a/packages/diffs/src/editor/css.ts +++ b/packages/diffs/src/editor/css.ts @@ -76,6 +76,23 @@ export const editorCSS: string = /* CSS */ ` z-index: -10; background-color: var(--diffs-editor-selection-bg); } + [data-selection-corner] { + width: 100%; + height: 100%; + background-color: var(--diffs-bg); + } + [data-rtl] { + border-top-left-radius: 3px; + } + [data-rtr] { + border-top-right-radius: 3px; + } + [data-rbl] { + border-bottom-left-radius: 3px; + } + [data-rbr] { + border-bottom-right-radius: 3px; + } [data-editor-overlay] { display: contents; } diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index becd57ccb..e69551853 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -76,6 +76,7 @@ function clampDomOffset(node: Node, offset: number): number { } export interface EditorOptions { + roundedSelection?: boolean; enabledQuickEdit?: boolean; renderQuickEdit?: (context: QuickEditContext) => HTMLElement; onChange?: ( @@ -648,12 +649,12 @@ export class Editor implements DiffsEditor { ) { return; } - const lineIndex = Number(lineNumber) - 1; + const line = Number(lineNumber) - 1; const selection: EditorSelection = { - start: { line: lineIndex, character: 0 }, + start: { line, character: 0 }, end: { - line: lineIndex, - character: textDocument.getLineText(lineIndex).length, + line, + character: textDocument.getLineText(line).length, }, direction: DirectionForward, }; @@ -1468,14 +1469,15 @@ export class Editor implements DiffsEditor { } const { start, end } = selection; - for (let ln = start.line; ln <= end.line; ln++) { - if (!this.#isLineVisible(ln)) { + for (let line = start.line; line <= end.line; line++) { + if (!this.#isLineVisible(line)) { continue; } - const lineText = this.#textDocument.getLineText(ln); - const startChar = ln === start.line ? start.character : 0; - const endChar = ln === end.line ? end.character : lineText.length; + const isLastLine = line === end.line; + const lineText = this.#textDocument.getLineText(line); + const startChar = line === start.line ? start.character : 0; + const endChar = isLastLine ? end.character : lineText.length; if (this.#wrap) { const contentWidth = this.#getContentWidth(); @@ -1484,11 +1486,11 @@ export class Editor implements DiffsEditor { if (textWidth > contentWidth) { this.#renderWrappedSelection( renderCtx, - selection, - ln, + line, lineText, startChar, - endChar + endChar, + isLastLine ); continue; } @@ -1499,15 +1501,17 @@ export class Editor implements DiffsEditor { if (startChar === 0) { left = this.#getGutterWidth() + this.#metrics.ch; // gutter width + inline padding (1ch) } else { - left = this.#getCharX(ln, startChar)[0]; + left = this.#getCharX(line, startChar)[0]; } if (startChar === endChar) { - width = ln === end.line ? 0 : this.#metrics.ch; + width = isLastLine ? 0 : this.#metrics.ch; } else { width = - endChar === startChar ? 0 : this.#getCharX(ln, endChar)[0] - left; + this.#getCharX(line, endChar)[0] - + left + + (isLastLine ? 0 : this.#metrics.ch); } - this.#renderSelectionRange(renderCtx, ln, 0, width, left); + this.#renderSelectionLine(renderCtx, line, 0, left, width); } } @@ -1522,11 +1526,11 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment; elements: Map; }, - selection: EditorSelection, line: number, lineText: string, startChar: number, - endChar: number + endChar: number, + isLastLine: boolean ) { const wrapOffsets = this.#wrapLineText(line); const segmentCount = wrapOffsets.length - 1; @@ -1571,14 +1575,17 @@ export class Editor implements DiffsEditor { selectionAsciiWidth !== -1 ? selectionAsciiWidth * this.#metrics.ch : this.#metrics.measureTextWidth(selectionInSegment); + if (!isLastLine) { + segmentWidth += this.#metrics.ch; + } } - this.#renderSelectionRange( + this.#renderSelectionLine( renderCtx, line, wrapLine, - segmentWidth, - segmentLeft + segmentLeft, + segmentWidth ); } } @@ -1588,30 +1595,142 @@ export class Editor implements DiffsEditor { // appended at the end. For wrapped logical lines this must be false on every // visual segment except the last one, since an intra-line wrap is not a real // newline and shouldn't visually extend past the wrapped content. - #renderSelectionRange( + #renderSelectionLine( renderCtx: { fragment: DocumentFragment; elements: Map; + previousSelectionRange?: { + element: HTMLElement; + line: number; + wrapLine: number; + left: number; + width: number; + }; }, - ln: number, + line: number, wrapLine: number, - width: number, left: number, - _rounded = true + width: number ) { if (width === 0) { return; } - const css = `width:${width}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#metrics.lineHeight}px) translateX(${left}px);`; - const cacheKey = 'selection-range-' + css; + 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 selectionEls = this.#selectionElements; - if (renderCtx.elements.has(cacheKey)) { + const rounded = this.#options.roundedSelection ?? true; + const addRoundedCorner = ( + line: number, + left: number, + radius: 'rtl' | 'rbl' | 'rbr' + ) => { + const top = this.#getLineY(line) + wrapLine * lineHeight; + const css = `width:${ch}px;transform:translateX(${left}px) translateY(${top}px);`; + const dataset = { + selectionCorner: '', + [radius]: '', + }; + const cacheKeyPrefix = `selection-line-${left}-${top}-1ch`; + let cacheKey = cacheKeyPrefix + '-' + radius; + if (radius === 'rbl') { + const prevCornerKey = cacheKeyPrefix + '-rtl'; + const prevCorner = renderCtx.elements.get(prevCornerKey); + if (prevCorner !== undefined) { + prevCorner.remove(); + renderCtx.elements.delete(prevCornerKey); + cacheKey += '-rtl'; + dataset.rtl = ''; + } + } + let cornerEl = renderCtx.elements.get(cacheKey); + if (cornerEl !== undefined) { + return; + } + if (selectionEls?.has(cacheKey) === true) { + cornerEl = selectionEls.get(cacheKey)!; + selectionEls.delete(cacheKey); + } else { + cornerEl = h( + 'div', + { + dataset: 'selectionRange', + style: { cssText: css }, + children: [ + h('div', { + dataset: dataset, + }), + ], + }, + renderCtx.fragment + ); + } + renderCtx.elements.set(cacheKey, cornerEl); + }; + const addRadiusStyle = (element: HTMLElement) => { + const end = left + width; + const dataset = element.dataset; + const previousSelectionRange = renderCtx.previousSelectionRange; + if ( + previousSelectionRange === undefined || + previousSelectionRange.line !== line || + previousSelectionRange.wrapLine !== wrapLine + ) { + renderCtx.previousSelectionRange = { + element, + line, + wrapLine, + left, + width, + }; + } + if ( + previousSelectionRange === undefined || + end <= previousSelectionRange.left + ) { + ['rtl', 'rtr', 'rbl', 'rbr'].forEach((key) => { + dataset[key] = ''; + }); + } else { + const prevLine = previousSelectionRange.line; + const prevLeft = previousSelectionRange.left; + const prevDataset = previousSelectionRange.element.dataset; + const prevEnd = prevLeft + previousSelectionRange.width; + if (prevLeft > left) { + addRoundedCorner(prevLine, prevLeft - ch, 'rbr'); + } + delete prevDataset.rbl; + delete dataset.rtl; + delete dataset.rtr; + if (end >= prevEnd) { + delete prevDataset.rbr; + } + if (end > prevEnd) { + addRoundedCorner(prevLine, prevEnd, 'rbl'); + dataset.rtr = ''; + } + if (end < prevEnd) { + addRoundedCorner(line, end, 'rtl'); + } + if (left < prevLeft) { + dataset.rtl = ''; + } + dataset.rbl = ''; + dataset.rbr = ''; + } + }; + + let rangeEl = renderCtx.elements.get(cacheKey); + if (rangeEl !== undefined) { + if (rounded) { + addRadiusStyle(rangeEl); + } return; } - let rangeEl: HTMLElement | undefined; if (selectionEls?.has(cacheKey) === true) { rangeEl = selectionEls.get(cacheKey)!; selectionEls.delete(cacheKey); @@ -1626,9 +1745,9 @@ export class Editor implements DiffsEditor { ); } - // todo: when the `_rounded` parameter is true, - // based on the collapsed state, add the rounded-top-left/right or rounded-bottom-left/right dataset to the element - + if (rounded) { + addRadiusStyle(rangeEl); + } renderCtx.elements.set(cacheKey, rangeEl); } @@ -1645,16 +1764,18 @@ export class Editor implements DiffsEditor { return; } const [left, wrapLine] = this.#getCharX(line, character); - const cacheKey = 'caret-' + line + '(' + wrapLine + ')-' + character; + const cacheKey = 'caret-' + line + '/' + wrapLine + ':' + character; if (renderCtx.elements.has(cacheKey)) { return; } + const x = left - 1; + const y = this.#getLineY(line) + wrapLine * this.#metrics.lineHeight; const caretEl = h( 'div', { dataset: 'caret', style: { - transform: `translateY(${this.#getLineY(line) + wrapLine * this.#metrics.lineHeight}px) translateX(${left - 1}px)`, + transform: `translateX(${x}px) translateY(${y}px)`, }, }, renderCtx.fragment From 292fc8c32ba305b45f178f0aef6182881d082844 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 1 Jun 2026 09:01:55 +0800 Subject: [PATCH 05/13] Add jsDocs --- packages/diffs/src/editor/editor.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index e69551853..5889097c3 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -76,9 +76,13 @@ function clampDomOffset(node: Node, offset: number): number { } export interface EditorOptions { + /** Render rounded corners for selection ranges, default is true. */ roundedSelection?: boolean; + /** Show the clickable quick edit icon, default is disabled. */ enabledQuickEdit?: boolean; + /** Render the quick edit widget element. */ renderQuickEdit?: (context: QuickEditContext) => HTMLElement; + /** Callback when the editor document changes. */ onChange?: ( file: FileContents, lineAnnotations?: DiffLineAnnotation[] From e523b6590a50da1aa301f1b429543f40b7ec32a0 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 1 Jun 2026 14:52:44 +0800 Subject: [PATCH 06/13] Use 'editorCursor.foreground' color --- packages/diffs/src/editor/css.ts | 13 ++++++++----- packages/diffs/src/editor/tokenzier.ts | 2 ++ packages/diffs/src/style.css | 8 -------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/diffs/src/editor/css.ts b/packages/diffs/src/editor/css.ts index 33b811b88..fabd07938 100644 --- a/packages/diffs/src/editor/css.ts +++ b/packages/diffs/src/editor/css.ts @@ -1,8 +1,6 @@ -const DEBUG_SELECTION = false; - export const editorCSS: string = /* CSS */ ` ::selection { - background-color: ${DEBUG_SELECTION ? 'rgba(255, 0, 0, 0.1)' : 'transparent'}; + background-color: transparent; } @keyframes blinking { 0% { opacity: 1; } @@ -22,7 +20,7 @@ export const editorCSS: string = /* CSS */ ` } @media (min-width: 480px) { [data-content] { - caret-color: ${DEBUG_SELECTION ? 'red' : 'transparent'}; + caret-color: transparent; } [data-quick-edit] { caret-color: currentColor; @@ -66,7 +64,12 @@ export const editorCSS: string = /* CSS */ ` [data-caret] { width: 2px; height: 1lh; - background-color: ${DEBUG_SELECTION ? 'transparent' : 'var(--diffs-bg-caret)'}; + 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)), + color-mix(in lab, var(--diffs-fg) 75%, var(--diffs-bg)) + )) + ); animation: blinking 1.2s infinite; animation-delay: 0.8s; visibility: hidden; diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index 9e2d5f69b..57875c591 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -92,12 +92,14 @@ export class EditorTokenizer { const lineHighlightBackground = colors['editor.lineHighlightBackground']; const gutterForeground = colors['editorLineNumber.foreground']; const gutterActiveForeground = colors['editorLineNumber.activeForeground']; + const cursorForeground = colors['editorCursor.foreground']; this.#setStyle(`:host { --diffs-editor-selection-bg: ${selectionBackground ?? 'var(--diffs-line-bg)'}; --diffs-editor-line-highlight-bg: ${lineHighlightBackground ?? 'var(--diffs-line-bg)'}; --diffs-editor-line-number-fg: ${gutterForeground ?? 'var(--diffs-fg-number)'}; --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 : ''}; }`); }; diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css index 44cccdf0b..981c90a3a 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -132,14 +132,6 @@ var(--diffs-fg-number) ); - --diffs-bg-caret: var( - --diffs-bg-caret-override, - light-dark( - color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg)), - color-mix(in lab, var(--diffs-fg) 75%, var(--diffs-bg)) - ) - ); - --diffs-deletion-base: var( --diffs-deletion-color-override, light-dark( From b5fdf1dd201564c0ea0907f3892796beeeb14698 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 2 Jun 2026 00:07:49 +0800 Subject: [PATCH 07/13] refactor(editor): refactor search panel --- packages/diffs/src/editor/css.ts | 60 +++-- packages/diffs/src/editor/editor.ts | 258 +++++++++++++------ packages/diffs/src/editor/pieceTable.ts | 62 +---- packages/diffs/src/editor/searchPanel.ts | 236 +++++++++-------- packages/diffs/src/editor/textDocument.ts | 8 +- packages/diffs/src/editor/tokenzier.ts | 5 + packages/diffs/test/editorPieceTable.test.ts | 5 +- 7 files changed, 347 insertions(+), 287 deletions(-) diff --git a/packages/diffs/src/editor/css.ts b/packages/diffs/src/editor/css.ts index fabd07938..7253f591b 100644 --- a/packages/diffs/src/editor/css.ts +++ b/packages/diffs/src/editor/css.ts @@ -7,9 +7,12 @@ export const editorCSS: string = /* CSS */ ` 50% { opacity: 0; } 100% { opacity: 1; } } - :host, /* for jump anchor */ + [data-editor-overlay] { + display: contents; + } + :host, /* for scroll anchor */ [data-code], /* for editor overlay */ - [data-content] /* for wrap line */ + [data-content] /* for wrap line measurement */ { position: relative; } @@ -18,13 +21,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 +44,21 @@ 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-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 +70,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 +78,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 +101,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 5889097c3..a110457d7 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( @@ -492,7 +494,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, @@ -764,15 +766,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(); }); @@ -1277,57 +1287,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; @@ -1372,7 +1416,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, @@ -1390,7 +1434,7 @@ export class Editor implements DiffsEditor { } } - #scrollToPrimaryCaret() { + #scrollToPrimaryCaret(noFocus = false) { const primaryCaretElement = this.#primaryCaretElement; const primarySelection = this.#selections?.at(-1); if (primarySelection === undefined) { @@ -1401,14 +1445,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); } } @@ -1422,7 +1468,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', @@ -1439,7 +1485,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), @@ -1456,6 +1504,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()); } @@ -1466,7 +1515,9 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment; elements: Map; }, - selection: EditorSelection + selection: EditorSelection, + type: 'selection' | 'match', + isFocused?: boolean ) { if (this.#textDocument === undefined) { return; @@ -1494,7 +1545,9 @@ export class Editor implements DiffsEditor { lineText, startChar, endChar, - isLastLine + isLastLine, + type, + isFocused ); continue; } @@ -1515,7 +1568,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 + ); } } @@ -1534,7 +1595,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; @@ -1584,12 +1647,14 @@ export class Editor implements DiffsEditor { } } - this.#renderSelectionLine( + this.#renderSelectionBlock( renderCtx, line, wrapLine, segmentLeft, - segmentWidth + segmentWidth, + type, + isFocused ); } } @@ -1599,7 +1664,7 @@ export class Editor implements DiffsEditor { // appended at the end. For wrapped logical lines this must be false on every // visual segment except the last one, since an intra-line wrap is not a real // newline and shouldn't visually extend past the wrapped content. - #renderSelectionLine( + #renderSelectionBlock( renderCtx: { fragment: DocumentFragment; elements: Map; @@ -1614,7 +1679,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; @@ -1623,10 +1690,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, left: number, @@ -1638,7 +1706,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'; @@ -1742,11 +1810,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) { @@ -1904,6 +1975,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; } @@ -1911,7 +1983,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)) { @@ -1921,7 +1992,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), @@ -1931,41 +2002,64 @@ 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([]); + 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; + if (this.#selections !== 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..e9bb19792 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,116 @@ 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; } - const firstMatch = matches[0]; - this.updateMatches(firstMatch); - postSearch(kind, firstMatch, retainFocus); + + if (matches.all.length === 0) { + matchResultElement.textContent = 'No results'; + matchResultElement.dataset.noMatches = ''; + } else { + delete matchResultElement.dataset.noMatches; + updateCurrentMatch(onUpdate(matches.all)); + } + }; + + 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 settingsSwitch = h('div', { dataset: { icon: 'settings' }, title: 'Settings', innerHTML: ` - - - - - - - - `, + + + + + + + + `, onclick: () => { settingsSwitch.replaceWith(settingsPanel); }, @@ -120,7 +155,7 @@ export class SearchPanelWidget { children: [ h('input', { type: 'checkbox', - checked: this.#searchParams.caseSensitive, + checked: searchParams.caseSensitive, onchange: (e: Event) => { updateSearchParam( 'caseSensitive', @@ -136,7 +171,7 @@ export class SearchPanelWidget { children: [ h('input', { type: 'checkbox', - checked: this.#searchParams.wholeWord, + checked: searchParams.wholeWord, onchange: (e: Event) => { updateSearchParam( 'wholeWord', @@ -152,7 +187,7 @@ export class SearchPanelWidget { children: [ h('input', { type: 'checkbox', - checked: this.#searchParams.regex, + checked: searchParams.regex, onchange: (e: Event) => { updateSearchParam( 'regex', @@ -165,45 +200,42 @@ 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') { e.preventDefault(); - close(); + this.cleanup(); + onClose(); } 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 +247,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 +268,10 @@ export class SearchPanelWidget { title: 'Next', innerHTML: ` - + `, onclick: () => { - search('findNext'); + findNextMatch(); }, }), h('div', { dataset: 'spacer' }), @@ -250,7 +282,7 @@ export class SearchPanelWidget { innerHTML: ` - + `, onclick: close, }), @@ -258,45 +290,27 @@ export class SearchPanelWidget { }), ], }); + + matches.current = initialMatch; containerElement.before(this.#container); requestAnimationFrame(() => { if (initialMatch !== undefined) { - updateAllMatches(); - this.updateMatches(initialMatch); + updateMatches(); } 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', () => { From ff893c667979aa016679997503a40f26495df4bd Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 2 Jun 2026 00:36:13 +0800 Subject: [PATCH 08/13] Fix --- packages/diffs/src/editor/css.ts | 2 +- packages/diffs/src/editor/editor.ts | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/diffs/src/editor/css.ts b/packages/diffs/src/editor/css.ts index fabd07938..f12cf2ef0 100644 --- a/packages/diffs/src/editor/css.ts +++ b/packages/diffs/src/editor/css.ts @@ -84,7 +84,7 @@ export const editorCSS: string = /* CSS */ ` height: 100%; background-color: var(--diffs-bg); } - [data-rtl] { + [data-rtl] { border-top-left-radius: 3px; } [data-rtr] { diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 5889097c3..fa3c0da4b 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -1568,7 +1568,7 @@ export class Editor implements DiffsEditor { : this.#metrics.measureTextWidth(prefixInSegment)); } if (wrapStartChar === wrapEndChar) { - segmentWidth = this.#metrics.ch; + segmentWidth = wrapLine === segmentCount - 1 ? 0 : this.#metrics.ch; } else { const selectionInSegment = lineText.slice(wrapStartChar, wrapEndChar); const selectionAsciiWidth = getExpandedAsciiTextColumns( @@ -1579,7 +1579,7 @@ export class Editor implements DiffsEditor { selectionAsciiWidth !== -1 ? selectionAsciiWidth * this.#metrics.ch : this.#metrics.measureTextWidth(selectionInSegment); - if (!isLastLine) { + if (!isLastLine && wrapLine === segmentCount - 1) { segmentWidth += this.#metrics.ch; } } @@ -1594,11 +1594,6 @@ export class Editor implements DiffsEditor { } } - // Render one selection range div for a single visual line. `applyEolSpacing` - // controls whether the trailing one-character "line continuation" marker is - // appended at the end. For wrapped logical lines this must be false on every - // visual segment except the last one, since an intra-line wrap is not a real - // newline and shouldn't visually extend past the wrapped content. #renderSelectionLine( renderCtx: { fragment: DocumentFragment; @@ -1629,6 +1624,7 @@ export class Editor implements DiffsEditor { const rounded = this.#options.roundedSelection ?? true; const addRoundedCorner = ( line: number, + wrapLine: number, left: number, radius: 'rtl' | 'rbl' | 'rbr' ) => { @@ -1700,11 +1696,12 @@ export class Editor implements DiffsEditor { }); } else { const prevLine = previousSelectionRange.line; + const prevWrapLine = previousSelectionRange.wrapLine; const prevLeft = previousSelectionRange.left; const prevDataset = previousSelectionRange.element.dataset; const prevEnd = prevLeft + previousSelectionRange.width; if (prevLeft > left) { - addRoundedCorner(prevLine, prevLeft - ch, 'rbr'); + addRoundedCorner(prevLine, prevWrapLine, prevLeft - ch, 'rbr'); } delete prevDataset.rbl; delete dataset.rtl; @@ -1713,11 +1710,11 @@ export class Editor implements DiffsEditor { delete prevDataset.rbr; } if (end > prevEnd) { - addRoundedCorner(prevLine, prevEnd, 'rbl'); + addRoundedCorner(prevLine, prevWrapLine, prevEnd, 'rbl'); dataset.rtr = ''; } if (end < prevEnd) { - addRoundedCorner(line, end, 'rtl'); + addRoundedCorner(line, wrapLine, end, 'rtl'); } if (left < prevLeft) { dataset.rtl = ''; From ab0c111c9e722055b5763e8a115dc7f954635d5c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 2 Jun 2026 00:58:41 +0800 Subject: [PATCH 09/13] Fix close button --- packages/diffs/src/editor/searchPanel.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/editor/searchPanel.ts b/packages/diffs/src/editor/searchPanel.ts index e9bb19792..ae5539db7 100644 --- a/packages/diffs/src/editor/searchPanel.ts +++ b/packages/diffs/src/editor/searchPanel.ts @@ -131,6 +131,11 @@ export class SearchPanelWidget { matches.current = nextMatch; }; + const close = () => { + this.cleanup(); + onClose(); + }; + const settingsSwitch = h('div', { dataset: { icon: 'settings' }, title: 'Settings', @@ -224,8 +229,7 @@ export class SearchPanelWidget { onkeydown: (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); - this.cleanup(); - onClose(); + close(); } else if (e.key === 'Enter') { e.preventDefault(); findNextMatch(false, true); From 6ebfda5f96d89d5f3df65112594db42ccb0191c4 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 2 Jun 2026 01:07:55 +0800 Subject: [PATCH 10/13] fix --- packages/diffs/src/editor/searchPanel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/diffs/src/editor/searchPanel.ts b/packages/diffs/src/editor/searchPanel.ts index ae5539db7..4157483df 100644 --- a/packages/diffs/src/editor/searchPanel.ts +++ b/packages/diffs/src/editor/searchPanel.ts @@ -301,6 +301,8 @@ export class SearchPanelWidget { requestAnimationFrame(() => { if (initialMatch !== undefined) { updateMatches(); + } else { + onUpdate([]); } this.#inputElement.select(); }); From 7f639cbceb150133cd6bd79f668d956b34f66c77 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 2 Jun 2026 10:27:23 +0800 Subject: [PATCH 11/13] Use parseInt --- packages/diffs/src/editor/editor.ts | 120 ++++++++++++++--------- packages/diffs/src/editor/selection.ts | 13 ++- packages/diffs/src/editor/textMeasure.ts | 6 +- 3 files changed, 90 insertions(+), 49 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index fa3c0da4b..4ed93814b 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -347,9 +347,12 @@ export class Editor implements DiffsEditor { const line = el.dataset.line; const lineType = el.dataset.lineType; if (line !== undefined) { - const lineIndex = Number(line) - 1; - startingLine ??= lineIndex; - endLine = lineIndex; + const lineNumber = parseInt(line, 10); + if (!Number.isNaN(lineNumber)) { + const lineIndex = lineNumber - 1; + startingLine ??= lineIndex; + endLine = lineIndex; + } } if (lineType === undefined || !isLineEditable(lineType)) { el.contentEditable = 'false'; @@ -373,9 +376,11 @@ export class Editor implements DiffsEditor { // inject editor css to the file container if (this.#componentContainer !== fileContainer) { this.#componentContainer = fileContainer; - this.#codePaddingTop = Number( - getComputedStyle(codeElement).paddingTop.slice(0, -2) + const codePaddingTop = parseInt( + getComputedStyle(codeElement).paddingTop.slice(0, -2), + 10 ); + this.#codePaddingTop = Number.isNaN(codePaddingTop) ? 0 : codePaddingTop; if (this.#globalStyleElement !== undefined) { fileContainer.appendChild(this.#globalStyleElement); } @@ -644,16 +649,20 @@ export class Editor implements DiffsEditor { if (target === undefined || textDocument === undefined) { return; } - const lineNumber = target.dataset.columnNumber; + const columnNumber = target.dataset.columnNumber; const lineType = target.dataset.lineType; if ( - lineNumber === undefined || + columnNumber === undefined || lineType === undefined || !isLineEditable(lineType) ) { return; } - const line = Number(lineNumber) - 1; + const lineNumber = parseInt(columnNumber, 10); + if (Number.isNaN(lineNumber)) { + return; + } + const line = lineNumber - 1; const selection: EditorSelection = { start: { line, character: 0 }, end: { @@ -682,17 +691,22 @@ export class Editor implements DiffsEditor { if (target === undefined) { return; } - const lineNumber = + + const line = target.dataset.columnNumber ?? target.dataset.line; const lineType = target.dataset.lineType; if ( this.#isGutterMouseDown && this.#textDocument !== undefined && - lineNumber !== undefined && + line !== undefined && lineType !== undefined && isLineEditable(lineType) ) { - const lineIndex = Number(lineNumber) - 1; + const lineNumber = parseInt(line, 10); + if (Number.isNaN(lineNumber)) { + return; + } + const lineIndex = lineNumber - 1; let selection: EditorSelection = { start: { line: lineIndex, character: 0 }, end: { @@ -1123,15 +1137,18 @@ export class Editor implements DiffsEditor { const el = child as HTMLElement; const line = el.dataset.line; if (line !== undefined) { - const lineIndex = Number(el.dataset.line) - 1; - const tokens = dirtyLines.get(lineIndex); - if (tokens !== undefined) { - el.replaceChildren( - ...renderLineTokens(tokens, tokenizer.themeType) - ); - dirtyLineIndexes.delete(lineIndex); - if (dirtyLineIndexes.size === 0) { - break; + const lineNumber = parseInt(line, 10); + if (!Number.isNaN(lineNumber)) { + const lineIndex = lineNumber - 1; + const tokens = dirtyLines.get(lineIndex); + if (tokens !== undefined) { + el.replaceChildren( + ...renderLineTokens(tokens, tokenizer.themeType) + ); + dirtyLineIndexes.delete(lineIndex); + if (dirtyLineIndexes.size === 0) { + break; + } } } } @@ -1144,16 +1161,19 @@ export class Editor implements DiffsEditor { i++ ) { const child = children[i] as HTMLElement | undefined; - if (child?.dataset.line !== undefined) { - const lineIndex = Number(child.dataset.line) - 1; - if (dirtyLines.has(lineIndex)) { - const tokens = dirtyLines.get(lineIndex)!; - child.replaceChildren( - ...renderLineTokens(tokens, tokenizer.themeType) - ); - dirtyLineIndexes.delete(lineIndex); - if (dirtyLineIndexes.size === 0) { - break; + if (child !== undefined && child.dataset.line !== undefined) { + const lineNumber = parseInt(child.dataset.line, 10); + if (!Number.isNaN(lineNumber)) { + const lineIndex = lineNumber - 1; + if (dirtyLines.has(lineIndex)) { + const tokens = dirtyLines.get(lineIndex)!; + child.replaceChildren( + ...renderLineTokens(tokens, tokenizer.themeType) + ); + dirtyLineIndexes.delete(lineIndex); + if (dirtyLineIndexes.size === 0) { + break; + } } } } @@ -1208,18 +1228,25 @@ export class Editor implements DiffsEditor { const children = parent.children; for (let i = children.length - 1; i >= 0; i--) { const child = children[i] as HTMLElement; - const { lineIndex, lineAnnotation } = child.dataset; - if (lineIndex !== undefined || lineAnnotation !== undefined) { - const lineIndexNum = Number( - lineAnnotation !== undefined - ? lineAnnotation.split(',')[1] - : lineIndex - ); - if (lineIndexNum < change.lineCount) { - break; - } - child.remove(); + const { line, columnNumber, lineAnnotation } = child.dataset; + if ( + line === undefined && + columnNumber === undefined && + lineAnnotation === undefined + ) { + continue; + } + const lineIndex = + lineAnnotation !== undefined + ? parseInt(lineAnnotation.split(',')[1], 10) + : parseInt(line ?? columnNumber!, 10) - 1; + if (Number.isNaN(lineIndex)) { + continue; } + if (lineIndex < change.lineCount) { + break; + } + child.remove(); } } } @@ -2234,7 +2261,7 @@ export class Editor implements DiffsEditor { lineNumber !== undefined && lineType !== undefined && isLineEditable(lineType) && - Number(lineNumber) === line + 1 + parseInt(lineNumber, 10) === line + 1 ) { return child; } @@ -2274,7 +2301,10 @@ export class Editor implements DiffsEditor { diffsColumnNumberWidth.length > 2 && diffsColumnNumberWidth.endsWith('px') ) { - this.#gutterWidthCache = Number(diffsColumnNumberWidth.slice(0, -2)); + this.#gutterWidthCache = parseInt( + diffsColumnNumberWidth.slice(0, -2), + 10 + ); } else { this.#gutterWidthCache = gutterElement.offsetWidth; } @@ -2298,7 +2328,9 @@ export class Editor implements DiffsEditor { diffsColumnContentWidth.length > 2 && diffsColumnContentWidth.endsWith('px') ) { - this.#contentWidthCache = Number(diffsColumnContentWidth.slice(0, -2)); + this.#contentWidthCache = parseFloat( + diffsColumnContentWidth.slice(0, -2) + ); } else { this.#contentWidthCache = this.#contentElement.offsetWidth; } diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index 31c4e9248..1db533fc7 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -1460,14 +1460,23 @@ function getLineChildEnd( function getLineIndex(el: HTMLElement): number | undefined { const { line } = el.dataset; if (line !== undefined) { - return parseInt(line) - 1; + const lineNumber = parseInt(line, 10); + if (!Number.isNaN(lineNumber)) { + return lineNumber - 1; + } } return undefined; } function getCharacterIndex(el: HTMLElement): number | undefined { const { char } = el.dataset; - return char !== undefined ? parseInt(char) : undefined; + if (char !== undefined) { + const charIndex = parseInt(char, 10); + if (!Number.isNaN(charIndex)) { + return charIndex; + } + } + return undefined; } function getTextOffset( diff --git a/packages/diffs/src/editor/textMeasure.ts b/packages/diffs/src/editor/textMeasure.ts index 24c270017..3839a08be 100644 --- a/packages/diffs/src/editor/textMeasure.ts +++ b/packages/diffs/src/editor/textMeasure.ts @@ -33,10 +33,10 @@ export class Metrics { const { fontSize, fontFamily, tabSize, lineHeight } = getComputedStyle(root); if (lineHeight.endsWith('px')) { - this.lineHeight = Number(lineHeight.slice(0, -2)); + this.lineHeight = parseFloat(lineHeight.slice(0, -2)); } else if (fontSize.endsWith('px')) { this.lineHeight = round( - Number(fontSize.slice(0, -2)) * Number(lineHeight) + parseFloat(fontSize.slice(0, -2)) * parseFloat(lineHeight) ); } const font = fontSize + ' ' + fontFamily; @@ -45,7 +45,7 @@ export class Metrics { this.#canvasCtx.font = font; this.ch = this.canvasMeasureTextWidth('0'); } - this.tabSize = Number(tabSize); + this.tabSize = parseInt(tabSize, 10); } /** measure the width of the text */ From d3de82cc120509907d5f560c5c5161a12743f3a3 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 2 Jun 2026 11:18:34 +0800 Subject: [PATCH 12/13] Fix typing with multiple cursor --- packages/diffs/src/editor/editor.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index d8421df72..6204c901f 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -840,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], From 75d88a0f886d75b134ec3b9c3f589c08bf54cdb6 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 2 Jun 2026 11:30:15 +0800 Subject: [PATCH 13/13] Fix hightlight --- packages/diffs/src/editor/editor.ts | 6 ++---- packages/diffs/src/editor/searchPanel.ts | 4 ++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 6204c901f..1a93906cd 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -2061,7 +2061,7 @@ export class Editor implements DiffsEditor { onUpdate: (allMatches: MatchRange[]): MatchRange | undefined => { if (allMatches.length === 0) { this.#matches = undefined; - this.#updateSelections([]); + this.#updateSelections(this.#selections ?? []); return; } @@ -2089,9 +2089,7 @@ export class Editor implements DiffsEditor { this.#searchPanel = undefined; this.#retainSearchPanelFocus = false; this.#matches = undefined; - if (this.#selections !== undefined) { - this.#updateSelections(this.#selections); - } + this.#updateSelections(this.#selections ?? []); }, }); diff --git a/packages/diffs/src/editor/searchPanel.ts b/packages/diffs/src/editor/searchPanel.ts index 4157483df..3052710ea 100644 --- a/packages/diffs/src/editor/searchPanel.ts +++ b/packages/diffs/src/editor/searchPanel.ts @@ -73,7 +73,11 @@ export class SearchPanelWidget { } else { delete matchResultElement.dataset.noMatches; updateCurrentMatch(onUpdate(matches.all)); + return; } + + matches.current = undefined; + onUpdate([]); }; const updateCurrentMatch = (currentMatch: MatchRange | undefined) => {