diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index 51672575..c9496399 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -32,6 +32,7 @@ import TableHeader from '@tiptap/extension-table-header' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock' +import { getLinkExtension } from '@components/Objects/Editor/EditorConf' interface Editor { content: string @@ -112,6 +113,7 @@ function Canva(props: Editor) { Table.configure({ resizable: true, }), + getLinkExtension(), TableRow, TableHeader, TableCell, @@ -194,6 +196,19 @@ const CanvaWrapper = styled.div` 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, ol { padding: 0 1rem; diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index 51422ce1..a7071e98 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -32,6 +32,8 @@ import TableRow from '@tiptap/extension-table-row' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import Link from 'next/link' import { getCourseThumbnailMediaDirectory } from '@services/media/media' +import { getLinkExtension } from './EditorConf' +import { Link as LinkExtension } from '@tiptap/extension-link' // Lowlight @@ -151,6 +153,7 @@ function Editor(props: Editor) { TableRow, TableHeader, TableCell, + getLinkExtension(), ], content: props.content, immediatelyRender: false, @@ -204,7 +207,7 @@ function Editor(props: Editor) { props.org?.org_uuid, props.course.course_uuid, props.course.thumbnail_image - ) : getUriWithOrg(props.org?.slug,'/empty_thumbnail.png')}`} + ) : getUriWithOrg(props.org?.slug, '/empty_thumbnail.png')}`} alt="" > @@ -459,6 +462,19 @@ export const EditorContentWrapper = styled.div` 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-right: 20px; padding-bottom: 20px; diff --git a/apps/web/components/Objects/Editor/EditorConf.ts b/apps/web/components/Objects/Editor/EditorConf.ts new file mode 100644 index 00000000..220c5038 --- /dev/null +++ b/apps/web/components/Objects/Editor/EditorConf.ts @@ -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 + } + }, + }) +} \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx b/apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx new file mode 100644 index 00000000..1ad2258f --- /dev/null +++ b/apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx @@ -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 = ({ 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 ( + +
+ setUrl(e.target.value)} + autoFocus + /> + + + + + + + + +
+
+ ) +} + +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 \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx index 320d36b7..c36871b7 100644 --- a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx +++ b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx @@ -23,6 +23,7 @@ import { FileText, ImagePlus, Lightbulb, + Link2, MousePointerClick, Sigma, Table, @@ -34,9 +35,12 @@ import { import { SiYoutube } from '@icons-pack/react-simple-icons' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import React from 'react' +import LinkInputTooltip from './LinkInputTooltip' export const ToolbarButtons = ({ editor, props }: any) => { const [showTableMenu, setShowTableMenu] = React.useState(false) + const [showLinkInput, setShowLinkInput] = React.useState(false) + const linkButtonRef = React.useRef(null) if (!editor) { 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 ( editor.chain().focus().undo().run()}> @@ -185,6 +230,24 @@ export const ToolbarButtons = ({ editor, props }: any) => { + +
+ + + + {showLinkInput && ( + + )} +
+
diff --git a/apps/web/package.json b/apps/web/package.json index ca1526a6..2ba0fb75 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@tanstack/react-table": "^8.21.2", "@tiptap/core": "^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-cell": "^2.11.7", "@tiptap/extension-table-header": "^2.11.7", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 5fb87869..03818c2a 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: '@tiptap/extension-code-block-lowlight': 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) + '@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': specifier: ^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: '@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': resolution: {integrity: sha512-6ikh7Y+qAbkSuIHXPIINqfzmWs5uIGrylihdZ9adaIyvrN1KSnWIqrZIk/NcZTg5YFIJlXrnGSRSjb/QM3WUhw==} peerDependencies: @@ -2646,6 +2655,9 @@ packages: linkify-it@5.0.0: 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: resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} @@ -3306,8 +3318,8 @@ packages: tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} - tailwind-merge@3.1.0: - resolution: {integrity: sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q==} + tailwind-merge@3.2.0: + resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} @@ -4665,6 +4677,12 @@ snapshots: dependencies: '@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))': dependencies: '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) @@ -5280,7 +5298,7 @@ snapshots: 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) - tailwind-merge: 3.1.0 + tailwind-merge: 3.2.0 transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -6012,6 +6030,8 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkifyjs@4.2.0: {} + load-script@1.0.0: {} locate-path@6.0.0: @@ -6772,7 +6792,7 @@ snapshots: tailwind-merge@2.6.0: {} - tailwind-merge@3.1.0: {} + tailwind-merge@3.2.0: {} tailwindcss-animate@1.0.7(tailwindcss@4.1.3): dependencies: