fix(textarea): stabilize inline interaction overlays

This commit is contained in:
777genius 2026-05-27 21:54:18 +03:00
parent 431e3f9a46
commit d477d272c5
7 changed files with 302 additions and 48 deletions

View file

@ -171,6 +171,34 @@ interface ChipInteractionLayerProps {
onRemove: (chipId: string) => void; onRemove: (chipId: string) => void;
} }
function areChipsEquivalent(a: InlineChip, b: InlineChip): boolean {
return (
a.id === b.id &&
a.filePath === b.filePath &&
a.fileName === b.fileName &&
a.fromLine === b.fromLine &&
a.toLine === b.toLine &&
a.codeText === b.codeText &&
a.displayPath === b.displayPath &&
a.isFolder === b.isFolder
);
}
function areChipPositionsEquivalent(current: ChipPosition[], next: ChipPosition[]): boolean {
if (current.length !== next.length) return false;
return current.every((position, index) => {
const nextPosition = next[index];
return (
position.top === nextPosition.top &&
position.left === nextPosition.left &&
position.width === nextPosition.width &&
position.height === nextPosition.height &&
areChipsEquivalent(position.chip, nextPosition.chip)
);
});
}
export const ChipInteractionLayer = ({ export const ChipInteractionLayer = ({
chips, chips,
value, value,
@ -179,18 +207,25 @@ export const ChipInteractionLayer = ({
onRemove, onRemove,
}: ChipInteractionLayerProps): React.JSX.Element | null => { }: ChipInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<ChipPosition[]>([]); const [positions, setPositions] = React.useState<ChipPosition[]>([]);
const positionsRef = React.useRef<ChipPosition[]>([]);
const revealFileInEditor = useStore((s) => s.revealFileInEditor); const revealFileInEditor = useStore((s) => s.revealFileInEditor);
const revealFolderInEditor = useStore((s) => s.revealFolderInEditor); const revealFolderInEditor = useStore((s) => s.revealFolderInEditor);
const commitPositions = React.useCallback((nextPositions: ChipPosition[]) => {
if (areChipPositionsEquivalent(positionsRef.current, nextPositions)) return;
positionsRef.current = nextPositions;
setPositions(nextPositions);
}, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (chips.length === 0) { if (chips.length === 0) {
setPositions([]); commitPositions([]);
return; return;
} }
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) return; if (!textarea) return;
setPositions(calculateChipPositions(textarea, value, chips)); commitPositions(calculateChipPositions(textarea, value, chips));
}, [chips, value, textareaRef]); }, [chips, commitPositions, value, textareaRef]);
if (positions.length === 0) return null; if (positions.length === 0) return null;
@ -200,6 +235,14 @@ export const ChipInteractionLayer = ({
{positions.map((pos) => { {positions.map((pos) => {
const isFileChip = pos.chip.fromLine == null; const isFileChip = pos.chip.fromLine == null;
const isFolderChip = pos.chip.isFolder === true; const isFolderChip = pos.chip.isFolder === true;
const openChipTarget = (): void => {
if (isFolderChip) {
revealFolderInEditor(pos.chip.filePath);
} else {
revealFileInEditor(pos.chip.filePath);
}
};
return ( return (
<Tooltip key={pos.chip.id}> <Tooltip key={pos.chip.id}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -211,20 +254,19 @@ export const ChipInteractionLayer = ({
width: pos.width, width: pos.width,
height: pos.height, height: pos.height,
}} }}
onClick={
isFileChip
? (e) => {
e.preventDefault();
e.stopPropagation();
if (isFolderChip) {
revealFolderInEditor(pos.chip.filePath);
} else {
revealFileInEditor(pos.chip.filePath);
}
}
: undefined
}
> >
{isFileChip ? (
<button
type="button"
className="absolute inset-0 cursor-pointer rounded-sm bg-transparent p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openChipTarget();
}}
aria-label={`Open ${chipDisplayLabel(pos.chip)}`}
/>
) : null}
<button <button
type="button" type="button"
className="pointer-events-none absolute -right-1 -top-1.5 z-30 flex size-3.5 items-center justify-center rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100" className="pointer-events-none absolute -right-1 -top-1.5 z-30 flex size-3.5 items-center justify-center rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"

View file

@ -0,0 +1,82 @@
import React, { act, Profiler } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MentionInteractionLayer } from './MentionInteractionLayer';
import type { MentionSuggestion } from '@renderer/types/mention';
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({
theme: 'light',
resolvedTheme: 'light',
isDark: false,
isLight: true,
}),
}));
const flushMicrotasks = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
const suggestion: MentionSuggestion = {
id: 'member:alice',
name: 'Alice',
subtitle: 'Engineer',
type: 'member',
};
describe('MentionInteractionLayer', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('does not schedule a nested update when suggestions exist but text has no mentions', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const renderPhases: string[] = [];
const Harness = (): React.JSX.Element => {
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
return (
<>
<textarea ref={textareaRef} defaultValue="" />
<Profiler
id="mention-interaction-layer"
onRender={(_id, phase) => {
renderPhases.push(phase);
}}
>
<MentionInteractionLayer
suggestions={[suggestion]}
value=""
textareaRef={textareaRef}
scrollTop={0}
/>
</Profiler>
</>
);
};
await act(async () => {
root.render(<Harness />);
await flushMicrotasks();
});
expect(renderPhases).toEqual(['mount']);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});

View file

@ -29,6 +29,36 @@ interface MentionInteractionLayerProps {
scrollTop: number; scrollTop: number;
} }
function areSuggestionsEquivalent(a: MentionSuggestion, b: MentionSuggestion): boolean {
return (
a.id === b.id &&
a.name === b.name &&
a.subtitle === b.subtitle &&
a.color === b.color &&
a.type === b.type &&
a.isOnline === b.isOnline &&
a.insertText === b.insertText
);
}
function areMentionPositionsEquivalent(
current: MentionPosition[],
next: MentionPosition[]
): boolean {
if (current.length !== next.length) return false;
return current.every((position, index) => {
const nextPosition = next[index];
return (
position.top === nextPosition.top &&
position.left === nextPosition.left &&
position.width === nextPosition.width &&
position.height === nextPosition.height &&
areSuggestionsEquivalent(position.suggestion, nextPosition.suggestion)
);
});
}
export const MentionInteractionLayer = ({ export const MentionInteractionLayer = ({
suggestions, suggestions,
value, value,
@ -36,20 +66,27 @@ export const MentionInteractionLayer = ({
scrollTop, scrollTop,
}: MentionInteractionLayerProps): React.JSX.Element | null => { }: MentionInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<MentionPosition[]>([]); const [positions, setPositions] = React.useState<MentionPosition[]>([]);
const positionsRef = React.useRef<MentionPosition[]>([]);
const { isLight } = useTheme(); const { isLight } = useTheme();
const commitPositions = React.useCallback((nextPositions: MentionPosition[]) => {
if (areMentionPositionsEquivalent(positionsRef.current, nextPositions)) return;
positionsRef.current = nextPositions;
setPositions(nextPositions);
}, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const filtered = suggestions.filter( const filtered = suggestions.filter(
(s) => s.type !== 'task' && s.type !== 'file' && s.type !== 'folder' (s) => s.type !== 'task' && s.type !== 'file' && s.type !== 'folder'
); );
if (filtered.length === 0) { if (filtered.length === 0) {
setPositions([]); commitPositions([]);
return; return;
} }
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) return; if (!textarea) return;
setPositions(calculateMentionPositions(textarea, value, filtered)); commitPositions(calculateMentionPositions(textarea, value, filtered));
}, [suggestions, value, textareaRef]); }, [commitPositions, suggestions, value, textareaRef]);
if (positions.length === 0) return null; if (positions.length === 0) return null;

View file

@ -701,16 +701,6 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
return () => resizeObserver.disconnect(); return () => resizeObserver.disconnect();
}, [surfaceDecoration]); }, [surfaceDecoration]);
// --- Overlay activation ---
const hasOverlay =
value.includes('http://') ||
value.includes('https://') ||
parseStandaloneSlashCommand(value) !== null ||
suggestions.length > 0 ||
teamSuggestions.length > 0 ||
taskSuggestions.length > 0 ||
chips.length > 0;
// Combine member + team suggestions for overlay parsing // Combine member + team suggestions for overlay parsing
const mentionOverlaySuggestions = React.useMemo( const mentionOverlaySuggestions = React.useMemo(
() => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions), () => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions),
@ -721,6 +711,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
() => (slashCommand ? getKnownSlashCommand(slashCommand.name) : null), () => (slashCommand ? getKnownSlashCommand(slashCommand.name) : null),
[slashCommand] [slashCommand]
); );
const hasUrlOverlay = value.includes('http://') || value.includes('https://');
const hasMentionOverlay = mentionOverlaySuggestions.length > 0 && value.includes('@');
const hasTaskOverlay = taskSuggestions.length > 0 && value.includes('#');
// --- Overlay activation ---
const hasOverlay =
hasUrlOverlay ||
slashCommand !== null ||
hasMentionOverlay ||
hasTaskOverlay ||
chips.length > 0;
const segments = React.useMemo( const segments = React.useMemo(
() => () =>
@ -951,7 +952,6 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
handleChipKeyDown, handleChipKeyDown,
mentionHandleKeyDown, mentionHandleKeyDown,
isOpen, isOpen,
effectiveSuggestions.length,
effectiveSuggestions, effectiveSuggestions,
handleActiveSelect, handleActiveSelect,
dismiss, dismiss,
@ -1289,7 +1289,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
</div> </div>
) : null} ) : null}
{taskSuggestions.length > 0 ? ( {hasTaskOverlay ? (
<TaskReferenceInteractionLayer <TaskReferenceInteractionLayer
taskSuggestions={taskSuggestions} taskSuggestions={taskSuggestions}
value={value} value={value}
@ -1298,7 +1298,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
/> />
) : null} ) : null}
{value.includes('http://') || value.includes('https://') ? ( {hasUrlOverlay ? (
<UrlInteractionLayer <UrlInteractionLayer
value={value} value={value}
textareaRef={internalRef} textareaRef={internalRef}
@ -1313,7 +1313,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
/> />
) : null} ) : null}
{mentionOverlaySuggestions.length > 0 ? ( {hasMentionOverlay ? (
<MentionInteractionLayer <MentionInteractionLayer
suggestions={mentionOverlaySuggestions} suggestions={mentionOverlaySuggestions}
value={value} value={value}

View file

@ -16,6 +16,27 @@ interface SlashCommandInteractionLayerProps {
scrollTop: number; scrollTop: number;
} }
interface SlashCommandPosition {
top: number;
left: number;
width: number;
height: number;
}
function areSlashCommandPositionsEquivalent(
current: SlashCommandPosition | null,
next: SlashCommandPosition | null
): boolean {
if (current === next) return true;
if (!current || !next) return false;
return (
current.top === next.top &&
current.left === next.left &&
current.width === next.width &&
current.height === next.height
);
}
export const SlashCommandInteractionLayer = ({ export const SlashCommandInteractionLayer = ({
command, command,
definition, definition,
@ -23,12 +44,14 @@ export const SlashCommandInteractionLayer = ({
textareaRef, textareaRef,
scrollTop, scrollTop,
}: SlashCommandInteractionLayerProps): React.JSX.Element | null => { }: SlashCommandInteractionLayerProps): React.JSX.Element | null => {
const [position, setPosition] = React.useState<{ const [position, setPosition] = React.useState<SlashCommandPosition | null>(null);
top: number; const positionRef = React.useRef<SlashCommandPosition | null>(null);
left: number;
width: number; const commitPosition = React.useCallback((nextPosition: SlashCommandPosition | null) => {
height: number; if (areSlashCommandPositionsEquivalent(positionRef.current, nextPosition)) return;
} | null>(null); positionRef.current = nextPosition;
setPosition(nextPosition);
}, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
@ -44,17 +67,17 @@ export const SlashCommandInteractionLayer = ({
]); ]);
if (!match) { if (!match) {
setPosition(null); commitPosition(null);
return; return;
} }
setPosition({ commitPosition({
top: match.top, top: match.top,
left: match.left, left: match.left,
width: match.width, width: match.width,
height: match.height, height: match.height,
}); });
}, [command, textareaRef, value]); }, [command, commitPosition, textareaRef, value]);
if (!definition || !position) return null; if (!definition || !position) return null;

View file

@ -17,6 +17,39 @@ interface TaskReferenceInteractionLayerProps {
type PositionedTaskReference = InlineMatchPosition<MentionSuggestion>; type PositionedTaskReference = InlineMatchPosition<MentionSuggestion>;
function areTaskSuggestionsEquivalent(a: MentionSuggestion, b: MentionSuggestion): boolean {
return (
a.id === b.id &&
a.name === b.name &&
a.taskId === b.taskId &&
a.teamName === b.teamName &&
a.teamDisplayName === b.teamDisplayName &&
a.ownerName === b.ownerName &&
a.ownerColor === b.ownerColor
);
}
function areTaskReferencePositionsEquivalent(
current: PositionedTaskReference[],
next: PositionedTaskReference[]
): boolean {
if (current.length !== next.length) return false;
return current.every((position, index) => {
const nextPosition = next[index];
return (
position.start === nextPosition.start &&
position.end === nextPosition.end &&
position.token === nextPosition.token &&
position.top === nextPosition.top &&
position.left === nextPosition.left &&
position.width === nextPosition.width &&
position.height === nextPosition.height &&
areTaskSuggestionsEquivalent(position.item, nextPosition.item)
);
});
}
export const TaskReferenceInteractionLayer = ({ export const TaskReferenceInteractionLayer = ({
taskSuggestions, taskSuggestions,
value, value,
@ -24,11 +57,18 @@ export const TaskReferenceInteractionLayer = ({
scrollTop, scrollTop,
}: TaskReferenceInteractionLayerProps): React.JSX.Element | null => { }: TaskReferenceInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<PositionedTaskReference[]>([]); const [positions, setPositions] = React.useState<PositionedTaskReference[]>([]);
const positionsRef = React.useRef<PositionedTaskReference[]>([]);
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const commitPositions = React.useCallback((nextPositions: PositionedTaskReference[]) => {
if (areTaskReferencePositionsEquivalent(positionsRef.current, nextPositions)) return;
positionsRef.current = nextPositions;
setPositions(nextPositions);
}, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (taskSuggestions.length === 0 || !value.includes('#')) { if (taskSuggestions.length === 0 || !value.includes('#')) {
setPositions([]); commitPositions([]);
return; return;
} }
@ -42,8 +82,8 @@ export const TaskReferenceInteractionLayer = ({
token: match.raw, token: match.raw,
})); }));
setPositions(calculateInlineMatchPositions(textarea, value, matches)); commitPositions(calculateInlineMatchPositions(textarea, value, matches));
}, [taskSuggestions, textareaRef, value]); }, [commitPositions, taskSuggestions, textareaRef, value]);
if (positions.length === 0) return null; if (positions.length === 0) return null;

View file

@ -17,6 +17,29 @@ interface UrlInteractionLayerProps {
type PositionedUrlReference = InlineMatchPosition<TextMatch>; type PositionedUrlReference = InlineMatchPosition<TextMatch>;
function areUrlPositionsEquivalent(
current: PositionedUrlReference[],
next: PositionedUrlReference[]
): boolean {
if (current.length !== next.length) return false;
return current.every((position, index) => {
const nextPosition = next[index];
return (
position.start === nextPosition.start &&
position.end === nextPosition.end &&
position.token === nextPosition.token &&
position.top === nextPosition.top &&
position.left === nextPosition.left &&
position.width === nextPosition.width &&
position.height === nextPosition.height &&
position.item.start === nextPosition.item.start &&
position.item.end === nextPosition.item.end &&
position.item.value === nextPosition.item.value
);
});
}
export const UrlInteractionLayer = ({ export const UrlInteractionLayer = ({
value, value,
textareaRef, textareaRef,
@ -24,10 +47,17 @@ export const UrlInteractionLayer = ({
onRemove, onRemove,
}: UrlInteractionLayerProps): React.JSX.Element | null => { }: UrlInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<PositionedUrlReference[]>([]); const [positions, setPositions] = React.useState<PositionedUrlReference[]>([]);
const positionsRef = React.useRef<PositionedUrlReference[]>([]);
const commitPositions = React.useCallback((nextPositions: PositionedUrlReference[]) => {
if (areUrlPositionsEquivalent(positionsRef.current, nextPositions)) return;
positionsRef.current = nextPositions;
setPositions(nextPositions);
}, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!value.includes('http://') && !value.includes('https://')) { if (!value.includes('http://') && !value.includes('https://')) {
setPositions([]); commitPositions([]);
return; return;
} }
@ -41,8 +71,8 @@ export const UrlInteractionLayer = ({
token: match.value, token: match.value,
})); }));
setPositions(calculateInlineMatchPositions(textarea, value, matches)); commitPositions(calculateInlineMatchPositions(textarea, value, matches));
}, [textareaRef, value]); }, [commitPositions, textareaRef, value]);
if (positions.length === 0) return null; if (positions.length === 0) return null;