@tiptap/react
Advanced tools
Comparing version 2.5.8 to 2.5.9
@@ -129,13 +129,2 @@ import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'; | ||
class Editor extends Editor$1 { | ||
constructor() { | ||
super(...arguments); | ||
this.contentComponent = null; | ||
} | ||
} | ||
var withSelector = {exports: {}}; | ||
var withSelector_production_min = {}; | ||
var shim = {exports: {}}; | ||
@@ -411,16 +400,21 @@ | ||
var hasRequiredShim; | ||
if (process.env.NODE_ENV === 'production') { | ||
shim.exports = requireUseSyncExternalStoreShim_production_min(); | ||
} else { | ||
shim.exports = requireUseSyncExternalStoreShim_development(); | ||
} | ||
function requireShim () { | ||
if (hasRequiredShim) return shim.exports; | ||
hasRequiredShim = 1; | ||
var shimExports = shim.exports; | ||
if (process.env.NODE_ENV === 'production') { | ||
shim.exports = requireUseSyncExternalStoreShim_production_min(); | ||
} else { | ||
shim.exports = requireUseSyncExternalStoreShim_development(); | ||
} | ||
return shim.exports; | ||
class Editor extends Editor$1 { | ||
constructor() { | ||
super(...arguments); | ||
this.contentComponent = null; | ||
} | ||
} | ||
var withSelector = {exports: {}}; | ||
var withSelector_production_min = {}; | ||
/** | ||
@@ -441,3 +435,3 @@ * @license React | ||
hasRequiredWithSelector_production_min = 1; | ||
var h=React,n=requireShim();function p(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}var q="function"===typeof Object.is?Object.is:p,r=n.useSyncExternalStore,t=h.useRef,u=h.useEffect,v=h.useMemo,w=h.useDebugValue; | ||
var h=React,n=shimExports;function p(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}var q="function"===typeof Object.is?Object.is:p,r=n.useSyncExternalStore,t=h.useRef,u=h.useEffect,v=h.useMemo,w=h.useDebugValue; | ||
withSelector_production_min.useSyncExternalStoreWithSelector=function(a,b,e,l,g){var c=t(null);if(null===c.current){var f={hasValue:!1,value:null};c.current=f;}else f=c.current;c=v(function(){function a(a){if(!c){c=!0;d=a;a=l(a);if(void 0!==g&&f.hasValue){var b=f.value;if(g(b,a))return k=b}return k=a}b=k;if(q(d,a))return b;var e=l(a);if(void 0!==g&&g(b,e))return b;d=a;return k=e}var c=!1,d,k,m=void 0===e?null:e;return [function(){return a(b())},null===m?void 0:function(){return a(m())}]},[b,e,l,g]);var d=r(a,c[0],c[1]); | ||
@@ -478,3 +472,3 @@ u(function(){f.hasValue=!0;f.value=d;},[d]);w(d);return d}; | ||
var React$1 = React; | ||
var shim = requireShim(); | ||
var shim = shimExports; | ||
@@ -633,62 +627,66 @@ /** | ||
*/ | ||
function makeEditorStateInstance(initialEditor) { | ||
let transactionNumber = 0; | ||
let lastTransactionNumber = 0; | ||
let lastSnapshot = { editor: initialEditor, transactionNumber: 0 }; | ||
let editor = initialEditor; | ||
const subscribers = new Set(); | ||
const editorInstance = { | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getSnapshot() { | ||
if (transactionNumber === lastTransactionNumber) { | ||
return lastSnapshot; | ||
} | ||
lastTransactionNumber = transactionNumber; | ||
lastSnapshot = { editor, transactionNumber }; | ||
return lastSnapshot; | ||
}, | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot() { | ||
return { editor: null, transactionNumber: 0 }; | ||
}, | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(callback) { | ||
subscribers.add(callback); | ||
class EditorStateManager { | ||
constructor(initialEditor) { | ||
this.transactionNumber = 0; | ||
this.lastTransactionNumber = 0; | ||
this.subscribers = new Set(); | ||
this.editor = initialEditor; | ||
this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 }; | ||
this.getSnapshot = this.getSnapshot.bind(this); | ||
this.getServerSnapshot = this.getServerSnapshot.bind(this); | ||
this.watch = this.watch.bind(this); | ||
this.subscribe = this.subscribe.bind(this); | ||
} | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getSnapshot() { | ||
if (this.transactionNumber === this.lastTransactionNumber) { | ||
return this.lastSnapshot; | ||
} | ||
this.lastTransactionNumber = this.transactionNumber; | ||
this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber }; | ||
return this.lastSnapshot; | ||
} | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot() { | ||
return { editor: null, transactionNumber: 0 }; | ||
} | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(callback) { | ||
this.subscribers.add(callback); | ||
return () => { | ||
this.subscribers.delete(callback); | ||
}; | ||
} | ||
/** | ||
* Watch the editor instance for changes. | ||
*/ | ||
watch(nextEditor) { | ||
this.editor = nextEditor; | ||
if (this.editor) { | ||
/** | ||
* This will force a re-render when the editor state changes. | ||
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`. | ||
* This could be more efficient, but it's a good trade-off for now. | ||
*/ | ||
const fn = () => { | ||
this.transactionNumber += 1; | ||
this.subscribers.forEach(callback => callback()); | ||
}; | ||
const currentEditor = this.editor; | ||
currentEditor.on('transaction', fn); | ||
return () => { | ||
subscribers.delete(callback); | ||
currentEditor.off('transaction', fn); | ||
}; | ||
}, | ||
/** | ||
* Watch the editor instance for changes. | ||
*/ | ||
watch(nextEditor) { | ||
editor = nextEditor; | ||
if (editor) { | ||
/** | ||
* This will force a re-render when the editor state changes. | ||
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`. | ||
* This could be more efficient, but it's a good trade-off for now. | ||
*/ | ||
const fn = () => { | ||
transactionNumber += 1; | ||
subscribers.forEach(callback => callback()); | ||
}; | ||
const currentEditor = editor; | ||
currentEditor.on('transaction', fn); | ||
return () => { | ||
currentEditor.off('transaction', fn); | ||
}; | ||
} | ||
}, | ||
}; | ||
return editorInstance; | ||
} | ||
return undefined; | ||
} | ||
} | ||
function useEditorState(options) { | ||
const [editorInstance] = useState(() => makeEditorStateInstance(options.editor)); | ||
const [editorInstance] = useState(() => new EditorStateManager(options.editor)); | ||
// Using the `useSyncExternalStore` hook to sync the editor instance with the component state | ||
@@ -698,3 +696,3 @@ const selectedState = withSelectorExports.useSyncExternalStoreWithSelector(editorInstance.subscribe, editorInstance.getSnapshot, editorInstance.getServerSnapshot, options.selector, options.equalityFn); | ||
return editorInstance.watch(options.editor); | ||
}, [options.editor]); | ||
}, [options.editor, editorInstance]); | ||
useDebugValue(selectedState); | ||
@@ -708,21 +706,46 @@ return selectedState; | ||
/** | ||
* Create a new editor instance. And attach event listeners. | ||
* This class handles the creation, destruction, and re-creation of the editor instance. | ||
*/ | ||
function createEditor(options) { | ||
const editor = new Editor(options.current); | ||
editor.on('beforeCreate', (...args) => { var _a, _b; return (_b = (_a = options.current).onBeforeCreate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('blur', (...args) => { var _a, _b; return (_b = (_a = options.current).onBlur) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('create', (...args) => { var _a, _b; return (_b = (_a = options.current).onCreate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('destroy', (...args) => { var _a, _b; return (_b = (_a = options.current).onDestroy) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('focus', (...args) => { var _a, _b; return (_b = (_a = options.current).onFocus) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('selectionUpdate', (...args) => { var _a, _b; return (_b = (_a = options.current).onSelectionUpdate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('transaction', (...args) => { var _a, _b; return (_b = (_a = options.current).onTransaction) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('update', (...args) => { var _a, _b; return (_b = (_a = options.current).onUpdate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('contentError', (...args) => { var _a, _b; return (_b = (_a = options.current).onContentError) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
return editor; | ||
} | ||
function useEditor(options = {}, deps = []) { | ||
const mostRecentOptions = useRef(options); | ||
const [editor, setEditor] = useState(() => { | ||
if (options.immediatelyRender === undefined) { | ||
class EditorInstanceManager { | ||
constructor(options) { | ||
/** | ||
* The current editor instance. | ||
*/ | ||
this.editor = null; | ||
/** | ||
* The subscriptions to notify when the editor instance | ||
* has been created or destroyed. | ||
*/ | ||
this.subscriptions = new Set(); | ||
/** | ||
* Whether the editor has been mounted. | ||
*/ | ||
this.isComponentMounted = false; | ||
/** | ||
* The most recent dependencies array. | ||
*/ | ||
this.previousDeps = null; | ||
/** | ||
* The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance. | ||
*/ | ||
this.instanceId = ''; | ||
this.options = options; | ||
this.subscriptions = new Set(); | ||
this.setEditor(this.getInitialEditor()); | ||
this.getEditor = this.getEditor.bind(this); | ||
this.getServerSnapshot = this.getServerSnapshot.bind(this); | ||
this.subscribe = this.subscribe.bind(this); | ||
this.refreshEditorInstance = this.refreshEditorInstance.bind(this); | ||
this.scheduleDestroy = this.scheduleDestroy.bind(this); | ||
this.onRender = this.onRender.bind(this); | ||
this.createEditor = this.createEditor.bind(this); | ||
} | ||
setEditor(editor) { | ||
this.editor = editor; | ||
this.instanceId = Math.random().toString(36).slice(2, 9); | ||
// Notify all subscribers that the editor instance has been created | ||
this.subscriptions.forEach(cb => cb()); | ||
} | ||
getInitialEditor() { | ||
if (this.options.current.immediatelyRender === undefined) { | ||
if (isSSR || isNext) { | ||
@@ -741,51 +764,144 @@ // TODO in the next major release, we should throw an error here | ||
// Default to immediately rendering when client-side rendering | ||
return createEditor(mostRecentOptions); | ||
return this.createEditor(); | ||
} | ||
if (options.immediatelyRender && isSSR && isDev) { | ||
if (this.options.current.immediatelyRender && isSSR && isDev) { | ||
// Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches. | ||
throw new Error('Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.'); | ||
} | ||
if (options.immediatelyRender) { | ||
return createEditor(mostRecentOptions); | ||
if (this.options.current.immediatelyRender) { | ||
return this.createEditor(); | ||
} | ||
return null; | ||
}); | ||
const mostRecentEditor = useRef(editor); | ||
mostRecentEditor.current = editor; | ||
useDebugValue(editor); | ||
// This effect will handle creating/updating the editor instance | ||
useEffect(() => { | ||
const destroyUnusedEditor = (editorInstance) => { | ||
if (editorInstance) { | ||
// We need to destroy the editor asynchronously to avoid memory leaks | ||
// because the editor instance is still being used in the component. | ||
setTimeout(() => { | ||
// re-use the editor instance if it hasn't been replaced yet | ||
// otherwise, asynchronously destroy the old editor instance | ||
if (editorInstance !== mostRecentEditor.current && !editorInstance.isDestroyed) { | ||
editorInstance.destroy(); | ||
} | ||
}); | ||
} | ||
/** | ||
* Create a new editor instance. And attach event listeners. | ||
*/ | ||
createEditor() { | ||
const editor = new Editor(this.options.current); | ||
// Always call the most recent version of the callback function by default | ||
editor.on('beforeCreate', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onBeforeCreate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('blur', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onBlur) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('create', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onCreate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('destroy', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onDestroy) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('focus', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onFocus) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('selectionUpdate', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onSelectionUpdate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('transaction', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onTransaction) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('update', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onUpdate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('contentError', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onContentError) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
// no need to keep track of the event listeners, they will be removed when the editor is destroyed | ||
return editor; | ||
} | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getEditor() { | ||
return this.editor; | ||
} | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot() { | ||
return null; | ||
} | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(onStoreChange) { | ||
this.subscriptions.add(onStoreChange); | ||
return () => { | ||
this.subscriptions.delete(onStoreChange); | ||
}; | ||
} | ||
/** | ||
* On each render, we will create, update, or destroy the editor instance. | ||
* @param deps The dependencies to watch for changes | ||
* @returns A cleanup function | ||
*/ | ||
onRender(deps) { | ||
// The returned callback will run on each render | ||
return () => { | ||
this.isComponentMounted = true; | ||
// Cleanup any scheduled destructions, since we are currently rendering | ||
clearTimeout(this.scheduledDestructionTimeout); | ||
if (this.editor && !this.editor.isDestroyed && deps.length === 0) { | ||
// if the editor does exist & deps are empty, we don't need to re-initialize the editor | ||
// we can fast-path to update the editor options on the existing instance | ||
this.editor.setOptions(this.options.current); | ||
} | ||
else { | ||
// When the editor: | ||
// - does not yet exist | ||
// - is destroyed | ||
// - the deps array changes | ||
// We need to destroy the editor instance and re-initialize it | ||
this.refreshEditorInstance(deps); | ||
} | ||
return () => { | ||
this.isComponentMounted = false; | ||
this.scheduleDestroy(); | ||
}; | ||
}; | ||
let editorInstance = mostRecentEditor.current; | ||
if (!editorInstance) { | ||
editorInstance = createEditor(mostRecentOptions); | ||
setEditor(editorInstance); | ||
return () => destroyUnusedEditor(editorInstance); | ||
} | ||
/** | ||
* Recreate the editor instance if the dependencies have changed. | ||
*/ | ||
refreshEditorInstance(deps) { | ||
if (this.editor && !this.editor.isDestroyed) { | ||
// Editor instance already exists | ||
if (this.previousDeps === null) { | ||
// If lastDeps has not yet been initialized, reuse the current editor instance | ||
this.previousDeps = deps; | ||
return; | ||
} | ||
const depsAreEqual = this.previousDeps.length === deps.length | ||
&& this.previousDeps.every((dep, index) => dep === deps[index]); | ||
if (depsAreEqual) { | ||
// deps exist and are equal, no need to recreate | ||
return; | ||
} | ||
} | ||
if (!Array.isArray(deps) || deps.length === 0) { | ||
// if the editor does exist & deps are empty, we don't need to re-initialize the editor | ||
// we can fast-path to update the editor options on the existing instance | ||
editorInstance.setOptions(options); | ||
return () => destroyUnusedEditor(editorInstance); | ||
if (this.editor && !this.editor.isDestroyed) { | ||
// Destroy the editor instance if it exists | ||
this.editor.destroy(); | ||
} | ||
// We need to destroy the editor instance and re-initialize it | ||
// when the deps array changes | ||
editorInstance.destroy(); | ||
// the deps array is used to re-initialize the editor instance | ||
editorInstance = createEditor(mostRecentOptions); | ||
setEditor(editorInstance); | ||
return () => destroyUnusedEditor(editorInstance); | ||
}, deps); | ||
this.setEditor(this.createEditor()); | ||
// Update the lastDeps to the current deps | ||
this.previousDeps = deps; | ||
} | ||
/** | ||
* Schedule the destruction of the editor instance. | ||
* This will only destroy the editor if it was not mounted on the next tick. | ||
* This is to avoid destroying the editor instance when it's actually still mounted. | ||
*/ | ||
scheduleDestroy() { | ||
const currentInstanceId = this.instanceId; | ||
const currentEditor = this.editor; | ||
// Wait a tick to see if the component is still mounted | ||
this.scheduledDestructionTimeout = setTimeout(() => { | ||
if (this.isComponentMounted && this.instanceId === currentInstanceId) { | ||
// If still mounted on the next tick, with the same instanceId, do not destroy the editor | ||
if (currentEditor) { | ||
// just re-apply options as they might have changed | ||
currentEditor.setOptions(this.options.current); | ||
} | ||
return; | ||
} | ||
if (currentEditor && !currentEditor.isDestroyed) { | ||
currentEditor.destroy(); | ||
if (this.instanceId === currentInstanceId) { | ||
this.setEditor(null); | ||
} | ||
} | ||
}, 0); | ||
} | ||
} | ||
function useEditor(options = {}, deps = []) { | ||
const mostRecentOptions = useRef(options); | ||
mostRecentOptions.current = options; | ||
const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions)); | ||
const editor = shimExports.useSyncExternalStore(instanceManager.subscribe, instanceManager.getEditor, instanceManager.getServerSnapshot); | ||
useDebugValue(editor); | ||
// This effect will handle creating/updating the editor instance | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
useEffect(instanceManager.onRender(deps)); | ||
// The default behavior is to re-render on each transaction | ||
@@ -792,0 +908,0 @@ // This is legacy behavior that will be removed in future versions |
@@ -128,13 +128,2 @@ (function (global, factory) { | ||
class Editor extends core.Editor { | ||
constructor() { | ||
super(...arguments); | ||
this.contentComponent = null; | ||
} | ||
} | ||
var withSelector = {exports: {}}; | ||
var withSelector_production_min = {}; | ||
var shim = {exports: {}}; | ||
@@ -410,16 +399,21 @@ | ||
var hasRequiredShim; | ||
if (process.env.NODE_ENV === 'production') { | ||
shim.exports = requireUseSyncExternalStoreShim_production_min(); | ||
} else { | ||
shim.exports = requireUseSyncExternalStoreShim_development(); | ||
} | ||
function requireShim () { | ||
if (hasRequiredShim) return shim.exports; | ||
hasRequiredShim = 1; | ||
var shimExports = shim.exports; | ||
if (process.env.NODE_ENV === 'production') { | ||
shim.exports = requireUseSyncExternalStoreShim_production_min(); | ||
} else { | ||
shim.exports = requireUseSyncExternalStoreShim_development(); | ||
} | ||
return shim.exports; | ||
class Editor extends core.Editor { | ||
constructor() { | ||
super(...arguments); | ||
this.contentComponent = null; | ||
} | ||
} | ||
var withSelector = {exports: {}}; | ||
var withSelector_production_min = {}; | ||
/** | ||
@@ -440,3 +434,3 @@ * @license React | ||
hasRequiredWithSelector_production_min = 1; | ||
var h=React,n=requireShim();function p(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}var q="function"===typeof Object.is?Object.is:p,r=n.useSyncExternalStore,t=h.useRef,u=h.useEffect,v=h.useMemo,w=h.useDebugValue; | ||
var h=React,n=shimExports;function p(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}var q="function"===typeof Object.is?Object.is:p,r=n.useSyncExternalStore,t=h.useRef,u=h.useEffect,v=h.useMemo,w=h.useDebugValue; | ||
withSelector_production_min.useSyncExternalStoreWithSelector=function(a,b,e,l,g){var c=t(null);if(null===c.current){var f={hasValue:!1,value:null};c.current=f;}else f=c.current;c=v(function(){function a(a){if(!c){c=!0;d=a;a=l(a);if(void 0!==g&&f.hasValue){var b=f.value;if(g(b,a))return k=b}return k=a}b=k;if(q(d,a))return b;var e=l(a);if(void 0!==g&&g(b,e))return b;d=a;return k=e}var c=!1,d,k,m=void 0===e?null:e;return [function(){return a(b())},null===m?void 0:function(){return a(m())}]},[b,e,l,g]);var d=r(a,c[0],c[1]); | ||
@@ -477,3 +471,3 @@ u(function(){f.hasValue=!0;f.value=d;},[d]);w(d);return d}; | ||
var React$1 = React; | ||
var shim = requireShim(); | ||
var shim = shimExports; | ||
@@ -632,62 +626,66 @@ /** | ||
*/ | ||
function makeEditorStateInstance(initialEditor) { | ||
let transactionNumber = 0; | ||
let lastTransactionNumber = 0; | ||
let lastSnapshot = { editor: initialEditor, transactionNumber: 0 }; | ||
let editor = initialEditor; | ||
const subscribers = new Set(); | ||
const editorInstance = { | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getSnapshot() { | ||
if (transactionNumber === lastTransactionNumber) { | ||
return lastSnapshot; | ||
} | ||
lastTransactionNumber = transactionNumber; | ||
lastSnapshot = { editor, transactionNumber }; | ||
return lastSnapshot; | ||
}, | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot() { | ||
return { editor: null, transactionNumber: 0 }; | ||
}, | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(callback) { | ||
subscribers.add(callback); | ||
class EditorStateManager { | ||
constructor(initialEditor) { | ||
this.transactionNumber = 0; | ||
this.lastTransactionNumber = 0; | ||
this.subscribers = new Set(); | ||
this.editor = initialEditor; | ||
this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 }; | ||
this.getSnapshot = this.getSnapshot.bind(this); | ||
this.getServerSnapshot = this.getServerSnapshot.bind(this); | ||
this.watch = this.watch.bind(this); | ||
this.subscribe = this.subscribe.bind(this); | ||
} | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getSnapshot() { | ||
if (this.transactionNumber === this.lastTransactionNumber) { | ||
return this.lastSnapshot; | ||
} | ||
this.lastTransactionNumber = this.transactionNumber; | ||
this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber }; | ||
return this.lastSnapshot; | ||
} | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot() { | ||
return { editor: null, transactionNumber: 0 }; | ||
} | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(callback) { | ||
this.subscribers.add(callback); | ||
return () => { | ||
this.subscribers.delete(callback); | ||
}; | ||
} | ||
/** | ||
* Watch the editor instance for changes. | ||
*/ | ||
watch(nextEditor) { | ||
this.editor = nextEditor; | ||
if (this.editor) { | ||
/** | ||
* This will force a re-render when the editor state changes. | ||
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`. | ||
* This could be more efficient, but it's a good trade-off for now. | ||
*/ | ||
const fn = () => { | ||
this.transactionNumber += 1; | ||
this.subscribers.forEach(callback => callback()); | ||
}; | ||
const currentEditor = this.editor; | ||
currentEditor.on('transaction', fn); | ||
return () => { | ||
subscribers.delete(callback); | ||
currentEditor.off('transaction', fn); | ||
}; | ||
}, | ||
/** | ||
* Watch the editor instance for changes. | ||
*/ | ||
watch(nextEditor) { | ||
editor = nextEditor; | ||
if (editor) { | ||
/** | ||
* This will force a re-render when the editor state changes. | ||
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`. | ||
* This could be more efficient, but it's a good trade-off for now. | ||
*/ | ||
const fn = () => { | ||
transactionNumber += 1; | ||
subscribers.forEach(callback => callback()); | ||
}; | ||
const currentEditor = editor; | ||
currentEditor.on('transaction', fn); | ||
return () => { | ||
currentEditor.off('transaction', fn); | ||
}; | ||
} | ||
}, | ||
}; | ||
return editorInstance; | ||
} | ||
return undefined; | ||
} | ||
} | ||
function useEditorState(options) { | ||
const [editorInstance] = React.useState(() => makeEditorStateInstance(options.editor)); | ||
const [editorInstance] = React.useState(() => new EditorStateManager(options.editor)); | ||
// Using the `useSyncExternalStore` hook to sync the editor instance with the component state | ||
@@ -697,3 +695,3 @@ const selectedState = withSelectorExports.useSyncExternalStoreWithSelector(editorInstance.subscribe, editorInstance.getSnapshot, editorInstance.getServerSnapshot, options.selector, options.equalityFn); | ||
return editorInstance.watch(options.editor); | ||
}, [options.editor]); | ||
}, [options.editor, editorInstance]); | ||
React.useDebugValue(selectedState); | ||
@@ -707,21 +705,46 @@ return selectedState; | ||
/** | ||
* Create a new editor instance. And attach event listeners. | ||
* This class handles the creation, destruction, and re-creation of the editor instance. | ||
*/ | ||
function createEditor(options) { | ||
const editor = new Editor(options.current); | ||
editor.on('beforeCreate', (...args) => { var _a, _b; return (_b = (_a = options.current).onBeforeCreate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('blur', (...args) => { var _a, _b; return (_b = (_a = options.current).onBlur) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('create', (...args) => { var _a, _b; return (_b = (_a = options.current).onCreate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('destroy', (...args) => { var _a, _b; return (_b = (_a = options.current).onDestroy) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('focus', (...args) => { var _a, _b; return (_b = (_a = options.current).onFocus) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('selectionUpdate', (...args) => { var _a, _b; return (_b = (_a = options.current).onSelectionUpdate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('transaction', (...args) => { var _a, _b; return (_b = (_a = options.current).onTransaction) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('update', (...args) => { var _a, _b; return (_b = (_a = options.current).onUpdate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('contentError', (...args) => { var _a, _b; return (_b = (_a = options.current).onContentError) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
return editor; | ||
} | ||
function useEditor(options = {}, deps = []) { | ||
const mostRecentOptions = React.useRef(options); | ||
const [editor, setEditor] = React.useState(() => { | ||
if (options.immediatelyRender === undefined) { | ||
class EditorInstanceManager { | ||
constructor(options) { | ||
/** | ||
* The current editor instance. | ||
*/ | ||
this.editor = null; | ||
/** | ||
* The subscriptions to notify when the editor instance | ||
* has been created or destroyed. | ||
*/ | ||
this.subscriptions = new Set(); | ||
/** | ||
* Whether the editor has been mounted. | ||
*/ | ||
this.isComponentMounted = false; | ||
/** | ||
* The most recent dependencies array. | ||
*/ | ||
this.previousDeps = null; | ||
/** | ||
* The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance. | ||
*/ | ||
this.instanceId = ''; | ||
this.options = options; | ||
this.subscriptions = new Set(); | ||
this.setEditor(this.getInitialEditor()); | ||
this.getEditor = this.getEditor.bind(this); | ||
this.getServerSnapshot = this.getServerSnapshot.bind(this); | ||
this.subscribe = this.subscribe.bind(this); | ||
this.refreshEditorInstance = this.refreshEditorInstance.bind(this); | ||
this.scheduleDestroy = this.scheduleDestroy.bind(this); | ||
this.onRender = this.onRender.bind(this); | ||
this.createEditor = this.createEditor.bind(this); | ||
} | ||
setEditor(editor) { | ||
this.editor = editor; | ||
this.instanceId = Math.random().toString(36).slice(2, 9); | ||
// Notify all subscribers that the editor instance has been created | ||
this.subscriptions.forEach(cb => cb()); | ||
} | ||
getInitialEditor() { | ||
if (this.options.current.immediatelyRender === undefined) { | ||
if (isSSR || isNext) { | ||
@@ -740,51 +763,144 @@ // TODO in the next major release, we should throw an error here | ||
// Default to immediately rendering when client-side rendering | ||
return createEditor(mostRecentOptions); | ||
return this.createEditor(); | ||
} | ||
if (options.immediatelyRender && isSSR && isDev) { | ||
if (this.options.current.immediatelyRender && isSSR && isDev) { | ||
// Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches. | ||
throw new Error('Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.'); | ||
} | ||
if (options.immediatelyRender) { | ||
return createEditor(mostRecentOptions); | ||
if (this.options.current.immediatelyRender) { | ||
return this.createEditor(); | ||
} | ||
return null; | ||
}); | ||
const mostRecentEditor = React.useRef(editor); | ||
mostRecentEditor.current = editor; | ||
React.useDebugValue(editor); | ||
// This effect will handle creating/updating the editor instance | ||
React.useEffect(() => { | ||
const destroyUnusedEditor = (editorInstance) => { | ||
if (editorInstance) { | ||
// We need to destroy the editor asynchronously to avoid memory leaks | ||
// because the editor instance is still being used in the component. | ||
setTimeout(() => { | ||
// re-use the editor instance if it hasn't been replaced yet | ||
// otherwise, asynchronously destroy the old editor instance | ||
if (editorInstance !== mostRecentEditor.current && !editorInstance.isDestroyed) { | ||
editorInstance.destroy(); | ||
} | ||
}); | ||
} | ||
/** | ||
* Create a new editor instance. And attach event listeners. | ||
*/ | ||
createEditor() { | ||
const editor = new Editor(this.options.current); | ||
// Always call the most recent version of the callback function by default | ||
editor.on('beforeCreate', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onBeforeCreate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('blur', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onBlur) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('create', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onCreate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('destroy', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onDestroy) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('focus', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onFocus) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('selectionUpdate', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onSelectionUpdate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('transaction', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onTransaction) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('update', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onUpdate) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
editor.on('contentError', (...args) => { var _a, _b; return (_b = (_a = this.options.current).onContentError) === null || _b === void 0 ? void 0 : _b.call(_a, ...args); }); | ||
// no need to keep track of the event listeners, they will be removed when the editor is destroyed | ||
return editor; | ||
} | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getEditor() { | ||
return this.editor; | ||
} | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot() { | ||
return null; | ||
} | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(onStoreChange) { | ||
this.subscriptions.add(onStoreChange); | ||
return () => { | ||
this.subscriptions.delete(onStoreChange); | ||
}; | ||
} | ||
/** | ||
* On each render, we will create, update, or destroy the editor instance. | ||
* @param deps The dependencies to watch for changes | ||
* @returns A cleanup function | ||
*/ | ||
onRender(deps) { | ||
// The returned callback will run on each render | ||
return () => { | ||
this.isComponentMounted = true; | ||
// Cleanup any scheduled destructions, since we are currently rendering | ||
clearTimeout(this.scheduledDestructionTimeout); | ||
if (this.editor && !this.editor.isDestroyed && deps.length === 0) { | ||
// if the editor does exist & deps are empty, we don't need to re-initialize the editor | ||
// we can fast-path to update the editor options on the existing instance | ||
this.editor.setOptions(this.options.current); | ||
} | ||
else { | ||
// When the editor: | ||
// - does not yet exist | ||
// - is destroyed | ||
// - the deps array changes | ||
// We need to destroy the editor instance and re-initialize it | ||
this.refreshEditorInstance(deps); | ||
} | ||
return () => { | ||
this.isComponentMounted = false; | ||
this.scheduleDestroy(); | ||
}; | ||
}; | ||
let editorInstance = mostRecentEditor.current; | ||
if (!editorInstance) { | ||
editorInstance = createEditor(mostRecentOptions); | ||
setEditor(editorInstance); | ||
return () => destroyUnusedEditor(editorInstance); | ||
} | ||
/** | ||
* Recreate the editor instance if the dependencies have changed. | ||
*/ | ||
refreshEditorInstance(deps) { | ||
if (this.editor && !this.editor.isDestroyed) { | ||
// Editor instance already exists | ||
if (this.previousDeps === null) { | ||
// If lastDeps has not yet been initialized, reuse the current editor instance | ||
this.previousDeps = deps; | ||
return; | ||
} | ||
const depsAreEqual = this.previousDeps.length === deps.length | ||
&& this.previousDeps.every((dep, index) => dep === deps[index]); | ||
if (depsAreEqual) { | ||
// deps exist and are equal, no need to recreate | ||
return; | ||
} | ||
} | ||
if (!Array.isArray(deps) || deps.length === 0) { | ||
// if the editor does exist & deps are empty, we don't need to re-initialize the editor | ||
// we can fast-path to update the editor options on the existing instance | ||
editorInstance.setOptions(options); | ||
return () => destroyUnusedEditor(editorInstance); | ||
if (this.editor && !this.editor.isDestroyed) { | ||
// Destroy the editor instance if it exists | ||
this.editor.destroy(); | ||
} | ||
// We need to destroy the editor instance and re-initialize it | ||
// when the deps array changes | ||
editorInstance.destroy(); | ||
// the deps array is used to re-initialize the editor instance | ||
editorInstance = createEditor(mostRecentOptions); | ||
setEditor(editorInstance); | ||
return () => destroyUnusedEditor(editorInstance); | ||
}, deps); | ||
this.setEditor(this.createEditor()); | ||
// Update the lastDeps to the current deps | ||
this.previousDeps = deps; | ||
} | ||
/** | ||
* Schedule the destruction of the editor instance. | ||
* This will only destroy the editor if it was not mounted on the next tick. | ||
* This is to avoid destroying the editor instance when it's actually still mounted. | ||
*/ | ||
scheduleDestroy() { | ||
const currentInstanceId = this.instanceId; | ||
const currentEditor = this.editor; | ||
// Wait a tick to see if the component is still mounted | ||
this.scheduledDestructionTimeout = setTimeout(() => { | ||
if (this.isComponentMounted && this.instanceId === currentInstanceId) { | ||
// If still mounted on the next tick, with the same instanceId, do not destroy the editor | ||
if (currentEditor) { | ||
// just re-apply options as they might have changed | ||
currentEditor.setOptions(this.options.current); | ||
} | ||
return; | ||
} | ||
if (currentEditor && !currentEditor.isDestroyed) { | ||
currentEditor.destroy(); | ||
if (this.instanceId === currentInstanceId) { | ||
this.setEditor(null); | ||
} | ||
} | ||
}, 0); | ||
} | ||
} | ||
function useEditor(options = {}, deps = []) { | ||
const mostRecentOptions = React.useRef(options); | ||
mostRecentOptions.current = options; | ||
const [instanceManager] = React.useState(() => new EditorInstanceManager(mostRecentOptions)); | ||
const editor = shimExports.useSyncExternalStore(instanceManager.subscribe, instanceManager.getEditor, instanceManager.getServerSnapshot); | ||
React.useDebugValue(editor); | ||
// This effect will handle creating/updating the editor instance | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
React.useEffect(instanceManager.onRender(deps)); | ||
// The default behavior is to re-render on each transaction | ||
@@ -791,0 +907,0 @@ // This is legacy behavior that will be removed in future versions |
@@ -341,4 +341,4 @@ import { Plugin, Transaction } from '@tiptap/pm/state'; | ||
static create<O = any, S = any>(config?: Partial<ExtensionConfig<O, S>>): Extension<O, S>; | ||
configure(options?: Partial<Options>): Extension<any, any>; | ||
configure(options?: Partial<Options>): Extension<Options, Storage>; | ||
extend<ExtendedOptions = Options, ExtendedStorage = Storage>(extendedConfig?: Partial<ExtensionConfig<ExtendedOptions, ExtendedStorage>>): Extension<ExtendedOptions, ExtendedStorage>; | ||
} |
import { Node as ProseMirrorNode } from '@tiptap/pm/model'; | ||
/** | ||
* Returns true if the given node is empty. | ||
* When `checkChildren` is true (default), it will also check if all children are empty. | ||
* Returns true if the given prosemirror node is empty. | ||
*/ | ||
export declare function isNodeEmpty(node: ProseMirrorNode, { checkChildren }?: { | ||
checkChildren: boolean; | ||
export declare function isNodeEmpty(node: ProseMirrorNode, { checkChildren, ignoreWhitespace, }?: { | ||
/** | ||
* When true (default), it will also check if all children are empty. | ||
*/ | ||
checkChildren?: boolean; | ||
/** | ||
* When true, it will ignore whitespace when checking for emptiness. | ||
*/ | ||
ignoreWhitespace?: boolean; | ||
}): boolean; |
@@ -445,3 +445,3 @@ import { DOMOutputSpec, Mark as ProseMirrorMark, MarkSpec, MarkType } from '@tiptap/pm/model'; | ||
static create<O = any, S = any>(config?: Partial<MarkConfig<O, S>>): Mark<O, S>; | ||
configure(options?: Partial<Options>): Mark<any, any>; | ||
configure(options?: Partial<Options>): Mark<Options, Storage>; | ||
extend<ExtendedOptions = Options, ExtendedStorage = Storage>(extendedConfig?: Partial<MarkConfig<ExtendedOptions, ExtendedStorage>>): Mark<ExtendedOptions, ExtendedStorage>; | ||
@@ -448,0 +448,0 @@ static handleExit({ editor, mark }: { |
@@ -609,4 +609,4 @@ import { DOMOutputSpec, Node as ProseMirrorNode, NodeSpec, NodeType } from '@tiptap/pm/model'; | ||
static create<O = any, S = any>(config?: Partial<NodeConfig<O, S>>): Node<O, S>; | ||
configure(options?: Partial<Options>): Node<any, any>; | ||
configure(options?: Partial<Options>): Node<Options, Storage>; | ||
extend<ExtendedOptions = Options, ExtendedStorage = Storage>(extendedConfig?: Partial<NodeConfig<ExtendedOptions, ExtendedStorage>>): Node<ExtendedOptions, ExtendedStorage>; | ||
} |
{ | ||
"name": "@tiptap/react", | ||
"description": "React components for tiptap", | ||
"version": "2.5.8", | ||
"version": "2.5.9", | ||
"homepage": "https://tiptap.dev", | ||
@@ -32,4 +32,4 @@ "keywords": [ | ||
"dependencies": { | ||
"@tiptap/extension-bubble-menu": "^2.5.8", | ||
"@tiptap/extension-floating-menu": "^2.5.8", | ||
"@tiptap/extension-bubble-menu": "^2.5.9", | ||
"@tiptap/extension-floating-menu": "^2.5.9", | ||
"@types/use-sync-external-store": "^0.0.6", | ||
@@ -39,4 +39,4 @@ "use-sync-external-store": "^1.2.2" | ||
"devDependencies": { | ||
"@tiptap/core": "^2.5.8", | ||
"@tiptap/pm": "^2.5.8", | ||
"@tiptap/core": "^2.5.9", | ||
"@tiptap/pm": "^2.5.9", | ||
"@types/react": "^18.2.14", | ||
@@ -48,4 +48,4 @@ "@types/react-dom": "^18.2.6", | ||
"peerDependencies": { | ||
"@tiptap/core": "^2.5.8", | ||
"@tiptap/pm": "^2.5.8", | ||
"@tiptap/core": "^2.5.9", | ||
"@tiptap/pm": "^2.5.9", | ||
"react": "^17.0.0 || ^18.0.0", | ||
@@ -52,0 +52,0 @@ "react-dom": "^17.0.0 || ^18.0.0" |
import { EditorOptions } from '@tiptap/core' | ||
import { | ||
DependencyList, MutableRefObject, | ||
useDebugValue, useEffect, useRef, useState, | ||
DependencyList, | ||
MutableRefObject, | ||
useDebugValue, | ||
useEffect, | ||
useRef, | ||
useState, | ||
} from 'react' | ||
import { useSyncExternalStore } from 'use-sync-external-store/shim' | ||
@@ -34,51 +39,65 @@ import { Editor } from './Editor.js' | ||
/** | ||
* Create a new editor instance. And attach event listeners. | ||
* This class handles the creation, destruction, and re-creation of the editor instance. | ||
*/ | ||
function createEditor(options: MutableRefObject<UseEditorOptions>): Editor { | ||
const editor = new Editor(options.current) | ||
class EditorInstanceManager { | ||
/** | ||
* The current editor instance. | ||
*/ | ||
private editor: Editor | null = null | ||
editor.on('beforeCreate', (...args) => options.current.onBeforeCreate?.(...args)) | ||
editor.on('blur', (...args) => options.current.onBlur?.(...args)) | ||
editor.on('create', (...args) => options.current.onCreate?.(...args)) | ||
editor.on('destroy', (...args) => options.current.onDestroy?.(...args)) | ||
editor.on('focus', (...args) => options.current.onFocus?.(...args)) | ||
editor.on('selectionUpdate', (...args) => options.current.onSelectionUpdate?.(...args)) | ||
editor.on('transaction', (...args) => options.current.onTransaction?.(...args)) | ||
editor.on('update', (...args) => options.current.onUpdate?.(...args)) | ||
editor.on('contentError', (...args) => options.current.onContentError?.(...args)) | ||
/** | ||
* The most recent options to apply to the editor. | ||
*/ | ||
private options: MutableRefObject<UseEditorOptions> | ||
return editor | ||
} | ||
/** | ||
* The subscriptions to notify when the editor instance | ||
* has been created or destroyed. | ||
*/ | ||
private subscriptions = new Set<() => void>() | ||
/** | ||
* This hook allows you to create an editor instance. | ||
* @param options The editor options | ||
* @param deps The dependencies to watch for changes | ||
* @returns The editor instance | ||
* @example const editor = useEditor({ extensions: [...] }) | ||
*/ | ||
export function useEditor( | ||
options: UseEditorOptions & { immediatelyRender: true }, | ||
deps?: DependencyList | ||
): Editor; | ||
/** | ||
* A timeout to destroy the editor if it was not mounted within a time frame. | ||
*/ | ||
private scheduledDestructionTimeout: ReturnType<typeof setTimeout> | undefined | ||
/** | ||
* This hook allows you to create an editor instance. | ||
* @param options The editor options | ||
* @param deps The dependencies to watch for changes | ||
* @returns The editor instance | ||
* @example const editor = useEditor({ extensions: [...] }) | ||
*/ | ||
export function useEditor( | ||
options?: UseEditorOptions, | ||
deps?: DependencyList | ||
): Editor | null; | ||
/** | ||
* Whether the editor has been mounted. | ||
*/ | ||
private isComponentMounted = false | ||
export function useEditor( | ||
options: UseEditorOptions = {}, | ||
deps: DependencyList = [], | ||
): Editor | null { | ||
const mostRecentOptions = useRef(options) | ||
const [editor, setEditor] = useState(() => { | ||
if (options.immediatelyRender === undefined) { | ||
/** | ||
* The most recent dependencies array. | ||
*/ | ||
private previousDeps: DependencyList | null = null | ||
/** | ||
* The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance. | ||
*/ | ||
public instanceId = '' | ||
constructor(options: MutableRefObject<UseEditorOptions>) { | ||
this.options = options | ||
this.subscriptions = new Set<() => void>() | ||
this.setEditor(this.getInitialEditor()) | ||
this.getEditor = this.getEditor.bind(this) | ||
this.getServerSnapshot = this.getServerSnapshot.bind(this) | ||
this.subscribe = this.subscribe.bind(this) | ||
this.refreshEditorInstance = this.refreshEditorInstance.bind(this) | ||
this.scheduleDestroy = this.scheduleDestroy.bind(this) | ||
this.onRender = this.onRender.bind(this) | ||
this.createEditor = this.createEditor.bind(this) | ||
} | ||
private setEditor(editor: Editor | null) { | ||
this.editor = editor | ||
this.instanceId = Math.random().toString(36).slice(2, 9) | ||
// Notify all subscribers that the editor instance has been created | ||
this.subscriptions.forEach(cb => cb()) | ||
} | ||
private getInitialEditor() { | ||
if (this.options.current.immediatelyRender === undefined) { | ||
if (isSSR || isNext) { | ||
@@ -101,6 +120,6 @@ // TODO in the next major release, we should throw an error here | ||
// Default to immediately rendering when client-side rendering | ||
return createEditor(mostRecentOptions) | ||
return this.createEditor() | ||
} | ||
if (options.immediatelyRender && isSSR && isDev) { | ||
if (this.options.current.immediatelyRender && isSSR && isDev) { | ||
// Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches. | ||
@@ -112,57 +131,192 @@ throw new Error( | ||
if (options.immediatelyRender) { | ||
return createEditor(mostRecentOptions) | ||
if (this.options.current.immediatelyRender) { | ||
return this.createEditor() | ||
} | ||
return null | ||
}) | ||
const mostRecentEditor = useRef<Editor | null>(editor) | ||
} | ||
mostRecentEditor.current = editor | ||
/** | ||
* Create a new editor instance. And attach event listeners. | ||
*/ | ||
private createEditor(): Editor { | ||
const editor = new Editor(this.options.current) | ||
useDebugValue(editor) | ||
// Always call the most recent version of the callback function by default | ||
editor.on('beforeCreate', (...args) => this.options.current.onBeforeCreate?.(...args)) | ||
editor.on('blur', (...args) => this.options.current.onBlur?.(...args)) | ||
editor.on('create', (...args) => this.options.current.onCreate?.(...args)) | ||
editor.on('destroy', (...args) => this.options.current.onDestroy?.(...args)) | ||
editor.on('focus', (...args) => this.options.current.onFocus?.(...args)) | ||
editor.on('selectionUpdate', (...args) => this.options.current.onSelectionUpdate?.(...args)) | ||
editor.on('transaction', (...args) => this.options.current.onTransaction?.(...args)) | ||
editor.on('update', (...args) => this.options.current.onUpdate?.(...args)) | ||
editor.on('contentError', (...args) => this.options.current.onContentError?.(...args)) | ||
// This effect will handle creating/updating the editor instance | ||
useEffect(() => { | ||
const destroyUnusedEditor = (editorInstance: Editor | null) => { | ||
if (editorInstance) { | ||
// We need to destroy the editor asynchronously to avoid memory leaks | ||
// because the editor instance is still being used in the component. | ||
// no need to keep track of the event listeners, they will be removed when the editor is destroyed | ||
setTimeout(() => { | ||
// re-use the editor instance if it hasn't been replaced yet | ||
// otherwise, asynchronously destroy the old editor instance | ||
if (editorInstance !== mostRecentEditor.current && !editorInstance.isDestroyed) { | ||
editorInstance.destroy() | ||
} | ||
}) | ||
} | ||
return editor | ||
} | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getEditor(): Editor | null { | ||
return this.editor | ||
} | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot(): null { | ||
return null | ||
} | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(onStoreChange: () => void) { | ||
this.subscriptions.add(onStoreChange) | ||
return () => { | ||
this.subscriptions.delete(onStoreChange) | ||
} | ||
} | ||
let editorInstance = mostRecentEditor.current | ||
/** | ||
* On each render, we will create, update, or destroy the editor instance. | ||
* @param deps The dependencies to watch for changes | ||
* @returns A cleanup function | ||
*/ | ||
onRender(deps: DependencyList) { | ||
// The returned callback will run on each render | ||
return () => { | ||
this.isComponentMounted = true | ||
// Cleanup any scheduled destructions, since we are currently rendering | ||
clearTimeout(this.scheduledDestructionTimeout) | ||
if (!editorInstance) { | ||
editorInstance = createEditor(mostRecentOptions) | ||
setEditor(editorInstance) | ||
return () => destroyUnusedEditor(editorInstance) | ||
if (this.editor && !this.editor.isDestroyed && deps.length === 0) { | ||
// if the editor does exist & deps are empty, we don't need to re-initialize the editor | ||
// we can fast-path to update the editor options on the existing instance | ||
this.editor.setOptions(this.options.current) | ||
} else { | ||
// When the editor: | ||
// - does not yet exist | ||
// - is destroyed | ||
// - the deps array changes | ||
// We need to destroy the editor instance and re-initialize it | ||
this.refreshEditorInstance(deps) | ||
} | ||
return () => { | ||
this.isComponentMounted = false | ||
this.scheduleDestroy() | ||
} | ||
} | ||
} | ||
if (!Array.isArray(deps) || deps.length === 0) { | ||
// if the editor does exist & deps are empty, we don't need to re-initialize the editor | ||
// we can fast-path to update the editor options on the existing instance | ||
editorInstance.setOptions(options) | ||
/** | ||
* Recreate the editor instance if the dependencies have changed. | ||
*/ | ||
private refreshEditorInstance(deps: DependencyList) { | ||
return () => destroyUnusedEditor(editorInstance) | ||
if (this.editor && !this.editor.isDestroyed) { | ||
// Editor instance already exists | ||
if (this.previousDeps === null) { | ||
// If lastDeps has not yet been initialized, reuse the current editor instance | ||
this.previousDeps = deps | ||
return | ||
} | ||
const depsAreEqual = this.previousDeps.length === deps.length | ||
&& this.previousDeps.every((dep, index) => dep === deps[index]) | ||
if (depsAreEqual) { | ||
// deps exist and are equal, no need to recreate | ||
return | ||
} | ||
} | ||
// We need to destroy the editor instance and re-initialize it | ||
// when the deps array changes | ||
editorInstance.destroy() | ||
if (this.editor && !this.editor.isDestroyed) { | ||
// Destroy the editor instance if it exists | ||
this.editor.destroy() | ||
} | ||
// the deps array is used to re-initialize the editor instance | ||
editorInstance = createEditor(mostRecentOptions) | ||
setEditor(editorInstance) | ||
return () => destroyUnusedEditor(editorInstance) | ||
}, deps) | ||
this.setEditor(this.createEditor()) | ||
// Update the lastDeps to the current deps | ||
this.previousDeps = deps | ||
} | ||
/** | ||
* Schedule the destruction of the editor instance. | ||
* This will only destroy the editor if it was not mounted on the next tick. | ||
* This is to avoid destroying the editor instance when it's actually still mounted. | ||
*/ | ||
private scheduleDestroy() { | ||
const currentInstanceId = this.instanceId | ||
const currentEditor = this.editor | ||
// Wait a tick to see if the component is still mounted | ||
this.scheduledDestructionTimeout = setTimeout(() => { | ||
if (this.isComponentMounted && this.instanceId === currentInstanceId) { | ||
// If still mounted on the next tick, with the same instanceId, do not destroy the editor | ||
if (currentEditor) { | ||
// just re-apply options as they might have changed | ||
currentEditor.setOptions(this.options.current) | ||
} | ||
return | ||
} | ||
if (currentEditor && !currentEditor.isDestroyed) { | ||
currentEditor.destroy() | ||
if (this.instanceId === currentInstanceId) { | ||
this.setEditor(null) | ||
} | ||
} | ||
}, 0) | ||
} | ||
} | ||
/** | ||
* This hook allows you to create an editor instance. | ||
* @param options The editor options | ||
* @param deps The dependencies to watch for changes | ||
* @returns The editor instance | ||
* @example const editor = useEditor({ extensions: [...] }) | ||
*/ | ||
export function useEditor( | ||
options: UseEditorOptions & { immediatelyRender: true }, | ||
deps?: DependencyList | ||
): Editor; | ||
/** | ||
* This hook allows you to create an editor instance. | ||
* @param options The editor options | ||
* @param deps The dependencies to watch for changes | ||
* @returns The editor instance | ||
* @example const editor = useEditor({ extensions: [...] }) | ||
*/ | ||
export function useEditor(options?: UseEditorOptions, deps?: DependencyList): Editor | null; | ||
export function useEditor( | ||
options: UseEditorOptions = {}, | ||
deps: DependencyList = [], | ||
): Editor | null { | ||
const mostRecentOptions = useRef(options) | ||
mostRecentOptions.current = options | ||
const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions)) | ||
const editor = useSyncExternalStore( | ||
instanceManager.subscribe, | ||
instanceManager.getEditor, | ||
instanceManager.getServerSnapshot, | ||
) | ||
useDebugValue(editor) | ||
// This effect will handle creating/updating the editor instance | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
useEffect(instanceManager.onRender(deps)) | ||
// The default behavior is to re-render on each transaction | ||
@@ -169,0 +323,0 @@ // This is legacy behavior that will be removed in future versions |
@@ -33,64 +33,79 @@ import { useDebugValue, useEffect, useState } from 'react' | ||
*/ | ||
function makeEditorStateInstance<TEditor extends Editor | null = Editor | null>(initialEditor: TEditor) { | ||
let transactionNumber = 0 | ||
let lastTransactionNumber = 0 | ||
let lastSnapshot: EditorStateSnapshot<TEditor> = { editor: initialEditor, transactionNumber: 0 } | ||
let editor = initialEditor | ||
const subscribers = new Set<() => void>() | ||
class EditorStateManager<TEditor extends Editor | null = Editor | null> { | ||
private transactionNumber = 0 | ||
const editorInstance = { | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getSnapshot(): EditorStateSnapshot<TEditor> { | ||
if (transactionNumber === lastTransactionNumber) { | ||
return lastSnapshot | ||
private lastTransactionNumber = 0 | ||
private lastSnapshot: EditorStateSnapshot<TEditor> | ||
private editor: TEditor | ||
private subscribers = new Set<() => void>() | ||
constructor(initialEditor: TEditor) { | ||
this.editor = initialEditor | ||
this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 } | ||
this.getSnapshot = this.getSnapshot.bind(this) | ||
this.getServerSnapshot = this.getServerSnapshot.bind(this) | ||
this.watch = this.watch.bind(this) | ||
this.subscribe = this.subscribe.bind(this) | ||
} | ||
/** | ||
* Get the current editor instance. | ||
*/ | ||
getSnapshot(): EditorStateSnapshot<TEditor> { | ||
if (this.transactionNumber === this.lastTransactionNumber) { | ||
return this.lastSnapshot | ||
} | ||
this.lastTransactionNumber = this.transactionNumber | ||
this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber } | ||
return this.lastSnapshot | ||
} | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot(): EditorStateSnapshot<null> { | ||
return { editor: null, transactionNumber: 0 } | ||
} | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(callback: () => void): () => void { | ||
this.subscribers.add(callback) | ||
return () => { | ||
this.subscribers.delete(callback) | ||
} | ||
} | ||
/** | ||
* Watch the editor instance for changes. | ||
*/ | ||
watch(nextEditor: Editor | null): undefined | (() => void) { | ||
this.editor = nextEditor as TEditor | ||
if (this.editor) { | ||
/** | ||
* This will force a re-render when the editor state changes. | ||
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`. | ||
* This could be more efficient, but it's a good trade-off for now. | ||
*/ | ||
const fn = () => { | ||
this.transactionNumber += 1 | ||
this.subscribers.forEach(callback => callback()) | ||
} | ||
lastTransactionNumber = transactionNumber | ||
lastSnapshot = { editor, transactionNumber } | ||
return lastSnapshot | ||
}, | ||
/** | ||
* Always disable the editor on the server-side. | ||
*/ | ||
getServerSnapshot(): EditorStateSnapshot<null> { | ||
return { editor: null, transactionNumber: 0 } | ||
}, | ||
/** | ||
* Subscribe to the editor instance's changes. | ||
*/ | ||
subscribe(callback: () => void) { | ||
subscribers.add(callback) | ||
const currentEditor = this.editor | ||
currentEditor.on('transaction', fn) | ||
return () => { | ||
subscribers.delete(callback) | ||
currentEditor.off('transaction', fn) | ||
} | ||
}, | ||
/** | ||
* Watch the editor instance for changes. | ||
*/ | ||
watch(nextEditor: Editor | null) { | ||
editor = nextEditor as TEditor | ||
} | ||
if (editor) { | ||
/** | ||
* This will force a re-render when the editor state changes. | ||
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`. | ||
* This could be more efficient, but it's a good trade-off for now. | ||
*/ | ||
const fn = () => { | ||
transactionNumber += 1 | ||
subscribers.forEach(callback => callback()) | ||
} | ||
const currentEditor = editor | ||
currentEditor.on('transaction', fn) | ||
return () => { | ||
currentEditor.off('transaction', fn) | ||
} | ||
} | ||
}, | ||
return undefined | ||
} | ||
return editorInstance | ||
} | ||
@@ -108,3 +123,3 @@ | ||
): TSelectorResult | null { | ||
const [editorInstance] = useState(() => makeEditorStateInstance(options.editor)) | ||
const [editorInstance] = useState(() => new EditorStateManager(options.editor)) | ||
@@ -122,3 +137,3 @@ // Using the `useSyncExternalStore` hook to sync the editor instance with the component state | ||
return editorInstance.watch(options.editor) | ||
}, [options.editor]) | ||
}, [options.editor, editorInstance]) | ||
@@ -125,0 +140,0 @@ useDebugValue(selectedState) |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
619560
8773