| 1 | "use client"; |
| 2 | |
| 3 | import { PlateElement, type StyledPlateElementProps } from "platejs/react"; |
| 4 | import React from "react"; |
| 5 | |
| 6 | import { IconPicker } from "@/components/ui/icon-picker"; |
| 7 | import { useForceUpdateChildrenOnLengthChange } from "@/hooks/presentation/useForceUpdateChildrenOnLengthChange"; |
| 8 | import { cn } from "@/lib/utils"; |
| 9 | import { |
| 10 | type TCycleGroupElement, |
| 11 | type TCycleItemElement, |
| 12 | } from "../plugins/cycle-plugin"; |
| 13 | import { getPresentationAccentColor } from "./color-utils"; |
| 14 | import { PresentationIcon } from "./presentation-icon"; |
| 15 | |
| 16 | export type CycleColumnSide = "left" | "right" | "stacked"; |
| 17 | |
| 18 | const CYCLE_MIN_LAYOUT_WIDTH_PX = 720; |
| 19 | export const CYCLE_WHEEL_SIZE_PX = 288; |
| 20 | |
| 21 | export interface CycleContextValue { |
| 22 | isMultiColumn: boolean; |
| 23 | side: CycleColumnSide; |
| 24 | } |
| 25 | |
| 26 | export const CycleContext = React.createContext<CycleContextValue>({ |
| 27 | isMultiColumn: true, |
| 28 | side: "stacked", |
| 29 | }); |
| 30 | |
| 31 | type CycleFitStyle = React.CSSProperties; |
| 32 | |
| 33 | const CYCLE_SCALE_EPSILON = 0.001; |
| 34 | const CYCLE_SIZE_EPSILON_PX = 0.5; |
| 35 | |
| 36 | function getCycleLayoutWidth(availableWidth: number) { |
| 37 | return Math.max(availableWidth, CYCLE_MIN_LAYOUT_WIDTH_PX); |
| 38 | } |
| 39 | |
| 40 | function getCycleFitScale(availableWidth: number, layoutWidth: number) { |
| 41 | return availableWidth > 0 ? Math.min(1, availableWidth / layoutWidth) : 1; |
| 42 | } |
| 43 | |
| 44 | export function useCycleFitScale<TContainer extends HTMLElement>() { |
| 45 | const containerRef = React.useRef<TContainer | null>(null); |
| 46 | const layoutRef = React.useRef<HTMLDivElement | null>(null); |
| 47 | const [scale, setScale] = React.useState(1); |
| 48 | const [layoutWidth, setLayoutWidth] = React.useState( |
| 49 | CYCLE_MIN_LAYOUT_WIDTH_PX, |
| 50 | ); |
| 51 | const [layoutHeight, setLayoutHeight] = React.useState(CYCLE_WHEEL_SIZE_PX); |
| 52 | |
| 53 | React.useLayoutEffect(() => { |
| 54 | const container = containerRef.current; |
| 55 | if (!container) { |
| 56 | return; |
| 57 | } |
| 58 | |
| 59 | let frame = 0; |
| 60 | const updateScale = () => { |
| 61 | cancelAnimationFrame(frame); |
| 62 | frame = requestAnimationFrame(() => { |
| 63 | const availableWidth = container.clientWidth; |
| 64 | const nextLayoutWidth = getCycleLayoutWidth(availableWidth); |
| 65 | const nextScale = getCycleFitScale(availableWidth, nextLayoutWidth); |
| 66 | |
| 67 | setScale((currentScale) => { |
| 68 | return Math.abs(currentScale - nextScale) > CYCLE_SCALE_EPSILON |
| 69 | ? nextScale |
| 70 | : currentScale; |
| 71 | }); |
| 72 | |
| 73 | setLayoutWidth((currentWidth) => { |
| 74 | return Math.abs(currentWidth - nextLayoutWidth) > |
| 75 | CYCLE_SIZE_EPSILON_PX |
| 76 | ? nextLayoutWidth |
| 77 | : currentWidth; |
| 78 | }); |
| 79 | }); |
| 80 | }; |
| 81 | |
| 82 | updateScale(); |
| 83 | |
| 84 | if (typeof ResizeObserver === "undefined") { |
| 85 | return () => cancelAnimationFrame(frame); |
| 86 | } |
| 87 | |
| 88 | const observer = new ResizeObserver(updateScale); |
| 89 | observer.observe(container); |
| 90 | |
| 91 | return () => { |
| 92 | cancelAnimationFrame(frame); |
| 93 | observer.disconnect(); |
| 94 | }; |
| 95 | }, []); |
| 96 | |
| 97 | React.useLayoutEffect(() => { |
| 98 | const layout = layoutRef.current; |
| 99 | if (!layout) { |
| 100 | return; |
| 101 | } |
| 102 | |
| 103 | let frame = 0; |
| 104 | const updateHeight = () => { |
| 105 | cancelAnimationFrame(frame); |
| 106 | frame = requestAnimationFrame(() => { |
| 107 | const nextHeight = Math.max(layout.scrollHeight, CYCLE_WHEEL_SIZE_PX); |
| 108 | |
| 109 | setLayoutHeight((currentHeight) => |
| 110 | Math.abs(currentHeight - nextHeight) > CYCLE_SIZE_EPSILON_PX |
| 111 | ? nextHeight |
| 112 | : currentHeight, |
| 113 | ); |
| 114 | }); |
| 115 | }; |
| 116 | |
| 117 | updateHeight(); |
| 118 | |
| 119 | if (typeof ResizeObserver === "undefined") { |
| 120 | return () => cancelAnimationFrame(frame); |
| 121 | } |
| 122 | |
| 123 | const observer = new ResizeObserver(updateHeight); |
| 124 | observer.observe(layout); |
| 125 | |
| 126 | return () => { |
| 127 | cancelAnimationFrame(frame); |
| 128 | observer.disconnect(); |
| 129 | }; |
| 130 | }, []); |
| 131 | |
| 132 | const scaledWidth = layoutWidth * scale; |
| 133 | const scaledHeight = layoutHeight * scale; |
| 134 | |
| 135 | return { |
| 136 | containerRef, |
| 137 | layoutRef, |
| 138 | frameStyle: { |
| 139 | height: scaledHeight, |
| 140 | maxWidth: "100%", |
| 141 | overflow: "visible", |
| 142 | position: "relative", |
| 143 | width: scaledWidth, |
| 144 | } satisfies React.CSSProperties, |
| 145 | fitStyle: { |
| 146 | transform: `scale(${scale})`, |
| 147 | transformOrigin: "top left", |
| 148 | width: layoutWidth, |
| 149 | } satisfies CycleFitStyle, |
| 150 | }; |
| 151 | } |
| 152 | |
| 153 | function getCycleItemKey(item: unknown, fallbackIndex: number) { |
| 154 | if ( |
| 155 | typeof item === "object" && |
| 156 | item !== null && |
| 157 | "id" in item && |
| 158 | (typeof item.id === "string" || typeof item.id === "number") |
| 159 | ) { |
| 160 | return item.id; |
| 161 | } |
| 162 | |
| 163 | return `cycle-item-${fallbackIndex}`; |
| 164 | } |
| 165 | |
| 166 | const generateSegmentPath = ( |
| 167 | index: number, |
| 168 | totalSegments: number, |
| 169 | innerRadius: number, |
| 170 | outerRadius: number, |
| 171 | centerX: number, |
| 172 | centerY: number, |
| 173 | ) => { |
| 174 | const anglePerSegment = (2 * Math.PI) / totalSegments; |
| 175 | const startAngle = index * anglePerSegment; |
| 176 | const endAngle = (index + 1) * anglePerSegment; |
| 177 | |
| 178 | const outerStart = { |
| 179 | x: centerX + outerRadius * Math.cos(startAngle), |
| 180 | y: centerY + outerRadius * Math.sin(startAngle), |
| 181 | }; |
| 182 | const outerEnd = { |
| 183 | x: centerX + outerRadius * Math.cos(endAngle), |
| 184 | y: centerY + outerRadius * Math.sin(endAngle), |
| 185 | }; |
| 186 | const innerStart = { |
| 187 | x: centerX + innerRadius * Math.cos(startAngle), |
| 188 | y: centerY + innerRadius * Math.sin(startAngle), |
| 189 | }; |
| 190 | const innerEnd = { |
| 191 | x: centerX + innerRadius * Math.cos(endAngle), |
| 192 | y: centerY + innerRadius * Math.sin(endAngle), |
| 193 | }; |
| 194 | |
| 195 | const notchDepth = 7; |
| 196 | const notchPosition = 0.5; |
| 197 | |
| 198 | const endNotchRadius = |
| 199 | innerRadius + (outerRadius - innerRadius) * notchPosition; |
| 200 | const endNotchBase = { |
| 201 | x: centerX + endNotchRadius * Math.cos(endAngle), |
| 202 | y: centerY + endNotchRadius * Math.sin(endAngle), |
| 203 | }; |
| 204 | const endNotchTip = { |
| 205 | x: endNotchBase.x + notchDepth * Math.sin(endAngle), |
| 206 | y: endNotchBase.y - notchDepth * Math.cos(endAngle), |
| 207 | }; |
| 208 | |
| 209 | const startNotchRadius = |
| 210 | innerRadius + (outerRadius - innerRadius) * notchPosition; |
| 211 | const startNotchBase = { |
| 212 | x: centerX + startNotchRadius * Math.cos(startAngle), |
| 213 | y: centerY + startNotchRadius * Math.sin(startAngle), |
| 214 | }; |
| 215 | const startNotchTip = { |
| 216 | x: startNotchBase.x + notchDepth * Math.sin(startAngle), |
| 217 | y: startNotchBase.y - notchDepth * Math.cos(startAngle), |
| 218 | }; |
| 219 | |
| 220 | const largeArcFlag = anglePerSegment > Math.PI ? 1 : 0; |
| 221 | |
| 222 | let path = `M ${outerStart.x} ${outerStart.y}`; |
| 223 | path += ` A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEnd.x} ${outerEnd.y}`; |
| 224 | path += ` L ${endNotchTip.x} ${endNotchTip.y}`; |
| 225 | path += ` L ${innerEnd.x} ${innerEnd.y}`; |
| 226 | path += ` A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStart.x} ${innerStart.y}`; |
| 227 | path += ` L ${startNotchTip.x} ${startNotchTip.y}`; |
| 228 | path += ` L ${outerStart.x} ${outerStart.y}`; |
| 229 | path += " Z"; |
| 230 | return path; |
| 231 | }; |
| 232 | |
| 233 | function generateRoundedSlicePath( |
| 234 | index: number, |
| 235 | total: number, |
| 236 | cx: number, |
| 237 | cy: number, |
| 238 | outerR: number, |
| 239 | innerR: number, |
| 240 | cornerRadius = 3, |
| 241 | ): string { |
| 242 | const count = Math.max(total, 1); |
| 243 | const startAngle = (index / count) * 2 * Math.PI - Math.PI / 2; |
| 244 | const endAngle = ((index + 1) / count) * 2 * Math.PI - Math.PI / 2; |
| 245 | |
| 246 | // Clamp corner radius so it doesn't exceed segment dimensions |
| 247 | const maxArcCorner = |
| 248 | ((endAngle - startAngle) * Math.min(outerR, innerR)) / 2.5; |
| 249 | const maxRadialCorner = (outerR - innerR) / 2.5; |
| 250 | const cr = Math.min(cornerRadius, maxArcCorner, maxRadialCorner); |
| 251 | |
| 252 | const outerAngOff = cr / outerR; |
| 253 | const innerAngOff = cr / innerR; |
| 254 | |
| 255 | // Effective arc spans after rounding |
| 256 | const outerArcSpan = endAngle - startAngle - 2 * outerAngOff; |
| 257 | const innerArcSpan = endAngle - startAngle - 2 * innerAngOff; |
| 258 | const outerLargeArc = outerArcSpan > Math.PI ? 1 : 0; |
| 259 | const innerLargeArc = innerArcSpan > Math.PI ? 1 : 0; |
| 260 | |
| 261 | // Helper to format a point |
| 262 | const p = (x: number, y: number) => `${x.toFixed(3)} ${y.toFixed(3)}`; |
| 263 | |
| 264 | // Corner 1: outer-start (where start radial meets outer arc) |
| 265 | const c1 = p( |
| 266 | cx + outerR * Math.cos(startAngle), |
| 267 | cy + outerR * Math.sin(startAngle), |
| 268 | ); |
| 269 | const c1_radial = p( |
| 270 | cx + (outerR - cr) * Math.cos(startAngle), |
| 271 | cy + (outerR - cr) * Math.sin(startAngle), |
| 272 | ); |
| 273 | const c1_arc = p( |
| 274 | cx + outerR * Math.cos(startAngle + outerAngOff), |
| 275 | cy + outerR * Math.sin(startAngle + outerAngOff), |
| 276 | ); |
| 277 | |
| 278 | // Corner 2: outer-end (where outer arc meets end radial) |
| 279 | const c2 = p( |
| 280 | cx + outerR * Math.cos(endAngle), |
| 281 | cy + outerR * Math.sin(endAngle), |
| 282 | ); |
| 283 | const c2_arc = p( |
| 284 | cx + outerR * Math.cos(endAngle - outerAngOff), |
| 285 | cy + outerR * Math.sin(endAngle - outerAngOff), |
| 286 | ); |
| 287 | const c2_radial = p( |
| 288 | cx + (outerR - cr) * Math.cos(endAngle), |
| 289 | cy + (outerR - cr) * Math.sin(endAngle), |
| 290 | ); |
| 291 | |
| 292 | // Corner 3: inner-end (where end radial meets inner arc) |
| 293 | const c3 = p( |
| 294 | cx + innerR * Math.cos(endAngle), |
| 295 | cy + innerR * Math.sin(endAngle), |
| 296 | ); |
| 297 | const c3_radial = p( |
| 298 | cx + (innerR + cr) * Math.cos(endAngle), |
| 299 | cy + (innerR + cr) * Math.sin(endAngle), |
| 300 | ); |
| 301 | const c3_arc = p( |
| 302 | cx + innerR * Math.cos(endAngle - innerAngOff), |
| 303 | cy + innerR * Math.sin(endAngle - innerAngOff), |
| 304 | ); |
| 305 | |
| 306 | // Corner 4: inner-start (where inner arc meets start radial) |
| 307 | const c4 = p( |
| 308 | cx + innerR * Math.cos(startAngle), |
| 309 | cy + innerR * Math.sin(startAngle), |
| 310 | ); |
| 311 | const c4_arc = p( |
| 312 | cx + innerR * Math.cos(startAngle + innerAngOff), |
| 313 | cy + innerR * Math.sin(startAngle + innerAngOff), |
| 314 | ); |
| 315 | const c4_radial = p( |
| 316 | cx + (innerR + cr) * Math.cos(startAngle), |
| 317 | cy + (innerR + cr) * Math.sin(startAngle), |
| 318 | ); |
| 319 | |
| 320 | return [ |
| 321 | `M ${c4_radial}`, |
| 322 | `L ${c1_radial}`, |
| 323 | `Q ${c1} ${c1_arc}`, |
| 324 | `A ${outerR} ${outerR} 0 ${outerLargeArc} 1 ${c2_arc}`, |
| 325 | `Q ${c2} ${c2_radial}`, |
| 326 | `L ${c3_radial}`, |
| 327 | `Q ${c3} ${c3_arc}`, |
| 328 | `A ${innerR} ${innerR} 0 ${innerLargeArc} 0 ${c4_arc}`, |
| 329 | `Q ${c4} ${c4_radial}`, |
| 330 | "Z", |
| 331 | ].join(" "); |
| 332 | } |
| 333 | |
| 334 | function getSliceIconPosition( |
| 335 | index: number, |
| 336 | total: number, |
| 337 | outerR = 45, |
| 338 | innerR = 18, |
| 339 | ): { left: string; top: string } { |
| 340 | const count = Math.max(total, 1); |
| 341 | const startAngle = (index / count) * 2 * Math.PI - Math.PI / 2; |
| 342 | const endAngle = ((index + 1) / count) * 2 * Math.PI - Math.PI / 2; |
| 343 | const midAngle = (startAngle + endAngle) / 2; |
| 344 | const midR = (outerR + innerR) / 2; |
| 345 | |
| 346 | const x = 50 + midR * Math.cos(midAngle); |
| 347 | const y = 50 + midR * Math.sin(midAngle); |
| 348 | |
| 349 | return { left: `${x.toFixed(1)}%`, top: `${y.toFixed(1)}%` }; |
| 350 | } |
| 351 | |
| 352 | function getWheelIconPosition( |
| 353 | index: number, |
| 354 | total: number, |
| 355 | innerRadius = 15, |
| 356 | outerRadius = 48, |
| 357 | centerX = 50, |
| 358 | centerY = 50, |
| 359 | ): { left: string; top: string } { |
| 360 | const anglePerSegment = (2 * Math.PI) / Math.max(total, 1); |
| 361 | const midAngle = (index + 0.5) * anglePerSegment; |
| 362 | const midR = (outerRadius + innerRadius) / 2; |
| 363 | |
| 364 | const x = centerX + midR * Math.cos(midAngle); |
| 365 | const y = centerY + midR * Math.sin(midAngle); |
| 366 | |
| 367 | return { left: `${x.toFixed(1)}%`, top: `${y.toFixed(1)}%` }; |
| 368 | } |
| 369 | |
| 370 | const generateFlowerPetalPath = ( |
| 371 | index: number, |
| 372 | totalSegments: number, |
| 373 | cx: number, |
| 374 | cy: number, |
| 375 | radius: number, |
| 376 | ): string => { |
| 377 | const segments = Math.max(totalSegments, 1); |
| 378 | const anglePerSegment = (2 * Math.PI) / segments; |
| 379 | const midAngle = index * anglePerSegment + anglePerSegment / 2; |
| 380 | const radialRadius = radius * (segments <= 4 ? 0.49 : 0.44); |
| 381 | const tangentRadius = Math.max( |
| 382 | radius * 0.15, |
| 383 | Math.min(radius * 0.5, radius * Math.sin(anglePerSegment / 2) * 0.82), |
| 384 | ); |
| 385 | const centerDistance = Math.max(0, radius - radialRadius + radius * 0.02); |
| 386 | const petalCx = cx + centerDistance * Math.cos(midAngle); |
| 387 | const petalCy = cy + centerDistance * Math.sin(midAngle); |
| 388 | const rotationDeg = (midAngle * 180) / Math.PI; |
| 389 | const innerRadialRadius = radialRadius * 0.58; |
| 390 | const innerTangentRadius = tangentRadius * 0.58; |
| 391 | |
| 392 | return generateEllipseDonutPath( |
| 393 | petalCx, |
| 394 | petalCy, |
| 395 | radialRadius, |
| 396 | tangentRadius, |
| 397 | innerRadialRadius, |
| 398 | innerTangentRadius, |
| 399 | rotationDeg, |
| 400 | ); |
| 401 | }; |
| 402 | |
| 403 | function formatSvgNumber(value: number): string { |
| 404 | return Number(value.toFixed(3)).toString(); |
| 405 | } |
| 406 | |
| 407 | function getRotatedEllipsePoint( |
| 408 | cx: number, |
| 409 | cy: number, |
| 410 | rx: number, |
| 411 | ry: number, |
| 412 | rotationRad: number, |
| 413 | theta: number, |
| 414 | ): { x: number; y: number } { |
| 415 | const cosRotation = Math.cos(rotationRad); |
| 416 | const sinRotation = Math.sin(rotationRad); |
| 417 | const x = rx * Math.cos(theta); |
| 418 | const y = ry * Math.sin(theta); |
| 419 | |
| 420 | return { |
| 421 | x: cx + x * cosRotation - y * sinRotation, |
| 422 | y: cy + x * sinRotation + y * cosRotation, |
| 423 | }; |
| 424 | } |
| 425 | |
| 426 | function generateEllipseDonutPath( |
| 427 | cx: number, |
| 428 | cy: number, |
| 429 | outerRx: number, |
| 430 | outerRy: number, |
| 431 | innerRx: number, |
| 432 | innerRy: number, |
| 433 | rotationDeg = 0, |
| 434 | ): string { |
| 435 | const rotationRad = (rotationDeg * Math.PI) / 180; |
| 436 | const outerStart = getRotatedEllipsePoint( |
| 437 | cx, |
| 438 | cy, |
| 439 | outerRx, |
| 440 | outerRy, |
| 441 | rotationRad, |
| 442 | 0, |
| 443 | ); |
| 444 | const outerMid = getRotatedEllipsePoint( |
| 445 | cx, |
| 446 | cy, |
| 447 | outerRx, |
| 448 | outerRy, |
| 449 | rotationRad, |
| 450 | Math.PI, |
| 451 | ); |
| 452 | const innerStart = getRotatedEllipsePoint( |
| 453 | cx, |
| 454 | cy, |
| 455 | innerRx, |
| 456 | innerRy, |
| 457 | rotationRad, |
| 458 | 0, |
| 459 | ); |
| 460 | const innerMid = getRotatedEllipsePoint( |
| 461 | cx, |
| 462 | cy, |
| 463 | innerRx, |
| 464 | innerRy, |
| 465 | rotationRad, |
| 466 | Math.PI, |
| 467 | ); |
| 468 | const pathNumbers = [ |
| 469 | outerStart.x, |
| 470 | outerStart.y, |
| 471 | outerRx, |
| 472 | outerRy, |
| 473 | rotationDeg, |
| 474 | outerMid.x, |
| 475 | outerMid.y, |
| 476 | outerStart.x, |
| 477 | outerStart.y, |
| 478 | innerStart.x, |
| 479 | innerStart.y, |
| 480 | innerRx, |
| 481 | innerRy, |
| 482 | rotationDeg, |
| 483 | innerMid.x, |
| 484 | innerMid.y, |
| 485 | innerStart.x, |
| 486 | innerStart.y, |
| 487 | ].map(formatSvgNumber); |
| 488 | |
| 489 | return [ |
| 490 | `M ${pathNumbers[0]} ${pathNumbers[1]}`, |
| 491 | `A ${pathNumbers[2]} ${pathNumbers[3]} ${pathNumbers[4]} 1 1 ${pathNumbers[5]} ${pathNumbers[6]}`, |
| 492 | `A ${pathNumbers[2]} ${pathNumbers[3]} ${pathNumbers[4]} 1 1 ${pathNumbers[7]} ${pathNumbers[8]}`, |
| 493 | "Z", |
| 494 | `M ${pathNumbers[9]} ${pathNumbers[10]}`, |
| 495 | `A ${pathNumbers[11]} ${pathNumbers[12]} ${pathNumbers[13]} 1 1 ${pathNumbers[14]} ${pathNumbers[15]}`, |
| 496 | `A ${pathNumbers[11]} ${pathNumbers[12]} ${pathNumbers[13]} 1 1 ${pathNumbers[16]} ${pathNumbers[17]}`, |
| 497 | "Z", |
| 498 | ].join(" "); |
| 499 | } |
| 500 | |
| 501 | export type CycleVariant = "cycle" | "flower" | "ring" | "circle"; |
| 502 | |
| 503 | function getIconPositionForVariant( |
| 504 | variant: CycleVariant, |
| 505 | index: number, |
| 506 | total: number, |
| 507 | ): { left: string; top: string } { |
| 508 | const cx = 50; |
| 509 | const cy = 50; |
| 510 | const innerR = 15; |
| 511 | const outerR = 48; |
| 512 | |
| 513 | switch (variant) { |
| 514 | case "flower": { |
| 515 | const anglePerSegment = (2 * Math.PI) / Math.max(total, 1); |
| 516 | const midAngle = (index + 0.5) * anglePerSegment; |
| 517 | const r = outerR * 0.55; |
| 518 | return { |
| 519 | left: `${(cx + r * Math.cos(midAngle)).toFixed(1)}%`, |
| 520 | top: `${(cy + r * Math.sin(midAngle)).toFixed(1)}%`, |
| 521 | }; |
| 522 | } |
| 523 | case "ring": { |
| 524 | const ringR = (innerR + outerR) / 2; |
| 525 | const anglePerSegment = (2 * Math.PI) / Math.max(total, 1); |
| 526 | const midAngle = (index + 0.5) * anglePerSegment; |
| 527 | return { |
| 528 | left: `${(cx + ringR * Math.cos(midAngle)).toFixed(1)}%`, |
| 529 | top: `${(cy + ringR * Math.sin(midAngle)).toFixed(1)}%`, |
| 530 | }; |
| 531 | } |
| 532 | case "circle": |
| 533 | return getSliceIconPosition(index, total); |
| 534 | default: |
| 535 | return getWheelIconPosition(index, total); |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | function DynamicWheelSVG({ |
| 540 | segments, |
| 541 | variant = "cycle", |
| 542 | }: { |
| 543 | segments: number; |
| 544 | variant?: CycleVariant; |
| 545 | }) { |
| 546 | const centerX = 50; |
| 547 | const centerY = 50; |
| 548 | const innerRadius = 15; |
| 549 | const outerRadius = 48; |
| 550 | const ringRadius = (innerRadius + outerRadius) / 2; |
| 551 | const flowerHubOuterRadius = 22; |
| 552 | const flowerHubInnerRadius = 8.5; |
| 553 | |
| 554 | return ( |
| 555 | <svg |
| 556 | xmlns="http://www.w3.org/2000/svg" |
| 557 | viewBox="0 0 100 100" |
| 558 | style={{ |
| 559 | fill: "var(--presentation-smart-layout, var(--presentation-primary))", |
| 560 | color: "var(--presentation-smart-layout, var(--presentation-primary))", |
| 561 | }} |
| 562 | > |
| 563 | {variant === "ring" ? ( |
| 564 | <> |
| 565 | <circle |
| 566 | cx={centerX} |
| 567 | cy={centerY} |
| 568 | r={ringRadius} |
| 569 | fill="none" |
| 570 | stroke="currentColor" |
| 571 | strokeWidth="5" |
| 572 | /> |
| 573 | {Array.from({ length: segments }).map((_, idx) => { |
| 574 | const angle = (2 * Math.PI * (idx + 0.5)) / segments; |
| 575 | return ( |
| 576 | <circle |
| 577 | key={idx} |
| 578 | cx={centerX + ringRadius * Math.cos(angle)} |
| 579 | cy={centerY + ringRadius * Math.sin(angle)} |
| 580 | r={7} |
| 581 | fill="currentColor" |
| 582 | /> |
| 583 | ); |
| 584 | })} |
| 585 | </> |
| 586 | ) : variant === "flower" ? ( |
| 587 | <> |
| 588 | {Array.from({ length: segments }).map((_, idx) => ( |
| 589 | <path |
| 590 | key={idx} |
| 591 | d={generateFlowerPetalPath( |
| 592 | idx, |
| 593 | segments, |
| 594 | centerX, |
| 595 | centerY, |
| 596 | outerRadius, |
| 597 | )} |
| 598 | fill="currentColor" |
| 599 | fillRule="evenodd" |
| 600 | clipRule="evenodd" |
| 601 | /> |
| 602 | ))} |
| 603 | <path |
| 604 | d={generateEllipseDonutPath( |
| 605 | centerX, |
| 606 | centerY, |
| 607 | flowerHubOuterRadius, |
| 608 | flowerHubOuterRadius, |
| 609 | flowerHubInnerRadius, |
| 610 | flowerHubInnerRadius, |
| 611 | )} |
| 612 | fill="currentColor" |
| 613 | fillRule="evenodd" |
| 614 | clipRule="evenodd" |
| 615 | /> |
| 616 | </> |
| 617 | ) : variant === "circle" ? ( |
| 618 | Array.from({ length: segments }).map((_, idx) => ( |
| 619 | <path |
| 620 | key={idx} |
| 621 | d={generateRoundedSlicePath( |
| 622 | idx, |
| 623 | segments, |
| 624 | centerX, |
| 625 | centerY, |
| 626 | outerRadius, |
| 627 | innerRadius, |
| 628 | )} |
| 629 | fill="currentColor" |
| 630 | stroke="var(--presentation-background)" |
| 631 | strokeWidth="2" |
| 632 | /> |
| 633 | )) |
| 634 | ) : ( |
| 635 | Array.from({ length: segments }).map((_, idx) => ( |
| 636 | <path |
| 637 | key={idx} |
| 638 | d={generateSegmentPath( |
| 639 | idx, |
| 640 | segments, |
| 641 | innerRadius, |
| 642 | outerRadius, |
| 643 | centerX, |
| 644 | centerY, |
| 645 | )} |
| 646 | fill="currentColor" |
| 647 | stroke="var(--presentation-background)" |
| 648 | strokeWidth="1" |
| 649 | /> |
| 650 | )) |
| 651 | )} |
| 652 | </svg> |
| 653 | ); |
| 654 | } |
| 655 | |
| 656 | export function CycleWheel({ |
| 657 | items, |
| 658 | onIconChange, |
| 659 | sizePx, |
| 660 | totalSegments, |
| 661 | variant = "cycle", |
| 662 | }: { |
| 663 | items: TCycleItemElement[]; |
| 664 | onIconChange?: (item: TCycleItemElement, index: number, icon: string) => void; |
| 665 | sizePx?: number; |
| 666 | totalSegments: number; |
| 667 | variant?: CycleVariant; |
| 668 | }) { |
| 669 | return ( |
| 670 | <div |
| 671 | data-decor="true" |
| 672 | className={cn( |
| 673 | "group relative shrink-0 self-center", |
| 674 | sizePx === undefined && "size-56 @[700px]/cycle-layout:size-72", |
| 675 | )} |
| 676 | style={ |
| 677 | sizePx === undefined |
| 678 | ? undefined |
| 679 | : { |
| 680 | height: sizePx, |
| 681 | width: sizePx, |
| 682 | } |
| 683 | } |
| 684 | > |
| 685 | <DynamicWheelSVG segments={totalSegments} variant={variant} /> |
| 686 | {items.map((child, idx) => { |
| 687 | if (!child.icon && !onIconChange) { |
| 688 | return null; |
| 689 | } |
| 690 | |
| 691 | const iconPos = getIconPositionForVariant(variant, idx, totalSegments); |
| 692 | const key = getCycleItemKey(child, idx); |
| 693 | |
| 694 | return ( |
| 695 | <div |
| 696 | key={key} |
| 697 | className={cn( |
| 698 | "absolute -translate-x-1/2 -translate-y-1/2", |
| 699 | !onIconChange && "pointer-events-none", |
| 700 | )} |
| 701 | style={iconPos} |
| 702 | > |
| 703 | {onIconChange ? ( |
| 704 | <IconPicker |
| 705 | defaultIcon={child.icon} |
| 706 | hidePlaceholderWhenEmpty |
| 707 | onIconSelect={(iconName) => onIconChange(child, idx, iconName)} |
| 708 | onIconRemove={() => onIconChange(child, idx, "")} |
| 709 | className="size-8 rounded-full border-transparent bg-transparent text-white shadow-none hover:bg-white/15 hover:text-white focus-visible:bg-white/15 focus-visible:text-white [&_svg]:drop-shadow-sm" |
| 710 | size="sm" |
| 711 | /> |
| 712 | ) : ( |
| 713 | <PresentationIcon |
| 714 | icon={child.icon} |
| 715 | size={18} |
| 716 | className="text-white drop-shadow-sm" |
| 717 | /> |
| 718 | )} |
| 719 | </div> |
| 720 | ); |
| 721 | })} |
| 722 | </div> |
| 723 | ); |
| 724 | } |
| 725 | |
| 726 | function splitCycleChildren( |
| 727 | children: React.ReactNode[], |
| 728 | isMultiColumn: boolean, |
| 729 | ) { |
| 730 | if (!isMultiColumn) { |
| 731 | return { |
| 732 | leftChildren: [] as React.ReactNode[], |
| 733 | rightChildren: children, |
| 734 | }; |
| 735 | } |
| 736 | |
| 737 | return { |
| 738 | leftChildren: children.filter((_, index) => index % 2 === 0), |
| 739 | rightChildren: children.filter((_, index) => index % 2 === 1), |
| 740 | }; |
| 741 | } |
| 742 | |
| 743 | function CycleColumn({ |
| 744 | children, |
| 745 | isMultiColumn, |
| 746 | side, |
| 747 | }: { |
| 748 | children: React.ReactNode[]; |
| 749 | isMultiColumn: boolean; |
| 750 | side: CycleColumnSide; |
| 751 | }) { |
| 752 | const contextValue = React.useMemo( |
| 753 | () => ({ isMultiColumn, side }), |
| 754 | [isMultiColumn, side], |
| 755 | ); |
| 756 | |
| 757 | if (children.length === 0) { |
| 758 | return <div className="min-w-0" aria-hidden="true" />; |
| 759 | } |
| 760 | |
| 761 | return ( |
| 762 | <CycleContext.Provider value={contextValue}> |
| 763 | <div |
| 764 | className={cn( |
| 765 | "flex min-w-0 flex-col justify-center", |
| 766 | isMultiColumn ? "h-full gap-4" : "gap-1", |
| 767 | )} |
| 768 | > |
| 769 | {children} |
| 770 | </div> |
| 771 | </CycleContext.Provider> |
| 772 | ); |
| 773 | } |
| 774 | |
| 775 | export const CycleElement = ({ |
| 776 | className, |
| 777 | ref, |
| 778 | element, |
| 779 | ...props |
| 780 | }: StyledPlateElementProps<TCycleGroupElement>) => { |
| 781 | const { alignment = "center", variant } = element; |
| 782 | const resolvedVariant: CycleVariant = variant ?? "cycle"; |
| 783 | const totalChildren = element.children?.length ?? 0; |
| 784 | const accentColor = getPresentationAccentColor( |
| 785 | element, |
| 786 | undefined, |
| 787 | "var(--presentation-smart-layout, var(--presentation-primary))", |
| 788 | ); |
| 789 | |
| 790 | useForceUpdateChildrenOnLengthChange(props.editor, element); |
| 791 | |
| 792 | const { containerRef, layoutRef, frameStyle, fitStyle } = |
| 793 | useCycleFitScale<HTMLDivElement>(); |
| 794 | const childArray = React.Children.toArray(props.children); |
| 795 | const { leftChildren, rightChildren } = splitCycleChildren(childArray, true); |
| 796 | const totalSegments = totalChildren > 0 ? totalChildren : 6; |
| 797 | const handleWheelIconChange = ( |
| 798 | item: TCycleItemElement, |
| 799 | index: number, |
| 800 | icon: string, |
| 801 | ) => { |
| 802 | const itemPath = props.editor.api.findPath(item); |
| 803 | if (itemPath) { |
| 804 | props.editor.tf.setNodes({ icon }, { at: itemPath }); |
| 805 | return; |
| 806 | } |
| 807 | |
| 808 | const cyclePath = props.editor.api.findPath(element); |
| 809 | if (!cyclePath) { |
| 810 | return; |
| 811 | } |
| 812 | |
| 813 | props.editor.tf.setNodes({ icon }, { at: [...cyclePath, index] }); |
| 814 | }; |
| 815 | |
| 816 | return ( |
| 817 | <PlateElement |
| 818 | ref={ref} |
| 819 | className={cn("relative my-0", className)} |
| 820 | element={element} |
| 821 | {...props} |
| 822 | style={ |
| 823 | { |
| 824 | "--presentation-smart-layout": accentColor, |
| 825 | } as React.CSSProperties |
| 826 | } |
| 827 | > |
| 828 | <div |
| 829 | className={cn( |
| 830 | "flex w-full", |
| 831 | alignment === "left" && "justify-start", |
| 832 | alignment === "right" && "justify-end", |
| 833 | alignment === "center" && "justify-center", |
| 834 | )} |
| 835 | > |
| 836 | <div |
| 837 | ref={containerRef} |
| 838 | className="@container/cycle-layout mx-auto w-full" |
| 839 | > |
| 840 | <div className="mx-auto" style={frameStyle}> |
| 841 | <div |
| 842 | ref={layoutRef} |
| 843 | className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-stretch gap-4" |
| 844 | style={fitStyle} |
| 845 | > |
| 846 | <CycleColumn isMultiColumn side="left"> |
| 847 | {leftChildren} |
| 848 | </CycleColumn> |
| 849 | |
| 850 | <CycleWheel |
| 851 | items={element.children as TCycleItemElement[]} |
| 852 | onIconChange={handleWheelIconChange} |
| 853 | sizePx={CYCLE_WHEEL_SIZE_PX} |
| 854 | totalSegments={totalSegments} |
| 855 | variant={resolvedVariant} |
| 856 | /> |
| 857 | |
| 858 | <CycleColumn isMultiColumn side="right"> |
| 859 | {rightChildren} |
| 860 | </CycleColumn> |
| 861 | </div> |
| 862 | </div> |
| 863 | </div> |
| 864 | </div> |
| 865 | </PlateElement> |
| 866 | ); |
| 867 | }; |
| 868 |