Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions src/features/editor/hooks/useLinkClickHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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])
}
8 changes: 5 additions & 3 deletions src/features/editor/ui/CustomLinkToolbar.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
61 changes: 61 additions & 0 deletions src/features/editor/ui/EditLinkButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Components.LinkToolbar.Button
className="bn-button"
mainTooltip="Edit link"
label="Edit"
icon={<Pencil size={16} />}
onClick={handleClick}
/>
)
}
151 changes: 151 additions & 0 deletions src/features/editor/ui/EditLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog
open={state !== null}
onOpenChange={(open) => {
if (!open) void handleDismiss()
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Edit Link</DialogTitle>
</DialogHeader>
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="edit-link-url">URL</Label>
<Input
id="edit-link-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
placeholder="https://example.com"
autoFocus
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="edit-link-text">Text</Label>
<Input
id="edit-link-text"
value={text}
onChange={(e) => setText(e.target.value)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
placeholder="Link text"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleDismiss}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
18 changes: 17 additions & 1 deletion src/features/editor/ui/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -217,6 +223,9 @@ export const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
const [contentReady, setContentReady] = useState(false)
/** Rename dialog state (null = closed, object = open for that block). */
const [renameState, setRenameState] = useState<RenameDialogState | null>(null)
/** Edit-link dialog state (null = closed, object = open for that link). */
const [editLinkState, setEditLinkState] =
useState<EditLinkDialogState | null>(null)
/** Resolved theme ("light" or "dark") passed to BlockNoteView. */
const { resolvedTheme } = useTheme()
/** User-configured editor font size in pixels. */
Expand Down Expand Up @@ -570,6 +579,7 @@ export const Editor = forwardRef<EditorHandle, EditorProps>(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) {
Expand Down Expand Up @@ -614,11 +624,17 @@ export const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
</FormattingToolbar>
)}
/>
<LinkToolbarController linkToolbar={CustomLinkToolbar} />
<EditLinkRequestContext.Provider value={setEditLinkState}>
<LinkToolbarController linkToolbar={CustomLinkToolbar} />
</EditLinkRequestContext.Provider>
<RenameDialog
state={renameState}
onDismiss={() => setRenameState(null)}
/>
<EditLinkDialog
state={editLinkState}
onDismiss={() => setEditLinkState(null)}
/>
</>
)}
</BlockNoteView>
Expand Down
3 changes: 3 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading