@octanejs/tiptap 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -28,7 +28,7 @@ export function RichTextEditor() @{
28
28
 
29
29
  ## Current scope
30
30
 
31
- The initial binding includes:
31
+ The binding includes:
32
32
 
33
33
  - `useEditor`, including create/update/recreate and deferred-destroy lifecycle
34
34
  behavior;
@@ -36,21 +36,33 @@ The initial binding includes:
36
36
  bailout behavior;
37
37
  - `EditorContext`, `EditorProvider`, `EditorConsumer`, and `useCurrentEditor`;
38
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.
39
+ exposing an editor DOM while owning its portal registry;
40
+ - `Tiptap`, `Tiptap.Content`, `useTiptap`, and `useTiptapState`;
41
+ - `ReactRenderer`, `ReactNodeViewRenderer`, `ReactMarkViewRenderer`,
42
+ `useReactNodeView`, and the NodeView/MarkView wrapper and content components
43
+ for custom Octane editor views; and
44
+ - `BubbleMenu` and `FloatingMenu` from the `@octanejs/tiptap/menus` subpath.
45
+
46
+ The public names follow `@tiptap/react` so existing TipTap extensions can keep
47
+ their renderer declarations while their components move to Octane. Despite the
48
+ compatibility names, the implementation does not install or render through
49
+ React.
50
+
51
+ For the pinned 3.28.0 release, the root and `./menus` runtime export surfaces
52
+ match `@tiptap/react`. Package-boundary tests lock those exports and the client
53
+ module directives, while shared-fixture differential tests exercise the same
54
+ editor and custom-view components through both bindings.
55
+
56
+ On the server, editor construction is suppressed, hook snapshots use a `null`
57
+ editor, and menu portals emit no detached target. Set `immediatelyRender: false`
58
+ when the editor should remain nullable through the initial client render as
59
+ well. The binding's SSR and hydration suite verifies deferred server output,
60
+ adoption of the existing host DOM, and creation of the live editor after
61
+ hydration.
62
+
63
+ Real-browser coverage complements the jsdom suite for contracts that require a
64
+ layout engine: caret-preserving input, text selection, NodeView dragging, and
65
+ BubbleMenu/FloatingMenu visibility and positioning.
54
66
 
55
67
  ## Status
56
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/tiptap",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -21,7 +21,7 @@
21
21
  }
22
22
  }
23
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.",
24
+ "description": "TipTap bindings for Octane, including editor hooks and contexts, EditorContent, custom node and mark views, and floating menus without a React runtime dependency.",
25
25
  "author": {
26
26
  "name": "Dominic Gannaway",
27
27
  "email": "dg@domgan.com"
@@ -42,15 +42,20 @@
42
42
  "README.md"
43
43
  ],
44
44
  "exports": {
45
- ".": "./src/index.ts"
45
+ ".": "./src/index.ts",
46
+ "./menus": "./src/menus/index.ts"
46
47
  },
47
48
  "dependencies": {
48
49
  "@tiptap/core": "3.28.0",
49
50
  "@tiptap/pm": "3.28.0",
50
51
  "fast-equals": "^5.3.3"
51
52
  },
53
+ "optionalDependencies": {
54
+ "@tiptap/extension-bubble-menu": "3.28.0",
55
+ "@tiptap/extension-floating-menu": "3.28.0"
56
+ },
52
57
  "peerDependencies": {
53
- "octane": "0.1.7"
58
+ "octane": "0.1.9"
54
59
  },
55
60
  "devDependencies": {
56
61
  "@testing-library/jest-dom": "^6.9.1",
@@ -58,11 +63,13 @@
58
63
  "@tiptap/starter-kit": "3.28.0",
59
64
  "@tsrx/react": "^0.2.37",
60
65
  "esbuild": "^0.28.1",
66
+ "playwright": "^1.61.0",
61
67
  "react": "^19.2.0",
62
68
  "react-dom": "^19.2.0",
69
+ "vite": "^8.0.16",
63
70
  "vitest": "^4.1.9",
64
- "@octanejs/testing-library": "0.1.4",
65
- "octane": "0.1.7"
71
+ "@octanejs/testing-library": "0.1.6",
72
+ "octane": "0.1.9"
66
73
  },
67
74
  "scripts": {
68
75
  "test": "vitest run"
package/src/Editor.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { Editor } from '@tiptap/core';
2
+ import type { PortalDescriptor } from 'octane';
3
+
4
+ import type { ReactRenderer } from './ReactRenderer';
5
+
6
+ /** One immutable renderer entry consumed by EditorContent's keyed portal list. */
7
+ export type RendererPortalEntry = {
8
+ id: string;
9
+ portal: PortalDescriptor;
10
+ };
11
+
12
+ export type RendererPortalSnapshot = Readonly<Record<string, RendererPortalEntry>>;
13
+
14
+ /** External-store registry shared by EditorContent and custom view renderers. */
15
+ export type ContentComponent = {
16
+ setRenderer(id: string, renderer: ReactRenderer<any, any>): void;
17
+ removeRenderer(id: string): void;
18
+ subscribe(callback: () => void): () => void;
19
+ getSnapshot(): RendererPortalSnapshot;
20
+ getServerSnapshot(): RendererPortalSnapshot;
21
+ };
22
+
23
+ /** TipTap's Editor augmented with the framework binding's mounted content state. */
24
+ export type EditorWithContentComponent = Editor & {
25
+ contentComponent?: ContentComponent | null;
26
+ isEditorContentInitialized?: boolean;
27
+ };
@@ -1,11 +1,86 @@
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';
1
+ // DOM lifecycle and portal-registry bridge ported from @tiptap/react 3.28.0.
2
+ import {
3
+ createPortal,
4
+ memo,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ } from 'octane';
11
+
12
+ function PortalEntry({ entry }) {
13
+ return entry.portal;
14
+ }
15
+
16
+ /** Render every custom view in this EditorContent's logical Octane tree. */
17
+ function Portals({ contentComponent }) @{
18
+ const renderers = useSyncExternalStore(
19
+ contentComponent.subscribe,
20
+ contentComponent.getSnapshot,
21
+ contentComponent.getServerSnapshot,
22
+ );
23
+ const entries = Object.values(renderers);
24
+
25
+ <>
26
+ @for (const entry of entries; key entry.id) {
27
+ <PortalEntry entry={entry} />
28
+ }
29
+ </>
30
+ }
31
+
32
+ /** Create the immutable external-store registry consumed by Portals. */
33
+ export function createContentComponent() {
34
+ const subscribers = new Set();
35
+ let renderers = {};
36
+ let isNotificationQueued = false;
37
+
38
+ const notifySubscribers = () => {
39
+ if (isNotificationQueued || !subscribers.size) {
40
+ return;
41
+ }
42
+
43
+ isNotificationQueued = true;
44
+ queueMicrotask(() => {
45
+ isNotificationQueued = false;
46
+ subscribers.forEach((subscriber) => subscriber());
47
+ });
48
+ };
49
+
50
+ return {
51
+ subscribe(callback) {
52
+ subscribers.add(callback);
53
+ return () => subscribers.delete(callback);
54
+ },
55
+ getSnapshot() {
56
+ return renderers;
57
+ },
58
+ getServerSnapshot() {
59
+ return renderers;
60
+ },
61
+ setRenderer(id, renderer) {
62
+ renderers = {
63
+ ...renderers,
64
+ [id]: {
65
+ id,
66
+ portal: createPortal(renderer.reactElement, renderer.element),
67
+ },
68
+ };
69
+ notifySubscribers();
70
+ },
71
+ removeRenderer(id) {
72
+ const nextRenderers = { ...renderers };
73
+ delete nextRenderers[id];
74
+ renderers = nextRenderers;
75
+ notifySubscribers();
76
+ },
77
+ };
78
+ }
5
79
 
6
80
  export function PureEditorContent(props) @{
7
81
  const { editor, innerRef, ref, ...rest } = props;
8
82
  const editorContentRef = useRef(null);
83
+ const [contentComponent, setContentComponent] = useState(null);
9
84
 
10
85
  useLayoutEffect(() => {
11
86
  if (!editor || editor.isDestroyed || editor.isEditorContentInitialized) {
@@ -24,16 +99,26 @@ export function PureEditorContent(props) @{
24
99
  // moving nodes preserves the existing EditorView and DOM identity.
25
100
  element.append(...editorParent.childNodes);
26
101
  editor.setOptions({ element });
102
+
103
+ // The registry must exist before createNodeViews: renderer factories publish
104
+ // into it while ProseMirror builds the custom view instances.
105
+ const nextContentComponent = createContentComponent();
106
+ editor.contentComponent = nextContentComponent;
27
107
  editor.createNodeViews();
28
108
  editor.isEditorContentInitialized = true;
109
+ setContentComponent(nextContentComponent);
29
110
 
30
111
  return () => {
31
112
  editor.isEditorContentInitialized = false;
32
113
 
33
114
  if (!editor.isDestroyed) {
34
- editor.view.setProps({ nodeViews: {} });
115
+ // ProseMirror destroys the live view instances here. Keep the registry
116
+ // installed until their destroy methods have unregistered every portal.
117
+ editor.view.setProps({ nodeViews: {}, markViews: {} });
35
118
  }
36
119
 
120
+ editor.contentComponent = null;
121
+
37
122
  // Reset the editor to a detached element so the still-live instance can
38
123
  // safely be mounted by a later EditorContent. A view that has already
39
124
  // been destroyed or never mounted has nothing left to transfer.
@@ -53,7 +138,12 @@ export function PureEditorContent(props) @{
53
138
  };
54
139
  }, [editor]);
55
140
 
56
- <div {...rest} ref={[ref, innerRef, editorContentRef]} />
141
+ <>
142
+ <div {...rest} ref={[ref, innerRef, editorContentRef]} />
143
+ @if (contentComponent) {
144
+ <Portals contentComponent={contentComponent} />
145
+ }
146
+ </>
57
147
  }
58
148
 
59
149
  function EditorContentWithKey(props) @{
@@ -1,5 +1,7 @@
1
1
  import type { Editor } from '@tiptap/core';
2
2
 
3
+ import type { ContentComponent } from './Editor';
4
+
3
5
  export type EditorContentRef<T> =
4
6
  | { current: T | null }
5
7
  | ((value: T | null) => void | (() => void))
@@ -17,5 +19,6 @@ export interface EditorContentProps {
17
19
  [key: string]: unknown;
18
20
  }
19
21
 
22
+ export declare function createContentComponent(): ContentComponent;
20
23
  export declare function PureEditorContent(props: EditorContentProps): unknown;
21
24
  export declare const EditorContent: (props: EditorContentProps) => unknown;
@@ -0,0 +1,30 @@
1
+ import { createElement, type ComponentBody } from 'octane';
2
+
3
+ import { useReactNodeView } from './useReactNodeView';
4
+
5
+ type HostComponent = string | ComponentBody<any>;
6
+
7
+ export interface NodeViewContentProps {
8
+ as?: HostComponent;
9
+ style?: Record<string, unknown>;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ export function NodeViewContent({ as: Tag = 'div', ...props }: NodeViewContentProps): unknown {
14
+ const { nodeViewContentRef, nodeViewContentChildren } = useReactNodeView();
15
+ // The editable content node is owned by ProseMirror rather than Octane. Keep a
16
+ // fresh adapter so a host-prop update re-attaches that node after Octane has
17
+ // reconciled the wrapper's authored children.
18
+ const contentRef = (element: HTMLElement | null) => nodeViewContentRef?.(element);
19
+
20
+ return createElement(Tag, {
21
+ ...props,
22
+ ref: contentRef,
23
+ 'data-node-view-content': '',
24
+ style: {
25
+ whiteSpace: 'pre-wrap',
26
+ ...props.style,
27
+ },
28
+ children: nodeViewContentChildren,
29
+ });
30
+ }
@@ -0,0 +1,27 @@
1
+ import { createElement, type ComponentBody } from 'octane';
2
+
3
+ import { useReactNodeView } from './useReactNodeView';
4
+
5
+ type HostComponent = string | ComponentBody<any>;
6
+
7
+ export interface NodeViewWrapperProps {
8
+ as?: HostComponent;
9
+ ref?: unknown;
10
+ style?: Record<string, unknown>;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ export function NodeViewWrapper({ as: Tag = 'div', ...props }: NodeViewWrapperProps): unknown {
15
+ const { onDragStart } = useReactNodeView();
16
+
17
+ return createElement(Tag, {
18
+ ...props,
19
+ ref: props.ref,
20
+ 'data-node-view-wrapper': '',
21
+ onDragStart,
22
+ style: {
23
+ whiteSpace: 'normal',
24
+ ...props.style,
25
+ },
26
+ });
27
+ }
@@ -0,0 +1,113 @@
1
+ import type { MarkViewProps, MarkViewRenderer, MarkViewRendererOptions } from '@tiptap/core';
2
+ import { MarkView } from '@tiptap/core';
3
+ import { createContext, createElement, memo, useContext, type ComponentBody } from 'octane';
4
+
5
+ import { ReactRenderer } from './ReactRenderer';
6
+
7
+ export interface MarkViewContextProps {
8
+ markViewContentRef: (element: HTMLElement | null) => void;
9
+ }
10
+
11
+ export const ReactMarkViewContext = createContext<MarkViewContextProps>({
12
+ markViewContentRef: () => {},
13
+ });
14
+
15
+ type HostComponent = string | ComponentBody<any>;
16
+
17
+ export interface MarkViewContentProps {
18
+ as?: HostComponent;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ /** Host the ProseMirror-managed content of a custom mark view. */
23
+ export function MarkViewContent({ as: Tag = 'span', ...props }: MarkViewContentProps): unknown {
24
+ const { markViewContentRef } = useContext(ReactMarkViewContext);
25
+ // Mark content is mutated by ProseMirror. Re-attach it after any Octane host
26
+ // reconciliation instead of treating the unmanaged child as authored output.
27
+ const contentRef = (element: HTMLElement | null) => markViewContentRef(element);
28
+
29
+ return createElement(Tag, {
30
+ ...props,
31
+ ref: contentRef,
32
+ 'data-mark-view-content': '',
33
+ });
34
+ }
35
+
36
+ export interface ReactMarkViewRendererOptions extends MarkViewRendererOptions {
37
+ /** Wrapper element tag. */
38
+ as?: string;
39
+ /** Additional wrapper classes. */
40
+ className?: string;
41
+ /** Static wrapper attributes. */
42
+ attrs?: Record<string, string>;
43
+ }
44
+
45
+ export class ReactMarkView extends MarkView<
46
+ ComponentBody<MarkViewProps>,
47
+ ReactMarkViewRendererOptions
48
+ > {
49
+ renderer: ReactRenderer<unknown, MarkViewProps>;
50
+ contentDOMElement: HTMLElement;
51
+
52
+ constructor(
53
+ component: ComponentBody<MarkViewProps>,
54
+ props: MarkViewProps,
55
+ options?: Partial<ReactMarkViewRendererOptions>,
56
+ ) {
57
+ super(component, props, options);
58
+
59
+ const { as = 'span', attrs, className = '' } = options || {};
60
+ const componentProps = {
61
+ ...props,
62
+ updateAttributes: this.updateAttributes.bind(this),
63
+ } satisfies MarkViewProps;
64
+
65
+ this.contentDOMElement = document.createElement('span');
66
+ const context: MarkViewContextProps = {
67
+ markViewContentRef: (element) => {
68
+ if (element && !element.contains(this.contentDOMElement)) {
69
+ element.appendChild(this.contentDOMElement);
70
+ }
71
+ },
72
+ };
73
+ const providerBody: ComponentBody<MarkViewProps> = (providerProps) =>
74
+ createElement(ReactMarkViewContext.Provider, {
75
+ value: context,
76
+ children: createElement(component, providerProps),
77
+ });
78
+ const ReactMarkViewProvider = memo(providerBody);
79
+ (ReactMarkViewProvider as any).displayName = 'ReactMarkView';
80
+
81
+ this.renderer = new ReactRenderer(ReactMarkViewProvider, {
82
+ editor: props.editor,
83
+ props: componentProps,
84
+ as,
85
+ className: `mark-${props.mark.type.name} ${className}`.trim(),
86
+ });
87
+
88
+ if (attrs) {
89
+ this.renderer.updateAttributes(attrs);
90
+ }
91
+ }
92
+
93
+ get dom(): HTMLElement {
94
+ return this.renderer.element;
95
+ }
96
+
97
+ get contentDOM(): HTMLElement {
98
+ return this.contentDOMElement;
99
+ }
100
+
101
+ /** ProseMirror calls this when the mark view leaves the document. */
102
+ destroy(): void {
103
+ this.renderer.destroy();
104
+ }
105
+ }
106
+
107
+ /** Create a TipTap mark-view renderer backed by an Octane portal. */
108
+ export function ReactMarkViewRenderer(
109
+ component: ComponentBody<MarkViewProps>,
110
+ options: Partial<ReactMarkViewRendererOptions> = {},
111
+ ): MarkViewRenderer {
112
+ return (props) => new ReactMarkView(component, props, options);
113
+ }