| 1 | "use client"; |
| 2 | |
| 3 | import { BlockSelectionPlugin } from "@platejs/selection/react"; |
| 4 | import { KEYS, NodeApi, PathApi, type TImageElement } from "platejs"; |
| 5 | import { useEditorRef, usePluginOption } from "platejs/react"; |
| 6 | import * as React from "react"; |
| 7 | |
| 8 | import { |
| 9 | type ChartDataMode, |
| 10 | type ChartDataType, |
| 11 | type SeriesChartType, |
| 12 | } from "@/components/notebook/presentation/editor/custom-elements/chart-data-editor-dialog"; |
| 13 | import { |
| 14 | BOX_GROUP, |
| 15 | COMPOSED_CHART_ELEMENT, |
| 16 | getAlignmentOptions, |
| 17 | getAvailableConversionOptions, |
| 18 | getChartDataCategory, |
| 19 | getColumnSizeOptions, |
| 20 | getDirectLayoutToolbarTargetEntry, |
| 21 | getElementDisplayName, |
| 22 | getOrientationOptions, |
| 23 | getSidednessOptions, |
| 24 | getVariantOptions, |
| 25 | ICON_LIST, |
| 26 | isChartType, |
| 27 | isLayoutChildType, |
| 28 | handleLayoutChange as layoutChange, |
| 29 | handleNodePropertyUpdate as nodePropertyUpdate, |
| 30 | supportsAlignment, |
| 31 | supportsColumnSize, |
| 32 | supportsNumbered, |
| 33 | supportsOrientation, |
| 34 | supportsShowLine, |
| 35 | supportsSidedness, |
| 36 | supportsVariant, |
| 37 | } from "@/components/notebook/presentation/editor/lib"; |
| 38 | import { PALETTE_DROP_MUTABLE_KEY } from "@/components/notebook/presentation/editor/utils/paletteDrop"; |
| 39 | import { type MyEditor } from "@/components/plate/editor-kit"; |
| 40 | import { |
| 41 | CALLOUT_VARIANTS, |
| 42 | getCalloutVariant, |
| 43 | } from "@/components/plate/ui/callout-variants"; |
| 44 | import { |
| 45 | usePresentationState, |
| 46 | type ImageEditorMode, |
| 47 | } from "@/states/presentation-state"; |
| 48 | |
| 49 | export interface ToolbarContextValue { |
| 50 | editor: MyEditor; |
| 51 | element: Record<string, unknown> | undefined; |
| 52 | elementId: string | undefined; |
| 53 | elementType: string; |
| 54 | |
| 55 | // Current property values |
| 56 | currentOrientation: string; |
| 57 | currentSidedness: string; |
| 58 | currentNumbered: boolean; |
| 59 | currentShowLine: boolean; |
| 60 | currentColor: string; |
| 61 | currentBackgroundColor: string; |
| 62 | currentTextColor: string; |
| 63 | currentColumnSize: "sm" | "md" | "lg" | "xl"; |
| 64 | currentAlignment: "left" | "center" | "right"; |
| 65 | currentVariant: string; |
| 66 | |
| 67 | // Capability checks |
| 68 | supportsOrientationControl: boolean; |
| 69 | supportsSidednessControl: boolean; |
| 70 | supportsNumberedControl: boolean; |
| 71 | supportsShowLineControl: boolean; |
| 72 | supportsColumnSizeControl: boolean; |
| 73 | supportsAlignmentControl: boolean; |
| 74 | supportsVariantControl: boolean; |
| 75 | isCurrentElementChart: boolean; |
| 76 | isComposedChart: boolean; |
| 77 | isImageElement: boolean; |
| 78 | isInfographicElement: boolean; |
| 79 | isLayoutChildElement: boolean; |
| 80 | isMediaEmbedElement: boolean; |
| 81 | imageUrl: string | undefined; |
| 82 | handleOpenImageEditor: (mode: ImageEditorMode) => void; |
| 83 | |
| 84 | // Chart-specific handler |
| 85 | handleOpenChartEditor: () => void; |
| 86 | |
| 87 | // Infographic-specific handler |
| 88 | handleOpenInfographicEditor: () => void; |
| 89 | |
| 90 | // Available options |
| 91 | orientationOptions: readonly string[]; |
| 92 | sidednessOptions: readonly string[]; |
| 93 | columnSizeOptions: readonly string[]; |
| 94 | alignmentOptions: readonly ("left" | "center" | "right")[]; |
| 95 | variantOptions: readonly string[]; |
| 96 | availableOptionsGrouped: ReturnType<typeof getAvailableConversionOptions>; |
| 97 | selectedOption: string; |
| 98 | |
| 99 | // Chart-specific |
| 100 | chartData: ChartDataType; |
| 101 | chartDataType: ChartDataMode; |
| 102 | seriesChartTypes: Record<string, SeriesChartType>; |
| 103 | |
| 104 | // Handlers |
| 105 | handleNodePropertyUpdate: ( |
| 106 | property: string, |
| 107 | value: |
| 108 | | string |
| 109 | | boolean |
| 110 | | number |
| 111 | | Record<string, unknown> |
| 112 | | unknown[] |
| 113 | | undefined, |
| 114 | ) => void; |
| 115 | handleLayoutChange: ( |
| 116 | type: string, |
| 117 | additionalData?: Record<string, unknown>, |
| 118 | ) => void; |
| 119 | handleChartDataUpdate: (newData: ChartDataType) => void; |
| 120 | handleSeriesChartTypesUpdate: ( |
| 121 | types: Record<string, SeriesChartType>, |
| 122 | ) => void; |
| 123 | } |
| 124 | |
| 125 | const ToolbarContext = React.createContext<ToolbarContextValue | null>(null); |
| 126 | const SMART_LAYOUT_COLOR_FALLBACK = |
| 127 | "var(--presentation-smart-layout, var(--presentation-primary))"; |
| 128 | |
| 129 | function getStringColor(value: unknown): string | undefined { |
| 130 | return typeof value === "string" ? value : undefined; |
| 131 | } |
| 132 | |
| 133 | export function useToolbarContext() { |
| 134 | const context = React.useContext(ToolbarContext); |
| 135 | if (!context) { |
| 136 | throw new Error("useToolbarContext must be used within a ToolbarProvider"); |
| 137 | } |
| 138 | return context; |
| 139 | } |
| 140 | |
| 141 | export function ToolbarProvider({ children }: { children: React.ReactNode }) { |
| 142 | const editor = useEditorRef<MyEditor>(); |
| 143 | |
| 144 | // Get current selection |
| 145 | const selectionIds = usePluginOption(BlockSelectionPlugin, "selectedIds"); |
| 146 | const selectedElementId = React.useMemo(() => { |
| 147 | const firstSelectedId = Array.from(selectionIds ?? [])[0]; |
| 148 | return typeof firstSelectedId === "string" ? firstSelectedId : undefined; |
| 149 | }, [selectionIds]); |
| 150 | const [committedElementId, setCommittedElementId] = React.useState< |
| 151 | string | undefined |
| 152 | >(); |
| 153 | |
| 154 | React.useEffect(() => { |
| 155 | if (selectedElementId) { |
| 156 | setCommittedElementId(selectedElementId); |
| 157 | } |
| 158 | }, [selectedElementId]); |
| 159 | |
| 160 | const elementId = selectedElementId ?? committedElementId; |
| 161 | const targetEntry = getDirectLayoutToolbarTargetEntry(editor, elementId); |
| 162 | const [element, elementPath] = targetEntry ?? []; |
| 163 | |
| 164 | // Get current element type and available options |
| 165 | const elementType = (element?.type as string) ?? ""; |
| 166 | const availableOptionsGrouped = React.useMemo( |
| 167 | () => getAvailableConversionOptions(elementType), |
| 168 | [elementType], |
| 169 | ); |
| 170 | |
| 171 | // Get display name for current element |
| 172 | const selectedOption = React.useMemo(() => { |
| 173 | return getElementDisplayName( |
| 174 | elementType, |
| 175 | element as Record<string, unknown> | undefined, |
| 176 | ); |
| 177 | }, [elementType, element]); |
| 178 | |
| 179 | // Get current properties |
| 180 | const currentOrientation = |
| 181 | (element as { orientation?: string })?.orientation ?? |
| 182 | (elementType === ICON_LIST ? "side" : "vertical"); |
| 183 | const currentSidedness = |
| 184 | (element as { sidedness?: string })?.sidedness ?? "single"; |
| 185 | const currentNumbered = |
| 186 | (element as { numbered?: boolean })?.numbered ?? false; |
| 187 | const currentShowLine = (element as { showLine?: boolean })?.showLine ?? true; |
| 188 | const elementColors = element as |
| 189 | | { |
| 190 | backgroundColor?: string; |
| 191 | color?: string; |
| 192 | textColor?: string; |
| 193 | variant?: unknown; |
| 194 | } |
| 195 | | undefined; |
| 196 | const calloutVariant = getCalloutVariant(elementColors?.variant); |
| 197 | const isLayoutChildElement = isLayoutChildType(elementType); |
| 198 | const layoutParentElement = |
| 199 | isLayoutChildElement && elementPath && elementPath.length > 0 |
| 200 | ? NodeApi.get(editor, PathApi.parent(elementPath)) |
| 201 | : undefined; |
| 202 | const parentColor = |
| 203 | typeof layoutParentElement === "object" && layoutParentElement !== null |
| 204 | ? getStringColor((layoutParentElement as { color?: unknown }).color) |
| 205 | : undefined; |
| 206 | const currentBackgroundColor = |
| 207 | elementColors?.backgroundColor ?? |
| 208 | (elementType === KEYS.callout |
| 209 | ? CALLOUT_VARIANTS[calloutVariant].backgroundColor |
| 210 | : "#ffffff"); |
| 211 | const currentColor = |
| 212 | elementColors?.color ?? parentColor ?? SMART_LAYOUT_COLOR_FALLBACK; |
| 213 | const currentTextColor = |
| 214 | elementColors?.textColor ?? |
| 215 | elementColors?.color ?? |
| 216 | (elementType === KEYS.callout |
| 217 | ? CALLOUT_VARIANTS[calloutVariant].textColor |
| 218 | : "#ffffff"); |
| 219 | const currentColumnSize = |
| 220 | (element as { columnSize?: "sm" | "md" | "lg" | "xl" })?.columnSize ?? "md"; |
| 221 | const currentAlignment = |
| 222 | (element as { alignment?: "left" | "center" | "right" })?.alignment ?? |
| 223 | (element as { align?: "left" | "center" | "right" })?.align ?? |
| 224 | "left"; |
| 225 | // Check capabilities |
| 226 | const supportsOrientationControl = |
| 227 | supportsOrientation(elementType) && |
| 228 | (elementType !== BOX_GROUP || |
| 229 | (element as { boxType?: string })?.boxType === "alternating"); |
| 230 | const supportsSidednessControl = supportsSidedness(elementType); |
| 231 | const supportsNumberedControl = supportsNumbered(elementType); |
| 232 | const supportsShowLineControl = supportsShowLine(elementType); |
| 233 | const supportsColumnSizeControl = supportsColumnSize(elementType); |
| 234 | const supportsAlignmentControl = supportsAlignment(elementType); |
| 235 | const supportsVariantControl = supportsVariant(elementType); |
| 236 | |
| 237 | // Get available options |
| 238 | const orientationOptions = getOrientationOptions(elementType); |
| 239 | const sidednessOptions = getSidednessOptions(elementType); |
| 240 | const columnSizeOptions = getColumnSizeOptions(elementType); |
| 241 | const alignmentOptions = getAlignmentOptions(elementType); |
| 242 | const variantOptions = getVariantOptions(elementType); |
| 243 | const currentVariant = |
| 244 | (element as { variant?: string })?.variant ?? |
| 245 | variantOptions[0] ?? |
| 246 | "default"; |
| 247 | |
| 248 | // Chart data |
| 249 | const isCurrentElementChart = isChartType(elementType); |
| 250 | const chartData = (element as { data?: unknown })?.data as ChartDataType; |
| 251 | const chartDataType: ChartDataMode = |
| 252 | (getChartDataCategory(elementType) as ChartDataMode) ?? "multi-series"; |
| 253 | const isComposedChart = elementType === COMPOSED_CHART_ELEMENT; |
| 254 | const seriesChartTypes = |
| 255 | (element as { seriesChartTypes?: Record<string, SeriesChartType> }) |
| 256 | ?.seriesChartTypes ?? {}; |
| 257 | |
| 258 | // Image-specific |
| 259 | const isImageElement = elementType === "img"; |
| 260 | const isMediaEmbedElement = elementType === KEYS.mediaEmbed; |
| 261 | // Infographic-specific |
| 262 | const isInfographicElement = elementType === "antv-infographic"; |
| 263 | const imageUrl = (element as TImageElement | undefined)?.url; |
| 264 | const openPresentationImageEditor = usePresentationState( |
| 265 | (s) => s.openPresentationImageEditor, |
| 266 | ); |
| 267 | const setPaletteDropTarget = usePresentationState( |
| 268 | (s) => s.setPaletteDropTarget, |
| 269 | ); |
| 270 | |
| 271 | const handleOpenImageEditor = React.useCallback( |
| 272 | (mode: ImageEditorMode) => { |
| 273 | if (!element?.id) return; |
| 274 | // Create a bound updateElement function that captures the current editor and element |
| 275 | const boundUpdateElement = (updateProps: Record<string, unknown>) => { |
| 276 | setPaletteDropTarget(null); |
| 277 | editor.tf.setNodes( |
| 278 | { |
| 279 | ...(updateProps as Partial<TImageElement>), |
| 280 | [PALETTE_DROP_MUTABLE_KEY]: false, |
| 281 | }, |
| 282 | { |
| 283 | at: [], |
| 284 | match: (n) => n.id === element.id, |
| 285 | }, |
| 286 | ); |
| 287 | }; |
| 288 | // Open the presentation image editor panel with the selected element snapshot. |
| 289 | openPresentationImageEditor( |
| 290 | mode, |
| 291 | boundUpdateElement, |
| 292 | element as Record<string, unknown>, |
| 293 | ); |
| 294 | }, |
| 295 | [editor, element, openPresentationImageEditor, setPaletteDropTarget], |
| 296 | ); |
| 297 | |
| 298 | // Chart-specific handler |
| 299 | const openChartEditor = usePresentationState((s) => s.openChartEditor); |
| 300 | |
| 301 | const handleOpenChartEditor = React.useCallback(() => { |
| 302 | if (!element?.id) return; |
| 303 | |
| 304 | // Get chart data from element |
| 305 | const chartElement = element as { |
| 306 | type?: string; |
| 307 | data?: unknown; |
| 308 | variant?: string; |
| 309 | orientation?: string; |
| 310 | interpolation?: string; |
| 311 | [key: string]: unknown; |
| 312 | }; |
| 313 | |
| 314 | // Extract chart options from element |
| 315 | const { |
| 316 | type, |
| 317 | data, |
| 318 | id: _id, |
| 319 | children: _children, |
| 320 | ...chartOptions |
| 321 | } = chartElement; |
| 322 | |
| 323 | // Create a bound updateElement function that captures the current editor and element |
| 324 | const boundUpdateElement = (updateProps: Record<string, unknown>) => { |
| 325 | setPaletteDropTarget(null); |
| 326 | editor.tf.setNodes( |
| 327 | { ...updateProps, [PALETTE_DROP_MUTABLE_KEY]: false }, |
| 328 | { |
| 329 | at: [], |
| 330 | match: (n) => n.id === element.id, |
| 331 | }, |
| 332 | ); |
| 333 | }; |
| 334 | |
| 335 | // Open the chart editor panel with the element data and bound function |
| 336 | openChartEditor( |
| 337 | { |
| 338 | chartType: (type as string) ?? "", |
| 339 | chartData: data, |
| 340 | chartOptions: chartOptions as Record<string, unknown>, |
| 341 | }, |
| 342 | boundUpdateElement, |
| 343 | ); |
| 344 | }, [editor, element, openChartEditor, setPaletteDropTarget]); |
| 345 | |
| 346 | // Infographic-specific handler |
| 347 | const openInfographicEditor = usePresentationState( |
| 348 | (s) => s.openInfographicEditor, |
| 349 | ); |
| 350 | |
| 351 | const handleOpenInfographicEditor = React.useCallback(() => { |
| 352 | if (!element?.id) return; |
| 353 | |
| 354 | // Create a bound updateElement function that captures the current editor and element |
| 355 | const boundUpdateElement = (updateProps: Record<string, unknown>) => { |
| 356 | setPaletteDropTarget(null); |
| 357 | editor.tf.setNodes( |
| 358 | { ...updateProps, [PALETTE_DROP_MUTABLE_KEY]: false }, |
| 359 | { |
| 360 | at: [], |
| 361 | match: (n) => n.id === element.id, |
| 362 | }, |
| 363 | ); |
| 364 | }; |
| 365 | |
| 366 | openInfographicEditor(boundUpdateElement); |
| 367 | }, [editor, element, openInfographicEditor, setPaletteDropTarget]); |
| 368 | |
| 369 | // Handlers |
| 370 | const handleNodePropertyUpdate = React.useCallback( |
| 371 | ( |
| 372 | property: string, |
| 373 | value: |
| 374 | | string |
| 375 | | boolean |
| 376 | | number |
| 377 | | Record<string, unknown> |
| 378 | | unknown[] |
| 379 | | undefined, |
| 380 | ) => { |
| 381 | const targetElementId = |
| 382 | typeof element?.id === "string" ? element.id : elementId; |
| 383 | |
| 384 | setPaletteDropTarget(null); |
| 385 | nodePropertyUpdate(editor, property, value, targetElementId); |
| 386 | }, |
| 387 | [editor, element, elementId, setPaletteDropTarget], |
| 388 | ); |
| 389 | |
| 390 | const handleLayoutChange = React.useCallback( |
| 391 | (type: string, additionalData?: Record<string, unknown>) => { |
| 392 | const targetElementId = |
| 393 | typeof element?.id === "string" ? element.id : elementId; |
| 394 | |
| 395 | setPaletteDropTarget(null); |
| 396 | return layoutChange(editor, type, additionalData, targetElementId); |
| 397 | }, |
| 398 | [editor, element, elementId, setPaletteDropTarget], |
| 399 | ); |
| 400 | |
| 401 | const handleChartDataUpdate = React.useCallback( |
| 402 | (newData: ChartDataType) => { |
| 403 | if (!element) return; |
| 404 | setPaletteDropTarget(null); |
| 405 | editor.tf.setNodes( |
| 406 | { data: newData, [PALETTE_DROP_MUTABLE_KEY]: false }, |
| 407 | { at: editor.api.findPath(element) }, |
| 408 | ); |
| 409 | }, |
| 410 | [editor, element, setPaletteDropTarget], |
| 411 | ); |
| 412 | |
| 413 | const handleSeriesChartTypesUpdate = React.useCallback( |
| 414 | (types: Record<string, SeriesChartType>) => { |
| 415 | if (!element) return; |
| 416 | setPaletteDropTarget(null); |
| 417 | editor.tf.setNodes( |
| 418 | { seriesChartTypes: types, [PALETTE_DROP_MUTABLE_KEY]: false }, |
| 419 | { at: editor.api.findPath(element) }, |
| 420 | ); |
| 421 | }, |
| 422 | [editor, element, setPaletteDropTarget], |
| 423 | ); |
| 424 | |
| 425 | const value: ToolbarContextValue = { |
| 426 | editor, |
| 427 | element: element as Record<string, unknown> | undefined, |
| 428 | elementId, |
| 429 | elementType, |
| 430 | currentOrientation, |
| 431 | currentSidedness, |
| 432 | currentNumbered, |
| 433 | currentShowLine, |
| 434 | currentColor, |
| 435 | currentBackgroundColor, |
| 436 | currentTextColor, |
| 437 | currentColumnSize, |
| 438 | currentAlignment, |
| 439 | currentVariant, |
| 440 | supportsOrientationControl, |
| 441 | supportsSidednessControl, |
| 442 | supportsNumberedControl, |
| 443 | supportsShowLineControl, |
| 444 | supportsColumnSizeControl, |
| 445 | supportsAlignmentControl, |
| 446 | supportsVariantControl, |
| 447 | isCurrentElementChart, |
| 448 | isComposedChart, |
| 449 | isImageElement, |
| 450 | isInfographicElement, |
| 451 | isLayoutChildElement, |
| 452 | isMediaEmbedElement, |
| 453 | imageUrl, |
| 454 | handleOpenImageEditor, |
| 455 | orientationOptions, |
| 456 | sidednessOptions, |
| 457 | columnSizeOptions, |
| 458 | alignmentOptions, |
| 459 | variantOptions, |
| 460 | availableOptionsGrouped, |
| 461 | selectedOption, |
| 462 | chartData, |
| 463 | chartDataType, |
| 464 | seriesChartTypes, |
| 465 | handleNodePropertyUpdate, |
| 466 | handleLayoutChange, |
| 467 | handleChartDataUpdate, |
| 468 | handleSeriesChartTypesUpdate, |
| 469 | handleOpenChartEditor, |
| 470 | handleOpenInfographicEditor, |
| 471 | }; |
| 472 | |
| 473 | return ( |
| 474 | <ToolbarContext.Provider value={value}>{children}</ToolbarContext.Provider> |
| 475 | ); |
| 476 | } |
| 477 |