[WEB-2730] chore: core/editor updates to support mobile editor (#5910)
* added editor changes w.r.t mobile-editor * added external extensions option * fix: type errors in image block * added on transaction method * fix: optional prop fixed * fix: memoize the extensions array * fix: added missing deps * fix: image component types * fix: remove range prop * fix: type fixes and better names of img src * fix: image load blinking * fix: code review * fix: props code review * fix: coderabbit review --------- Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
This commit is contained in:
parent
8ea34b5995
commit
3696062372
26 changed files with 164 additions and 61 deletions
|
|
@ -14,7 +14,8 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||||
const extensions: Extensions = [SlashCommands()];
|
const { disabledExtensions } = _props;
|
||||||
|
const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
|
||||||
|
|
||||||
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||||
const {
|
const {
|
||||||
|
onTransaction,
|
||||||
aiHandler,
|
aiHandler,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
|
|
@ -43,6 +44,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||||
|
|
||||||
// use document editor
|
// use document editor
|
||||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||||
|
onTransaction,
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
embedHandler,
|
embedHandler,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
onChange,
|
onChange,
|
||||||
|
onTransaction,
|
||||||
|
handleEditorReady,
|
||||||
|
autofocus,
|
||||||
placeholder,
|
placeholder,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
value,
|
value,
|
||||||
|
|
@ -43,6 +46,9 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||||
initialValue,
|
initialValue,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
onChange,
|
onChange,
|
||||||
|
onTransaction,
|
||||||
|
handleEditorReady,
|
||||||
|
autofocus,
|
||||||
placeholder,
|
placeholder,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { forwardRef } from "react";
|
import { forwardRef, useMemo } from "react";
|
||||||
// components
|
// components
|
||||||
import { EditorWrapper } from "@/components/editors/editor-wrapper";
|
import { EditorWrapper } from "@/components/editors/editor-wrapper";
|
||||||
// extensions
|
// extensions
|
||||||
|
|
@ -7,9 +7,15 @@ import { EnterKeyExtension } from "@/extensions";
|
||||||
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
||||||
|
|
||||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||||
const { onEnterKeyPress } = props;
|
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
|
||||||
|
|
||||||
const extensions = [EnterKeyExtension(onEnterKeyPress)];
|
const extensions = useMemo(
|
||||||
|
() => [
|
||||||
|
...externalExtensions,
|
||||||
|
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
|
||||||
|
],
|
||||||
|
[externalExtensions, disabledExtensions, onEnterKeyPress]
|
||||||
|
);
|
||||||
|
|
||||||
return <EditorWrapper {...props} extensions={extensions} />;
|
return <EditorWrapper {...props} extensions={extensions} />;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,31 @@ import { SideMenuExtension, SlashCommands } from "@/extensions";
|
||||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||||
|
|
||||||
const RichTextEditor = (props: IRichTextEditor) => {
|
const RichTextEditor = (props: IRichTextEditor) => {
|
||||||
const { dragDropEnabled } = props;
|
const {
|
||||||
|
disabledExtensions,
|
||||||
|
dragDropEnabled,
|
||||||
|
bubbleMenuEnabled = true,
|
||||||
|
extensions: externalExtensions = [],
|
||||||
|
} = props;
|
||||||
|
|
||||||
const getExtensions = useCallback(() => {
|
const getExtensions = useCallback(() => {
|
||||||
const extensions = [SlashCommands()];
|
const extensions = [
|
||||||
|
...externalExtensions,
|
||||||
extensions.push(
|
|
||||||
SideMenuExtension({
|
SideMenuExtension({
|
||||||
aiEnabled: false,
|
aiEnabled: false,
|
||||||
dragDropEnabled: !!dragDropEnabled,
|
dragDropEnabled: !!dragDropEnabled,
|
||||||
})
|
}),
|
||||||
);
|
];
|
||||||
|
if (!disabledExtensions?.includes("slash-commands")) {
|
||||||
|
extensions.push(SlashCommands());
|
||||||
|
}
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
}, [dragDropEnabled]);
|
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||||
{(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>}
|
{(editor) => <>{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}</>}
|
||||||
</EditorWrapper>
|
</EditorWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ import {
|
||||||
Heading6,
|
Heading6,
|
||||||
CaseSensitive,
|
CaseSensitive,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
|
MinusSquare,
|
||||||
Palette,
|
Palette,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import {
|
import {
|
||||||
|
insertHorizontalRule,
|
||||||
insertImage,
|
insertImage,
|
||||||
insertTableCommand,
|
insertTableCommand,
|
||||||
setText,
|
setText,
|
||||||
|
|
@ -208,6 +210,15 @@ export const ImageItem = (editor: Editor) =>
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
}) as const;
|
}) as const;
|
||||||
|
|
||||||
|
export const HorizontalRuleItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
|
key: "divider",
|
||||||
|
name: "Divider",
|
||||||
|
isActive: () => editor?.isActive("horizontalRule"),
|
||||||
|
command: () => insertHorizontalRule(editor),
|
||||||
|
icon: MinusSquare,
|
||||||
|
}) as const;
|
||||||
|
|
||||||
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
|
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
|
||||||
key: "text-color",
|
key: "text-color",
|
||||||
name: "Color",
|
name: "Color",
|
||||||
|
|
@ -246,6 +257,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
|
||||||
QuoteItem(editor),
|
QuoteItem(editor),
|
||||||
TableItem(editor),
|
TableItem(editor),
|
||||||
ImageItem(editor),
|
ImageItem(editor),
|
||||||
|
HorizontalRuleItem(editor),
|
||||||
TextColorItem(editor),
|
TextColorItem(editor),
|
||||||
BackgroundColorItem(editor),
|
BackgroundColorItem(editor),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||||
import { NodeSelection } from "@tiptap/pm/state";
|
import { NodeSelection } from "@tiptap/pm/state";
|
||||||
// extensions
|
// extensions
|
||||||
import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common";
|
import { cn } from "@/helpers/common";
|
||||||
|
|
||||||
|
|
@ -37,7 +37,7 @@ const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefin
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CustomImageBlockProps = CustomImageNodeViewProps & {
|
type CustomImageBlockProps = CustoBaseImageNodeViewProps & {
|
||||||
imageFromFileSystem: string;
|
imageFromFileSystem: string;
|
||||||
setFailedToLoadImage: (isError: boolean) => void;
|
setFailedToLoadImage: (isError: boolean) => void;
|
||||||
editorContainer: HTMLDivElement | null;
|
editorContainer: HTMLDivElement | null;
|
||||||
|
|
@ -56,10 +56,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||||
getPos,
|
getPos,
|
||||||
editor,
|
editor,
|
||||||
editorContainer,
|
editorContainer,
|
||||||
src: remoteImageSrc,
|
src: resolvedImageSrc,
|
||||||
setEditorContainer,
|
setEditorContainer,
|
||||||
} = props;
|
} = props;
|
||||||
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs;
|
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
|
||||||
// states
|
// states
|
||||||
const [size, setSize] = useState<Size>({
|
const [size, setSize] = useState<Size>({
|
||||||
width: ensurePixelString(nodeWidth, "35%"),
|
width: ensurePixelString(nodeWidth, "35%"),
|
||||||
|
|
@ -210,13 +210,13 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||||
|
|
||||||
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
|
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
|
||||||
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
|
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
|
||||||
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
|
const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
|
||||||
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
||||||
const showImageUtils = remoteImageSrc && initialResizeComplete;
|
const showImageUtils = resolvedImageSrc && initialResizeComplete;
|
||||||
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
||||||
const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete;
|
const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete;
|
||||||
// show the preview image from the file system if the remote image's src is not set
|
// show the preview image from the file system if the remote image's src is not set
|
||||||
const displayedImageSrc = remoteImageSrc || imageFromFileSystem;
|
const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -248,8 +248,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||||
try {
|
try {
|
||||||
setHasErroredOnFirstLoad(true);
|
setHasErroredOnFirstLoad(true);
|
||||||
// this is a type error from tiptap, don't remove await until it's fixed
|
// this is a type error from tiptap, don't remove await until it's fixed
|
||||||
await editor?.commands.restoreImage?.(node.attrs.src);
|
await editor?.commands.restoreImage?.(imgNodeSrc);
|
||||||
imageRef.current.src = remoteImageSrc;
|
imageRef.current.src = resolvedImageSrc;
|
||||||
} catch {
|
} catch {
|
||||||
// if the image failed to even restore, then show the error state
|
// if the image failed to even restore, then show the error state
|
||||||
setFailedToLoadImage(true);
|
setFailedToLoadImage(true);
|
||||||
|
|
@ -264,7 +264,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||||
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
|
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
|
||||||
hidden: showImageLoader,
|
hidden: showImageLoader,
|
||||||
"read-only-image": !editor.isEditable,
|
"read-only-image": !editor.isEditable,
|
||||||
"blur-sm opacity-80 loading-image": !remoteImageSrc,
|
"blur-sm opacity-80 loading-image": !resolvedImageSrc,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
width: size.width,
|
width: size.width,
|
||||||
|
|
@ -277,14 +277,14 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||||
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||||
}
|
}
|
||||||
image={{
|
image={{
|
||||||
src: remoteImageSrc,
|
src: resolvedImageSrc,
|
||||||
aspectRatio: size.aspectRatio,
|
aspectRatio: size.aspectRatio,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
width: size.width,
|
width: size.width,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selected && displayedImageSrc === remoteImageSrc && (
|
{selected && displayedImageSrc === resolvedImageSrc && (
|
||||||
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
|
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
|
||||||
)}
|
)}
|
||||||
{showImageResizer && (
|
{showImageResizer && (
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,24 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
// extensions
|
// extensions
|
||||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||||
|
|
||||||
export type CustomImageComponentProps = {
|
export type CustoBaseImageNodeViewProps = {
|
||||||
getPos: () => number;
|
getPos: () => number;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
node: NodeViewProps["node"] & {
|
node: NodeViewProps["node"] & {
|
||||||
attrs: ImageAttributes;
|
attrs: ImageAttributes;
|
||||||
};
|
};
|
||||||
updateAttributes: (attrs: ImageAttributes) => void;
|
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomImageNodeViewProps = NodeViewProps & CustomImageComponentProps;
|
export type CustomImageNodeProps = NodeViewProps & CustoBaseImageNodeViewProps;
|
||||||
|
|
||||||
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||||
const { src: remoteImageSrc } = node.attrs;
|
const { src: imgNodeSrc } = node.attrs;
|
||||||
|
|
||||||
const [isUploaded, setIsUploaded] = useState(false);
|
const [isUploaded, setIsUploaded] = useState(false);
|
||||||
|
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
|
||||||
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
|
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
|
||||||
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
|
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
|
||||||
|
|
||||||
|
|
@ -39,13 +40,22 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||||
// the image is already uploaded if the image-component node has src attribute
|
// the image is already uploaded if the image-component node has src attribute
|
||||||
// and we need to remove the blob from our file system
|
// and we need to remove the blob from our file system
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (remoteImageSrc) {
|
if (resolvedSrc) {
|
||||||
setIsUploaded(true);
|
setIsUploaded(true);
|
||||||
setImageFromFileSystem(undefined);
|
setImageFromFileSystem(undefined);
|
||||||
} else {
|
} else {
|
||||||
setIsUploaded(false);
|
setIsUploaded(false);
|
||||||
}
|
}
|
||||||
}, [remoteImageSrc]);
|
}, [resolvedSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getImageSource = async () => {
|
||||||
|
// @ts-expect-error function not expected here, but will still work and don't remove await
|
||||||
|
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc);
|
||||||
|
setResolvedSrc(url as string);
|
||||||
|
};
|
||||||
|
getImageSource();
|
||||||
|
}, [imageFromFileSystem, node.attrs.src]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
|
|
@ -55,8 +65,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||||
imageFromFileSystem={imageFromFileSystem}
|
imageFromFileSystem={imageFromFileSystem}
|
||||||
editorContainer={editorContainer}
|
editorContainer={editorContainer}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
// @ts-expect-error function not expected here, but will still work
|
src={resolvedSrc}
|
||||||
src={editor?.commands?.getImageSource?.(remoteImageSrc)}
|
|
||||||
getPos={getPos}
|
getPos={getPos}
|
||||||
node={node}
|
node={node}
|
||||||
setEditorContainer={setEditorContainer}
|
setEditorContainer={setEditorContainer}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { cn } from "@/helpers/common";
|
||||||
// hooks
|
// hooks
|
||||||
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
|
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||||
// extensions
|
// extensions
|
||||||
import { type CustomImageComponentProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||||
|
|
||||||
type CustomImageUploaderProps = CustomImageComponentProps & {
|
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
loadImageFromFileSystem: (file: string) => void;
|
loadImageFromFileSystem: (file: string) => void;
|
||||||
failedToLoadImage: boolean;
|
failedToLoadImage: boolean;
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ declare module "@tiptap/core" {
|
||||||
imageComponent: {
|
imageComponent: {
|
||||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||||
uploadImage: (file: File) => () => Promise<string> | undefined;
|
uploadImage: (file: File) => () => Promise<string> | undefined;
|
||||||
|
getImageSource?: (path: string) => () => Promise<string>;
|
||||||
restoreImage: (src: string) => () => Promise<void>;
|
restoreImage: (src: string) => () => Promise<void>;
|
||||||
getImageSource?: (path: string) => () => string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,10 +193,10 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||||
const fileUrl = await upload(file);
|
const fileUrl = await upload(file);
|
||||||
return fileUrl;
|
return fileUrl;
|
||||||
},
|
},
|
||||||
|
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||||
restoreImage: (src: string) => async () => {
|
restoreImage: (src: string) => async () => {
|
||||||
await restoreImageFn(src);
|
await restoreImageFn(src);
|
||||||
},
|
},
|
||||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAsset
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">)
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
getImageSource: (path: string) => () => getAssetSrc(path),
|
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,22 @@ export const MentionNodeView = (props) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.extension.options.mentionHighlights) return;
|
if (!props.extension.options.mentionHighlights) return;
|
||||||
const hightlights = async () => {
|
const hightlights = async () => {
|
||||||
const userId = await props.extension.options.mentionHighlights();
|
const userId = await props.extension.options.mentionHighlights?.();
|
||||||
setHighlightsState(userId);
|
setHighlightsState(userId);
|
||||||
};
|
};
|
||||||
hightlights();
|
hightlights();
|
||||||
}, [props.extension.options]);
|
}, [props.extension.options]);
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (!props.node.attrs.redirect_uri) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="mention-component inline w-fit">
|
<NodeViewWrapper className="mention-component inline w-fit">
|
||||||
<a
|
<a
|
||||||
href={props.node.attrs.redirect_uri}
|
href={props.node.attrs.redirect_uri || "#"}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
|
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
|
||||||
"bg-yellow-500/20 text-yellow-500": highlightsState
|
"bg-yellow-500/20 text-yellow-500": highlightsState
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,10 @@ export const toggleBackgroundColor = (color: string | undefined, editor: Editor,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const insertHorizontalRule = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
|
else editor.chain().focus().setHorizontalRule().run();
|
||||||
|
}
|
||||||
export const insertCallout = (editor: Editor, range?: Range) => {
|
export const insertCallout = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
|
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
|
||||||
else editor.chain().focus().insertCallout().run();
|
else editor.chain().focus().insertCallout().run();
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,26 @@ function scrollToNode(editor: Editor, pos: number): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scrollToNodeViaDOMCoordinates(editor: Editor, pos: number, behavior?: ScrollBehavior): void {
|
||||||
|
const view = editor.view;
|
||||||
|
|
||||||
|
// Get the coordinates of the position
|
||||||
|
const coords = view.coordsAtPos(pos);
|
||||||
|
|
||||||
|
if (coords) {
|
||||||
|
// Scroll to the coordinates
|
||||||
|
window.scrollTo({
|
||||||
|
top: coords.top + window.scrollY - window.innerHeight / 2,
|
||||||
|
behavior: behavior,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally, you can also focus the editor
|
||||||
|
view.focus();
|
||||||
|
} else {
|
||||||
|
console.warn("Unable to find coordinates for the given position");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function scrollSummary(editor: Editor, marking: IMarking) {
|
export function scrollSummary(editor: Editor, marking: IMarking) {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const pos = findNthH1(editor, marking.sequence, marking.level);
|
const pos = findNthH1(editor, marking.sequence, marking.level);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { TCollaborativeEditorProps } from "@/types";
|
||||||
|
|
||||||
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||||
const {
|
const {
|
||||||
|
onTransaction,
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
editorProps = {},
|
editorProps = {},
|
||||||
|
|
@ -75,6 +76,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
id,
|
id,
|
||||||
|
onTransaction,
|
||||||
editorProps,
|
editorProps,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
enableHistory: false,
|
enableHistory: false,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { CoreEditorExtensions } from "@/extensions";
|
||||||
// helpers
|
// helpers
|
||||||
import { getParagraphCount } from "@/helpers/common";
|
import { getParagraphCount } from "@/helpers/common";
|
||||||
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
|
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
|
||||||
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node";
|
||||||
// props
|
// props
|
||||||
import { CoreEditorProps } from "@/props";
|
import { CoreEditorProps } from "@/props";
|
||||||
// types
|
// types
|
||||||
|
|
@ -33,6 +33,8 @@ export interface CustomEditorProps {
|
||||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
};
|
};
|
||||||
onChange?: (json: object, html: string) => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
|
onTransaction?: () => void;
|
||||||
|
autofocus?: boolean;
|
||||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
provider?: HocuspocusProvider;
|
provider?: HocuspocusProvider;
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
|
|
@ -54,10 +56,12 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
initialValue,
|
initialValue,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
onChange,
|
onChange,
|
||||||
|
onTransaction,
|
||||||
placeholder,
|
placeholder,
|
||||||
provider,
|
provider,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
value,
|
value,
|
||||||
|
autofocus = false,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
|
|
||||||
|
|
@ -66,6 +70,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
const savedSelectionRef = useRef(savedSelection);
|
const savedSelectionRef = useRef(savedSelection);
|
||||||
const editor = useTiptapEditor({
|
const editor = useTiptapEditor({
|
||||||
|
autofocus,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
...CoreEditorProps({
|
...CoreEditorProps({
|
||||||
editorClassName,
|
editorClassName,
|
||||||
|
|
@ -87,7 +92,10 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
],
|
],
|
||||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||||
onCreate: () => handleEditorReady?.(true),
|
onCreate: () => handleEditorReady?.(true),
|
||||||
onTransaction: ({ editor }) => setSavedSelection(editor.state.selection),
|
onTransaction: ({ editor }) => {
|
||||||
|
setSavedSelection(editor.state.selection);
|
||||||
|
onTransaction?.();
|
||||||
|
},
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||||
onDestroy: () => handleEditorReady?.(false),
|
onDestroy: () => handleEditorReady?.(false),
|
||||||
});
|
});
|
||||||
|
|
@ -120,6 +128,13 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
() => ({
|
() => ({
|
||||||
|
blur: () => editorRef.current?.commands.blur(),
|
||||||
|
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
|
||||||
|
const resolvedPos = pos ?? savedSelection?.from;
|
||||||
|
if (!editorRef.current || !resolvedPos) return;
|
||||||
|
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
|
||||||
|
},
|
||||||
|
getCurrentCursorPosition: () => savedSelection?.from,
|
||||||
clearEditor: (emitUpdate = false) => {
|
clearEditor: (emitUpdate = false) => {
|
||||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type TCollaborativeEditorHookProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||||
|
onTransaction?: () => void;
|
||||||
embedHandler?: TEmbedConfig;
|
embedHandler?: TEmbedConfig;
|
||||||
fileHandler: TFileHandler;
|
fileHandler: TFileHandler;
|
||||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
|
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
|
||||||
|
|
||||||
export type TFileHandler = {
|
export type TFileHandler = {
|
||||||
getAssetSrc: (path: string) => string;
|
getAssetSrc: (path: string) => Promise<string>;
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
delete: DeleteImage;
|
delete: DeleteImage;
|
||||||
upload: UploadImage;
|
upload: UploadImage;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ export type EditorReadOnlyRefApi = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||||
|
blur: () => void;
|
||||||
|
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
|
||||||
|
getCurrentCursorPosition: () => number | undefined;
|
||||||
setEditorValueAtCursorPosition: (content: string) => void;
|
setEditorValueAtCursorPosition: (content: string) => void;
|
||||||
executeMenuItemCommand: (
|
executeMenuItemCommand: (
|
||||||
props:
|
props:
|
||||||
|
|
@ -68,6 +71,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||||
export interface IEditorProps {
|
export interface IEditorProps {
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
displayConfig?: TDisplayConfig;
|
displayConfig?: TDisplayConfig;
|
||||||
|
disabledExtensions?: TExtensions[];
|
||||||
editorClassName?: string;
|
editorClassName?: string;
|
||||||
fileHandler: TFileHandler;
|
fileHandler: TFileHandler;
|
||||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
|
|
@ -78,22 +82,26 @@ export interface IEditorProps {
|
||||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
};
|
};
|
||||||
onChange?: (json: object, html: string) => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
|
onTransaction?: () => void;
|
||||||
|
handleEditorReady?: (value: boolean) => void;
|
||||||
|
autofocus?: boolean;
|
||||||
onEnterKeyPress?: (e?: any) => void;
|
onEnterKeyPress?: (e?: any) => void;
|
||||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
}
|
}
|
||||||
|
export interface ILiteTextEditor extends IEditorProps {
|
||||||
export type ILiteTextEditor = IEditorProps;
|
extensions?: any[];
|
||||||
|
}
|
||||||
export interface IRichTextEditor extends IEditorProps {
|
export interface IRichTextEditor extends IEditorProps {
|
||||||
|
extensions?: any[];
|
||||||
|
bubbleMenuEnabled?: boolean;
|
||||||
dragDropEnabled?: boolean;
|
dragDropEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICollaborativeDocumentEditor
|
export interface ICollaborativeDocumentEditor
|
||||||
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
|
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
|
||||||
aiHandler?: TAIHandler;
|
aiHandler?: TAIHandler;
|
||||||
disabledExtensions: TExtensions[];
|
|
||||||
embedHandler: TEmbedConfig;
|
embedHandler: TEmbedConfig;
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed";
|
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands"| "enter-key";
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// styles
|
// styles
|
||||||
// import "./styles/tailwind.css";
|
// import "./styles/tailwind.css";
|
||||||
import "src/styles/variables.css";
|
import "./styles/variables.css";
|
||||||
import "src/styles/editor.css";
|
import "./styles/editor.css";
|
||||||
import "src/styles/table.css";
|
import "./styles/table.css";
|
||||||
import "src/styles/github-dark.css";
|
import "./styles/github-dark.css";
|
||||||
import "src/styles/drag-drop.css";
|
import "./styles/drag-drop.css";
|
||||||
|
|
||||||
// editors
|
// editors
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
position: relative;
|
position: relative;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
|
||||||
const { anchor, uploadFile, workspaceId } = args;
|
const { anchor, uploadFile, workspaceId } = args;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAssetSrc: (path) => {
|
getAssetSrc: async (path) => {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path?.startsWith("http")) {
|
if (path?.startsWith("http")) {
|
||||||
return path;
|
return path;
|
||||||
|
|
@ -70,7 +70,7 @@ export const getReadOnlyEditorFileHandlers = (
|
||||||
const { anchor } = args;
|
const { anchor } = args;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAssetSrc: (path) => {
|
getAssetSrc: async (path) => {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path?.startsWith("http")) {
|
if (path?.startsWith("http")) {
|
||||||
return path;
|
return path;
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
|
||||||
const { maxFileSize, projectId, uploadFile, workspaceId, workspaceSlug } = args;
|
const { maxFileSize, projectId, uploadFile, workspaceId, workspaceSlug } = args;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAssetSrc: (path) => {
|
getAssetSrc: async (path) => {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path?.startsWith("http")) {
|
if (path?.startsWith("http")) {
|
||||||
return path;
|
return path;
|
||||||
|
|
@ -94,7 +94,7 @@ export const getReadOnlyEditorFileHandlers = (
|
||||||
const { projectId, workspaceSlug } = args;
|
const { projectId, workspaceSlug } = args;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAssetSrc: (path) => {
|
getAssetSrc: async (path) => {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path?.startsWith("http")) {
|
if (path?.startsWith("http")) {
|
||||||
return path;
|
return path;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue