From 40a25a96bac2e404d4b4db148404bd8dd914977e Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sun, 14 Jun 2026 11:57:34 -0700 Subject: [PATCH 01/15] feat(composer): add link-edit modal, fix broken link picker Replace the native window.prompt link flow (a no-op in the Tauri WebView) with a shadcn dialog that edits display text + URL and can remove a link. Add a click-to-edit affordance: clicking a set link resolves its full mark range and opens the same modal. buzz:// links keep working unchanged. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/forum/ui/ForumComposer.tsx | 20 +- .../features/messages/lib/useLinkEditor.tsx | 173 ++++++++++++++++++ .../messages/lib/useRichTextEditor.ts | 142 ++++++++++++++ .../messages/ui/FormattingToolbar.tsx | 18 +- .../features/messages/ui/MessageComposer.tsx | 16 ++ .../messages/ui/MessageComposerToolbar.tsx | 3 + 6 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/messages/lib/useLinkEditor.tsx diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 8d54889a2..3e57edead 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -10,7 +10,11 @@ import { hasMentionClipboardHtml, normalizeMentionClipboardHtml, } from "@/features/messages/lib/normalizeMentionClipboard"; -import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor"; +import { + type LinkSelectionInfo, + useRichTextEditor, +} from "@/features/messages/lib/useRichTextEditor"; +import { useLinkEditor } from "@/features/messages/lib/useLinkEditor"; import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar"; @@ -77,6 +81,12 @@ export function ForumComposer({ const submitMessageRef = React.useRef<() => void>(() => {}); + // Set after `useLinkEditor` exists; the editor's link-click handler + // delegates through this ref to break the hook ordering cycle. + const onEditLinkRef = React.useRef< + ((info: LinkSelectionInfo) => void) | null + >(null); + const richText = useRichTextEditor({ placeholder, editable: !disabled, @@ -84,6 +94,7 @@ export function ForumComposer({ channelNames: channelLinks.knownChannelNames, onSubmit: () => submitMessageRef.current(), isAutocompleteOpen: isAutocompleteOpenRef, + onEditLink: (info) => onEditLinkRef.current?.(info), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; @@ -94,6 +105,9 @@ export function ForumComposer({ }, }); + const linkEditor = useLinkEditor(richText); + onEditLinkRef.current = linkEditor.openFromClick; + // ── Mention / channel autocomplete insertion ──────────────────────── // Native ProseMirror transactions — no markdown round-trip. const applyMentionInsert = React.useCallback( @@ -379,6 +393,7 @@ export function ForumComposer({ }, [compact, isCompactExpanded, richText.focus]); const autocompletePosition = autocompleteBelow ? "below" : "above"; return ( + <>
)}
+ {linkEditor.dialog} + ); } diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx new file mode 100644 index 000000000..0ae08400b --- /dev/null +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -0,0 +1,173 @@ +import * as React from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; + +import type { + LinkSelectionInfo, + UseRichTextEditorResult, +} from "./useRichTextEditor"; + +type DraftState = { + text: string; + url: string; + from: number; + to: number; + /** Whether the targeted range already carried a link (enables Remove). */ + isExistingLink: boolean; +}; + +/** + * Owns the link-edit modal for a composer. Replaces the old `window.prompt` + * flow (a no-op in the Tauri WebView) with a shadcn dialog that edits both + * the display text and the URL, and offers a Remove action for existing + * links. + * + * Returns: + * - `openFromToolbar` — wire to the formatting toolbar's link button. Seeds + * the modal from the current selection (existing link or selected text). + * - `openFromClick` — wire to `useRichTextEditor`'s `onEditLink`. Seeds the + * modal from the clicked link's range. + * - `dialog` — render once inside the composer tree. + */ +export function useLinkEditor(richText: UseRichTextEditorResult) { + const { getLinkSelectionInfo, applyLink, removeLink } = richText; + const [draft, setDraft] = React.useState(null); + const textId = React.useId(); + const urlId = React.useId(); + + const openFromClick = React.useCallback((info: LinkSelectionInfo) => { + setDraft({ + text: info.text, + url: info.href, + from: info.from, + to: info.to, + isExistingLink: info.href.length > 0, + }); + }, []); + + const openFromToolbar = React.useCallback(() => { + const info = getLinkSelectionInfo(); + if (info) { + openFromClick(info); + return; + } + // No selection and no link under the caret — open an empty modal that + // inserts a fresh link at the caret on save. + setDraft({ text: "", url: "", from: 0, to: 0, isExistingLink: false }); + }, [getLinkSelectionInfo, openFromClick]); + + const close = React.useCallback(() => setDraft(null), []); + + const save = React.useCallback(() => { + if (!draft) return; + const url = draft.url.trim(); + if (!url) return; + if (draft.from === 0 && draft.to === 0) { + // Empty-caret insert: fall back to current selection range. + const info = getLinkSelectionInfo(); + applyLink({ + href: url, + text: draft.text, + from: info?.from ?? 0, + to: info?.to ?? 0, + }); + } else { + applyLink({ href: url, text: draft.text, from: draft.from, to: draft.to }); + } + close(); + }, [draft, applyLink, getLinkSelectionInfo, close]); + + const remove = React.useCallback(() => { + if (!draft) return; + removeLink({ from: draft.from, to: draft.to }); + close(); + }, [draft, removeLink, close]); + + const dialog = ( + { + if (!open) close(); + }} + > + + + + {draft?.isExistingLink ? "Edit link" : "Add link"} + + + Set the text shown in the message and the URL it points to. + + +
{ + event.preventDefault(); + save(); + }} + > + + +
+ {draft?.isExistingLink ? ( + + ) : ( + + )} +
+ + +
+
+
+
+
+ ); + + return { openFromToolbar, openFromClick, dialog }; +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index e293aa9ab..68c929c3c 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -7,6 +7,7 @@ import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; import { Selection, TextSelection } from "@tiptap/pm/state"; +import type { EditorState } from "@tiptap/pm/state"; import { isMacPlatform } from "@/shared/lib/platform"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; @@ -71,6 +72,22 @@ export type RichTextEditorOptions = { onEditLastOwnMessage?: () => boolean; /** When true, plain Enter is passed through (e.g. to select an autocomplete item). */ isAutocompleteOpen?: React.RefObject; + /** + * Called when the user clicks an existing link in the editor. The link + * extension runs with `openOnClick: false` (a chat composer must not + * navigate away on click), so we route the click here instead: the owner + * opens the link-edit modal to change or remove the URL. `from`/`to` bound + * the full link mark range so the owner can apply edits without re-selecting. + */ + onEditLink?: (info: LinkSelectionInfo) => void; +}; + +/** A link mark range with its href and the text it covers. */ +export type LinkSelectionInfo = { + href: string; + text: string; + from: number; + to: number; }; /** @@ -93,6 +110,7 @@ export function useRichTextEditor({ onSubmit, onEditLastOwnMessage, isAutocompleteOpen, + onEditLink, }: RichTextEditorOptions) { const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; @@ -103,6 +121,9 @@ export function useRichTextEditor({ const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); onEditLastOwnMessageRef.current = onEditLastOwnMessage; + const onEditLinkRef = React.useRef(onEditLink); + onEditLinkRef.current = onEditLink; + const placeholderRef = React.useRef(placeholder); placeholderRef.current = placeholder; @@ -363,6 +384,20 @@ export function useRichTextEditor({ // otherwise let ArrowUp fall through to normal caret movement. return handler(); }, + // Click on an existing link → open the link-edit modal. The link + // extension is configured `openOnClick: false` (never navigate away + // from a chat composer), so without this hook a click on a link does + // nothing. We resolve the full link mark range under the cursor and + // hand it to the owner; returning false leaves caret placement to + // ProseMirror so the click still feels native. + handleClick: (view, pos) => { + const handler = onEditLinkRef.current; + if (!handler) return false; + const info = resolveLinkAt(view.state, pos); + if (!info) return false; + handler(info); + return false; + }, }, onUpdate: ({ editor: ed }) => { const markdown = getMarkdownFromEditor(ed); @@ -579,6 +614,63 @@ export function useRichTextEditor({ [editor, customEmojiWiring.resolveUrl], ); + /** + * Link mark info for the current selection — its href and the covered + * text, expanded to the full link range when the caret merely sits inside + * a link. Returns `null` when there is no link. Used to prefill the + * link-edit modal when the user clicks the link toolbar button. + */ + const getLinkSelectionInfo = + React.useCallback((): LinkSelectionInfo | null => { + if (!editor) return null; + const { from, to } = editor.state.selection; + const onLink = resolveLinkAt(editor.state, from); + if (onLink) return onLink; + if (from === to) return null; + // No existing link, but text is selected — seed the modal with the + // selected text as the display value and the selection range. + const text = editor.state.doc.textBetween(from, to, "\n", "\n"); + return { href: "", text, from, to }; + }, [editor]); + + /** + * Apply a link to the given range, replacing the covered text with + * `text` and marking it with `href`. When `from === to` (no range), the + * linked text is inserted at the caret. Used by both the toolbar button + * and the click-to-edit modal. + */ + const applyLink = React.useCallback( + ({ href, text, from, to }: LinkSelectionInfo) => { + if (!editor) return; + const label = text.trim().length > 0 ? text : href; + const linkMark = editor.schema.marks.link.create({ href }); + const node = editor.schema.text(label, [linkMark]); + const tr = editor.state.tr.replaceRangeWith(from, to, node); + const cursorPM = tr.mapping.map(to); + tr.setSelection(TextSelection.create(tr.doc, cursorPM)); + editor.view.dispatch(tr); + editor.view.focus(); + }, + [editor], + ); + + /** + * Remove the link mark across the given range, leaving the text in place. + */ + const removeLink = React.useCallback( + ({ from, to }: { from: number; to: number }) => { + if (!editor) return; + editor + .chain() + .focus() + .setTextSelection({ from, to }) + .unsetLink() + .setTextSelection(to) + .run(); + }, + [editor], + ); + return { editor, getMarkdown, @@ -590,6 +682,9 @@ export function useRichTextEditor({ focusPreserve, getPlainTextAndCursor, replacePlainTextRange, + getLinkSelectionInfo, + applyLink, + removeLink, }; } @@ -616,3 +711,50 @@ function getMarkdownFromEditor(editor: Editor): string { // Fallback: plain text return editor.state.doc.textContent; } + +/** + * Resolve the link mark covering position `pos`, expanded to the full + * contiguous range of that same link. Returns the href, the covered text, + * and the `from`/`to` document positions, or `null` when `pos` is not + * inside a link. + * + * ProseMirror stores links as marks on text nodes, so a single visual link + * can span several adjacent text nodes. We walk outward from `pos` while the + * link mark (with the same href) stays present to recover the whole range. + */ +function resolveLinkAt( + state: EditorState, + pos: number, +): LinkSelectionInfo | null { + const linkType = state.schema.marks.link; + if (!linkType) return null; + + const $pos = state.doc.resolve(pos); + // The mark at the caret sits on the character *before* the position, with + // the character after as a fallback (caret at a link's left edge). + const mark = + linkType.isInSet($pos.marks()) || + (pos < state.doc.content.size + ? linkType.isInSet(state.doc.resolve(pos + 1).marks()) + : null); + if (!mark) return null; + + const href = mark.attrs.href as string; + const parent = $pos.parent; + const parentStart = $pos.start(); + + // Scan the text block for the contiguous run carrying this same link. + let from = pos; + let to = pos; + parent.forEach((child, childOffset) => { + const childFrom = parentStart + childOffset; + const childTo = childFrom + child.nodeSize; + if (mark.isInSet(child.marks) && childFrom <= pos && pos <= childTo) { + from = Math.min(from, childFrom); + to = Math.max(to, childTo); + } + }); + + const text = state.doc.textBetween(from, to, "\n", "\n"); + return { href, text, from, to }; +} diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx index fab4a1bfd..89bf48f9d 100644 --- a/desktop/src/features/messages/ui/FormattingToolbar.tsx +++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx @@ -18,6 +18,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; type FormattingToolbarProps = { editor: Editor | null; disabled?: boolean; + /** + * Opens the link-edit modal for the current selection. When provided, the + * link button routes here instead of the legacy `window.prompt` flow + * (a no-op in the Tauri WebView). + */ + onLinkButton?: () => void; }; type ActiveStates = { @@ -54,6 +60,7 @@ function getActiveStates(editor: Editor): ActiveStates { export const FormattingToolbar = React.memo(function FormattingToolbar({ editor, disabled = false, + onLinkButton, }: FormattingToolbarProps) { const [activeStates, setActiveStates] = React.useState( () => (editor ? getActiveStates(editor) : null), @@ -98,6 +105,15 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ const toggleLink = React.useCallback(() => { if (!editor) return; + // Preferred path: open the link-edit modal, which handles add, edit, and + // remove with proper display-text + URL fields. + if (onLinkButton) { + onLinkButton(); + return; + } + + // Legacy fallback (no modal wired): the native prompts below are a no-op + // in the Tauri WebView, so this path effectively does nothing there. if (editor.isActive("link")) { editor.chain().focus().unsetLink().run(); return; @@ -118,7 +134,7 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ editor.chain().focus().insertContent(`[${label}](${url})`).run(); } } - }, [editor]); + }, [editor, onLinkButton]); const toggleBulletList = React.useCallback(() => { editor?.chain().focus().toggleBulletList().run(); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 36f5985a0..8328e8e17 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -30,8 +30,10 @@ import { import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode"; import { type AutocompleteEdit, + type LinkSelectionInfo, useRichTextEditor, } from "@/features/messages/lib/useRichTextEditor"; +import { useLinkEditor } from "@/features/messages/lib/useLinkEditor"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; import { cn } from "@/shared/lib/cn"; @@ -190,6 +192,13 @@ export function MessageComposer({ const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); + // Set after `useLinkEditor` exists below; the editor's link-click handler + // delegates through this ref to break the hook ordering cycle (the editor + // needs `onEditLink`, but the link editor needs the editor's `richText`). + const onEditLinkRef = React.useRef< + ((info: LinkSelectionInfo) => void) | null + >(null); + const scrollComposerToBottom = React.useCallback(() => { window.requestAnimationFrame(() => { const scrollElement = composerScrollRef.current; @@ -221,6 +230,7 @@ export function MessageComposer({ return handler ? handler() : false; }, isAutocompleteOpen: isAutocompleteOpenRef, + onEditLink: (info) => onEditLinkRef.current?.(info), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; @@ -239,6 +249,9 @@ export function MessageComposer({ }, }); + const linkEditor = useLinkEditor(richText); + onEditLinkRef.current = linkEditor.openFromClick; + const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -867,6 +880,7 @@ export function MessageComposer({ onEmojiPickerOpenChange={setIsEmojiPickerOpen} onEmojiSelect={insertEmoji} onFormattingToggle={handleFormattingToggle} + onLinkButton={linkEditor.openFromToolbar} onOpenMentionPicker={openMentionPicker} onPaperclip={handlePaperclipClick} sendDisabled={sendDisabled} @@ -884,6 +898,8 @@ export function MessageComposer({ onInvite={mentionSendFlow.inviteNonMembers} open={mentionSendFlow.pendingNonMemberSend !== null} /> + + {linkEditor.dialog} ); } diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 8d2d95d8c..5b584d38d 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -29,6 +29,7 @@ export const MessageComposerToolbar = React.memo( onEmojiPickerOpenChange, onEmojiSelect, onFormattingToggle, + onLinkButton, onOpenMentionPicker, onPaperclip, sendDisabled, @@ -45,6 +46,7 @@ export const MessageComposerToolbar = React.memo( onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; onFormattingToggle: (pressed: boolean) => void; + onLinkButton: () => void; onOpenMentionPicker: () => void; onPaperclip: () => void; sendDisabled: boolean; @@ -134,6 +136,7 @@ export const MessageComposerToolbar = React.memo( From c74ed249aff91a1d128ce927e82c064058789b39 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sun, 14 Jun 2026 12:11:43 -0700 Subject: [PATCH 02/15] fix(composer): resolve full link range across mixed-formatting nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveLinkAt walked only the single text node under the cursor, so a link whose text carried bold/italic/code (split into multiple text nodes) resolved to just the fragment under the click — Edit/Remove then mangled a partial range, leaving a dangling half-link. Extract resolveLinkAt into its own module and extend the range outward from the clicked child across all contiguous siblings carrying the same link href. Add unit tests covering the mixed-formatting case, adjacent links with differing hrefs, and the plain-link / no-link paths. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../messages/lib/resolveLinkAt.test.mjs | 85 +++++++++++++++++++ .../features/messages/lib/resolveLinkAt.ts | 68 +++++++++++++++ .../messages/lib/useRichTextEditor.ts | 63 ++------------ 3 files changed, 160 insertions(+), 56 deletions(-) create mode 100644 desktop/src/features/messages/lib/resolveLinkAt.test.mjs create mode 100644 desktop/src/features/messages/lib/resolveLinkAt.ts diff --git a/desktop/src/features/messages/lib/resolveLinkAt.test.mjs b/desktop/src/features/messages/lib/resolveLinkAt.test.mjs new file mode 100644 index 000000000..1b62e4d1d --- /dev/null +++ b/desktop/src/features/messages/lib/resolveLinkAt.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Schema } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; + +import { resolveLinkAt } from "./resolveLinkAt.ts"; + +// Minimal schema mirroring the editor's relevant pieces: a link mark with an +// href plus a bold mark, so a single link can be split across text nodes. +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*" }, + text: { group: "inline" }, + }, + marks: { + link: { attrs: { href: {} }, inclusive: false }, + bold: {}, + }, +}); + +const link = (href) => schema.marks.link.create({ href }); +const bold = schema.marks.bold.create(); + +function stateFromParagraph(nodes) { + const doc = schema.node("doc", null, [schema.node("paragraph", null, nodes)]); + return EditorState.create({ doc }); +} + +// ── resolveLinkAt ───────────────────────────────────────────────────── + +test("resolves a plain single-node link", () => { + const href = "https://example.com"; + const state = stateFromParagraph([schema.text("click here", [link(href)])]); + + // pos 1 is the start of the paragraph content (inside the link text). + const info = resolveLinkAt(state, 3); + assert.ok(info); + assert.equal(info.href, href); + assert.equal(info.text, "click here"); + assert.equal(info.from, 1); + assert.equal(info.to, 1 + "click here".length); +}); + +test("resolves a link split across mixed-formatting text nodes", () => { + const href = "https://example.com"; + // "see " + bold "this" + " link" — three text nodes, one visual link. + const state = stateFromParagraph([ + schema.text("see ", [link(href)]), + schema.text("this", [link(href), bold]), + schema.text(" link", [link(href)]), + ]); + + const full = "see this link"; + // Click lands inside the bold fragment in the middle. + const info = resolveLinkAt(state, 7); + assert.ok(info); + assert.equal(info.href, href); + // The whole link must be recovered, not just the bold fragment. + assert.equal(info.text, full); + assert.equal(info.from, 1); + assert.equal(info.to, 1 + full.length); +}); + +test("does not extend across an adjacent link with a different href", () => { + const a = "https://a.com"; + const b = "https://b.com"; + const state = stateFromParagraph([ + schema.text("alpha", [link(a)]), + schema.text("beta", [link(b)]), + ]); + + const info = resolveLinkAt(state, 3); + assert.ok(info); + assert.equal(info.href, a); + assert.equal(info.text, "alpha"); + assert.equal(info.from, 1); + assert.equal(info.to, 1 + "alpha".length); +}); + +test("returns null when position is not inside a link", () => { + const state = stateFromParagraph([schema.text("no link here")]); + assert.equal(resolveLinkAt(state, 3), null); +}); diff --git a/desktop/src/features/messages/lib/resolveLinkAt.ts b/desktop/src/features/messages/lib/resolveLinkAt.ts new file mode 100644 index 000000000..cb16e4731 --- /dev/null +++ b/desktop/src/features/messages/lib/resolveLinkAt.ts @@ -0,0 +1,68 @@ +import type { EditorState } from "@tiptap/pm/state"; + +export type LinkSelectionInfo = { + href: string; + text: string; + from: number; + to: number; +}; + +/** + * Resolve the link mark covering position `pos`, expanded to the full + * contiguous range of that same link. Returns the href, the covered text, + * and the `from`/`to` document positions, or `null` when `pos` is not + * inside a link. + * + * ProseMirror stores links as marks on text nodes, so one visual link can + * span several adjacent text nodes when its text carries mixed formatting + * (bold/italic/code). We extend outward from the child under `pos` across + * every contiguous sibling carrying the same link href, so Edit/Remove + * operate on the whole link rather than a single fragment. + */ +export function resolveLinkAt( + state: EditorState, + pos: number, +): LinkSelectionInfo | null { + const linkType = state.schema.marks.link; + if (!linkType) return null; + + const $pos = state.doc.resolve(pos); + // The mark at the caret sits on the character *before* the position, with + // the character after as a fallback (caret at a link's left edge). + const mark = + linkType.isInSet($pos.marks()) || + (pos < state.doc.content.size + ? linkType.isInSet(state.doc.resolve(pos + 1).marks()) + : null); + if (!mark) return null; + + const href = mark.attrs.href as string; + const parent = $pos.parent; + const parentStart = $pos.start(); + + type ChildSpan = { from: number; to: number; hasLink: boolean }; + const spans: ChildSpan[] = []; + let anchorIndex = -1; + parent.forEach((child, childOffset) => { + const childFrom = parentStart + childOffset; + const childTo = childFrom + child.nodeSize; + const childLink = linkType.isInSet(child.marks); + const hasLink = childLink != null && childLink.attrs.href === href; + if (childFrom <= pos && pos <= childTo) anchorIndex = spans.length; + spans.push({ from: childFrom, to: childTo, hasLink }); + }); + + if (anchorIndex === -1) return { href, text: "", from: pos, to: pos }; + + let from = spans[anchorIndex].from; + let to = spans[anchorIndex].to; + for (let i = anchorIndex - 1; i >= 0 && spans[i].hasLink; i--) { + from = spans[i].from; + } + for (let i = anchorIndex + 1; i < spans.length && spans[i].hasLink; i++) { + to = spans[i].to; + } + + const text = state.doc.textBetween(from, to, "\n", "\n"); + return { href, text, from, to }; +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 68c929c3c..40adc689d 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -7,11 +7,17 @@ import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; import { Selection, TextSelection } from "@tiptap/pm/state"; -import type { EditorState } from "@tiptap/pm/state"; import { isMacPlatform } from "@/shared/lib/platform"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import { + resolveLinkAt, + type LinkSelectionInfo, +} from "./resolveLinkAt"; + +export type { LinkSelectionInfo } from "./resolveLinkAt"; + import { MentionHighlightExtension, mentionHighlightKey, @@ -82,14 +88,6 @@ export type RichTextEditorOptions = { onEditLink?: (info: LinkSelectionInfo) => void; }; -/** A link mark range with its href and the text it covers. */ -export type LinkSelectionInfo = { - href: string; - text: string; - from: number; - to: number; -}; - /** * Creates and manages a Tiptap editor configured for Markdown output. * @@ -711,50 +709,3 @@ function getMarkdownFromEditor(editor: Editor): string { // Fallback: plain text return editor.state.doc.textContent; } - -/** - * Resolve the link mark covering position `pos`, expanded to the full - * contiguous range of that same link. Returns the href, the covered text, - * and the `from`/`to` document positions, or `null` when `pos` is not - * inside a link. - * - * ProseMirror stores links as marks on text nodes, so a single visual link - * can span several adjacent text nodes. We walk outward from `pos` while the - * link mark (with the same href) stays present to recover the whole range. - */ -function resolveLinkAt( - state: EditorState, - pos: number, -): LinkSelectionInfo | null { - const linkType = state.schema.marks.link; - if (!linkType) return null; - - const $pos = state.doc.resolve(pos); - // The mark at the caret sits on the character *before* the position, with - // the character after as a fallback (caret at a link's left edge). - const mark = - linkType.isInSet($pos.marks()) || - (pos < state.doc.content.size - ? linkType.isInSet(state.doc.resolve(pos + 1).marks()) - : null); - if (!mark) return null; - - const href = mark.attrs.href as string; - const parent = $pos.parent; - const parentStart = $pos.start(); - - // Scan the text block for the contiguous run carrying this same link. - let from = pos; - let to = pos; - parent.forEach((child, childOffset) => { - const childFrom = parentStart + childOffset; - const childTo = childFrom + child.nodeSize; - if (mark.isInSet(child.marks) && childFrom <= pos && pos <= childTo) { - from = Math.min(from, childFrom); - to = Math.max(to, childTo); - } - }); - - const text = state.doc.textBetween(from, to, "\n", "\n"); - return { href, text, from, to }; -} From 0ebc0a5688ae14b0eae46397dfeede6fe382dfa3 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sun, 14 Jun 2026 13:39:19 -0700 Subject: [PATCH 03/15] feat(composer): link-info popover, focus-by-intent, drop helper label Clicking a set link now opens an info-only popover anchored at the link (display text + URL as a real hyperlink that opens via the opener plugin, with Edit and Remove) instead of the takeover modal. The caret moves to the clicked position so display text can be tweaked inline. The modal stays for the Add and Edit flows. Add-link (toolbar) focuses the URL input first; Edit-link focuses the display-text input. Removed the self-evident DialogDescription label. Both composers thread the click anchor rect through onEditLink. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/forum/ui/ForumComposer.tsx | 4 +- .../features/messages/lib/useLinkEditor.tsx | 179 +++++++++++++++--- .../messages/lib/useRichTextEditor.ts | 25 ++- .../features/messages/ui/MessageComposer.tsx | 4 +- 4 files changed, 175 insertions(+), 37 deletions(-) diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 3e57edead..ed34e2daa 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -84,7 +84,7 @@ export function ForumComposer({ // Set after `useLinkEditor` exists; the editor's link-click handler // delegates through this ref to break the hook ordering cycle. const onEditLinkRef = React.useRef< - ((info: LinkSelectionInfo) => void) | null + ((info: LinkSelectionInfo, anchorRect: DOMRect) => void) | null >(null); const richText = useRichTextEditor({ @@ -94,7 +94,7 @@ export function ForumComposer({ channelNames: channelLinks.knownChannelNames, onSubmit: () => submitMessageRef.current(), isAutocompleteOpen: isAutocompleteOpenRef, - onEditLink: (info) => onEditLinkRef.current?.(info), + onEditLink: (info, anchorRect) => onEditLinkRef.current?.(info, anchorRect), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index 0ae08400b..8c9f9c672 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -1,12 +1,18 @@ import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; + import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/shared/ui/popover"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; @@ -24,45 +30,98 @@ type DraftState = { isExistingLink: boolean; }; +type PopoverState = { + text: string; + url: string; + from: number; + to: number; + /** Viewport rect of the clicked link, used to anchor the popover. */ + rect: DOMRect; +}; + /** - * Owns the link-edit modal for a composer. Replaces the old `window.prompt` - * flow (a no-op in the Tauri WebView) with a shadcn dialog that edits both - * the display text and the URL, and offers a Remove action for existing - * links. + * Owns the link UX for a composer: an info popover shown when a set link is + * clicked, plus the add/edit modal. Replaces the old `window.prompt` flow (a + * no-op in the Tauri WebView). + * + * Clicking a set link surfaces an info-only popover (display text + URL, the + * URL a real hyperlink that opens the link) with Edit and Remove — so a user + * can tweak display text inline without a takeover modal. The modal is reached + * via the popover's Edit button (focus on display text) and the toolbar's Add + * flow (focus on URL). * * Returns: - * - `openFromToolbar` — wire to the formatting toolbar's link button. Seeds - * the modal from the current selection (existing link or selected text). - * - `openFromClick` — wire to `useRichTextEditor`'s `onEditLink`. Seeds the - * modal from the clicked link's range. - * - `dialog` — render once inside the composer tree. + * - `openFromToolbar` — wire to the formatting toolbar's link button. Opens + * the modal seeded from the current selection (existing link or selected + * text). + * - `openFromClick` — wire to `useRichTextEditor`'s `onEditLink`. Opens the + * info popover anchored at the clicked link. + * - `dialog` — render once inside the composer tree (popover + modal). */ export function useLinkEditor(richText: UseRichTextEditorResult) { const { getLinkSelectionInfo, applyLink, removeLink } = richText; const [draft, setDraft] = React.useState(null); + const [popover, setPopover] = React.useState(null); const textId = React.useId(); const urlId = React.useId(); - const openFromClick = React.useCallback((info: LinkSelectionInfo) => { - setDraft({ - text: info.text, - url: info.href, - from: info.from, - to: info.to, - isExistingLink: info.href.length > 0, - }); + // Clicking a set link → info popover anchored at the clicked link. + const openFromClick = React.useCallback( + (info: LinkSelectionInfo, rect: DOMRect) => { + setPopover({ + text: info.text, + url: info.href, + from: info.from, + to: info.to, + rect, + }); + }, + [], + ); + + const closePopover = React.useCallback(() => setPopover(null), []); + + // Opens the modal seeded from a link's range. `focusUrl` decides which input + // takes initial focus (URL for Add, display text for Edit). + const openModal = React.useCallback((state: DraftState) => { + setDraft(state); }, []); const openFromToolbar = React.useCallback(() => { const info = getLinkSelectionInfo(); if (info) { - openFromClick(info); + openModal({ + text: info.text, + url: info.href, + from: info.from, + to: info.to, + isExistingLink: info.href.length > 0, + }); return; } // No selection and no link under the caret — open an empty modal that // inserts a fresh link at the caret on save. - setDraft({ text: "", url: "", from: 0, to: 0, isExistingLink: false }); - }, [getLinkSelectionInfo, openFromClick]); + openModal({ text: "", url: "", from: 0, to: 0, isExistingLink: false }); + }, [getLinkSelectionInfo, openModal]); + + // Popover Edit → close the popover, open the modal on the same range. + const editFromPopover = React.useCallback(() => { + if (!popover) return; + openModal({ + text: popover.text, + url: popover.url, + from: popover.from, + to: popover.to, + isExistingLink: true, + }); + closePopover(); + }, [popover, openModal, closePopover]); + + const removeFromPopover = React.useCallback(() => { + if (!popover) return; + removeLink({ from: popover.from, to: popover.to }); + closePopover(); + }, [popover, removeLink, closePopover]); const close = React.useCallback(() => setDraft(null), []); @@ -91,6 +150,67 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { close(); }, [draft, removeLink, close]); + // Add link (no existing link) focuses the URL; Edit focuses display text. + const focusUrlFirst = draft ? !draft.isExistingLink : false; + + const popoverCard = ( + { + if (!open) closePopover(); + }} + > + + event.preventDefault()} + > +
+ {popover?.text} +
+ { + event.preventDefault(); + if (popover?.url) void openUrl(popover.url); + }} + > + {popover?.url} + +
+ + +
+
+
+ ); + const dialog = ( {draft?.isExistingLink ? "Edit link" : "Add link"} - - Set the text shown in the message and the URL it points to. -
@@ -138,6 +255,7 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { URL @@ -169,5 +287,14 @@ export function useLinkEditor(richText: UseRichTextEditorResult) {
); - return { openFromToolbar, openFromClick, dialog }; + return { + openFromToolbar, + openFromClick, + dialog: ( + <> + {popoverCard} + {dialog} + + ), + }; } diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 40adc689d..32bbe28fc 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -82,10 +82,11 @@ export type RichTextEditorOptions = { * Called when the user clicks an existing link in the editor. The link * extension runs with `openOnClick: false` (a chat composer must not * navigate away on click), so we route the click here instead: the owner - * opens the link-edit modal to change or remove the URL. `from`/`to` bound - * the full link mark range so the owner can apply edits without re-selecting. + * shows an info popover (display text + URL, with Edit/Remove). `from`/`to` + * bound the full link mark range. `anchorRect` is the viewport rect of the + * clicked position so the owner can anchor the popover at the link. */ - onEditLink?: (info: LinkSelectionInfo) => void; + onEditLink?: (info: LinkSelectionInfo, anchorRect: DOMRect) => void; }; /** @@ -382,18 +383,28 @@ export function useRichTextEditor({ // otherwise let ArrowUp fall through to normal caret movement. return handler(); }, - // Click on an existing link → open the link-edit modal. The link + // Click on an existing link → show the link-info popover. The link // extension is configured `openOnClick: false` (never navigate away // from a chat composer), so without this hook a click on a link does // nothing. We resolve the full link mark range under the cursor and - // hand it to the owner; returning false leaves caret placement to - // ProseMirror so the click still feels native. + // hand it to the owner along with the clicked position's viewport rect + // (so the popover can anchor at the link). Returning false leaves + // caret placement to ProseMirror, so the click moves the caret to the + // clicked spot — letting the user tweak display text inline without + // being yanked out of the editor. handleClick: (view, pos) => { const handler = onEditLinkRef.current; if (!handler) return false; const info = resolveLinkAt(view.state, pos); if (!info) return false; - handler(info); + const coords = view.coordsAtPos(pos); + const anchorRect = new DOMRect( + coords.left, + coords.top, + 0, + coords.bottom - coords.top, + ); + handler(info, anchorRect); return false; }, }, diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 8328e8e17..52cde50ba 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -196,7 +196,7 @@ export function MessageComposer({ // delegates through this ref to break the hook ordering cycle (the editor // needs `onEditLink`, but the link editor needs the editor's `richText`). const onEditLinkRef = React.useRef< - ((info: LinkSelectionInfo) => void) | null + ((info: LinkSelectionInfo, anchorRect: DOMRect) => void) | null >(null); const scrollComposerToBottom = React.useCallback(() => { @@ -230,7 +230,7 @@ export function MessageComposer({ return handler ? handler() : false; }, isAutocompleteOpen: isAutocompleteOpenRef, - onEditLink: (info) => onEditLinkRef.current?.(info), + onEditLink: (info, anchorRect) => onEditLinkRef.current?.(info, anchorRect), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; From 53a45fecdac082ff543491c22a1770080c93561b Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sun, 14 Jun 2026 13:49:08 -0700 Subject: [PATCH 04/15] Route buzz:// deep-links in-app from the link-edit popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover's URL hyperlink called openUrl() for every href, handing buzz://message?… deep-links to the OS opener instead of navigating in-app. Extract openPopoverLink() to mirror the rendered-link path in markdown.tsx: isMessageLink → parseMessageLink → goChannel; everything else (http(s), other buzz:// URLs) falls through to openUrl. Wired via useAppNavigation in useLinkEditor, so both composers get it for free. Adds 4 unit tests covering the buzz://message, http(s), non-message buzz://, and malformed-deep-link branches. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../messages/lib/openPopoverLink.test.mjs | 56 +++++++++++++++++++ .../features/messages/lib/openPopoverLink.ts | 25 +++++++++ .../features/messages/lib/useLinkEditor.tsx | 19 ++++++- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/messages/lib/openPopoverLink.test.mjs create mode 100644 desktop/src/features/messages/lib/openPopoverLink.ts diff --git a/desktop/src/features/messages/lib/openPopoverLink.test.mjs b/desktop/src/features/messages/lib/openPopoverLink.test.mjs new file mode 100644 index 000000000..289833a13 --- /dev/null +++ b/desktop/src/features/messages/lib/openPopoverLink.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { openPopoverLink } from "./openPopoverLink.ts"; + +const CHANNEL = "f570339f-8f8a-4e08-a779-8d954aa44109"; +const MESSAGE = + "b04819ffc1f7c8ffb49c6d30b5899f470198264680d02e78894a658e30a9059f"; + +function makeSpies() { + const external = []; + const inApp = []; + return { + handlers: { + openExternal: (url) => external.push(url), + openMessageLink: (link) => inApp.push(link), + }, + external, + inApp, + }; +} + +test("buzz://message deep-link routes in-app, not the OS opener", () => { + const { handlers, external, inApp } = makeSpies(); + openPopoverLink( + `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`, + handlers, + ); + assert.equal(external.length, 0); + assert.deepEqual(inApp, [ + { channelId: CHANNEL, messageId: MESSAGE, threadRootId: null }, + ]); +}); + +test("http(s) URLs go to the OS opener", () => { + const { handlers, external, inApp } = makeSpies(); + openPopoverLink("https://example.com/path", handlers); + assert.deepEqual(external, ["https://example.com/path"]); + assert.equal(inApp.length, 0); +}); + +test("non-message buzz:// URLs fall through to the OS opener", () => { + const { handlers, external, inApp } = makeSpies(); + openPopoverLink("buzz://channel?foo=bar", handlers); + assert.deepEqual(external, ["buzz://channel?foo=bar"]); + assert.equal(inApp.length, 0); +}); + +test("malformed buzz://message URL falls back to the OS opener", () => { + const { handlers, external, inApp } = makeSpies(); + // Matches isMessageLink (starts with buzz://message?) but is missing the + // required channel/id params, so parse fails and we don't navigate in-app. + openPopoverLink("buzz://message?nope=1", handlers); + assert.deepEqual(external, ["buzz://message?nope=1"]); + assert.equal(inApp.length, 0); +}); diff --git a/desktop/src/features/messages/lib/openPopoverLink.ts b/desktop/src/features/messages/lib/openPopoverLink.ts new file mode 100644 index 000000000..8440abd09 --- /dev/null +++ b/desktop/src/features/messages/lib/openPopoverLink.ts @@ -0,0 +1,25 @@ +import { isMessageLink, parseMessageLink } from "./messageLink"; +import type { ParsedMessageLink } from "./messageLink"; + +/** + * Open a link the same way the rendered-message link path does: + * `buzz://message?…` deep-links navigate in-app, everything else (http(s), + * other buzz:// URLs) goes to the OS opener. Mirrors `markdown.tsx`'s `a` + * renderer so the composer popover and the rendered link behave identically. + */ +export function openPopoverLink( + url: string, + handlers: { + openExternal: (url: string) => void; + openMessageLink: (link: ParsedMessageLink) => void; + }, +): void { + if (isMessageLink(url)) { + const parsed = parseMessageLink(url); + if (parsed.ok) { + handlers.openMessageLink(parsed.value); + return; + } + } + handlers.openExternal(url); +} diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index 8c9f9c672..fb61675b0 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { Dialog, DialogContent, @@ -16,6 +17,7 @@ import { import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; +import { openPopoverLink } from "./openPopoverLink"; import type { LinkSelectionInfo, UseRichTextEditorResult, @@ -60,6 +62,7 @@ type PopoverState = { */ export function useLinkEditor(richText: UseRichTextEditorResult) { const { getLinkSelectionInfo, applyLink, removeLink } = richText; + const { goChannel } = useAppNavigation(); const [draft, setDraft] = React.useState(null); const [popover, setPopover] = React.useState(null); const textId = React.useId(); @@ -123,6 +126,20 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { closePopover(); }, [popover, removeLink, closePopover]); + // Popover URL click: route `buzz://message?…` deep-links in-app (matching + // the rendered-message link path), everything else to the OS opener. + const openLink = React.useCallback(() => { + if (!popover?.url) return; + openPopoverLink(popover.url, { + openExternal: (url) => void openUrl(url), + openMessageLink: (link) => + void goChannel(link.channelId, { + messageId: link.messageId, + threadRootId: link.threadRootId, + }), + }); + }, [popover, goChannel]); + const close = React.useCallback(() => setDraft(null), []); const save = React.useCallback(() => { @@ -185,7 +202,7 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { className="text-primary text-xs underline underline-offset-4 break-all" onClick={(event) => { event.preventDefault(); - if (popover?.url) void openUrl(popover.url); + openLink(); }} > {popover?.url} From 5d219e1da36a52681334b7461d2f6b674a072ad2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 14:02:58 -0700 Subject: [PATCH 05/15] fix(composer): restore modal link editing - Revert the click-to-popover link editing flow back to the modal-only behavior from the original link editor direction. - Remove the popover-specific link opener helper and tests since composer links are no longer opened directly from a popover. - Simplify the message and forum composer link-edit callback plumbing back to passing only the resolved link selection. - Intercept editor anchor clicks at the DOM event layer and in the ProseMirror click hook so link clicks open the modal without launching the URL externally. --- .../src/features/forum/ui/ForumComposer.tsx | 4 +- .../messages/lib/openPopoverLink.test.mjs | 56 ----- .../features/messages/lib/openPopoverLink.ts | 25 --- .../features/messages/lib/useLinkEditor.tsx | 196 +++--------------- .../messages/lib/useRichTextEditor.ts | 60 ++++-- .../features/messages/ui/MessageComposer.tsx | 4 +- 6 files changed, 69 insertions(+), 276 deletions(-) delete mode 100644 desktop/src/features/messages/lib/openPopoverLink.test.mjs delete mode 100644 desktop/src/features/messages/lib/openPopoverLink.ts diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index ed34e2daa..3e57edead 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -84,7 +84,7 @@ export function ForumComposer({ // Set after `useLinkEditor` exists; the editor's link-click handler // delegates through this ref to break the hook ordering cycle. const onEditLinkRef = React.useRef< - ((info: LinkSelectionInfo, anchorRect: DOMRect) => void) | null + ((info: LinkSelectionInfo) => void) | null >(null); const richText = useRichTextEditor({ @@ -94,7 +94,7 @@ export function ForumComposer({ channelNames: channelLinks.knownChannelNames, onSubmit: () => submitMessageRef.current(), isAutocompleteOpen: isAutocompleteOpenRef, - onEditLink: (info, anchorRect) => onEditLinkRef.current?.(info, anchorRect), + onEditLink: (info) => onEditLinkRef.current?.(info), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; diff --git a/desktop/src/features/messages/lib/openPopoverLink.test.mjs b/desktop/src/features/messages/lib/openPopoverLink.test.mjs deleted file mode 100644 index 289833a13..000000000 --- a/desktop/src/features/messages/lib/openPopoverLink.test.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { openPopoverLink } from "./openPopoverLink.ts"; - -const CHANNEL = "f570339f-8f8a-4e08-a779-8d954aa44109"; -const MESSAGE = - "b04819ffc1f7c8ffb49c6d30b5899f470198264680d02e78894a658e30a9059f"; - -function makeSpies() { - const external = []; - const inApp = []; - return { - handlers: { - openExternal: (url) => external.push(url), - openMessageLink: (link) => inApp.push(link), - }, - external, - inApp, - }; -} - -test("buzz://message deep-link routes in-app, not the OS opener", () => { - const { handlers, external, inApp } = makeSpies(); - openPopoverLink( - `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`, - handlers, - ); - assert.equal(external.length, 0); - assert.deepEqual(inApp, [ - { channelId: CHANNEL, messageId: MESSAGE, threadRootId: null }, - ]); -}); - -test("http(s) URLs go to the OS opener", () => { - const { handlers, external, inApp } = makeSpies(); - openPopoverLink("https://example.com/path", handlers); - assert.deepEqual(external, ["https://example.com/path"]); - assert.equal(inApp.length, 0); -}); - -test("non-message buzz:// URLs fall through to the OS opener", () => { - const { handlers, external, inApp } = makeSpies(); - openPopoverLink("buzz://channel?foo=bar", handlers); - assert.deepEqual(external, ["buzz://channel?foo=bar"]); - assert.equal(inApp.length, 0); -}); - -test("malformed buzz://message URL falls back to the OS opener", () => { - const { handlers, external, inApp } = makeSpies(); - // Matches isMessageLink (starts with buzz://message?) but is missing the - // required channel/id params, so parse fails and we don't navigate in-app. - openPopoverLink("buzz://message?nope=1", handlers); - assert.deepEqual(external, ["buzz://message?nope=1"]); - assert.equal(inApp.length, 0); -}); diff --git a/desktop/src/features/messages/lib/openPopoverLink.ts b/desktop/src/features/messages/lib/openPopoverLink.ts deleted file mode 100644 index 8440abd09..000000000 --- a/desktop/src/features/messages/lib/openPopoverLink.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isMessageLink, parseMessageLink } from "./messageLink"; -import type { ParsedMessageLink } from "./messageLink"; - -/** - * Open a link the same way the rendered-message link path does: - * `buzz://message?…` deep-links navigate in-app, everything else (http(s), - * other buzz:// URLs) goes to the OS opener. Mirrors `markdown.tsx`'s `a` - * renderer so the composer popover and the rendered link behave identically. - */ -export function openPopoverLink( - url: string, - handlers: { - openExternal: (url: string) => void; - openMessageLink: (link: ParsedMessageLink) => void; - }, -): void { - if (isMessageLink(url)) { - const parsed = parseMessageLink(url); - if (parsed.ok) { - handlers.openMessageLink(parsed.value); - return; - } - } - handlers.openExternal(url); -} diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index fb61675b0..0ae08400b 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -1,23 +1,15 @@ import * as React from "react"; -import { openUrl } from "@tauri-apps/plugin-opener"; - -import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { - Popover, - PopoverAnchor, - PopoverContent, -} from "@/shared/ui/popover"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; -import { openPopoverLink } from "./openPopoverLink"; import type { LinkSelectionInfo, UseRichTextEditorResult, @@ -32,113 +24,45 @@ type DraftState = { isExistingLink: boolean; }; -type PopoverState = { - text: string; - url: string; - from: number; - to: number; - /** Viewport rect of the clicked link, used to anchor the popover. */ - rect: DOMRect; -}; - /** - * Owns the link UX for a composer: an info popover shown when a set link is - * clicked, plus the add/edit modal. Replaces the old `window.prompt` flow (a - * no-op in the Tauri WebView). - * - * Clicking a set link surfaces an info-only popover (display text + URL, the - * URL a real hyperlink that opens the link) with Edit and Remove — so a user - * can tweak display text inline without a takeover modal. The modal is reached - * via the popover's Edit button (focus on display text) and the toolbar's Add - * flow (focus on URL). + * Owns the link-edit modal for a composer. Replaces the old `window.prompt` + * flow (a no-op in the Tauri WebView) with a shadcn dialog that edits both + * the display text and the URL, and offers a Remove action for existing + * links. * * Returns: - * - `openFromToolbar` — wire to the formatting toolbar's link button. Opens - * the modal seeded from the current selection (existing link or selected - * text). - * - `openFromClick` — wire to `useRichTextEditor`'s `onEditLink`. Opens the - * info popover anchored at the clicked link. - * - `dialog` — render once inside the composer tree (popover + modal). + * - `openFromToolbar` — wire to the formatting toolbar's link button. Seeds + * the modal from the current selection (existing link or selected text). + * - `openFromClick` — wire to `useRichTextEditor`'s `onEditLink`. Seeds the + * modal from the clicked link's range. + * - `dialog` — render once inside the composer tree. */ export function useLinkEditor(richText: UseRichTextEditorResult) { const { getLinkSelectionInfo, applyLink, removeLink } = richText; - const { goChannel } = useAppNavigation(); const [draft, setDraft] = React.useState(null); - const [popover, setPopover] = React.useState(null); const textId = React.useId(); const urlId = React.useId(); - // Clicking a set link → info popover anchored at the clicked link. - const openFromClick = React.useCallback( - (info: LinkSelectionInfo, rect: DOMRect) => { - setPopover({ - text: info.text, - url: info.href, - from: info.from, - to: info.to, - rect, - }); - }, - [], - ); - - const closePopover = React.useCallback(() => setPopover(null), []); - - // Opens the modal seeded from a link's range. `focusUrl` decides which input - // takes initial focus (URL for Add, display text for Edit). - const openModal = React.useCallback((state: DraftState) => { - setDraft(state); + const openFromClick = React.useCallback((info: LinkSelectionInfo) => { + setDraft({ + text: info.text, + url: info.href, + from: info.from, + to: info.to, + isExistingLink: info.href.length > 0, + }); }, []); const openFromToolbar = React.useCallback(() => { const info = getLinkSelectionInfo(); if (info) { - openModal({ - text: info.text, - url: info.href, - from: info.from, - to: info.to, - isExistingLink: info.href.length > 0, - }); + openFromClick(info); return; } // No selection and no link under the caret — open an empty modal that // inserts a fresh link at the caret on save. - openModal({ text: "", url: "", from: 0, to: 0, isExistingLink: false }); - }, [getLinkSelectionInfo, openModal]); - - // Popover Edit → close the popover, open the modal on the same range. - const editFromPopover = React.useCallback(() => { - if (!popover) return; - openModal({ - text: popover.text, - url: popover.url, - from: popover.from, - to: popover.to, - isExistingLink: true, - }); - closePopover(); - }, [popover, openModal, closePopover]); - - const removeFromPopover = React.useCallback(() => { - if (!popover) return; - removeLink({ from: popover.from, to: popover.to }); - closePopover(); - }, [popover, removeLink, closePopover]); - - // Popover URL click: route `buzz://message?…` deep-links in-app (matching - // the rendered-message link path), everything else to the OS opener. - const openLink = React.useCallback(() => { - if (!popover?.url) return; - openPopoverLink(popover.url, { - openExternal: (url) => void openUrl(url), - openMessageLink: (link) => - void goChannel(link.channelId, { - messageId: link.messageId, - threadRootId: link.threadRootId, - }), - }); - }, [popover, goChannel]); + setDraft({ text: "", url: "", from: 0, to: 0, isExistingLink: false }); + }, [getLinkSelectionInfo, openFromClick]); const close = React.useCallback(() => setDraft(null), []); @@ -167,67 +91,6 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { close(); }, [draft, removeLink, close]); - // Add link (no existing link) focuses the URL; Edit focuses display text. - const focusUrlFirst = draft ? !draft.isExistingLink : false; - - const popoverCard = ( - { - if (!open) closePopover(); - }} - > - - event.preventDefault()} - > -
- {popover?.text} -
- { - event.preventDefault(); - openLink(); - }} - > - {popover?.url} - -
- - -
-
-
- ); - const dialog = ( {draft?.isExistingLink ? "Edit link" : "Add link"} + + Set the text shown in the message and the URL it points to. + @@ -272,7 +138,6 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { URL @@ -304,14 +169,5 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { ); - return { - openFromToolbar, - openFromClick, - dialog: ( - <> - {popoverCard} - {dialog} - - ), - }; + return { openFromToolbar, openFromClick, dialog }; } diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 32bbe28fc..785ac7c05 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -82,11 +82,10 @@ export type RichTextEditorOptions = { * Called when the user clicks an existing link in the editor. The link * extension runs with `openOnClick: false` (a chat composer must not * navigate away on click), so we route the click here instead: the owner - * shows an info popover (display text + URL, with Edit/Remove). `from`/`to` - * bound the full link mark range. `anchorRect` is the viewport rect of the - * clicked position so the owner can anchor the popover at the link. + * opens the link-edit modal to change or remove the URL. `from`/`to` bound + * the full link mark range so the owner can apply edits without re-selecting. */ - onEditLink?: (info: LinkSelectionInfo, anchorRect: DOMRect) => void; + onEditLink?: (info: LinkSelectionInfo) => void; }; /** @@ -345,6 +344,34 @@ export function useRichTextEditor({ "min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none", "data-testid": "message-input", }, + handleDOMEvents: { + // Native anchor default can still win in the WebView before + // ProseMirror's semantic click hook runs, so intercept editor links + // at the DOM event layer and route them to the modal instead. + click: (view, event) => { + if (!(event instanceof MouseEvent)) return false; + const target = event.target; + if (!(target instanceof Element)) return false; + const anchor = target.closest("a[href]"); + if (!anchor || !view.dom.contains(anchor)) return false; + + const handler = onEditLinkRef.current; + if (!handler) return false; + + event.preventDefault(); + event.stopPropagation(); + + const position = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + const info = position + ? resolveLinkAt(view.state, position.pos) + : null; + if (info) handler(info); + return true; + }, + }, // ArrowUp in an empty composer → edit your last message (Slack // parity). Handled here in ProseMirror's own DOM `keydown` hook — // NOT via `addKeyboardShortcuts` (the keymap plugin) and NOT via a @@ -383,29 +410,20 @@ export function useRichTextEditor({ // otherwise let ArrowUp fall through to normal caret movement. return handler(); }, - // Click on an existing link → show the link-info popover. The link + // Click on an existing link → open the link-edit modal. The link // extension is configured `openOnClick: false` (never navigate away // from a chat composer), so without this hook a click on a link does - // nothing. We resolve the full link mark range under the cursor and - // hand it to the owner along with the clicked position's viewport rect - // (so the popover can anchor at the link). Returning false leaves - // caret placement to ProseMirror, so the click moves the caret to the - // clicked spot — letting the user tweak display text inline without - // being yanked out of the editor. - handleClick: (view, pos) => { + // nothing. We resolve the full link mark range under the cursor, + // cancel the anchor's default navigation, and hand it to the owner. + handleClick: (view, pos, event) => { const handler = onEditLinkRef.current; if (!handler) return false; const info = resolveLinkAt(view.state, pos); if (!info) return false; - const coords = view.coordsAtPos(pos); - const anchorRect = new DOMRect( - coords.left, - coords.top, - 0, - coords.bottom - coords.top, - ); - handler(info, anchorRect); - return false; + event.preventDefault(); + event.stopPropagation(); + handler(info); + return true; }, }, onUpdate: ({ editor: ed }) => { diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 52cde50ba..8328e8e17 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -196,7 +196,7 @@ export function MessageComposer({ // delegates through this ref to break the hook ordering cycle (the editor // needs `onEditLink`, but the link editor needs the editor's `richText`). const onEditLinkRef = React.useRef< - ((info: LinkSelectionInfo, anchorRect: DOMRect) => void) | null + ((info: LinkSelectionInfo) => void) | null >(null); const scrollComposerToBottom = React.useCallback(() => { @@ -230,7 +230,7 @@ export function MessageComposer({ return handler ? handler() : false; }, isAutocompleteOpen: isAutocompleteOpenRef, - onEditLink: (info, anchorRect) => onEditLinkRef.current?.(info, anchorRect), + onEditLink: (info) => onEditLinkRef.current?.(info), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; From 7fd5bf0feed5477a81de719840dc2811168cfd72 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 14:07:02 -0700 Subject: [PATCH 06/15] fix(composer): focus URL for selected text links - Add link-editor focus intent so selected text opens the Add link modal with URL focused - Preserve display-text focus for existing-link edits, empty caret inserts, and whitespace-only selections - Add focused unit tests for link-editor initial focus decisions --- .../messages/lib/linkEditorFocus.test.mjs | 52 +++++++++++++++++++ .../features/messages/lib/linkEditorFocus.ts | 14 +++++ .../features/messages/lib/useLinkEditor.tsx | 29 ++++++++--- 3 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/messages/lib/linkEditorFocus.test.mjs create mode 100644 desktop/src/features/messages/lib/linkEditorFocus.ts diff --git a/desktop/src/features/messages/lib/linkEditorFocus.test.mjs b/desktop/src/features/messages/lib/linkEditorFocus.test.mjs new file mode 100644 index 000000000..89823a939 --- /dev/null +++ b/desktop/src/features/messages/lib/linkEditorFocus.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getLinkEditorInitialFocus } from "./linkEditorFocus.ts"; + +test("focuses URL when adding a link to selected text", () => { + assert.equal( + getLinkEditorInitialFocus({ + href: "", + text: "selected text", + from: 3, + to: 16, + }), + "url", + ); +}); + +test("focuses display text when editing an existing link", () => { + assert.equal( + getLinkEditorInitialFocus({ + href: "https://example.com", + text: "existing link", + from: 3, + to: 16, + }), + "text", + ); +}); + +test("focuses display text when inserting a link with no selected text", () => { + assert.equal( + getLinkEditorInitialFocus({ + href: "", + text: "", + from: 3, + to: 3, + }), + "text", + ); +}); + +test("focuses display text when the selected text is whitespace only", () => { + assert.equal( + getLinkEditorInitialFocus({ + href: "", + text: " ", + from: 3, + to: 6, + }), + "text", + ); +}); diff --git a/desktop/src/features/messages/lib/linkEditorFocus.ts b/desktop/src/features/messages/lib/linkEditorFocus.ts new file mode 100644 index 000000000..508ad5d8a --- /dev/null +++ b/desktop/src/features/messages/lib/linkEditorFocus.ts @@ -0,0 +1,14 @@ +import type { LinkSelectionInfo } from "./useRichTextEditor"; + +export type LinkEditorInitialFocus = "text" | "url"; + +export function getLinkEditorInitialFocus( + info: LinkSelectionInfo, +): LinkEditorInitialFocus { + const isSelectedTextLinkInsert = + info.href.length === 0 && + info.text.trim().length > 0 && + info.from !== info.to; + + return isSelectedTextLinkInsert ? "url" : "text"; +} diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index 0ae08400b..c77122049 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -3,7 +3,6 @@ import * as React from "react"; import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; @@ -14,6 +13,10 @@ import type { LinkSelectionInfo, UseRichTextEditorResult, } from "./useRichTextEditor"; +import { + getLinkEditorInitialFocus, + type LinkEditorInitialFocus, +} from "./linkEditorFocus"; type DraftState = { text: string; @@ -22,6 +25,7 @@ type DraftState = { to: number; /** Whether the targeted range already carried a link (enables Remove). */ isExistingLink: boolean; + initialFocus: LinkEditorInitialFocus; }; /** @@ -50,6 +54,7 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { from: info.from, to: info.to, isExistingLink: info.href.length > 0, + initialFocus: getLinkEditorInitialFocus(info), }); }, []); @@ -61,7 +66,14 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { } // No selection and no link under the caret — open an empty modal that // inserts a fresh link at the caret on save. - setDraft({ text: "", url: "", from: 0, to: 0, isExistingLink: false }); + setDraft({ + text: "", + url: "", + from: 0, + to: 0, + isExistingLink: false, + initialFocus: "text", + }); }, [getLinkSelectionInfo, openFromClick]); const close = React.useCallback(() => setDraft(null), []); @@ -80,7 +92,12 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { to: info?.to ?? 0, }); } else { - applyLink({ href: url, text: draft.text, from: draft.from, to: draft.to }); + applyLink({ + href: url, + text: draft.text, + from: draft.from, + to: draft.to, + }); } close(); }, [draft, applyLink, getLinkSelectionInfo, close]); @@ -103,9 +120,6 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { {draft?.isExistingLink ? "Edit link" : "Add link"} - - Set the text shown in the message and the URL it points to. - @@ -138,6 +152,7 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { URL From 8feed33a4e5bf60beb73afce6f4a958a2088c8b5 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 14:22:03 -0700 Subject: [PATCH 07/15] feat(composer): show link hover card from editor cursor - Replace composer link click editing with an anchored hover card that previews display text, renders the full clickable URL, and exposes unlink and edit actions - Wire link selection changes through the shared rich text editor so mouse clicks and left/right cursor movement surface the same card in message and forum composers - Move focus into the card on Tab while preserving normal selection behavior when highlighting linked text - Keep the existing dialog for toolbar link creation and explicit card edit actions - Use a text cursor for composer links and prevent popover close/reopen flicker when clicking between links --- .../src/features/forum/ui/ForumComposer.tsx | 196 +++++++------ .../features/messages/lib/useLinkEditor.tsx | 265 +++++++++++++++++- .../messages/lib/useRichTextEditor.ts | 60 +++- .../features/messages/ui/MessageComposer.tsx | 16 ++ desktop/src/shared/styles/globals.css | 2 +- 5 files changed, 426 insertions(+), 113 deletions(-) diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 3e57edead..867c7104c 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -86,6 +86,9 @@ export function ForumComposer({ const onEditLinkRef = React.useRef< ((info: LinkSelectionInfo) => void) | null >(null); + const onLinkSelectionChangeRef = React.useRef< + ((info: LinkSelectionInfo | null) => void) | null + >(null); const richText = useRichTextEditor({ placeholder, @@ -95,6 +98,7 @@ export function ForumComposer({ onSubmit: () => submitMessageRef.current(), isAutocompleteOpen: isAutocompleteOpenRef, onEditLink: (info) => onEditLinkRef.current?.(info), + onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; @@ -107,6 +111,7 @@ export function ForumComposer({ const linkEditor = useLinkEditor(richText); onEditLinkRef.current = linkEditor.openFromClick; + onLinkSelectionChangeRef.current = linkEditor.showFromCursor; // ── Mention / channel autocomplete insertion ──────────────────────── // Native ProseMirror transactions — no markdown round-trip. @@ -277,12 +282,22 @@ export function ForumComposer({ } return; } + + if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) { + event.preventDefault(); + if (!linkEditor.focusCardFirstControl()) { + requestAnimationFrame(linkEditor.focusCardFirstControl); + } + return; + } }, [ channelLinks.handleChannelKeyDown, applyChannelInsert, mentions.handleMentionKeyDown, applyMentionInsert, + linkEditor.isCardOpen, + linkEditor.focusCardFirstControl, ], ); @@ -394,99 +409,102 @@ export function ForumComposer({ const autocompletePosition = autocompleteBelow ? "below" : "above"; return ( <> - { - expandCompactComposer(); - media.handleDragEnter(event); - }} - onDragLeave={media.handleDragLeave} - onDragOver={media.handleDragOver} - onDrop={(e) => { - void media.handleDrop(e); - }} - onFocusCapture={expandCompactComposer} - onSubmit={handleSubmit} - > - {media.isDragOver && } - {isCompactLayout ? ( - - ) : ( - <> - {header ? ( + { + expandCompactComposer(); + media.handleDragEnter(event); + }} + onDragLeave={media.handleDragLeave} + onDragOver={media.handleDragOver} + onDrop={(e) => { + void media.handleDrop(e); + }} + onFocusCapture={expandCompactComposer} + onSubmit={handleSubmit} + > + {media.isDragOver && } + {isCompactLayout ? ( + + ) : ( + <> + {header ? ( +
+ {header} +
+ ) : null} + + + + + {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
- {header} +
- ) : null} - - - - - {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */} -
- -
- - Cancel - - ) : undefined - } - formattingDisabled={disabled ?? false} - isEmojiPickerOpen={isEmojiPickerOpen} - isFormattingOpen={isFormattingOpen} - isSending={isSending ?? false} - isUploading={media.isUploading} - onCaptureSelection={handleToolbarMouseDown} - onEmojiPickerOpenChange={setIsEmojiPickerOpen} - onEmojiSelect={insertEmoji} - onFormattingToggle={handleFormattingToggle} - onLinkButton={linkEditor.openFromToolbar} - onOpenMentionPicker={openMentionPicker} - onPaperclip={handlePaperclipClick} - sendDisabled={sendDisabled} - /> - - )} - + + Cancel + + ) : undefined + } + formattingDisabled={disabled ?? false} + isEmojiPickerOpen={isEmojiPickerOpen} + isFormattingOpen={isFormattingOpen} + isSending={isSending ?? false} + isUploading={media.isUploading} + onCaptureSelection={handleToolbarMouseDown} + onEmojiPickerOpenChange={setIsEmojiPickerOpen} + onEmojiSelect={insertEmoji} + onFormattingToggle={handleFormattingToggle} + onLinkButton={linkEditor.openFromToolbar} + onOpenMentionPicker={openMentionPicker} + onPaperclip={handlePaperclipClick} + sendDisabled={sendDisabled} + /> + + )} + + {linkEditor.card} {linkEditor.dialog} ); diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index c77122049..bf8a9a2fe 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Pencil, Unlink } from "lucide-react"; import { Dialog, @@ -8,6 +10,7 @@ import { } from "@/shared/ui/dialog"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import type { LinkSelectionInfo, @@ -28,26 +31,108 @@ type DraftState = { initialFocus: LinkEditorInitialFocus; }; +type LinkCardState = { + info: LinkSelectionInfo; + rect: { + left: number; + top: number; + width: number; + height: number; + }; +}; + /** - * Owns the link-edit modal for a composer. Replaces the old `window.prompt` - * flow (a no-op in the Tauri WebView) with a shadcn dialog that edits both - * the display text and the URL, and offers a Remove action for existing - * links. + * Owns composer link controls. Existing links show an anchored hover card on + * click/caret movement; the card can remove the link, open its URL, or enter + * the existing shadcn dialog to edit display text and URL. Toolbar-created + * links still open the dialog directly. * * Returns: * - `openFromToolbar` — wire to the formatting toolbar's link button. Seeds - * the modal from the current selection (existing link or selected text). - * - `openFromClick` — wire to `useRichTextEditor`'s `onEditLink`. Seeds the - * modal from the clicked link's range. - * - `dialog` — render once inside the composer tree. + * the dialog from the current selection (existing link or selected text). + * - `openFromClick` — wire to `useRichTextEditor`'s `onEditLink`. Moves the + * clicked link into the hover-card state. + * - `showFromCursor` — wire to cursor/selection updates to show the same card + * when arrow-key movement lands inside a link. + * - `card`/`dialog` — render once inside the composer tree. */ export function useLinkEditor(richText: UseRichTextEditorResult) { const { getLinkSelectionInfo, applyLink, removeLink } = richText; const [draft, setDraft] = React.useState(null); + const [cardState, setCardState] = React.useState(null); + const cardContentRef = React.useRef(null); const textId = React.useId(); const urlId = React.useId(); - const openFromClick = React.useCallback((info: LinkSelectionInfo) => { + const getLinkRect = React.useCallback( + (info: LinkSelectionInfo): LinkCardState["rect"] | null => { + const editor = richText.editor; + if (!editor) return null; + + try { + const range = document.createRange(); + const start = editor.view.domAtPos(info.from); + const end = editor.view.domAtPos(info.to); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + + const rect = range.getBoundingClientRect(); + range.detach(); + if (rect.width > 0 || rect.height > 0) { + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + } + } catch { + // Fall through to the caret coordinates below. + } + + const coords = editor.view.coordsAtPos(info.from); + return { + left: coords.left, + top: coords.top, + width: Math.max(1, coords.right - coords.left), + height: Math.max(1, coords.bottom - coords.top), + }; + }, + [richText.editor], + ); + + const showCard = React.useCallback( + (info: LinkSelectionInfo | null) => { + if (!info) { + setCardState(null); + return; + } + + const rect = getLinkRect(info); + if (!rect) { + setCardState(null); + return; + } + + setCardState((prev) => { + const sameLink = + prev?.info.href === info.href && + prev.info.from === info.from && + prev.info.to === info.to && + prev.info.text === info.text; + const sameRect = + prev?.rect.left === rect.left && + prev.rect.top === rect.top && + prev.rect.width === rect.width && + prev.rect.height === rect.height; + if (sameLink && sameRect) return prev; + return { info, rect }; + }); + }, + [getLinkRect], + ); + + const openDialogFromInfo = React.useCallback((info: LinkSelectionInfo) => { setDraft({ text: info.text, url: info.href, @@ -58,10 +143,17 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { }); }, []); + const openFromClick = React.useCallback( + (info: LinkSelectionInfo) => { + showCard(info); + }, + [showCard], + ); + const openFromToolbar = React.useCallback(() => { const info = getLinkSelectionInfo(); if (info) { - openFromClick(info); + openDialogFromInfo(info); return; } // No selection and no link under the caret — open an empty modal that @@ -74,10 +166,12 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { isExistingLink: false, initialFocus: "text", }); - }, [getLinkSelectionInfo, openFromClick]); + }, [getLinkSelectionInfo, openDialogFromInfo]); const close = React.useCallback(() => setDraft(null), []); + const closeCard = React.useCallback(() => setCardState(null), []); + const save = React.useCallback(() => { if (!draft) return; const url = draft.url.trim(); @@ -108,6 +202,145 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { close(); }, [draft, removeLink, close]); + const removeFromCard = React.useCallback(() => { + if (!cardState) return; + removeLink({ from: cardState.info.from, to: cardState.info.to }); + closeCard(); + }, [cardState, removeLink, closeCard]); + + const editFromCard = React.useCallback(() => { + if (!cardState) return; + openDialogFromInfo(cardState.info); + closeCard(); + }, [cardState, openDialogFromInfo, closeCard]); + + const openCardUrl = React.useCallback( + (event: React.MouseEvent) => { + const href = cardState?.info.href.trim(); + if (!href) return; + event.preventDefault(); + void openUrl(href); + }, + [cardState], + ); + + const refreshCardRect = React.useCallback(() => { + setCardState((prev) => { + if (!prev) return prev; + const rect = getLinkRect(prev.info); + return rect ? { ...prev, rect } : null; + }); + }, [getLinkRect]); + + React.useEffect(() => { + if (!cardState) return; + + window.addEventListener("resize", refreshCardRect); + window.addEventListener("scroll", refreshCardRect, true); + + return () => { + window.removeEventListener("resize", refreshCardRect); + window.removeEventListener("scroll", refreshCardRect, true); + }; + }, [cardState, refreshCardRect]); + + const focusCardFirstControl = React.useCallback((): boolean => { + if (!cardState) return false; + const target = cardContentRef.current?.querySelector( + "a[href], button:not([disabled])", + ); + if (!target) return false; + target.focus(); + return true; + }, [cardState]); + + const card = ( + { + if (!open) closeCard(); + }} + > + {cardState ? ( + + + ) : null} + {cardState ? ( + event.preventDefault()} + onInteractOutside={(event) => { + const target = event.detail.originalEvent.target; + if ( + target instanceof Element && + target.closest(".rich-text-composer a[href]") + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + ref={cardContentRef} + side="top" + sideOffset={8} + > +
+
+

+ Display text +

+

+ {cardState.info.text || cardState.info.href} +

+
+ + {cardState.info.href} + +
+ + +
+
+
+ ) : null} +
+ ); + const dialog = ( ); - return { openFromToolbar, openFromClick, dialog }; + return { + openFromToolbar, + openFromClick, + showFromCursor: showCard, + focusCardFirstControl, + isCardOpen: cardState !== null, + card, + dialog, + }; } diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 785ac7c05..570fd6a7a 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -11,10 +11,7 @@ import { Selection, TextSelection } from "@tiptap/pm/state"; import { isMacPlatform } from "@/shared/lib/platform"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import { - resolveLinkAt, - type LinkSelectionInfo, -} from "./resolveLinkAt"; +import { resolveLinkAt, type LinkSelectionInfo } from "./resolveLinkAt"; export type { LinkSelectionInfo } from "./resolveLinkAt"; @@ -82,10 +79,15 @@ export type RichTextEditorOptions = { * Called when the user clicks an existing link in the editor. The link * extension runs with `openOnClick: false` (a chat composer must not * navigate away on click), so we route the click here instead: the owner - * opens the link-edit modal to change or remove the URL. `from`/`to` bound - * the full link mark range so the owner can apply edits without re-selecting. + * can surface composer-local link controls. `from`/`to` bound the full link + * mark range so the owner can apply edits without re-selecting. */ onEditLink?: (info: LinkSelectionInfo) => void; + /** + * Called when the caret/selection moves onto or away from a link. Owners use + * this for link affordances that follow keyboard cursor movement. + */ + onLinkSelectionChange?: (info: LinkSelectionInfo | null) => void; }; /** @@ -109,6 +111,7 @@ export function useRichTextEditor({ onEditLastOwnMessage, isAutocompleteOpen, onEditLink, + onLinkSelectionChange, }: RichTextEditorOptions) { const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; @@ -122,6 +125,9 @@ export function useRichTextEditor({ const onEditLinkRef = React.useRef(onEditLink); onEditLinkRef.current = onEditLink; + const onLinkSelectionChangeRef = React.useRef(onLinkSelectionChange); + onLinkSelectionChangeRef.current = onLinkSelectionChange; + const placeholderRef = React.useRef(placeholder); placeholderRef.current = placeholder; @@ -328,7 +334,7 @@ export function useRichTextEditor({ // stripped on paste/typed input. protocols: ["buzz"], HTMLAttributes: { - class: "text-primary underline underline-offset-4 cursor-pointer", + class: "text-primary underline underline-offset-4 cursor-text", }, }), TiptapMarkdown.configure({ @@ -347,7 +353,7 @@ export function useRichTextEditor({ handleDOMEvents: { // Native anchor default can still win in the WebView before // ProseMirror's semantic click hook runs, so intercept editor links - // at the DOM event layer and route them to the modal instead. + // at the DOM event layer and route them to composer-local controls. click: (view, event) => { if (!(event instanceof MouseEvent)) return false; const target = event.target; @@ -360,11 +366,24 @@ export function useRichTextEditor({ event.preventDefault(); event.stopPropagation(); + if (!view.state.selection.empty) { + return true; + } const position = view.posAtCoords({ left: event.clientX, top: event.clientY, }); + if (position) { + view.dispatch( + view.state.tr + .setSelection( + TextSelection.create(view.state.doc, position.pos), + ) + .scrollIntoView(), + ); + view.focus(); + } const info = position ? resolveLinkAt(view.state, position.pos) : null; @@ -410,11 +429,12 @@ export function useRichTextEditor({ // otherwise let ArrowUp fall through to normal caret movement. return handler(); }, - // Click on an existing link → open the link-edit modal. The link + // Click on an existing link → surface composer-local link controls. The link // extension is configured `openOnClick: false` (never navigate away // from a chat composer), so without this hook a click on a link does - // nothing. We resolve the full link mark range under the cursor, - // cancel the anchor's default navigation, and hand it to the owner. + // nothing. We resolve the full link mark range under the cursor, move + // the editor selection there, cancel anchor navigation, and hand it to + // the owner. handleClick: (view, pos, event) => { const handler = onEditLinkRef.current; if (!handler) return false; @@ -422,6 +442,15 @@ export function useRichTextEditor({ if (!info) return false; event.preventDefault(); event.stopPropagation(); + if (!view.state.selection.empty) { + return true; + } + view.dispatch( + view.state.tr + .setSelection(TextSelection.create(view.state.doc, pos)) + .scrollIntoView(), + ); + view.focus(); handler(info); return true; }, @@ -435,6 +464,15 @@ export function useRichTextEditor({ const text = buildPlainTextProjection(ed.state.doc).text; onUpdateRef.current?.({ markdown, text }); }, + onSelectionUpdate: ({ editor: ed }) => { + const handler = onLinkSelectionChangeRef.current; + if (!handler) return; + if (!ed.state.selection.empty) { + handler(null); + return; + } + handler(resolveLinkAt(ed.state, ed.state.selection.from)); + }, }, [], ); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 8328e8e17..fa8aa52c8 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -198,6 +198,9 @@ export function MessageComposer({ const onEditLinkRef = React.useRef< ((info: LinkSelectionInfo) => void) | null >(null); + const onLinkSelectionChangeRef = React.useRef< + ((info: LinkSelectionInfo | null) => void) | null + >(null); const scrollComposerToBottom = React.useCallback(() => { window.requestAnimationFrame(() => { @@ -231,6 +234,7 @@ export function MessageComposer({ }, isAutocompleteOpen: isAutocompleteOpenRef, onEditLink: (info) => onEditLinkRef.current?.(info), + onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; @@ -251,6 +255,7 @@ export function MessageComposer({ const linkEditor = useLinkEditor(richText); onEditLinkRef.current = linkEditor.openFromClick; + onLinkSelectionChangeRef.current = linkEditor.showFromCursor; const mentionSendFlow = useMentionSendFlow({ channelId, @@ -605,6 +610,14 @@ export function MessageComposer({ return; } + if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) { + event.preventDefault(); + if (!linkEditor.focusCardFirstControl()) { + requestAnimationFrame(linkEditor.focusCardFirstControl); + } + return; + } + // Escape in edit mode if (event.key === "Escape" && editTargetRef.current && onCancelEdit) { event.preventDefault(); @@ -619,6 +632,8 @@ export function MessageComposer({ applyChannelInsert, mentions.handleMentionKeyDown, applyMentionInsert, + linkEditor.isCardOpen, + linkEditor.focusCardFirstControl, onCancelEdit, ], ); @@ -899,6 +914,7 @@ export function MessageComposer({ open={mentionSendFlow.pendingNonMemberSend !== null} /> + {linkEditor.card} {linkEditor.dialog} ); diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index bb3fdb249..09af55fef 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -602,7 +602,7 @@ color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 4px; - cursor: pointer; + cursor: text; } .rich-text-composer .tiptap hr { From 3b2b0b25b734b4dddff83b44a03f7031b1cc95cd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 14:42:13 -0700 Subject: [PATCH 08/15] refactor(composer): move link interactions into editor plugin - Add linkInteractionExtension to centralize ProseMirror-specific link behavior, including active-link reporting, link click interception, drag-selection preservation, and native navigation prevention - Register the extension from useRichTextEditor and remove embedded link click and selection-update handlers from the shared editor hook - Keep hover card rendering and link edit/remove actions in useLinkEditor so React owns UI concerns while the plugin owns editor state concerns - Compact the composer link hover card layout with inline URL, edit, and unlink controls - Add shared xs and icon-xs button sizes for dense popover actions --- .../messages/lib/linkInteractionExtension.ts | 142 ++++++++++++++++++ .../features/messages/lib/useLinkEditor.tsx | 38 ++--- .../messages/lib/useRichTextEditor.ts | 80 +--------- desktop/src/shared/ui/button.tsx | 5 +- 4 files changed, 165 insertions(+), 100 deletions(-) create mode 100644 desktop/src/features/messages/lib/linkInteractionExtension.ts diff --git a/desktop/src/features/messages/lib/linkInteractionExtension.ts b/desktop/src/features/messages/lib/linkInteractionExtension.ts new file mode 100644 index 000000000..033f603a1 --- /dev/null +++ b/desktop/src/features/messages/lib/linkInteractionExtension.ts @@ -0,0 +1,142 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import type { EditorState } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { resolveLinkAt, type LinkSelectionInfo } from "./resolveLinkAt"; + +type LinkInteractionExtensionOptions = { + getEditLinkHandler: () => ((info: LinkSelectionInfo) => void) | undefined; + getSelectionChangeHandler: () => + | ((info: LinkSelectionInfo | null) => void) + | undefined; +}; + +export const linkInteractionKey = new PluginKey("linkInteraction"); + +/** + * Centralises composer link interactions that depend on ProseMirror editor + * state: click interception, click-vs-drag selection preservation, and active + * link reporting as the selection moves. + */ +export function createLinkInteractionExtension({ + getEditLinkHandler, + getSelectionChangeHandler, +}: LinkInteractionExtensionOptions) { + return Extension.create({ + name: "linkInteraction", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: linkInteractionKey, + view(view) { + notifyLinkSelection(view.state, getSelectionChangeHandler); + + return { + update(updatedView, previousState) { + const nextState = updatedView.state; + if ( + previousState.selection.eq(nextState.selection) && + previousState.doc.eq(nextState.doc) + ) { + return; + } + notifyLinkSelection(nextState, getSelectionChangeHandler); + }, + }; + }, + props: { + handleDOMEvents: { + // Native anchor default can still win in the WebView before + // ProseMirror's semantic click hook runs, so intercept editor + // links at the DOM event layer and route them to composer-local + // controls. + click(view, event) { + if (!(event instanceof MouseEvent)) return false; + const target = event.target; + if (!(target instanceof Element)) return false; + const anchor = target.closest("a[href]"); + if (!anchor || !view.dom.contains(anchor)) return false; + + const position = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + if (!position) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + return handleLinkClick({ + event, + getEditLinkHandler, + pos: position.pos, + view, + }); + }, + }, + // Click on an existing link -> surface composer-local link controls. + // The link extension is configured `openOnClick: false` (never + // navigate away from a chat composer), so without this hook a click + // on a link does nothing. + handleClick(view, pos, event) { + return handleLinkClick({ + event, + getEditLinkHandler, + pos, + view, + }); + }, + }, + }), + ]; + }, + }); +} + +function notifyLinkSelection( + state: EditorState, + getSelectionChangeHandler: LinkInteractionExtensionOptions["getSelectionChangeHandler"], +) { + const handler = getSelectionChangeHandler(); + if (!handler) return; + if (!state.selection.empty) { + handler(null); + return; + } + handler(resolveLinkAt(state, state.selection.from)); +} + +function handleLinkClick({ + event, + getEditLinkHandler, + pos, + view, +}: { + event: MouseEvent; + getEditLinkHandler: LinkInteractionExtensionOptions["getEditLinkHandler"]; + pos: number; + view: EditorView; +}): boolean { + const handler = getEditLinkHandler(); + if (!handler) return false; + + const info = resolveLinkAt(view.state, pos); + if (!info) return false; + + event.preventDefault(); + event.stopPropagation(); + if (!view.state.selection.empty) { + return true; + } + + view.dispatch( + view.state.tr + .setSelection(TextSelection.create(view.state.doc, pos)) + .scrollIntoView(), + ); + view.focus(); + handler(info); + return true; +} diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index bf8a9a2fe..3790727fb 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -278,8 +278,8 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { ) : null} {cardState ? ( event.preventDefault()} onInteractOutside={(event) => { const target = event.detail.originalEvent.target; @@ -295,17 +295,9 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { side="top" sideOffset={8} > -
-
-

- Display text -

-

- {cardState.info.text || cardState.info.href} -

-
+
{cardState.info.href} -
+
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 570fd6a7a..3384291bd 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -22,6 +22,7 @@ import { import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; +import { createLinkInteractionExtension } from "./linkInteractionExtension"; import { CodeBlockAfterHardBreak, handleCodeFenceEnter, @@ -337,6 +338,10 @@ export function useRichTextEditor({ class: "text-primary underline underline-offset-4 cursor-text", }, }), + createLinkInteractionExtension({ + getEditLinkHandler: () => onEditLinkRef.current, + getSelectionChangeHandler: () => onLinkSelectionChangeRef.current, + }), TiptapMarkdown.configure({ html: false, transformPastedText: true, @@ -350,47 +355,6 @@ export function useRichTextEditor({ "min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none", "data-testid": "message-input", }, - handleDOMEvents: { - // Native anchor default can still win in the WebView before - // ProseMirror's semantic click hook runs, so intercept editor links - // at the DOM event layer and route them to composer-local controls. - click: (view, event) => { - if (!(event instanceof MouseEvent)) return false; - const target = event.target; - if (!(target instanceof Element)) return false; - const anchor = target.closest("a[href]"); - if (!anchor || !view.dom.contains(anchor)) return false; - - const handler = onEditLinkRef.current; - if (!handler) return false; - - event.preventDefault(); - event.stopPropagation(); - if (!view.state.selection.empty) { - return true; - } - - const position = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - if (position) { - view.dispatch( - view.state.tr - .setSelection( - TextSelection.create(view.state.doc, position.pos), - ) - .scrollIntoView(), - ); - view.focus(); - } - const info = position - ? resolveLinkAt(view.state, position.pos) - : null; - if (info) handler(info); - return true; - }, - }, // ArrowUp in an empty composer → edit your last message (Slack // parity). Handled here in ProseMirror's own DOM `keydown` hook — // NOT via `addKeyboardShortcuts` (the keymap plugin) and NOT via a @@ -429,31 +393,6 @@ export function useRichTextEditor({ // otherwise let ArrowUp fall through to normal caret movement. return handler(); }, - // Click on an existing link → surface composer-local link controls. The link - // extension is configured `openOnClick: false` (never navigate away - // from a chat composer), so without this hook a click on a link does - // nothing. We resolve the full link mark range under the cursor, move - // the editor selection there, cancel anchor navigation, and hand it to - // the owner. - handleClick: (view, pos, event) => { - const handler = onEditLinkRef.current; - if (!handler) return false; - const info = resolveLinkAt(view.state, pos); - if (!info) return false; - event.preventDefault(); - event.stopPropagation(); - if (!view.state.selection.empty) { - return true; - } - view.dispatch( - view.state.tr - .setSelection(TextSelection.create(view.state.doc, pos)) - .scrollIntoView(), - ); - view.focus(); - handler(info); - return true; - }, }, onUpdate: ({ editor: ed }) => { const markdown = getMarkdownFromEditor(ed); @@ -464,15 +403,6 @@ export function useRichTextEditor({ const text = buildPlainTextProjection(ed.state.doc).text; onUpdateRef.current?.({ markdown, text }); }, - onSelectionUpdate: ({ editor: ed }) => { - const handler = onLinkSelectionChangeRef.current; - if (!handler) return; - if (!ed.state.selection.empty) { - handler(null); - return; - } - handler(resolveLinkAt(ed.state, ed.state.selection.from)); - }, }, [], ); diff --git a/desktop/src/shared/ui/button.tsx b/desktop/src/shared/ui/button.tsx index 577a56015..390b0cfb1 100644 --- a/desktop/src/shared/ui/button.tsx +++ b/desktop/src/shared/ui/button.tsx @@ -22,8 +22,10 @@ const buttonVariants = cva( size: { default: "h-9 px-4 py-2", sm: "h-8 px-3 text-xs", + xs: "h-6 px-2 text-xs", lg: "h-10 px-8", icon: "h-8 w-8", + "icon-xs": "h-6 w-6 [&_svg]:size-3.5", }, }, defaultVariants: { @@ -34,7 +36,8 @@ const buttonVariants = cva( ); export interface ButtonProps - extends React.ButtonHTMLAttributes, + extends + React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } From 7699449b011daea1faa04d180d2a37f3141f7275 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 15:33:49 -0700 Subject: [PATCH 09/15] fix(composer): stack link preview actions - Update desktop/src/features/messages/lib/useLinkEditor.tsx so the link hover card sizes to URL content with a max width. - Move the preview URL onto its own row above the edit and unlink icon buttons. - Keep long URLs truncated within max-w-80 while allowing shorter URLs to shrink-wrap. --- desktop/src/features/messages/lib/useLinkEditor.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index 3790727fb..11f7564fa 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -279,7 +279,7 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { {cardState ? ( event.preventDefault()} onInteractOutside={(event) => { const target = event.detail.originalEvent.target; @@ -295,9 +295,9 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { side="top" sideOffset={8} > -
+
{cardState.info.href} -
+