feat: add link extension support and styling to editor components

This commit is contained in:
swve 2025-04-17 15:25:29 +02:00
parent 1350cb7354
commit e6d7e881e3
7 changed files with 297 additions and 5 deletions

View file

@ -32,6 +32,7 @@ import TableHeader from '@tiptap/extension-table-header'
import TableRow from '@tiptap/extension-table-row' import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell' import TableCell from '@tiptap/extension-table-cell'
import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock' import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
import { getLinkExtension } from '@components/Objects/Editor/EditorConf'
interface Editor { interface Editor {
content: string content: string
@ -112,6 +113,7 @@ function Canva(props: Editor) {
Table.configure({ Table.configure({
resizable: true, resizable: true,
}), }),
getLinkExtension(),
TableRow, TableRow,
TableHeader, TableHeader,
TableCell, TableCell,
@ -194,6 +196,19 @@ const CanvaWrapper = styled.div`
margin-bottom: 10px; margin-bottom: 10px;
} }
// Link styling
a {
color: #2563eb;
text-decoration: underline;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: #1d4ed8;
text-decoration: none;
}
}
ul, ul,
ol { ol {
padding: 0 1rem; padding: 0 1rem;

View file

@ -32,6 +32,8 @@ import TableRow from '@tiptap/extension-table-row'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import Link from 'next/link' import Link from 'next/link'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { getLinkExtension } from './EditorConf'
import { Link as LinkExtension } from '@tiptap/extension-link'
// Lowlight // Lowlight
@ -151,6 +153,7 @@ function Editor(props: Editor) {
TableRow, TableRow,
TableHeader, TableHeader,
TableCell, TableCell,
getLinkExtension(),
], ],
content: props.content, content: props.content,
immediatelyRender: false, immediatelyRender: false,
@ -204,7 +207,7 @@ function Editor(props: Editor) {
props.org?.org_uuid, props.org?.org_uuid,
props.course.course_uuid, props.course.course_uuid,
props.course.thumbnail_image props.course.thumbnail_image
) : getUriWithOrg(props.org?.slug,'/empty_thumbnail.png')}`} ) : getUriWithOrg(props.org?.slug, '/empty_thumbnail.png')}`}
alt="" alt=""
></EditorInfoThumbnail> ></EditorInfoThumbnail>
</Link> </Link>
@ -459,6 +462,19 @@ export const EditorContentWrapper = styled.div`
margin-bottom: 10px; margin-bottom: 10px;
} }
// Link styling
a {
color: #2563eb;
text-decoration: underline;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: #1d4ed8;
text-decoration: none;
}
}
padding-left: 20px; padding-left: 20px;
padding-right: 20px; padding-right: 20px;
padding-bottom: 20px; padding-bottom: 20px;

View file

@ -0,0 +1,59 @@
import { Link as LinkExtension } from '@tiptap/extension-link'
export const getLinkExtension = () => {
return LinkExtension.configure({
openOnClick: true,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
autolink: true,
defaultProtocol: 'https',
protocols: ['http', 'https'],
isAllowedUri: (url: string, ctx: any) => {
try {
// construct URL
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`)
// use default validation
if (!ctx.defaultValidate(parsedUrl.href)) {
return false
}
// disallowed protocols
const disallowedProtocols = ['ftp', 'file', 'mailto']
const protocol = parsedUrl.protocol.replace(':', '')
if (disallowedProtocols.includes(protocol)) {
return false
}
// only allow protocols specified in ctx.protocols
const allowedProtocols = ctx.protocols.map((p: any) => (typeof p === 'string' ? p : p.scheme))
if (!allowedProtocols.includes(protocol)) {
return false
}
// all checks have passed
return true
} catch {
return false
}
},
shouldAutoLink: (url: string) => {
try {
// construct URL
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`https://${url}`)
// only auto-link if the domain is not in the disallowed list
const disallowedDomains = ['example-no-autolink.com', 'another-no-autolink.com']
const domain = parsedUrl.hostname
return !disallowedDomains.includes(domain)
} catch {
return false
}
},
})
}

View file

@ -0,0 +1,118 @@
import React, { useState, useEffect } from 'react'
import styled from 'styled-components'
import { CheckIcon, Cross2Icon } from '@radix-ui/react-icons'
interface LinkInputTooltipProps {
onSave: (url: string) => void
onCancel: () => void
currentUrl?: string
}
const LinkInputTooltip: React.FC<LinkInputTooltipProps> = ({ onSave, onCancel, currentUrl }) => {
const [url, setUrl] = useState(currentUrl || '')
useEffect(() => {
setUrl(currentUrl || '')
}, [currentUrl])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (url) {
// Ensure the URL has a protocol
const formattedUrl = url.startsWith('http://') || url.startsWith('https://')
? url
: `https://${url}`
onSave(formattedUrl)
}
}
return (
<TooltipContainer>
<Form onSubmit={handleSubmit}>
<Input
type="text"
placeholder="Enter URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
autoFocus
/>
<ButtonGroup>
<SaveButton type="submit" disabled={!url}>
<CheckIcon />
</SaveButton>
<CancelButton type="button" onClick={onCancel}>
<Cross2Icon />
</CancelButton>
</ButtonGroup>
</Form>
</TooltipContainer>
)
}
const TooltipContainer = styled.div`
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid rgba(217, 217, 217, 0.5);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding: 8px;
margin-top: 4px;
`
const Form = styled.form`
display: flex;
align-items: center;
gap: 4px;
`
const Input = styled.input`
padding: 4px 8px;
border: 1px solid rgba(217, 217, 217, 0.5);
border-radius: 4px;
font-size: 12px;
width: 200px;
&:focus {
outline: none;
border-color: rgba(217, 217, 217, 0.8);
}
`
const ButtonGroup = styled.div`
display: flex;
gap: 2px;
`
const Button = styled.button`
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
border-radius: 4px;
cursor: pointer;
background: rgba(217, 217, 217, 0.24);
transition: background 0.2s;
&:hover {
background: rgba(217, 217, 217, 0.48);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
const SaveButton = styled(Button)`
color: #4CAF50;
`
const CancelButton = styled(Button)`
color: #F44336;
`
export default LinkInputTooltip

View file

@ -23,6 +23,7 @@ import {
FileText, FileText,
ImagePlus, ImagePlus,
Lightbulb, Lightbulb,
Link2,
MousePointerClick, MousePointerClick,
Sigma, Sigma,
Table, Table,
@ -34,9 +35,12 @@ import {
import { SiYoutube } from '@icons-pack/react-simple-icons' import { SiYoutube } from '@icons-pack/react-simple-icons'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import React from 'react' import React from 'react'
import LinkInputTooltip from './LinkInputTooltip'
export const ToolbarButtons = ({ editor, props }: any) => { export const ToolbarButtons = ({ editor, props }: any) => {
const [showTableMenu, setShowTableMenu] = React.useState(false) const [showTableMenu, setShowTableMenu] = React.useState(false)
const [showLinkInput, setShowLinkInput] = React.useState(false)
const linkButtonRef = React.useRef<HTMLDivElement>(null)
if (!editor) { if (!editor) {
return null return null
@ -83,6 +87,47 @@ export const ToolbarButtons = ({ editor, props }: any) => {
} }
] ]
const handleLinkClick = () => {
// Store the current selection
const { from, to } = editor.state.selection
if (editor.isActive('link')) {
const currentLink = editor.getAttributes('link')
setShowLinkInput(true)
} else {
setShowLinkInput(true)
}
// Restore the selection after a small delay to ensure the tooltip is rendered
setTimeout(() => {
editor.commands.setTextSelection({ from, to })
}, 0)
}
const getCurrentLinkUrl = () => {
if (editor.isActive('link')) {
return editor.getAttributes('link').href
}
return ''
}
const handleLinkSave = (url: string) => {
editor
.chain()
.focus()
.setLink({
href: url,
target: '_blank',
rel: 'noopener noreferrer'
})
.run()
setShowLinkInput(false)
}
const handleLinkCancel = () => {
setShowLinkInput(false)
}
return ( return (
<ToolButtonsWrapper> <ToolButtonsWrapper>
<ToolBtn onClick={() => editor.chain().focus().undo().run()}> <ToolBtn onClick={() => editor.chain().focus().undo().run()}>
@ -185,6 +230,24 @@ export const ToolbarButtons = ({ editor, props }: any) => {
<AlertTriangle size={15} /> <AlertTriangle size={15} />
</ToolBtn> </ToolBtn>
</ToolTip> </ToolTip>
<ToolTip content={'Link'}>
<div style={{ position: 'relative' }}>
<ToolBtn
ref={linkButtonRef}
onClick={handleLinkClick}
className={editor.isActive('link') ? 'is-active' : ''}
>
<Link2 size={15} />
</ToolBtn>
{showLinkInput && (
<LinkInputTooltip
onSave={handleLinkSave}
onCancel={handleLinkCancel}
currentUrl={getCurrentLinkUrl()}
/>
)}
</div>
</ToolTip>
<ToolTip content={'Image'}> <ToolTip content={'Image'}>
<ToolBtn <ToolBtn
onClick={() => onClick={() =>

View file

@ -34,6 +34,7 @@
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"@tiptap/core": "^2.11.7", "@tiptap/core": "^2.11.7",
"@tiptap/extension-code-block-lowlight": "^2.11.7", "@tiptap/extension-code-block-lowlight": "^2.11.7",
"@tiptap/extension-link": "^2.11.7",
"@tiptap/extension-table": "^2.11.7", "@tiptap/extension-table": "^2.11.7",
"@tiptap/extension-table-cell": "^2.11.7", "@tiptap/extension-table-cell": "^2.11.7",
"@tiptap/extension-table-header": "^2.11.7", "@tiptap/extension-table-header": "^2.11.7",

View file

@ -81,6 +81,9 @@ importers:
'@tiptap/extension-code-block-lowlight': '@tiptap/extension-code-block-lowlight':
specifier: ^2.11.7 specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0) version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0)
'@tiptap/extension-link':
specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
'@tiptap/extension-table': '@tiptap/extension-table':
specifier: ^2.11.7 specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7) version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
@ -1490,6 +1493,12 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@tiptap/core': ^2.7.0
'@tiptap/extension-link@2.11.7':
resolution: {integrity: sha512-qKIowE73aAUrnQCIifYP34xXOHOsZw46cT/LBDlb0T60knVfQoKVE4ku08fJzAV+s6zqgsaaZ4HVOXkQYLoW7g==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-list-item@2.11.7': '@tiptap/extension-list-item@2.11.7':
resolution: {integrity: sha512-6ikh7Y+qAbkSuIHXPIINqfzmWs5uIGrylihdZ9adaIyvrN1KSnWIqrZIk/NcZTg5YFIJlXrnGSRSjb/QM3WUhw==} resolution: {integrity: sha512-6ikh7Y+qAbkSuIHXPIINqfzmWs5uIGrylihdZ9adaIyvrN1KSnWIqrZIk/NcZTg5YFIJlXrnGSRSjb/QM3WUhw==}
peerDependencies: peerDependencies:
@ -2646,6 +2655,9 @@ packages:
linkify-it@5.0.0: linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.2.0:
resolution: {integrity: sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==}
load-script@1.0.0: load-script@1.0.0:
resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==}
@ -3306,8 +3318,8 @@ packages:
tailwind-merge@2.6.0: tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
tailwind-merge@3.1.0: tailwind-merge@3.2.0:
resolution: {integrity: sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q==} resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
tailwindcss-animate@1.0.7: tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
@ -4665,6 +4677,12 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
'@tiptap/extension-link@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)':
dependencies:
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
'@tiptap/pm': 2.11.7
linkifyjs: 4.2.0
'@tiptap/extension-list-item@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))': '@tiptap/extension-list-item@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))':
dependencies: dependencies:
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
@ -5280,7 +5298,7 @@ snapshots:
react: 19.0.0 react: 19.0.0
react-dom: 19.0.0(react@19.0.0) react-dom: 19.0.0(react@19.0.0)
react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tailwind-merge: 3.1.0 tailwind-merge: 3.2.0
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
@ -6012,6 +6030,8 @@ snapshots:
dependencies: dependencies:
uc.micro: 2.1.0 uc.micro: 2.1.0
linkifyjs@4.2.0: {}
load-script@1.0.0: {} load-script@1.0.0: {}
locate-path@6.0.0: locate-path@6.0.0:
@ -6772,7 +6792,7 @@ snapshots:
tailwind-merge@2.6.0: {} tailwind-merge@2.6.0: {}
tailwind-merge@3.1.0: {} tailwind-merge@3.2.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.3): tailwindcss-animate@1.0.7(tailwindcss@4.1.3):
dependencies: dependencies: