diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 8d54889a2..867c7104c 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,15 @@ 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 onLinkSelectionChangeRef = React.useRef< + ((info: LinkSelectionInfo | null) => void) | null + >(null); + const richText = useRichTextEditor({ placeholder, editable: !disabled, @@ -84,6 +97,8 @@ export function ForumComposer({ channelNames: channelLinks.knownChannelNames, onSubmit: () => submitMessageRef.current(), isAutocompleteOpen: isAutocompleteOpenRef, + onEditLink: (info) => onEditLinkRef.current?.(info), + onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; @@ -94,6 +109,10 @@ 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. const applyMentionInsert = React.useCallback( @@ -263,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, ], ); @@ -379,97 +408,104 @@ export function ForumComposer({ }, [compact, isCompactExpanded, richText.focus]); 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} - 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/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/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/openPopoverLink.test.mjs b/desktop/src/features/messages/lib/openPopoverLink.test.mjs new file mode 100644 index 000000000..b9d827378 --- /dev/null +++ b/desktop/src/features/messages/lib/openPopoverLink.test.mjs @@ -0,0 +1,53 @@ +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/resolveLinkAt.test.mjs b/desktop/src/features/messages/lib/resolveLinkAt.test.mjs new file mode 100644 index 000000000..a0f6505a2 --- /dev/null +++ b/desktop/src/features/messages/lib/resolveLinkAt.test.mjs @@ -0,0 +1,124 @@ +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("anchors on the left link at the seam between two adjacent links", () => { + const a = "https://a.com"; + const b = "https://b.com"; + // "alpha" (link a) immediately followed by "beta" (link b). The seam sits + // at pos 6 (paragraph start 1 + "alpha".length). Both child spans touch + // that position; the caret there belongs to the character before it, so we + // must resolve to link `a`, not whichever span the iteration visits last. + const state = stateFromParagraph([ + schema.text("alpha", [link(a)]), + schema.text("beta", [link(b)]), + ]); + + const seam = 1 + "alpha".length; + const info = resolveLinkAt(state, seam); + assert.ok(info); + assert.equal(info.href, a); + assert.equal(info.text, "alpha"); + assert.equal(info.from, 1); + assert.equal(info.to, seam); +}); + +test("resolves a link when the caret sits at its very start", () => { + const href = "https://example.com"; + // Plain text, then the link — caret at the link's left edge has no link + // mark on the character *before* it, exercising the `pos + 1` fallback. + const state = stateFromParagraph([ + schema.text("go "), + schema.text("here", [link(href)]), + ]); + + const linkStart = 1 + "go ".length; + const info = resolveLinkAt(state, linkStart); + assert.ok(info); + assert.equal(info.href, href); + assert.equal(info.text, "here"); + assert.equal(info.from, linkStart); + assert.equal(info.to, linkStart + "here".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..769a1f4bf --- /dev/null +++ b/desktop/src/features/messages/lib/resolveLinkAt.ts @@ -0,0 +1,92 @@ +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); + // A caret belongs to the character *before* it, so resolve the link from + // `nodeBefore` first. This disambiguates the seam between two adjacent + // links: at that boundary both `marks()` and `nodeAfter` point at the + // right-hand link, but the caret should anchor on the left one. When the + // position is inside a text node (not on a boundary) `nodeBefore`/ + // `nodeAfter` are null, so we fall back to `marks()` for the mid-node case + // and to `nodeAfter` for the caret at a link's very left edge. + const markBefore = $pos.nodeBefore + ? linkType.isInSet($pos.nodeBefore.marks) + : null; + const markAfter = $pos.nodeAfter + ? linkType.isInSet($pos.nodeAfter.marks) + : null; + const onBoundary = $pos.nodeBefore != null || $pos.nodeAfter != null; + const mark = onBoundary + ? markBefore || markAfter + : linkType.isInSet($pos.marks()); + if (!mark) return null; + + const rawHref = mark.attrs.href; + if (typeof rawHref !== "string") return null; + const href = rawHref; + const parent = $pos.parent; + const parentStart = $pos.start(); + + type ChildSpan = { from: number; to: number; hasLink: boolean }; + const spans: ChildSpan[] = []; + // Adjacent text nodes share a boundary (span N's `to` equals span N+1's + // `from`), so a `pos` landing exactly on a seam falls inside both. Picking + // the last match there would anchor on the wrong node when two links with + // different hrefs abut. We resolve the ambiguity in two passes: first try + // the span that both contains `pos` and carries our target link; only if + // none does (e.g. caret resting just past a link) fall back to a plain + // containment test with an exclusive right edge so the seam belongs to the + // node on its left. + let anchorIndex = -1; + let linkAnchorIndex = -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; + const index = spans.length; + if (childFrom <= pos && pos < childTo) anchorIndex = index; + if (hasLink && childFrom <= pos && pos <= childTo) linkAnchorIndex = index; + spans.push({ from: childFrom, to: childTo, hasLink }); + }); + if (linkAnchorIndex !== -1) anchorIndex = linkAnchorIndex; + + 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/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx new file mode 100644 index 000000000..a623afc2b --- /dev/null +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -0,0 +1,441 @@ +import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Pencil, Unlink } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} 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 { useAppNavigation } from "@/app/navigation/useAppNavigation"; + +import type { + LinkSelectionInfo, + UseRichTextEditorResult, +} from "./useRichTextEditor"; +import { + getLinkEditorInitialFocus, + type LinkEditorInitialFocus, +} from "./linkEditorFocus"; +import { openPopoverLink } from "./openPopoverLink"; + +type DraftState = { + text: string; + url: string; + from: number; + to: number; + /** + * Whether `from`/`to` point at a real document range. `false` for an + * empty-caret toolbar insert, where the range is resolved from the live + * selection at save time instead of the (placeholder) draft positions. + */ + hasRange: boolean; + /** Whether the targeted range already carried a link (enables Remove). */ + isExistingLink: boolean; + initialFocus: LinkEditorInitialFocus; +}; + +type LinkCardState = { + info: LinkSelectionInfo; + rect: { + left: number; + top: number; + width: number; + height: number; + }; +}; + +/** + * 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 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 { goChannel } = useAppNavigation(); + 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 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, + from: info.from, + to: info.to, + hasRange: true, + isExistingLink: info.href.length > 0, + initialFocus: getLinkEditorInitialFocus(info), + }); + }, []); + + const openFromClick = React.useCallback( + (info: LinkSelectionInfo) => { + showCard(info); + }, + [showCard], + ); + + const openFromToolbar = React.useCallback(() => { + const info = getLinkSelectionInfo(); + if (info) { + openDialogFromInfo(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, + hasRange: false, + isExistingLink: false, + initialFocus: "text", + }); + }, [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(); + if (!url) return; + if (draft.hasRange) { + applyLink({ + href: url, + text: draft.text, + from: draft.from, + to: draft.to, + }); + } else { + // Empty-caret insert: prefer the live selection range; if there's no + // selection, omit the range so `applyLink` inserts at the caret rather + // than at the placeholder doc position 0. + const info = getLinkSelectionInfo(); + applyLink({ + href: url, + text: draft.text, + from: info?.from, + to: info?.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 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]); + + // Card URL click: route `buzz://message?…` deep-links in-app (matching the + // rendered-message link path), everything else to the OS opener. + const openCardUrl = React.useCallback( + (event: React.MouseEvent) => { + const href = cardState?.info.href.trim(); + if (!href) return; + event.preventDefault(); + openPopoverLink(href, { + openExternal: (url) => void openUrl(url), + openMessageLink: (link) => + void goChannel(link.channelId, { + messageId: link.messageId, + threadRootId: link.threadRootId, + }), + }); + }, + [cardState, goChannel], + ); + + 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} + > +
+ + {cardState.info.href} + +
+ + +
+
+
+ ) : null} +
+ ); + + const dialog = ( + { + if (!open) close(); + }} + > + + + + {draft?.isExistingLink ? "Edit link" : "Add link"} + + +
{ + event.preventDefault(); + save(); + }} + > + + +
+ {draft?.isExistingLink ? ( + + ) : ( + + )} +
+ + +
+
+
+
+
+ ); + + 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 d54e3c8a2..9b294f9d6 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -11,6 +11,10 @@ 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"; + +export type { LinkSelectionInfo } from "./resolveLinkAt"; + import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip"; import { @@ -20,6 +24,7 @@ import { import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; +import { createLinkInteractionExtension } from "./linkInteractionExtension"; import { CodeBlockAfterHardBreak, handleCodeFenceEnter, @@ -74,6 +79,19 @@ 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 + * 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; }; /** @@ -96,6 +114,8 @@ export function useRichTextEditor({ onSubmit, onEditLastOwnMessage, isAutocompleteOpen, + onEditLink, + onLinkSelectionChange, }: RichTextEditorOptions) { const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; @@ -106,6 +126,12 @@ export function useRichTextEditor({ const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); onEditLastOwnMessageRef.current = onEditLastOwnMessage; + const onEditLinkRef = React.useRef(onEditLink); + onEditLinkRef.current = onEditLink; + + const onLinkSelectionChangeRef = React.useRef(onLinkSelectionChange); + onLinkSelectionChangeRef.current = onLinkSelectionChange; + const placeholderRef = React.useRef(placeholder); placeholderRef.current = placeholder; @@ -313,9 +339,13 @@ 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", }, }), + createLinkInteractionExtension({ + getEditLinkHandler: () => onEditLinkRef.current, + getSelectionChangeHandler: () => onLinkSelectionChangeRef.current, + }), TiptapMarkdown.configure({ html: false, transformPastedText: true, @@ -585,6 +615,81 @@ 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 `range` is omitted (an + * empty-caret insert with no live selection), the link is inserted at the + * current caret — never at the placeholder position `0`, which sits + * outside the document content. When `from === to`, the linked text is + * inserted at that point. Used by both the toolbar button and the + * click-to-edit modal. + */ + const applyLink = React.useCallback( + ({ + href, + text, + from, + to, + }: { + href: string; + text: string; + from?: number; + to?: number; + }) => { + if (!editor) return; + // Default to the live caret when no range is supplied, so an + // empty-caret insert lands at the cursor rather than doc position 0. + const selection = editor.state.selection; + const start = from ?? selection.from; + const end = to ?? start; + 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(start, end, node); + const cursorPM = tr.mapping.map(end); + 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, @@ -596,6 +701,9 @@ export function useRichTextEditor({ focusPreserve, getPlainTextAndCursor, replacePlainTextRange, + getLinkSelectionInfo, + applyLink, + removeLink, }; } diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx index e1b5762d7..63d41145e 100644 --- a/desktop/src/features/messages/ui/FormattingToolbar.tsx +++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx @@ -20,6 +20,12 @@ import { SPOILER_MARK_NAME } from "@/features/messages/lib/spoilerMark"; 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; }; export type SpoilerToggleState = { @@ -108,6 +114,7 @@ export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState { export const FormattingToolbar = React.memo(function FormattingToolbar({ editor, disabled = false, + onLinkButton, }: FormattingToolbarProps) { const [activeStates, setActiveStates] = React.useState( () => (editor ? getActiveStates(editor) : null), @@ -152,6 +159,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; @@ -172,7 +188,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 8c02ae079..77320f556 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -31,8 +31,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 { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; @@ -196,6 +198,16 @@ 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 onLinkSelectionChangeRef = React.useRef< + ((info: LinkSelectionInfo | null) => void) | null + >(null); + const scrollComposerToBottom = React.useCallback(() => { window.requestAnimationFrame(() => { const scrollElement = composerScrollRef.current; @@ -227,6 +239,8 @@ export function MessageComposer({ return handler ? handler() : false; }, isAutocompleteOpen: isAutocompleteOpenRef, + onEditLink: (info) => onEditLinkRef.current?.(info), + onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onUpdate: ({ markdown, text }) => { setContent(markdown); contentRef.current = markdown; @@ -245,6 +259,9 @@ export function MessageComposer({ }, }); + const linkEditor = useLinkEditor(richText); + onEditLinkRef.current = linkEditor.openFromClick; + onLinkSelectionChangeRef.current = linkEditor.showFromCursor; useComposerSpoilerParticles(richText.editor, composerScrollRef); const mentionSendFlow = useMentionSendFlow({ @@ -619,6 +636,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(); @@ -633,6 +658,8 @@ export function MessageComposer({ applyChannelInsert, mentions.handleMentionKeyDown, applyMentionInsert, + linkEditor.isCardOpen, + linkEditor.focusCardFirstControl, onCancelEdit, ], ); @@ -944,6 +971,7 @@ export function MessageComposer({ onEmojiPickerOpenChange={setIsEmojiPickerOpen} onEmojiSelect={insertEmoji} onFormattingToggle={handleFormattingToggle} + onLinkButton={linkEditor.openFromToolbar} onOpenMentionPicker={openMentionPicker} onPaperclip={handlePaperclipClick} onSpoilerToggle={handleComposerSpoilerToggle} @@ -963,6 +991,9 @@ export function MessageComposer({ onInvite={mentionSendFlow.inviteNonMembers} open={mentionSendFlow.pendingNonMemberSend !== null} /> + + {linkEditor.card} + {linkEditor.dialog} ); } diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 153fa8210..b47d2fa68 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -42,6 +42,7 @@ export const MessageComposerToolbar = React.memo( onEmojiPickerOpenChange, onEmojiSelect, onFormattingToggle, + onLinkButton, onOpenMentionPicker, onPaperclip, onSpoilerToggle, @@ -60,6 +61,7 @@ export const MessageComposerToolbar = React.memo( onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; onFormattingToggle: (pressed: boolean) => void; + onLinkButton: () => void; onOpenMentionPicker: () => void; onPaperclip: () => void; onSpoilerToggle?: (state: SpoilerToggleState) => void; @@ -179,6 +181,7 @@ export const MessageComposerToolbar = React.memo( diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 2c9353729..794c8389d 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -596,7 +596,7 @@ color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 4px; - cursor: pointer; + cursor: text; } .rich-text-composer .tiptap hr { diff --git a/desktop/src/shared/ui/button.tsx b/desktop/src/shared/ui/button.tsx index 577a56015..318642130 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: {