Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/docs/app/(diffs)/docs/CodeView/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,44 @@ For vanilla `CodeView`, keep using `CodeViewOptions.createEditor`. It exposes
the same item-aware callbacks, and CodeView owns each returned editor's
lifecycle.

#### Autofocus on Attach

Autofocus is opt-in per edit session. In React, pass a stable `editOptions`
object whose `onAttach` callback targets the first editable row with a visible
top edge:

```tsx
const editOptions: EditorOptions<ThreadMetadata> = {
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
};

<CodeView items={items} editOptions={editOptions} />;
```

For vanilla `CodeView`, add the callback while constructing each item editor:

```ts
const viewer = new CodeView({
createEditor(options) {
return new Editor({
...options,
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
});
},
});
```

`preventScroll: true` preserves the viewer's scroll position. `CodeView` keeps
the editor alive while an item is recycled, so the callback does not steal focus
again when that item re-enters the virtualized window. If several items start an
autofocusing edit session together, the last callback to run owns focus. See
[Autofocus on Attach](#edit-mode-autofocus-on-attach) for explicit line targets,
viewport fallback, offsets, and selection-state behavior.

### Padding &amp; Gap

For controlling layout inside and between items in `CodeView`, you can use the
Expand Down
36 changes: 30 additions & 6 deletions apps/docs/app/(diffs)/docs/Editor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,12 @@ root.style.overflow = 'auto';
const viewer = new CodeView<ThreadMetadata>({
theme: { dark: 'pierre-dark', light: 'pierre-light' },
createEditor(options) {
return new Editor(options);
return new Editor({
...options,
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
});
},
onItemEditChange(item, _file, nextAnnotations) {
if (
Expand Down Expand Up @@ -737,6 +742,12 @@ const initialItems: CodeViewItem<ThreadMetadata>[] = [

const codeViewStyle = { height: '24rem', overflow: 'auto' } as const;

const editOptions: EditorOptions<ThreadMetadata> = {
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
};

function createEditor(options: EditorOptions<ThreadMetadata>) {
return new Editor(options);
}
Expand Down Expand Up @@ -823,6 +834,7 @@ export function EditableCodeView() {
<CodeView
items={items}
style={codeViewStyle}
editOptions={editOptions}
onItemEditChange={syncAnnotations}
onItemEditComplete={commitEdit}
renderAnnotation={(annotation) => (
Expand Down Expand Up @@ -931,7 +943,7 @@ interface EditorOptions<LAnnotation> {
// Max undo stack entries
historyMaxEntries?: number;

// Preserve each File's document and editor state between renders.
// Preserve each File's document and item-local editor state between renders.
// Requires every editable file to provide a unique, stable cacheKey.
// Default: false.
persistState?: boolean;
Expand Down Expand Up @@ -1001,7 +1013,7 @@ export const EDITOR_PUBLIC_API: PreloadFileOptions<undefined> = {
type EditorState,
type FileContents,
} from '@pierre/diffs';
import { Editor } from '@pierre/diffs/editor';
import { Editor, type EditorFocusOptions } from '@pierre/diffs/editor';

// Editor
// Most methods require an attached surface via edit().
Expand Down Expand Up @@ -1053,14 +1065,14 @@ const file: FileContents | undefined = editor.getFile();
// Full document text, or '' when nothing is attached.
const text: string = editor.getText();

// Snapshot selections and scroll position for persistence or remount restore.
// Snapshot selections and horizontal code position for explicit restoration.
const state: EditorState = editor.getState();
// EditorState = {
// selections?: EditorSelection[];
// view?: { scrollLeft: number; scrollTop: number };
// view?: { scrollLeft: number };
// }

// Restore selections and scroll after re-rendering the underlying component.
// Restore selections and horizontal code position after re-rendering.
editor.setState(state);

// Replace all cursors and ranges programmatically. Positions are zero-based;
Expand Down Expand Up @@ -1089,6 +1101,18 @@ editor.setMarkers([]);
// Blur removes focus from the content area.
editor.focus();
editor.focus({ preventScroll: true });

// Numeric line numbers are one-based; character offsets are zero-based.
editor.focus({ lineNumber: 13, character: 4 });

// Target the first editable row whose top is visible. offset adds a
// non-negative CSS-pixel inset below the viewport or sticky file header.
const focusOptions: EditorFocusOptions = {
lineNumber: 'first-visible',
offset: 8,
preventScroll: true,
};
editor.focus(focusOptions);
editor.blur();

// Whether there is an edit to undo or redo.
Expand Down
64 changes: 59 additions & 5 deletions apps/docs/app/(diffs)/docs/Editor/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,60 @@ sessions keep their current editor, while the latest values apply to the next
item that enters edit mode. Simultaneously edited items always receive
independent editor instances.

### Autofocus on Attach

Use `onAttach` to opt into initial caret placement. At this point the text
document and editable DOM are ready. In React, pass a stable `editOptions`
object to any editable surface, including `CodeView`:

```tsx
const editOptions = useMemo<EditorOptions<undefined>>(
() => ({
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
}),
[]
);

return <CodeView items={items} editOptions={editOptions} />;
```

Vanilla `CodeView` can add the same behavior in its editor factory:

```ts
const viewer = new CodeView({
createEditor(options) {
return new Editor({
...options,
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
});
},
});
```

`'first-visible'` selects the first editable row whose top falls inside the
usable viewport and places the caret at character zero. If no editable row top
is visible, the call does nothing. Set `offset` to a non-negative number of CSS
pixels to move the usable top below the viewport if needed. With
`preventScroll: true`, the focus request does not change the vertical scroll
position.

You can also target a specific document position:

```ts
editor.focus({ lineNumber: 13, character: 4 });
```

Numeric `lineNumber` values are one-based, while `character` values are
zero-based. Numeric targeting works on every attached editor. Any targeted focus
replaces the current selection, so use either this initial placement or restored
selection/view state as the owner of the edit session's starting position, not
both. `CodeView` retains an editor while its item is recycled, so `onAttach` is
not repeated for virtualized remounts in the same edit session.

### Editor Options

Pass these when constructing `new Editor({ ... })`, or update them later with
Expand All @@ -265,10 +319,10 @@ Pass these when constructing `new Editor({ ... })`, or update them later with
#### Persisting file state

State persistence is disabled by default. Set `persistState: true` to preserve
an editable `File`'s text document, undo history, selections, and scroll
position when the attached file renders a different file and later returns. This
currently applies to `File` and `VirtualizedFile` surfaces; diff surfaces do not
use the persistence cache.
an editable `File`'s text document, undo history, selections, and horizontal
code scroll position when the attached file renders a different file and later
returns. This currently applies to `File` and `VirtualizedFile` surfaces; diff
surfaces do not use the persistence cache.

Every editable file must provide an explicit, non-empty `file.cacheKey` while
`persistState` is enabled. The editor throws if it encounters an unkeyed file;
Expand Down Expand Up @@ -312,7 +366,7 @@ const editorWithCustomStorage = new Editor({
`persistStateStorage` accepts `'inMemory'`, `'indexedDB'`, or an `IStateStorage`
implementation. It defaults to `'inMemory'`. Text documents and undo history
remain scoped to the `Editor` instance; IndexedDB and custom storage persist
only the serializable editor state (`selections` and `view`).
only serializable item-local editor state.

### API Reference

Expand Down
1 change: 1 addition & 0 deletions apps/docs/app/(diffs)/playground/PlaygroundClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
() => ({
onAttach(editor: Editor<PlaygroundAnnotationMetadata>) {
editorRef.current = editor;
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
onChange: (_file, lineAnnotations) => {
if (
Expand Down
8 changes: 8 additions & 0 deletions apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseDiffFromFile,
type SelectedLineRange,
} from '@pierre/diffs';
import type { EditorOptions } from '@pierre/diffs/editor';
import {
CodeView,
type CodeViewReactOptions,
Expand All @@ -32,6 +33,12 @@ const CODE_VIEW_STYLES = { height: '70vh', overflow: 'auto' } as const;

type PlaygroundItem = CodeViewItem<PlaygroundAnnotationMetadata>;

const CODE_VIEW_EDIT_OPTIONS: EditorOptions<PlaygroundAnnotationMetadata> = {
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
};

interface PlaygroundCodeViewProps {
items: PlaygroundItem[];
options: CodeViewReactOptions<PlaygroundAnnotationMetadata>;
Expand Down Expand Up @@ -420,6 +427,7 @@ export function PlaygroundCodeView({

return (
<CodeView
editOptions={CODE_VIEW_EDIT_OPTIONS}
items={items}
className="border-border rounded-lg border"
style={CODE_VIEW_STYLES}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ function ElementVirtualizerDiff({
// nowhere for the frames in between.
const editOptions = useMemo<EditorOptions<PlaygroundAnnotationMetadata>>(
() => ({
onAttach(editor) {
editor.focus({ lineNumber: 'first-visible', preventScroll: true });
},
onChange(_file, lineAnnotations) {
if (
lineAnnotations != null &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ export function PlaygroundVirtualizerView({
// pre-edit lines. An annotation whose line was deleted is dropped from
// the set; retire its orphaned React root.
const editor = new Editor<VirtualizerAnnotationMetadata>({
onAttach(attachedEditor) {
attachedEditor.focus({
lineNumber: 'first-visible',
preventScroll: true,
});
},
onChange: (_file, lineAnnotations) => {
if (
lineAnnotations == null ||
Expand Down
21 changes: 17 additions & 4 deletions packages/diffs/src/components/File.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,16 @@ export class File<
});
}

public getCodeScrollLeft(): number {
return this.code?.scrollLeft ?? 0;
}

public setCodeScrollLeft(position: number): void {
if (this.code != null) {
this.code.scrollLeft = position;
}
}

public flushManagers(): void {
if (!this.managersDirty || this.pre == null) {
this.managersDirty = false;
Expand All @@ -323,6 +333,9 @@ export class File<

public cleanUp(recycle = false): void {
this.emitPostRender(true);
// Persist editor state while the code scroller still exists.
this.editor?.cleanUp(recycle);
this.editor = undefined;
this.resizeManager.cleanUp();
this.interactionManager.cleanUp();
this.managersDirty = false;
Expand Down Expand Up @@ -373,11 +386,7 @@ export class File<
this.workerManager = undefined;
this.file = undefined;
}

this.enabled = false;

this.editor?.cleanUp(recycle);
this.editor = undefined;
}

public virtualizedSetup(): void {
Expand Down Expand Up @@ -1476,6 +1485,9 @@ export class File<
previousContainer ??
document.createElement(DIFFS_TAG_NAME);
const containerChanged = previousContainer !== nextContainer;
if (previousContainer != null && containerChanged) {
this.editor?.__captureFocusForDOMReplacement();
}
if (containerChanged) {
this.emitPostRender(true);
}
Expand Down Expand Up @@ -1555,6 +1567,7 @@ export class File<
// If we have a new parent container for the pre element, lets go ahead and
// move it into the new container
else if (this.pre.parentNode !== shadowRoot) {
this.editor?.__captureFocusForDOMReplacement();
container.shadowRoot?.appendChild(this.pre);
this.appliedPreAttributes = undefined;
}
Expand Down
Loading