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;
}
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 = ({
chips,
value,
@ -179,18 +207,25 @@ export const ChipInteractionLayer = ({
onRemove,
}: ChipInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<ChipPosition[]>([]);
const positionsRef = React.useRef<ChipPosition[]>([]);
const revealFileInEditor = useStore((s) => s.revealFileInEditor);
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(() => {
if (chips.length === 0) {
setPositions([]);
commitPositions([]);
return;
}
const textarea = textareaRef.current;
if (!textarea) return;
setPositions(calculateChipPositions(textarea, value, chips));
}, [chips, value, textareaRef]);
commitPositions(calculateChipPositions(textarea, value, chips));
}, [chips, commitPositions, value, textareaRef]);
if (positions.length === 0) return null;
@ -200,6 +235,14 @@ export const ChipInteractionLayer = ({
{positions.map((pos) => {
const isFileChip = pos.chip.fromLine == null;
const isFolderChip = pos.chip.isFolder === true;
const openChipTarget = (): void => {
if (isFolderChip) {
revealFolderInEditor(pos.chip.filePath);
} else {
revealFileInEditor(pos.chip.filePath);
}
};
return (
<Tooltip key={pos.chip.id}>
<TooltipTrigger asChild>
@ -211,20 +254,19 @@ export const ChipInteractionLayer = ({
width: pos.width,
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
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"

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;
}
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 = ({
suggestions,
value,
@ -36,20 +66,27 @@ export const MentionInteractionLayer = ({
scrollTop,
}: MentionInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<MentionPosition[]>([]);
const positionsRef = React.useRef<MentionPosition[]>([]);
const { isLight } = useTheme();
const commitPositions = React.useCallback((nextPositions: MentionPosition[]) => {
if (areMentionPositionsEquivalent(positionsRef.current, nextPositions)) return;
positionsRef.current = nextPositions;
setPositions(nextPositions);
}, []);
React.useLayoutEffect(() => {
const filtered = suggestions.filter(
(s) => s.type !== 'task' && s.type !== 'file' && s.type !== 'folder'
);
if (filtered.length === 0) {
setPositions([]);
commitPositions([]);
return;
}
const textarea = textareaRef.current;
if (!textarea) return;
setPositions(calculateMentionPositions(textarea, value, filtered));
}, [suggestions, value, textareaRef]);
commitPositions(calculateMentionPositions(textarea, value, filtered));
}, [commitPositions, suggestions, value, textareaRef]);
if (positions.length === 0) return null;

View file

@ -701,16 +701,6 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
return () => resizeObserver.disconnect();
}, [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
const mentionOverlaySuggestions = React.useMemo(
() => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions),
@ -721,6 +711,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
() => (slashCommand ? getKnownSlashCommand(slashCommand.name) : null),
[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(
() =>
@ -951,7 +952,6 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
handleChipKeyDown,
mentionHandleKeyDown,
isOpen,
effectiveSuggestions.length,
effectiveSuggestions,
handleActiveSelect,
dismiss,
@ -1289,7 +1289,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
</div>
) : null}
{taskSuggestions.length > 0 ? (
{hasTaskOverlay ? (
<TaskReferenceInteractionLayer
taskSuggestions={taskSuggestions}
value={value}
@ -1298,7 +1298,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
/>
) : null}
{value.includes('http://') || value.includes('https://') ? (
{hasUrlOverlay ? (
<UrlInteractionLayer
value={value}
textareaRef={internalRef}
@ -1313,7 +1313,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
/>
) : null}
{mentionOverlaySuggestions.length > 0 ? (
{hasMentionOverlay ? (
<MentionInteractionLayer
suggestions={mentionOverlaySuggestions}
value={value}

View file

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

View file

@ -17,6 +17,39 @@ interface TaskReferenceInteractionLayerProps {
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 = ({
taskSuggestions,
value,
@ -24,11 +57,18 @@ export const TaskReferenceInteractionLayer = ({
scrollTop,
}: TaskReferenceInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<PositionedTaskReference[]>([]);
const positionsRef = React.useRef<PositionedTaskReference[]>([]);
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(() => {
if (taskSuggestions.length === 0 || !value.includes('#')) {
setPositions([]);
commitPositions([]);
return;
}
@ -42,8 +82,8 @@ export const TaskReferenceInteractionLayer = ({
token: match.raw,
}));
setPositions(calculateInlineMatchPositions(textarea, value, matches));
}, [taskSuggestions, textareaRef, value]);
commitPositions(calculateInlineMatchPositions(textarea, value, matches));
}, [commitPositions, taskSuggestions, textareaRef, value]);
if (positions.length === 0) return null;

View file

@ -17,6 +17,29 @@ interface UrlInteractionLayerProps {
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 = ({
value,
textareaRef,
@ -24,10 +47,17 @@ export const UrlInteractionLayer = ({
onRemove,
}: UrlInteractionLayerProps): React.JSX.Element | null => {
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(() => {
if (!value.includes('http://') && !value.includes('https://')) {
setPositions([]);
commitPositions([]);
return;
}
@ -41,8 +71,8 @@ export const UrlInteractionLayer = ({
token: match.value,
}));
setPositions(calculateInlineMatchPositions(textarea, value, matches));
}, [textareaRef, value]);
commitPositions(calculateInlineMatchPositions(textarea, value, matches));
}, [commitPositions, textareaRef, value]);
if (positions.length === 0) return null;