返回 presentation-ai
event-boundary.tsx
1 "use client";
2
3 import React, {
4 useEffect,
5 useRef,
6 type HTMLAttributes,
7 type ReactNode,
8 type Ref,
9 } from "react";
10
11 /**
12 * List of event types to contain within the boundary.
13 * These events will have stopPropagation() called on them.
14 *
15 * NOTE: We intentionally exclude mouse/pointer events to allow
16 * parent editors (like Plate) to handle selection and focus.
17 */
18 const DEFAULT_CONTAINED_EVENTS = [
19 // Keyboard events
20 "keydown",
21 "keyup",
22 "keypress",
23 // Focus events
24 "focusin",
25 "focusout",
26 // Input events
27 "input",
28 "change",
29 "beforeinput",
30 "compositionstart",
31 "compositionupdate",
32 "compositionend",
33 // Form events
34 "submit",
35 ] as const;
36
37 type ContainedEventType = (typeof DEFAULT_CONTAINED_EVENTS)[number];
38
39 interface EventBoundaryProps extends HTMLAttributes<HTMLDivElement> {
40 children: ReactNode;
41 ref?: Ref<HTMLDivElement>;
42 /**
43 * List of event types to contain. Defaults to keyboard/input/form events.
44 * Does NOT include mouse/pointer events by default to preserve editor selection.
45 */
46 containedEvents?: readonly ContainedEventType[];
47 /**
48 * Whether the boundary is active. Set to false to disable event containment.
49 * @default true
50 */
51 active?: boolean;
52 }
53
54 /**
55 * A wrapper component that contains specified events within its boundary
56 * by calling stopPropagation() on them. Useful for isolating interactive
57 * components (like editors, forms, or third-party widgets) from parent
58 * event handlers.
59 *
60 * @example
61 * ```tsx
62 * <EventBoundary>
63 * <ThirdPartyEditor />
64 * </EventBoundary>
65 * ```
66 */
67 export const EventBoundary = ({
68 children,
69 containedEvents = DEFAULT_CONTAINED_EVENTS,
70 active = true,
71 ref,
72 ...divProps
73 }: Omit<EventBoundaryProps, "ref"> & React.RefAttributes<HTMLDivElement>) => {
74 const internalRef = useRef<HTMLDivElement>(null);
75
76 // Use internal ref for event handling, forward the external ref
77 useEffect(() => {
78 if (!active) return;
79
80 const el = internalRef.current;
81 if (!el) return;
82
83 const handler = (e: Event) => {
84 e.stopPropagation();
85 };
86
87 // Only use bubble phase to allow events to reach children first,
88 // then stop them from bubbling up to parent editors
89 for (const type of containedEvents) {
90 el.addEventListener(type, handler, { capture: false, passive: true });
91 }
92
93 return () => {
94 for (const type of containedEvents) {
95 el.removeEventListener(type, handler, { capture: false });
96 }
97 };
98 }, [containedEvents, active]);
99
100 // Merge refs - assign to both internal and external refs
101 const setRefs = (node: HTMLDivElement | null) => {
102 internalRef.current = node;
103 if (typeof ref === "function") {
104 ref(node);
105 } else if (ref) {
106 ref.current = node;
107 }
108 };
109
110 return (
111 <div {...divProps} ref={setRefs}>
112 {children}
113 </div>
114 );
115 };
116
117 EventBoundary.displayName = "EventBoundary";
118
118 lines Plain Text