返回 presentation-ai
alternating-box-layout.ts
根目录 / src / components / notebook / presentation / editor / custom-elements / alternating-box-layout.ts
1 import React from "react";
2
3 const ALTERNATING_BOX_COLUMN_WIDTH_PX = 220;
4 export const ALTERNATING_BOX_COLUMN_GAP_PX = 0;
5 export const ALTERNATING_BOX_ROW_GAP_PX = 0;
6 export const ALTERNATING_BOX_MIN_ROW_HEIGHT_PX = 112;
7 const ALTERNATING_BOX_MIN_LAYOUT_WIDTH_PX = 720;
8
9 const ALTERNATING_BOX_SCALE_EPSILON = 0.001;
10 const ALTERNATING_BOX_SIZE_EPSILON_PX = 0.5;
11
12 export type AlternatingBoxOrientation = "horizontal" | "vertical";
13
14 function getAlternatingBoxLayoutWidth(
15 itemCount: number,
16 orientation: AlternatingBoxOrientation,
17 availableWidth = 0,
18 ) {
19 const columnCount = Math.max(itemCount, 1);
20 const minimumWidth =
21 orientation === "vertical"
22 ? ALTERNATING_BOX_MIN_LAYOUT_WIDTH_PX
23 : columnCount * ALTERNATING_BOX_COLUMN_WIDTH_PX +
24 (columnCount - 1) * ALTERNATING_BOX_COLUMN_GAP_PX;
25
26 return Math.max(availableWidth, minimumWidth);
27 }
28
29 function getAlternatingBoxMinLayoutHeight(
30 itemCount: number,
31 orientation: AlternatingBoxOrientation,
32 ) {
33 const rowCount = Math.max(itemCount, 1);
34
35 if (orientation === "vertical") {
36 return (
37 rowCount * ALTERNATING_BOX_MIN_ROW_HEIGHT_PX +
38 (rowCount - 1) * ALTERNATING_BOX_ROW_GAP_PX
39 );
40 }
41
42 return ALTERNATING_BOX_MIN_ROW_HEIGHT_PX * 2 + ALTERNATING_BOX_ROW_GAP_PX;
43 }
44
45 export function getAlternatingBoxPlacementCss(
46 layoutId: string,
47 itemCount: number,
48 orientation: AlternatingBoxOrientation,
49 ) {
50 return Array.from({ length: itemCount }, (_, index) => {
51 const childIndex = index + 1;
52 const column =
53 orientation === "vertical" ? (index % 2 === 0 ? 1 : 2) : childIndex;
54 const row =
55 orientation === "vertical" ? childIndex : index % 2 === 0 ? 1 : 2;
56
57 return `
58 [data-alternating-box-layout="${layoutId}"] > [data-dnd-wrapper]:nth-child(${childIndex}),
59 [data-alternating-box-layout="${layoutId}"] > :not([data-dnd-wrapper]):nth-child(${childIndex}) {
60 grid-column-start: ${column};
61 grid-row-start: ${row};
62 min-width: 0;
63 position: relative;
64 width: 100%;
65 z-index: 1;
66 }
67 `;
68 }).join("\n");
69 }
70
71 export function useAlternatingBoxFitScale<TContainer extends HTMLElement>(
72 itemCount: number,
73 orientation: AlternatingBoxOrientation,
74 ) {
75 const containerRef = React.useRef<TContainer | null>(null);
76 const layoutRef = React.useRef<HTMLDivElement | null>(null);
77 const minLayoutHeight = getAlternatingBoxMinLayoutHeight(
78 itemCount,
79 orientation,
80 );
81 const [scale, setScale] = React.useState(1);
82 const [layoutWidth, setLayoutWidth] = React.useState(
83 getAlternatingBoxLayoutWidth(itemCount, orientation),
84 );
85 const [layoutHeight, setLayoutHeight] = React.useState(minLayoutHeight);
86
87 React.useLayoutEffect(() => {
88 const container = containerRef.current;
89 if (!container) return;
90
91 let frame = 0;
92 const updateScale = () => {
93 cancelAnimationFrame(frame);
94 frame = requestAnimationFrame(() => {
95 const availableWidth = container.clientWidth;
96 const nextLayoutWidth = getAlternatingBoxLayoutWidth(
97 itemCount,
98 orientation,
99 availableWidth,
100 );
101 const nextScale =
102 availableWidth > 0
103 ? Math.min(1, availableWidth / nextLayoutWidth)
104 : 1;
105
106 setLayoutWidth((currentWidth) =>
107 Math.abs(currentWidth - nextLayoutWidth) >
108 ALTERNATING_BOX_SIZE_EPSILON_PX
109 ? nextLayoutWidth
110 : currentWidth,
111 );
112 setScale((currentScale) =>
113 Math.abs(currentScale - nextScale) > ALTERNATING_BOX_SCALE_EPSILON
114 ? nextScale
115 : currentScale,
116 );
117 });
118 };
119
120 updateScale();
121
122 if (typeof ResizeObserver === "undefined") {
123 return () => cancelAnimationFrame(frame);
124 }
125
126 const observer = new ResizeObserver(updateScale);
127 observer.observe(container);
128
129 return () => {
130 cancelAnimationFrame(frame);
131 observer.disconnect();
132 };
133 }, [itemCount, orientation]);
134
135 React.useLayoutEffect(() => {
136 const layout = layoutRef.current;
137 if (!layout) return;
138
139 let frame = 0;
140 const updateHeight = () => {
141 cancelAnimationFrame(frame);
142 frame = requestAnimationFrame(() => {
143 const nextHeight = Math.max(layout.scrollHeight, minLayoutHeight);
144
145 setLayoutHeight((currentHeight) =>
146 Math.abs(currentHeight - nextHeight) > ALTERNATING_BOX_SIZE_EPSILON_PX
147 ? nextHeight
148 : currentHeight,
149 );
150 });
151 };
152
153 updateHeight();
154
155 if (typeof ResizeObserver === "undefined") {
156 return () => cancelAnimationFrame(frame);
157 }
158
159 const observer = new ResizeObserver(updateHeight);
160 observer.observe(layout);
161
162 return () => {
163 cancelAnimationFrame(frame);
164 observer.disconnect();
165 };
166 }, [minLayoutHeight]);
167
168 return {
169 containerRef,
170 layoutRef,
171 frameStyle: {
172 height: layoutHeight * scale,
173 maxWidth: "100%",
174 overflow: "visible",
175 position: "relative",
176 width: layoutWidth * scale,
177 } satisfies React.CSSProperties,
178 fitStyle: {
179 transform: `scale(${scale})`,
180 transformOrigin: "top left",
181 width: layoutWidth,
182 } satisfies React.CSSProperties,
183 };
184 }
185
185 lines TYPESCRIPT