Skip to content

Commit 0ba53a2

Browse files
committed
feat(editor): integrate Lexical editor support in various components
- Added `lexicalEditor` prop to `TextBaseDrawer`, `WriteEditor`, and `RichWriteEditor` components. - Introduced `LexicalImageDetailSection` for handling images in Lexical content. - Updated `pnpm-lock.yaml` to use `@unocss/core` version 66.6.6. - Enhanced image handling utilities in `image.ts` for better image metadata processing. Signed-off-by: Innei <tukon479@gmail.com>
1 parent e1f30c9 commit 0ba53a2

10 files changed

Lines changed: 291 additions & 5 deletions

File tree

pnpm-lock.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { uniqBy } from 'es-toolkit/compat'
2+
import { $nodesOfType } from 'lexical'
3+
import { ImageIcon } from 'lucide-vue-next'
4+
import { NButton } from 'naive-ui'
5+
import { computed, defineComponent, ref } from 'vue'
6+
import { toast } from 'vue-sonner'
7+
import type { LexicalEditor } from 'lexical'
8+
import type { PropType } from 'vue'
9+
10+
import { ImageNode } from '@haklex/rich-editor/nodes'
11+
12+
import { encodeImageToThumbhash, getDominantColor } from '~/utils/image'
13+
14+
type SerializedImageNode = {
15+
type: string
16+
src?: string
17+
width?: number
18+
height?: number
19+
accent?: string
20+
thumbhash?: string
21+
children?: SerializedImageNode[]
22+
}
23+
24+
type LexicalImageMeta = {
25+
src: string
26+
width?: number
27+
height?: number
28+
accent?: string
29+
thumbhash?: string
30+
}
31+
32+
const collectImageNodes = (nodes: SerializedImageNode[] = []) => {
33+
const images: LexicalImageMeta[] = []
34+
35+
nodes.forEach((node) => {
36+
if (node.type === 'image' && node.src) {
37+
images.push({
38+
src: node.src,
39+
width: node.width,
40+
height: node.height,
41+
accent: node.accent,
42+
thumbhash: node.thumbhash,
43+
})
44+
}
45+
46+
if (node.children?.length) {
47+
images.push(...collectImageNodes(node.children))
48+
}
49+
})
50+
51+
return images
52+
}
53+
54+
export const LexicalImageDetailSection = defineComponent({
55+
name: 'LexicalImageDetailSection',
56+
props: {
57+
content: {
58+
type: String,
59+
required: true,
60+
},
61+
editor: {
62+
type: Object as PropType<LexicalEditor | null>,
63+
required: false,
64+
default: null,
65+
},
66+
},
67+
setup(props) {
68+
const loading = ref(false)
69+
70+
const images = computed(() => {
71+
if (!props.content) {
72+
return [] as LexicalImageMeta[]
73+
}
74+
75+
try {
76+
const parsed = JSON.parse(props.content) as {
77+
root?: { children?: SerializedImageNode[] }
78+
}
79+
return uniqBy(collectImageNodes(parsed.root?.children), 'src')
80+
} catch {
81+
return [] as LexicalImageMeta[]
82+
}
83+
})
84+
85+
const handleCorrectImageDimensions = async () => {
86+
if (!props.editor) {
87+
toast.warning('Lexical 编辑器尚未就绪')
88+
return
89+
}
90+
91+
loading.value = true
92+
93+
const fetchImageTasks = await Promise.allSettled(
94+
images.value.map((item) => {
95+
return new Promise<LexicalImageMeta>((resolve, reject) => {
96+
const image = new Image()
97+
image.crossOrigin = 'Anonymous'
98+
image.src = item.src
99+
100+
image.addEventListener('load', async () => {
101+
try {
102+
let accent = item.accent
103+
let thumbhash = item.thumbhash
104+
105+
try {
106+
accent = getDominantColor(image)
107+
} catch {
108+
// Cross-origin images may block canvas reads.
109+
}
110+
111+
try {
112+
thumbhash = await encodeImageToThumbhash(image)
113+
} catch {
114+
// Keep existing thumbhash when recomputing is not possible.
115+
}
116+
117+
resolve({
118+
src: item.src,
119+
width: image.naturalWidth,
120+
height: image.naturalHeight,
121+
accent,
122+
thumbhash,
123+
})
124+
} catch (error) {
125+
reject({
126+
err: error,
127+
src: item.src,
128+
})
129+
}
130+
})
131+
132+
image.onerror = (err) => {
133+
reject({
134+
err,
135+
src: item.src,
136+
})
137+
}
138+
})
139+
}),
140+
)
141+
142+
const nextImageMetaMap = new Map<string, LexicalImageMeta>()
143+
144+
fetchImageTasks.forEach((task) => {
145+
if (task.status === 'fulfilled') {
146+
nextImageMetaMap.set(task.value.src, task.value)
147+
return
148+
}
149+
150+
toast.warning(`获取图片信息失败:${task.reason.src}`)
151+
})
152+
153+
props.editor.update(() => {
154+
const imageNodes = $nodesOfType(ImageNode)
155+
imageNodes.forEach((node) => {
156+
const nextMeta = nextImageMetaMap.get(node.getSrc())
157+
if (!nextMeta) return
158+
159+
node.setDimensions(nextMeta.width, nextMeta.height)
160+
node.setAccent(nextMeta.accent)
161+
node.setThumbhash(nextMeta.thumbhash)
162+
})
163+
})
164+
165+
loading.value = false
166+
}
167+
168+
return () => (
169+
<div class="flex w-full flex-col">
170+
<div class="flex items-center justify-between gap-3">
171+
<span class="text-sm text-neutral-500">
172+
调整 Lexical 中的图片信息
173+
</span>
174+
<NButton
175+
loading={loading.value}
176+
size="tiny"
177+
onClick={handleCorrectImageDimensions}
178+
type="primary"
179+
tertiary
180+
disabled={!props.editor || images.value.length === 0}
181+
>
182+
自动修正
183+
</NButton>
184+
</div>
185+
186+
{images.value.length > 0 ? (
187+
<div class="mt-4 space-y-2">
188+
{images.value.map((image) => {
189+
const fileName = image.src.split('/').pop() || image.src
190+
return (
191+
<div
192+
key={image.src}
193+
class="rounded-lg border border-neutral-200 px-3 py-2.5 dark:border-neutral-700"
194+
>
195+
<div class="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-200">
196+
<ImageIcon class="h-4 w-4 flex-shrink-0 text-neutral-400" />
197+
<span class="min-w-0 flex-1 truncate">{fileName}</span>
198+
</div>
199+
<div class="mt-1 text-xs text-neutral-400">
200+
{image.width && image.height
201+
? `${image.width}×${image.height}`
202+
: '未写入尺寸'}
203+
{image.accent ? ' · 已有 accent' : ''}
204+
{image.thumbhash ? ' · 已有 thumbhash' : ''}
205+
</div>
206+
</div>
207+
)
208+
})}
209+
</div>
210+
) : (
211+
<div class="mt-4 rounded-lg border border-dashed border-neutral-200 px-3 py-4 text-sm text-neutral-400 dark:border-neutral-700">
212+
当前 Lexical 内容中没有图片节点
213+
</div>
214+
)}
215+
</div>
216+
)
217+
},
218+
})

src/components/drawer/text-base-drawer.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import isURL from 'validator/lib/isURL'
1313
import { defineComponent, h, ref } from 'vue'
1414
import type { Image } from '@mx-space/api-client'
1515
import type { MetaPresetScope } from '~/models/meta-preset'
16+
import type { LexicalEditor } from 'lexical'
1617
import type { SelectOption } from 'naive-ui'
1718
import type { PropType, VNode } from 'vue'
1819

1920
import { ImageDetailSection } from './components/image-detail-section'
21+
import { LexicalImageDetailSection } from './components/lexical-image-detail-section'
2022
import { MetaPresetSection } from './components/meta-preset-section'
2123
import { FormField, SectionTitle, SwitchRow } from './components/ui'
2224

@@ -64,6 +66,11 @@ export const TextBaseDrawer = defineComponent({
6466
type: String as PropType<MetaPresetScope>,
6567
default: 'both',
6668
},
69+
lexicalEditor: {
70+
type: Object as PropType<LexicalEditor | null>,
71+
required: false,
72+
default: null,
73+
},
6774
},
6875
setup(props, { slots }) {
6976
const disabledItem = new Set(props.disabledItem || [])
@@ -144,7 +151,7 @@ export const TextBaseDrawer = defineComponent({
144151
/>
145152
</FormField>
146153

147-
{props.data.contentFormat !== 'lexical' && (
154+
{props.data.contentFormat !== 'lexical' ? (
148155
<ImageDetailSection
149156
text={props.data.text}
150157
images={props.data.images}
@@ -155,6 +162,11 @@ export const TextBaseDrawer = defineComponent({
155162
props.data.images = images
156163
}}
157164
/>
165+
) : (
166+
<LexicalImageDetailSection
167+
content={props.data.content || ''}
168+
editor={props.lexicalEditor}
169+
/>
158170
)}
159171

160172
{/* 附加字段 */}

src/components/editor/rich/RichEditor.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,6 @@ export const RichEditor = defineComponent({
197197
() => [
198198
props.theme,
199199
uiStore.isDark,
200-
props.initialValue,
201200
props.placeholder,
202201
props.variant,
203202
props.autoFocus,

src/components/editor/write-editor/RichWriteEditor.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { computed, defineComponent, ref, watch } from 'vue'
22
import type { ContentFormat } from '~/shared/types/base'
3-
import type { SerializedEditorState } from 'lexical'
3+
import type { LexicalEditor, SerializedEditorState } from 'lexical'
44
import type { PropType, VNode } from 'vue'
55
import type { WriteEditorVariant } from './types'
66

@@ -63,6 +63,9 @@ export const RichWriteEditor = defineComponent({
6363
onRichContentChange: {
6464
type: Function as PropType<(value: SerializedEditorState) => void>,
6565
},
66+
onRichEditorReady: {
67+
type: Function as PropType<(editor: LexicalEditor | null) => void>,
68+
},
6669
onTextChange: {
6770
type: Function as PropType<(value: string) => void>,
6871
},
@@ -87,6 +90,7 @@ export const RichWriteEditor = defineComponent({
8790
(val) => {
8891
const json = val ? JSON.stringify(val) : ''
8992
if (json !== lastEmittedJson) {
93+
lastEmittedJson = json
9094
editorKey.value++
9195
}
9296
},
@@ -118,6 +122,9 @@ export const RichWriteEditor = defineComponent({
118122
lastEmittedJson = JSON.stringify(value)
119123
props.onRichContentChange?.(value)
120124
}}
125+
onEditorReady={(editor: LexicalEditor | null) => {
126+
props.onRichEditorReady?.(editor)
127+
}}
121128
onTextChange={(text: string) => {
122129
currentText.value = text
123130
props.onTextChange?.(text)

src/components/editor/write-editor/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
import { defineComponent } from 'vue'
66
import type { ContentFormat } from '~/shared/types/base'
7-
import type { SerializedEditorState } from 'lexical'
7+
import type { LexicalEditor, SerializedEditorState } from 'lexical'
88
import type { PropType, VNode } from 'vue'
99
import type { WriteEditorVariant } from './types'
1010

@@ -57,6 +57,9 @@ export const WriteEditor = defineComponent({
5757
onRichContentChange: {
5858
type: Function as PropType<(value: SerializedEditorState) => void>,
5959
},
60+
onRichEditorReady: {
61+
type: Function as PropType<(editor: LexicalEditor | null) => void>,
62+
},
6063
variant: {
6164
type: String as PropType<WriteEditorVariant>,
6265
default: 'post',
@@ -77,6 +80,7 @@ export const WriteEditor = defineComponent({
7780
onContentFormatChange={props.onContentFormatChange}
7881
richContent={props.richContent}
7982
onRichContentChange={props.onRichContentChange}
83+
onRichEditorReady={props.onRichEditorReady}
8084
onTextChange={props.onChange}
8185
saveConfirmFn={props.saveConfirmFn}
8286
variant={props.variant}

src/utils/image.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { encode } from 'blurhash'
44

5+
import { computeImageMeta } from '@haklex/rich-editor/renderers'
6+
57
export function getDominantColor(imageObject: HTMLImageElement) {
68
const canvas = document.createElement('canvas'),
79
ctx = canvas.getContext('2d')!
@@ -113,3 +115,26 @@ export const encodeImageToBlurhashWebgl = (image: HTMLImageElement) => {
113115

114116
return encode(resizedImageData.data, 32, 32, 4, 4)
115117
}
118+
119+
export const encodeImageToThumbhash = async (image: HTMLImageElement) => {
120+
const canvas = document.createElement('canvas')
121+
canvas.width = image.naturalWidth
122+
canvas.height = image.naturalHeight
123+
124+
const context = canvas.getContext('2d')!
125+
context.drawImage(image, 0, 0)
126+
127+
const blob = await new Promise<Blob>((resolve, reject) => {
128+
canvas.toBlob((nextBlob) => {
129+
if (nextBlob) {
130+
resolve(nextBlob)
131+
return
132+
}
133+
reject(new Error('Failed to encode image thumbhash'))
134+
})
135+
})
136+
137+
const file = new File([blob], 'image.png', { type: blob.type || 'image/png' })
138+
const { thumbhash } = await computeImageMeta(file)
139+
return thumbhash
140+
}

0 commit comments

Comments
 (0)