Skip to content

Commit 15e4891

Browse files
authored
Merge pull request #76 from cortex-reply/fix/knowledge-view
feat: adds image node on richtext
2 parents dc53e69 + dc229de commit 15e4891

5 files changed

Lines changed: 597 additions & 41 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
'use client'
2+
3+
import { File } from '@payloadcms/ui'
4+
import React, { useCallback, useEffect, useState } from 'react'
5+
import { UploadData } from '@payloadcms/richtext-lexical'
6+
// import { getMediaById } from '@/app/(frontend)/actions/media'
7+
import { useLexicalComposerContext } from '@payloadcms/richtext-lexical/lexical/react/LexicalComposerContext'
8+
import { $getNodeByKey } from '@payloadcms/richtext-lexical/lexical'
9+
import { X } from 'lucide-react'
10+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
11+
12+
export type ElementProps = {
13+
data: UploadData
14+
nodeKey: string
15+
}
16+
17+
const Component: React.FC<ElementProps> = (props) => {
18+
const {
19+
data: { relationTo, value },
20+
nodeKey,
21+
} = props
22+
const [editor] = useLexicalComposerContext()
23+
const [thumbnailUrl, setThumbnailUrl] = useState('')
24+
const [fileType, setFileType] = useState('')
25+
const [filesize, setFileSize] = useState<string>('')
26+
27+
// if (typeof value === 'object') {
28+
// throw new Error(
29+
// 'Upload value should be a string or number. The Lexical Upload component should not receive the populated value object.',
30+
// )
31+
// }
32+
33+
const fetchMedia = async (id: string) => {
34+
console.log('fetchMedia', id)
35+
// const res = await getMediaById(id)
36+
// const res =
37+
// // if (res && res.status === 'success') {
38+
// // setThumbnailUrl(res.data.url || res.data.thumbnailURL || '')
39+
// // setFileSize((res.data.filesize || 0) / 1000)
40+
// // setFileType(res.data.mimeType?.split('/')[1] || '')
41+
// }
42+
}
43+
44+
useEffect(() => {
45+
if (typeof value === 'string') {
46+
if (value.length === 24) fetchMedia(value)
47+
else {
48+
setThumbnailUrl(value || '')
49+
setFileSize(formatFileSize(getFileSize()))
50+
setFileType(getFileExtension() || '')
51+
}
52+
} else if (typeof value === 'object') {
53+
setThumbnailUrl(`${process.env.NEXT_PUBLIC_API_URL}${value.url}` || '')
54+
setFileSize(formatFileSize(value.filesize))
55+
setFileType(value.mimeType?.split('/')[1] || '')
56+
}
57+
}, [])
58+
59+
const removeUpload = useCallback(() => {
60+
editor.update(() => {
61+
$getNodeByKey(nodeKey)?.remove()
62+
})
63+
}, [editor, nodeKey])
64+
65+
const getFileExtension = () => {
66+
const currVal = props.data.value
67+
if (typeof currVal === 'string') {
68+
return currVal.split(';base64')[0]?.split('image/')[1]
69+
}
70+
return ''
71+
}
72+
const getFileSize = () => {
73+
const currVal = props.data.value
74+
if (typeof currVal === 'string') {
75+
const stringLength = currVal.split(',')[1]?.length || 0
76+
77+
const sizeInBytes = 4 * Math.ceil(stringLength / 3) * 0.5624896334383812
78+
const sizeInKb = sizeInBytes / 1000
79+
return sizeInKb
80+
}
81+
return 0
82+
}
83+
84+
function formatFileSize(bytes: number): string {
85+
if (bytes >= 1_000_000) {
86+
const mb = bytes / 1_000_000
87+
// 10.0+ MB -> no decimals, otherwise 1 decimal
88+
const str = mb >= 10 ? Math.round(mb).toString() : mb.toFixed(1)
89+
return `${str} MB`
90+
}
91+
// Below 1 MB -> show whole KB (floor), e.g. 273852 -> 273 KB
92+
const kb = Math.floor(bytes / 1_000)
93+
return `${kb} KB`
94+
}
95+
96+
return (
97+
<div className="my-2" contentEditable={false}>
98+
<div className="border border-slate-800 rounded-xl overflow-clip w-60 flex flex-col gap-2 bg-[#222222]">
99+
<div className="flex gap-2 border-b border-b-slate-100/10">
100+
<div className="flex-1">
101+
{props.data.value ? (
102+
<img
103+
// alt={props.data.}
104+
className="object-cover m-0 w-full min-w-[115px] min-h-[105px]"
105+
data-lexical-upload-id={value}
106+
data-lexical-upload-relation-to={relationTo}
107+
src={thumbnailUrl}
108+
/>
109+
) : (
110+
<File />
111+
)}
112+
</div>
113+
<TooltipProvider>
114+
<Tooltip>
115+
<TooltipTrigger className="flex-1 flex justify-center items-center text-[#b5b5b5]">
116+
<span className="font-medium text-sm">Media</span>
117+
<button
118+
onClick={(e) => {
119+
e.preventDefault()
120+
removeUpload()
121+
}}
122+
className="flex-1 flex justify-center items-center"
123+
>
124+
<X width={24} height={24} strokeWidth={1} />
125+
</button>
126+
</TooltipTrigger>
127+
<TooltipContent>
128+
<span>Remove image</span>
129+
</TooltipContent>
130+
</Tooltip>
131+
</TooltipProvider>
132+
</div>
133+
<div className="flex items-center justify-center gap-4 py-3 flex-1 text-xs font-bold">
134+
<span className="">{fileType?.toLocaleUpperCase()}</span>
135+
<span>{filesize}</span>
136+
</div>
137+
</div>
138+
</div>
139+
)
140+
}
141+
142+
export default Component

src/components/Foundary/RichText/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import { OnChangePlugin } from '@payloadcms/richtext-lexical/lexical/react/Lexic
1414
import React, { Dispatch, SetStateAction, useEffect } from 'react'
1515

1616
import ToolbarPlugin from './plugins/toolbar-plugin'
17+
import { UploadNode } from './nodes/image-node'
1718
import { useLexicalComposerContext } from '@payloadcms/richtext-lexical/lexical/react/LexicalComposerContext'
19+
import InlineImagePlugin from './plugins/image-plugin'
1820
// import { UploadNode } from '@payloadcms/richtext-lexical/client'
1921

2022
interface RichTextProps {
@@ -67,6 +69,7 @@ const RichTextContent: React.FC<RichTextProps> = ({ setValue, value, name, edita
6769
<AutoFocusPlugin />
6870
<ListPlugin />
6971
<OnChangePlugin onChange={handleEditorChange} />
72+
<InlineImagePlugin />
7073
</>
7174
)}
7275
</div>
@@ -79,7 +82,7 @@ const RichText: React.FC<RichTextProps> = ({ setValue, value, name, editable = t
7982
const editorConfig = {
8083
namespace: 'Lexical editor',
8184
// nodes: [TableNode, TableCellNode, TableRowNode],
82-
nodes: [ListNode, ListItemNode, HeadingNode],
85+
nodes: [ListNode, ListItemNode, HeadingNode, UploadNode],
8386
// Handling of errors during update
8487
onError(error: Error) {
8588
console.error('Lexical error:', error)
@@ -124,6 +127,7 @@ const RichText: React.FC<RichTextProps> = ({ setValue, value, name, editable = t
124127
}
125128

126129
return (
130+
// @ts-ignore
127131
<LexicalComposer initialConfig={editorConfig}>
128132
<RichTextContent value={value} setValue={setValue} name={name} editable={editable} />
129133
</LexicalComposer>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client'
2+
3+
import type {
4+
DOMConversionMap,
5+
DOMConversionOutput,
6+
LexicalNode,
7+
Spread,
8+
} from '@payloadcms/richtext-lexical/lexical'
9+
import type { SerializedDecoratorBlockNode } from '@payloadcms/richtext-lexical/lexical/react/LexicalDecoratorBlockNode'
10+
11+
import { UploadNode as ClientUploadNode } from '@payloadcms/richtext-lexical/client'
12+
13+
import ObjectID from 'bson-objectid'
14+
import RawUploadComponent from '../components/RawUploadComponent'
15+
16+
// ⬇️ Infer UploadData instead of importing it
17+
type UploadData = Parameters<
18+
typeof import('@payloadcms/richtext-lexical/client').$createUploadNode
19+
>[0]['data']
20+
21+
export type SerializedUploadNode = {
22+
children?: never
23+
type: 'upload'
24+
} & Spread<UploadData, SerializedDecoratorBlockNode>
25+
26+
function $convertUploadElement(domNode: HTMLImageElement): DOMConversionOutput | null {
27+
if (
28+
domNode.hasAttribute('data-lexical-upload-relation-to') &&
29+
domNode.hasAttribute('data-lexical-upload-id')
30+
) {
31+
const id = domNode.getAttribute('data-lexical-upload-id')
32+
if (id) {
33+
const node = $createMyUploadNode({
34+
fields: {},
35+
relationTo: 'media',
36+
value: id,
37+
})
38+
return { node }
39+
}
40+
}
41+
const img = domNode
42+
if (img.src.startsWith('file:///')) return null
43+
return null
44+
}
45+
46+
export class UploadNode extends ClientUploadNode {
47+
static override importDOM(): DOMConversionMap<HTMLImageElement> {
48+
return { img: () => ({ conversion: $convertUploadElement, priority: 0 }) }
49+
}
50+
51+
static override importJSON(serialized: SerializedUploadNode): UploadNode {
52+
if (serialized.version === 1 && (serialized?.value as any)?.id) {
53+
serialized.value = (serialized.value as any).id
54+
}
55+
if (serialized.version === 2 && !serialized?.id) {
56+
serialized.id = new ObjectID().toHexString()
57+
serialized.version = 3
58+
}
59+
60+
const data: UploadData = {
61+
id: serialized.id ?? new ObjectID().toHexString(),
62+
fields: serialized.fields,
63+
relationTo: 'media',
64+
value: serialized.value,
65+
}
66+
67+
const node = $createMyUploadNode(data)
68+
// optional, if present on your version
69+
node.setFormat?.(serialized.format)
70+
return node as UploadNode
71+
}
72+
73+
override decorate() {
74+
// Prefer public getters if available in your version; keep this narrow:
75+
return <RawUploadComponent data={this.__data} nodeKey={this.getKey()} />
76+
}
77+
}
78+
79+
export function $createMyUploadNode(data: UploadData): UploadNode {
80+
return new UploadNode({ data } as any)
81+
}
82+
83+
export function $isUploadNode(node: LexicalNode | null | undefined): node is UploadNode {
84+
return node instanceof UploadNode
85+
}

0 commit comments

Comments
 (0)