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);
}