-
Notifications
You must be signed in to change notification settings - Fork 60
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: link, open new tab, keep track link change etc
- Loading branch information
Showing
5 changed files
with
186 additions
and
168 deletions.
There are no files selected for viewing
106 changes: 77 additions & 29 deletions
106
src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
143
src/components/minimal-tiptap/components/link/link-edit-block.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.