mosaicmap/components/tiptap-ui/link-popover/link-popover.tsx
2025-08-12 21:25:52 +08:00

311 lines
7.3 KiB
TypeScript

"use client"
import * as React from "react"
import type { Editor } from "@tiptap/react"
// --- Hooks ---
import { useIsMobile } from "@/hooks/use-mobile"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons ---
import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon"
import { ExternalLinkIcon } from "@/components/tiptap-icons/external-link-icon"
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
import { TrashIcon } from "@/components/tiptap-icons/trash-icon"
// --- Tiptap UI ---
import type { UseLinkPopoverConfig } from "@/components/tiptap-ui/link-popover"
import { useLinkPopover } from "@/components/tiptap-ui/link-popover"
// --- UI Primitives ---
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/tiptap-ui-primitive/popover"
import { Separator } from "@/components/tiptap-ui-primitive/separator"
import {
Card,
CardBody,
CardItemGroup,
} from "@/components/tiptap-ui-primitive/card"
import { Input, InputGroup } from "@/components/tiptap-ui-primitive/input"
export interface LinkMainProps {
/**
* The URL to set for the link.
*/
url: string
/**
* Function to update the URL state.
*/
setUrl: React.Dispatch<React.SetStateAction<string | null>>
/**
* Function to set the link in the editor.
*/
setLink: () => void
/**
* Function to remove the link from the editor.
*/
removeLink: () => void
/**
* Function to open the link.
*/
openLink: () => void
/**
* Whether the link is currently active in the editor.
*/
isActive: boolean
}
export interface LinkPopoverProps
extends Omit<ButtonProps, "type">,
UseLinkPopoverConfig {
/**
* Callback for when the popover opens or closes.
*/
onOpenChange?: (isOpen: boolean) => void
/**
* Whether to automatically open the popover when a link is active.
* @default true
*/
autoOpenOnLinkActive?: boolean
}
/**
* Link button component for triggering the link popover
*/
export const LinkButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, ...props }, ref) => {
return (
<Button
type="button"
className={className}
data-style="ghost"
role="button"
tabIndex={-1}
aria-label="Link"
tooltip="Link"
ref={ref}
{...props}
>
{children || <LinkIcon className="tiptap-button-icon" />}
</Button>
)
}
)
LinkButton.displayName = "LinkButton"
/**
* Main content component for the link popover
*/
const LinkMain: React.FC<LinkMainProps> = ({
url,
setUrl,
setLink,
removeLink,
openLink,
isActive,
}) => {
const isMobile = useIsMobile()
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault()
setLink()
}
}
return (
<Card
style={{
...(isMobile ? { boxShadow: "none", border: 0 } : {}),
}}
>
<CardBody
style={{
...(isMobile ? { padding: 0 } : {}),
}}
>
<CardItemGroup orientation="horizontal">
<InputGroup>
<Input
type="url"
placeholder="Paste a link..."
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</InputGroup>
<ButtonGroup orientation="horizontal">
<Button
type="button"
onClick={setLink}
title="Apply link"
disabled={!url && !isActive}
data-style="ghost"
>
<CornerDownLeftIcon className="tiptap-button-icon" />
</Button>
</ButtonGroup>
<Separator />
<ButtonGroup orientation="horizontal">
<Button
type="button"
onClick={openLink}
title="Open in new window"
disabled={!url && !isActive}
data-style="ghost"
>
<ExternalLinkIcon className="tiptap-button-icon" />
</Button>
<Button
type="button"
onClick={removeLink}
title="Remove link"
disabled={!url && !isActive}
data-style="ghost"
>
<TrashIcon className="tiptap-button-icon" />
</Button>
</ButtonGroup>
</CardItemGroup>
</CardBody>
</Card>
)
}
/**
* Link content component for standalone use
*/
export const LinkContent: React.FC<{
editor?: Editor | null
}> = ({ editor }) => {
const linkPopover = useLinkPopover({
editor,
})
return <LinkMain {...linkPopover} />
}
/**
* Link popover component for Tiptap editors.
*
* For custom popover implementations, use the `useLinkPopover` hook instead.
*/
export const LinkPopover = React.forwardRef<
HTMLButtonElement,
LinkPopoverProps
>(
(
{
editor: providedEditor,
hideWhenUnavailable = false,
onSetLink,
onOpenChange,
autoOpenOnLinkActive = true,
onClick,
children,
...buttonProps
},
ref
) => {
const { editor } = useTiptapEditor(providedEditor)
const [isOpen, setIsOpen] = React.useState(false)
const {
isVisible,
canSet,
isActive,
url,
setUrl,
setLink,
removeLink,
openLink,
label,
Icon,
} = useLinkPopover({
editor,
hideWhenUnavailable,
onSetLink,
})
const handleOnOpenChange = React.useCallback(
(nextIsOpen: boolean) => {
setIsOpen(nextIsOpen)
onOpenChange?.(nextIsOpen)
},
[onOpenChange]
)
const handleSetLink = React.useCallback(() => {
setLink()
setIsOpen(false)
}, [setLink])
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (event.defaultPrevented) return
setIsOpen(!isOpen)
},
[onClick, isOpen]
)
React.useEffect(() => {
if (autoOpenOnLinkActive && isActive) {
setIsOpen(true)
}
}, [autoOpenOnLinkActive, isActive])
if (!isVisible) {
return null
}
return (
<Popover open={isOpen} onOpenChange={handleOnOpenChange}>
<PopoverTrigger asChild>
<LinkButton
disabled={!canSet}
data-active-state={isActive ? "on" : "off"}
data-disabled={!canSet}
aria-label={label}
aria-pressed={isActive}
onClick={handleClick}
{...buttonProps}
ref={ref}
>
{children ?? <Icon className="tiptap-button-icon" />}
</LinkButton>
</PopoverTrigger>
<PopoverContent>
<LinkMain
url={url}
setUrl={setUrl}
setLink={handleSetLink}
removeLink={removeLink}
openLink={openLink}
isActive={isActive}
/>
</PopoverContent>
</Popover>
)
}
)
LinkPopover.displayName = "LinkPopover"
export default LinkPopover