From efad2debb524147ea1a16e5eae967f8d6e38c5cf Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 6 Aug 2026 00:55:28 +0800 Subject: [PATCH 1/5] [diffs/edit] add decorations API --- .../docs/app/(diffs)/_edit/DecorationDemo.tsx | 55 ++ apps/docs/app/(diffs)/_edit/EditPage.tsx | 21 + .../app/(diffs)/_edit/KeyboardShortcuts.tsx | 5 +- apps/docs/app/(diffs)/_edit/constants.ts | 42 +- apps/docs/app/(diffs)/edit/page.tsx | 8 +- apps/docs/components/AppEditProvider.tsx | 6 +- packages/diffs/src/edit/index.ts | 8 +- packages/diffs/src/editor/editor.css | 3 +- packages/diffs/src/editor/editor.ts | 132 +++- packages/diffs/src/editor/selection.ts | 2 +- packages/diffs/src/react/CodeView.tsx | 26 +- packages/diffs/src/react/EditContext.tsx | 31 +- packages/diffs/src/react/File.tsx | 4 +- packages/diffs/src/react/FileDiff.tsx | 7 +- packages/diffs/src/react/MultiFileDiff.tsx | 16 +- packages/diffs/src/react/PatchDiff.tsx | 7 +- packages/diffs/src/react/types.ts | 8 +- .../src/react/utils/useFileDiffInstance.ts | 13 +- .../diffs/src/react/utils/useFileInstance.ts | 10 +- packages/diffs/src/types.ts | 5 + packages/diffs/test/editorDecorations.test.ts | 626 ++++++++++++++++++ 21 files changed, 964 insertions(+), 71 deletions(-) create mode 100644 apps/docs/app/(diffs)/_edit/DecorationDemo.tsx create mode 100644 packages/diffs/test/editorDecorations.test.ts diff --git a/apps/docs/app/(diffs)/_edit/DecorationDemo.tsx b/apps/docs/app/(diffs)/_edit/DecorationDemo.tsx new file mode 100644 index 000000000..34f48b0cb --- /dev/null +++ b/apps/docs/app/(diffs)/_edit/DecorationDemo.tsx @@ -0,0 +1,55 @@ +'use client'; + +import type { EditorOptions } from '@pierre/diffs/edit'; +import { File } from '@pierre/diffs/react'; +import type { PreloadedFileResult } from '@pierre/diffs/ssr'; +import { useMemo } from 'react'; + +import { + type CursorDecorationMetadata, + DECORATION_DEMO_DECORATIONS, +} from './constants'; + +interface DecorationDemoProps { + // Server-preloaded, highlighted File; hydrating from it avoids a highlight flash on load. + prerenderedFile: PreloadedFileResult; +} + +// Decorations render inside the editor's shadow DOM, so the collaborator +// cursors use inline styles rather than page-level Tailwind classes. +export function DecorationDemo({ prerenderedFile }: DecorationDemoProps) { + const editorOptions = useMemo< + EditorOptions + >( + () => ({ + renderDecoration({ metadata }) { + const cursor = document.createElement('span'); + cursor.ariaLabel = `${metadata.name}'s cursor`; + cursor.style.cssText = `position:relative;display:block;width:2px;height:1lh;background-color:${metadata.color};pointer-events:none;`; + + const label = document.createElement('span'); + label.ariaHidden = 'true'; + label.textContent = metadata.name; + label.style.cssText = `position:absolute;bottom:100%;left:0;padding:1px 5px;border-radius:4px 4px 4px 0;background-color:${metadata.color};color:#fff;font:500 11px/16px system-ui,sans-serif;white-space:nowrap;`; + + cursor.append(label); + return cursor; + }, + onAttach(editor) { + editor.setDecorations(DECORATION_DEMO_DECORATIONS); + }, + }), + [] + ); + + return ( +
+ + {...prerenderedFile} + className="diff-container" + edit + editorOptions={editorOptions} + /> +
+ ); +} diff --git a/apps/docs/app/(diffs)/_edit/EditPage.tsx b/apps/docs/app/(diffs)/_edit/EditPage.tsx index 2671ee225..fac2079e8 100644 --- a/apps/docs/app/(diffs)/_edit/EditPage.tsx +++ b/apps/docs/app/(diffs)/_edit/EditPage.tsx @@ -5,6 +5,7 @@ import type { import { WorkerPoolContext } from '../_components/WorkerPoolContext'; import { LiveEditing } from '../_examples/LiveEditing/LiveEditing'; +import { DecorationDemo } from './DecorationDemo'; import { EditHero } from './EditHero'; import { EditReference } from './EditReference'; import { FindDemo } from './FindDemo'; @@ -26,6 +27,7 @@ interface EditPageProps { historyFile: PreloadedFileResult; keymapFile: PreloadedFileResult; selectionFile: PreloadedFileResult; + decorationFile: PreloadedFileResult; } export function EditPage({ @@ -36,6 +38,7 @@ export function EditPage({ historyFile, keymapFile, selectionFile, + decorationFile, }: EditPageProps) { return ( @@ -68,6 +71,24 @@ export function EditPage({ +
+ + Use editor.setDecorations() to anchor arbitrary + UI to document positions. Supply typed metadata and map it to + DOM with renderDecoration()—here, custom + decorations render collaborators' cursors. Type or press{' '} + Enter before either cursor to see it follow the + surrounding code. + + } + /> + +
+
; +type ShortcutModifier = T extends `${infer Modifier}+${string}` + ? Modifier + : never; +type KeyboardModifier = ShortcutModifier; interface ShortcutRow { shortcut: EditorShortcut; diff --git a/apps/docs/app/(diffs)/_edit/constants.ts b/apps/docs/app/(diffs)/_edit/constants.ts index ebf2805ad..b55697105 100644 --- a/apps/docs/app/(diffs)/_edit/constants.ts +++ b/apps/docs/app/(diffs)/_edit/constants.ts @@ -1,4 +1,8 @@ -import { DEFAULT_THEMES, type FileContents } from '@pierre/diffs'; +import { + DEFAULT_THEMES, + type EditorDecoration, + type FileContents, +} from '@pierre/diffs'; import type { EditorCommand, EditorKeymap } from '@pierre/diffs/edit'; import type { FileOptions } from '@pierre/diffs/react'; import type { PreloadFileOptions } from '@pierre/diffs/ssr'; @@ -12,6 +16,37 @@ const EDITABLE_FILE_OPTIONS: FileOptions = { useTokenTransformer: true, }; +export interface CursorDecorationMetadata { + name: string; + color: string; +} + +export const DECORATION_DEMO_FILE: FileContents = { + name: 'review.ts', + contents: `type Review = { + author: string + approved: boolean +} + +export function summarize(review: Review) { + const status = review.approved ? 'approved' : 'needs review' + return \`\${review.author}: \${status}\` +} +`, +}; + +export const DECORATION_DEMO_DECORATIONS: EditorDecoration[] = + [ + { + position: { line: 5, character: 26 }, + metadata: { name: 'Amadeus', color: '#7c3aed' }, + }, + { + position: { line: 7, character: 18 }, + metadata: { name: 'Mark', color: '#c2410c' }, + }, + ]; + // Lint-marker demo source. Marker positions below are tied to these exact // lines, so keep the two in sync if the contents change. export const MARKER_DEMO_FILE: FileContents = { @@ -333,6 +368,11 @@ export const MARKER_DEMO_FILE_EXAMPLE: PreloadFileOptions = { options: EDITABLE_FILE_OPTIONS, }; +export const DECORATION_DEMO_FILE_EXAMPLE: PreloadFileOptions = { + file: DECORATION_DEMO_FILE, + options: EDITABLE_FILE_OPTIONS, +}; + export const FIND_DEMO_FILE_EXAMPLE: PreloadFileOptions = { file: FIND_DEMO_FILE, options: EDITABLE_FILE_OPTIONS, diff --git a/apps/docs/app/(diffs)/edit/page.tsx b/apps/docs/app/(diffs)/edit/page.tsx index 4f7ee4bad..07fdc4b67 100644 --- a/apps/docs/app/(diffs)/edit/page.tsx +++ b/apps/docs/app/(diffs)/edit/page.tsx @@ -2,6 +2,7 @@ import { preloadFile, preloadFileDiff } from '@pierre/diffs/ssr'; import type { Metadata } from 'next'; import { + DECORATION_DEMO_FILE_EXAMPLE, DEFAULT_KEYMAP_FILE_EXAMPLE, FIND_DEMO_FILE_EXAMPLE, HISTORY_DEMO_FILE_EXAMPLE, @@ -16,7 +17,7 @@ import { const editTitle = 'Pierre Diffs — now with edit'; const editDescription = - 'A lightweight, SSR, mobile-friendly editable file and diff layer for @pierre/diffs. Edit files and diffs in place with selection management, multiple cursors, undo history, find/replace, and lint markers.'; + 'A lightweight, SSR, mobile-friendly editable file and diff layer for @pierre/diffs. Edit files and diffs in place with selection management, multiple cursors, virtual cursors, undo history, find/replace, and lint markers.'; export const metadata: Metadata = { title: editTitle, @@ -34,7 +35,7 @@ export const metadata: Metadata = { // Server-renders every edit demo so they all paint highlighted on first load // and hydrate cleanly (no flash): the "Live editing" File surface, and the -// lint-marker, find-in-file, undo-history, shortcuts, and selection files. +// lint-marker, find-in-file, undo-history, shortcuts, selection, and decoration files. export default async function EditRoute() { const [ liveEditingFile, @@ -44,6 +45,7 @@ export default async function EditRoute() { historyFile, keymapFile, selectionFile, + decorationFile, ] = await Promise.all([ preloadFile(LIVE_EDITING_FILE_EXAMPLE), preloadFileDiff(LIVE_EDITING_FILE_DIFF_EXAMPLE), @@ -52,6 +54,7 @@ export default async function EditRoute() { preloadFile(HISTORY_DEMO_FILE_EXAMPLE), preloadFile(DEFAULT_KEYMAP_FILE_EXAMPLE), preloadFile(SELECTION_DEMO_FILE_EXAMPLE), + preloadFile(DECORATION_DEMO_FILE_EXAMPLE), ]); return ( @@ -63,6 +66,7 @@ export default async function EditRoute() { historyFile={historyFile} keymapFile={keymapFile} selectionFile={selectionFile} + decorationFile={decorationFile} /> ); } diff --git a/apps/docs/components/AppEditProvider.tsx b/apps/docs/components/AppEditProvider.tsx index 9cf2a0033..6afcd186a 100644 --- a/apps/docs/components/AppEditProvider.tsx +++ b/apps/docs/components/AppEditProvider.tsx @@ -4,9 +4,9 @@ import { Editor, type EditorOptions } from '@pierre/diffs/edit'; import { EditProvider } from '@pierre/diffs/react'; import type { ReactNode } from 'react'; -function createEditor( - options: EditorOptions -): Editor { +function createEditor( + options: EditorOptions +): Editor { return new Editor(options); } diff --git a/packages/diffs/src/edit/index.ts b/packages/diffs/src/edit/index.ts index 009e0e94e..b4371dc9a 100644 --- a/packages/diffs/src/edit/index.ts +++ b/packages/diffs/src/edit/index.ts @@ -2,8 +2,6 @@ export type { EditorCommand, EditorKeymap, EditorShortcut, - KeyboardKey, - KeyboardModifier, } from '../editor/command'; export * from '../editor/editor'; export type { @@ -11,4 +9,8 @@ export type { PersistStateStorage, } from '../editor/stateStorage'; export * from '../editor/textDocument'; -export type { EditorChange, EditorChangeEvent } from '../types'; +export type { + EditorChange, + EditorChangeEvent, + EditorDecoration, +} from '../types'; diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index a65a8bf78..888ffcf57 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -72,7 +72,8 @@ [data-selection-range], [data-match-range], [data-bracket-match-range], -[data-marker-range] { +[data-marker-range], +[data-editor-decoration] { position: absolute; top: 0; left: 0; diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index b8af7cd87..348a3fdf8 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -9,6 +9,7 @@ import type { DiffsHighlighter, EditableInstance, EditorChangeEvent, + EditorDecoration, EditorSelection, EditorState, FileContents, @@ -96,6 +97,7 @@ import { mapCursorMove, mapSelectionShift, mergeOverlappingSelections, + remapOffsetThroughEdits, remapSelectionsAfterEdits, resolveIndentEdits, resolveSelectionCut, @@ -188,7 +190,12 @@ interface ViewportInputWatch { dispose(): void; } -export interface EditorOptions { +interface TrackedDecoration { + decoration: EditorDecoration; + offset: number; +} + +export interface EditorOptions { /** The maximum number of entries to keep in the undo stack. */ historyMaxEntries?: number; /** Custom keymap groups checked before defaults; later groups take precedence. */ @@ -231,9 +238,11 @@ export interface EditorOptions { renderSelectionAction?: ( context: SelectionActionContext ) => HTMLElement; + /** Render a decoration. */ + renderDecoration?: (decoration: EditorDecoration) => HTMLElement; /** Callback when the editor is attached to a file. */ onAttach?: ( - editor: Editor, + editor: Editor, fileInstance: DiffsEditableComponent ) => void; /** Callback when the editor document changes. */ @@ -278,8 +287,11 @@ const SELECTION_ACTION_POPOVER_PLACEMENT_KEY = 'selection-action'; const MULTI_SELECTION_CLIPBOARD_TYPE = 'application/vnd.pierre.diffs-selections+json'; -export class Editor implements DiffsEditor { - #options: EditorOptions; +export class Editor< + LAnnotation, + LDecoration = undefined, +> implements DiffsEditor { + #options: EditorOptions; #metrics = new Metrics(); #tokenizer?: EditorTokenizer; #popoverManager?: PopoverManager; @@ -335,6 +347,7 @@ export class Editor implements DiffsEditor { #contentElement?: HTMLElement; #overlayElement?: HTMLElement; #overlayElements?: Map; + #decorationElements?: Map, HTMLElement>; #primaryCaretElement?: HTMLElement; #resizeObserver?: ResizeObserver; @@ -352,6 +365,7 @@ export class Editor implements DiffsEditor { // windows, where no cap is needed. #viewportWindowLines?: number; #markerRenderer?: MarkerRenderer; + #decorations?: TrackedDecoration[]; #searchPanel?: SearchPanelWidget; #selectionAction?: SelectionActionWidget; // Programmatic ranges stay passive until the user interacts with the editor. @@ -424,11 +438,12 @@ export class Editor implements DiffsEditor { } }; - constructor(options: EditorOptions = {}) { + constructor(options: EditorOptions = {}) { this.#options = options; } - setOptions(options: EditorOptions): void { + setOptions(options: EditorOptions): void { + const previousRenderDecoration = this.#options.renderDecoration; const previousStorageOption = this.#options.persistStateStorage ?? 'inMemory'; const nextOptions = { @@ -445,6 +460,11 @@ export class Editor implements DiffsEditor { } } this.#options = nextOptions; + if (previousRenderDecoration !== nextOptions.renderDecoration) { + this.#decorationElements?.forEach((element) => element.remove()); + this.#decorationElements = undefined; + this.#renderDecorations(); + } if (this.#options.persistState !== true) { this.#textDocumentCache.clear(); this.#stateRestoreGeneration++; @@ -694,6 +714,28 @@ export class Editor implements DiffsEditor { this.#updateSelections(this.#selections ?? []); } + setDecorations(decorations: EditorDecoration[]): void { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + return; + } + this.#decorationElements?.forEach((element) => element.remove()); + this.#decorationElements = undefined; + this.#decorations = + decorations.length === 0 + ? undefined + : decorations.map((decoration) => { + const position = textDocument.normalizePosition( + decoration.position + ); + return { + decoration: { ...decoration, position }, + offset: textDocument.offsetAt(position), + }; + }); + this.#renderDecorations(); + } + focus(options?: EditorFocusOptions): void { const preventScroll = options?.preventScroll ?? false; const lineNumber = options?.lineNumber; @@ -814,7 +856,7 @@ export class Editor implements DiffsEditor { this.#themeSelectionRefreshFrame = undefined; } - this.#resetState(); + this.#resetState(recycle); this.#fileInstance = undefined; } @@ -1129,6 +1171,7 @@ export class Editor implements DiffsEditor { ) { this.#updateSelections(this.#selections ?? []); } + this.#renderDecorations(); if ( this.#initSelections !== undefined && @@ -1467,7 +1510,7 @@ export class Editor implements DiffsEditor { this.#lastAccessedCharX = undefined; } - #resetState(): void { + #resetState(preserveDecorations = false): void { this.#setEditorActiveLineSafe(null); this.#gutterWidthCache = undefined; this.#contentWidthCache = undefined; @@ -1475,6 +1518,11 @@ export class Editor implements DiffsEditor { this.#suppressNativeSelectionSync = false; this.#overlayElements?.forEach((el) => el.remove()); this.#overlayElements = undefined; + this.#decorationElements?.forEach((element) => element.remove()); + this.#decorationElements = undefined; + if (!preserveDecorations) { + this.#decorations = undefined; + } this.#selections = undefined; this.#reservedSelections = undefined; this.#scrollingToLine = undefined; @@ -3149,6 +3197,7 @@ export class Editor implements DiffsEditor { } this.#markerRenderer?.removePopover(); this.#computeContentOffset(this.#contentElement!); + this.#renderDecorations(); }; // A custom monospace web font can finish loading after the editor first @@ -3186,6 +3235,7 @@ export class Editor implements DiffsEditor { ) { this.#updateSelections(this.#selections ?? []); } + this.#renderDecorations(); this.#markerRenderer?.removePopover(); }); } @@ -3379,6 +3429,7 @@ export class Editor implements DiffsEditor { this.#lineAnnotations = newLineAnnotations; renderLineAnnotations(newLineAnnotations, contentEl, gutterEl); } + this.#renderDecorations(); if (this.#options.__debug === true) { console.log( @@ -4065,6 +4116,56 @@ export class Editor implements DiffsEditor { } } + // Mount visible custom decorations outside contenteditable and keep the + // consumer's element untouched inside a library-owned position anchor. + #renderDecorations(): void { + const decorations = this.#decorations; + const renderDecoration = this.#options.renderDecoration; + const overlayElement = this.#overlayElement; + if ( + decorations === undefined || + renderDecoration === undefined || + overlayElement === undefined + ) { + this.#decorationElements?.forEach((element) => element.remove()); + this.#decorationElements = undefined; + return; + } + + const elements = (this.#decorationElements ??= new Map()); + for (const [trackedDecoration, element] of elements) { + if (!this.#isLineVisible(trackedDecoration.decoration.position.line)) { + element.remove(); + elements.delete(trackedDecoration); + } + } + + const fragment = document.createDocumentFragment(); + for (const trackedDecoration of decorations) { + const { decoration } = trackedDecoration; + const { line, character } = decoration.position; + if (!this.#isLineVisible(line)) { + continue; + } + const [left, wrapLine] = this.#getCharX(line, character); + const top = this.#getLineY(line) + wrapLine * this.#metrics.lineHeight; + let element = elements.get(trackedDecoration); + if (element === undefined) { + element = h( + 'div', + { + dataset: 'editorDecoration', + children: [renderDecoration(decoration)], + }, + fragment + ); + elements.set(trackedDecoration, element); + } + element.style.transform = `translateX(${left}px) translateY(${top}px)`; + } + overlayElement.appendChild(fragment); + } + // Re-render the selection overlay after a theme swap so rounded corner masks // recompute their `--diffs-selection-corner-bg`. Those masks capture the // resolved line-background color when the selection is drawn; a light/dark or @@ -5228,6 +5329,21 @@ export class Editor implements DiffsEditor { newLineAnnotations?: DiffLineAnnotation[], options?: { skipSearchRefresh?: boolean; skipFocus?: boolean } ) { + const textDocument = this.#textDocument; + if (textDocument !== undefined && this.#decorations !== undefined) { + // Remap before onChange so decorations replaced by that callback are not + // shifted through the same edit a second time. + for (const trackedDecoration of this.#decorations) { + trackedDecoration.offset = remapOffsetThroughEdits( + trackedDecoration.offset, + change.changes + ); + trackedDecoration.decoration.position = textDocument.positionAt( + trackedDecoration.offset + ); + } + } + const fileRef = this.getFile(); const onChange = this.#options.onChange; if (fileRef !== undefined && onChange !== undefined) { diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index cb83b2e15..99322d3ea 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -1384,7 +1384,7 @@ export function createSelectionFromAnchorAndFocusOffsets( * the caret past it; an offset strictly before an edit is only shifted by the * net length change of the edits that precede it. */ -function remapOffsetThroughEdits( +export function remapOffsetThroughEdits( offset: number, edits: readonly ResolvedTextEdit[] ): number { diff --git a/packages/diffs/src/react/CodeView.tsx b/packages/diffs/src/react/CodeView.tsx index 7aee20bdc..1d19581a3 100644 --- a/packages/diffs/src/react/CodeView.tsx +++ b/packages/diffs/src/react/CodeView.tsx @@ -54,13 +54,13 @@ export type CodeViewReactOptions = Omit< 'controlledSelection' | 'createEditor' | 'onSelectedLinesChange' >; -interface CodeViewBaseProps { +interface CodeViewBaseProps { options?: CodeViewReactOptions; /** * Creation-time options passed to the nearest EditProvider factory. * CodeView supplies its item-specific change callback. */ - editorOptions?: Omit, 'onChange'>; + editorOptions?: Omit, 'onChange'>; className?: string; style?: CSSProperties; containerRef?: Ref; @@ -112,23 +112,25 @@ interface CodeViewBaseProps { export interface ControlledCodeViewProps< LAnnotation, -> extends CodeViewBaseProps { + LDecoration = undefined, +> extends CodeViewBaseProps { items: readonly CodeViewItem[]; initialItems?: never; } export interface UncontrolledCodeViewProps< LAnnotation, -> extends CodeViewBaseProps { + LDecoration = undefined, +> extends CodeViewBaseProps { // Seeds the imperative CodeView instance once. Later item changes should go // through the ref API instead of being reconciled from React props. initialItems?: readonly CodeViewItem[]; items?: never; } -export type CodeViewProps = - | ControlledCodeViewProps - | UncontrolledCodeViewProps; +export type CodeViewProps = + | ControlledCodeViewProps + | UncontrolledCodeViewProps; export interface CodeViewHandle { addItems(items: readonly CodeViewItem[]): void; @@ -144,8 +146,8 @@ export interface CodeViewHandle { getInstance(): CodeViewClass | undefined; } -type CodeViewComponent = ( - props: CodeViewProps & { +type CodeViewComponent = ( + props: CodeViewProps & { ref?: React.Ref>; } ) => React.JSX.Element; @@ -182,8 +184,8 @@ function createDefaultCache( }; } -function CodeViewInner( - props: CodeViewProps, +function CodeViewInner( + props: CodeViewProps, ref: React.ForwardedRef> ): React.JSX.Element { const { @@ -210,7 +212,7 @@ function CodeViewInner( style, } = props; const controlled = controlledItems !== undefined; - const contextCreateEditor = useCreateEditor(); + const contextCreateEditor = useCreateEditor(); const poolManager = useContext(WorkerPoolContext); const cachedDataRef = useRef>( createDefaultCache(controlled) diff --git a/packages/diffs/src/react/EditContext.tsx b/packages/diffs/src/react/EditContext.tsx index e915b2762..46f10fa2d 100644 --- a/packages/diffs/src/react/EditContext.tsx +++ b/packages/diffs/src/react/EditContext.tsx @@ -9,29 +9,36 @@ import type { DiffsEditor } from '../types'; import { useStableCallback } from './utils/useStableCallback'; /** Creates an Editor. Components manage the instance lifecycle. */ -export type CreateEditor = ( - options: EditorOptions +export type CreateEditor = ( + options: EditorOptions ) => DiffsEditor; -export interface EditProviderProps { +export interface EditProviderProps { /** Combines shared defaults with the supplied per-surface options. */ - createEditor: CreateEditor; + createEditor: CreateEditor; } -export const EditContext: Context | undefined> = - createContext | undefined>(undefined); +export const EditContext: Context | undefined> = + createContext | undefined>(undefined); -export function EditProvider({ +export function EditProvider({ children, createEditor, -}: PropsWithChildren>): React.JSX.Element { +}: PropsWithChildren< + EditProviderProps +>): React.JSX.Element { // Editors cached by options-object identity: an edit session that restarts // with the same `editorOptions` const editorCacheRef = useRef( - new WeakMap, DiffsEditor>() + new WeakMap< + EditorOptions, + DiffsEditor + >() ); const stableCreateEditor = useStableCallback( - (options: EditorOptions): DiffsEditor => { + ( + options: EditorOptions + ): DiffsEditor => { const cached = editorCacheRef.current.get(options); if (cached != null) { return cached; @@ -48,8 +55,8 @@ export function EditProvider({ ); } -export function useCreateEditor(): - | CreateEditor +export function useCreateEditor(): + | CreateEditor | undefined { return useContext(EditContext); } diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx index ad329fbfe..dd09807b1 100644 --- a/packages/diffs/src/react/File.tsx +++ b/packages/diffs/src/react/File.tsx @@ -9,7 +9,7 @@ import { useFileInstance } from './utils/useFileInstance'; export type { FileOptions }; -export function File({ +export function File({ file, lineAnnotations, selectedLines, @@ -27,7 +27,7 @@ export function File({ renderGutterUtility, disableWorkerPool = false, edit = false, -}: FileProps): React.JSX.Element { +}: FileProps): React.JSX.Element { const { ref, getHoveredLine } = useFileInstance({ file, options, diff --git a/packages/diffs/src/react/FileDiff.tsx b/packages/diffs/src/react/FileDiff.tsx index d7009c50e..c91db9b4d 100644 --- a/packages/diffs/src/react/FileDiff.tsx +++ b/packages/diffs/src/react/FileDiff.tsx @@ -11,12 +11,13 @@ export type { FileDiffMetadata }; export interface FileDiffProps< LAnnotation, -> extends DiffBasePropsReact { + LDecoration = undefined, +> extends DiffBasePropsReact { fileDiff: FileDiffMetadata; disableWorkerPool?: boolean; } -export function FileDiff({ +export function FileDiff({ fileDiff, options, editorOptions, @@ -34,7 +35,7 @@ export function FileDiff({ renderGutterUtility, disableWorkerPool = false, edit = false, -}: FileDiffProps): React.JSX.Element { +}: FileDiffProps): React.JSX.Element { const { ref, getHoveredLine } = useFileDiffInstance({ fileDiff, options, diff --git a/packages/diffs/src/react/MultiFileDiff.tsx b/packages/diffs/src/react/MultiFileDiff.tsx index fe437f2a5..d731b2780 100644 --- a/packages/diffs/src/react/MultiFileDiff.tsx +++ b/packages/diffs/src/react/MultiFileDiff.tsx @@ -14,14 +14,20 @@ export type { FileContents }; interface MultiFileDiffBaseProps< LAnnotation, -> extends DiffBasePropsReact { + LDecoration = undefined, +> extends DiffBasePropsReact { disableWorkerPool?: boolean; } -export type MultiFileDiffProps = - MultiFileDiffBaseProps & DiffFileInput; +export type MultiFileDiffProps< + LAnnotation, + LDecoration = undefined, +> = MultiFileDiffBaseProps & DiffFileInput; -export function MultiFileDiff({ +export function MultiFileDiff< + LAnnotation = undefined, + LDecoration = undefined, +>({ oldFile, newFile, options, @@ -40,7 +46,7 @@ export function MultiFileDiff({ renderGutterUtility, disableWorkerPool = false, edit = false, -}: MultiFileDiffProps): React.JSX.Element { +}: MultiFileDiffProps): React.JSX.Element { const fileDiff = useMemo(() => { return parseDiffFromFile(oldFile, newFile, options?.parseDiffOptions); }, [oldFile, newFile, options?.parseDiffOptions]); diff --git a/packages/diffs/src/react/PatchDiff.tsx b/packages/diffs/src/react/PatchDiff.tsx index 16417f804..1704823c7 100644 --- a/packages/diffs/src/react/PatchDiff.tsx +++ b/packages/diffs/src/react/PatchDiff.tsx @@ -12,12 +12,13 @@ import { useFileDiffInstance } from './utils/useFileDiffInstance'; export interface PatchDiffProps< LAnnotation, -> extends DiffBasePropsReact { + LDecoration = undefined, +> extends DiffBasePropsReact { patch: string; disableWorkerPool?: boolean; } -export function PatchDiff({ +export function PatchDiff({ patch, options, editorOptions, @@ -35,7 +36,7 @@ export function PatchDiff({ renderGutterUtility, disableWorkerPool = false, edit = false, -}: PatchDiffProps): React.JSX.Element { +}: PatchDiffProps): React.JSX.Element { const fileDiff = usePatch(patch); const { ref, getHoveredLine } = useFileDiffInstance({ fileDiff, diff --git a/packages/diffs/src/react/types.ts b/packages/diffs/src/react/types.ts index dff3e9007..e6c1e80d8 100644 --- a/packages/diffs/src/react/types.ts +++ b/packages/diffs/src/react/types.ts @@ -13,12 +13,12 @@ import type { VirtualFileMetrics, } from '../types'; -export interface DiffBasePropsReact { +export interface DiffBasePropsReact { options?: FileDiffOptions; /** Whether this surface has an active edit session. */ edit?: boolean; /** Creation-time options passed to the nearest EditProvider factory. */ - editorOptions?: EditorOptions; + editorOptions?: EditorOptions; metrics?: VirtualFileMetrics; lineAnnotations?: DiffLineAnnotation[]; selectedLines?: SelectedLineRange | null; @@ -35,13 +35,13 @@ export interface DiffBasePropsReact { prerenderedHTML?: string; } -export interface FileProps { +export interface FileProps { file: FileContents; options?: FileOptions; /** Whether this surface has an active edit session. */ edit?: boolean; /** Creation-time options passed to the nearest EditProvider factory. */ - editorOptions?: EditorOptions; + editorOptions?: EditorOptions; metrics?: VirtualFileMetrics; lineAnnotations?: LineAnnotation[]; selectedLines?: SelectedLineRange | null; diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts index a7a1848fb..7abd04dee 100644 --- a/packages/diffs/src/react/utils/useFileDiffInstance.ts +++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts @@ -26,10 +26,10 @@ import { useStableCallback } from './useStableCallback'; const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -interface UseFileDiffInstanceProps { +interface UseFileDiffInstanceProps { fileDiff: FileDiffMetadata; options: FileDiffOptions | undefined; - editorOptions: EditorOptions | undefined; + editorOptions: EditorOptions | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; @@ -45,7 +45,7 @@ interface UseFileDiffInstanceReturn { getHoveredLine(): GetHoveredLineResult<'diff'> | undefined; } -export function useFileDiffInstance({ +export function useFileDiffInstance({ fileDiff, options, editorOptions, @@ -57,11 +57,14 @@ export function useFileDiffInstance({ hasCustomHeader, disableWorkerPool, edit, -}: UseFileDiffInstanceProps): UseFileDiffInstanceReturn { +}: UseFileDiffInstanceProps< + LAnnotation, + LDecoration +>): UseFileDiffInstanceReturn { const simpleVirtualizer = useVirtualizer(); const controlledSelection = selectedLines !== undefined; const poolManager = useContext(WorkerPoolContext); - const createEditor = useCreateEditor(); + const createEditor = useCreateEditor(); const instanceRef = useRef< FileDiff | VirtualizedFileDiff | null >(null); diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 5208af962..e0b968c3c 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -26,10 +26,10 @@ import { useStableCallback } from './useStableCallback'; const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -interface UseFileInstanceProps { +interface UseFileInstanceProps { file: FileContents; options: FileOptions | undefined; - editorOptions: EditorOptions | undefined; + editorOptions: EditorOptions | undefined; lineAnnotations: LineAnnotation[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; @@ -49,7 +49,7 @@ interface UseFileInstanceReturn { getHoveredLine(): GetHoveredLineResult<'file'> | undefined; } -export function useFileInstance({ +export function useFileInstance({ file, options, editorOptions, @@ -61,11 +61,11 @@ export function useFileInstance({ hasCustomHeader, disableWorkerPool, edit, -}: UseFileInstanceProps): UseFileInstanceReturn { +}: UseFileInstanceProps): UseFileInstanceReturn { const simpleVirtualizer = useVirtualizer(); const controlledSelection = selectedLines !== undefined; const poolManager = useContext(WorkerPoolContext); - const createEditor = useCreateEditor(); + const createEditor = useCreateEditor(); const instanceRef = useRef< File | VirtualizedFile | null >(null); diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 67c013c7f..be27f1213 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1253,6 +1253,11 @@ export interface EditorSelection extends Range { direction: SelectionDirection; } +export interface EditorDecoration { + position: Position; + metadata: T; +} + export interface EditorViewState { /** Horizontal position owned by the current editable code scroller. */ scrollLeft: number; diff --git a/packages/diffs/test/editorDecorations.test.ts b/packages/diffs/test/editorDecorations.test.ts new file mode 100644 index 000000000..b9e1c8581 --- /dev/null +++ b/packages/diffs/test/editorDecorations.test.ts @@ -0,0 +1,626 @@ +import { afterAll, describe, expect, mock, test } from 'bun:test'; + +import { File } from '../src/components/File'; +import { DEFAULT_THEMES } from '../src/constants'; +import { Editor, type EditorOptions } from '../src/editor/editor'; +import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import type { EditorDecoration, FileContents } from '../src/types'; +import { installDom, wait } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +interface DecorationMetadata { + id: string; +} + +async function waitForEditableContent( + container: HTMLElement +): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const content = container.shadowRoot?.querySelector('[data-content]'); + if ( + content instanceof HTMLElement && + (content.contentEditable === 'true' || + content.getAttribute('contenteditable') === 'true') + ) { + return content; + } + await wait(0); + } + throw new Error('editor content did not become editable'); +} + +async function createEditorFixture( + contents: string, + options: EditorOptions +): Promise<{ + cleanup(): void; + content: HTMLElement; + editor: Editor; + fileContainer: HTMLElement; +}> { + const dom = installDom(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const editor = new Editor(options); + const initialFile: FileContents = { + name: 'decorations.ts', + contents, + }; + + file.render({ file: initialFile, fileContainer, forceRender: true }); + editor.edit(file); + const content = await waitForEditableContent(fileContainer); + + return { + cleanup() { + editor.cleanUp(); + file.cleanUp(); + dom.cleanup(); + }, + content, + editor, + fileContainer, + }; +} + +function decorationElement(id: string): HTMLElement { + const element = document.createElement('span'); + element.dataset.peer = id; + element.textContent = id; + return element; +} + +function getDecorationAnchor( + fileContainer: HTMLElement, + id: string +): HTMLElement { + const rendered = fileContainer.shadowRoot?.querySelector( + `[data-peer="${id}"]` + ); + const anchor = rendered?.parentElement; + if (anchor?.dataset.editorDecoration === undefined) { + throw new Error(`decoration ${id} was not rendered`); + } + return anchor; +} + +function getDecorationTransform(element: HTMLElement): { + x: number; + y: number; +} { + const match = /translateX\(([-\d.]+)px\) translateY\(([-\d.]+)px\)/.exec( + element.style.transform + ); + if (match === null) { + throw new Error(`invalid decoration transform: ${element.style.transform}`); + } + return { x: Number(match[1]), y: Number(match[2]) }; +} + +describe('Editor decorations', () => { + test('normalizes positions and mounts the custom renderer inside an anchor', async () => { + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const { cleanup, editor, fileContainer } = await createEditorFixture( + 'alpha\nbravo', + { renderDecoration } + ); + const metadata = { id: 'ada' }; + const input: EditorDecoration = { + position: { line: 99, character: 99 }, + metadata, + }; + + try { + editor.setDecorations([input]); + + const anchor = fileContainer.shadowRoot?.querySelector( + '[data-editor-decoration]' + ); + expect(anchor).not.toBeNull(); + expect(anchor?.firstElementChild).toBe( + fileContainer.shadowRoot?.querySelector('[data-peer="ada"]') + ); + expect(anchor?.style.transform).toMatch( + /^translateX\([\d.-]+px\) translateY\([\d.-]+px\)$/ + ); + expect(renderDecoration).toHaveBeenCalledTimes(1); + expect(renderDecoration.mock.calls[0]?.[0]).toEqual({ + position: { line: 1, character: 5 }, + metadata, + }); + expect(input.position).toEqual({ line: 99, character: 99 }); + } finally { + cleanup(); + } + }); + + test('replaces and clears decorations, including peers at one position', async () => { + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const { cleanup, editor, fileContainer } = await createEditorFixture( + 'alpha\nbravo', + { renderDecoration } + ); + const position = { line: 0, character: 2 }; + + try { + editor.setDecorations([ + { position, metadata: { id: 'ada' } }, + { position, metadata: { id: 'grace' } }, + ]); + const firstAnchors = Array.from( + fileContainer.shadowRoot?.querySelectorAll( + '[data-editor-decoration]' + ) ?? [] + ); + expect(firstAnchors).toHaveLength(2); + expect( + firstAnchors.map((anchor) => anchor.firstElementChild?.textContent) + ).toEqual(['ada', 'grace']); + + editor.setDecorations([ + { position: { line: 1, character: 1 }, metadata: { id: 'linus' } }, + ]); + expect(firstAnchors.every((anchor) => !anchor.isConnected)).toBe(true); + expect( + fileContainer.shadowRoot?.querySelectorAll('[data-editor-decoration]') + ).toHaveLength(1); + expect( + fileContainer.shadowRoot?.querySelector('[data-peer="linus"]') + ).not.toBeNull(); + + const replacement = fileContainer.shadowRoot?.querySelector( + '[data-editor-decoration]' + ); + editor.setDecorations([]); + expect(replacement?.isConnected).toBe(false); + expect( + fileContainer.shadowRoot?.querySelectorAll('[data-editor-decoration]') + ).toHaveLength(0); + expect(renderDecoration).toHaveBeenCalledTimes(3); + } finally { + cleanup(); + } + }); + + test('reuses renderer nodes while an edit repaints geometry without a selection', async () => { + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const { cleanup, editor, fileContainer } = await createEditorFixture( + 'aa-tail', + { renderDecoration } + ); + + try { + editor.setDecorations([ + { + position: { line: 0, character: 2 }, + metadata: { id: 'ada' }, + }, + ]); + expect(editor.getState().selections).toBeUndefined(); + + const anchor = fileContainer.shadowRoot?.querySelector( + '[data-editor-decoration]' + ); + const rendered = anchor?.firstElementChild; + const xBefore = Number( + /translateX\(([-\d.]+)px\)/.exec(anchor?.style.transform ?? '')?.[1] + ); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + newText: '\t', + }, + ]); + + const repainted = fileContainer.shadowRoot?.querySelector( + '[data-editor-decoration]' + ); + const xAfter = Number( + /translateX\(([-\d.]+)px\)/.exec(repainted?.style.transform ?? '')?.[1] + ); + expect(Number.isFinite(xBefore)).toBe(true); + expect(xAfter).toBeGreaterThan(xBefore); + expect(repainted).toBe(anchor); + expect(repainted?.firstElementChild).toBe(rendered); + expect(renderDecoration).toHaveBeenCalledTimes(1); + expect(renderDecoration.mock.calls[0]?.[0].position).toEqual({ + line: 0, + character: 2, + }); + expect(editor.getState().selections).toBeUndefined(); + } finally { + cleanup(); + } + }); + + test('moves a decoration when real typing inserts text before it', async () => { + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const { cleanup, content, editor, fileContainer } = + await createEditorFixture('abcdef', { renderDecoration }); + + try { + editor.setDecorations([ + { + position: { line: 0, character: 4 }, + metadata: { id: 'ada' }, + }, + ]); + const before = getDecorationTransform( + getDecorationAnchor(fileContainer, 'ada') + ); + editor.setSelections([ + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + direction: 'none', + }, + ]); + + const view = content.ownerDocument.defaultView; + if (view === null) { + throw new Error('editor content has no window'); + } + const event = new view.InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + composed: true, + data: 'XY', + inputType: 'insertText', + }); + content.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(editor.getText()).toBe('aXYbcdef'); + const after = getDecorationTransform( + getDecorationAnchor(fileContainer, 'ada') + ); + expect(after.x - before.x).toBe(16); + expect(after.y).toBe(before.y); + } finally { + cleanup(); + } + }); + + test('maps a batched applyEdits insertion before and exactly at the decoration', async () => { + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const { cleanup, editor, fileContainer } = await createEditorFixture( + 'abcdefghij', + { renderDecoration } + ); + + try { + editor.setDecorations([ + { + position: { line: 0, character: 5 }, + metadata: { id: 'ada' }, + }, + ]); + const before = getDecorationTransform( + getDecorationAnchor(fileContainer, 'ada') + ); + + // Input order is deliberately not document order. Both the insertion + // before the point and the insertion exactly at it have right gravity. + editor.applyEdits([ + { + range: { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + }, + newText: '!', + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'XX', + }, + { + range: { + start: { line: 0, character: 3 }, + end: { line: 0, character: 4 }, + }, + newText: '', + }, + ]); + + expect(editor.getText()).toBe('aXXbce!fghij'); + const after = getDecorationTransform( + getDecorationAnchor(fileContainer, 'ada') + ); + // Original character 5 + 2 inserted - 1 deleted + 1 inserted at point. + expect(after.x - before.x).toBe(16); + expect(after.y).toBe(before.y); + } finally { + cleanup(); + } + }); + + test('maps decorations through deletion and replacement ranges', async () => { + for (const { expectedAfterDelta, expectedInsideDelta, newText } of [ + { expectedAfterDelta: -24, expectedInsideDelta: -8, newText: '' }, + { expectedAfterDelta: 8, expectedInsideDelta: 24, newText: 'WXYZ' }, + ]) { + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const { cleanup, editor, fileContainer } = await createEditorFixture( + 'abcdefgh', + { renderDecoration } + ); + + try { + editor.setDecorations([ + { + position: { line: 0, character: 3 }, + metadata: { id: 'inside' }, + }, + { + position: { line: 0, character: 7 }, + metadata: { id: 'after' }, + }, + ]); + const insideBefore = getDecorationTransform( + getDecorationAnchor(fileContainer, 'inside') + ); + const afterBefore = getDecorationTransform( + getDecorationAnchor(fileContainer, 'after') + ); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 5 }, + }, + newText, + }, + ]); + + const insideAfter = getDecorationTransform( + getDecorationAnchor(fileContainer, 'inside') + ); + const afterAfter = getDecorationTransform( + getDecorationAnchor(fileContainer, 'after') + ); + expect(insideAfter.x - insideBefore.x).toBe(expectedInsideDelta); + expect(afterAfter.x - afterBefore.x).toBe(expectedAfterDelta); + } finally { + cleanup(); + } + } + }); + + test('tracks multiline edits through undo and redo', async () => { + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const { cleanup, editor, fileContainer } = await createEditorFixture( + 'zero\none\ntwo\nthree', + { renderDecoration } + ); + const offsetTopDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetTop' + ); + Object.defineProperty(HTMLElement.prototype, 'offsetTop', { + configurable: true, + get(this: HTMLElement): number { + const lineNumber = Number(this.dataset.line); + return lineNumber > 0 ? (lineNumber - 1) * 20 : 0; + }, + }); + + try { + editor.setDecorations([ + { + position: { line: 2, character: 2 }, + metadata: { id: 'ada' }, + }, + ]); + const original = getDecorationTransform( + getDecorationAnchor(fileContainer, 'ada') + ); + + editor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + }, + newText: 'new-a\nnew-b\n', + }, + ]); + const inserted = getDecorationTransform( + getDecorationAnchor(fileContainer, 'ada') + ); + expect(inserted.y - original.y).toBe(40); + expect(inserted.x).toBe(original.x); + + editor.undo(); + expect( + getDecorationTransform(getDecorationAnchor(fileContainer, 'ada')) + ).toEqual(original); + editor.redo(); + expect( + getDecorationTransform(getDecorationAnchor(fileContainer, 'ada')) + ).toEqual(inserted); + + editor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 3, character: 0 }, + }, + newText: '', + }, + ]); + expect(editor.getText()).toBe('zero\none\ntwo\nthree'); + expect( + getDecorationTransform(getDecorationAnchor(fileContainer, 'ada')) + ).toEqual(original); + } finally { + if (offsetTopDescriptor === undefined) { + Reflect.deleteProperty(HTMLElement.prototype, 'offsetTop'); + } else { + Object.defineProperty( + HTMLElement.prototype, + 'offsetTop', + offsetTopDescriptor + ); + } + cleanup(); + } + }); + + test('does not remap decorations replaced synchronously by onChange', async () => { + const editorRef: { + current?: Editor; + } = {}; + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const onChange = mock(() => { + editorRef.current?.setDecorations([ + { + // onChange receives the post-edit document, so this is already the + // final coordinate and must not be mapped through the edit again. + position: { line: 0, character: 1 }, + metadata: { id: 'fresh' }, + }, + ]); + }); + const { cleanup, editor, fileContainer } = await createEditorFixture( + 'abcdef', + { onChange, renderDecoration } + ); + editorRef.current = editor; + + try { + editor.setDecorations([ + { + position: { line: 0, character: 1 }, + metadata: { id: 'stale' }, + }, + ]); + const expected = getDecorationTransform( + getDecorationAnchor(fileContainer, 'stale') + ); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'XX', + }, + ]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect( + fileContainer.shadowRoot?.querySelector('[data-peer="stale"]') + ).toBe(null); + expect( + getDecorationTransform(getDecorationAnchor(fileContainer, 'fresh')) + ).toEqual(expected); + } finally { + cleanup(); + } + }); + + test('keeps definitions through recycle and clears them after full cleanup', async () => { + const dom = installDom(); + const renderDecoration = mock( + (decoration: EditorDecoration) => + decorationElement(decoration.metadata.id) + ); + const editor = new Editor({ + renderDecoration, + }); + const fileContents: FileContents = { + name: 'decorations.ts', + contents: 'alpha\nbravo', + }; + const files: File[] = []; + + const attach = async (): Promise => { + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + files.push(file); + file.render({ file: fileContents, fileContainer, forceRender: true }); + editor.edit(file); + await waitForEditableContent(fileContainer); + return fileContainer; + }; + + try { + const first = await attach(); + editor.setDecorations([ + { + position: { line: 1, character: 2 }, + metadata: { id: 'ada' }, + }, + ]); + expect( + first.shadowRoot?.querySelector('[data-peer="ada"]') + ).not.toBeNull(); + + editor.cleanUp(true); + files[0].cleanUp(); + const second = await attach(); + expect( + second.shadowRoot?.querySelector('[data-peer="ada"]') + ).not.toBeNull(); + expect(renderDecoration).toHaveBeenCalledTimes(2); + + editor.cleanUp(); + files[1].cleanUp(); + const third = await attach(); + expect(third.shadowRoot?.querySelector('[data-editor-decoration]')).toBe( + null + ); + expect(renderDecoration).toHaveBeenCalledTimes(2); + } finally { + editor.cleanUp(); + for (const file of files) { + file.cleanUp(); + } + dom.cleanup(); + } + }); +}); From 2c7a0093063e57dfdfb1223d404ac25af1de8ed6 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 6 Aug 2026 01:23:21 +0800 Subject: [PATCH 2/5] clean up --- packages/diffs/src/editor/editor.ts | 152 +++++++++--------- packages/diffs/src/editor/textMeasure.ts | 19 +++ packages/diffs/test/editorTextMeasure.test.ts | 11 ++ 3 files changed, 103 insertions(+), 79 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 348a3fdf8..d4f0c57e7 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -463,7 +463,9 @@ export class Editor< if (previousRenderDecoration !== nextOptions.renderDecoration) { this.#decorationElements?.forEach((element) => element.remove()); this.#decorationElements = undefined; - this.#renderDecorations(); + if (this.#decorations !== undefined) { + this.#renderDecorations(this.#decorations); + } } if (this.#options.persistState !== true) { this.#textDocumentCache.clear(); @@ -733,7 +735,9 @@ export class Editor< offset: textDocument.offsetAt(position), }; }); - this.#renderDecorations(); + if (this.#decorations !== undefined) { + this.#renderDecorations(this.#decorations); + } } focus(options?: EditorFocusOptions): void { @@ -1171,7 +1175,11 @@ export class Editor< ) { this.#updateSelections(this.#selections ?? []); } - this.#renderDecorations(); + + // re-render the existing decorations + if (this.#decorations !== undefined) { + this.#renderDecorations(this.#decorations); + } if ( this.#initSelections !== undefined && @@ -3195,9 +3203,11 @@ export class Editor< this.focus(); } } + if (this.#decorations !== undefined) { + this.#renderDecorations(this.#decorations); + } this.#markerRenderer?.removePopover(); this.#computeContentOffset(this.#contentElement!); - this.#renderDecorations(); }; // A custom monospace web font can finish loading after the editor first @@ -3235,7 +3245,9 @@ export class Editor< ) { this.#updateSelections(this.#selections ?? []); } - this.#renderDecorations(); + if (this.#decorations !== undefined) { + this.#renderDecorations(this.#decorations); + } this.#markerRenderer?.removePopover(); }); } @@ -3429,7 +3441,9 @@ export class Editor< this.#lineAnnotations = newLineAnnotations; renderLineAnnotations(newLineAnnotations, contentEl, gutterEl); } - this.#renderDecorations(); + if (this.#decorations !== undefined) { + this.#renderDecorations(this.#decorations); + } if (this.#options.__debug === true) { console.log( @@ -4116,56 +4130,6 @@ export class Editor< } } - // Mount visible custom decorations outside contenteditable and keep the - // consumer's element untouched inside a library-owned position anchor. - #renderDecorations(): void { - const decorations = this.#decorations; - const renderDecoration = this.#options.renderDecoration; - const overlayElement = this.#overlayElement; - if ( - decorations === undefined || - renderDecoration === undefined || - overlayElement === undefined - ) { - this.#decorationElements?.forEach((element) => element.remove()); - this.#decorationElements = undefined; - return; - } - - const elements = (this.#decorationElements ??= new Map()); - for (const [trackedDecoration, element] of elements) { - if (!this.#isLineVisible(trackedDecoration.decoration.position.line)) { - element.remove(); - elements.delete(trackedDecoration); - } - } - - const fragment = document.createDocumentFragment(); - for (const trackedDecoration of decorations) { - const { decoration } = trackedDecoration; - const { line, character } = decoration.position; - if (!this.#isLineVisible(line)) { - continue; - } - const [left, wrapLine] = this.#getCharX(line, character); - const top = this.#getLineY(line) + wrapLine * this.#metrics.lineHeight; - let element = elements.get(trackedDecoration); - if (element === undefined) { - element = h( - 'div', - { - dataset: 'editorDecoration', - children: [renderDecoration(decoration)], - }, - fragment - ); - elements.set(trackedDecoration, element); - } - element.style.transform = `translateX(${left}px) translateY(${top}px)`; - } - overlayElement.appendChild(fragment); - } - // Re-render the selection overlay after a theme swap so rounded corner masks // recompute their `--diffs-selection-corner-bg`. Those masks capture the // resolved line-background color when the selection is drawn; a light/dark or @@ -4479,7 +4443,7 @@ export class Editor< continue; } - const segmentStartWidth = this.#segmentTextWidth( + const segmentStartWidth = this.#metrics.segmentTextWidth( lineText, segmentStart, wrapStartChar @@ -4499,7 +4463,11 @@ export class Editor< const segmentWidth = wrapStartChar === wrapEndChar ? paddingEnd - : this.#segmentTextWidth(lineText, segmentStart, wrapEndChar) - + : this.#metrics.segmentTextWidth( + lineText, + segmentStart, + wrapEndChar + ) - segmentStartWidth + paddingEnd; @@ -4515,27 +4483,6 @@ export class Editor< } } - // Pixel width of the text from a wrapped segment's start up to a character, - // relative to the segment's left edge. Tabs advance from the segment start, - // which sits on a tab stop, so tab stops line up with the rendered text. - #segmentTextWidth( - lineText: string, - segmentStart: number, - character: number - ): number { - if (character <= segmentStart) { - return 0; - } - const segmentText = lineText.slice(segmentStart, character); - const asciiColumns = getExpandedAsciiTextColumns( - segmentText, - this.#metrics.tabSize - ); - return asciiColumns !== -1 - ? asciiColumns * this.#metrics.ch - : this.#metrics.measureTextWidth(segmentText); - } - // Render one selection block for a single visual line. #renderSelectionBlock( renderCtx: { @@ -4749,6 +4696,53 @@ export class Editor< } } + #renderDecorations(decorations: TrackedDecoration[]): void { + const renderDecoration = this.#options.renderDecoration; + const overlayElement = this.#overlayElement; + if ( + decorations === undefined || + renderDecoration === undefined || + overlayElement === undefined + ) { + this.#decorationElements?.forEach((element) => element.remove()); + this.#decorationElements = undefined; + return; + } + + const elements = (this.#decorationElements ??= new Map()); + for (const [trackedDecoration, element] of elements) { + if (!this.#isLineVisible(trackedDecoration.decoration.position.line)) { + element.remove(); + elements.delete(trackedDecoration); + } + } + + const fragment = document.createDocumentFragment(); + for (const trackedDecoration of decorations) { + const { decoration } = trackedDecoration; + const { line, character } = decoration.position; + if (!this.#isLineVisible(line)) { + continue; + } + const [left, wrapLine] = this.#getCharX(line, character); + const top = this.#getLineY(line) + wrapLine * this.#metrics.lineHeight; + let element = elements.get(trackedDecoration); + if (element === undefined) { + element = h( + 'div', + { + dataset: 'editorDecoration', + children: [renderDecoration(decoration)], + }, + fragment + ); + elements.set(trackedDecoration, element); + } + element.style.transform = `translateX(${left}px) translateY(${top}px)`; + } + overlayElement.appendChild(fragment); + } + #updateSelectionActionPopover(): void { const primarySelection = this.#selections?.at(-1); const overlayElement = this.#overlayElement; diff --git a/packages/diffs/src/editor/textMeasure.ts b/packages/diffs/src/editor/textMeasure.ts index 2ac818466..246e82cc6 100644 --- a/packages/diffs/src/editor/textMeasure.ts +++ b/packages/diffs/src/editor/textMeasure.ts @@ -142,6 +142,25 @@ export class Metrics { return round(width); } + /** + * Pixel width from a wrapped segment's start up to a character. Tabs advance + * from the segment start so their stops line up with the rendered text. + */ + segmentTextWidth( + lineText: string, + segmentStart: number, + character: number + ): number { + if (character <= segmentStart) { + return 0; + } + const segmentText = lineText.slice(segmentStart, character); + const asciiColumns = getExpandedAsciiTextColumns(segmentText, this.tabSize); + return asciiColumns !== -1 + ? asciiColumns * this.ch + : this.measureTextWidth(segmentText); + } + #measureTextWidthWithoutTabs(text: string): number { if (needsDomTextMeasurement(text)) { return this.domMeasureTextWidth(text); diff --git a/packages/diffs/test/editorTextMeasure.test.ts b/packages/diffs/test/editorTextMeasure.test.ts index 3fe03bf82..e3043f005 100644 --- a/packages/diffs/test/editorTextMeasure.test.ts +++ b/packages/diffs/test/editorTextMeasure.test.ts @@ -269,6 +269,17 @@ describe('Metrics.measureTextWidth (tab stops)', () => { cleanup(); } }); + + test('measures text relative to a wrapped segment start', () => { + const { cleanup, metrics } = installTabMetrics(); + try { + expect(metrics.segmentTextWidth('prefixabcx\t', 6, 9)).toBe(30); + expect(metrics.segmentTextWidth('prefixabcx\t', 6, 11)).toBe(80); + expect(metrics.segmentTextWidth('prefix', 6, 6)).toBe(0); + } finally { + cleanup(); + } + }); }); describe('Metrics.remeasureCharacterWidth', () => { From 99c6d68cbc90bae392b94689d3506b84efb95155 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 6 Aug 2026 23:42:03 +0800 Subject: [PATCH 3/5] Update docs --- apps/docs/app/(diffs)/_docs/DocsPage.tsx | 4 ++ apps/docs/app/(diffs)/docs/Edit/constants.ts | 67 +++++++++++++++++++- apps/docs/app/(diffs)/docs/Edit/content.mdx | 27 ++++++-- 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index 2f4be5c3a..ad449434a 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -31,6 +31,7 @@ import { CUSTOM_HUNK_SEPARATORS_SWITCHER, } from '../docs/CustomHunkSeparators/constants'; import { + EDIT_DECORATION_EXAMPLE, EDIT_FOCUS_POSITION_EXAMPLE, EDIT_LAZY_FILE_EXAMPLE, EDIT_MARKER_EXAMPLE, @@ -445,6 +446,7 @@ async function EditSection() { editSelectionActionExample, editPersistStateExample, editPersistStateReactExample, + editDecorationExample, editMarkerType, editMarkerExample, editReactCreateEditorExample, @@ -472,6 +474,7 @@ async function EditSection() { preloadFile(EDIT_SELECTION_ACTION_EXAMPLE), preloadFile(EDIT_PERSIST_STATE_EXAMPLE), preloadFile(EDIT_PERSIST_STATE_REACT_EXAMPLE), + preloadFile(EDIT_DECORATION_EXAMPLE), preloadFile(EDIT_MARKER_TYPE), preloadFile(EDIT_MARKER_EXAMPLE), preloadFile(EDIT_REACT_CREATE_EDITOR_EXAMPLE), @@ -502,6 +505,7 @@ async function EditSection() { editSelectionActionExample, editPersistStateExample, editPersistStateReactExample, + editDecorationExample, editMarkerType, editMarkerExample, editReactCreateEditorExample, diff --git a/apps/docs/app/(diffs)/docs/Edit/constants.ts b/apps/docs/app/(diffs)/docs/Edit/constants.ts index e71a8fd78..d11b1a92b 100644 --- a/apps/docs/app/(diffs)/docs/Edit/constants.ts +++ b/apps/docs/app/(diffs)/docs/Edit/constants.ts @@ -477,6 +477,41 @@ editor.setMarkers([]);`, options, }; +export const EDIT_DECORATION_EXAMPLE: PreloadFileOptions = { + file: { + name: 'editor_decorations.ts', + contents: `import { Editor } from '@pierre/diffs/edit'; + +interface CursorMetadata { + name: string; + color: string; +} + +const editor = new Editor({ + renderDecoration({ metadata }) { + const cursor = document.createElement('span'); + cursor.ariaLabel = \`\${metadata.name}'s cursor\`; + cursor.style.cssText = \`display:block;width:2px;height:1lh;background:\${metadata.color}\`; + return cursor; + }, + onAttach(editor) { + editor.setDecorations([ + { + position: { line: 5, character: 26 }, + metadata: { name: 'Ada', color: '#7c3aed' }, + }, + ]); + }, +}); + +editor.edit(fileInstance); + +// Each call replaces every decoration. Pass an empty array to clear them. +editor.setDecorations([]);`, + }, + options, +}; + export const EDIT_UNDO_REDO_EXAMPLE: PreloadFileOptions = { file: { name: 'editor_undo_redo.tsx', @@ -1018,11 +1053,12 @@ export const EDITOR_OPTIONS_TYPE: PreloadFileOptions = { } from '@pierre/diffs'; import { Editor, + type EditorDecoration, type EditorKeymap, type IStateStorage, } from '@pierre/diffs/edit'; -interface EditorOptions { +interface EditorOptions { // Max undo stack entries historyMaxEntries?: number; @@ -1071,9 +1107,14 @@ interface EditorOptions { // Custom Selection Action UI. See Selection Action docs for context shape. renderSelectionAction?: (context) => HTMLElement; + // Render custom UI anchored to an EditorDecoration's document position. + renderDecoration?: ( + decoration: EditorDecoration + ) => HTMLElement; + // Fires after attach when the text document is ready onAttach?: ( - editor: Editor, + editor: Editor, fileInstance: DiffsEditableComponent ) => void; @@ -1173,6 +1214,10 @@ export const EDITOR_PUBLIC_API: PreloadFileOptions = { } from '@pierre/diffs'; import { Editor, type EditorFocusOptions } from '@pierre/diffs/edit'; +interface DecorationMetadata { + label: string; +} + // Editor // Most methods require an attached surface via edit(). @@ -1182,7 +1227,13 @@ fileInstance.render({ containerWrapper: document.body, }); -const editor = new Editor(); +const editor = new Editor({ + renderDecoration({ metadata }) { + const element = document.createElement('span'); + element.textContent = metadata.label; + return element; + }, +}); // Merge partial options at runtime. Existing fields are preserved. // onChange and similar handlers read from the latest options on each call; @@ -1255,6 +1306,16 @@ editor.setMarkers([ ]); editor.setMarkers([]); +// Anchor custom DOM to zero-based document positions. Each call replaces the +// complete decoration set; pass [] to clear. Call after attaching. +editor.setDecorations([ + { + position: { line: 1, character: 2 }, + metadata: { label: 'Ada' }, + }, +]); +editor.setDecorations([]); + // Focus the editable content. preventScroll skips scrolling the caret into view. // Blur removes focus from the content area. editor.focus(); diff --git a/apps/docs/app/(diffs)/docs/Edit/content.mdx b/apps/docs/app/(diffs)/docs/Edit/content.mdx index 94b9d2f2f..ca5d86a32 100644 --- a/apps/docs/app/(diffs)/docs/Edit/content.mdx +++ b/apps/docs/app/(diffs)/docs/Edit/content.mdx @@ -18,7 +18,7 @@ Edit mode features include: - History (undo and redo) - Find-in-file search and replace - [Selection Action](#edit-mode-selection-action) (opt-in, custom UI) -- Markers (inline diagnostics) +- Markers (inline diagnostics) and custom decorations - SSR support - Mobile-friendly - Lightweight @@ -34,8 +34,9 @@ Edit mode is not a full-fledged IDE, though you can build IDE-like experiences on top of it. It is purpose-built for code rendered by this library: the existing file and diff surfaces keep their diff layout, annotations, syntax highlighting, SSR, and virtualization, and edit mode adds editing, multiple -selections, history, search and replace, and markers. That focus makes it a -natural fit for “review and correct” flows with generative code changes. +selections, history, search and replace, markers, and custom decorations. That +focus makes it a natural fit for “review and correct” flows with generative code +changes. ### How It Works @@ -99,9 +100,9 @@ selections, and scroll position survive switches — see Changes to `createEditor` or `editorOptions` do not disturb an active edit session; their latest values apply the next time `edit` transitions from false to true. Use `onAttach` with your own ref when controls need imperative APIs -such as history, selections, markers, save, or search. Use `Virtualizer` for -large editable files. The `FileDiff` tab below shows the controlled annotation -feedback loop. +such as history, selections, markers, decorations, save, or search. Use +`Virtualizer` for large editable files. The `FileDiff` tab below shows the +controlled annotation feedback loop. -### Markers +### Markers & Decorations Markers add inline diagnostics — errors, warnings, and other annotations — to the editable surface, with a hover popover that shows each marker's message. @@ -313,6 +314,18 @@ the `severity` literals against the `Marker` type without importing it. Markers re-anchor as the document changes, so they stay attached to their text while you edit. Pass an empty array to clear them. +Decorations anchor arbitrary UI to a zero-based document `position`. Supply +typed `metadata` with each decoration, then map it to an `HTMLElement` with the +`renderDecoration` editor option. The editor owns positioning while your +renderer owns the returned element. + + + +Call `editor.setDecorations(decorations)` after the editor has attached. Like +markers, decorations re-anchor as the document changes. Each call replaces the +complete decoration set; pass an empty array to clear it. The second generic on +`Editor` and `EditorOptions` controls the metadata type. + ### History Each editor keeps a single undo stack per file. Typed input and programmatic From 686db2ac4e1091afd6734af6098cdabace1fa7ca Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 7 Aug 2026 00:25:39 +0800 Subject: [PATCH 4/5] Update docs --- apps/docs/app/(diffs)/docs/Edit/content.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/docs/app/(diffs)/docs/Edit/content.mdx b/apps/docs/app/(diffs)/docs/Edit/content.mdx index ca5d86a32..bbedeaac2 100644 --- a/apps/docs/app/(diffs)/docs/Edit/content.mdx +++ b/apps/docs/app/(diffs)/docs/Edit/content.mdx @@ -19,6 +19,7 @@ Edit mode features include: - Find-in-file search and replace - [Selection Action](#edit-mode-selection-action) (opt-in, custom UI) - Markers (inline diagnostics) and custom decorations +- Decorations (arbitrary UI) - SSR support - Mobile-friendly - Lightweight @@ -293,7 +294,7 @@ yields one instance across file switches: -### Markers & Decorations +### Markers Markers add inline diagnostics — errors, warnings, and other annotations — to the editable surface, with a hover popover that shows each marker's message. @@ -312,6 +313,9 @@ the `severity` literals against the `Marker` type without importing it. Markers re-anchor as the document changes, so they stay attached to their text + +### Decorations + while you edit. Pass an empty array to clear them. Decorations anchor arbitrary UI to a zero-based document `position`. Supply From d569b9c025901800b2461945788acdd90e5447cc Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 7 Aug 2026 01:13:49 +0800 Subject: [PATCH 5/5] fix testing --- packages/diffs/test/editorDecorations.test.ts | 66 ++++++++----------- 1 file changed, 26 insertions(+), 40 deletions(-) diff --git a/packages/diffs/test/editorDecorations.test.ts b/packages/diffs/test/editorDecorations.test.ts index b9e1c8581..220e0caf8 100644 --- a/packages/diffs/test/editorDecorations.test.ts +++ b/packages/diffs/test/editorDecorations.test.ts @@ -420,25 +420,17 @@ describe('Editor decorations', () => { }); test('tracks multiline edits through undo and redo', async () => { + let renderedDecoration: EditorDecoration | undefined; const renderDecoration = mock( - (decoration: EditorDecoration) => - decorationElement(decoration.metadata.id) + (decoration: EditorDecoration) => { + renderedDecoration = decoration; + return decorationElement(decoration.metadata.id); + } ); - const { cleanup, editor, fileContainer } = await createEditorFixture( + const { cleanup, editor } = await createEditorFixture( 'zero\none\ntwo\nthree', { renderDecoration } ); - const offsetTopDescriptor = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - 'offsetTop' - ); - Object.defineProperty(HTMLElement.prototype, 'offsetTop', { - configurable: true, - get(this: HTMLElement): number { - const lineNumber = Number(this.dataset.line); - return lineNumber > 0 ? (lineNumber - 1) * 20 : 0; - }, - }); try { editor.setDecorations([ @@ -447,9 +439,10 @@ describe('Editor decorations', () => { metadata: { id: 'ada' }, }, ]); - const original = getDecorationTransform( - getDecorationAnchor(fileContainer, 'ada') - ); + expect(renderedDecoration?.position).toEqual({ + line: 2, + character: 2, + }); editor.applyEdits([ { @@ -460,20 +453,21 @@ describe('Editor decorations', () => { newText: 'new-a\nnew-b\n', }, ]); - const inserted = getDecorationTransform( - getDecorationAnchor(fileContainer, 'ada') - ); - expect(inserted.y - original.y).toBe(40); - expect(inserted.x).toBe(original.x); + expect(renderedDecoration?.position).toEqual({ + line: 4, + character: 2, + }); editor.undo(); - expect( - getDecorationTransform(getDecorationAnchor(fileContainer, 'ada')) - ).toEqual(original); + expect(renderedDecoration?.position).toEqual({ + line: 2, + character: 2, + }); editor.redo(); - expect( - getDecorationTransform(getDecorationAnchor(fileContainer, 'ada')) - ).toEqual(inserted); + expect(renderedDecoration?.position).toEqual({ + line: 4, + character: 2, + }); editor.applyEdits([ { @@ -485,19 +479,11 @@ describe('Editor decorations', () => { }, ]); expect(editor.getText()).toBe('zero\none\ntwo\nthree'); - expect( - getDecorationTransform(getDecorationAnchor(fileContainer, 'ada')) - ).toEqual(original); + expect(renderedDecoration?.position).toEqual({ + line: 2, + character: 2, + }); } finally { - if (offsetTopDescriptor === undefined) { - Reflect.deleteProperty(HTMLElement.prototype, 'offsetTop'); - } else { - Object.defineProperty( - HTMLElement.prototype, - 'offsetTop', - offsetTopDescriptor - ); - } cleanup(); } });