Skip to content

Commit

Permalink
fix: link, open new tab, keep track link change etc
Browse files Browse the repository at this point in the history
  • Loading branch information
Aslam97 committed Aug 18, 2024
1 parent ac24e77 commit 734be69
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 168 deletions.
Original file line number Diff line number Diff line change
@@ -1,59 +1,107 @@
import type { Editor } from '@tiptap/core'
import { useState } from 'react'
import React, { useState, useCallback, useEffect } from 'react'
import { Editor } from '@tiptap/core'
import { BubbleMenu } from '@tiptap/react'
import { LinkEditBlock } from '../link/link-edit-block'
import { LinkPopoverBlock } from '../link/link-popover-block'
import { BubbleMenu } from '@tiptap/react'
import { LinkProps, ShouldShowProps } from '../../types'
import { setLink } from '../../utils'
import { ShouldShowProps } from '../../types'

const LinkBubbleMenu = ({ editor }: { editor: Editor }) => {
interface LinkBubbleMenuProps {
editor: Editor
}

interface LinkAttributes {
href: string
target: string
}

export const LinkBubbleMenu: React.FC<LinkBubbleMenuProps> = ({ editor }) => {
const [showEdit, setShowEdit] = useState(false)
const shouldShow = ({ editor, from, to }: ShouldShowProps) => {
const [linkAttrs, setLinkAttrs] = useState<LinkAttributes>({ href: '', target: '' })
const [selectedText, setSelectedText] = useState('')

const updateLinkState = useCallback(() => {
const { from, to } = editor.state.selection
const { href, target } = editor.getAttributes('link')
const text = editor.state.doc.textBetween(from, to, ' ')

setLinkAttrs({ href, target })
setSelectedText(text)
}, [editor])

useEffect(() => {
editor.on('selectionUpdate', updateLinkState)

return () => {
editor.off('selectionUpdate', updateLinkState)
}
}, [editor, updateLinkState])

const shouldShow = useCallback(({ editor, from, to }: ShouldShowProps) => {
if (from === to) {
return false
}

const link = editor.getAttributes('link')
return typeof link.href === 'string' && link.href !== ''
}, [])

if (link.href) {
return true
}
const handleEdit = useCallback(() => {
setShowEdit(true)
}, [])

return false
}

const unSetLink = () => {
editor.chain().extendMarkRange('link').unsetLink().focus().run()
setShowEdit(false)
}
const onSetLink = useCallback(
(url: string, text?: string, openInNewTab?: boolean) => {
editor
.chain()
.focus()
.extendMarkRange('link')
.insertContent({
type: 'text',
text: text || url,
marks: [
{
type: 'link',
attrs: {
href: url,
target: openInNewTab ? '_blank' : ''
}
}
]
})
.setLink({ href: url, target: openInNewTab ? '_blank' : '' })
.run()
setShowEdit(false)
updateLinkState()
},
[editor, updateLinkState]
)

function onSetLink(props: LinkProps) {
setLink(editor, props)
const onUnsetLink = useCallback(() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
setShowEdit(false)
}
updateLinkState()
return null
}, [editor, updateLinkState])

return (
<BubbleMenu
editor={editor}
shouldShow={shouldShow}
tippyOptions={{
placement: 'bottom-start',
onHidden: () => {
setShowEdit(false)
}
onHidden: () => setShowEdit(false)
}}
>
{showEdit ? (
<LinkEditBlock
onSetLink={onSetLink}
editor={editor}
defaultUrl={linkAttrs.href}
defaultText={selectedText}
defaultIsNewTab={linkAttrs.target === '_blank'}
onSave={onSetLink}
className="w-full min-w-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none"
/>
) : (
<LinkPopoverBlock onClear={unSetLink} link={editor.getAttributes('link')} onEdit={() => setShowEdit(true)} />
<LinkPopoverBlock onClear={onUnsetLink} url={linkAttrs.href} onEdit={handleEdit} />
)}
</BubbleMenu>
)
}

export { LinkBubbleMenu }
143 changes: 55 additions & 88 deletions src/components/minimal-tiptap/components/link/link-edit-block.tsx
Original file line number Diff line number Diff line change
@@ -1,108 +1,75 @@
import type { Editor } from '@tiptap/core'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { LinkProps } from '../../types'
import { cn } from '@/lib/utils'

interface LinkEditBlockProps extends React.HTMLAttributes<HTMLDivElement> {
editor: Editor
onSetLink: ({ url, text, openInNewTab }: LinkProps) => void
close?: () => void
export interface LinkEditorProps extends React.HTMLAttributes<HTMLDivElement> {
defaultUrl?: string
defaultText?: string
defaultIsNewTab?: boolean
onSave: (url: string, text?: string, isNewTab?: boolean) => void
}

const LinkEditBlock = ({ editor, onSetLink, close, className, ...props }: LinkEditBlockProps) => {
const formRef = React.useRef<HTMLDivElement>(null)
export const LinkEditBlock = React.forwardRef<HTMLDivElement, LinkEditorProps>(
({ onSave, defaultIsNewTab, defaultUrl, defaultText, className }, ref) => {
const formRef = React.useRef<HTMLDivElement>(null)
const [url, setUrl] = React.useState(defaultUrl || '')
const [text, setText] = React.useState(defaultText || '')
const [isNewTab, setIsNewTab] = React.useState(defaultIsNewTab || false)

const [field, setField] = React.useState<LinkProps>({
url: '',
text: '',
openInNewTab: false
})
const handleSave = React.useCallback(
(e: React.FormEvent) => {
e.preventDefault()
if (formRef.current) {
const isValid = Array.from(formRef.current.querySelectorAll('input')).every(input => input.checkValidity())

const data = React.useMemo(() => {
const { href, target } = editor.getAttributes('link')
const { from, to } = editor.state.selection
const text = editor.state.doc.textBetween(from, to, ' ')

return {
url: href,
text,
openInNewTab: target === '_blank' ? true : false
}
}, [editor])

React.useEffect(() => {
setField(data)
}, [data])

const handleClick = (e: React.FormEvent) => {
e.preventDefault()

if (formRef.current) {
const isValid = Array.from(formRef.current.querySelectorAll('input')).every(input => input.checkValidity())

if (isValid) {
onSetLink(field)
close?.()
} else {
formRef.current.querySelectorAll('input').forEach(input => {
if (!input.checkValidity()) {
input.reportValidity()
if (isValid) {
onSave(url, text, isNewTab)
} else {
formRef.current.querySelectorAll('input').forEach(input => {
if (!input.checkValidity()) {
input.reportValidity()
}
})
}
})
}
}
}
}
},
[onSave, url, text, isNewTab]
)

return (
<div ref={formRef}>
<div className={cn('space-y-4', className)} {...props}>
<div className="space-y-1">
<Label>Link</Label>
<Input
type="url"
required
placeholder="Paste a link"
value={field.url ?? ''}
onChange={e => setField({ ...field, url: e.target.value })}
/>
</div>
React.useImperativeHandle(ref, () => formRef.current as HTMLDivElement)

<div className="space-y-1">
<Label>Display text (optional)</Label>
<Input
type="text"
placeholder="Text to display"
value={field.text ?? ''}
onChange={e => setField({ ...field, text: e.target.value })}
/>
</div>
return (
<div ref={formRef}>
<div className={cn('space-y-4', className)}>
<div className="space-y-1">
<Label>URL</Label>
<Input type="url" required placeholder="Enter URL" value={url} onChange={e => setUrl(e.target.value)} />
</div>

<div className="flex items-center space-x-2">
<Label>Open in new tab</Label>
<Switch
checked={field.openInNewTab}
onCheckedChange={checked => setField({ ...field, openInNewTab: checked })}
/>
</div>
<div className="space-y-1">
<Label>Display Text (optional)</Label>
<Input type="text" placeholder="Enter display text" value={text} onChange={e => setText(e.target.value)} />
</div>

<div className="flex justify-end space-x-2">
{close && (
<Button variant="ghost" type="button" onClick={close}>
Cancel
</Button>
)}
<div className="flex items-center space-x-2">
<Label>Open in New Tab</Label>
<Switch checked={isNewTab} onCheckedChange={setIsNewTab} />
</div>

<Button type="button" onClick={handleClick}>
Insert
</Button>
<div className="flex justify-end space-x-2">
<Button type="button" onClick={handleSave}>
Save
</Button>
</div>
</div>
</div>
</div>
)
}
)
}
)

LinkEditBlock.displayName = 'LinkEditBlock'

export { LinkEditBlock }
export default LinkEditBlock
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,39 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Link2Icon } from '@radix-ui/react-icons'
import { ToolbarButton } from '../toolbar-button'
import { LinkEditBlock } from './link-edit-block'
import { LinkProps } from '../../types'
import { setLink } from '../../utils'

const LinkEditPopover = ({ editor }: { editor: Editor }) => {
const [open, setOpen] = React.useState(false)

const onSetLink = (props: LinkProps) => {
setLink(editor, props)
editor.commands.enter()
}
const { from, to } = editor.state.selection
const text = editor.state.doc.textBetween(from, to, ' ')

const onSetLink = React.useCallback(
(url: string, text?: string, openInNewTab?: boolean) => {
editor
.chain()
.focus()
.extendMarkRange('link')
.insertContent({
type: 'text',
text: text || url,
marks: [
{
type: 'link',
attrs: {
href: url,
target: openInNewTab ? '_blank' : ''
}
}
]
})
.setLink({ href: url })
.run()

editor.commands.enter()
},
[editor]
)

return (
<Popover open={open} onOpenChange={setOpen}>
Expand All @@ -28,7 +51,7 @@ const LinkEditPopover = ({ editor }: { editor: Editor }) => {
</ToolbarButton>
</PopoverTrigger>
<PopoverContent className="w-full min-w-80" align="start" side="bottom">
<LinkEditBlock editor={editor} close={() => setOpen(false)} onSetLink={onSetLink} />
<LinkEditBlock onSave={onSetLink} defaultText={text} />
</PopoverContent>
</Popover>
)
Expand Down
Loading

0 comments on commit 734be69

Please sign in to comment.