'use client'; /** * Cmd/Ctrl+K link prompt. Modeless dialog with a single URL input and * Save/Remove/Cancel actions. Mounted as a side-channel above the editor * so it survives selection collapse — without this, focusing the input * would unset the selection and TipTap couldn't apply the mark. * * We snapshot `from`/`to` of the selection on open and re-apply it * before running the link command. ProseMirror tolerates this because * the dialog is rendered in a portal (Radix's `DialogPortal`) and the * editor's view does not lose its `state` — only its DOM focus. */ import { useEffect, useRef, useState } from 'react'; import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, } from '@djangocfg/ui-core'; import type { Editor } from '@tiptap/react'; /** Reject `javascript:`, `data:`, `vbscript:` schemes. Plain anchors, * http(s), mailto, tel, and relative paths pass. Same allow-list TipTap * uses internally when `validate` is omitted, made explicit here. */ function isSafeHref(value: string): boolean { const trimmed = value.trim(); const colon = trimmed.indexOf(':'); if (colon === -1) return true; // relative or hash link const scheme = trimmed.slice(0, colon).toLowerCase(); return ['http', 'https', 'mailto', 'tel', 'sms', 'ftp'].includes(scheme); } interface LinkDialogProps { editor: Editor; open: boolean; onOpenChange: (open: boolean) => void; } export function LinkDialog({ editor, open, onOpenChange }: LinkDialogProps) { const [href, setHref] = useState(''); const inputRef = useRef(null); // Remember the selection range when the dialog opened — focusing the // input collapses selection in the editor; we re-apply the snapshot // before running the link command. const rangeRef = useRef<{ from: number; to: number } | null>(null); useEffect(() => { if (!open) return; const { from, to } = editor.state.selection; rangeRef.current = { from, to }; const existing = editor.getAttributes('link').href as string | undefined; setHref(existing ?? ''); // Focus is handled by Radix's `onOpenAutoFocus` below — that fires // after the dialog DOM is in the document and avoids racing the // built-in focus trap. }, [open, editor]); const applyLink = (value: string) => { const range = rangeRef.current; if (range) { editor.chain().focus().setTextSelection(range).run(); } const trimmed = value.trim(); if (!trimmed) { editor.chain().focus().unsetLink().run(); } else if (isSafeHref(trimmed)) { editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run(); } onOpenChange(false); }; const removeLink = () => { const range = rangeRef.current; if (range) { editor.chain().focus().setTextSelection(range).run(); } editor.chain().focus().unsetLink().run(); onOpenChange(false); }; return ( { e.preventDefault(); inputRef.current?.focus(); inputRef.current?.select(); }} > Link
{ e.preventDefault(); applyLink(href); }} > setHref(e.target.value)} autoComplete="off" spellCheck={false} /> {editor.isActive('link') ? ( ) : null}
); }