mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-23 23:19:40 +01:00
editor: fix performance issues due to using portals for react node views
This commit is contained in:
committed by
Abdullah Atta
parent
716239abfd
commit
a8db65b5af
@@ -87,6 +87,7 @@ function saveContent(noteId: string, ignoreEdit: boolean, content: string) {
|
|||||||
const deferredSave = debounceWithId(saveContent, 100);
|
const deferredSave = debounceWithId(saveContent, 100);
|
||||||
|
|
||||||
export default function TabsView() {
|
export default function TabsView() {
|
||||||
|
const sessions = useEditorStore((store) => store.sessions);
|
||||||
const documentPreview = useEditorStore((store) => store.documentPreview);
|
const documentPreview = useEditorStore((store) => store.documentPreview);
|
||||||
const activeSessionId = useEditorStore((store) => store.activeSessionId);
|
const activeSessionId = useEditorStore((store) => store.activeSessionId);
|
||||||
const arePropertiesVisible = useEditorStore(
|
const arePropertiesVisible = useEditorStore(
|
||||||
@@ -95,7 +96,6 @@ export default function TabsView() {
|
|||||||
const isTOCVisible = useEditorStore((store) => store.isTOCVisible);
|
const isTOCVisible = useEditorStore((store) => store.isTOCVisible);
|
||||||
const [dropRef, overlayRef] = useDragOverlay();
|
const [dropRef, overlayRef] = useDragOverlay();
|
||||||
|
|
||||||
const sessions = useEditorStore.getState().sessions;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{IS_DESKTOP_APP ? (
|
{IS_DESKTOP_APP ? (
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import "@notesnook/editor/styles/fonts.css";
|
|||||||
import {
|
import {
|
||||||
Toolbar,
|
Toolbar,
|
||||||
useTiptap,
|
useTiptap,
|
||||||
PortalProvider,
|
|
||||||
Editor,
|
Editor,
|
||||||
AttachmentType,
|
AttachmentType,
|
||||||
usePermissionHandler,
|
usePermissionHandler,
|
||||||
@@ -41,7 +40,6 @@ import {
|
|||||||
import { Box, Flex } from "@theme-ui/components";
|
import { Box, Flex } from "@theme-ui/components";
|
||||||
import {
|
import {
|
||||||
PropsWithChildren,
|
PropsWithChildren,
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -61,7 +59,6 @@ import { writeToClipboard } from "../../utils/clipboard";
|
|||||||
import { useEditorStore } from "../../stores/editor-store";
|
import { useEditorStore } from "../../stores/editor-store";
|
||||||
import { parseInternalLink } from "@notesnook/core";
|
import { parseInternalLink } from "@notesnook/core";
|
||||||
import Skeleton from "react-loading-skeleton";
|
import Skeleton from "react-loading-skeleton";
|
||||||
import { showToast } from "../../utils/toast";
|
|
||||||
|
|
||||||
export type OnChangeHandler = (
|
export type OnChangeHandler = (
|
||||||
content: () => string,
|
content: () => string,
|
||||||
@@ -402,7 +399,6 @@ function TiptapWrapper(
|
|||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalProvider>
|
|
||||||
<Flex
|
<Flex
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -453,7 +449,6 @@ function TiptapWrapper(
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</PortalProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export default TiptapWrapper;
|
export default TiptapWrapper;
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Editor,
|
Editor,
|
||||||
getFontById,
|
getFontById,
|
||||||
getTableOfContents,
|
getTableOfContents,
|
||||||
PortalProvider,
|
|
||||||
TiptapOptions,
|
TiptapOptions,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
usePermissionHandler,
|
usePermissionHandler,
|
||||||
@@ -801,11 +800,7 @@ const TiptapProvider = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}, [settings.fontSize, settings.fontFamily]);
|
}, [settings.fontSize, settings.fontFamily]);
|
||||||
|
|
||||||
return (
|
return <Tiptap settings={settings} getContentDiv={getContentDiv} />;
|
||||||
<PortalProvider>
|
|
||||||
<Tiptap settings={settings} getContentDiv={getContentDiv} />
|
|
||||||
</PortalProvider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TiptapProvider;
|
export default TiptapProvider;
|
||||||
|
|||||||
@@ -16,12 +16,7 @@ GNU General Public License for more details.
|
|||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import {
|
import { TiptapOptions, getFontById, useTiptap } from "@notesnook/editor";
|
||||||
PortalProvider,
|
|
||||||
TiptapOptions,
|
|
||||||
getFontById,
|
|
||||||
useTiptap
|
|
||||||
} from "@notesnook/editor";
|
|
||||||
import { useThemeColors } from "@notesnook/theme";
|
import { useThemeColors } from "@notesnook/theme";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -71,11 +66,7 @@ export const ReadonlyEditorProvider = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}, [settings.fontSize, settings.fontFamily]);
|
}, [settings.fontSize, settings.fontFamily]);
|
||||||
|
|
||||||
return (
|
return <Tiptap settings={settings} getContentDiv={getContentDiv} />;
|
||||||
<PortalProvider>
|
|
||||||
<Tiptap settings={settings} getContentDiv={getContentDiv} />
|
|
||||||
</PortalProvider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Tiptap = ({
|
const Tiptap = ({
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ declare module "prosemirror-view" {
|
|||||||
slice: Slice
|
slice: Slice
|
||||||
): { dom: HTMLElement; text: string };
|
): { dom: HTMLElement; text: string };
|
||||||
}
|
}
|
||||||
|
const portalProviderAPI = new PortalProviderAPI();
|
||||||
export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
||||||
private domRef!: HTMLElement;
|
private domRef!: HTMLElement;
|
||||||
private contentDOMWrapper?: Node;
|
private contentDOMWrapper?: Node;
|
||||||
@@ -53,30 +53,10 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
|||||||
pos = -1;
|
pos = -1;
|
||||||
posEnd: number | undefined;
|
posEnd: number | undefined;
|
||||||
|
|
||||||
// in order to cleanly unmount a React Portal, we have to preserve the
|
|
||||||
// dom structure for the full node. However, Prosemirror/browser can,
|
|
||||||
// sometimes, detach the node before React is able to call "unmount".
|
|
||||||
// Unmounting a React child whose DOM counterpart is already removed
|
|
||||||
// results in a DOMException.
|
|
||||||
// To fix that, we observe and store all the detached subnodes and later add
|
|
||||||
// them back when we are ready to destroy our node view once & for all.
|
|
||||||
// This wouldn't be necessary if React allowed for a way to "sync" the DOM
|
|
||||||
// changes to its Virtual DOM.
|
|
||||||
detached: Set<Node> = new Set();
|
|
||||||
detachObserver = new MutationObserver((mutations) => {
|
|
||||||
const filtered = mutations.filter(
|
|
||||||
(m) => m.target === this.domRef && m.removedNodes.length > 0
|
|
||||||
);
|
|
||||||
for (const mutation of filtered) {
|
|
||||||
for (const node of mutation.removedNodes) this.detached.add(node);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
node: PMNode,
|
node: PMNode,
|
||||||
protected readonly editor: Editor,
|
protected readonly editor: Editor,
|
||||||
protected readonly getPos: GetPosNode,
|
protected readonly getPos: GetPosNode,
|
||||||
protected readonly portalProviderAPI: PortalProviderAPI,
|
|
||||||
protected readonly options: ReactNodeViewOptions<P>
|
protected readonly options: ReactNodeViewOptions<P>
|
||||||
) {
|
) {
|
||||||
this.node = node;
|
this.node = node;
|
||||||
@@ -105,11 +85,6 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
|||||||
init() {
|
init() {
|
||||||
this.domRef = this.createDomRef();
|
this.domRef = this.createDomRef();
|
||||||
this.domRef.ondragstart = (ev) => this.onDragStart(ev);
|
this.domRef.ondragstart = (ev) => this.onDragStart(ev);
|
||||||
this.detachObserver.observe(this.domRef, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
// this.setDomAttrs(this.node, this.domRef);
|
|
||||||
|
|
||||||
const { dom: contentDOMWrapper, contentDOM } = this.getContentDOM() ?? {};
|
const { dom: contentDOMWrapper, contentDOM } = this.getContentDOM() ?? {};
|
||||||
|
|
||||||
@@ -131,12 +106,12 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
|||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
if (process.env.NODE_ENV === "test") return;
|
if (process.env.NODE_ENV === "test") return;
|
||||||
if (!this.domRef || !this.portalProviderAPI) {
|
if (!this.domRef) {
|
||||||
console.warn("Cannot render node view");
|
console.warn("Cannot render node view");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.portalProviderAPI.render(this.Component, this.domRef);
|
portalProviderAPI.render(this.Component, this.domRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
createDomRef(): HTMLElement {
|
createDomRef(): HTMLElement {
|
||||||
@@ -489,14 +464,7 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.detachObserver.disconnect();
|
portalProviderAPI.remove(this.domRef);
|
||||||
// add back the detached nodes because React expects an untouched
|
|
||||||
// DOM representation (and there's no way to reconcile the DOM later).
|
|
||||||
for (const node of this.detached) {
|
|
||||||
this.domRef.appendChild(node);
|
|
||||||
}
|
|
||||||
this.portalProviderAPI.remove(this.domRef);
|
|
||||||
this.detached.clear();
|
|
||||||
this.domRef.remove();
|
this.domRef.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,18 +475,11 @@ export function createNodeView<TProps extends ReactNodeViewProps>(
|
|||||||
) {
|
) {
|
||||||
return ({ node, getPos, editor }: NodeViewRendererProps) => {
|
return ({ node, getPos, editor }: NodeViewRendererProps) => {
|
||||||
const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos());
|
const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos());
|
||||||
if (!editor.storage.portalProviderAPI) return {};
|
|
||||||
|
|
||||||
return new ReactNodeView<TProps>(
|
return new ReactNodeView<TProps>(node, editor as Editor, _getPos, {
|
||||||
node,
|
|
||||||
editor as Editor,
|
|
||||||
_getPos,
|
|
||||||
editor.storage.portalProviderAPI,
|
|
||||||
{
|
|
||||||
...options,
|
...options,
|
||||||
component
|
component
|
||||||
}
|
}).init();
|
||||||
).init();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,10 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import { FunctionComponent, PropsWithChildren } from "react";
|
||||||
FunctionComponent,
|
import { flushSync } from "react-dom";
|
||||||
PropsWithChildren,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState
|
|
||||||
} from "react";
|
|
||||||
import { createPortal, flushSync } from "react-dom";
|
|
||||||
import { EventDispatcher } from "./event-dispatcher";
|
import { EventDispatcher } from "./event-dispatcher";
|
||||||
import { nanoid } from "nanoid";
|
import { Root, createRoot } from "react-dom/client";
|
||||||
|
|
||||||
export type BasePortalProviderProps = PropsWithChildren<unknown>;
|
export type BasePortalProviderProps = PropsWithChildren<unknown>;
|
||||||
|
|
||||||
@@ -45,93 +37,31 @@ export type PortalRendererState = {
|
|||||||
|
|
||||||
export class PortalProviderAPI extends EventDispatcher<Portals> {
|
export class PortalProviderAPI extends EventDispatcher<Portals> {
|
||||||
portals: Map<HTMLElement, MountedPortal> = new Map();
|
portals: Map<HTMLElement, MountedPortal> = new Map();
|
||||||
|
roots: Map<HTMLElement, Root> = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger an update in all subscribers.
|
|
||||||
*/
|
|
||||||
private update() {
|
|
||||||
this.emit("update", this.portals);
|
|
||||||
}
|
|
||||||
|
|
||||||
render(Component: FunctionComponent, container: HTMLElement) {
|
render(Component: FunctionComponent, container: HTMLElement) {
|
||||||
const portal = this.portals.get(container);
|
const root = this.roots.get(container) || createRoot(container);
|
||||||
this.portals.set(container, { Component, key: portal?.key ?? nanoid() });
|
flushSync(() => root.render(<Component />));
|
||||||
this.update();
|
this.roots.set(container, root);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force an update in all the portals by setting new keys for every portal.
|
|
||||||
*
|
|
||||||
* Delete all orphaned containers (deleted from the DOM). This is useful for
|
|
||||||
* Decoration where there is no destroy method.
|
|
||||||
*/
|
|
||||||
forceUpdate(): void {
|
|
||||||
for (const [container, { Component }] of this.portals) {
|
|
||||||
this.portals.set(container, { Component, key: nanoid() });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(container: HTMLElement) {
|
remove(container: HTMLElement) {
|
||||||
// Remove the portal which was being wrapped in the provided container.
|
// if container is already unmounted (maybe by prosemirror),
|
||||||
this.portals.delete(container);
|
// no need to proceed
|
||||||
|
if (!container.parentNode) return;
|
||||||
|
|
||||||
// Trigger an update
|
const root = this.roots.get(container);
|
||||||
this.update();
|
if (!root) return;
|
||||||
|
this.roots.delete(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
root.unmount();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const PortalProviderContext = React.createContext<
|
|
||||||
PortalProviderAPI | undefined
|
|
||||||
>(undefined);
|
|
||||||
export function usePortalProvider() {
|
|
||||||
return useContext(PortalProviderContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PortalProvider(props: PropsWithChildren) {
|
|
||||||
const portalProviderAPI = useMemo(() => new PortalProviderAPI(), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PortalProviderContext.Provider value={portalProviderAPI}>
|
|
||||||
{props.children}
|
|
||||||
<PortalRenderer portalProviderAPI={portalProviderAPI} />
|
|
||||||
</PortalProviderContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PortalRenderer(props: { portalProviderAPI: PortalProviderAPI }) {
|
|
||||||
const { portalProviderAPI } = props;
|
|
||||||
const mounted = useRef(true);
|
|
||||||
const [portals, setPortals] = useState(() =>
|
|
||||||
Array.from(portalProviderAPI.portals.entries())
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mounted.current = true;
|
|
||||||
function onUpdate(portalMap: Portals) {
|
|
||||||
// we have to make sure the component is mounted otherwise React
|
|
||||||
// throws an error.
|
|
||||||
if (!mounted.current) return;
|
|
||||||
|
|
||||||
// flushSync is necessary here, otherwise we get into a loop where
|
|
||||||
// ProseMirror destroys and recreates the node view over and over again.
|
|
||||||
flushSync(() => setPortals(Array.from(portalMap.entries())));
|
|
||||||
}
|
|
||||||
portalProviderAPI.on("update", onUpdate);
|
|
||||||
return () => {
|
|
||||||
mounted.current = false;
|
|
||||||
portalProviderAPI.off("update", onUpdate);
|
|
||||||
};
|
|
||||||
}, [portalProviderAPI]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{portals.map(([container, { Component, key }]) =>
|
|
||||||
createPortal(<Component />, container, key)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ export function TableComponent(props: ReactNodeViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TableNodeView(editor: TiptapEditor) {
|
export function TableNodeView(editor: TiptapEditor) {
|
||||||
if (!editor.storage.portalProviderAPI) return;
|
|
||||||
const api = editor.storage.portalProviderAPI;
|
|
||||||
class TableNode
|
class TableNode
|
||||||
extends ReactNodeView<ReactNodeViewProps<unknown>>
|
extends ReactNodeView<ReactNodeViewProps<unknown>>
|
||||||
implements NodeView
|
implements NodeView
|
||||||
@@ -93,7 +91,6 @@ export function TableNodeView(editor: TiptapEditor) {
|
|||||||
node,
|
node,
|
||||||
editor,
|
editor,
|
||||||
() => 0, // todo
|
() => 0, // todo
|
||||||
api,
|
|
||||||
{
|
{
|
||||||
component: TableComponent,
|
component: TableComponent,
|
||||||
shouldUpdate: (prev, next) => {
|
shouldUpdate: (prev, next) => {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ import OrderedList from "./extensions/ordered-list";
|
|||||||
import { OutlineList } from "./extensions/outline-list";
|
import { OutlineList } from "./extensions/outline-list";
|
||||||
import { OutlineListItem } from "./extensions/outline-list-item";
|
import { OutlineListItem } from "./extensions/outline-list-item";
|
||||||
import { Paragraph } from "./extensions/paragraph";
|
import { Paragraph } from "./extensions/paragraph";
|
||||||
import { PortalProviderAPI, usePortalProvider } from "./extensions/react";
|
|
||||||
import { SearchReplace } from "./extensions/search-replace";
|
import { SearchReplace } from "./extensions/search-replace";
|
||||||
import { Table } from "./extensions/table";
|
import { Table } from "./extensions/table";
|
||||||
import TableCell from "./extensions/table-cell";
|
import TableCell from "./extensions/table-cell";
|
||||||
@@ -87,7 +86,6 @@ import { useEditorSearchStore } from "./toolbar/stores/search-store";
|
|||||||
import { DiffHighlighter } from "./extensions/diff-highlighter";
|
import { DiffHighlighter } from "./extensions/diff-highlighter";
|
||||||
|
|
||||||
interface TiptapStorage {
|
interface TiptapStorage {
|
||||||
portalProviderAPI?: PortalProviderAPI;
|
|
||||||
dateFormat?: DateTimeOptions["dateFormat"];
|
dateFormat?: DateTimeOptions["dateFormat"];
|
||||||
timeFormat?: DateTimeOptions["timeFormat"];
|
timeFormat?: DateTimeOptions["timeFormat"];
|
||||||
openLink?: (url: string) => void;
|
openLink?: (url: string) => void;
|
||||||
@@ -124,7 +122,7 @@ export type TiptapOptions = EditorOptions &
|
|||||||
Omit<WebClipOptions, "HTMLAttributes"> &
|
Omit<WebClipOptions, "HTMLAttributes"> &
|
||||||
Omit<ImageOptions, "HTMLAttributes"> &
|
Omit<ImageOptions, "HTMLAttributes"> &
|
||||||
DateTimeOptions &
|
DateTimeOptions &
|
||||||
Omit<TiptapStorage, "portalProviderAPI"> & {
|
TiptapStorage & {
|
||||||
downloadOptions?: DownloadOptions;
|
downloadOptions?: DownloadOptions;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
doubleSpacedLines?: boolean;
|
doubleSpacedLines?: boolean;
|
||||||
@@ -153,7 +151,6 @@ const useTiptap = (
|
|||||||
...restOptions
|
...restOptions
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const PortalProviderAPI = usePortalProvider();
|
|
||||||
const setIsMobile = useToolbarStore((store) => store.setIsMobile);
|
const setIsMobile = useToolbarStore((store) => store.setIsMobile);
|
||||||
const closeAllPopups = useToolbarStore((store) => store.closeAllPopups);
|
const closeAllPopups = useToolbarStore((store) => store.closeAllPopups);
|
||||||
const setDownloadOptions = useToolbarStore(
|
const setDownloadOptions = useToolbarStore(
|
||||||
@@ -356,7 +353,6 @@ const useTiptap = (
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
onBeforeCreate: ({ editor }) => {
|
onBeforeCreate: ({ editor }) => {
|
||||||
editor.storage.portalProviderAPI = PortalProviderAPI;
|
|
||||||
editor.storage.dateFormat = dateFormat;
|
editor.storage.dateFormat = dateFormat;
|
||||||
editor.storage.timeFormat = timeFormat;
|
editor.storage.timeFormat = timeFormat;
|
||||||
|
|
||||||
@@ -378,7 +374,6 @@ const useTiptap = (
|
|||||||
downloadAttachment,
|
downloadAttachment,
|
||||||
openAttachmentPicker,
|
openAttachmentPicker,
|
||||||
getAttachmentData,
|
getAttachmentData,
|
||||||
PortalProviderAPI,
|
|
||||||
onBeforeCreate,
|
onBeforeCreate,
|
||||||
openLink,
|
openLink,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
@@ -403,7 +398,6 @@ const useTiptap = (
|
|||||||
export { type Fragment } from "prosemirror-model";
|
export { type Fragment } from "prosemirror-model";
|
||||||
export { type Attachment, type AttachmentType } from "./extensions/attachment";
|
export { type Attachment, type AttachmentType } from "./extensions/attachment";
|
||||||
export { type ImageAttributes } from "./extensions/image";
|
export { type ImageAttributes } from "./extensions/image";
|
||||||
export * from "./extensions/react";
|
|
||||||
export * from "./toolbar";
|
export * from "./toolbar";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./utils/word-counter";
|
export * from "./utils/word-counter";
|
||||||
|
|||||||
Reference in New Issue
Block a user