fix(textarea): stabilize inline interaction overlays
This commit is contained in:
parent
431e3f9a46
commit
d477d272c5
7 changed files with 302 additions and 48 deletions
|
|
@ -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"
|
||||
|
|
|
|||
82
src/renderer/components/ui/MentionInteractionLayer.test.tsx
Normal file
82
src/renderer/components/ui/MentionInteractionLayer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue