@octanejs/tiptap 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/package.json +70 -0
- package/src/Context.ts +16 -0
- package/src/Context.tsrx +42 -0
- package/src/Context.tsrx.d.ts +23 -0
- package/src/EditorContent.tsrx +68 -0
- package/src/EditorContent.tsrx.d.ts +21 -0
- package/src/Tiptap.tsrx +73 -0
- package/src/Tiptap.tsrx.d.ts +48 -0
- package/src/index.ts +9 -0
- package/src/internal.ts +37 -0
- package/src/useEditor.ts +282 -0
- package/src/useEditorState.ts +137 -0
- package/src/useSyncExternalStoreWithSelector.ts +104 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @octanejs/tiptap
|
|
2
|
+
|
|
3
|
+
[TipTap](https://tiptap.dev) for the
|
|
4
|
+
[Octane](https://github.com/octanejs/octane) UI framework.
|
|
5
|
+
|
|
6
|
+
This package reuses the framework-independent `@tiptap/core` and `@tiptap/pm`
|
|
7
|
+
packages and ports the editor binding layer from `@tiptap/react` to Octane. It is
|
|
8
|
+
pinned to **TipTap 3.28.0** and re-exports the public `@tiptap/core` API from its
|
|
9
|
+
root entry.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @octanejs/tiptap @tiptap/starter-kit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { EditorContent, useEditor } from '@octanejs/tiptap';
|
|
17
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
18
|
+
|
|
19
|
+
export function RichTextEditor() @{
|
|
20
|
+
const editor = useEditor({
|
|
21
|
+
extensions: [StarterKit],
|
|
22
|
+
content: '<p>Hello from TipTap and Octane.</p>',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
<EditorContent editor={editor} />
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Current scope
|
|
30
|
+
|
|
31
|
+
The initial binding includes:
|
|
32
|
+
|
|
33
|
+
- `useEditor`, including create/update/recreate and deferred-destroy lifecycle
|
|
34
|
+
behavior;
|
|
35
|
+
- `useEditorState`, with selector-based transaction subscriptions and deep-equal
|
|
36
|
+
bailout behavior;
|
|
37
|
+
- `EditorContext`, `EditorProvider`, `EditorConsumer`, and `useCurrentEditor`;
|
|
38
|
+
- `EditorContent` / `PureEditorContent` for adopting, switching, resetting, and
|
|
39
|
+
exposing the DOM of a normal `@tiptap/core` editor; and
|
|
40
|
+
- `Tiptap`, `Tiptap.Content`, `useTiptap`, and `useTiptapState`.
|
|
41
|
+
|
|
42
|
+
The portal bridge required by custom Octane NodeViews and MarkViews is the next
|
|
43
|
+
stage. `ReactRenderer`, `ReactNodeViewRenderer`, `ReactMarkViewRenderer`,
|
|
44
|
+
`useReactNodeView`, the NodeView wrapper/content components, and the `./menus`
|
|
45
|
+
entry are therefore not part of this release yet. In particular, importing
|
|
46
|
+
`@octanejs/tiptap/menus` is intentionally unsupported until `BubbleMenu` and
|
|
47
|
+
`FloatingMenu` have working Octane implementations.
|
|
48
|
+
|
|
49
|
+
On the server, editor construction is suppressed and hook snapshots use a `null`
|
|
50
|
+
editor. Set `immediatelyRender: false` when the editor should remain nullable
|
|
51
|
+
through the initial client render as well. The binding's SSR and hydration suite
|
|
52
|
+
verifies deferred server output, adoption of the existing host DOM, and creation
|
|
53
|
+
of the live editor after hydration.
|
|
54
|
+
|
|
55
|
+
## Status
|
|
56
|
+
|
|
57
|
+
Current scope, known divergences, and verification status are tracked in the
|
|
58
|
+
generated [bindings status table](../../docs/bindings-status.md), sourced from
|
|
59
|
+
this package's [`status.json`](./status.json).
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/tiptap",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"octane": {
|
|
11
|
+
"hookSlots": {
|
|
12
|
+
"manual": [
|
|
13
|
+
"src"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"vite": {
|
|
17
|
+
"optimizeDeps": {
|
|
18
|
+
"exclude": [
|
|
19
|
+
"@tiptap/*"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"description": "TipTap bindings for the Octane renderer — reuses @tiptap/core and @tiptap/pm and ports the React editor lifecycle, state subscriptions, contexts, content surface, and compound TipTap API onto Octane.",
|
|
25
|
+
"author": {
|
|
26
|
+
"name": "Dominic Gannaway",
|
|
27
|
+
"email": "dg@domgan.com"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
35
|
+
"directory": "packages/tiptap"
|
|
36
|
+
},
|
|
37
|
+
"main": "src/index.ts",
|
|
38
|
+
"module": "src/index.ts",
|
|
39
|
+
"types": "src/index.ts",
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"exports": {
|
|
45
|
+
".": "./src/index.ts"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@tiptap/core": "3.28.0",
|
|
49
|
+
"@tiptap/pm": "3.28.0",
|
|
50
|
+
"fast-equals": "^5.3.3"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"octane": "0.1.7"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
57
|
+
"@tiptap/react": "3.28.0",
|
|
58
|
+
"@tiptap/starter-kit": "3.28.0",
|
|
59
|
+
"@tsrx/react": "^0.2.37",
|
|
60
|
+
"esbuild": "^0.28.1",
|
|
61
|
+
"react": "^19.2.0",
|
|
62
|
+
"react-dom": "^19.2.0",
|
|
63
|
+
"vitest": "^4.1.9",
|
|
64
|
+
"@octanejs/testing-library": "0.1.4",
|
|
65
|
+
"octane": "0.1.7"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "vitest run"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/Context.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core';
|
|
2
|
+
import { createContext, useContext } from 'octane';
|
|
3
|
+
|
|
4
|
+
/** The editor value shared by the legacy TipTap context API. */
|
|
5
|
+
export type EditorContextValue = {
|
|
6
|
+
editor: Editor | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const EditorContext = createContext<EditorContextValue>({
|
|
10
|
+
editor: null,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** Read the editor from the nearest `EditorProvider` or `Tiptap` component. */
|
|
14
|
+
export function useCurrentEditor(): EditorContextValue {
|
|
15
|
+
return useContext(EditorContext);
|
|
16
|
+
}
|
package/src/Context.tsrx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Ported from @tiptap/react 3.28.0 Context.tsx. Octane contexts intentionally
|
|
2
|
+
// have no `.Consumer`; the compatibility component below implements the same
|
|
3
|
+
// render-prop contract with useContext instead.
|
|
4
|
+
import { isChildrenBlock, useMemo } from 'octane';
|
|
5
|
+
|
|
6
|
+
import { EditorContext, useCurrentEditor } from './Context';
|
|
7
|
+
import { EditorContent } from './EditorContent.tsrx';
|
|
8
|
+
import { useEditor } from './useEditor';
|
|
9
|
+
|
|
10
|
+
export function EditorConsumer(props) {
|
|
11
|
+
const value = useCurrentEditor();
|
|
12
|
+
const children = props.children;
|
|
13
|
+
|
|
14
|
+
if (typeof children !== 'function' || isChildrenBlock(children)) {
|
|
15
|
+
throw new TypeError('EditorConsumer expects a function as its only child.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return children(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create an editor and expose it to descendants through the legacy context.
|
|
23
|
+
*/
|
|
24
|
+
export function EditorProvider(props) @{
|
|
25
|
+
const { children, slotAfter, slotBefore, editorContainerProps = {}, ...editorOptions } = props;
|
|
26
|
+
const editor = useEditor(editorOptions);
|
|
27
|
+
const contextValue = useMemo(() => ({ editor }), [editor]);
|
|
28
|
+
|
|
29
|
+
if (!editor) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
<EditorContext.Provider value={contextValue}>
|
|
34
|
+
{slotBefore}
|
|
35
|
+
<EditorConsumer>
|
|
36
|
+
{({ editor: currentEditor }) =>
|
|
37
|
+
<EditorContent editor={currentEditor} {...editorContainerProps} />}
|
|
38
|
+
</EditorConsumer>
|
|
39
|
+
{children}
|
|
40
|
+
{slotAfter}
|
|
41
|
+
</EditorContext.Provider>
|
|
42
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EditorContentProps } from './EditorContent.tsrx';
|
|
2
|
+
import type { EditorContextValue } from './Context';
|
|
3
|
+
import type { UseEditorOptions } from './useEditor';
|
|
4
|
+
|
|
5
|
+
export interface EditorConsumerProps {
|
|
6
|
+
children: (value: EditorContextValue) => unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type EditorContainerProps = Omit<EditorContentProps, 'editor' | 'innerRef' | 'ref'> & {
|
|
10
|
+
editor?: never;
|
|
11
|
+
innerRef?: never;
|
|
12
|
+
ref?: never;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type EditorProviderProps = UseEditorOptions & {
|
|
16
|
+
children?: unknown;
|
|
17
|
+
slotBefore?: unknown;
|
|
18
|
+
slotAfter?: unknown;
|
|
19
|
+
editorContainerProps?: EditorContainerProps;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export declare function EditorConsumer(props: EditorConsumerProps): unknown;
|
|
23
|
+
export declare function EditorProvider(props: EditorProviderProps): unknown;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// DOM lifecycle bridge ported from @tiptap/react 3.28.0 EditorContent.tsx.
|
|
2
|
+
// Portal-backed node and mark views intentionally arrive in the next stage;
|
|
3
|
+
// this component owns only adoption, reset, editor switching, and host refs.
|
|
4
|
+
import { memo, useLayoutEffect, useMemo, useRef } from 'octane';
|
|
5
|
+
|
|
6
|
+
export function PureEditorContent(props) @{
|
|
7
|
+
const { editor, innerRef, ref, ...rest } = props;
|
|
8
|
+
const editorContentRef = useRef(null);
|
|
9
|
+
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
if (!editor || editor.isDestroyed || editor.isEditorContentInitialized) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const element = editorContentRef.current;
|
|
16
|
+
const editorParent = editor.view.dom?.parentNode;
|
|
17
|
+
|
|
18
|
+
if (!element || !editorParent) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The core Editor initially mounts into its own detached element. Move the
|
|
23
|
+
// complete ProseMirror-owned subtree into Octane's committed host element;
|
|
24
|
+
// moving nodes preserves the existing EditorView and DOM identity.
|
|
25
|
+
element.append(...editorParent.childNodes);
|
|
26
|
+
editor.setOptions({ element });
|
|
27
|
+
editor.createNodeViews();
|
|
28
|
+
editor.isEditorContentInitialized = true;
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
editor.isEditorContentInitialized = false;
|
|
32
|
+
|
|
33
|
+
if (!editor.isDestroyed) {
|
|
34
|
+
editor.view.setProps({ nodeViews: {} });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Reset the editor to a detached element so the still-live instance can
|
|
38
|
+
// safely be mounted by a later EditorContent. A view that has already
|
|
39
|
+
// been destroyed or never mounted has nothing left to transfer.
|
|
40
|
+
try {
|
|
41
|
+
const currentParent = editor.view.dom?.parentNode;
|
|
42
|
+
|
|
43
|
+
if (!currentParent) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const detachedElement = document.createElement('div');
|
|
48
|
+
detachedElement.append(...currentParent.childNodes);
|
|
49
|
+
editor.setOptions({ element: detachedElement });
|
|
50
|
+
} catch {
|
|
51
|
+
// The editor may have been destroyed before this layout cleanup.
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}, [editor]);
|
|
55
|
+
|
|
56
|
+
<div {...rest} ref={[ref, innerRef, editorContentRef]} />
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function EditorContentWithKey(props) @{
|
|
60
|
+
// Remount the host whenever the Editor identity changes, matching the keyed
|
|
61
|
+
// wrapper in @tiptap/react and preventing one editor from retaining another
|
|
62
|
+
// editor's managed DOM.
|
|
63
|
+
const key = useMemo(() => Math.floor(Math.random() * 4294967295).toString(), [props.editor]);
|
|
64
|
+
|
|
65
|
+
<PureEditorContent key={key} {...props} />
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const EditorContent = memo(EditorContentWithKey);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
export type EditorContentRef<T> =
|
|
4
|
+
| { current: T | null }
|
|
5
|
+
| ((value: T | null) => void | (() => void))
|
|
6
|
+
| null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Props accepted by the Octane editor host. Unknown host attributes are passed
|
|
10
|
+
* through to the rendered `div` without introducing a React type dependency.
|
|
11
|
+
*/
|
|
12
|
+
export interface EditorContentProps {
|
|
13
|
+
editor: Editor | null;
|
|
14
|
+
ref?: EditorContentRef<HTMLDivElement>;
|
|
15
|
+
innerRef?: EditorContentRef<HTMLDivElement>;
|
|
16
|
+
children?: unknown;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export declare function PureEditorContent(props: EditorContentProps): unknown;
|
|
21
|
+
export declare const EditorContent: (props: EditorContentProps) => unknown;
|
package/src/Tiptap.tsrx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Ported from @tiptap/react 3.28.0 Tiptap.tsx. Refs remain ordinary props in
|
|
2
|
+
// Octane, and the compound Tiptap.Content component needs no forwardRef layer.
|
|
3
|
+
import { createContext, useContext, useMemo } from 'octane';
|
|
4
|
+
|
|
5
|
+
import { EditorContext } from './Context';
|
|
6
|
+
import { EditorContent } from './EditorContent.tsrx';
|
|
7
|
+
import { splitSlot, subSlot } from './internal';
|
|
8
|
+
import { useEditorState } from './useEditorState';
|
|
9
|
+
|
|
10
|
+
export const TiptapContext = createContext({
|
|
11
|
+
get editor() {
|
|
12
|
+
throw new Error('useTiptap must be used within a <Tiptap> provider');
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
TiptapContext.displayName = 'TiptapContext';
|
|
17
|
+
|
|
18
|
+
/** Read the editor instance provided by the nearest `Tiptap` component. */
|
|
19
|
+
export function useTiptap() {
|
|
20
|
+
return useContext(TiptapContext);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Select a reactive slice of editor state from the context editor. */
|
|
24
|
+
export function useTiptapState(selector, ...args) {
|
|
25
|
+
const [userArgs, slot] = splitSlot(args);
|
|
26
|
+
const equalityFn = userArgs[0];
|
|
27
|
+
const { editor } = useTiptap();
|
|
28
|
+
|
|
29
|
+
// Call through a non-hook-named alias because this source is itself compiled:
|
|
30
|
+
// the public call site's slot is already being forwarded explicitly here.
|
|
31
|
+
const selectEditorState = useEditorState;
|
|
32
|
+
return selectEditorState(
|
|
33
|
+
{
|
|
34
|
+
editor,
|
|
35
|
+
selector,
|
|
36
|
+
equalityFn,
|
|
37
|
+
},
|
|
38
|
+
subSlot(slot, 'tiptap:state'),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function TiptapWrapper({ children, ...props }) @{
|
|
43
|
+
const resolvedEditor =
|
|
44
|
+
'editor' in props ? props.editor : props.instance;
|
|
45
|
+
|
|
46
|
+
if (!resolvedEditor) {
|
|
47
|
+
throw new Error('Tiptap: An editor instance is required. Pass a non-null `editor` prop.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const tiptapContextValue = useMemo(() => ({ editor: resolvedEditor }), [resolvedEditor]);
|
|
51
|
+
const legacyContextValue = useMemo(() => ({ editor: resolvedEditor }), [resolvedEditor]);
|
|
52
|
+
|
|
53
|
+
<EditorContext.Provider value={legacyContextValue}>
|
|
54
|
+
<TiptapContext.Provider value={tiptapContextValue}>{children}</TiptapContext.Provider>
|
|
55
|
+
</EditorContext.Provider>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
TiptapWrapper.displayName = 'Tiptap';
|
|
59
|
+
|
|
60
|
+
/** Render EditorContent using the editor supplied by the nearest Tiptap root. */
|
|
61
|
+
export function TiptapContent(props) @{
|
|
62
|
+
const { editor } = useTiptap();
|
|
63
|
+
|
|
64
|
+
<EditorContent editor={editor} {...props} />
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
TiptapContent.displayName = 'Tiptap.Content';
|
|
68
|
+
|
|
69
|
+
export const Tiptap = Object.assign(TiptapWrapper, {
|
|
70
|
+
Content: TiptapContent,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export default Tiptap
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core';
|
|
2
|
+
import type { Context } from 'octane';
|
|
3
|
+
|
|
4
|
+
import type { EditorContentProps } from './EditorContent.tsrx';
|
|
5
|
+
import type { EditorStateSnapshot } from './useEditorState';
|
|
6
|
+
|
|
7
|
+
export type TiptapContextType = {
|
|
8
|
+
/** The Tiptap editor instance. */
|
|
9
|
+
editor: Editor;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export declare const TiptapContext: Context<TiptapContextType> & {
|
|
13
|
+
displayName?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export declare function useTiptap(): TiptapContextType;
|
|
17
|
+
|
|
18
|
+
export declare function useTiptapState<TSelectorResult>(
|
|
19
|
+
selector: (context: EditorStateSnapshot<Editor>) => TSelectorResult,
|
|
20
|
+
equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean,
|
|
21
|
+
): TSelectorResult;
|
|
22
|
+
|
|
23
|
+
export type TiptapWrapperEditorInstanceProps =
|
|
24
|
+
| {
|
|
25
|
+
editor: Editor;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
/** @deprecated Use `editor` instead. */
|
|
29
|
+
instance: Editor;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TiptapWrapperProps = TiptapWrapperEditorInstanceProps & {
|
|
33
|
+
children: unknown;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TiptapContentProps = Omit<EditorContentProps, 'editor' | 'ref'> & {
|
|
37
|
+
editor?: never;
|
|
38
|
+
ref?: never;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export declare function TiptapWrapper(props: TiptapWrapperProps): unknown;
|
|
42
|
+
export declare function TiptapContent(props: TiptapContentProps): unknown;
|
|
43
|
+
|
|
44
|
+
export declare const Tiptap: typeof TiptapWrapper & {
|
|
45
|
+
Content: typeof TiptapContent;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default Tiptap;
|
package/src/index.ts
ADDED
package/src/internal.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Slot mechanics for @octanejs/tiptap's plain-`.ts` hooks. The Octane
|
|
2
|
+
// compiler injects a per-call-site Symbol as the final argument of every
|
|
3
|
+
// custom-hook call. Binding hooks split that argument from their public
|
|
4
|
+
// options and derive one stable sub-slot for each base hook they compose.
|
|
5
|
+
|
|
6
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
7
|
+
const bareSlotCache = new Map<string, symbol>();
|
|
8
|
+
|
|
9
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol {
|
|
10
|
+
if (slot === undefined) {
|
|
11
|
+
let bare = bareSlotCache.get(tag);
|
|
12
|
+
if (bare === undefined) {
|
|
13
|
+
bare = Symbol.for(`@octanejs/tiptap:${tag}`);
|
|
14
|
+
bareSlotCache.set(tag, bare);
|
|
15
|
+
}
|
|
16
|
+
return bare;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let byTag = subSlotCache.get(slot);
|
|
20
|
+
if (byTag === undefined) {
|
|
21
|
+
byTag = new Map();
|
|
22
|
+
subSlotCache.set(slot, byTag);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let result = byTag.get(tag);
|
|
26
|
+
if (result === undefined) {
|
|
27
|
+
result = Symbol.for(`${slot.description ?? ''}:@octanejs/tiptap:${tag}`);
|
|
28
|
+
byTag.set(tag, result);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Split a compiler-owned trailing slot from a custom hook's user arguments. */
|
|
34
|
+
export function splitSlot(args: readonly unknown[]): [readonly unknown[], symbol | undefined] {
|
|
35
|
+
const tail = args.at(-1);
|
|
36
|
+
return typeof tail === 'symbol' ? [args.slice(0, -1), tail] : [args, undefined];
|
|
37
|
+
}
|
package/src/useEditor.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { type EditorOptions, Editor } from '@tiptap/core';
|
|
2
|
+
import { useDebugValue, useEffect, useRef, useState, useSyncExternalStore } from 'octane';
|
|
3
|
+
|
|
4
|
+
import { splitSlot, subSlot } from './internal';
|
|
5
|
+
import { useEditorState, type UseEditorStateOptions } from './useEditorState';
|
|
6
|
+
|
|
7
|
+
type DependencyList = readonly unknown[];
|
|
8
|
+
type MutableRefObject<T> = { current: T };
|
|
9
|
+
|
|
10
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
11
|
+
const isSSR = typeof window === 'undefined';
|
|
12
|
+
const isNext = isSSR || Boolean(typeof window !== 'undefined' && (window as any).next);
|
|
13
|
+
|
|
14
|
+
export type UseEditorOptions = Partial<EditorOptions> & {
|
|
15
|
+
/**
|
|
16
|
+
* Whether to create the editor during the first render. Set this to `false`
|
|
17
|
+
* for server-rendered applications.
|
|
18
|
+
* @default true
|
|
19
|
+
*/
|
|
20
|
+
immediatelyRender?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether every editor transaction should re-render the component.
|
|
23
|
+
* @default false
|
|
24
|
+
*/
|
|
25
|
+
shouldRerenderOnTransaction?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const editorCallbackOptions = new Set<keyof UseEditorOptions>([
|
|
29
|
+
'onBeforeCreate',
|
|
30
|
+
'onBlur',
|
|
31
|
+
'onCreate',
|
|
32
|
+
'onDestroy',
|
|
33
|
+
'onFocus',
|
|
34
|
+
'onSelectionUpdate',
|
|
35
|
+
'onTransaction',
|
|
36
|
+
'onUpdate',
|
|
37
|
+
'onContentError',
|
|
38
|
+
'onDrop',
|
|
39
|
+
'onPaste',
|
|
40
|
+
'onDelete',
|
|
41
|
+
'onMount',
|
|
42
|
+
'onUnmount',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Manages editor creation, option updates, recreation, and deferred teardown. */
|
|
46
|
+
class EditorInstanceManager {
|
|
47
|
+
private editor: Editor | null = null;
|
|
48
|
+
|
|
49
|
+
private options: MutableRefObject<UseEditorOptions>;
|
|
50
|
+
|
|
51
|
+
private subscriptions = new Set<() => void>();
|
|
52
|
+
|
|
53
|
+
private scheduledDestructionTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
|
|
55
|
+
private isComponentMounted = false;
|
|
56
|
+
|
|
57
|
+
private previousDeps: DependencyList | null = null;
|
|
58
|
+
|
|
59
|
+
public instanceId = '';
|
|
60
|
+
|
|
61
|
+
constructor(options: MutableRefObject<UseEditorOptions>) {
|
|
62
|
+
this.options = options;
|
|
63
|
+
this.setEditor(this.getInitialEditor());
|
|
64
|
+
this.scheduleDestroy();
|
|
65
|
+
|
|
66
|
+
this.getEditor = this.getEditor.bind(this);
|
|
67
|
+
this.getServerSnapshot = this.getServerSnapshot.bind(this);
|
|
68
|
+
this.subscribe = this.subscribe.bind(this);
|
|
69
|
+
this.refreshEditorInstance = this.refreshEditorInstance.bind(this);
|
|
70
|
+
this.scheduleDestroy = this.scheduleDestroy.bind(this);
|
|
71
|
+
this.onRender = this.onRender.bind(this);
|
|
72
|
+
this.createEditor = this.createEditor.bind(this);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private setEditor(editor: Editor | null): void {
|
|
76
|
+
this.editor = editor;
|
|
77
|
+
this.instanceId = Math.random().toString(36).slice(2, 9);
|
|
78
|
+
this.subscriptions.forEach((callback) => callback());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private getInitialEditor(): Editor | null {
|
|
82
|
+
const explicit = this.options.current.immediatelyRender;
|
|
83
|
+
let immediatelyRender = explicit ?? true;
|
|
84
|
+
|
|
85
|
+
if (isSSR) {
|
|
86
|
+
if (immediatelyRender && isDev) {
|
|
87
|
+
console.warn(
|
|
88
|
+
'SSR detected. `immediatelyRender` has been set to false to avoid hydration mismatches',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
immediatelyRender = false;
|
|
92
|
+
} else if (isNext && explicit === undefined) {
|
|
93
|
+
immediatelyRender = false;
|
|
94
|
+
if (isDev) {
|
|
95
|
+
console.warn(
|
|
96
|
+
'Next.js detected. `immediatelyRender` defaults to false to avoid hydration mismatches. Pass `immediatelyRender: true` explicitly if you are rendering the editor only on the client.',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return immediatelyRender ? this.createEditor() : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private createEditor(): Editor {
|
|
105
|
+
const optionsToApply: Partial<EditorOptions> = {
|
|
106
|
+
...this.options.current,
|
|
107
|
+
onBeforeCreate: (...args) => this.options.current.onBeforeCreate?.(...args),
|
|
108
|
+
onBlur: (...args) => this.options.current.onBlur?.(...args),
|
|
109
|
+
onCreate: (...args) => this.options.current.onCreate?.(...args),
|
|
110
|
+
onDestroy: (...args) => this.options.current.onDestroy?.(...args),
|
|
111
|
+
onFocus: (...args) => this.options.current.onFocus?.(...args),
|
|
112
|
+
onSelectionUpdate: (...args) => this.options.current.onSelectionUpdate?.(...args),
|
|
113
|
+
onTransaction: (...args) => this.options.current.onTransaction?.(...args),
|
|
114
|
+
onUpdate: (...args) => this.options.current.onUpdate?.(...args),
|
|
115
|
+
onContentError: (...args) => this.options.current.onContentError?.(...args),
|
|
116
|
+
onDrop: (...args) => this.options.current.onDrop?.(...args),
|
|
117
|
+
onPaste: (...args) => this.options.current.onPaste?.(...args),
|
|
118
|
+
onDelete: (...args) => this.options.current.onDelete?.(...args),
|
|
119
|
+
onMount: (...args) => this.options.current.onMount?.(...args),
|
|
120
|
+
onUnmount: (...args) => this.options.current.onUnmount?.(...args),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return new Editor(optionsToApply);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getEditor(): Editor | null {
|
|
127
|
+
return this.editor;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getServerSnapshot(): null {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
subscribe(onStoreChange: () => void): () => void {
|
|
135
|
+
this.subscriptions.add(onStoreChange);
|
|
136
|
+
return () => {
|
|
137
|
+
this.subscriptions.delete(onStoreChange);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static compareOptions(left: UseEditorOptions, right: UseEditorOptions): boolean {
|
|
142
|
+
return (Object.keys(left) as (keyof UseEditorOptions)[]).every((key) => {
|
|
143
|
+
if (editorCallbackOptions.has(key)) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (key === 'extensions' && left.extensions && right.extensions) {
|
|
148
|
+
return (
|
|
149
|
+
left.extensions.length === right.extensions.length &&
|
|
150
|
+
left.extensions.every((extension, index) => extension === right.extensions?.[index])
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return left[key] === right[key];
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
onRender(deps: DependencyList): () => () => void {
|
|
159
|
+
return () => {
|
|
160
|
+
this.isComponentMounted = true;
|
|
161
|
+
clearTimeout(this.scheduledDestructionTimeout);
|
|
162
|
+
|
|
163
|
+
if (this.editor && !this.editor.isDestroyed && deps.length === 0) {
|
|
164
|
+
if (!EditorInstanceManager.compareOptions(this.options.current, this.editor.options)) {
|
|
165
|
+
this.editor.setOptions({
|
|
166
|
+
...this.options.current,
|
|
167
|
+
editable: this.editor.isEditable,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
this.refreshEditorInstance(deps);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return () => {
|
|
175
|
+
this.isComponentMounted = false;
|
|
176
|
+
this.scheduleDestroy();
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private refreshEditorInstance(deps: DependencyList): void {
|
|
182
|
+
if (this.editor && !this.editor.isDestroyed) {
|
|
183
|
+
if (this.previousDeps === null) {
|
|
184
|
+
this.previousDeps = deps;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const depsAreEqual =
|
|
189
|
+
this.previousDeps.length === deps.length &&
|
|
190
|
+
this.previousDeps.every((dependency, index) => dependency === deps[index]);
|
|
191
|
+
if (depsAreEqual) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (this.editor && !this.editor.isDestroyed) {
|
|
197
|
+
this.editor.destroy();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.setEditor(this.createEditor());
|
|
201
|
+
this.previousDeps = deps;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private scheduleDestroy(): void {
|
|
205
|
+
const currentInstanceId = this.instanceId;
|
|
206
|
+
const currentEditor = this.editor;
|
|
207
|
+
|
|
208
|
+
this.scheduledDestructionTimeout = setTimeout(() => {
|
|
209
|
+
if (this.isComponentMounted && this.instanceId === currentInstanceId) {
|
|
210
|
+
currentEditor?.setOptions(this.options.current);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (currentEditor && !currentEditor.isDestroyed) {
|
|
215
|
+
currentEditor.destroy();
|
|
216
|
+
if (this.instanceId === currentInstanceId) {
|
|
217
|
+
this.setEditor(null);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}, 1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function useEditor(
|
|
225
|
+
options: UseEditorOptions & { immediatelyRender: false },
|
|
226
|
+
deps?: DependencyList,
|
|
227
|
+
): Editor | null;
|
|
228
|
+
export function useEditor(options: UseEditorOptions, deps?: DependencyList): Editor;
|
|
229
|
+
export function useEditor(
|
|
230
|
+
...args: [options?: UseEditorOptions, deps?: DependencyList, slot?: symbol]
|
|
231
|
+
): Editor | null {
|
|
232
|
+
const [userArgs, slot] = splitSlot(args);
|
|
233
|
+
const options = (userArgs[0] as UseEditorOptions | undefined) ?? {};
|
|
234
|
+
const deps = (userArgs[1] as DependencyList | undefined) ?? [];
|
|
235
|
+
const mostRecentOptions = useRef(options, subSlot(slot, 'editor:options'));
|
|
236
|
+
|
|
237
|
+
mostRecentOptions.current = options;
|
|
238
|
+
|
|
239
|
+
const [instanceManager] = useState(
|
|
240
|
+
() => new EditorInstanceManager(mostRecentOptions),
|
|
241
|
+
subSlot(slot, 'editor:manager'),
|
|
242
|
+
);
|
|
243
|
+
const editor = useSyncExternalStore(
|
|
244
|
+
instanceManager.subscribe,
|
|
245
|
+
instanceManager.getEditor,
|
|
246
|
+
instanceManager.getServerSnapshot,
|
|
247
|
+
subSlot(slot, 'editor:store'),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
useDebugValue(editor, undefined, subSlot(slot, 'editor:debug'));
|
|
251
|
+
|
|
252
|
+
// TipTap intentionally performs this lifecycle pass after every render. In
|
|
253
|
+
// Octane, `null` is the explicit opt-out from inferred dependency arrays.
|
|
254
|
+
useEffect(instanceManager.onRender(deps), null, subSlot(slot, 'editor:lifecycle'));
|
|
255
|
+
|
|
256
|
+
const useEditorStateWithSlot = useEditorState as <TSelectorResult>(
|
|
257
|
+
options: UseEditorStateOptions<TSelectorResult, Editor | null>,
|
|
258
|
+
slot: symbol,
|
|
259
|
+
) => TSelectorResult | null;
|
|
260
|
+
useEditorStateWithSlot(
|
|
261
|
+
{
|
|
262
|
+
editor,
|
|
263
|
+
selector: ({ transactionNumber }) => {
|
|
264
|
+
if (
|
|
265
|
+
options.shouldRerenderOnTransaction === false ||
|
|
266
|
+
options.shouldRerenderOnTransaction === undefined
|
|
267
|
+
) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (options.immediatelyRender && transactionNumber === 0) {
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return transactionNumber + 1;
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
subSlot(slot, 'editor:state'),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return editor;
|
|
282
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core';
|
|
2
|
+
import { deepEqual } from 'fast-equals';
|
|
3
|
+
import { useDebugValue, useEffect, useLayoutEffect, useState } from 'octane';
|
|
4
|
+
|
|
5
|
+
import { splitSlot, subSlot } from './internal';
|
|
6
|
+
import { useSyncExternalStoreWithSelector } from './useSyncExternalStoreWithSelector';
|
|
7
|
+
|
|
8
|
+
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
9
|
+
|
|
10
|
+
export type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> = {
|
|
11
|
+
editor: TEditor;
|
|
12
|
+
transactionNumber: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type UseEditorStateOptions<
|
|
16
|
+
TSelectorResult,
|
|
17
|
+
TEditor extends Editor | null = Editor | null,
|
|
18
|
+
> = {
|
|
19
|
+
/** The editor instance to observe. */
|
|
20
|
+
editor: TEditor;
|
|
21
|
+
/** Select the value that should drive component re-renders. */
|
|
22
|
+
selector: (context: EditorStateSnapshot<TEditor>) => TSelectorResult;
|
|
23
|
+
/** @default `deepEqual` from `fast-equals`. */
|
|
24
|
+
equalityFn?: (left: TSelectorResult, right: TSelectorResult | null) => boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Synchronizes a TipTap editor's transactions with a stable external-store
|
|
29
|
+
* snapshot, independently of component re-renders.
|
|
30
|
+
*/
|
|
31
|
+
class EditorStateManager<TEditor extends Editor | null = Editor | null> {
|
|
32
|
+
private transactionNumber = 0;
|
|
33
|
+
|
|
34
|
+
private lastTransactionNumber = 0;
|
|
35
|
+
|
|
36
|
+
private lastSnapshot: EditorStateSnapshot<TEditor>;
|
|
37
|
+
|
|
38
|
+
private editor: TEditor;
|
|
39
|
+
|
|
40
|
+
private subscribers = new Set<() => void>();
|
|
41
|
+
|
|
42
|
+
constructor(initialEditor: TEditor) {
|
|
43
|
+
this.editor = initialEditor;
|
|
44
|
+
this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 };
|
|
45
|
+
|
|
46
|
+
this.getSnapshot = this.getSnapshot.bind(this);
|
|
47
|
+
this.getServerSnapshot = this.getServerSnapshot.bind(this);
|
|
48
|
+
this.watch = this.watch.bind(this);
|
|
49
|
+
this.subscribe = this.subscribe.bind(this);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getSnapshot(): EditorStateSnapshot<TEditor> {
|
|
53
|
+
if (this.transactionNumber === this.lastTransactionNumber) {
|
|
54
|
+
return this.lastSnapshot;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.lastTransactionNumber = this.transactionNumber;
|
|
58
|
+
this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber };
|
|
59
|
+
return this.lastSnapshot;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getServerSnapshot(): EditorStateSnapshot<null> {
|
|
63
|
+
return { editor: null, transactionNumber: 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
subscribe(callback: () => void): () => void {
|
|
67
|
+
this.subscribers.add(callback);
|
|
68
|
+
return () => {
|
|
69
|
+
this.subscribers.delete(callback);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
watch(nextEditor: Editor | null): undefined | (() => void) {
|
|
74
|
+
if (this.editor !== nextEditor) {
|
|
75
|
+
this.editor = nextEditor as TEditor;
|
|
76
|
+
this.lastTransactionNumber = this.transactionNumber;
|
|
77
|
+
this.lastSnapshot = {
|
|
78
|
+
editor: this.editor,
|
|
79
|
+
transactionNumber: this.transactionNumber,
|
|
80
|
+
};
|
|
81
|
+
this.subscribers.forEach((callback) => callback());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!this.editor) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const onTransaction = () => {
|
|
89
|
+
this.transactionNumber += 1;
|
|
90
|
+
this.subscribers.forEach((callback) => callback());
|
|
91
|
+
};
|
|
92
|
+
const currentEditor = this.editor;
|
|
93
|
+
|
|
94
|
+
currentEditor.on('transaction', onTransaction);
|
|
95
|
+
return () => {
|
|
96
|
+
currentEditor.off('transaction', onTransaction);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function useEditorState<TSelectorResult>(
|
|
102
|
+
options: UseEditorStateOptions<TSelectorResult, Editor>,
|
|
103
|
+
): TSelectorResult;
|
|
104
|
+
export function useEditorState<TSelectorResult>(
|
|
105
|
+
options: UseEditorStateOptions<TSelectorResult, Editor | null>,
|
|
106
|
+
): TSelectorResult | null;
|
|
107
|
+
export function useEditorState<TSelectorResult>(
|
|
108
|
+
options:
|
|
109
|
+
| UseEditorStateOptions<TSelectorResult, Editor>
|
|
110
|
+
| UseEditorStateOptions<TSelectorResult, Editor | null>,
|
|
111
|
+
...args: [slot?: symbol]
|
|
112
|
+
): TSelectorResult | null {
|
|
113
|
+
const [, slot] = splitSlot(args);
|
|
114
|
+
const [editorStateManager] = useState(
|
|
115
|
+
() => new EditorStateManager(options.editor),
|
|
116
|
+
subSlot(slot, 'editor-state:manager'),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const selectedState = useSyncExternalStoreWithSelector(
|
|
120
|
+
editorStateManager.subscribe,
|
|
121
|
+
editorStateManager.getSnapshot,
|
|
122
|
+
editorStateManager.getServerSnapshot,
|
|
123
|
+
options.selector as UseEditorStateOptions<TSelectorResult, Editor | null>['selector'],
|
|
124
|
+
options.equalityFn ?? deepEqual,
|
|
125
|
+
subSlot(slot, 'editor-state:selector'),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
useIsomorphicLayoutEffect(
|
|
129
|
+
() => editorStateManager.watch(options.editor),
|
|
130
|
+
[options.editor, editorStateManager],
|
|
131
|
+
subSlot(slot, 'editor-state:watch'),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
useDebugValue(selectedState, undefined, subSlot(slot, 'editor-state:debug'));
|
|
135
|
+
|
|
136
|
+
return selectedState;
|
|
137
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Port of `use-sync-external-store/with-selector` onto Octane's native
|
|
2
|
+
// useSyncExternalStore. Snapshot memoization preserves the previous selected
|
|
3
|
+
// reference when the equality function accepts a new selection.
|
|
4
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'octane';
|
|
5
|
+
|
|
6
|
+
import { splitSlot, subSlot } from './internal';
|
|
7
|
+
|
|
8
|
+
const objectIs: (left: unknown, right: unknown) => boolean =
|
|
9
|
+
typeof Object.is === 'function'
|
|
10
|
+
? Object.is
|
|
11
|
+
: (left: any, right: any) =>
|
|
12
|
+
(left === right && (left !== 0 || 1 / left === 1 / right)) ||
|
|
13
|
+
(left !== left && right !== right);
|
|
14
|
+
|
|
15
|
+
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
|
|
16
|
+
subscribe: (onStoreChange: () => void) => () => void,
|
|
17
|
+
getSnapshot: () => Snapshot,
|
|
18
|
+
getServerSnapshot: undefined | null | (() => Snapshot),
|
|
19
|
+
selector: (snapshot: Snapshot) => Selection,
|
|
20
|
+
...args: [isEqual?: (left: Selection, right: Selection) => boolean, slot?: symbol]
|
|
21
|
+
): Selection {
|
|
22
|
+
const [userArgs, slot] = splitSlot(args);
|
|
23
|
+
const isEqual = userArgs[0] as ((left: Selection, right: Selection) => boolean) | undefined;
|
|
24
|
+
const instRef = useRef<{ hasValue: boolean; value: Selection | null } | null>(
|
|
25
|
+
null,
|
|
26
|
+
subSlot(slot, 'selector:instance'),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
let inst: { hasValue: boolean; value: Selection | null };
|
|
30
|
+
if (instRef.current === null) {
|
|
31
|
+
inst = { hasValue: false, value: null };
|
|
32
|
+
instRef.current = inst;
|
|
33
|
+
} else {
|
|
34
|
+
inst = instRef.current;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [getSelection, getServerSelection] = useMemo(
|
|
38
|
+
() => {
|
|
39
|
+
let hasMemo = false;
|
|
40
|
+
let memoizedSnapshot: Snapshot;
|
|
41
|
+
let memoizedSelection: Selection;
|
|
42
|
+
|
|
43
|
+
const memoizedSelector = (nextSnapshot: Snapshot): Selection => {
|
|
44
|
+
if (!hasMemo) {
|
|
45
|
+
hasMemo = true;
|
|
46
|
+
memoizedSnapshot = nextSnapshot;
|
|
47
|
+
const nextSelection = selector(nextSnapshot);
|
|
48
|
+
|
|
49
|
+
if (isEqual !== undefined && inst.hasValue) {
|
|
50
|
+
const currentSelection = inst.value as Selection;
|
|
51
|
+
if (isEqual(currentSelection, nextSelection)) {
|
|
52
|
+
memoizedSelection = currentSelection;
|
|
53
|
+
return currentSelection;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
memoizedSelection = nextSelection;
|
|
58
|
+
return nextSelection;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const currentSelection = memoizedSelection;
|
|
62
|
+
if (objectIs(memoizedSnapshot, nextSnapshot)) {
|
|
63
|
+
return currentSelection;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const nextSelection = selector(nextSnapshot);
|
|
67
|
+
if (isEqual !== undefined && isEqual(currentSelection, nextSelection)) {
|
|
68
|
+
memoizedSnapshot = nextSnapshot;
|
|
69
|
+
return currentSelection;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
memoizedSnapshot = nextSnapshot;
|
|
73
|
+
memoizedSelection = nextSelection;
|
|
74
|
+
return nextSelection;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const serverSnapshot = getServerSnapshot ?? undefined;
|
|
78
|
+
return [
|
|
79
|
+
() => memoizedSelector(getSnapshot()),
|
|
80
|
+
serverSnapshot === undefined ? undefined : () => memoizedSelector(serverSnapshot()),
|
|
81
|
+
] as const;
|
|
82
|
+
},
|
|
83
|
+
[getSnapshot, getServerSnapshot, selector, isEqual],
|
|
84
|
+
subSlot(slot, 'selector:memo'),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const value = useSyncExternalStore(
|
|
88
|
+
subscribe,
|
|
89
|
+
getSelection,
|
|
90
|
+
getServerSelection ?? getSelection,
|
|
91
|
+
subSlot(slot, 'selector:store'),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
useEffect(
|
|
95
|
+
() => {
|
|
96
|
+
inst.hasValue = true;
|
|
97
|
+
inst.value = value;
|
|
98
|
+
},
|
|
99
|
+
[value],
|
|
100
|
+
subSlot(slot, 'selector:effect'),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return value;
|
|
104
|
+
}
|