[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:
Lakhan Baheti 2024-10-30 17:39:02 +05:30 committed by GitHub
parent 8ea34b5995
commit 3696062372
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 164 additions and 61 deletions

View file

@ -14,7 +14,8 @@ type Props = {
};
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const extensions: Extensions = [SlashCommands()];
const { disabledExtensions } = _props;
const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
return extensions;
};

View file

@ -14,6 +14,7 @@ import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
const {
onTransaction,
aiHandler,
containerClassName,
disabledExtensions,
@ -43,6 +44,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
onTransaction,
disabledExtensions,
editorClassName,
embedHandler,

View file

@ -28,6 +28,9 @@ export const EditorWrapper: React.FC<Props> = (props) => {
forwardedRef,
mentionHandler,
onChange,
onTransaction,
handleEditorReady,
autofocus,
placeholder,
tabIndex,
value,
@ -43,6 +46,9 @@ export const EditorWrapper: React.FC<Props> = (props) => {
initialValue,
mentionHandler,
onChange,
onTransaction,
handleEditorReady,
autofocus,
placeholder,
tabIndex,
value,

View file

@ -1,4 +1,4 @@
import { forwardRef } from "react";
import { forwardRef, useMemo } from "react";
// components
import { EditorWrapper } from "@/components/editors/editor-wrapper";
// extensions
@ -7,9 +7,15 @@ import { EnterKeyExtension } from "@/extensions";
import { EditorRefApi, ILiteTextEditor } from "@/types";
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} />;
};

View file

@ -8,24 +8,31 @@ import { SideMenuExtension, SlashCommands } from "@/extensions";
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled } = props;
const {
disabledExtensions,
dragDropEnabled,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
} = props;
const getExtensions = useCallback(() => {
const extensions = [SlashCommands()];
extensions.push(
const extensions = [
...externalExtensions,
SideMenuExtension({
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
})
);
}),
];
if (!disabledExtensions?.includes("slash-commands")) {
extensions.push(SlashCommands());
}
return extensions;
}, [dragDropEnabled]);
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
{(editor) => <>{editor && <EditorBubbleMenu editor={editor} />}</>}
{(editor) => <>{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}</>}
</EditorWrapper>
);
};

View file

@ -20,10 +20,12 @@ import {
Heading6,
CaseSensitive,
LucideIcon,
MinusSquare,
Palette,
} from "lucide-react";
// helpers
import {
insertHorizontalRule,
insertImage,
insertTableCommand,
setText,
@ -208,6 +210,15 @@ export const ImageItem = (editor: Editor) =>
icon: ImageIcon,
}) 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 => ({
key: "text-color",
name: "Color",
@ -246,6 +257,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
QuoteItem(editor),
TableItem(editor),
ImageItem(editor),
HorizontalRuleItem(editor),
TextColorItem(editor),
BackgroundColorItem(editor),
];

View file

@ -1,7 +1,7 @@
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
// extensions
import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
// helpers
import { cn } from "@/helpers/common";
@ -37,7 +37,7 @@ const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefin
return value;
};
type CustomImageBlockProps = CustomImageNodeViewProps & {
type CustomImageBlockProps = CustoBaseImageNodeViewProps & {
imageFromFileSystem: string;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
@ -56,10 +56,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
getPos,
editor,
editorContainer,
src: remoteImageSrc,
src: resolvedImageSrc,
setEditorContainer,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states
const [size, setSize] = useState<Size>({
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)
// 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)
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)
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
const displayedImageSrc = remoteImageSrc || imageFromFileSystem;
const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;
return (
<div
@ -248,8 +248,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
try {
setHasErroredOnFirstLoad(true);
// this is a type error from tiptap, don't remove await until it's fixed
await editor?.commands.restoreImage?.(node.attrs.src);
imageRef.current.src = remoteImageSrc;
await editor?.commands.restoreImage?.(imgNodeSrc);
imageRef.current.src = resolvedImageSrc;
} catch {
// if the image failed to even restore, then show the error state
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
hidden: showImageLoader,
"read-only-image": !editor.isEditable,
"blur-sm opacity-80 loading-image": !remoteImageSrc,
"blur-sm opacity-80 loading-image": !resolvedImageSrc,
})}
style={{
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"
}
image={{
src: remoteImageSrc,
src: resolvedImageSrc,
aspectRatio: size.aspectRatio,
height: size.height,
width: size.width,
}}
/>
)}
{selected && displayedImageSrc === remoteImageSrc && (
{selected && displayedImageSrc === resolvedImageSrc && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{showImageResizer && (

View file

@ -3,23 +3,24 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
export type CustomImageComponentProps = {
export type CustoBaseImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: NodeViewProps["node"] & {
attrs: ImageAttributes;
};
updateAttributes: (attrs: ImageAttributes) => void;
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
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 { src: remoteImageSrc } = node.attrs;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
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
// and we need to remove the blob from our file system
useEffect(() => {
if (remoteImageSrc) {
if (resolvedSrc) {
setIsUploaded(true);
setImageFromFileSystem(undefined);
} else {
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 (
<NodeViewWrapper>
@ -55,8 +65,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
// @ts-expect-error function not expected here, but will still work
src={editor?.commands?.getImageSource?.(remoteImageSrc)}
src={resolvedSrc}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}

View file

@ -5,9 +5,9 @@ import { cn } from "@/helpers/common";
// hooks
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
// extensions
import { type CustomImageComponentProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
type CustomImageUploaderProps = CustomImageComponentProps & {
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
failedToLoadImage: boolean;

View file

@ -24,8 +24,8 @@ declare module "@tiptap/core" {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
getImageSource?: (path: string) => () => string;
};
}
}
@ -193,10 +193,10 @@ export const CustomImageExtension = (props: TFileHandler) => {
const fileUrl = await upload(file);
return fileUrl;
},
getImageSource: (path: string) => async () => await getAssetSrc(path),
restoreImage: (src: string) => async () => {
await restoreImageFn(src);
},
getImageSource: (path: string) => () => getAssetSrc(path),
};
},

View file

@ -68,7 +68,7 @@ export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAsset
addCommands() {
return {
getImageSource: (path: string) => () => getAssetSrc(path),
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},

View file

@ -76,7 +76,7 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
addCommands() {
return {
getImageSource: (path: string) => () => getAssetSrc(path),
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},

View file

@ -26,7 +26,7 @@ export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">)
addCommands() {
return {
getImageSource: (path: string) => () => getAssetSrc(path),
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},

View file

@ -18,16 +18,22 @@ export const MentionNodeView = (props) => {
useEffect(() => {
if (!props.extension.options.mentionHighlights) return;
const hightlights = async () => {
const userId = await props.extension.options.mentionHighlights();
const userId = await props.extension.options.mentionHighlights?.();
setHighlightsState(userId);
};
hightlights();
}, [props.extension.options]);
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!props.node.attrs.redirect_uri) {
event.preventDefault();
}
};
return (
<NodeViewWrapper className="mention-component inline w-fit">
<a
href={props.node.attrs.redirect_uri}
href={props.node.attrs.redirect_uri || "#"}
target="_blank"
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

View file

@ -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) => {
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
else editor.chain().focus().insertCallout().run();

View file

@ -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) {
if (editor) {
const pos = findNthH1(editor, marking.sequence, marking.level);

View file

@ -13,6 +13,7 @@ import { TCollaborativeEditorProps } from "@/types";
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const {
onTransaction,
disabledExtensions,
editorClassName,
editorProps = {},
@ -75,6 +76,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const editor = useEditor({
id,
onTransaction,
editorProps,
editorClassName,
enableHistory: false,

View file

@ -12,7 +12,7 @@ import { CoreEditorExtensions } from "@/extensions";
// helpers
import { getParagraphCount } from "@/helpers/common";
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
import { CoreEditorProps } from "@/props";
// types
@ -33,6 +33,8 @@ export interface CustomEditorProps {
suggestions?: () => Promise<IMentionSuggestion[]>;
};
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
autofocus?: boolean;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: HocuspocusProvider;
tabIndex?: number;
@ -54,10 +56,12 @@ export const useEditor = (props: CustomEditorProps) => {
initialValue,
mentionHandler,
onChange,
onTransaction,
placeholder,
provider,
tabIndex,
value,
autofocus = false,
} = props;
// states
@ -66,6 +70,7 @@ export const useEditor = (props: CustomEditorProps) => {
const editorRef: MutableRefObject<Editor | null> = useRef(null);
const savedSelectionRef = useRef(savedSelection);
const editor = useTiptapEditor({
autofocus,
editorProps: {
...CoreEditorProps({
editorClassName,
@ -87,7 +92,10 @@ export const useEditor = (props: CustomEditorProps) => {
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => setSavedSelection(editor.state.selection),
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onDestroy: () => handleEditorReady?.(false),
});
@ -120,6 +128,13 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
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) => {
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},

View file

@ -36,6 +36,7 @@ type TCollaborativeEditorHookProps = {
};
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
onTransaction?: () => void;
embedHandler?: TEmbedConfig;
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;

View file

@ -1,7 +1,7 @@
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
export type TFileHandler = {
getAssetSrc: (path: string) => string;
getAssetSrc: (path: string) => Promise<string>;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;

View file

@ -35,6 +35,9 @@ export type EditorReadOnlyRefApi = {
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
blur: () => void;
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
getCurrentCursorPosition: () => number | undefined;
setEditorValueAtCursorPosition: (content: string) => void;
executeMenuItemCommand: (
props:
@ -68,6 +71,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
export interface IEditorProps {
containerClassName?: string;
displayConfig?: TDisplayConfig;
disabledExtensions?: TExtensions[];
editorClassName?: string;
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
@ -78,22 +82,26 @@ export interface IEditorProps {
suggestions?: () => Promise<IMentionSuggestion[]>;
};
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
handleEditorReady?: (value: boolean) => void;
autofocus?: boolean;
onEnterKeyPress?: (e?: any) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
value?: string | null;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: any[];
}
export type ILiteTextEditor = IEditorProps;
export interface IRichTextEditor extends IEditorProps {
extensions?: any[];
bubbleMenuEnabled?: boolean;
dragDropEnabled?: boolean;
}
export interface ICollaborativeDocumentEditor
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
aiHandler?: TAIHandler;
disabledExtensions: TExtensions[];
embedHandler: TEmbedConfig;
handleEditorReady?: (value: boolean) => void;
id: string;

View file

@ -1 +1 @@
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed";
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands"| "enter-key";

View file

@ -1,10 +1,10 @@
// styles
// import "./styles/tailwind.css";
import "src/styles/variables.css";
import "src/styles/editor.css";
import "src/styles/table.css";
import "src/styles/github-dark.css";
import "src/styles/drag-drop.css";
import "./styles/variables.css";
import "./styles/editor.css";
import "./styles/table.css";
import "./styles/github-dark.css";
import "./styles/drag-drop.css";
// editors
export {

View file

@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.ProseMirror {
position: relative;
word-wrap: break-word;

View file

@ -31,7 +31,7 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
const { anchor, uploadFile, workspaceId } = args;
return {
getAssetSrc: (path) => {
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
@ -70,7 +70,7 @@ export const getReadOnlyEditorFileHandlers = (
const { anchor } = args;
return {
getAssetSrc: (path) => {
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;

View file

@ -43,7 +43,7 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
const { maxFileSize, projectId, uploadFile, workspaceId, workspaceSlug } = args;
return {
getAssetSrc: (path) => {
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
@ -94,7 +94,7 @@ export const getReadOnlyEditorFileHandlers = (
const { projectId, workspaceSlug } = args;
return {
getAssetSrc: (path) => {
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;