返回 presentation-ai
cycle-element.tsx
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
868 lines Plain Text