返回 oh-my-ppt
InsertToolRow.tsx
1 import { useState } from 'react'
2 import {
3 ChartColumn,
4 ChartLine,
5 ChartPie,
6 ChevronDown,
7 CircleStop,
8 Copy,
9 Donut,
10 ImagePlus,
11 Layers,
12 Loader2,
13 Radar,
14 Shapes,
15 Sigma,
16 Smile,
17 Sparkles,
18 Trash2,
19 Type,
20 Video,
21 WandSparkles
22 } from 'lucide-react'
23 import { ART_TEXT_TEMPLATES } from '@renderer/lib/artTextTemplates'
24 import {
25 ICON_LIST,
26 ICON_VIEWBOX,
27 SHAPE_LIST,
28 serializeIconInner,
29 type InsertShapeType
30 } from '@renderer/components/session-detail/workspace/insert-shapes'
31 import {
32 CHART_TYPE_LIST,
33 type InsertChartType
34 } from '@renderer/components/session-detail/workspace/insert-charts'
35 import { useT, type I18nKey } from '@renderer/i18n'
36 import { ipc } from '@renderer/lib/ipc'
37 import { useModelAction } from '@renderer/hooks/useModelAction'
38 import {
39 useEditSessionStore,
40 useGenerateStore,
41 useSessionDetailRuntimeStore,
42 useSessionDetailUiStore,
43 useSessionStore,
44 useToastStore
45 } from '@renderer/store'
46 import {
47 AlertDialog,
48 AlertDialogAction,
49 AlertDialogCancel,
50 AlertDialogContent,
51 AlertDialogDescription,
52 AlertDialogTitle
53 } from '../../../../ui/AlertDialog'
54 import {
55 DropdownMenu,
56 DropdownMenuContent,
57 DropdownMenuItem,
58 DropdownMenuTrigger
59 } from '../../../../ui/DropdownMenu'
60 import { Popover, PopoverContent, PopoverTrigger } from '../../../../ui/Popover'
61 import { Tooltip, TooltipContent, TooltipTrigger } from '../../../../ui/Tooltip'
62 import type { InsertAssetType } from '../types'
63 import { ToolRowShell } from './ToolRowShell'
64 import type { ToolRowProps } from './types'
65
66 const toolButtonClass =
67 'group inline-flex h-7 min-w-[78px] shrink-0 items-center justify-center gap-1 rounded-full bg-[#fffaf1]/92 px-2.5 text-[10px] font-bold leading-none text-[#2f3b28] shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_4px_10px_rgba(47,59,40,0.09)] transition-colors hover:bg-white hover:text-[#1f2a1b] disabled:pointer-events-none disabled:opacity-40'
68 const iconWrapClass =
69 'inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-[#d4e4c1]/78 text-[#3e4a32] group-hover:bg-[#8fbc8f]/42'
70 const iconClass = 'h-2.5 w-2.5'
71 const selectedToolButtonClass =
72 'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full transition-colors disabled:pointer-events-none disabled:opacity-45'
73
74 // Keep these compact previews visually aligned with the full-size templates in artTextTemplates.ts.
75 const artTextPreviewStyles = `
76 .ppt-art-preview-card {
77 min-height: 90px;
78 border-radius: 8px;
79 border: 1px solid rgba(216, 204, 181, 0.78);
80 background: linear-gradient(145deg, rgba(31, 38, 29, 0.96), rgba(18, 24, 22, 0.94));
81 color: #fff;
82 padding: 9px;
83 text-align: left;
84 transition: border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
85 }
86 .ppt-art-preview-card:hover,
87 .ppt-art-preview-card:focus-visible {
88 border-color: rgba(143, 188, 143, 0.95);
89 box-shadow: 0 10px 24px rgba(47, 59, 40, 0.18);
90 transform: translateY(-1px);
91 outline: none;
92 }
93 .ppt-art-preview-label {
94 color: rgba(255, 250, 241, 0.76);
95 font-size: 10px;
96 font-weight: 700;
97 line-height: 1;
98 }
99 .ppt-art-preview-stage {
100 display: flex;
101 min-height: 52px;
102 align-items: center;
103 justify-content: center;
104 overflow: hidden;
105 padding-top: 7px;
106 text-align: center;
107 }
108 .ppt-art-preview-text {
109 display: inline-block;
110 max-width: 100%;
111 font-size: 18px;
112 font-weight: 850;
113 line-height: 1.12;
114 letter-spacing: 0;
115 }
116 .ppt-art-preview-gradient-shine .ppt-art-preview-text {
117 background: linear-gradient(135deg, #ffd89b, #c7e9fb, #f6d5f7, #a0e9ff);
118 background-size: 300% 300%;
119 background-clip: text;
120 -webkit-background-clip: text;
121 color: transparent;
122 animation: pptArtPreviewFlowGradient 6s ease infinite;
123 }
124 .ppt-art-preview-neon-glow .ppt-art-preview-text {
125 color: #0ff;
126 text-shadow: 0 0 5px #0ff, 0 0 12px #0ff, 0 0 22px #00aaff;
127 animation: pptArtPreviewNeonPulse 1.2s ease-in-out infinite alternate;
128 }
129 .ppt-art-preview-shadow-3d .ppt-art-preview-text {
130 color: #f3b33d;
131 text-shadow: 1px 1px 0 #7a4c1a, 2px 2px 0 #6b4215, 3px 3px 0 #5a3710, 4px 4px 0 #482d0c;
132 }
133 .ppt-art-preview-wave-text .ppt-art-preview-text {
134 display: flex;
135 gap: 2px;
136 }
137 .ppt-art-preview-wave-char {
138 display: inline-block;
139 animation: pptArtPreviewWaveFloat 1.4s infinite ease-in-out;
140 }
141 .ppt-art-preview-stroke-text .ppt-art-preview-text {
142 color: transparent;
143 -webkit-text-stroke: 1px #00e0ff;
144 text-stroke: 1px #00e0ff;
145 text-shadow: 0 0 10px rgba(0, 255, 255, 0.36);
146 }
147 .ppt-art-preview-typewriter .ppt-art-preview-text {
148 overflow: hidden;
149 white-space: nowrap;
150 border-right: 2px solid #f0f;
151 animation: pptArtPreviewTyping 3s steps(20, end) infinite, pptArtPreviewBlink 0.75s step-end infinite;
152 }
153 .ppt-art-preview-glitch .ppt-art-preview-text {
154 position: relative;
155 color: #fff;
156 text-shadow: 0.05em 0 0 rgba(255, 0, 0, 0.75), -0.05em -0.025em 0 rgba(0, 255, 0, 0.75);
157 animation: pptArtPreviewGlitchShake 0.3s infinite;
158 }
159 .ppt-art-preview-glitch .ppt-art-preview-text::before,
160 .ppt-art-preview-glitch .ppt-art-preview-text::after {
161 content: attr(data-text);
162 position: absolute;
163 inset: 0;
164 }
165 .ppt-art-preview-glitch .ppt-art-preview-text::before {
166 left: 1px;
167 text-shadow: -1px 0 red;
168 clip: rect(8px, 200px, 20px, 0);
169 }
170 .ppt-art-preview-glitch .ppt-art-preview-text::after {
171 left: -1px;
172 text-shadow: -1px 0 blue;
173 clip: rect(24px, 200px, 46px, 0);
174 }
175 .ppt-art-preview-fire-text .ppt-art-preview-text {
176 background-image: linear-gradient(0deg, #ff4d00, #ff9900, #ffcc00);
177 background-clip: text;
178 -webkit-background-clip: text;
179 color: transparent;
180 text-shadow: 0 0 10px #ff7b00, 0 0 16px #ff4400;
181 animation: pptArtPreviewFireFlicker 0.2s infinite alternate;
182 }
183 .ppt-art-preview-glass-text .ppt-art-preview-text {
184 border: 1px solid rgba(255, 255, 255, 0.25);
185 border-radius: 999px;
186 background: rgba(255, 255, 255, 0.12);
187 box-shadow: 0 4px 18px rgba(0, 0, 0, 0.2);
188 color: #fff3cf;
189 padding: 7px 12px;
190 }
191 .ppt-art-preview-rotate-3d .ppt-art-preview-stage {
192 perspective: 480px;
193 }
194 .ppt-art-preview-rotate-3d .ppt-art-preview-text {
195 color: #fe9c8f;
196 transform-style: preserve-3d;
197 animation: pptArtPreviewRotateText 5s infinite linear;
198 }
199 .ppt-art-preview-rainbow .ppt-art-preview-text {
200 animation: pptArtPreviewRainbowColor 2s linear infinite;
201 }
202 .ppt-art-preview-liquid .ppt-art-preview-text {
203 background: linear-gradient(45deg, #ff6bcb, #ffb86c, #2ed8ff, #ff6bcb);
204 background-size: 400% 400%;
205 background-clip: text;
206 -webkit-background-clip: text;
207 color: transparent;
208 animation: pptArtPreviewLiquidMove 5s ease infinite;
209 }
210 .ppt-art-preview-bounce-text .ppt-art-preview-text {
211 animation: pptArtPreviewTextBounce 1.2s ease infinite;
212 }
213 .ppt-art-preview-float-shadow .ppt-art-preview-text {
214 text-shadow: 0 8px 14px rgba(0, 0, 0, 0.35);
215 animation: pptArtPreviewFloatUp 2.6s infinite alternate;
216 }
217 .ppt-art-preview-metal-text .ppt-art-preview-text {
218 background: linear-gradient(135deg, #e8e8e8 0%, #a0a0b0 40%, #f5f5ff 50%, #b0b0c0 80%, #dfdfdf 100%);
219 background-clip: text;
220 -webkit-background-clip: text;
221 color: transparent;
222 filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.34)) drop-shadow(0 3px 7px rgba(0, 0, 0, 0.18));
223 }
224 @keyframes pptArtPreviewFlowGradient {
225 0% { background-position: 0% 50%; }
226 50% { background-position: 100% 50%; }
227 100% { background-position: 0% 50%; }
228 }
229 @keyframes pptArtPreviewNeonPulse {
230 0% { text-shadow: 0 0 2px #0ff, 0 0 6px #0aa; opacity: 0.9; }
231 100% { text-shadow: 0 0 8px #0ff, 0 0 22px #0cf; opacity: 1; }
232 }
233 @keyframes pptArtPreviewWaveFloat {
234 0%, 100% { transform: translateY(0); }
235 50% { transform: translateY(-6px); }
236 }
237 @keyframes pptArtPreviewTyping {
238 0% { width: 0; }
239 40% { width: 100%; }
240 80% { width: 100%; }
241 100% { width: 0; }
242 }
243 @keyframes pptArtPreviewBlink {
244 0%, 100% { border-color: #f0f; }
245 50% { border-color: transparent; }
246 }
247 @keyframes pptArtPreviewGlitchShake {
248 0% { transform: translate(0); }
249 40% { transform: translate(-1px, -1px); }
250 80% { transform: translate(1px, -1px); }
251 100% { transform: translate(0); }
252 }
253 @keyframes pptArtPreviewFireFlicker {
254 0% { opacity: 0.95; text-shadow: 0 0 8px #ff5500; }
255 100% { opacity: 1; text-shadow: 0 0 15px #ffaa33, 0 0 5px #ff3300; }
256 }
257 @keyframes pptArtPreviewRotateText {
258 0% { transform: rotateY(0deg); }
259 100% { transform: rotateY(360deg); }
260 }
261 @keyframes pptArtPreviewRainbowColor {
262 0% { color: #ff0000; }
263 17% { color: #ff9900; }
264 33% { color: #ffff00; }
265 50% { color: #00ff00; }
266 67% { color: #0099ff; }
267 83% { color: #8b00ff; }
268 100% { color: #ff0000; }
269 }
270 @keyframes pptArtPreviewLiquidMove {
271 0% { background-position: 0% 50%; }
272 50% { background-position: 100% 50%; }
273 100% { background-position: 0% 50%; }
274 }
275 @keyframes pptArtPreviewTextBounce {
276 0%, 100% { transform: scale(1); letter-spacing: 0; }
277 50% { transform: scale(1.05); letter-spacing: 2px; color: #fff2a0; }
278 }
279 @keyframes pptArtPreviewFloatUp {
280 0% { transform: translateY(0); text-shadow: 0 5px 12px rgba(0, 0, 0, 0.24); }
281 100% { transform: translateY(-5px); text-shadow: 0 18px 22px rgba(0, 0, 0, 0.45); }
282 }
283 `
284
285 export function InsertToolRow({ disabled }: ToolRowProps): React.JSX.Element {
286 const t = useT()
287 const { ensureModelActive } = useModelAction()
288 const [artTextOpen, setArtTextOpen] = useState(false)
289 const [shapeOpen, setShapeOpen] = useState(false)
290 const [iconOpen, setIconOpen] = useState(false)
291 const [chartOpen, setChartOpen] = useState(false)
292 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
293 const actions = useSessionDetailRuntimeStore((state) => state.workspaceRibbonActions)
294 const currentSession = useSessionStore((state) => state.currentSession)
295 const selectedPageEntityId = useSessionDetailUiStore((state) => state.selectedPageId)
296 const currentPages = useGenerateStore((state) => state.currentPages)
297 const selectedPage = currentPages.find((page) => page.id === selectedPageEntityId) || null
298 const pageBeautifyJob = useGenerateStore((state) =>
299 currentSession ? state.pageBeautifyJobs[currentSession.id] || null : null
300 )
301 const toastError = useToastStore((state) => state.error)
302 const selection = useEditSessionStore((state) => state.selection)
303 const isApplyingSyncElement = useEditSessionStore((state) => state.isApplyingSyncElement)
304 const isPageBeautifying = Boolean(pageBeautifyJob)
305
306 const handlePageBeautify = async (): Promise<void> => {
307 if (!currentSession) return
308
309 if (pageBeautifyJob) {
310 useGenerateStore.getState().updatePageBeautify(currentSession.id, { status: 'cancelling' })
311 try {
312 const result = await ipc.cancelPageBeautify(currentSession.id)
313 // The backend legitimately returns { success: false } when the local job has already
314 // ended or never existed (crash recovery, race with terminal event, etc.). In that
315 // case no terminal chunk will ever arrive, so the editor would stay locked in
316 // `cancelling` forever. Reconcile against the authoritative backend state: if no
317 // page-beautify run is active, clear the optimistic job and unlock the editor.
318 if (!result.success) {
319 try {
320 const snapshot = await ipc.getPageBeautifyState(currentSession.id)
321 if (snapshot.hasActiveRun && snapshot.status !== 'cancelled') {
322 useGenerateStore.getState().updatePageBeautify(currentSession.id, {
323 status: snapshot.status === 'queued' ? 'queued' : 'running'
324 })
325 } else {
326 useGenerateStore.getState().finishPageBeautify(currentSession.id)
327 }
328 } catch {
329 // Reconciliation itself failed. The cancel result already confirmed no cancellable
330 // page-beautify job exists, so restoring `running` would lock the editor with no
331 // terminal event to clear it. Finish the optimistic job to unlock.
332 useGenerateStore.getState().finishPageBeautify(currentSession.id)
333 }
334 }
335 } catch (error) {
336 useGenerateStore.getState().updatePageBeautify(currentSession.id, { status: 'running' })
337 toastError(error instanceof Error ? error.message : t('generating.failed'))
338 }
339 return
340 }
341
342 if (!selectedPage?.pageId) {
343 toastError(t('sessionDetail.pageBeautifyNoPage'))
344 return
345 }
346
347 const saveResult = await useEditSessionStore.getState().save()
348 if (saveResult.error) {
349 toastError(saveResult.error)
350 return
351 }
352
353 const modelConfigId = await ensureModelActive()
354 if (!modelConfigId) return
355
356 // The visible preview already has the page's real fonts, runtime CSS, and chart
357 // layout applied. Measure it once at click time so non-multimodal models receive
358 // concrete overflow and boundary facts without another render pass.
359 const layoutAudit = await useEditSessionStore.getState().iframeHandle?.readPageLayoutAudit()
360
361 useGenerateStore.getState().startPageBeautify(currentSession.id, {
362 pageId: selectedPage.pageId,
363 pageNumber: selectedPage.pageNumber
364 })
365 try {
366 const result = await ipc.startPageBeautify({
367 sessionId: currentSession.id,
368 selectedPageId: selectedPage.pageId,
369 modelConfigId,
370 layoutAudit: layoutAudit || undefined
371 })
372 if (result.alreadyRunning) {
373 // `alreadyRunning` is reported by the session-wide coordinator and can refer to a
374 // page-edit, deck-edit, style-switch, or generation run — NOT necessarily this
375 // beautify attempt. Attaching that foreign runId to pageBeautifyJobs would lock the
376 // editor: the foreign run's terminal event is handled by its own branch and would
377 // never clear the beautify job. Hydrate from the authoritative page-beautify state
378 // instead, and only keep the optimistic job if a page-beautify run is truly active.
379 const snapshot = await ipc.getPageBeautifyState(currentSession.id)
380 if (snapshot.hasActiveRun && snapshot.status !== 'cancelled') {
381 useGenerateStore.getState().updatePageBeautify(currentSession.id, {
382 runId: snapshot.runId || result.runId,
383 status: snapshot.status === 'queued' ? 'queued' : 'running',
384 progress: snapshot.progress ?? 0
385 })
386 } else {
387 useGenerateStore.getState().finishPageBeautify(currentSession.id)
388 toastError(t('sessionDetail.pageBeautifyBusy'))
389 }
390 } else {
391 const currentJob = useGenerateStore.getState().pageBeautifyJobs[currentSession.id]
392 if (currentJob && result.runId) {
393 useGenerateStore.getState().updatePageBeautify(currentSession.id, {
394 runId: result.runId,
395 status: currentJob.status === 'cancelling' ? 'cancelling' : 'running'
396 })
397 }
398 }
399 } catch (error) {
400 useGenerateStore.getState().finishPageBeautify(currentSession.id)
401 toastError(error instanceof Error ? error.message : t('sessionDetail.pageBeautifyFailed'))
402 }
403 }
404
405 const renderSelectedElementTools = (): React.JSX.Element | null => {
406 if (!selection) return null
407 return (
408 <div className="absolute right-2 top-1/2 z-10 flex -translate-y-1/2 shrink-0 items-center gap-0.5 rounded-full border border-[#ead29a]/70 bg-[#fff4d8]/92 px-0.5 py-0.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.62),0_4px_12px_rgba(167,116,34,0.1)] backdrop-blur">
409 <Tooltip>
410 <TooltipTrigger asChild>
411 <button
412 type="button"
413 className={`${selectedToolButtonClass} bg-[#d8942f] text-white shadow-[0_4px_10px_rgba(168,105,23,0.2)] hover:bg-[#bd7621]`}
414 onClick={() => actions?.onApplySelectedToAllPages()}
415 disabled={disabled || isApplyingSyncElement}
416 aria-label={t('sessionDetail.applyElementToAllPages')}
417 >
418 {isApplyingSyncElement ? (
419 <Loader2 className="h-3 w-3 animate-spin" />
420 ) : (
421 <Layers className="h-3 w-3" />
422 )}
423 </button>
424 </TooltipTrigger>
425 <TooltipContent side="bottom">
426 {isApplyingSyncElement
427 ? t('sessionDetail.syncElementApplying')
428 : t('sessionDetail.applyElementToAllPages')}
429 </TooltipContent>
430 </Tooltip>
431 <Tooltip>
432 <TooltipTrigger asChild>
433 <button
434 type="button"
435 className={`${selectedToolButtonClass} text-[#6b765c] hover:bg-[#f9e7bd] hover:text-[#3e4a32]`}
436 onClick={() => actions?.onCopySelectedElement()}
437 disabled={disabled || isApplyingSyncElement}
438 aria-label={t('sessionDetail.copyElement')}
439 >
440 <Copy className="h-3 w-3" />
441 </button>
442 </TooltipTrigger>
443 <TooltipContent side="bottom">{t('sessionDetail.copyElement')}</TooltipContent>
444 </Tooltip>
445 <Tooltip>
446 <TooltipTrigger asChild>
447 <button
448 type="button"
449 className={`${selectedToolButtonClass} text-[#9a5a50] hover:bg-[#f8dfd9] hover:text-[#7d342c]`}
450 onClick={() => setDeleteConfirmOpen(true)}
451 disabled={disabled || isApplyingSyncElement}
452 aria-label={t('sessionDetail.deleteElement')}
453 >
454 <Trash2 className="h-3 w-3" />
455 </button>
456 </TooltipTrigger>
457 <TooltipContent side="bottom">{t('sessionDetail.deleteElement')}</TooltipContent>
458 </Tooltip>
459 <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
460 <AlertDialogContent>
461 <AlertDialogTitle>{t('sessionDetail.deleteElement')}</AlertDialogTitle>
462 <AlertDialogDescription>
463 {t('sessionDetail.deleteElementConfirm')}
464 </AlertDialogDescription>
465 <div className="flex justify-end gap-2">
466 <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
467 <AlertDialogAction
468 className="bg-[#c0392b] text-white hover:bg-[#a93226]"
469 onClick={() => actions?.onDeleteSelectedElement()}
470 >
471 {t('common.delete')}
472 </AlertDialogAction>
473 </div>
474 </AlertDialogContent>
475 </AlertDialog>
476 </div>
477 )
478 }
479
480 const renderMediaDropdown = (type: InsertAssetType): React.JSX.Element => {
481 const Icon = type === 'image' ? ImagePlus : Video
482 const label = type === 'image' ? t('editMode.addImage') : t('editMode.addVideo')
483 return (
484 <DropdownMenu>
485 <DropdownMenuTrigger asChild>
486 <button type="button" className={toolButtonClass} disabled={disabled}>
487 <span className={iconWrapClass}>
488 <Icon className={iconClass} />
489 </span>
490 {label}
491 <ChevronDown className="h-2.5 w-2.5 opacity-70" />
492 </button>
493 </DropdownMenuTrigger>
494 <DropdownMenuContent align="start" className="min-w-[10rem]">
495 <DropdownMenuItem onClick={() => actions?.onAddFromLibrary(type)}>
496 {t('editMode.fromLibrary')}
497 </DropdownMenuItem>
498 <DropdownMenuItem onClick={() => actions?.onAddFromLocal(type)}>
499 {t('editMode.fromLocal')}
500 </DropdownMenuItem>
501 </DropdownMenuContent>
502 </DropdownMenu>
503 )
504 }
505
506 const chartTypeIcons: Record<InsertChartType, typeof ChartColumn> = {
507 bar: ChartColumn,
508 line: ChartLine,
509 pie: ChartPie,
510 doughnut: Donut,
511 radar: Radar
512 }
513
514 const renderChartPopover = (): React.JSX.Element => (
515 <Popover open={chartOpen} onOpenChange={setChartOpen}>
516 <PopoverTrigger asChild>
517 <button type="button" className={toolButtonClass} disabled={disabled}>
518 <span className={iconWrapClass}>
519 <ChartColumn className={iconClass} />
520 </span>
521 {t('editMode.chart')}
522 <ChevronDown className="h-2.5 w-2.5 opacity-70" />
523 </button>
524 </PopoverTrigger>
525 <PopoverContent
526 align="start"
527 className="w-[280px] max-w-[calc(100vw-2rem)] border-[#d8ccb5]/85 bg-[#fff9ef] p-2"
528 >
529 <div className="grid grid-cols-3 gap-2">
530 {CHART_TYPE_LIST.map((item) => {
531 const Icon = chartTypeIcons[item.type]
532 return (
533 <button
534 type="button"
535 key={item.type}
536 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"
537 onClick={() => {
538 actions?.onAddChart(item.type)
539 setChartOpen(false)
540 }}
541 >
542 <Icon className="h-5 w-5 text-[#5d6b4d]" />
543 <span>{t(item.labelKey as I18nKey)}</span>
544 </button>
545 )
546 })}
547 </div>
548 </PopoverContent>
549 </Popover>
550 )
551
552 const renderArtTextPreview = (
553 template: (typeof ART_TEXT_TEMPLATES)[number]
554 ): React.JSX.Element => {
555 if (template.id === 'wave-text') {
556 return (
557 <span className="ppt-art-preview-text">
558 {Array.from(template.defaultText).map((char, index) => (
559 <span
560 key={`${char}-${index}`}
561 className="ppt-art-preview-wave-char"
562 style={{ animationDelay: `${index * 0.08}s` }}
563 >
564 {char === ' ' ? '\u00a0' : char}
565 </span>
566 ))}
567 </span>
568 )
569 }
570
571 return (
572 <span
573 className="ppt-art-preview-text"
574 data-text={template.id === 'glitch' ? template.defaultText : undefined}
575 >
576 {template.defaultText}
577 </span>
578 )
579 }
580
581 const renderArtTextDropdown = (): React.JSX.Element => (
582 <Popover open={artTextOpen} onOpenChange={setArtTextOpen}>
583 <PopoverTrigger asChild>
584 <button type="button" className={toolButtonClass} disabled={disabled}>
585 <span className={iconWrapClass}>
586 <Sparkles className={iconClass} />
587 </span>
588 {t('editMode.artText')}
589 <ChevronDown className="h-2.5 w-2.5 opacity-70" />
590 </button>
591 </PopoverTrigger>
592 <PopoverContent
593 align="start"
594 className="w-[560px] max-w-[calc(100vw-2rem)] border-[#d8ccb5]/85 bg-[#fff9ef] p-2"
595 >
596 <style>{artTextPreviewStyles}</style>
597 <div className="grid max-h-[420px] grid-cols-3 gap-2 overflow-y-auto pr-1">
598 {ART_TEXT_TEMPLATES.map((template) => (
599 <button
600 type="button"
601 key={template.id}
602 className={`ppt-art-preview-card ppt-art-preview-${template.id}`}
603 onClick={() => {
604 actions?.onAddArtText(template.id)
605 setArtTextOpen(false)
606 }}
607 >
608 <div className="ppt-art-preview-label">{template.label}</div>
609 <div className="ppt-art-preview-stage">{renderArtTextPreview(template)}</div>
610 </button>
611 ))}
612 </div>
613 </PopoverContent>
614 </Popover>
615 )
616
617 const shapeLabelKey: Record<InsertShapeType, I18nKey> = {
618 rect: 'editMode.shapeRect',
619 'rounded-rect': 'editMode.shapeRoundedRect',
620 ellipse: 'editMode.shapeEllipse',
621 triangle: 'editMode.shapeTriangle',
622 diamond: 'editMode.shapeDiamond',
623 pentagon: 'editMode.shapePentagon',
624 hexagon: 'editMode.shapeHexagon',
625 parallelogram: 'editMode.shapeParallelogram',
626 trapezoid: 'editMode.shapeTrapezoid',
627 'star-5': 'editMode.shapeStar',
628 line: 'editMode.shapeLine',
629 'arrow-right': 'editMode.shapeArrowRight',
630 'chevron-right': 'editMode.shapeChevron'
631 }
632
633 const renderShapePreviewInner = (type: InsertShapeType): string => {
634 const def = SHAPE_LIST.find((s) => s.type === type)
635 if (!def) return ''
636 return def.renderInner(def.defaultWidth, def.defaultHeight, {
637 fill: def.defaultFill,
638 stroke: def.defaultStroke,
639 strokeWidth: def.strokeWidth
640 })
641 }
642
643 const renderShapePopover = (): React.JSX.Element => (
644 <Popover open={shapeOpen} onOpenChange={setShapeOpen}>
645 <PopoverTrigger asChild>
646 <button type="button" className={toolButtonClass} disabled={disabled}>
647 <span className={iconWrapClass}>
648 <Shapes className={iconClass} />
649 </span>
650 {t('editMode.addShape')}
651 <ChevronDown className="h-2.5 w-2.5 opacity-70" />
652 </button>
653 </PopoverTrigger>
654 <PopoverContent
655 align="start"
656 className="w-[280px] max-w-[calc(100vw-2rem)] border-[#d8ccb5]/85 bg-[#fff9ef] p-2"
657 >
658 <div className="grid grid-cols-3 gap-2">
659 {SHAPE_LIST.map((def) => (
660 <button
661 type="button"
662 key={def.type}
663 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"
664 onClick={() => {
665 actions?.onAddShape(def.type)
666 setShapeOpen(false)
667 }}
668 >
669 <svg
670 viewBox={`0 0 ${def.defaultWidth} ${def.defaultHeight}`}
671 className="h-9 w-12"
672 preserveAspectRatio="xMidYMid meet"
673 dangerouslySetInnerHTML={{ __html: renderShapePreviewInner(def.type) }}
674 />
675 <span>{t(shapeLabelKey[def.type])}</span>
676 </button>
677 ))}
678 </div>
679 </PopoverContent>
680 </Popover>
681 )
682
683 const strokeIcons = ICON_LIST.filter((icon) => icon.variant !== 'badge')
684 const badgeIcons = ICON_LIST.filter((icon) => icon.variant === 'badge')
685
686 const renderIconGrid = (icons: typeof ICON_LIST): React.JSX.Element => (
687 <div className="grid grid-cols-6 gap-1.5">
688 {icons.map((icon) => {
689 const isBadge = icon.variant === 'badge'
690 return (
691 <button
692 type="button"
693 key={icon.id}
694 title={icon.label}
695 className="flex h-12 items-center justify-center rounded-lg border border-transparent text-[#3e4a32] transition-colors hover:border-[#8fbc8f] hover:bg-white"
696 onClick={() => {
697 actions?.onAddIcon(icon.id)
698 setIconOpen(false)
699 }}
700 >
701 <svg
702 viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
703 className={isBadge ? 'h-6 w-6' : 'h-5 w-5'}
704 fill="none"
705 stroke={isBadge ? 'none' : 'currentColor'}
706 strokeWidth={isBadge ? 0 : 2}
707 strokeLinecap="round"
708 strokeLinejoin="round"
709 dangerouslySetInnerHTML={{ __html: serializeIconInner(icon) }}
710 />
711 </button>
712 )
713 })}
714 </div>
715 )
716
717 const renderIconPopover = (): React.JSX.Element => (
718 <Popover open={iconOpen} onOpenChange={setIconOpen}>
719 <PopoverTrigger asChild>
720 <button type="button" className={toolButtonClass} disabled={disabled}>
721 <span className={iconWrapClass}>
722 <Smile className={iconClass} />
723 </span>
724 {t('editMode.addIcon')}
725 <ChevronDown className="h-2.5 w-2.5 opacity-70" />
726 </button>
727 </PopoverTrigger>
728 <PopoverContent
729 align="start"
730 className="w-[390px] max-w-[calc(100vw-2rem)] border-[#d8ccb5]/85 bg-[#fff9ef] p-2"
731 >
732 <div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
733 <div>
734 <div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wide text-[#7a875f]">
735 {t('editMode.iconSectionIcons')}
736 </div>
737 {renderIconGrid(strokeIcons)}
738 </div>
739 <div>
740 <div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wide text-[#7a875f]">
741 {t('editMode.iconSectionNumbers')}
742 </div>
743 {renderIconGrid(badgeIcons)}
744 </div>
745 </div>
746 </PopoverContent>
747 </Popover>
748 )
749
750 return (
751 <div className="relative">
752 {renderSelectedElementTools()}
753 <ToolRowShell>
754 <Tooltip>
755 <TooltipTrigger asChild>
756 <button
757 type="button"
758 className={toolButtonClass}
759 onClick={() => void handlePageBeautify()}
760 disabled={(disabled || !selectedPage?.pageId) && !isPageBeautifying}
761 aria-label={
762 isPageBeautifying
763 ? t('sessionDetail.cancelPageBeautify')
764 : t('sessionDetail.pageBeautify')
765 }
766 >
767 <span className={iconWrapClass}>
768 {isPageBeautifying ? (
769 <CircleStop className={iconClass} />
770 ) : (
771 <WandSparkles className={iconClass} />
772 )}
773 </span>
774 {isPageBeautifying
775 ? t('sessionDetail.pageBeautifying')
776 : t('sessionDetail.pageBeautify')}
777 </button>
778 </TooltipTrigger>
779 <TooltipContent side="bottom">
780 {isPageBeautifying
781 ? t('sessionDetail.cancelPageBeautify')
782 : t('sessionDetail.pageBeautifyTooltip')}
783 </TooltipContent>
784 </Tooltip>
785 <button
786 type="button"
787 className={toolButtonClass}
788 onClick={() => actions?.onAddText()}
789 disabled={disabled}
790 >
791 <span className={iconWrapClass}>
792 <Type className={iconClass} />
793 </span>
794 {t('editMode.addText')}
795 </button>
796 {renderArtTextDropdown()}
797 {renderShapePopover()}
798 {renderIconPopover()}
799 {renderMediaDropdown('image')}
800 {renderMediaDropdown('video')}
801 {renderChartPopover()}
802 <button
803 type="button"
804 className={toolButtonClass}
805 onClick={() => actions?.onAddFormula()}
806 disabled={disabled}
807 >
808 <span className={iconWrapClass}>
809 <Sigma className={iconClass} />
810 </span>
811 {t('editMode.formula')}
812 </button>
813 </ToolRowShell>
814 </div>
815 )
816 }
817
817 lines Plain Text