diff --git a/apps/demo/package.json b/apps/demo/package.json index 81b678a9a..6071137b9 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@pierre/diffs": "workspace:*", + "@pierre/icons": "catalog:", "react": "catalog:", "react-dom": "catalog:", "shiki": "catalog:" diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 55cb19feb..285ec5dfa 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -23,6 +23,13 @@ import { } from '@pierre/diffs'; import { Editor } from '@pierre/diffs/editor'; import type { WorkerPoolManager } from '@pierre/diffs/worker'; +import { + IconCiFailedOctagonFill, + IconCiWarningFill, + IconInfoFill, +} from '@pierre/icons'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { cleanupCodeView, @@ -59,6 +66,33 @@ const CRAZY_FILE = false; const LARGE_CONFLICT_FILE = false; const CODE_VIEW_OLD_NEW_FILE = true; +// Pre-render the @pierre/icons SVG markup once so it can be embedded into the +// `message.html` strings the editor injects for markers. The icons default to +// `fill: currentcolor`, so each one inherits the surrounding text color. +const MARKER_INFO_ICON = renderToStaticMarkup( + createElement(IconInfoFill, { size: 16 }) +); +const MARKER_WARNING_ICON = renderToStaticMarkup( + createElement(IconCiWarningFill, { size: 16 }) +); +const MARKER_ERROR_ICON = renderToStaticMarkup( + createElement(IconCiFailedOctagonFill, { size: 16 }) +); + +// Builds the HTML for a single marker overlay: a severity-colored leading icon +// next to a message, with an additional description indented below the message +// (aligned with the message text, not the icon). +function markerMessage(opts: { + color: string; + icon: string; + message: string; + description: string; +}): string { + const iconCol = `${opts.icon}`; + const textCol = `
${opts.message}
${opts.description}
`; + return `
${iconCol}${textCol}
`; +} + const FileStreamCodeConfigs: FileStreamCodeConfigsItem[] = [ { content: tsContent, @@ -868,6 +902,67 @@ if (renderFileButton != null) { direction: 'none', }, ]); + requestAnimationFrame(() => { + editor.setMarkers([ + { + start: { + line: 1, + character: 2, + }, + end: { + line: 1, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + severity: 'info', + message: { + html: markerMessage({ + color: 'var(--diffs-editor-info-fg, #3794ff)', + icon: MARKER_INFO_ICON, + message: 'CodeOptionsMultipleThemes', + description: 'Code options of multiple themes.', + }), + }, + }, + { + start: { + line: 2, + character: 2, + }, + end: { + line: 2, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + severity: 'warning', + message: { + html: markerMessage({ + color: 'var(--diffs-editor-warning-fg, #cca700)', + icon: MARKER_WARNING_ICON, + message: 'CodeToHastOptions', + description: 'Code to Hast Options is deprecated.', + }), + }, + }, + { + start: { + line: 3, + character: 2, + }, + end: { + line: 3, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + severity: 'error', + message: { + html: markerMessage({ + color: 'var(--diffs-editor-error-fg, red)', + icon: MARKER_ERROR_ICON, + message: 'DecorationItem', + description: 'Type not defined.', + }), + }, + }, + ]); + }); } else { editor.cleanUp(); } diff --git a/bun.lock b/bun.lock index 6d235b511..c3dc2bc64 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "version": "0.0.0", "dependencies": { "@pierre/diffs": "workspace:*", + "@pierre/icons": "catalog:", "react": "catalog:", "react-dom": "catalog:", "shiki": "catalog:", diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index 4bfdbbbfd..fb523ed1e 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -56,7 +56,8 @@ } [data-caret], [data-selection-range], -[data-match-range] { +[data-match-range], +[data-marker-range] { position: absolute; top: 0; left: 0; @@ -90,11 +91,11 @@ background-color: var(--diffs-bg); } [data-match-range] { + z-index: -10; background-color: var( --diffs-editor-match-bg, var(--diffs-editor-selection-bg) ); - z-index: -10; } [data-match-range]:not([data-focus]) { background-color: var( @@ -102,6 +103,30 @@ light-dark(#ff963288, #ff963266) ); } +[data-marker-range] { + z-index: 1; + /* White stroke: luminance masks treat black as transparent. */ + -webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2IiBoZWlnaHQ9IjMiPjxwYXRoIGQ9Im0wIDIuNSBsMiAtMS41IGwxIDAgbDIgMS41IGwxIDAiIHN0cm9rZT0iI2ZmZiIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIi8+PC9zdmc+'); + mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2IiBoZWlnaHQ9IjMiPjxwYXRoIGQ9Im0wIDIuNSBsMiAtMS41IGwxIDAgbDIgMS41IGwxIDAiIHN0cm9rZT0iI2ZmZiIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIi8+PC9zdmc+'); + -webkit-mask-position: left bottom; + mask-position: left bottom; + -webkit-mask-repeat: repeat-x; + mask-repeat: repeat-x; + -webkit-mask-size: 6px 3px; + mask-size: 6px 3px; +} +[data-marker-error] { + background-color: var(--diffs-editor-error-fg, red); +} +[data-marker-warning] { + background-color: var(--diffs-editor-warning-fg, #cca700); +} +[data-marker-info] { + background-color: var(--diffs-editor-info-fg, #3794ff); +} +[data-marker-hint] { + background-color: var(--diffs-editor-hint-fg, #6a6a6a); +} [data-rtl] { border-top-left-radius: 3px; } @@ -126,6 +151,36 @@ visibility: visible; } } +[data-editor-widget] { + --diffs-widget-bg: color-mix(in lab, var(--diffs-fg) 4%, var(--diffs-bg)); + --diffs-widget-border: color-mix(in lab, var(--diffs-fg) 20%, transparent); + --diffs-widget-shadow: + inset 0 0 0 1px var(--diffs-bg), 0 4px 8px rgb(0 0 0 / 0.075), + 0 6px 18px rgb(0 0 0 / 0.075); + position: absolute; + top: 0; + left: 0; + z-index: 100; + width: fit-content; + max-width: calc(100% - 24px); + border: 1px solid var(--diffs-widget-border); + border-radius: 9px; + background-color: var(--diffs-widget-bg); + background-clip: padding-box; + box-shadow: var(--diffs-widget-shadow); +} +[data-marker-popup] { + padding: 8px 12px; + min-width: 180px; + pointer-events: auto; + font-family: var(--diffs-header-font-fallback); + font-size: 14px; + line-height: 1.4; +} +[data-marker-popup] code { + display: inline; + background-color: transparent; +} /* Selection Action Widget */ [data-selection-action-icon] { @@ -159,27 +214,14 @@ /* Search Panel Widget */ [data-search-panel] { + display: flex; + gap: 6px; position: sticky; top: 12px; - z-index: 100; - width: fit-content; min-width: 300px; - max-width: calc(100% - 24px); margin-inline: auto 12px; margin-bottom: 4px; - display: flex; - gap: 4px; - background-clip: padding-box; - background-color: color-mix( - in lab, - color-mix(in lab, var(--diffs-fg) 4%, var(--diffs-bg)), - transparent 40% - ); - border: 1px solid color-mix(in lab, var(--diffs-fg) 15%, var(--diffs-bg)); - padding: 6px 6px 6px 10px; - border-radius: 8px; - box-shadow: 0 0 12px 0 color-mix(in lab, var(--diffs-fg) 10%, var(--diffs-bg)); - backdrop-filter: blur(8px); + padding: 4px; } [data-search-panel-row] { display: flex; @@ -187,7 +229,7 @@ justify-content: flex-start; gap: 1px; width: 100%; - font-family: Arial, Helvetica, sans-serif; + font-family: var(--diffs-header-font-fallback); font-size: 14px; } [data-search-panel-row] input { @@ -234,8 +276,6 @@ color 0.1s ease-in-out; } [data-search-panel-row] [data-icon='search'] { - width: 16px; - margin-right: 2px; color: color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg)); cursor: default; } diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 8ef0c3bca..3f7568b8a 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -19,6 +19,7 @@ import { applyDocumentChangeToLineAnnotations, renderLineAnnotations, } from './lineAnnotations'; +import { type Marker, MarkerManager, markerSeverityDatasetKey } from './marker'; import { isMoveCursorShortcut, isPrimaryModifier, isSafari } from './platform'; import { type MatchRange, SearchPanelWidget } from './searchPanel'; import type { EditorSelection } from './selection'; @@ -60,6 +61,7 @@ import { import { createSpriteElement } from './sprite'; import { type Position, + type Range, type ResolvedTextEdit, TextDocument, type TextDocumentChange, @@ -72,18 +74,14 @@ import { snapTextOffsetToUnicodeBoundary, } from './textMeasure'; import { EditorTokenizer, renderLineTokens } from './tokenzier'; -import { addEventListener, extend, getLineNumberAttr, h, round } from './utils'; - -function clampDomOffset(node: Node, offset: number): number { - if (node.nodeType === 3) { - const length = (node as Text).textContent?.length ?? 0; - return Math.max(0, Math.min(offset, length)); - } - if (node.nodeType === 1) { - return Math.max(0, Math.min(offset, node.childNodes.length)); - } - return 0; -} +import { + addEventListener, + clampDomOffset, + extend, + getLineNumberAttr, + h, + round, +} from './utils'; export interface EditorOptions { /** Render rounded corners for selection ranges, default is true. */ @@ -107,11 +105,12 @@ export class Editor implements DiffsEditor { #wrap = false; #metrics = new Metrics(); #tokenizer?: EditorTokenizer; + #markerManager?: MarkerManager; - // event handlers + // event disposes #editorEventDisposes?: (() => void)[]; #globalEventDisposes?: (() => void)[]; - #mouseUpDisposes?: (() => void)[]; + #selectEventDisposes?: (() => void)[]; #detach?: () => void; // file @@ -145,7 +144,7 @@ export class Editor implements DiffsEditor { #contentElement?: HTMLElement; #overlayElement?: HTMLElement; #primaryCaretElement?: HTMLElement; - #selectionElements?: Map; + #overlayElements?: Map; #selectionAction?: SelectionActionWidget; #searchPanel?: SearchPanelWidget; #resizeObserver?: ResizeObserver; @@ -308,11 +307,11 @@ export class Editor implements DiffsEditor { }, __debug: this.#options.__debug, }); - this.#shouldIgnoreSelectionChange = false; - this.#selectionElements?.forEach((el) => el.remove()); - this.#selectionElements?.clear(); this.#fileInstance?.setSelectedLines(null); - this.#selectionElements = undefined; + this.#shouldIgnoreSelectionChange = false; + this.#overlayElements?.forEach((el) => el.remove()); + this.#overlayElements?.clear(); + this.#overlayElements = undefined; this.#selections = undefined; this.#scrollingToLine = undefined; this.#reservedSelections = undefined; @@ -368,10 +367,14 @@ export class Editor implements DiffsEditor { this.setSelections(this.#initSelections); this.#scrollToPrimaryCaret(); this.#initSelections = undefined; - } else if (this.#selections !== undefined && this.#selections.length > 0) { + } else if ( + this.#selections !== undefined || + this.#matches !== undefined || + this.#markerManager !== undefined + ) { // when re-rendering triggered by virtual viewport scroll, - // re-render the existing selections - this.#updateSelections(this.#selections); + // re-render the existing selections, matches, and markers + this.#updateSelections(this.#selections ?? []); } if (this.#options.__debug === true && renderRange !== undefined) { @@ -447,6 +450,33 @@ export class Editor implements DiffsEditor { } } + setMarkers(markers: Marker[]): void { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + throw new Error('Text document is not initialized'); + } + + if (markers.length === 0) { + this.#markerManager?.cleanup(); + this.#markerManager = undefined; + this.#updateSelections(this.#selections ?? []); + return; + } + + this.#markerManager ??= new MarkerManager({ + getLineHeight: () => this.#metrics.lineHeight, + getFileContainer: () => this.#fileContainer, + getCharX: (line, character) => this.#getCharX(line, character), + getLineY: (line) => this.#getLineY(line), + isMouseDown: () => this.#isContentMouseDown || this.#isGutterMouseDown, + }); + this.#markerManager.setMarkers(markers, textDocument); + if (this.#contentElement !== undefined) { + this.#markerManager.listenHover(this.#contentElement); + } + this.#updateSelections(this.#selections ?? []); + } + focus(options?: FocusOptions): void { const preventScroll = options?.preventScroll ?? false; const primarySelection = this.#selections?.at(-1); @@ -469,6 +499,10 @@ export class Editor implements DiffsEditor { this.#globalEventDisposes = undefined; this.#editorEventDisposes?.forEach((dispose) => dispose()); this.#editorEventDisposes = undefined; + this.#selectEventDisposes?.forEach((dispose) => dispose()); + this.#selectEventDisposes = undefined; + this.#markerManager?.cleanup(); + this.#markerManager = undefined; this.#detach?.(); this.#detach = undefined; @@ -500,11 +534,9 @@ export class Editor implements DiffsEditor { this.#contentElement = undefined; this.#overlayElement?.remove(); this.#overlayElement = undefined; - this.#primaryCaretElement?.remove(); + this.#overlayElements?.forEach((el) => el.remove()); + this.#overlayElements = undefined; this.#primaryCaretElement = undefined; - this.#selectionElements?.forEach((el) => el.remove()); - this.#selectionElements?.clear(); - this.#selectionElements = undefined; this.#searchPanel?.cleanup(); this.#searchPanel = undefined; this.#selectionAction?.cleanup(); @@ -633,8 +665,8 @@ export class Editor implements DiffsEditor { return; } - this.#mouseUpDisposes?.forEach((dispose) => dispose()); - this.#mouseUpDisposes = undefined; + this.#selectEventDisposes?.forEach((dispose) => dispose()); + this.#selectEventDisposes = undefined; if (this.#isGutterMouseDown) { this.#isGutterMouseDown = false; @@ -645,7 +677,7 @@ export class Editor implements DiffsEditor { this.#shiftKeyPressed = false; this.#selectionStart = undefined; this.#reservedSelections = undefined; - this.#selectionElements?.forEach((el, key) => { + this.#overlayElements?.forEach((el, key) => { if (key.startsWith('selectionActionIcon-')) { el.dataset.visible = 'true'; } @@ -680,8 +712,11 @@ export class Editor implements DiffsEditor { #listenContentElement(contentEl: HTMLElement, gutterEl?: HTMLElement): void { const targetIsContentElement = (e: Event) => { - const target = e.composedPath()[0] as HTMLElement; - return target === contentEl || contentEl.contains(target); + const target = e.composedPath()[0] as HTMLElement | undefined; + return ( + target !== undefined && + (target === contentEl || contentEl.contains(target)) + ); }; this.#editorEventDisposes?.forEach((dispose) => dispose()); @@ -694,6 +729,8 @@ export class Editor implements DiffsEditor { return; } + this.#markerManager?.removePopup(); + // this is a workaround for the selection rendering glitch // happens when selecting content in shadow DOM on Safari if ( @@ -701,7 +738,7 @@ export class Editor implements DiffsEditor { this.#lineAnnotations !== undefined && this.#lineAnnotations.length > 0 ) { - this.#mouseUpDisposes = [ + this.#selectEventDisposes = [ ...contentEl.querySelectorAll( '[data-line-annotation]' ), @@ -877,35 +914,58 @@ export class Editor implements DiffsEditor { ), ]; if (gutterEl !== undefined) { + const resolveGutterTarget = ( + eventTarget: HTMLElement | undefined, + includeContentLine = false + ) => { + let target = eventTarget; + if (target?.dataset.lineNumberContent !== undefined) { + target = target.parentElement ?? undefined; + } else if (includeContentLine && target?.tagName === 'SPAN') { + target = target.closest('[data-line]') as HTMLElement | undefined; + } + return target; + }; + + const resolveEditableLine = (target: HTMLElement | undefined) => { + if (target === undefined) { + return; + } + const lineType = target.dataset.lineType; + const lineNumber = + getLineNumberAttr(target) ?? + getLineNumberAttr(target, 'columnNumber'); + if ( + lineNumber === undefined || + lineType === undefined || + !isLineEditable(lineType) + ) { + return; + } + return lineNumber - 1; + }; + this.#editorEventDisposes.push( addEventListener( gutterEl, 'pointerdown', (e) => { - let target = e.composedPath()[0] as HTMLElement | undefined; - if (target?.dataset.lineNumberContent !== undefined) { - target = - target.parentElement ?? (undefined as HTMLElement | undefined); - } const textDocument = this.#textDocument; - if (target === undefined || textDocument === undefined) { - return; - } - const lineType = target.dataset.lineType; - const lineNumber = getLineNumberAttr(target, 'columnNumber'); - if ( - lineNumber === undefined || - lineType === undefined || - !isLineEditable(lineType) - ) { + const lineIndex = resolveEditableLine( + resolveGutterTarget( + e.composedPath()[0] as HTMLElement | undefined + ) + ); + if (lineIndex === undefined || textDocument === undefined) { return; } - const line = lineNumber - 1; + + this.#markerManager?.removePopup(); const selection: EditorSelection = { - start: { line, character: 0 }, + start: { line: lineIndex, character: 0 }, end: { - line, - character: textDocument.getLineText(line).length, + line: lineIndex, + character: textDocument.getLineText(lineIndex).length, }, direction: DirectionForward, }; @@ -913,55 +973,43 @@ export class Editor implements DiffsEditor { this.#selectionStart = selection; this.#updateSelections([selection]); this.#focus(selection.end); - this.#mouseUpDisposes = [ + this.#selectEventDisposes = [ addEventListener( document, 'mousemove', (e) => { - let target = e.composedPath()[0] as HTMLElement | undefined; - if (target?.dataset.lineNumberContent !== undefined) { - target = target?.parentElement ?? undefined; - } else if (target?.tagName === 'SPAN') { - target = target?.closest('[data-line]') as - | HTMLElement - | undefined; + if (!this.#isGutterMouseDown) { + return; } - if (target === undefined) { + const textDocument = this.#textDocument; + const lineIndex = resolveEditableLine( + resolveGutterTarget( + e.composedPath()[0] as HTMLElement | undefined, + true + ) + ); + if (lineIndex === undefined || textDocument === undefined) { return; } - const lineType = target.dataset.lineType; - const lineNumber = - getLineNumberAttr(target) ?? - getLineNumberAttr(target, 'columnNumber'); - if ( - this.#isGutterMouseDown && - this.#textDocument !== undefined && - lineNumber !== undefined && - lineType !== undefined && - isLineEditable(lineType) - ) { - const lineIndex = lineNumber - 1; - let selection: EditorSelection = { - start: { line: lineIndex, character: 0 }, - end: { - line: lineIndex, - character: - this.#textDocument.getLineText(lineIndex).length, - }, - direction: DirectionForward, - }; - if (this.#selectionStart !== undefined) { - selection = createSelectionFrom( - this.#selectionStart, - selection - ); - } else { - this.#selectionStart = selection; - } - this.#updateSelections([selection]); - this.#focus(selection.end); + let selection: EditorSelection = { + start: { line: lineIndex, character: 0 }, + end: { + line: lineIndex, + character: textDocument.getLineText(lineIndex).length, + }, + direction: DirectionForward, + }; + if (this.#selectionStart !== undefined) { + selection = createSelectionFrom( + this.#selectionStart, + selection + ); + } else { + this.#selectionStart = selection; } + this.#updateSelections([selection]); + this.#focus(selection.end); }, { passive: true } ), @@ -971,6 +1019,9 @@ export class Editor implements DiffsEditor { ) ); } + + this.#markerManager?.listenHover(contentEl); + this.#resizeObserver?.disconnect(); this.#resizeObserver = new ResizeObserver(() => { this.#handleLayoutResize(); @@ -993,8 +1044,7 @@ export class Editor implements DiffsEditor { case 'findNextMatch': { const selections = this.#selections; - const textDocument = this.#textDocument; - if (selections === undefined || textDocument === undefined) { + if (selections === undefined) { break; } const hasCollapsed = selections.some(isCollapsedSelection); @@ -1127,10 +1177,17 @@ export class Editor implements DiffsEditor { this.#lineYCache.clear(); this.#wrapLineOffsetsCache.clear(); } - if (this.#selections !== undefined) { - this.#updateSelections(this.#selections); - this.focus(); + if ( + this.#selections !== undefined || + this.#matches !== undefined || + this.#markerManager !== undefined + ) { + this.#updateSelections(this.#selections ?? []); + if (this.#selections !== undefined) { + this.focus(); + } } + this.#markerManager?.removePopup(); } #rerender( @@ -1331,89 +1388,27 @@ export class Editor implements DiffsEditor { } } - #updateSelections(selections: EditorSelection[]) { - this.postponeBackgroundTokenizeToNextFrame(); - - this.#primaryCaretElement = undefined; - this.#fileInstance?.setSelectedLines(null); - this.#gutterElement - ?.querySelectorAll('[data-active]') - .forEach((el) => el.removeAttribute('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(), - }; - - 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.#fileInstance?.setSelectedLines({ - start: line, - end: line, + #focus(position?: Position, preventScroll = true) { + if (position !== undefined) { + this.#shouldIgnoreSelectionChange = true; + this.#setWindowSelection({ + start: position, + end: position, + direction: DirectionNone, + }); + // call focus in a request animation frame to prevent conflict with + // the `setBaseAndExtent` method + requestAnimationFrame(() => { + this.#contentElement?.focus({ preventScroll }); + // another request animation frame since the `focus` call + // may trigger a selectionchange event, which we want to ignore + requestAnimationFrame(() => { + this.#shouldIgnoreSelectionChange = false; }); - } else { - if (this.#gutterElement !== undefined) { - const pos = getCaretPosition(primarySelection); - this.#gutterElement - .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.enabledSelectionAction === true && - !isCollapsedSelection(primarySelection) - ) { - this.#renderSelectionActionIcon(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); - } + }); + } else { + this.#contentElement?.focus({ preventScroll }); } - - this.#overlayElement?.appendChild(fragment); - this.#selectionElements?.forEach((el) => el.remove()); - this.#selectionElements?.clear(); - this.#selectionElements = renderCtx.elements; } // set window native selection to match the selection @@ -1459,29 +1454,6 @@ export class Editor implements DiffsEditor { } } - #focus(position?: Position, preventScroll = true) { - if (position !== undefined) { - this.#shouldIgnoreSelectionChange = true; - this.#setWindowSelection({ - start: position, - end: position, - direction: DirectionNone, - }); - // call focus in a request animation frame to prevent conflict with - // the `setBaseAndExtent` method - requestAnimationFrame(() => { - this.#contentElement?.focus({ preventScroll }); - // another request animation frame since the `focus` call - // may trigger a selectionchange event, which we want to ignore - requestAnimationFrame(() => { - this.#shouldIgnoreSelectionChange = false; - }); - }); - } else { - this.#contentElement?.focus({ preventScroll }); - } - } - #scrollToPrimaryCaret(noFocus = false) { const primaryCaretElement = this.#primaryCaretElement; const primarySelection = this.#selections?.at(-1); @@ -1586,20 +1558,123 @@ export class Editor implements DiffsEditor { virtualCaret.remove(); } + #updateSelections(selections: EditorSelection[]) { + this.postponeBackgroundTokenizeToNextFrame(); + + this.#primaryCaretElement = undefined; + this.#fileInstance?.setSelectedLines(null); + this.#gutterElement + ?.querySelectorAll('[data-active]') + .forEach((el) => el.removeAttribute('data-active')); + + if ( + selections.length === 0 && + this.#matches === undefined && + this.#markerManager === undefined + ) { + this.#selections = undefined; + this.#overlayElements?.forEach((el) => el.remove()); + this.#overlayElements?.clear(); + return; + } + + const fragment = document.createDocumentFragment(); + const renderCtx = { + fragment, + elements: new Map(), + }; + + 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.#fileInstance?.setSelectedLines({ + start: line, + end: line, + }); + } else { + if (this.#gutterElement !== undefined) { + const pos = getCaretPosition(primarySelection); + this.#gutterElement + .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.enabledSelectionAction === true && + !isCollapsedSelection(primarySelection) + ) { + this.#renderSelectionActionIcon(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 range: Range = { + start: textDocument.positionAt(startOffset), + end: textDocument.positionAt(endOffset), + }; + const isFocused = + primaryStartOffset === startOffset && primaryEndOffset === endOffset; + this.#renderSelection( + renderCtx, + 'match', + range, + isFocused ? 'focus' : undefined + ); + } + } + + if (this.#markerManager !== undefined && textDocument !== undefined) { + for (const marker of this.#markerManager.markers) { + this.#renderSelection( + renderCtx, + 'marker', + marker, + markerSeverityDatasetKey(marker.severity) + ); + } + } + + this.#overlayElement?.appendChild(fragment); + this.#overlayElements?.forEach((el) => el.remove()); + this.#overlayElements?.clear(); + this.#overlayElements = renderCtx.elements; + } + #renderSelection( renderCtx: { fragment: DocumentFragment; elements: Map; }, - selection: EditorSelection, - type: 'selection' | 'match', - isFocused?: boolean + type: 'selection' | 'match' | 'marker', + range: Range, + extraDataset?: string ) { if (this.#textDocument === undefined) { return; } - const { start, end } = selection; + const { start, end } = range; for (let line = start.line; line <= end.line; line++) { if (!this.#isLineVisible(line)) { continue; @@ -1623,7 +1698,7 @@ export class Editor implements DiffsEditor { endChar, isLastLine, type, - isFocused + extraDataset ); continue; } @@ -1631,27 +1706,28 @@ export class Editor implements DiffsEditor { let left = 0; let width = 0; + let paddingEnd = 0; if (startChar === 0) { left = this.#getGutterWidth() + this.#metrics.ch; // gutter width + inline padding (1ch) } else { left = this.#getCharX(line, startChar)[0]; } + if (!isLastLine && type === 'selection') { + paddingEnd = this.#metrics.ch; + } if (startChar === endChar) { - width = isLastLine ? 0 : this.#metrics.ch; + width = paddingEnd; } else { - width = - this.#getCharX(line, endChar)[0] - - left + - (isLastLine ? 0 : this.#metrics.ch); + width = this.#getCharX(line, endChar)[0] - left + paddingEnd; } this.#renderSelectionBlock( renderCtx, + type, line, 0, left, width, - type, - isFocused + extraDataset ); } } @@ -1672,8 +1748,8 @@ export class Editor implements DiffsEditor { startChar: number, endChar: number, isLastLine: boolean, - type: 'selection' | 'match', - isFocused: boolean = false + type: 'selection' | 'match' | 'marker', + extraDataset?: string ) { const wrapOffsets = this.#wrapLineText(line); const segmentCount = wrapOffsets.length - 1; @@ -1692,6 +1768,7 @@ export class Editor implements DiffsEditor { let segmentLeft: number; let segmentWidth: number; + let paddingEnd = 0; if (wrapStartChar === 0) { segmentLeft = offsetLeft; } else { @@ -1706,8 +1783,15 @@ export class Editor implements DiffsEditor { ? prefixAsciiColumns * this.#metrics.ch : this.#metrics.measureTextWidth(prefixInSegment)); } + if ( + !isLastLine && + wrapLine === segmentCount - 1 && + type === 'selection' + ) { + paddingEnd = this.#metrics.ch; + } if (wrapStartChar === wrapEndChar) { - segmentWidth = wrapLine === segmentCount - 1 ? 0 : this.#metrics.ch; + segmentWidth = paddingEnd; } else { const selectionInSegment = lineText.slice(wrapStartChar, wrapEndChar); const selectionAsciiWidth = getExpandedAsciiTextColumns( @@ -1718,19 +1802,17 @@ export class Editor implements DiffsEditor { selectionAsciiWidth !== -1 ? selectionAsciiWidth * this.#metrics.ch : this.#metrics.measureTextWidth(selectionInSegment); - if (!isLastLine && wrapLine === segmentCount - 1) { - segmentWidth += this.#metrics.ch; - } + segmentWidth += paddingEnd; } this.#renderSelectionBlock( renderCtx, + type, line, wrapLine, segmentLeft, segmentWidth, - type, - isFocused + extraDataset ); } } @@ -1748,12 +1830,12 @@ export class Editor implements DiffsEditor { width: number; }; }, + type: 'selection' | 'match' | 'marker', line: number, wrapLine: number, left: number, width: number, - type: 'selection' | 'match', - isFocused: boolean = false + extraDataset?: string ) { if (width === 0) { return; @@ -1762,8 +1844,8 @@ 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 = `${type}-block-${left}-${y}-${width}-${isFocused ? 'f' : ''}`; - const selectionEls = this.#selectionElements; + const cacheKey = `${type}-${left}-${y}-${width}${extraDataset ?? ''}`; + const overlayEls = this.#overlayElements; const rounded = (this.#options.roundedSelection ?? true) && type === 'selection'; @@ -1795,9 +1877,9 @@ export class Editor implements DiffsEditor { if (cornerEl !== undefined) { return; } - if (selectionEls?.has(cacheKey) === true) { - cornerEl = selectionEls.get(cacheKey)!; - selectionEls.delete(cacheKey); + if (overlayEls?.has(cacheKey) === true) { + cornerEl = overlayEls.get(cacheKey)!; + overlayEls.delete(cacheKey); } else { cornerEl = h( 'div', @@ -1877,21 +1959,20 @@ export class Editor implements DiffsEditor { return; } - if (selectionEls?.has(cacheKey) === true) { - rangeEl = selectionEls.get(cacheKey)!; - selectionEls.delete(cacheKey); + if (overlayEls?.has(cacheKey) === true) { + rangeEl = overlayEls.get(cacheKey)!; + overlayEls.delete(cacheKey); } else { rangeEl = h( 'div', { - dataset: type + 'Range', + dataset: extraDataset + ? [type + 'Range', extraDataset] + : type + 'Range', style: { cssText: css }, }, renderCtx.fragment ); - if (type === 'match' && isFocused === true) { - rangeEl.dataset.focus = ''; - } } if (rounded) { @@ -2118,9 +2199,9 @@ export class Editor implements DiffsEditor { } if (nextMatch !== undefined) { scrollToMatch(nextMatch, true); + } else { + this.#updateSelections(this.#selections ?? []); } - this.#matches = allMatches; - this.#updateSelections(this.#selections ?? []); return nextMatch; }, onClose: () => { @@ -2195,23 +2276,24 @@ export class Editor implements DiffsEditor { return; } - const edit = isCollapsedSelection(primarySelection) - ? (() => { - const offset = textDocument.offsetAt(primarySelection.start); - const nextOffset = forward - ? Math.min(textDocument.getText().length, offset + 1) - : Math.max(0, offset - 1); - return { - start: Math.min(offset, nextOffset), - end: Math.max(offset, nextOffset), - text: '', - }; - })() - : { - start: textDocument.offsetAt(primarySelection.start), - end: textDocument.offsetAt(primarySelection.end), - text: '', - }; + let edit: ResolvedTextEdit; + if (isCollapsedSelection(primarySelection)) { + const offset = textDocument.offsetAt(primarySelection.start); + const nextOffset = forward + ? Math.min(textDocument.getText().length, offset + 1) + : Math.max(0, offset - 1); + edit = { + start: Math.min(offset, nextOffset), + end: Math.max(offset, nextOffset), + text: '', + }; + } else { + edit = { + start: textDocument.offsetAt(primarySelection.start), + end: textDocument.offsetAt(primarySelection.end), + text: '', + }; + } this.#applyResolvedTextEdit(edit); } diff --git a/packages/diffs/src/editor/marker.ts b/packages/diffs/src/editor/marker.ts new file mode 100644 index 000000000..5aeb5435b --- /dev/null +++ b/packages/diffs/src/editor/marker.ts @@ -0,0 +1,294 @@ +import { selectionIntersects } from './selection'; +import type { Position, Range, TextDocument } from './textDocument'; +import { addEventListener, getLineNumberAttr, h } from './utils'; + +const MARKER_POPUP_SHOW_DELAY_MS = 300; +const MARKER_POPUP_HIDE_DELAY_MS = 100; + +export type MarkerSeverity = 'error' | 'warning' | 'info' | 'hint'; + +export interface Marker extends Range { + severity: MarkerSeverity; + message: string | { html: string } | HTMLElement; + source?: string; + metadata?: Record; +} + +export interface EditorStub { + getLineHeight: () => number; + getFileContainer: () => HTMLElement | undefined; + getCharX: (line: number, character: number) => [number, number]; + getLineY: (line: number) => number; + isMouseDown: () => boolean; +} + +export class MarkerManager { + #editor: EditorStub; + #markers: Marker[] = []; + #markerPopupElement?: HTMLElement; + #markerPopupEventDisposes?: (() => void)[]; + #markerEventDisposes?: (() => void)[]; + #markerPopupShowTimeout?: ReturnType; + #markerPopupHideTimeout?: ReturnType; + #pendingMarkerPopupIndex?: number; + #hoveredMarkerIndex?: number; + #isMarkerPopupHovered = false; + + constructor(editor: EditorStub) { + this.#editor = editor; + } + + get markers(): readonly Marker[] { + return this.#markers; + } + + isPopupVisible(): boolean { + return this.#hoveredMarkerIndex !== undefined; + } + + setMarkers( + markers: Marker[], + textDocument: TextDocument + ): void { + this.#markers = markers.map((marker) => ({ + ...marker, + start: textDocument.normalizePosition(marker.start), + end: textDocument.normalizePosition(marker.end), + })); + this.removePopup(); + } + + listenHover(contentEl: HTMLElement): void { + this.#markerEventDisposes?.forEach((dispose) => dispose()); + this.#markerEventDisposes = undefined; + if (this.#markers.length === 0) { + return; + } + + this.#markerEventDisposes = [ + addEventListener(contentEl, 'mouseover', (e) => { + if (this.#editor.isMouseDown()) { + return; + } + const target = e.composedPath()[0] as HTMLElement | undefined; + if (target === undefined) { + return; + } + + const hoverMarkerIndex = this.#findHoveredMarkerIndex(target); + if (hoverMarkerIndex !== undefined) { + this.#scheduleMarkerPopup(hoverMarkerIndex); + } else { + this.#cancelMarkerPopupShow(); + this.#scheduleMarkerPopupHide(); + } + }), + addEventListener(contentEl, 'mouseleave', () => { + this.#cancelMarkerPopupShow(); + this.#scheduleMarkerPopupHide(); + }), + ]; + } + + removePopup(): void { + this.#cancelMarkerPopupShow(); + this.#cancelMarkerPopupHide(); + this.#dismissMarkerPopup(); + } + + cleanup(): void { + this.#markerEventDisposes?.forEach((dispose) => dispose()); + this.#markerEventDisposes = undefined; + this.removePopup(); + this.#markers = []; + } + + #findHoveredMarkerIndex(target: HTMLElement): number | undefined { + const lineElement = target.closest('[data-line]'); + if (lineElement == null) { + return; + } + + const lineNumber = getLineNumberAttr(lineElement as HTMLElement); + if (lineNumber === undefined) { + return; + } + + let character: number | undefined; + if (target.tagName === 'SPAN') { + const char = target.dataset.char; + if (char === undefined) { + return; + } + character = parseInt(char, 10); + if (Number.isNaN(character)) { + return; + } + } else if (target.tagName === 'BR') { + character = 0; + } else { + return; + } + + const position: Position = { line: lineNumber - 1, character }; + for (let i = this.#markers.length - 1; i >= 0; i--) { + if ( + selectionIntersects( + { start: position, end: position }, + this.#markers[i] + ) + ) { + return i; + } + } + return undefined; + } + + #cancelMarkerPopupShow(): void { + if (this.#markerPopupShowTimeout !== undefined) { + clearTimeout(this.#markerPopupShowTimeout); + this.#markerPopupShowTimeout = undefined; + } + this.#pendingMarkerPopupIndex = undefined; + } + + #cancelMarkerPopupHide(): void { + if (this.#markerPopupHideTimeout !== undefined) { + clearTimeout(this.#markerPopupHideTimeout); + this.#markerPopupHideTimeout = undefined; + } + } + + #scheduleMarkerPopup(markerIndex: number): void { + if ( + markerIndex === this.#hoveredMarkerIndex || + markerIndex === this.#pendingMarkerPopupIndex + ) { + this.#cancelMarkerPopupHide(); + return; + } + + this.#cancelMarkerPopupShow(); + this.#cancelMarkerPopupHide(); + if (this.#markerPopupElement !== undefined) { + this.#renderMarkerPopup(markerIndex); + return; + } + + this.#pendingMarkerPopupIndex = markerIndex; + this.#markerPopupShowTimeout = setTimeout(() => { + this.#markerPopupShowTimeout = undefined; + this.#pendingMarkerPopupIndex = undefined; + this.#renderMarkerPopup(markerIndex); + }, MARKER_POPUP_SHOW_DELAY_MS); + } + + #scheduleMarkerPopupHide(): void { + if (this.#isMarkerPopupHovered) { + return; + } + + this.#cancelMarkerPopupHide(); + this.#markerPopupHideTimeout = setTimeout(() => { + this.#markerPopupHideTimeout = undefined; + if (!this.#isMarkerPopupHovered) { + this.removePopup(); + } + }, MARKER_POPUP_HIDE_DELAY_MS); + } + + #dismissMarkerPopup(): void { + this.#markerPopupEventDisposes?.forEach((dispose) => dispose()); + this.#markerPopupEventDisposes = undefined; + this.#markerPopupElement?.remove(); + this.#markerPopupElement = undefined; + this.#hoveredMarkerIndex = undefined; + this.#isMarkerPopupHovered = false; + } + + #renderMarkerPopup(hoveredMarkerIndex: number): void { + if (hoveredMarkerIndex === this.#hoveredMarkerIndex) { + return; + } + + const fileContainer = this.#editor.getFileContainer(); + const preElement = + fileContainer?.shadowRoot?.querySelector('pre'); + const codeElement = preElement?.querySelector('[data-code]'); + if ( + hoveredMarkerIndex >= this.#markers.length || + preElement == null || + codeElement == null + ) { + return; + } + + const { start, message } = this.#markers[hoveredMarkerIndex]; + const { line, character } = start; + const { getCharX, getLineY, getLineHeight } = this.#editor; + const [left, wrapLine] = getCharX(line, character); + const lineHeight = getLineHeight(); + const y = getLineY(line) + wrapLine * lineHeight + lineHeight; + const transform = `translateX(${codeElement.offsetLeft + left}px) translateY(${codeElement.offsetTop + y}px)`; + const popup = this.#markerPopupElement; + + if (popup !== undefined) { + popup.style.transform = transform; + const content = popup.firstElementChild as HTMLElement | null; + if (content?.dataset.markerMessage !== undefined) { + if (typeof message === 'string') { + content.textContent = message; + } else if (message instanceof HTMLElement) { + content.replaceChildren(message); + } else { + content.innerHTML = message.html; + } + } + this.#hoveredMarkerIndex = hoveredMarkerIndex; + return; + } + + this.#markerPopupElement = h( + 'div', + { + dataset: ['editorWidget', 'markerPopup'], + style: { transform }, + children: [ + h('div', { + dataset: 'markerMessage', + ...(typeof message === 'string' + ? { textContent: message } + : message instanceof HTMLElement + ? { children: [message] } + : { innerHTML: message.html }), + }), + ], + }, + preElement + ); + this.#hoveredMarkerIndex = hoveredMarkerIndex; + this.#markerPopupEventDisposes = [ + addEventListener(this.#markerPopupElement, 'mouseenter', () => { + this.#isMarkerPopupHovered = true; + this.#cancelMarkerPopupHide(); + }), + addEventListener(this.#markerPopupElement, 'mouseleave', () => { + this.#isMarkerPopupHovered = false; + this.#scheduleMarkerPopupHide(); + }), + ]; + } +} + +export function markerSeverityDatasetKey(severity: MarkerSeverity): string { + switch (severity) { + case 'error': + return 'markerError'; + case 'warning': + return 'markerWarning'; + case 'info': + return 'markerInfo'; + case 'hint': + return 'markerHint'; + } +} diff --git a/packages/diffs/src/editor/searchPanel.ts b/packages/diffs/src/editor/searchPanel.ts index d667dc8fd..0296d3c1f 100644 --- a/packages/diffs/src/editor/searchPanel.ts +++ b/packages/diffs/src/editor/searchPanel.ts @@ -194,7 +194,7 @@ export class SearchPanelWidget { }); this.#container = h('div', { - dataset: 'searchPanel', + dataset: ['searchPanel', 'editorWidget'], children: [ h('div', { dataset: 'searchPanelRow', diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index f2f0328a8..944386837 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -745,7 +745,9 @@ export function applyDeleteWordBackwardToSelections( /** * Checks if a selection is collapsed. */ -export function isCollapsedSelection(selection: EditorSelection): boolean { +export function isCollapsedSelection( + selection: EditorSelection | Range +): boolean { return ( selection.start.line === selection.end.line && selection.start.character === selection.end.character @@ -775,8 +777,8 @@ export function isLineEditable(lineType: string): boolean { * Checks whether selections `a` and `b` intersect. */ export function selectionIntersects( - a: EditorSelection, - b: EditorSelection + a: EditorSelection | Range, + b: EditorSelection | Range ): boolean { const aCollapsed = isCollapsedSelection(a); const bCollapsed = isCollapsedSelection(b); diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index 4ab03bbb3..ca9baa5ea 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -171,15 +171,23 @@ export class EditorTokenizer { const findMatchBackground = colors['editor.findMatchBackground']; const findMatchHighlightBackground = colors['editor.findMatchHighlightBackground']; + const hintForeground = colors['editorHint.foreground']; + const infoForeground = colors['editorInfo.foreground']; + const warningForeground = colors['editorWarning.foreground']; + const errorForeground = colors['editorError.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};` : ''} - ${findMatchBackground !== undefined ? `--diffs-editor-match-bg: ${findMatchBackground};` : ''} - ${findMatchHighlightBackground !== undefined ? `--diffs-editor-match-highlight-bg: ${findMatchHighlightBackground};` : ''} + --diffs-editor-match-bg: ${findMatchBackground ?? 'unset'}; + --diffs-editor-match-highlight-bg: ${findMatchHighlightBackground ?? 'unset'}; + --diffs-editor-cursor-fg: ${cursorForeground ?? 'unset'}; + --diffs-editor-hint-fg: ${hintForeground ?? 'unset'}; + --diffs-editor-info-fg: ${infoForeground ?? 'unset'}; + --diffs-editor-warning-fg: ${warningForeground ?? 'unset'}; + --diffs-editor-error-fg: ${errorForeground ?? 'unset'}; }`); } diff --git a/packages/diffs/src/editor/utils.ts b/packages/diffs/src/editor/utils.ts index 5c64bb233..214cf06be 100644 --- a/packages/diffs/src/editor/utils.ts +++ b/packages/diffs/src/editor/utils.ts @@ -86,6 +86,17 @@ export function getLineNumberAttr( return lineNumber; } +export function clampDomOffset(node: Node, offset: number): number { + if (node.nodeType === 3) { + const length = (node as Text).textContent?.length ?? 0; + return Math.max(0, Math.min(offset, length)); + } + if (node.nodeType === 1) { + return Math.max(0, Math.min(offset, node.childNodes.length)); + } + return 0; +} + export function extend(obj: T, attrs: Partial): T { return Object.assign(obj, attrs); }