diff --git a/examples/basic/src/App.tsx b/examples/basic/src/App.tsx index c1254c1..276d3bc 100644 --- a/examples/basic/src/App.tsx +++ b/examples/basic/src/App.tsx @@ -20,26 +20,26 @@ export default function App() {
- - - - - - -
diff --git a/packages/react/src/components/BubbleMenu.tsx b/packages/react/src/components/BubbleMenu.tsx index 2798d61..48ffad7 100644 --- a/packages/react/src/components/BubbleMenu.tsx +++ b/packages/react/src/components/BubbleMenu.tsx @@ -36,9 +36,9 @@ import { ColorPicker } from './ColorPicker'; */ export interface BubbleMenuProps { /** - * The OpenBlockEditor instance. + * The OpenBlockEditor instance (can be null during initialization). */ - editor: OpenBlockEditor; + editor: OpenBlockEditor | null; /** * Custom render function for the menu content. @@ -402,6 +402,8 @@ export function BubbleMenu({ const linkButtonRef = useRef(null); useEffect(() => { + if (!editor || editor.isDestroyed) return; + const updateState = () => { const state = BUBBLE_MENU_PLUGIN_KEY.getState(editor.pm.state); setMenuState(state ?? null); @@ -421,26 +423,31 @@ export function BubbleMenu({ }, [menuState?.visible]); const toggleBold = useCallback(() => { + if (!editor || editor.isDestroyed) return; editor.toggleBold(); editor.pm.view.focus(); }, [editor]); const toggleItalic = useCallback(() => { + if (!editor || editor.isDestroyed) return; editor.toggleItalic(); editor.pm.view.focus(); }, [editor]); const toggleUnderline = useCallback(() => { + if (!editor || editor.isDestroyed) return; editor.toggleUnderline(); editor.pm.view.focus(); }, [editor]); const toggleStrikethrough = useCallback(() => { + if (!editor || editor.isDestroyed) return; editor.toggleStrikethrough(); editor.pm.view.focus(); }, [editor]); const toggleCode = useCallback(() => { + if (!editor || editor.isDestroyed) return; editor.toggleCode(); editor.pm.view.focus(); }, [editor]); @@ -453,7 +460,7 @@ export function BubbleMenu({ setShowLinkPopover(false); }, []); - if (!menuState?.visible || !menuState.coords) { + if (!editor || editor.isDestroyed || !menuState?.visible || !menuState.coords) { return null; } diff --git a/packages/react/src/components/OpenBlockView.tsx b/packages/react/src/components/OpenBlockView.tsx index e7940fe..bba621b 100644 --- a/packages/react/src/components/OpenBlockView.tsx +++ b/packages/react/src/components/OpenBlockView.tsx @@ -28,9 +28,9 @@ import { OpenBlockEditor } from '@labbs/openblock-core'; */ export interface OpenBlockViewProps { /** - * The OpenBlockEditor instance to render + * The OpenBlockEditor instance to render (can be null during initialization) */ - editor: OpenBlockEditor; + editor: OpenBlockEditor | null; /** * Additional class name(s) for the container @@ -58,9 +58,9 @@ export interface OpenBlockViewRef { container: HTMLDivElement | null; /** - * The OpenBlockEditor instance + * The OpenBlockEditor instance (can be null during initialization) */ - editor: OpenBlockEditor; + editor: OpenBlockEditor | null; } /** diff --git a/packages/react/src/components/SlashMenu.tsx b/packages/react/src/components/SlashMenu.tsx index 440f436..a7c0687 100644 --- a/packages/react/src/components/SlashMenu.tsx +++ b/packages/react/src/components/SlashMenu.tsx @@ -37,9 +37,9 @@ import { */ export interface SlashMenuProps { /** - * The OpenBlockEditor instance. + * The OpenBlockEditor instance (can be null during initialization). */ - editor: OpenBlockEditor; + editor: OpenBlockEditor | null; /** * Custom menu items (optional). @@ -121,12 +121,14 @@ export function SlashMenu({ const [openUpward, setOpenUpward] = useState(false); const menuRef = useRef(null); - // Get menu items - const allItems = customItems ?? getDefaultSlashMenuItems(editor.pm.state.schema); + // Get menu items (only when editor is available) + const allItems = editor ? (customItems ?? getDefaultSlashMenuItems(editor.pm.state.schema)) : []; const filteredItems = menuState ? filterSlashMenuItems(allItems, menuState.query) : []; // Subscribe to plugin state changes useEffect(() => { + if (!editor || editor.isDestroyed) return; + const updateState = () => { const state = SLASH_MENU_PLUGIN_KEY.getState(editor.pm.state); setMenuState(state ?? null); @@ -143,7 +145,7 @@ export function SlashMenu({ // Handle keyboard navigation useEffect(() => { - if (!menuState?.active) return; + if (!editor || editor.isDestroyed || !menuState?.active) return; const handleKeyDown = (event: KeyboardEvent) => { switch (event.key) { @@ -175,7 +177,7 @@ export function SlashMenu({ // Handle item selection const handleSelect = useCallback( (item: SlashMenuItem) => { - if (!menuState) return; + if (!editor || editor.isDestroyed || !menuState) return; executeSlashCommand(editor.pm.view, menuState, item.action); editor.pm.view.focus(); }, diff --git a/packages/react/src/components/TableHandles.tsx b/packages/react/src/components/TableHandles.tsx index 9e567a1..ba4a562 100644 --- a/packages/react/src/components/TableHandles.tsx +++ b/packages/react/src/components/TableHandles.tsx @@ -36,9 +36,9 @@ import { */ export interface TableHandlesProps { /** - * The OpenBlockEditor instance. + * The OpenBlockEditor instance (can be null during initialization). */ - editor: OpenBlockEditor; + editor: OpenBlockEditor | null; /** * Additional class name for the handles container. @@ -144,6 +144,8 @@ export function TableHandles({ // Track mouse position to detect which row/col is hovered useEffect(() => { + if (!editor || editor.isDestroyed) return; + let hideTimeout: ReturnType | null = null; const clearHideTimeout = () => { @@ -286,7 +288,7 @@ export function TableHandles({ const handleAddRow = useCallback( (index: number) => { - if (!tableState) return; + if (!editor || editor.isDestroyed || !tableState) return; addRowAtIndex(editor.pm.state, editor.pm.view.dispatch, tableState.tablePos, index); editor.pm.view.focus(); setShowRowMenu(null); @@ -296,7 +298,7 @@ export function TableHandles({ const handleDeleteRow = useCallback( (index: number) => { - if (!tableState) return; + if (!editor || editor.isDestroyed || !tableState) return; deleteRowAtIndex(editor.pm.state, editor.pm.view.dispatch, tableState.tablePos, index); editor.pm.view.focus(); setShowRowMenu(null); @@ -306,7 +308,7 @@ export function TableHandles({ const handleAddCol = useCallback( (index: number) => { - if (!tableState) return; + if (!editor || editor.isDestroyed || !tableState) return; addColumnAtIndex(editor.pm.state, editor.pm.view.dispatch, tableState.tablePos, index); editor.pm.view.focus(); setShowColMenu(null); @@ -316,7 +318,7 @@ export function TableHandles({ const handleDeleteCol = useCallback( (index: number) => { - if (!tableState) return; + if (!editor || editor.isDestroyed || !tableState) return; deleteColumnAtIndex(editor.pm.state, editor.pm.view.dispatch, tableState.tablePos, index); editor.pm.view.focus(); setShowColMenu(null); @@ -324,7 +326,7 @@ export function TableHandles({ [editor, tableState] ); - if (!tableState) return null; + if (!editor || editor.isDestroyed || !tableState) return null; const tableRect = tableState.tableElement.getBoundingClientRect(); diff --git a/packages/react/src/components/TableMenu.tsx b/packages/react/src/components/TableMenu.tsx index 4bbbb4b..b43a357 100644 --- a/packages/react/src/components/TableMenu.tsx +++ b/packages/react/src/components/TableMenu.tsx @@ -39,9 +39,9 @@ import { */ export interface TableMenuProps { /** - * The OpenBlockEditor instance. + * The OpenBlockEditor instance (can be null during initialization). */ - editor: OpenBlockEditor; + editor: OpenBlockEditor | null; /** * Additional class name for the menu container. @@ -207,6 +207,8 @@ export function TableMenu({ const [coords, setCoords] = useState<{ left: number; top: number } | null>(null); useEffect(() => { + if (!editor || editor.isDestroyed) return; + const updateState = () => { const state = editor.pm.state; const isInsideTable = isInTable(state); @@ -247,41 +249,48 @@ export function TableMenu({ }, [editor]); const handleAddRowBefore = useCallback(() => { + if (!editor || editor.isDestroyed) return; addRowBefore(editor.pm.state, editor.pm.view.dispatch); editor.pm.view.focus(); }, [editor]); const handleAddRowAfter = useCallback(() => { + if (!editor || editor.isDestroyed) return; addRowAfter(editor.pm.state, editor.pm.view.dispatch); editor.pm.view.focus(); }, [editor]); const handleDeleteRow = useCallback(() => { + if (!editor || editor.isDestroyed) return; deleteRow(editor.pm.state, editor.pm.view.dispatch); editor.pm.view.focus(); }, [editor]); const handleAddColumnBefore = useCallback(() => { + if (!editor || editor.isDestroyed) return; addColumnBefore(editor.pm.state, editor.pm.view.dispatch); editor.pm.view.focus(); }, [editor]); const handleAddColumnAfter = useCallback(() => { + if (!editor || editor.isDestroyed) return; addColumnAfter(editor.pm.state, editor.pm.view.dispatch); editor.pm.view.focus(); }, [editor]); const handleDeleteColumn = useCallback(() => { + if (!editor || editor.isDestroyed) return; deleteColumn(editor.pm.state, editor.pm.view.dispatch); editor.pm.view.focus(); }, [editor]); const handleDeleteTable = useCallback(() => { + if (!editor || editor.isDestroyed) return; deleteTable(editor.pm.state, editor.pm.view.dispatch); editor.pm.view.focus(); }, [editor]); - if (!inTable || !coords) { + if (!editor || editor.isDestroyed || !inTable || !coords) { return null; } diff --git a/packages/react/src/hooks/useOpenBlock.ts b/packages/react/src/hooks/useOpenBlock.ts index 1b9bdcb..c2e9f39 100644 --- a/packages/react/src/hooks/useOpenBlock.ts +++ b/packages/react/src/hooks/useOpenBlock.ts @@ -19,7 +19,7 @@ * ``` */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { OpenBlockEditor, EditorConfig, Block } from '@labbs/openblock-core'; /** @@ -31,21 +31,25 @@ export interface UseOpenBlockOptions extends Omit {} * Create and manage an OpenBlockEditor instance * * @param options - Editor configuration options - * @returns The OpenBlockEditor instance + * @returns The OpenBlockEditor instance, or null during initialization + * + * @remarks + * This hook properly handles React 18+ StrictMode, which mounts components twice + * in development. The editor is created in useEffect to ensure a fresh instance + * is created after each mount/unmount cycle. */ -export function useOpenBlock(options: UseOpenBlockOptions = {}): OpenBlockEditor { - // Use useState with lazy initializer to create the editor exactly once - // This is React 18+ safe and works with StrictMode - const [editor] = useState(() => new OpenBlockEditor(options)); +export function useOpenBlock(options: UseOpenBlockOptions = {}): OpenBlockEditor | null { + const [editor, setEditor] = useState(null); + const optionsRef = useRef(options); - // Cleanup on unmount only useEffect(() => { + const newEditor = new OpenBlockEditor(optionsRef.current); + setEditor(newEditor); + return () => { - if (!editor.isDestroyed) { - editor.destroy(); - } + newEditor.destroy(); }; - }, [editor]); + }, []); return editor; }