| 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 |