Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions examples/basic/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,26 @@ export default function App() {
<div className="container">
<div className="editor-section">
<div className="toolbar">
<button onClick={() => editor.toggleBold()} title="Bold (Cmd+B)">
<button onClick={() => editor?.toggleBold()} title="Bold (Cmd+B)" disabled={!editor}>
<strong>B</strong>
</button>
<button onClick={() => editor.toggleItalic()} title="Italic (Cmd+I)">
<button onClick={() => editor?.toggleItalic()} title="Italic (Cmd+I)" disabled={!editor}>
<em>I</em>
</button>
<button onClick={() => editor.toggleUnderline()} title="Underline (Cmd+U)">
<button onClick={() => editor?.toggleUnderline()} title="Underline (Cmd+U)" disabled={!editor}>
<u>U</u>
</button>
<button onClick={() => editor.toggleStrikethrough()} title="Strikethrough">
<button onClick={() => editor?.toggleStrikethrough()} title="Strikethrough" disabled={!editor}>
<s>S</s>
</button>
<button onClick={() => editor.toggleCode()} title="Code">
<button onClick={() => editor?.toggleCode()} title="Code" disabled={!editor}>
{'</>'}
</button>
<span className="separator" />
<button onClick={() => console.log(editor.getDocument())} title="Log document to console">
<button onClick={() => editor && console.log(editor.getDocument())} title="Log document to console" disabled={!editor}>
Log JSON
</button>
<button onClick={() => console.log(editor.pm.state)} title="Log ProseMirror state">
<button onClick={() => editor && console.log(editor.pm.state)} title="Log ProseMirror state" disabled={!editor}>
Log PM State
</button>
</div>
Expand Down
13 changes: 10 additions & 3 deletions packages/react/src/components/BubbleMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -402,6 +402,8 @@ export function BubbleMenu({
const linkButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
if (!editor || editor.isDestroyed) return;

const updateState = () => {
const state = BUBBLE_MENU_PLUGIN_KEY.getState(editor.pm.state);
setMenuState(state ?? null);
Expand All @@ -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]);
Expand All @@ -453,7 +460,7 @@ export function BubbleMenu({
setShowLinkPopover(false);
}, []);

if (!menuState?.visible || !menuState.coords) {
if (!editor || editor.isDestroyed || !menuState?.visible || !menuState.coords) {
return null;
}

Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/components/OpenBlockView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand Down
14 changes: 8 additions & 6 deletions packages/react/src/components/SlashMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -121,12 +121,14 @@ export function SlashMenu({
const [openUpward, setOpenUpward] = useState(false);
const menuRef = useRef<HTMLDivElement>(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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
},
Expand Down
16 changes: 9 additions & 7 deletions packages/react/src/components/TableHandles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;

const clearHideTimeout = () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -316,15 +318,15 @@ 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);
},
[editor, tableState]
);

if (!tableState) return null;
if (!editor || editor.isDestroyed || !tableState) return null;

const tableRect = tableState.tableElement.getBoundingClientRect();

Expand Down
15 changes: 12 additions & 3 deletions packages/react/src/components/TableMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
26 changes: 15 additions & 11 deletions packages/react/src/hooks/useOpenBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* ```
*/

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

/**
Expand All @@ -31,21 +31,25 @@ export interface UseOpenBlockOptions extends Omit<EditorConfig, 'element'> {}
* 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<OpenBlockEditor | null>(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;
}
Expand Down