Skip to content

Commit b44f4fe

Browse files
moutonjeremyJeremy Mouton
andauthored
Fix null editor handling and improve accessibility (#5)
* fix: handle null editor instances in components and improve button accessibility * fix: update useOpenBlock to handle React 18+ StrictMode and improve editor initialization --------- Co-authored-by: Jeremy Mouton <jeremy.mouton@spendesk.com>
1 parent b8cb6dc commit b44f4fe

7 files changed

Lines changed: 65 additions & 41 deletions

File tree

examples/basic/src/App.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,26 @@ export default function App() {
2020
<div className="container">
2121
<div className="editor-section">
2222
<div className="toolbar">
23-
<button onClick={() => editor.toggleBold()} title="Bold (Cmd+B)">
23+
<button onClick={() => editor?.toggleBold()} title="Bold (Cmd+B)" disabled={!editor}>
2424
<strong>B</strong>
2525
</button>
26-
<button onClick={() => editor.toggleItalic()} title="Italic (Cmd+I)">
26+
<button onClick={() => editor?.toggleItalic()} title="Italic (Cmd+I)" disabled={!editor}>
2727
<em>I</em>
2828
</button>
29-
<button onClick={() => editor.toggleUnderline()} title="Underline (Cmd+U)">
29+
<button onClick={() => editor?.toggleUnderline()} title="Underline (Cmd+U)" disabled={!editor}>
3030
<u>U</u>
3131
</button>
32-
<button onClick={() => editor.toggleStrikethrough()} title="Strikethrough">
32+
<button onClick={() => editor?.toggleStrikethrough()} title="Strikethrough" disabled={!editor}>
3333
<s>S</s>
3434
</button>
35-
<button onClick={() => editor.toggleCode()} title="Code">
35+
<button onClick={() => editor?.toggleCode()} title="Code" disabled={!editor}>
3636
{'</>'}
3737
</button>
3838
<span className="separator" />
39-
<button onClick={() => console.log(editor.getDocument())} title="Log document to console">
39+
<button onClick={() => editor && console.log(editor.getDocument())} title="Log document to console" disabled={!editor}>
4040
Log JSON
4141
</button>
42-
<button onClick={() => console.log(editor.pm.state)} title="Log ProseMirror state">
42+
<button onClick={() => editor && console.log(editor.pm.state)} title="Log ProseMirror state" disabled={!editor}>
4343
Log PM State
4444
</button>
4545
</div>

packages/react/src/components/BubbleMenu.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ import { ColorPicker } from './ColorPicker';
3636
*/
3737
export interface BubbleMenuProps {
3838
/**
39-
* The OpenBlockEditor instance.
39+
* The OpenBlockEditor instance (can be null during initialization).
4040
*/
41-
editor: OpenBlockEditor;
41+
editor: OpenBlockEditor | null;
4242

4343
/**
4444
* Custom render function for the menu content.
@@ -402,6 +402,8 @@ export function BubbleMenu({
402402
const linkButtonRef = useRef<HTMLButtonElement>(null);
403403

404404
useEffect(() => {
405+
if (!editor || editor.isDestroyed) return;
406+
405407
const updateState = () => {
406408
const state = BUBBLE_MENU_PLUGIN_KEY.getState(editor.pm.state);
407409
setMenuState(state ?? null);
@@ -421,26 +423,31 @@ export function BubbleMenu({
421423
}, [menuState?.visible]);
422424

423425
const toggleBold = useCallback(() => {
426+
if (!editor || editor.isDestroyed) return;
424427
editor.toggleBold();
425428
editor.pm.view.focus();
426429
}, [editor]);
427430

428431
const toggleItalic = useCallback(() => {
432+
if (!editor || editor.isDestroyed) return;
429433
editor.toggleItalic();
430434
editor.pm.view.focus();
431435
}, [editor]);
432436

433437
const toggleUnderline = useCallback(() => {
438+
if (!editor || editor.isDestroyed) return;
434439
editor.toggleUnderline();
435440
editor.pm.view.focus();
436441
}, [editor]);
437442

438443
const toggleStrikethrough = useCallback(() => {
444+
if (!editor || editor.isDestroyed) return;
439445
editor.toggleStrikethrough();
440446
editor.pm.view.focus();
441447
}, [editor]);
442448

443449
const toggleCode = useCallback(() => {
450+
if (!editor || editor.isDestroyed) return;
444451
editor.toggleCode();
445452
editor.pm.view.focus();
446453
}, [editor]);
@@ -453,7 +460,7 @@ export function BubbleMenu({
453460
setShowLinkPopover(false);
454461
}, []);
455462

456-
if (!menuState?.visible || !menuState.coords) {
463+
if (!editor || editor.isDestroyed || !menuState?.visible || !menuState.coords) {
457464
return null;
458465
}
459466

packages/react/src/components/OpenBlockView.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ import { OpenBlockEditor } from '@labbs/openblock-core';
2828
*/
2929
export interface OpenBlockViewProps {
3030
/**
31-
* The OpenBlockEditor instance to render
31+
* The OpenBlockEditor instance to render (can be null during initialization)
3232
*/
33-
editor: OpenBlockEditor;
33+
editor: OpenBlockEditor | null;
3434

3535
/**
3636
* Additional class name(s) for the container
@@ -58,9 +58,9 @@ export interface OpenBlockViewRef {
5858
container: HTMLDivElement | null;
5959

6060
/**
61-
* The OpenBlockEditor instance
61+
* The OpenBlockEditor instance (can be null during initialization)
6262
*/
63-
editor: OpenBlockEditor;
63+
editor: OpenBlockEditor | null;
6464
}
6565

6666
/**

packages/react/src/components/SlashMenu.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ import {
3737
*/
3838
export interface SlashMenuProps {
3939
/**
40-
* The OpenBlockEditor instance.
40+
* The OpenBlockEditor instance (can be null during initialization).
4141
*/
42-
editor: OpenBlockEditor;
42+
editor: OpenBlockEditor | null;
4343

4444
/**
4545
* Custom menu items (optional).
@@ -121,12 +121,14 @@ export function SlashMenu({
121121
const [openUpward, setOpenUpward] = useState(false);
122122
const menuRef = useRef<HTMLDivElement>(null);
123123

124-
// Get menu items
125-
const allItems = customItems ?? getDefaultSlashMenuItems(editor.pm.state.schema);
124+
// Get menu items (only when editor is available)
125+
const allItems = editor ? (customItems ?? getDefaultSlashMenuItems(editor.pm.state.schema)) : [];
126126
const filteredItems = menuState ? filterSlashMenuItems(allItems, menuState.query) : [];
127127

128128
// Subscribe to plugin state changes
129129
useEffect(() => {
130+
if (!editor || editor.isDestroyed) return;
131+
130132
const updateState = () => {
131133
const state = SLASH_MENU_PLUGIN_KEY.getState(editor.pm.state);
132134
setMenuState(state ?? null);
@@ -143,7 +145,7 @@ export function SlashMenu({
143145

144146
// Handle keyboard navigation
145147
useEffect(() => {
146-
if (!menuState?.active) return;
148+
if (!editor || editor.isDestroyed || !menuState?.active) return;
147149

148150
const handleKeyDown = (event: KeyboardEvent) => {
149151
switch (event.key) {
@@ -175,7 +177,7 @@ export function SlashMenu({
175177
// Handle item selection
176178
const handleSelect = useCallback(
177179
(item: SlashMenuItem) => {
178-
if (!menuState) return;
180+
if (!editor || editor.isDestroyed || !menuState) return;
179181
executeSlashCommand(editor.pm.view, menuState, item.action);
180182
editor.pm.view.focus();
181183
},

packages/react/src/components/TableHandles.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ import {
3636
*/
3737
export interface TableHandlesProps {
3838
/**
39-
* The OpenBlockEditor instance.
39+
* The OpenBlockEditor instance (can be null during initialization).
4040
*/
41-
editor: OpenBlockEditor;
41+
editor: OpenBlockEditor | null;
4242

4343
/**
4444
* Additional class name for the handles container.
@@ -144,6 +144,8 @@ export function TableHandles({
144144

145145
// Track mouse position to detect which row/col is hovered
146146
useEffect(() => {
147+
if (!editor || editor.isDestroyed) return;
148+
147149
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
148150

149151
const clearHideTimeout = () => {
@@ -286,7 +288,7 @@ export function TableHandles({
286288

287289
const handleAddRow = useCallback(
288290
(index: number) => {
289-
if (!tableState) return;
291+
if (!editor || editor.isDestroyed || !tableState) return;
290292
addRowAtIndex(editor.pm.state, editor.pm.view.dispatch, tableState.tablePos, index);
291293
editor.pm.view.focus();
292294
setShowRowMenu(null);
@@ -296,7 +298,7 @@ export function TableHandles({
296298

297299
const handleDeleteRow = useCallback(
298300
(index: number) => {
299-
if (!tableState) return;
301+
if (!editor || editor.isDestroyed || !tableState) return;
300302
deleteRowAtIndex(editor.pm.state, editor.pm.view.dispatch, tableState.tablePos, index);
301303
editor.pm.view.focus();
302304
setShowRowMenu(null);
@@ -306,7 +308,7 @@ export function TableHandles({
306308

307309
const handleAddCol = useCallback(
308310
(index: number) => {
309-
if (!tableState) return;
311+
if (!editor || editor.isDestroyed || !tableState) return;
310312
addColumnAtIndex(editor.pm.state, editor.pm.view.dispatch, tableState.tablePos, index);
311313
editor.pm.view.focus();
312314
setShowColMenu(null);
@@ -316,15 +318,15 @@ export function TableHandles({
316318

317319
const handleDeleteCol = useCallback(
318320
(index: number) => {
319-
if (!tableState) return;
321+
if (!editor || editor.isDestroyed || !tableState) return;
320322
deleteColumnAtIndex(editor.pm.state, editor.pm.view.dispatch, tableState.tablePos, index);
321323
editor.pm.view.focus();
322324
setShowColMenu(null);
323325
},
324326
[editor, tableState]
325327
);
326328

327-
if (!tableState) return null;
329+
if (!editor || editor.isDestroyed || !tableState) return null;
328330

329331
const tableRect = tableState.tableElement.getBoundingClientRect();
330332

packages/react/src/components/TableMenu.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ import {
3939
*/
4040
export interface TableMenuProps {
4141
/**
42-
* The OpenBlockEditor instance.
42+
* The OpenBlockEditor instance (can be null during initialization).
4343
*/
44-
editor: OpenBlockEditor;
44+
editor: OpenBlockEditor | null;
4545

4646
/**
4747
* Additional class name for the menu container.
@@ -207,6 +207,8 @@ export function TableMenu({
207207
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
208208

209209
useEffect(() => {
210+
if (!editor || editor.isDestroyed) return;
211+
210212
const updateState = () => {
211213
const state = editor.pm.state;
212214
const isInsideTable = isInTable(state);
@@ -247,41 +249,48 @@ export function TableMenu({
247249
}, [editor]);
248250

249251
const handleAddRowBefore = useCallback(() => {
252+
if (!editor || editor.isDestroyed) return;
250253
addRowBefore(editor.pm.state, editor.pm.view.dispatch);
251254
editor.pm.view.focus();
252255
}, [editor]);
253256

254257
const handleAddRowAfter = useCallback(() => {
258+
if (!editor || editor.isDestroyed) return;
255259
addRowAfter(editor.pm.state, editor.pm.view.dispatch);
256260
editor.pm.view.focus();
257261
}, [editor]);
258262

259263
const handleDeleteRow = useCallback(() => {
264+
if (!editor || editor.isDestroyed) return;
260265
deleteRow(editor.pm.state, editor.pm.view.dispatch);
261266
editor.pm.view.focus();
262267
}, [editor]);
263268

264269
const handleAddColumnBefore = useCallback(() => {
270+
if (!editor || editor.isDestroyed) return;
265271
addColumnBefore(editor.pm.state, editor.pm.view.dispatch);
266272
editor.pm.view.focus();
267273
}, [editor]);
268274

269275
const handleAddColumnAfter = useCallback(() => {
276+
if (!editor || editor.isDestroyed) return;
270277
addColumnAfter(editor.pm.state, editor.pm.view.dispatch);
271278
editor.pm.view.focus();
272279
}, [editor]);
273280

274281
const handleDeleteColumn = useCallback(() => {
282+
if (!editor || editor.isDestroyed) return;
275283
deleteColumn(editor.pm.state, editor.pm.view.dispatch);
276284
editor.pm.view.focus();
277285
}, [editor]);
278286

279287
const handleDeleteTable = useCallback(() => {
288+
if (!editor || editor.isDestroyed) return;
280289
deleteTable(editor.pm.state, editor.pm.view.dispatch);
281290
editor.pm.view.focus();
282291
}, [editor]);
283292

284-
if (!inTable || !coords) {
293+
if (!editor || editor.isDestroyed || !inTable || !coords) {
285294
return null;
286295
}
287296

packages/react/src/hooks/useOpenBlock.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
* ```
2020
*/
2121

22-
import { useEffect, useState } from 'react';
22+
import { useEffect, useRef, useState } from 'react';
2323
import { OpenBlockEditor, EditorConfig, Block } from '@labbs/openblock-core';
2424

2525
/**
@@ -31,21 +31,25 @@ export interface UseOpenBlockOptions extends Omit<EditorConfig, 'element'> {}
3131
* Create and manage an OpenBlockEditor instance
3232
*
3333
* @param options - Editor configuration options
34-
* @returns The OpenBlockEditor instance
34+
* @returns The OpenBlockEditor instance, or null during initialization
35+
*
36+
* @remarks
37+
* This hook properly handles React 18+ StrictMode, which mounts components twice
38+
* in development. The editor is created in useEffect to ensure a fresh instance
39+
* is created after each mount/unmount cycle.
3540
*/
36-
export function useOpenBlock(options: UseOpenBlockOptions = {}): OpenBlockEditor {
37-
// Use useState with lazy initializer to create the editor exactly once
38-
// This is React 18+ safe and works with StrictMode
39-
const [editor] = useState(() => new OpenBlockEditor(options));
41+
export function useOpenBlock(options: UseOpenBlockOptions = {}): OpenBlockEditor | null {
42+
const [editor, setEditor] = useState<OpenBlockEditor | null>(null);
43+
const optionsRef = useRef(options);
4044

41-
// Cleanup on unmount only
4245
useEffect(() => {
46+
const newEditor = new OpenBlockEditor(optionsRef.current);
47+
setEditor(newEditor);
48+
4349
return () => {
44-
if (!editor.isDestroyed) {
45-
editor.destroy();
46-
}
50+
newEditor.destroy();
4751
};
48-
}, [editor]);
52+
}, []);
4953

5054
return editor;
5155
}

0 commit comments

Comments
 (0)