返回 presentation-ai
presentation-column-elements.tsx
根目录 / src / components / notebook / presentation / editor / plugins / presentation-column-elements.tsx
1 "use client";
2
3 import {
4 NodeApi,
5 PathApi,
6 type TColumnElement,
7 type TColumnGroupElement,
8 } from "platejs";
9 import {
10 PlateElement,
11 useReadOnly,
12 type PlateElementProps,
13 } from "platejs/react";
14 import {
15 type CSSProperties,
16 type PointerEvent as ReactPointerEvent,
17 } from "react";
18
19 import { cn } from "@/lib/utils";
20
21 const MIN_COLUMN_WIDTH_PERCENT = 10;
22
23 function parseColumnWidth(width: unknown, fallback: number) {
24 if (width === undefined || width === null) return fallback;
25
26 const parsedWidth = Number.parseFloat(String(width));
27
28 return Number.isFinite(parsedWidth) && parsedWidth > 0
29 ? parsedWidth
30 : fallback;
31 }
32
33 function roundColumnWidth(width: number): number {
34 return Math.round(width * 100) / 100;
35 }
36
37 function formatColumnWidth(width: number) {
38 return `${roundColumnWidth(width)}%`;
39 }
40
41 function clampColumnWidth(width: number, min: number, max: number) {
42 return Math.min(Math.max(width, min), max);
43 }
44
45 function getColumnWidths(columns: TColumnElement[]) {
46 const fallbackWidth = 100 / columns.length;
47
48 return columns.map((column) => parseColumnWidth(column.width, fallbackWidth));
49 }
50
51 function getGridTemplateColumns(columns: TColumnElement[]): CSSProperties {
52 if (columns.length === 0) {
53 return {};
54 }
55
56 return {
57 gridTemplateColumns: getColumnWidths(columns)
58 .map((width) => `minmax(0, ${formatColumnWidth(width)})`)
59 .join(" "),
60 };
61 }
62
63 export function PresentationColumnGroupElement(
64 props: PlateElementProps<TColumnGroupElement>,
65 ) {
66 return (
67 <PlateElement className="mb-2" {...props}>
68 <div
69 className="grid w-full rounded"
70 data-presentation-column-grid
71 style={getGridTemplateColumns(props.element.children)}
72 >
73 {props.children}
74 </div>
75 </PlateElement>
76 );
77 }
78
79 export function PresentationColumnElement(
80 props: PlateElementProps<TColumnElement>,
81 ) {
82 const readOnly = useReadOnly();
83 const columnIndex = props.path.at(-1);
84 const parentPath = PathApi.parent(props.path);
85 const parentElement = NodeApi.get(props.editor, parentPath) as
86 | TColumnGroupElement
87 | undefined;
88 const columns = parentElement?.children ?? [];
89 const isLastColumn =
90 typeof columnIndex !== "number" || columnIndex >= columns.length - 1;
91
92 const handlePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
93 if (readOnly || typeof columnIndex !== "number" || isLastColumn) return;
94
95 const gridElement = event.currentTarget.closest<HTMLElement>(
96 "[data-presentation-column-grid]",
97 );
98 const groupWidth = gridElement?.getBoundingClientRect().width ?? 0;
99
100 if (groupWidth <= 0) return;
101
102 event.preventDefault();
103 event.stopPropagation();
104
105 const widths = getColumnWidths(columns);
106 const initialCurrentWidth = widths[columnIndex] ?? 0;
107 const initialNextWidth = widths[columnIndex + 1] ?? 0;
108 const pairedWidth = initialCurrentWidth + initialNextWidth;
109 const maxCurrentWidth = Math.max(
110 MIN_COLUMN_WIDTH_PERCENT,
111 pairedWidth - MIN_COLUMN_WIDTH_PERCENT,
112 );
113 const startX = event.clientX;
114 const applyPreviewWidths = (nextWidths: number[]) => {
115 if (!gridElement) return;
116
117 gridElement.style.gridTemplateColumns = nextWidths
118 .map((width) => `minmax(0, ${formatColumnWidth(width)})`)
119 .join(" ");
120 };
121
122 const getNextWidths = (clientX: number) => {
123 const deltaPercent = ((clientX - startX) / groupWidth) * 100;
124 const nextCurrentWidth = clampColumnWidth(
125 initialCurrentWidth + deltaPercent,
126 MIN_COLUMN_WIDTH_PERCENT,
127 maxCurrentWidth,
128 );
129 const nextSiblingWidth = Math.max(
130 MIN_COLUMN_WIDTH_PERCENT,
131 pairedWidth - nextCurrentWidth,
132 );
133
134 return widths.map((width, index) => {
135 if (index === columnIndex) return nextCurrentWidth;
136 if (index === columnIndex + 1) return nextSiblingWidth;
137 return width;
138 });
139 };
140
141 const handlePointerMove = (pointerMoveEvent: PointerEvent) => {
142 applyPreviewWidths(getNextWidths(pointerMoveEvent.clientX));
143 };
144
145 const handlePointerUp = (pointerUpEvent: PointerEvent) => {
146 document.body.style.cursor = "";
147 document.body.style.userSelect = "";
148 window.removeEventListener("pointermove", handlePointerMove);
149 window.removeEventListener("pointerup", handlePointerUp);
150
151 const finalWidths = getNextWidths(pointerUpEvent.clientX).map(
152 roundColumnWidth,
153 );
154
155 props.editor.tf.withoutNormalizing(() => {
156 finalWidths.forEach((width, index) => {
157 props.editor.tf.setNodes(
158 { width: formatColumnWidth(width) },
159 { at: [...parentPath, index] },
160 );
161 });
162 });
163 };
164
165 document.body.style.cursor = "col-resize";
166 document.body.style.userSelect = "none";
167 window.addEventListener("pointermove", handlePointerMove);
168 window.addEventListener("pointerup", handlePointerUp, { once: true });
169 };
170
171 return (
172 <div className="group/column relative min-w-0">
173 <PlateElement
174 {...props}
175 className="min-w-0 px-2 group-first/column:pl-0 group-last/column:pr-0"
176 >
177 <div className="relative min-w-0 border border-transparent p-1.5">
178 {props.children}
179 </div>
180 </PlateElement>
181 {!readOnly && !isLastColumn && (
182 <button
183 aria-label="Resize column"
184 className={cn(
185 "absolute top-0 -right-1 z-20 h-full w-2 cursor-col-resize touch-none select-none",
186 "before:absolute before:top-2 before:right-1/2 before:bottom-2 before:w-0.75 before:translate-x-1/2 before:rounded-full before:bg-(--presentation-primary)/70 before:opacity-0 before:transition-opacity",
187 "hover:before:opacity-100 focus-visible:before:opacity-100 group-hover/column:before:opacity-100",
188 )}
189 contentEditable={false}
190 onPointerDown={handlePointerDown}
191 type="button"
192 />
193 )}
194 </div>
195 );
196 }
197
197 lines Plain Text