Skip to content
Closed
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
20 changes: 20 additions & 0 deletions apps/docs/app/(diffs)/_docs/DocsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ import {
REACT_API_SHARED_FILE_RENDER_PROPS,
REACT_API_UNRESOLVED_FILE,
} from '../docs/ReactAPI/constants';
import {
SPAN_DECORATIONS_REACT,
SPAN_DECORATIONS_VANILLA,
} from '../docs/SpanDecorations/constants';
import {
SSR_PRELOAD_FILE,
SSR_PRELOAD_FILE_DIFF,
Expand Down Expand Up @@ -169,6 +173,7 @@ export default function DocsPage() {
<StylingSection />
<ThemingSection />
<TokenHooksSection />
<SpanDecorationsSection />
<WorkerPoolSection />
<SSRSection />
</div>
Expand Down Expand Up @@ -503,6 +508,21 @@ async function TokenHooksSection() {
return <ProseWrapper>{content}</ProseWrapper>;
}

async function SpanDecorationsSection() {
const [reactSpanDecorations, vanillaSpanDecorations] = await Promise.all([
preloadFile(SPAN_DECORATIONS_REACT),
preloadFile(SPAN_DECORATIONS_VANILLA),
]);
const content = await renderMDX({
filePath: '(diffs)/docs/SpanDecorations/content.mdx',
scope: {
reactSpanDecorations,
vanillaSpanDecorations,
},
});
return <ProseWrapper>{content}</ProseWrapper>;
}

async function SSRSection() {
const [
usageServer,
Expand Down
36 changes: 36 additions & 0 deletions apps/docs/app/(diffs)/docs/ReactAPI/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,25 @@ interface ThreadMetadata {
<CommentThread threadId={annotation.metadata.threadId} />
)}

// ─────────────────────────────────────────────────────────────
// SPAN DECORATIONS
// ─────────────────────────────────────────────────────────────

// Style arbitrary character ranges within a line. lineNumber is
// 1-based, spanStart is a 0-based character offset. className is
// applied to the rendered span; style it with options.unsafeCSS.
// Keep arrays stable (useState/useMemo) - changes re-highlight.
// See the Span Decorations section for interaction callbacks.
spanDecorations={[
{
side: 'additions', // or 'deletions'
lineNumber: 16,
spanStart: 4,
spanLength: 9,
className: 'hl-risk',
},
]}

// ─────────────────────────────────────────────────────────────
// HEADER CALLBACKS
// ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -988,6 +1007,23 @@ interface CommentMetadata {
<Comment commentId={annotation.metadata.commentId} />
)}

// ─────────────────────────────────────────────────────────────
// SPAN DECORATIONS
// ─────────────────────────────────────────────────────────────

// Style arbitrary character ranges within a line. Like
// lineAnnotations, the File variant has no 'side' property.
// className is applied to the rendered span; style it with
// options.unsafeCSS. See the Span Decorations section.
spanDecorations={[
{
lineNumber: 5,
spanStart: 4,
spanLength: 9,
className: 'hl-match',
},
]}

// ─────────────────────────────────────────────────────────────
// HEADER CALLBACKS
// ─────────────────────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/app/(diffs)/docs/ReactAPI/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,7 @@ state captured by `node`.
Token callbacks (`onTokenClick`, `onTokenEnter`, `onTokenLeave`) and
`useTokenTransformer` are documented in [Token Hooks](#token-hooks), including
examples, payload details, performance notes, and Worker Pool caveats.

Sub-line range styling (`spanDecorations`) and its interaction callbacks
(`onDecorationClick`, `onDecorationEnter`, `onDecorationLeave`) are documented
in [Span Decorations](#span-decorations).
38 changes: 38 additions & 0 deletions apps/docs/app/(diffs)/docs/SpanDecorations/ComponentTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client';

import type { PreloadedFileResult } from '@pierre/diffs/ssr';
import { useState } from 'react';

import { DocsCodeExample } from '@/components/docs/DocsCodeExample';
import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group';

type SpanDecorationMode = 'react' | 'vanilla';

interface SpanDecorationTabsProps {
reactExample: PreloadedFileResult<undefined>;
vanillaExample: PreloadedFileResult<undefined>;
}

export function SpanDecorationTabs({
reactExample,
vanillaExample,
}: SpanDecorationTabsProps) {
const [mode, setMode] = useState<SpanDecorationMode>('react');

return (
<>
<ButtonGroup
value={mode}
onValueChange={(value) => setMode(value as SpanDecorationMode)}
>
<ButtonGroupItem value="react">React</ButtonGroupItem>
<ButtonGroupItem value="vanilla">Vanilla JS</ButtonGroupItem>
</ButtonGroup>
{mode === 'react' ? (
<DocsCodeExample {...reactExample} key={mode} />
) : (
<DocsCodeExample {...vanillaExample} key={mode} />
)}
</>
);
}
171 changes: 171 additions & 0 deletions apps/docs/app/(diffs)/docs/SpanDecorations/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { PreloadFileOptions } from '@pierre/diffs/ssr';

import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS';

const options = {
theme: { dark: 'pierre-dark', light: 'pierre-light' },
disableFileHeader: true,
unsafeCSS: CustomScrollbarCSS,
} as const;

export const SPAN_DECORATIONS_REACT: PreloadFileOptions<undefined> = {
file: {
name: 'span_decorations.tsx',
contents: `import type { DiffSpanDecoration } from '@pierre/diffs';
import { MultiFileDiff } from '@pierre/diffs/react';

const oldFile = {
name: 'query.ts',
contents: "const user = db.query('SELECT * FROM users WHERE id = ?', [id]);",
};

const newFile = {
name: 'query.ts',
contents: 'const user = db.query("SELECT * FROM users WHERE id = " + id);',
};

// Decorations address character ranges on rendered lines:
// 1-based lineNumber, 0-based spanStart, end-exclusive length.
// Diff decorations also take a side, like DiffLineAnnotation.
// Keep decoration arrays stable (useState/useMemo) to avoid re-highlights.
const spanDecorations: DiffSpanDecoration[] = [
{
side: 'additions',
lineNumber: 1,
spanStart: 22,
spanLength: 39,
className: 'hl-risk',
},
];

export function SpanDecorationsExample() {
return (
<MultiFileDiff
oldFile={oldFile}
newFile={newFile}
// Decorations are render props (content-coupled, like
// lineAnnotations), not options.
spanDecorations={spanDecorations}
options={{
theme: { dark: 'pierre-dark', light: 'pierre-light' },

// Decoration spans render inside the shadow DOM, so classes
// are styled through unsafeCSS.
unsafeCSS: \`
.hl-risk {
background: light-dark(
rgba(220, 38, 38, 0.14),
rgba(248, 113, 113, 0.18)
);
box-shadow: inset 0 -2px 0 light-dark(#dc2626, #f87171);
border-radius: 2px;
}
\`,

// Optional interaction callbacks, mirroring onToken*.
// Props carry your original decoration object plus the
// rendered span element to anchor popovers against.
onDecorationClick({ decoration, decorationElement, lineNumber, side }) {
console.log('clicked decoration', {
className: decoration.className,
lineNumber,
side,
rect: decorationElement.getBoundingClientRect(),
});
},
onDecorationEnter({ decorationElement }) {
decorationElement.style.outline = '1px solid currentColor';
},
onDecorationLeave({ decorationElement }) {
decorationElement.style.outline = '';
},
}}
/>
);
}`,
},
options,
};

export const SPAN_DECORATIONS_VANILLA: PreloadFileOptions<undefined> = {
file: {
name: 'span_decorations.ts',
contents: `import {
FileDiff,
type DiffSpanDecoration,
} from '@pierre/diffs';

const instance = new FileDiff({
theme: { dark: 'pierre-dark', light: 'pierre-light' },

// Decoration spans render inside the shadow DOM, so classes are
// styled through unsafeCSS.
unsafeCSS: \`
.hl-risk {
background: light-dark(
rgba(220, 38, 38, 0.14),
rgba(248, 113, 113, 0.18)
);
box-shadow: inset 0 -2px 0 light-dark(#dc2626, #f87171);
border-radius: 2px;
}
\`,

// Optional interaction callbacks, mirroring onToken*.
onDecorationClick({ decoration, decorationElement, lineNumber, side }) {
console.log('clicked decoration', {
className: decoration.className,
lineNumber,
side,
rect: decorationElement.getBoundingClientRect(),
});
},
onDecorationEnter({ decorationElement }) {
decorationElement.style.outline = '1px solid currentColor';
},
onDecorationLeave({ decorationElement }) {
decorationElement.style.outline = '';
},
});

// Decorations address character ranges on rendered lines:
// 1-based lineNumber, 0-based spanStart, end-exclusive length.
// Diff decorations also take a side, like DiffLineAnnotation.
const spanDecorations: DiffSpanDecoration[] = [
{
side: 'additions',
lineNumber: 1,
spanStart: 22,
spanLength: 39,
className: 'hl-risk',
},
];

instance.render({
oldFile: {
name: 'query.ts',
contents: "const user = db.query('SELECT * FROM users WHERE id = ?', [id]);",
},
newFile: {
name: 'query.ts',
contents: 'const user = db.query("SELECT * FROM users WHERE id = " + id);',
},
spanDecorations,
containerWrapper: document.getElementById('diff-container'),
});

// Update decorations after the initial render
instance.render({
spanDecorations: [
{
side: 'additions',
lineNumber: 1,
spanStart: 22,
spanLength: 39,
className: 'hl-risk',
},
],
});`,
},
options,
};
46 changes: 46 additions & 0 deletions apps/docs/app/(diffs)/docs/SpanDecorations/content.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## Span Decorations

Span decorations style arbitrary character ranges within a line — without
touching tokenization. Use them for review-assist overlays like flagging
high-risk spans, search match highlighting, diagnostics squiggles, muting
low-relevance code, or anchoring assist UI to sub-line selections.

Where [annotations](#react-api) attach content to whole lines, span decorations
address `{ lineNumber, spanStart, spanLength }` character ranges and wrap them
in a span carrying your `className`. They ride the same Shiki decorations
pipeline as the built-in intra-line diff highlighting.

Available on:

- React: `MultiFileDiff`, `PatchDiff`, `FileDiff`, `File`, `UnresolvedFile`, and
`CodeView` (per item)
- Vanilla JS: `FileDiff`, `File`, `UnresolvedFile`, and `CodeView` (per item)
- SSR: all `preload*` helpers accept a `spanDecorations` option

Shared behavior:

- `lineNumber` is 1-based; `spanStart` is a 0-based character offset;
`spanLength` is end-exclusive. Diff variants (`DiffSpanDecoration`) also take
`side: 'deletions' | 'additions'`, matching `DiffLineAnnotation`.
- Decorations are content-coupled, so they are render props / item fields (like
`lineAnnotations`), not options. Keep arrays stable to avoid re-highlights —
changing them re-runs highlighting for that file.
- `className` resolves to classes on the rendered span (plus a
`data-span-decoration` attribute). Spans render inside the shadow DOM, so
style the classes with `unsafeCSS`. Structured input only — no raw HTML.
- Ranges are clamped to the rendered line length; zero-length and out-of-range
decorations are dropped rather than throwing.
- When a decoration overlaps the built-in intra-line diff highlight, the
consumer span nests inside the `data-diff-span` wrapper and both classes apply
— built-in styling wins by default, and you can layer on top with CSS
specificity.
- `onDecorationClick`, `onDecorationEnter`, and `onDecorationLeave` options
mirror the [Token Hooks](#token-hooks) callbacks: they receive your original
decoration object, the rendered `decorationElement`, `lineNumber`, and (for
diffs) `side`, plus the raw pointer event. Unlike token hooks, they work
without `useTokenTransformer` and add no DOM overhead when unused.

<SpanDecorationTabs
reactExample={reactSpanDecorations}
vanillaExample={vanillaSpanDecorations}
/>
8 changes: 8 additions & 0 deletions apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,10 @@ instance.render({
oldFile: { name: 'file.ts', contents: '...' },
newFile: { name: 'file.ts', contents: '...' },
lineAnnotations: [{ side: 'additions', lineNumber: 5, metadata: {} }],
// Sub-line range styling - see the Span Decorations section
spanDecorations: [
{ side: 'additions', lineNumber: 5, spanStart: 4, spanLength: 9, className: 'hl' },
],
containerWrapper: document.body,
});

Expand Down Expand Up @@ -796,6 +800,10 @@ const instance = new File({
instance.render({
file: { name: 'example.ts', contents: '...' },
lineAnnotations: [{ lineNumber: 5, metadata: {} }],
// Sub-line range styling - see the Span Decorations section
spanDecorations: [
{ lineNumber: 5, spanStart: 4, spanLength: 9, className: 'hl' },
],
containerWrapper: document.body,
});

Expand Down
4 changes: 4 additions & 0 deletions apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ Token callbacks (`onTokenClick`, `onTokenEnter`, `onTokenLeave`) and
`useTokenTransformer` are documented in [Token Hooks](#token-hooks), including
examples, payload details, performance notes, and Worker Pool caveats.

Sub-line range styling (`spanDecorations`) and its interaction callbacks
(`onDecorationClick`, `onDecorationEnter`, `onDecorationLeave`) are documented
in [Span Decorations](#span-decorations).

#### Custom Hunk Separators

Start with the [Hunk Separators](#hunk-separators) section first. In most cases,
Expand Down
Loading