Skip to content

Commit 5bb1f94

Browse files
committed
feat: enhance editor functionality with linkification and configurable extensions
- Updated `defaultExtensions` to accept options for placeholder text and link click behavior. - Implemented `linkifyContent` utility to automatically convert plain URLs into clickable links in read-only mode. - Refactored editor content validation to incorporate linkification, improving user experience when viewing content.
1 parent 09f52cf commit 5bb1f94

File tree

3 files changed

+97
-5
lines changed

3 files changed

+97
-5
lines changed

packages/ui/src/components/editor/extensions.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ import Typography from '@tiptap/extension-typography';
1515
import Underline from '@tiptap/extension-underline';
1616
import StarterKit from '@tiptap/starter-kit';
1717

18-
export const defaultExtensions = (placeholder: string = 'Start writing...') => [
18+
type DefaultExtensionsOptions = {
19+
placeholder?: string;
20+
openLinksOnClick?: boolean;
21+
};
22+
23+
export const defaultExtensions = ({
24+
placeholder = 'Start writing...',
25+
openLinksOnClick = false,
26+
}: DefaultExtensionsOptions = {}) => [
1927
StarterKit.configure({
2028
bulletList: {
2129
HTMLAttributes: {
@@ -69,10 +77,15 @@ export const defaultExtensions = (placeholder: string = 'Start writing...') => [
6977
}),
7078
// Links and images
7179
Link.configure({
72-
openOnClick: false,
80+
// Make links clickable when viewing (readOnly). When editing, keep disabled.
81+
openOnClick: openLinksOnClick,
82+
autolink: true,
83+
linkOnPaste: true,
7384
HTMLAttributes: {
7485
class:
7586
'text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer',
87+
target: '_blank',
88+
rel: 'noopener noreferrer',
7689
},
7790
}),
7891
Image.configure({

packages/ui/src/components/editor/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { defaultExtensions } from './extensions';
99
import { LinkSelector } from './selectors/link-selector';
1010
import { NodeSelector } from './selectors/node-selector';
1111
import { TextButtons } from './selectors/text-buttons';
12+
import { linkifyContent } from './utils/linkify-content';
1213
import { validateAndFixTipTapContent } from './utils/validate-content';
1314

1415
export interface EditorProps {
@@ -46,11 +47,12 @@ export const Editor = ({
4647
const [openNode, setOpenNode] = useState(false);
4748
const [openLink, setOpenLink] = useState(false);
4849

49-
// Ensure content is properly structured with a doc type and fix any schema issues
50-
const formattedContent = initialContent ? validateAndFixTipTapContent(initialContent) : null;
50+
// Ensure content is properly structured and add link marks for plain URLs in read-only mode
51+
const validated = initialContent ? validateAndFixTipTapContent(initialContent) : null;
52+
const formattedContent = readOnly && validated ? linkifyContent(validated) : validated;
5153

5254
const editor = useEditor({
53-
extensions: defaultExtensions(placeholder),
55+
extensions: defaultExtensions({ placeholder, openLinksOnClick: readOnly }),
5456
content: formattedContent || '',
5557
editable: !readOnly,
5658
immediatelyRender: false,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { JSONContent } from '@tiptap/react';
2+
3+
const URL_REGEX = /\b(https?:\/\/[^\s)]+|www\.[^\s)]+)\b/gi;
4+
5+
function createLinkMark(href: string) {
6+
const normalized = href.startsWith('http') ? href : `https://${href}`;
7+
return {
8+
type: 'link',
9+
attrs: {
10+
href: normalized,
11+
target: '_blank',
12+
rel: 'noopener noreferrer',
13+
},
14+
};
15+
}
16+
17+
function linkifyText(text: string): JSONContent[] {
18+
const parts: JSONContent[] = [];
19+
let lastIndex = 0;
20+
let match: RegExpExecArray | null;
21+
22+
while ((match = URL_REGEX.exec(text)) !== null) {
23+
const [raw] = match;
24+
const start = match.index;
25+
const end = start + raw.length;
26+
27+
if (start > lastIndex) {
28+
parts.push({ type: 'text', text: text.slice(lastIndex, start) });
29+
}
30+
parts.push({ type: 'text', text: raw, marks: [createLinkMark(raw) as any] });
31+
lastIndex = end;
32+
}
33+
34+
if (lastIndex < text.length) {
35+
parts.push({ type: 'text', text: text.slice(lastIndex) });
36+
}
37+
38+
return parts;
39+
}
40+
41+
export function linkifyContent(doc: JSONContent): JSONContent {
42+
if (!doc || typeof doc !== 'object') return doc;
43+
44+
const recurse = (node: JSONContent): JSONContent => {
45+
if (!node) return node;
46+
47+
if (node.type === 'text' && typeof node.text === 'string') {
48+
// If it already has a link mark, leave as-is
49+
const hasLink = Array.isArray(node.marks) && node.marks.some((m) => m.type === 'link');
50+
if (hasLink) return node;
51+
const segments = linkifyText(node.text);
52+
// If no links detected, return original
53+
if (segments.length === 1 && segments[0].text === node.text && !segments[0].marks) {
54+
return node;
55+
}
56+
return { type: 'text', text: '', content: segments } as any; // handled by parent rewrite below
57+
}
58+
59+
if (Array.isArray(node.content)) {
60+
const newChildren: JSONContent[] = [];
61+
for (const child of node.content) {
62+
const next = recurse(child);
63+
// If a text node returned a wrapper with inline content, flatten it
64+
if (next && (next as any).content && next.type === 'text' && next.text === '') {
65+
newChildren.push(...(((next as any).content as JSONContent[]) || []));
66+
} else {
67+
newChildren.push(next);
68+
}
69+
}
70+
return { ...node, content: newChildren };
71+
}
72+
73+
return node;
74+
};
75+
76+
return recurse(doc);
77+
}

0 commit comments

Comments
 (0)