diff --git a/src/features/editor/hooks/useLinkClickHandler.ts b/src/features/editor/hooks/useLinkClickHandler.ts index a3fd91e..5cc2a53 100644 --- a/src/features/editor/hooks/useLinkClickHandler.ts +++ b/src/features/editor/hooks/useLinkClickHandler.ts @@ -3,11 +3,17 @@ import { openUrl } from '@tauri-apps/plugin-opener' import { useEffect } from 'react' /** - * Intercepts link clicks in the BlockNote editor and opens them in the - * system default browser via `tauri-plugin-opener`. + * Intercepts Cmd/Ctrl+Click on links in the BlockNote editor and opens + * them in the system default browser via `tauri-plugin-opener`. + * + * Plain clicks propagate normally so the editor can position the cursor + * within link text and show the link toolbar. This matches the behavior + * of VS Code, Notion, and other desktop editors. * * Uses a capture-phase event listener so it fires before TipTap's own * click handler. + * + * @param editor - The BlockNote editor instance whose DOM will be instrumented. */ export function useLinkClickHandler(editor: BlockNoteEditor): void { useEffect(() => { @@ -22,21 +28,55 @@ export function useLinkClickHandler(editor: BlockNoteEditor): void { const anchor = target.closest('a[href]') if (!anchor) return + // Always intercept clicks on links to prevent TipTap's Link + // extension (openOnClick: true) from calling window.open(). + // Cursor positioning already happened during mousedown, so + // stopPropagation here only suppresses the unwanted open. event.preventDefault() event.stopPropagation() - const href = anchor.getAttribute('href') - if (!href) return + // Only open URL on Cmd+Click (macOS) / Ctrl+Click (Windows/Linux) + if (event.metaKey || event.ctrlKey) { + const href = anchor.getAttribute('href') + if (href) { + openUrl(href).catch(() => { + console.error('Failed to open URL:', href) + }) + } + } + } + + // Toggle `.link-modifier-held` class to switch cursor to pointer + // when Cmd (macOS) / Ctrl (Windows/Linux) is held over links. + const modifierClass = 'link-modifier-held' + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey) { + editorDom.classList.add(modifierClass) + } + } + + const handleKeyUp = (event: KeyboardEvent) => { + if (!event.metaKey && !event.ctrlKey) { + editorDom.classList.remove(modifierClass) + } + } - openUrl(href).catch(() => { - console.error('Failed to open URL:', href) - }) + const handleBlur = () => { + editorDom.classList.remove(modifierClass) } editorDom.addEventListener('click', handleClick, true) + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keyup', handleKeyUp) + window.addEventListener('blur', handleBlur) return () => { editorDom.removeEventListener('click', handleClick, true) + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('keyup', handleKeyUp) + window.removeEventListener('blur', handleBlur) + editorDom.classList.remove(modifierClass) } }, [editor]) } diff --git a/src/features/editor/ui/CustomLinkToolbar.tsx b/src/features/editor/ui/CustomLinkToolbar.tsx index a32cbe6..bcc690d 100644 --- a/src/features/editor/ui/CustomLinkToolbar.tsx +++ b/src/features/editor/ui/CustomLinkToolbar.tsx @@ -1,10 +1,12 @@ import type { LinkToolbarProps } from '@blocknote/react' -import { DeleteLinkButton, EditLinkButton, LinkToolbar } from '@blocknote/react' +import { DeleteLinkButton, LinkToolbar } from '@blocknote/react' +import { EditLinkButton } from './EditLinkButton' import { OpenInBrowserButton } from './OpenInBrowserButton' /** - * Custom link toolbar that replaces the default "Open in new tab" button - * with an "Open in browser" button that uses the system browser. + * Custom link toolbar that replaces BlockNote's default EditLinkButton + * (nested Radix Popover) with a lifted dialog approach, and uses the + * system browser for "Open in browser". * * @param props - Standard BlockNote link toolbar props including `url`, * `text`, `range`, and toolbar state setters. diff --git a/src/features/editor/ui/EditLinkButton.tsx b/src/features/editor/ui/EditLinkButton.tsx new file mode 100644 index 0000000..ddb6b13 --- /dev/null +++ b/src/features/editor/ui/EditLinkButton.tsx @@ -0,0 +1,61 @@ +import type { LinkToolbarProps } from '@blocknote/react' +import { useComponentsContext } from '@blocknote/react' +import { Pencil } from 'lucide-react' +import { createContext, useCallback, useContext } from 'react' + +/** + * State payload used to open the edit-link dialog. + */ +export interface EditLinkDialogState { + url: string + text: string + rangeFrom: number +} + +/** + * React context that bridges the `onRequestOpen` callback across the + * `LinkToolbarController` boundary (which only passes `LinkToolbarProps`). + */ +export const EditLinkRequestContext = createContext< + ((state: EditLinkDialogState) => void) | null +>(null) + +/** + * Custom link toolbar button that requests opening a lifted edit-link + * dialog, replacing BlockNote's built-in nested Popover approach. + * + * @param props - Subset of {@link LinkToolbarProps} containing the current + * link URL, display text, ProseMirror range, and toolbar state setters. + */ +export function EditLinkButton( + props: Pick< + LinkToolbarProps, + 'url' | 'text' | 'range' | 'setToolbarOpen' | 'setToolbarPositionFrozen' + > +) { + const Components = useComponentsContext() + const onRequestOpen = useContext(EditLinkRequestContext) + + const handleClick = useCallback(() => { + if (!onRequestOpen) return + onRequestOpen({ + url: props.url, + text: props.text, + rangeFrom: props.range.from, + }) + props.setToolbarOpen?.(false) + props.setToolbarPositionFrozen?.(false) + }, [onRequestOpen, props]) + + if (!Components) return null + + return ( + } + onClick={handleClick} + /> + ) +} diff --git a/src/features/editor/ui/EditLinkDialog.tsx b/src/features/editor/ui/EditLinkDialog.tsx new file mode 100644 index 0000000..f18dc4a --- /dev/null +++ b/src/features/editor/ui/EditLinkDialog.tsx @@ -0,0 +1,151 @@ +import { + DEFAULT_LINK_PROTOCOL, + LinkToolbarExtension, + VALID_LINK_PROTOCOLS, +} from '@blocknote/core/extensions' +import { useBlockNoteEditor, useExtension } from '@blocknote/react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { EditLinkDialogState } from './EditLinkButton' + +/** + * Ensures the URL starts with a recognized protocol. + * + * @param url - The raw URL string entered by the user. + * @returns The URL with a valid protocol prefix, prepending + * {@link DEFAULT_LINK_PROTOCOL} if none is found. + */ +function validateUrl(url: string): string { + const trimmed = url.trim() + for (const protocol of VALID_LINK_PROTOCOLS) { + if (trimmed.startsWith(protocol)) { + return trimmed + } + } + return `${DEFAULT_LINK_PROTOCOL}://${trimmed}` +} + +/** + * Props for the {@link EditLinkDialog} component. + */ +interface EditLinkDialogProps { + /** Current dialog state, or `null` when the dialog is closed. */ + state: EditLinkDialogState | null + /** Callback invoked when the dialog should close (cancel or save). */ + onDismiss: () => void +} + +/** + * Dialog for editing link URL and display text. + * + * Rendered at the Editor level (outside the LinkToolbar) so it survives + * toolbar unmount cycles. Follows the same "lifted dialog" pattern as + * {@link RenameDialog}. + * + * @param props - Dialog state and dismiss callback. See {@link EditLinkDialogProps}. + */ +export function EditLinkDialog({ state, onDismiss }: EditLinkDialogProps) { + const editor = useBlockNoteEditor() + const { editLink } = useExtension(LinkToolbarExtension) + + const [url, setUrl] = useState('') + const [text, setText] = useState('') + const composingRef = useRef(false) + + useEffect(() => { + if (state) { + setUrl(state.url) + setText(state.text) + } + }, [state]) + + const handleCompositionStart = useCallback(() => { + composingRef.current = true + }, []) + + const handleCompositionEnd = useCallback(() => { + setTimeout(() => { + composingRef.current = false + }, 50) + }, []) + + const handleDismiss = useCallback(() => { + onDismiss() + editor.focus() + }, [editor, onDismiss]) + + const handleSave = useCallback(() => { + if (state) { + editLink(validateUrl(url), text, state.rangeFrom) + } + onDismiss() + editor.focus() + }, [editor, editLink, state, url, text, onDismiss]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !composingRef.current) { + e.preventDefault() + handleSave() + } + }, + [handleSave] + ) + + return ( + { + if (!open) void handleDismiss() + }} + > + + + Edit Link + + + + URL + setUrl(e.target.value)} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + onKeyDown={handleKeyDown} + placeholder="https://example.com" + autoFocus + /> + + + Text + setText(e.target.value)} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + onKeyDown={handleKeyDown} + placeholder="Link text" + /> + + + + + Cancel + + Save + + + + ) +} diff --git a/src/features/editor/ui/Editor.tsx b/src/features/editor/ui/Editor.tsx index 4d10b3b..53361c0 100644 --- a/src/features/editor/ui/Editor.tsx +++ b/src/features/editor/ui/Editor.tsx @@ -54,6 +54,9 @@ import { slashMenuEmacsKeysExtension } from '../lib/slashMenuEmacsKeys' import { CustomColorStyleButton } from './CustomColorStyleButton' import { CustomLinkToolbar } from './CustomLinkToolbar' import { DownloadButton } from './DownloadButton' +import type { EditLinkDialogState } from './EditLinkButton' +import { EditLinkRequestContext } from './EditLinkButton' +import { EditLinkDialog } from './EditLinkDialog' import { HighlightButton } from './HighlightButton' import type { RenameDialogState } from './RenameButton' import { RenameButton } from './RenameButton' @@ -77,6 +80,9 @@ const BLOCKS = DEFAULT_BLOCKS as any * * This prevents prosemirror-history from recording programmatic content * loads (e.g. `replaceBlocks`, `backfillImageNames`) as undoable steps. + * + * @param view - The ProseMirror `EditorView` (or `null` if not yet mounted). + * @param fn - The callback whose dispatched transactions should be non-undoable. */ function withSuppressedHistory( view: { dispatch: (tr: Transaction) => void } | null, @@ -217,6 +223,9 @@ export const Editor = forwardRef(function Editor( const [contentReady, setContentReady] = useState(false) /** Rename dialog state (null = closed, object = open for that block). */ const [renameState, setRenameState] = useState(null) + /** Edit-link dialog state (null = closed, object = open for that link). */ + const [editLinkState, setEditLinkState] = + useState(null) /** Resolved theme ("light" or "dark") passed to BlockNoteView. */ const { resolvedTheme } = useTheme() /** User-configured editor font size in pixels. */ @@ -570,6 +579,7 @@ export const Editor = forwardRef(function Editor( if (locked) return const target = e.target as HTMLElement if (target.closest('.bn-editor')) return + if (target.closest('[role="dialog"]')) return const lastBlock = editor.document[editor.document.length - 1] if (lastBlock) { @@ -614,11 +624,17 @@ export const Editor = forwardRef(function Editor( )} /> - + + + setRenameState(null)} /> + setEditLinkState(null)} + /> > )} diff --git a/src/index.css b/src/index.css index 555b01f..5a5d656 100644 --- a/src/index.css +++ b/src/index.css @@ -363,6 +363,9 @@ span[data-style-type="backgroundColor"][data-value="highlight"] { * slightly lighter shade on hover. */ .bn-shadcn .bn-editor a { + cursor: text; +} +.bn-shadcn .bn-editor.link-modifier-held a { cursor: pointer; } .dark .bn-shadcn .bn-editor a {