Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
da92dd4
add url-detection included with test
denisugiarto Sep 18, 2025
f76dace
feat: comprehensive link handling system for editor
denisugiarto Sep 22, 2025
b9398f4
fix: remove line by line handling during editor paste to fix list paste
denisugiarto Sep 22, 2025
9983b1a
feat: add search entity popup in floating toolbar link item
denisugiarto Jan 17, 2026
722fa12
fix(web2-url-extension):adjust node detection for all paragraph and f…
denisugiarto Jan 17, 2026
7d3f272
Fix text
b-d055 Nov 3, 2025
156ad0b
Added recursion depth limit to prevent stack overflow, fixed RAF race…
b-d055 Nov 3, 2025
e973401
refactor: Improve editor link handling by preventing floating toolbar…
denisugiarto Jan 20, 2026
527e4ba
feat: Implement temporary space redirection and enhance entity link h…
denisugiarto Jan 24, 2026
031e90b
feat(editor): improve graph link handling and editor state management
denisugiarto Jan 27, 2026
de5ef12
fix(editor): prevent web2 url hover in view mode and improve edit mod…
denisugiarto Jan 27, 2026
16b3a5f
feat(editor): add shared graph link utility with entity caching
denisugiarto Jan 30, 2026
6561371
Merge branch 'master' into feat/add-link-handling-in-text-blocks
denisugiarto Feb 3, 2026
f0a1cc5
feat: enhance link handling in text blocks with tooltip visibility im…
denisugiarto Feb 12, 2026
3037d49
Merge branch 'master' into feat/add-link-handling-in-text-blocks
denisugiarto Feb 18, 2026
82b76a3
Merge branch 'master' into feat/add-link-handling-in-text-blocks
denisugiarto Feb 18, 2026
917b849
feat(editor): add href attribute to link marks in graph link insertion
denisugiarto Feb 18, 2026
e112898
feat(editor): add loading state and data attributes to graph links
denisugiarto Feb 19, 2026
6296130
Merge branch 'master' into feat/add-link-handling-in-text-blocks
denisugiarto Feb 27, 2026
469b00f
refactor(editor): migrate from tippy.js to @floating-ui/dom for popups
denisugiarto Mar 4, 2026
9dd4293
Merge branch 'master' into feat/add-link-handling-in-text-blocks
denisugiarto Apr 8, 2026
16afcc0
chore: regenerate bun.lock
denisugiarto Apr 8, 2026
cae9d28
chore: remove unused import
denisugiarto Apr 8, 2026
490b1e3
refactor: replace anchor tags with spans for links, improve editor st…
denisugiarto Apr 11, 2026
ef0c7e5
Merge branch 'master' into feat/add-link-handling-in-text-blocks
denisugiarto Apr 11, 2026
396a8e8
feat(editor): add mouseleave handling to link hover tooltips
denisugiarto Apr 13, 2026
c9aa384
Merge branch 'master' into feat/add-link-handling-in-text-blocks
denisugiarto Apr 14, 2026
bdc4013
fix: improve editor link insertion, popup click-outside handling, and…
denisugiarto Apr 14, 2026
a3aeead
feat: implement entity mention functionality in comment input using R…
denisugiarto Apr 14, 2026
1a2d0f0
Merge branch 'master' into feat/add-link-handling-in-comments
denisugiarto Apr 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/design-system/select-entity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type SelectEntityProps = {
withSearchIcon?: boolean;
advanced?: boolean;
autoFocus?: boolean;
showUrlWarning?: boolean;

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showUrlWarning?: boolean was added to SelectEntityProps, but the component doesn’t destructure or use this prop anywhere, so it has no effect and may confuse callers. Either wire it through to the relevant UI/behavior (and include it in the component’s parameter destructuring) or remove the prop until it’s implemented.

Suggested change
showUrlWarning?: boolean;

Copilot uses AI. Check for mistakes.
showIDs?: boolean;
initialQuery?: string;
/** When set, the result with this ID gets a "Currently selected" indicator */
Expand Down
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.37.0",
"@vercel/analytics": "^2.0.1",
"@vercel/og": "^0.9.0",

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel/og is added to dependencies, but there are no references to it under apps/web in this PR. If it’s not required for the comment/link handling work, please remove it to avoid increasing install size and dependency surface area; otherwise, add the corresponding usage/change that requires it.

Suggested change
"@vercel/og": "^0.9.0",

Copilot uses AI. Check for mistakes.
"@xstate/store": "^3.17.1",
"ai": "^6.0.168",
"babel-plugin-react-compiler": "^1.0.0",
Expand Down Expand Up @@ -153,4 +154,4 @@
"stream": false,
"zlib": false
}
}
}
204 changes: 192 additions & 12 deletions apps/web/partials/comments/comments-section.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import * as Popover from '@radix-ui/react-popover';
import * as React from 'react';
import { useState } from 'react';

Expand All @@ -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';
Expand Down Expand Up @@ -88,6 +91,7 @@ export function CommentSection({ entityId, spaceId }: CommentSectionProps) {
<TopLevelCommentInput
onSubmit={text => handleCreateComment(text)}
isCreating={isCreating}
spaceId={spaceId}
/>
{totalCount > 0 && (
<>
Expand Down Expand Up @@ -180,9 +184,11 @@ function CommentFilters({
function TopLevelCommentInput({
onSubmit,
isCreating,
spaceId,
}: {
onSubmit: (text: string) => void;
isCreating: boolean;
spaceId: string;
}) {
const [isExpanded, setIsExpanded] = useState(false);

Expand All @@ -207,6 +213,7 @@ function TopLevelCommentInput({
placeholder=""
autoFocus
onCancel={() => setIsExpanded(false)}
spaceId={spaceId}
/>
);
}
Expand All @@ -218,15 +225,19 @@ function CommentInput({
autoFocus = false,
onCancel,
initialValue = '',
spaceId,
}: {
onSubmit: (text: string) => void;
isCreating: boolean;
placeholder: string;
autoFocus?: boolean;
onCancel?: () => void;
initialValue?: string;
spaceId: string;
}) {
const [text, setText] = useState(initialValue);
const [isMentioning, setIsMentioning] = useState(false);
const [mentionStartIndex, setMentionStartIndex] = useState<number | null>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);

const handleSubmit = () => {
Expand All @@ -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);
Comment on lines +296 to +300

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mention insertion builds markdown with the raw entity name: [${result.name}](graph://...). If result.name contains ], (, ), backslashes, or newlines, this can produce malformed markdown (or unintended formatting/links). Suggest escaping/sanitizing the link label before constructing the markdown, or using a small helper that safely formats []() sequences.

Copilot uses AI. Check for mistakes.
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
Expand All @@ -257,18 +322,56 @@ function CommentInput({

const hasText = text.trim().length > 0;

const [mentionCoords, setMentionCoords] = useState({ x: 0, y: 0 });

return (
<div className="flex flex-col gap-2 rounded-lg border border-grey-02 p-3">
<textarea
ref={textareaRef}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoFocus={autoFocus}
rows={3}
className="w-full resize-none bg-transparent text-body text-text outline-none placeholder:text-grey-04"
/>
<Popover.Root open={isMentioning} onOpenChange={setIsMentioning}>
{/* Invisible fixed anchor positioned at the caret */}
<div
style={{
position: 'fixed',
left: mentionCoords.x,
top: mentionCoords.y,
width: 0,
height: 0,
pointerEvents: 'none',
zIndex: 9999,
}}
>
<Popover.Anchor />
</div>
<textarea
ref={textareaRef}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoFocus={autoFocus}
rows={3}
className="relative w-full resize-none bg-transparent text-body text-text outline-none placeholder:text-grey-04"
/>
<Popover.Portal>
<Popover.Content
side="bottom"
align="start"
sideOffset={0}
className="z-9999 ml-2 -mt-2"
onOpenAutoFocus={e => {
// We want SelectEntity to focus, but we don't want Radix to do its own focus management
// because it might conflict with our timing. However, SelectEntity already auto-focuses.
Comment on lines +360 to +362

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onOpenAutoFocus is currently a no-op (and the e param is unused). In other Popover usages in this codebase, the handler calls event.preventDefault() (and often event.stopPropagation()) to prevent Radix focus management from stealing focus unexpectedly. Consider matching that pattern here to avoid focus flicker between the textarea and SelectEntity when the mention popover opens, and to avoid unused-arg linting issues.

Suggested change
onOpenAutoFocus={e => {
// We want SelectEntity to focus, but we don't want Radix to do its own focus management
// because it might conflict with our timing. However, SelectEntity already auto-focuses.
onOpenAutoFocus={event => {
// We want SelectEntity to focus, but we don't want Radix to do its own focus management
// because it might conflict with our timing. However, SelectEntity already auto-focuses.
event.preventDefault();
event.stopPropagation();

Copilot uses AI. Check for mistakes.
}}
>
<SelectEntity
spaceId={spaceId}
onDone={onMentionSelect}
variant="floating"
width="clamped"
autoFocus
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<div className="flex items-center justify-end gap-2">
{onCancel && (
<button
Expand Down Expand Up @@ -538,6 +641,7 @@ function CommentItem({
autoFocus
onCancel={() => setIsEditing(false)}
initialValue={comment.markdownContent}
spaceId={comment.spaceId}
/>
) : (
<div className="prose prose-sm max-w-none text-body text-text [&_a]:text-ctaPrimary [&_h1]:text-mediumTitle [&_h2]:text-smallTitle [&_h3]:text-body [&_h3]:font-semibold [&_p]:my-1">
Expand Down Expand Up @@ -581,6 +685,7 @@ function CommentItem({
placeholder={`Reply to ${comment.author.name ?? 'comment'}...`}
autoFocus
onCancel={() => setIsReplying(false)}
spaceId={spaceId}
/>
</div>
)}
Expand All @@ -607,6 +712,7 @@ function CommentItem({
);
}


function getRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
Expand All @@ -623,3 +729,77 @@ function getRelativeTime(dateString: string): string {
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
return date.toLocaleDateString();
}

/**
* Calculates the (x, y) coordinates of the caret in a textarea.
* Based on the shadow-element technique.
*/
function getCaretCoordinates(element: HTMLTextAreaElement, position: number) {
const div = document.createElement('div');
const style = window.getComputedStyle(element);

// Copy essential styles
const properties = [
'direction',
'boxSizing',
'width',
'height',
'overflowX',
'overflowY',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderStyle',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontSizeAdjust',
'lineHeight',
'fontFamily',
'textAlign',
'textTransform',
'textIndent',
'textDecoration',
'letterSpacing',
'wordSpacing',
'tabSize',
'MozTabSize',
];

div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.whiteSpace = 'pre-wrap';
div.style.wordBreak = 'break-word';

properties.forEach(prop => {
// @ts-expect-error - indexable style
div.style[prop] = style[prop];
});

// Text up to the caret
div.textContent = element.value.substring(0, position);

const span = document.createElement('span');
span.textContent = element.value.substring(position) || '.';
div.appendChild(span);

document.body.appendChild(div);

const { offsetLeft: spanLeft, offsetTop: spanTop } = span;
const rect = element.getBoundingClientRect();

document.body.removeChild(div);

// Return coordinates relative to the viewport
return {
x: rect.left + spanLeft - element.scrollLeft,
y: rect.top + spanTop - element.scrollTop,
};
}
8 changes: 8 additions & 0 deletions apps/web/partials/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,14 @@ function useInterceptEditorLinks(spaceId: string) {
const originalUrl = link.href;

if (originalUrl.startsWith('graph://')) {
// Check if we're in edit mode - if so, don't redirect, allow text editing
const isInEditMode = link.closest('.editable') !== null;

if (isInEditMode) {
// In edit mode, don't prevent default - allow normal text selection/editing
return;
}

// Prevent the default link behavior
event.stopPropagation();
event.preventDefault();
Expand Down
Loading
Loading