From 734be690dfa7254cae7c8bc7fca7a8714603eebe Mon Sep 17 00:00:00 2001 From: Aslam H Date: Sun, 18 Aug 2024 11:01:28 +0700 Subject: [PATCH] fix: link, open new tab, keep track link change etc --- .../bubble-menu/link-bubble-menu.tsx | 106 +++++++++---- .../components/link/link-edit-block.tsx | 143 +++++++----------- .../components/link/link-edit-popover.tsx | 37 ++++- .../components/link/link-popover-block.tsx | 45 +++--- src/components/minimal-tiptap/utils.ts | 23 --- 5 files changed, 186 insertions(+), 168 deletions(-) diff --git a/src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx b/src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx index 75139fd..db29ea8 100644 --- a/src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx +++ b/src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx @@ -1,36 +1,86 @@ -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 = ({ editor }) => { const [showEdit, setShowEdit] = useState(false) - const shouldShow = ({ editor, from, to }: ShouldShowProps) => { + const [linkAttrs, setLinkAttrs] = useState({ 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 ( { shouldShow={shouldShow} tippyOptions={{ placement: 'bottom-start', - onHidden: () => { - setShowEdit(false) - } + onHidden: () => setShowEdit(false) }} > {showEdit ? ( ) : ( - setShowEdit(true)} /> + )} ) } - -export { LinkBubbleMenu } diff --git a/src/components/minimal-tiptap/components/link/link-edit-block.tsx b/src/components/minimal-tiptap/components/link/link-edit-block.tsx index 77b4ca2..d42bc8c 100644 --- a/src/components/minimal-tiptap/components/link/link-edit-block.tsx +++ b/src/components/minimal-tiptap/components/link/link-edit-block.tsx @@ -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 { - editor: Editor - onSetLink: ({ url, text, openInNewTab }: LinkProps) => void - close?: () => void +export interface LinkEditorProps extends React.HTMLAttributes { + 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(null) +export const LinkEditBlock = React.forwardRef( + ({ onSave, defaultIsNewTab, defaultUrl, defaultText, className }, ref) => { + const formRef = React.useRef(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({ - 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 ( -
-
-
- - setField({ ...field, url: e.target.value })} - /> -
+ React.useImperativeHandle(ref, () => formRef.current as HTMLDivElement) -
- - setField({ ...field, text: e.target.value })} - /> -
+ return ( +
+
+
+ + setUrl(e.target.value)} /> +
-
- - setField({ ...field, openInNewTab: checked })} - /> -
+
+ + setText(e.target.value)} /> +
-
- {close && ( - - )} +
+ + +
- +
+ +
-
- ) -} + ) + } +) + +LinkEditBlock.displayName = 'LinkEditBlock' -export { LinkEditBlock } +export default LinkEditBlock diff --git a/src/components/minimal-tiptap/components/link/link-edit-popover.tsx b/src/components/minimal-tiptap/components/link/link-edit-popover.tsx index 98ea512..9046bb3 100644 --- a/src/components/minimal-tiptap/components/link/link-edit-popover.tsx +++ b/src/components/minimal-tiptap/components/link/link-edit-popover.tsx @@ -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 ( @@ -28,7 +51,7 @@ const LinkEditPopover = ({ editor }: { editor: Editor }) => { - setOpen(false)} onSetLink={onSetLink} /> + ) diff --git a/src/components/minimal-tiptap/components/link/link-popover-block.tsx b/src/components/minimal-tiptap/components/link/link-popover-block.tsx index 1735178..7d49543 100644 --- a/src/components/minimal-tiptap/components/link/link-popover-block.tsx +++ b/src/components/minimal-tiptap/components/link/link-popover-block.tsx @@ -1,29 +1,34 @@ +import React, { useState, useCallback } from 'react' import { Separator } from '@/components/ui/separator' import { ToolbarButton } from '../toolbar-button' import { CopyIcon, ExternalLinkIcon, LinkBreak2Icon } from '@radix-ui/react-icons' -import { useState } from 'react' -const LinkPopoverBlock = ({ - link, - onClear, - onEdit -}: { - link: Record +interface LinkPopoverBlockProps { + url: string onClear: () => void onEdit: (e: React.MouseEvent) => void -}) => { - const [copyTitle, setCopyTitle] = useState('Copy') +} - const handleCopy = (e: React.MouseEvent) => { - e.preventDefault() +export const LinkPopoverBlock: React.FC = ({ url, onClear, onEdit }) => { + const [copyTitle, setCopyTitle] = useState('Copy') - setCopyTitle('Copied!') - navigator.clipboard.writeText(link.href as string) + const handleCopy = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + navigator.clipboard + .writeText(url) + .then(() => { + setCopyTitle('Copied!') + setTimeout(() => setCopyTitle('Copy'), 1000) + }) + .catch(console.error) + }, + [url] + ) - setTimeout(() => { - setCopyTitle('Copy') - }, 1000) - } + const handleOpenLink = useCallback(() => { + window.open(url, '_blank', 'noopener,noreferrer') + }, [url]) return (
@@ -32,7 +37,7 @@ const LinkPopoverBlock = ({ Edit link - window.open(link.href as string, '_blank')}> + @@ -44,7 +49,7 @@ const LinkPopoverBlock = ({ tooltip={copyTitle} onClick={handleCopy} tooltipOptions={{ - onPointerDownOutside: (e: Event) => { + onPointerDownOutside: e => { if (e.target === e.currentTarget) e.preventDefault() } }} @@ -55,5 +60,3 @@ const LinkPopoverBlock = ({
) } - -export { LinkPopoverBlock } diff --git a/src/components/minimal-tiptap/utils.ts b/src/components/minimal-tiptap/utils.ts index f421a8e..3296a60 100644 --- a/src/components/minimal-tiptap/utils.ts +++ b/src/components/minimal-tiptap/utils.ts @@ -1,6 +1,5 @@ import type { Editor } from '@tiptap/core' import type { MinimalTiptapProps } from './minimal-tiptap' -import { LinkProps } from './types' let isMac: boolean | undefined @@ -80,25 +79,3 @@ export function getOutput(editor: Editor, format: MinimalTiptapProps['output']) return editor.getText() } - -export function setLink(editor: Editor, { url, text, openInNewTab }: LinkProps) { - editor - .chain() - .extendMarkRange('link') - .insertContent({ - type: 'text', - text: text || url, - marks: [ - { - type: 'link', - attrs: { - href: url, - target: openInNewTab ? '_blank' : '' - } - } - ] - }) - .setLink({ href: url }) - .focus() - .run() -}