返回 oh-my-ppt
HtmlEditorInsertRibbon.tsx
根目录 / src / renderer / src / components / html-editor / HtmlEditorInsertRibbon.tsx
1 import { useEffect, useRef, useState, type ReactNode } from 'react'
2 import {
3 ChartColumn,
4 ChartLine,
5 ChartPie,
6 Donut,
7 ImagePlus,
8 Radar,
9 Shapes,
10 Smile,
11 Sparkles,
12 Type,
13 Video
14 } from 'lucide-react'
15 import { Popover, PopoverContent, PopoverTrigger } from '../ui/Popover'
16 import { useT, type I18nKey } from '../../i18n'
17 import {
18 ICON_LIST,
19 ICON_VIEWBOX,
20 serializeIconInner,
21 SHAPE_LIST,
22 type InsertShapeType
23 } from '../session-detail/workspace/insert-shapes'
24 import { CHART_TYPE_LIST, type InsertChartType } from '../session-detail/workspace/insert-charts'
25 import { ART_TEXT_TEMPLATES, type ArtTextTemplateId } from '../../lib/artTextTemplates'
26 import type { useHtmlElementInsertion } from './useHtmlElementInsertion'
27 import { HtmlEditorMediaInsertDialog } from './HtmlEditorMediaInsertDialog'
28
29 type Insertion = ReturnType<typeof useHtmlElementInsertion>
30
31 const triggerBtnClass =
32 'flex h-9 w-9 items-center justify-center rounded-md text-[#5d6b4d] transition-colors hover:bg-[#ece5d6] disabled:pointer-events-none disabled:opacity-40'
33
34 const shapeLabelKey: Record<InsertShapeType, I18nKey> = {
35 rect: 'editMode.shapeRect',
36 'rounded-rect': 'editMode.shapeRoundedRect',
37 ellipse: 'editMode.shapeEllipse',
38 triangle: 'editMode.shapeTriangle',
39 diamond: 'editMode.shapeDiamond',
40 pentagon: 'editMode.shapePentagon',
41 hexagon: 'editMode.shapeHexagon',
42 parallelogram: 'editMode.shapeParallelogram',
43 trapezoid: 'editMode.shapeTrapezoid',
44 'star-5': 'editMode.shapeStar',
45 line: 'editMode.shapeLine',
46 'arrow-right': 'editMode.shapeArrowRight',
47 'chevron-right': 'editMode.shapeChevron'
48 }
49
50 const chartIcons: Record<InsertChartType, typeof ChartColumn> = {
51 bar: ChartColumn,
52 line: ChartLine,
53 pie: ChartPie,
54 doughnut: Donut,
55 radar: Radar
56 }
57
58 /** hover 弹出的画廊按钮:鼠标移入打开、移出关闭(含跨入内容的容忍延时)。 */
59 function HoverInsertButton({
60 icon: Icon,
61 label,
62 disabled,
63 children
64 }: {
65 icon: typeof Type
66 label: string
67 disabled?: boolean
68 children: ReactNode
69 }): ReactNode {
70 const [open, setOpen] = useState(false)
71 const closeTimer = useRef<number | null>(null)
72 const cancelClose = (): void => {
73 if (closeTimer.current !== null) {
74 window.clearTimeout(closeTimer.current)
75 closeTimer.current = null
76 }
77 }
78 const openNow = (): void => {
79 if (disabled) return
80 cancelClose()
81 setOpen(true)
82 }
83 const scheduleClose = (): void => {
84 cancelClose()
85 closeTimer.current = window.setTimeout(() => setOpen(false), 120)
86 }
87 useEffect(() => () => cancelClose(), [])
88 return (
89 <Popover open={open} onOpenChange={setOpen}>
90 <PopoverTrigger asChild>
91 <div
92 role="button"
93 tabIndex={disabled ? -1 : 0}
94 title={label}
95 onMouseEnter={openNow}
96 onMouseLeave={scheduleClose}
97 className={triggerBtnClass}
98 >
99 <Icon className="h-4 w-4" />
100 </div>
101 </PopoverTrigger>
102 <PopoverContent
103 side="right"
104 align="start"
105 sideOffset={8}
106 onMouseEnter={cancelClose}
107 onMouseLeave={scheduleClose}
108 className="z-50 border-[#d8ccb5]/85 bg-[#fff9ef] p-2 shadow-lg"
109 >
110 {children}
111 </PopoverContent>
112 </Popover>
113 )
114 }
115
116 function DirectButton({
117 icon: Icon,
118 label,
119 disabled,
120 onClick
121 }: {
122 icon: typeof Type
123 label: string
124 disabled?: boolean
125 onClick: () => void
126 }): ReactNode {
127 return (
128 <button
129 type="button"
130 title={label}
131 disabled={disabled}
132 onClick={onClick}
133 className={triggerBtnClass}
134 >
135 <Icon className="h-4 w-4" />
136 </button>
137 )
138 }
139
140 export function HtmlEditorInsertRibbon({
141 insertion,
142 disabled
143 }: {
144 insertion: Insertion
145 disabled?: boolean
146 }): ReactNode {
147 const t = useT()
148 const [mediaType, setMediaType] = useState<'image' | 'video' | null>(null)
149 const strokeIcons = ICON_LIST.filter((icon) => icon.variant !== 'badge')
150 const badgeIcons = ICON_LIST.filter((icon) => icon.variant === 'badge')
151
152 const iconGrid = (icons: typeof ICON_LIST): ReactNode => (
153 <div className="grid grid-cols-6 gap-1.5">
154 {icons.map((icon) => {
155 const isBadge = icon.variant === 'badge'
156 return (
157 <button
158 key={icon.id}
159 type="button"
160 title={icon.label}
161 className="flex h-12 items-center justify-center rounded-lg border border-transparent text-[#3e4a32] transition-colors hover:border-[#8fbc8f] hover:bg-white"
162 onClick={() => void insertion.addIcon(icon.id)}
163 >
164 <svg
165 viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
166 className={isBadge ? 'h-6 w-6' : 'h-5 w-5'}
167 fill="none"
168 stroke={isBadge ? 'none' : 'currentColor'}
169 strokeWidth={isBadge ? 0 : 2}
170 strokeLinecap="round"
171 strokeLinejoin="round"
172 dangerouslySetInnerHTML={{ __html: serializeIconInner(icon) }}
173 />
174 </button>
175 )
176 })}
177 </div>
178 )
179
180 return (
181 <aside className="flex w-12 flex-col items-center gap-1 border-r border-[#e2dccf] bg-[#f5f1e8] py-3">
182 <DirectButton
183 icon={Type}
184 label={t('editMode.addText')}
185 disabled={disabled}
186 onClick={() => void insertion.addText()}
187 />
188
189 <DirectButton
190 icon={ImagePlus}
191 label={t('editMode.addImage')}
192 disabled={disabled}
193 onClick={() => setMediaType('image')}
194 />
195
196 <DirectButton
197 icon={Video}
198 label={t('editMode.addVideo')}
199 disabled={disabled}
200 onClick={() => setMediaType('video')}
201 />
202
203 <HoverInsertButton icon={Sparkles} label={t('editMode.artText')} disabled={disabled}>
204 <div className="grid max-h-[420px] w-[420px] grid-cols-3 gap-2 overflow-y-auto pr-1">
205 {ART_TEXT_TEMPLATES.map((tpl) => (
206 <button
207 key={tpl.id}
208 type="button"
209 className="flex min-h-[90px] flex-col items-center justify-center gap-2 rounded-lg border border-[#d8ccb5]/78 bg-[#1f261d] px-2 py-3 text-center transition-colors hover:border-[#8fbc8f]"
210 onClick={() => void insertion.addArtText(tpl.id as ArtTextTemplateId)}
211 >
212 <span className="text-[18px] font-extrabold text-white">{tpl.defaultText}</span>
213 <span className="text-[10px] text-white/70">{tpl.label}</span>
214 </button>
215 ))}
216 </div>
217 </HoverInsertButton>
218
219 <HoverInsertButton icon={Shapes} label={t('editMode.addShape')} disabled={disabled}>
220 <div className="grid w-[280px] grid-cols-3 gap-2">
221 {SHAPE_LIST.map((def) => (
222 <button
223 key={def.type}
224 type="button"
225 className="flex min-h-[68px] flex-col items-center justify-center gap-1 rounded-lg border border-[#d8ccb5]/70 bg-white/70 px-2 py-2 text-[10px] font-bold text-[#3e4a32] transition-colors hover:border-[#8fbc8f] hover:bg-white"
226 onClick={() => void insertion.addShape(def.type)}
227 >
228 <svg
229 viewBox={`0 0 ${def.defaultWidth} ${def.defaultHeight}`}
230 className="h-9 w-12"
231 preserveAspectRatio="xMidYMid meet"
232 dangerouslySetInnerHTML={{
233 __html: def.renderInner(def.defaultWidth, def.defaultHeight, {
234 fill: def.defaultFill,
235 stroke: def.defaultStroke,
236 strokeWidth: def.strokeWidth
237 })
238 }}
239 />
240 <span>{t(shapeLabelKey[def.type])}</span>
241 </button>
242 ))}
243 </div>
244 </HoverInsertButton>
245
246 <HoverInsertButton icon={Smile} label={t('editMode.addIcon')} disabled={disabled}>
247 <div className="max-h-[360px] w-[390px] space-y-2 overflow-y-auto pr-1">
248 <div>
249 <div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wide text-[#7a875f]">
250 {t('editMode.iconSectionIcons')}
251 </div>
252 {iconGrid(strokeIcons)}
253 </div>
254 <div>
255 <div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wide text-[#7a875f]">
256 {t('editMode.iconSectionNumbers')}
257 </div>
258 {iconGrid(badgeIcons)}
259 </div>
260 </div>
261 </HoverInsertButton>
262
263 <HoverInsertButton icon={ChartColumn} label={t('editMode.chart')} disabled={disabled}>
264 <div className="grid w-[280px] grid-cols-3 gap-2">
265 {CHART_TYPE_LIST.map((item) => {
266 const Icon = chartIcons[item.type]
267 return (
268 <button
269 key={item.type}
270 type="button"
271 className="flex min-h-[60px] flex-col items-center justify-center gap-1 rounded-lg border border-[#d8ccb5]/70 bg-white/70 px-2 py-2 text-[10px] font-bold text-[#3e4a32] transition-colors hover:border-[#8fbc8f] hover:bg-white"
272 onClick={() => void insertion.addChart(item.type)}
273 >
274 <Icon className="h-5 w-5 text-[#5d6b4d]" />
275 <span>{t(item.labelKey as I18nKey)}</span>
276 </button>
277 )
278 })}
279 </div>
280 </HoverInsertButton>
281 <HtmlEditorMediaInsertDialog
282 mediaType={mediaType}
283 insertion={insertion}
284 onClose={() => setMediaType(null)}
285 />
286 </aside>
287 )
288 }
289
289 lines Plain Text