Skip to content
Merged
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
257 changes: 244 additions & 13 deletions apps/docs/app/diff-examples/Annotations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

import { FileDiff } from '@/components/diff-ui/FileDiff';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import type { FileContents } from '@pierre/precision-diffs';
import { CornerDownRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
AnnotationSide,
DiffLineAnnotation,
FileContents,
} from '@pierre/precision-diffs';
import { CornerDownRight, Plus } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';

import { FeatureHeader } from './FeatureHeader';

Expand Down Expand Up @@ -42,24 +48,249 @@ export default function Home() {
`,
};

interface AnnotationMetadata {
key: string;
isThread: boolean;
}

export function Annotations() {
const [annotations, setAnnotations] = useState<
DiffLineAnnotation<AnnotationMetadata>[]
>([
{
side: 'additions',
lineNumber: 8,
metadata: {
key: 'additions-8',
isThread: true,
},
},
]);
const [buttonPosition, setButtonPosition] = useState<{
top: number;
left: number;
} | null>(null);
const [hoveredLine, setHoveredLine] = useState<{
side: AnnotationSide;
lineNumber: number;
} | null>(null);
const containerRef = useRef<HTMLDivElement>(null);

const handleLineEnter = useCallback(
(props: {
lineElement: HTMLElement;
annotationSide: AnnotationSide;
lineNumber: number;
}) => {
const lineElement = props.lineElement;
const container = containerRef.current;

if (container == null) return;

const { annotationSide, lineNumber } = props;

// Don't show button if there's already an annotation on this line
const hasAnnotation = annotations.some(
(ann) => ann.side === annotationSide && ann.lineNumber === lineNumber
);

if (hasAnnotation) {
setButtonPosition(null);
setHoveredLine(null);
return;
}

// Get the position of the line element relative to the container
const containerRect = container.getBoundingClientRect();
const lineRect = lineElement.getBoundingClientRect();

setButtonPosition({
top: lineRect.top - containerRect.top + lineRect.height / 2,
left: 16, // Fixed position from left edge
});

setHoveredLine({ side: annotationSide, lineNumber });
},
[annotations]
);

const handleLineLeave = useCallback(() => {
setButtonPosition(null);
setHoveredLine(null);
}, []);

const handleContainerMouseLeave = useCallback(() => {
setButtonPosition(null);
setHoveredLine(null);
}, []);

const handleAddComment = useCallback(() => {
if (hoveredLine != null) {
setAnnotations((prev) => [
...prev,
{
side: hoveredLine.side,
lineNumber: hoveredLine.lineNumber,
metadata: {
key: `${hoveredLine.side}-${hoveredLine.lineNumber}`,
isThread: false, // Start as a form, not a thread yet
},
},
]);
setButtonPosition(null);
setHoveredLine(null);
}
}, [hoveredLine]);

const handleSubmitComment = useCallback(
(side: AnnotationSide, lineNumber: number) => {
// TODO: Implement
console.log('submit comment', side, lineNumber);
},
[]
);

const handleCancelComment = useCallback(
(side: AnnotationSide, lineNumber: number) => {
setAnnotations((prev) =>
prev.filter(
(ann) => !(ann.side === side && ann.lineNumber === lineNumber)
)
);
},
[]
);

return (
<div className="space-y-5">
<FeatureHeader
title="Comments & Annotations"
description="Precision Diffs provide a flexible annotation framework for injecting additional content and context into your diffs. Use it to render line comments, annotations from CI jobs, and other third party content."
/>
<FileDiff
oldFile={OLD_FILE}
newFile={NEW_FILE}
className="rounded-lg overflow-hidden border"
options={{
theme: 'pierre-dark',
diffStyle: 'unified',
}}
annotations={[{ side: 'additions', lineNumber: 8 }]}
renderAnnotation={() => <Thread />}
/>
<div
ref={containerRef}
style={{ position: 'relative' }}
onMouseLeave={handleContainerMouseLeave}
>
{buttonPosition != null && (
<Button
size="icon-sm"
variant="default"
onClick={handleAddComment}
style={{
position: 'absolute',
top: buttonPosition.top,
left: buttonPosition.left + 4,
transform: 'translateY(-50%)',
zIndex: 10,
backgroundColor: '#1a76d4',
transition: 'none',
cursor: 'pointer',
}}
>
<Plus className="h-4 w-4" />
</Button>
)}
<FileDiff
oldFile={OLD_FILE}
newFile={NEW_FILE}
className="rounded-lg overflow-hidden border"
options={{
theme: 'pierre-dark',
diffStyle: 'unified',
onLineEnter: handleLineEnter,
onLineLeave: handleLineLeave,
}}
annotations={annotations}
renderAnnotation={(annotation) =>
annotation.metadata.isThread ? (
<Thread />
) : (
<CommentForm
side={annotation.side}
lineNumber={annotation.lineNumber}
onSubmit={handleSubmitComment}
onCancel={handleCancelComment}
/>
)
}
/>
</div>
</div>
);
}

function CommentForm({
side,
lineNumber,
onSubmit,
onCancel,
}: {
side: AnnotationSide;
lineNumber: number;
onSubmit: (side: AnnotationSide, lineNumber: number) => void;
onCancel: (side: AnnotationSide, lineNumber: number) => void;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}, []);

const handleSubmit = useCallback(() => {
onSubmit(side, lineNumber);
}, [side, lineNumber, onSubmit]);

const handleCancel = useCallback(() => {
onCancel(side, lineNumber);
}, [side, lineNumber, onCancel]);

return (
<div
className="max-w-[95%] sm:max-w-[70%]"
style={{
whiteSpace: 'normal',
margin: 20,
fontFamily: 'Geist',
}}
>
<div className="rounded-lg border bg-card p-5 shadow-sm">
<div className="flex gap-2">
<div className="relative flex-shrink-0 -mt-0.5">
<Avatar className="h-6 w-6">
<AvatarImage
src="https://db.heypierre.app/storage/v1/object/public/avatars/i8UHRtQf_400x400.jpg"
alt="You"
/>
<AvatarFallback>Y</AvatarFallback>
</Avatar>
</div>
<div className="flex-1">
<textarea
ref={textareaRef}
placeholder="Leave a comment"
className="w-full min-h-[60px] p-2 text-sm text-foreground bg-background border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
className="cursor-pointer"
onClick={handleSubmit}
>
Comment
</Button>
<button
onClick={handleCancel}
className="text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1 cursor-pointer"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
);
}
Expand Down
1 change: 1 addition & 0 deletions apps/docs/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const buttonVariants = cva(
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
xl: 'h-11 rounded-md px-8 has-[>svg]:px-5',
icon: 'size-9',
'icon-sm': 'size-5 rounded-sm',
},
},
defaultVariants: {
Expand Down
56 changes: 38 additions & 18 deletions packages/precision-diffs/src/FileDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,14 @@ export class FileDiff<LAnnotation = undefined> {

async render(props: FileDiffRenderProps<LAnnotation>) {
const { forceRender = false, lineAnnotations, containerWrapper } = props;
const annotationsChanged = !deepEquals(
lineAnnotations,
this.lineAnnotations
);
if (
props.fileDiff == null &&
!forceRender &&
!annotationsChanged &&
deepEquals(props.oldFile, this.oldFile) &&
deepEquals(props.newFile, this.newFile)
) {
Expand Down Expand Up @@ -310,11 +315,43 @@ export class FileDiff<LAnnotation = undefined> {
}

spriteSVG: SVGElement | undefined;

private attachEventListeners() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this wouldn't be necessary, I might have to poke around and fix this

if (this.fileContainer == null) return;

const shadowRoot = this.fileContainer.shadowRoot as HTMLElement | null;
if (shadowRoot == null) return;

// Remove old event listeners if they exist
shadowRoot.removeEventListener('click', this.handleMouseClick);
shadowRoot.removeEventListener('mousemove', this.handleMouseMove);
shadowRoot.removeEventListener('mouseleave', this.handleMouseLeave);

const {
onLineClick,
onLineEnter,
onLineLeave,
hunkSeparators = 'line-info',
} = this.options;

if (onLineClick != null || hunkSeparators === 'line-info') {
shadowRoot.addEventListener('click', this.handleMouseClick);
}
if (onLineEnter != null || onLineLeave != null) {
shadowRoot.addEventListener('mousemove', this.handleMouseMove);
if (onLineLeave != null) {
shadowRoot.addEventListener('mouseleave', this.handleMouseLeave);
}
}
}

getOrCreateFileContainer(fileContainer?: HTMLElement) {
if (
(fileContainer != null && fileContainer === this.fileContainer) ||
(fileContainer == null && this.fileContainer != null)
) {
// Re-attach event listeners with updated callbacks
this.attachEventListeners();
return this.fileContainer;
}
this.fileContainer =
Expand All @@ -328,24 +365,7 @@ export class FileDiff<LAnnotation = undefined> {
this.fileContainer.shadowRoot?.appendChild(this.spriteSVG);
}
}
const {
onLineClick,
onLineEnter,
onLineLeave,
hunkSeparators = 'line-info',
} = this.options;
if (onLineClick != null || hunkSeparators === 'line-info') {
this.fileContainer.addEventListener('click', this.handleMouseClick);
}
if (onLineEnter != null || onLineLeave != null) {
this.fileContainer.addEventListener('mousemove', this.handleMouseMove);
if (onLineLeave != null) {
this.fileContainer.addEventListener(
'mouseleave',
this.handleMouseLeave
);
}
}
this.attachEventListeners();
return this.fileContainer;
}

Expand Down