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;
|
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"
|
||||||
|
|
|
||||||
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;
|
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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue