diff --git a/apps/web/design-system/select-entity.tsx b/apps/web/design-system/select-entity.tsx index 721299e33c..c20c7f3213 100644 --- a/apps/web/design-system/select-entity.tsx +++ b/apps/web/design-system/select-entity.tsx @@ -72,6 +72,7 @@ type SelectEntityProps = { withSearchIcon?: boolean; advanced?: boolean; autoFocus?: boolean; + showUrlWarning?: boolean; showIDs?: boolean; initialQuery?: string; /** When set, the result with this ID gets a "Currently selected" indicator */ diff --git a/apps/web/package.json b/apps/web/package.json index 8caec2b77c..edc23e5b12 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -67,6 +67,7 @@ "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.37.0", "@vercel/analytics": "^2.0.1", + "@vercel/og": "^0.9.0", "@xstate/store": "^3.17.1", "ai": "^6.0.168", "babel-plugin-react-compiler": "^1.0.0", @@ -153,4 +154,4 @@ "stream": false, "zlib": false } -} +} \ No newline at end of file diff --git a/apps/web/partials/comments/comments-section.tsx b/apps/web/partials/comments/comments-section.tsx index 14deb2815b..5ac337cd6a 100644 --- a/apps/web/partials/comments/comments-section.tsx +++ b/apps/web/partials/comments/comments-section.tsx @@ -1,5 +1,6 @@ 'use client'; +import * as Popover from '@radix-ui/react-popover'; import * as React from 'react'; import { useState } from 'react'; @@ -8,6 +9,8 @@ import { useCreateComment } from '~/core/hooks/use-create-comment'; import { usePersonalSpaceId } from '~/core/hooks/use-personal-space-id'; import { useSpace } from '~/core/hooks/use-space'; +import { SelectEntity } from '~/design-system/select-entity'; + import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; import { NavUtils } from '~/core/utils/utils'; @@ -88,6 +91,7 @@ export function CommentSection({ entityId, spaceId }: CommentSectionProps) { handleCreateComment(text)} isCreating={isCreating} + spaceId={spaceId} /> {totalCount > 0 && ( <> @@ -180,9 +184,11 @@ function CommentFilters({ function TopLevelCommentInput({ onSubmit, isCreating, + spaceId, }: { onSubmit: (text: string) => void; isCreating: boolean; + spaceId: string; }) { const [isExpanded, setIsExpanded] = useState(false); @@ -207,6 +213,7 @@ function TopLevelCommentInput({ placeholder="" autoFocus onCancel={() => setIsExpanded(false)} + spaceId={spaceId} /> ); } @@ -218,6 +225,7 @@ function CommentInput({ autoFocus = false, onCancel, initialValue = '', + spaceId, }: { onSubmit: (text: string) => void; isCreating: boolean; @@ -225,8 +233,11 @@ function CommentInput({ autoFocus?: boolean; onCancel?: () => void; initialValue?: string; + spaceId: string; }) { const [text, setText] = useState(initialValue); + const [isMentioning, setIsMentioning] = useState(false); + const [mentionStartIndex, setMentionStartIndex] = useState(null); const textareaRef = React.useRef(null); const handleSubmit = () => { @@ -240,10 +251,64 @@ function CommentInput({ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); handleSubmit(); + return; } - if (e.key === 'Escape' && onCancel) { - onCancel(); + if (e.key === 'Escape') { + if (isMentioning) { + setIsMentioning(false); + e.preventDefault(); + return; + } + if (onCancel) { + onCancel(); + } + return; } + + if (e.key === '@') { + const { selectionStart } = e.currentTarget as HTMLTextAreaElement; + // Trigger mention only if @ is at start or preceded by whitespace + const beforeChar = text[selectionStart - 1]; + if (!beforeChar || /\s/.test(beforeChar)) { + const textarea = e.currentTarget as HTMLTextAreaElement; + // Small timeout to ensure the '@' is inserted into the textarea first + // and doesn't leak into the SelectEntity search input which auto-focuses. + setTimeout(() => { + const coords = getCaretCoordinates(textarea, selectionStart + 1); + setMentionCoords(coords); + setMentionStartIndex(selectionStart); + setIsMentioning(true); + }, 0); + } + } + }; + + const onMentionSelect = (result: { id: string; name: string | null }) => { + if (mentionStartIndex === null) return; + + const before = text.slice(0, mentionStartIndex); + const after = text.slice(mentionStartIndex); + + // We only replace the '@' and any characters that might have been typed since + // However, SelectEntity has its own input, so we just replace from mentionStartIndex + // But we need to find how many characters were typed after @ in the textarea if we were tracking query there + // For now, let's assume we replace just the '@' and any non-whitespace chars immediately after it at that position + const match = after.match(/^@\S*/); + const lengthToReplace = match ? match[0].length : 1; + + const newText = before + `[${result.name || result.id}](graph://${result.id})` + after.slice(lengthToReplace); + setText(newText); + setIsMentioning(false); + setMentionStartIndex(null); + + // Focus back and set cursor + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + const newCursorPos = before.length + `[${result.name || result.id}](graph://${result.id})`.length; + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); }; // Auto-resize textarea @@ -257,18 +322,56 @@ function CommentInput({ const hasText = text.trim().length > 0; + const [mentionCoords, setMentionCoords] = useState({ x: 0, y: 0 }); + return (
-