Files
openchamber/packages/ui/src/components/comments/PierreDiffCommentOverlays.tsx
T
Bohdan Triapitsyn 823cefd4b5 fix: preserve inline comment drafts on focus changes
Keeps typed or pasted comment text when the editor remounts
Prevents focus changes from dismissing active inline comments
Allows cancelling by pressing the selected line number again
2026-06-13 00:48:42 +03:00

235 lines
7.1 KiB
TypeScript

import React from 'react';
import { createPortal } from 'react-dom';
import type { SelectedLineRange } from '@pierre/diffs';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { InlineCommentCard } from './InlineCommentCard';
import { InlineCommentInput } from './InlineCommentInput';
import { toPierreAnnotationId } from './PierreDiffCommentUtils';
interface PierreDiffCommentOverlaysProps {
diffRootRef: React.RefObject<HTMLDivElement | null>;
drafts: InlineCommentDraft[];
selection: SelectedLineRange | null;
editingDraftId: string | null;
commentText: string;
onTextChange: (text: string) => void;
fileLabel: string;
onSave: (text: string, range?: SelectedLineRange) => void;
onCancel: () => void;
onEdit: (draft: InlineCommentDraft) => void;
onDelete: (draft: InlineCommentDraft) => void;
}
function parseCssWidth(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const parsed = Number.parseFloat(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
if (trimmed.endsWith('px')) {
const parsed = Number.parseFloat(trimmed.slice(0, -2));
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function clampMaxWidth(value: number | null | undefined): number | undefined {
if (!value || value <= 0) return undefined;
return Math.max(200, Math.floor(value));
}
export function PierreDiffCommentOverlays(props: PierreDiffCommentOverlaysProps) {
const {
diffRootRef,
drafts,
selection,
editingDraftId,
commentText,
onTextChange,
fileLabel,
onSave,
onCancel,
onEdit,
onDelete,
} = props;
const [retryTick, setRetryTick] = React.useState(0);
const [fallbackMaxWidth, setFallbackMaxWidth] = React.useState<number | null>(null);
const selectionAnnotationId = React.useMemo(() => {
if (!selection || editingDraftId) return null;
return toPierreAnnotationId({ type: 'new', selection });
}, [editingDraftId, selection]);
const expectedTargetIds = React.useMemo(() => {
const ids = drafts.map((draft) => toPierreAnnotationId({ type: draft.id === editingDraftId ? 'edit' : 'saved', draft }));
if (selectionAnnotationId) {
ids.push(selectionAnnotationId);
}
return ids;
}, [drafts, editingDraftId, selectionAnnotationId]);
const resolveTarget = React.useCallback((annotationId: string): HTMLElement | null => {
const wrapper = diffRootRef.current;
if (!wrapper) return null;
const host = wrapper.querySelector('diffs-container');
if (!(host instanceof HTMLElement)) return null;
const lightDomTarget = host.querySelector(`[data-annotation-id="${annotationId}"]`);
if (lightDomTarget instanceof HTMLElement) {
return lightDomTarget;
}
const shadowRoot = host.shadowRoot;
if (!shadowRoot) return null;
return shadowRoot.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement | null;
}, [diffRootRef]);
React.useEffect(() => {
if (expectedTargetIds.length === 0) return;
let cancelled = false;
let attempts = 0;
const maxAttempts = 12;
const checkTargets = () => {
if (cancelled) return;
const allResolved = expectedTargetIds.every((id) => Boolean(resolveTarget(id)));
if (allResolved || attempts >= maxAttempts) {
return;
}
attempts += 1;
requestAnimationFrame(() => {
if (cancelled) return;
setRetryTick((tick) => tick + 1);
checkTargets();
});
};
checkTargets();
return () => {
cancelled = true;
};
}, [expectedTargetIds, resolveTarget]);
React.useEffect(() => {
const root = diffRootRef.current;
if (!root) return;
const computeMaxWidth = () => {
const styles = getComputedStyle(root);
const cssWidth = parseCssWidth(styles.getPropertyValue('--oc-context-panel-width'));
const rootRect = root.getBoundingClientRect();
const measured = cssWidth ?? rootRect.width;
setFallbackMaxWidth(measured > 0 ? measured : null);
};
computeMaxWidth();
const observer = new ResizeObserver(() => {
computeMaxWidth();
});
observer.observe(root);
window.addEventListener('resize', computeMaxWidth);
return () => {
observer.disconnect();
window.removeEventListener('resize', computeMaxWidth);
};
}, [diffRootRef]);
const resolveTargetMaxWidth = React.useCallback((target: HTMLElement): number | undefined => {
const root = diffRootRef.current;
const rootRect = root?.getBoundingClientRect();
const annotationContent = target.closest('[data-annotation-content]');
const contentRect = annotationContent instanceof HTMLElement
? annotationContent.getBoundingClientRect()
: target.getBoundingClientRect();
const candidates = [contentRect.width];
if (rootRect) {
candidates.push(rootRect.right - contentRect.left);
}
const positiveCandidates = candidates.filter((value) => Number.isFinite(value) && value > 0);
if (positiveCandidates.length > 0) {
return clampMaxWidth(Math.min(...positiveCandidates));
}
return clampMaxWidth(fallbackMaxWidth);
}, [diffRootRef, fallbackMaxWidth]);
void retryTick;
return (
<>
{drafts.map((draft) => {
const id = toPierreAnnotationId({ type: draft.id === editingDraftId ? 'edit' : 'saved', draft });
const target = resolveTarget(id);
if (!target) return null;
const targetMaxWidth = resolveTargetMaxWidth(target);
if (draft.id === editingDraftId) {
return createPortal(
<InlineCommentInput
initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel}
lineRange={{
start: draft.startLine,
end: draft.endLine,
side: draft.side === 'original' ? 'deletions' : 'additions',
}}
isEditing={true}
onSave={onSave}
onCancel={onCancel}
maxWidth={targetMaxWidth}
/>,
target,
`draft-edit-${draft.id}`
);
}
return createPortal(
<InlineCommentCard
draft={draft}
onEdit={() => onEdit(draft)}
onDelete={() => onDelete(draft)}
maxWidth={targetMaxWidth}
/>,
target,
`draft-card-${draft.id}`
);
})}
{selection && !editingDraftId && selectionAnnotationId && (() => {
const target = resolveTarget(selectionAnnotationId);
if (!target) return null;
const targetMaxWidth = resolveTargetMaxWidth(target);
return createPortal(
<InlineCommentInput
initialText={commentText}
onTextChange={onTextChange}
fileLabel={fileLabel}
lineRange={selection}
isEditing={false}
onSave={onSave}
onCancel={onCancel}
maxWidth={targetMaxWidth}
/>,
target,
selectionAnnotationId
);
})()}
</>
);
}