Compare commits

...

10 Commits

Author SHA1 Message Date
Lakhan 9d75df1384 removed unused import 2025-07-15 15:13:37 +05:30
Lakhan 11f6e17153 fix: params hierarchy 2025-07-15 15:09:14 +05:30
Lakhan 7d7d8760bb fix: image extension props 2025-07-15 13:30:15 +05:30
Lakhan f628ee1dff Merge branch 'preview' of https://github.com/makeplane/plane into chore/mobile-editor-support 2025-07-15 13:25:44 +05:30
Lakhan e3fd8a5421 fix: code rabbit review 2025-06-16 14:54:21 +05:30
Lakhan b07351cd33 fix: readonly editor params 2025-06-13 20:08:47 +05:30
Lakhan 9c0cd96013 fix: page renderer params 2025-06-13 20:02:04 +05:30
Lakhan c1e73505b2 fix: formatting 2025-06-13 18:59:19 +05:30
Lakhan 06d9f4b5aa fix: formatting 2025-06-13 18:41:28 +05:30
Lakhan 3bc345dfcb chore: additional changes for mobile editor support 2025-06-13 16:53:46 +05:30
32 changed files with 338 additions and 105 deletions
+8
View File
@@ -0,0 +1,8 @@
/* Display Placeholders on every new line. */
.ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
+1
View File
@@ -1,4 +1,5 @@
@import "@plane/propel/styles/fonts";
@import url("editor.css");
@tailwind base;
@tailwind components;
+8
View File
@@ -0,0 +1,8 @@
/* Display Placeholders on every new line. */
.ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
+1
View File
@@ -1,4 +1,5 @@
@import "@plane/propel/styles/fonts";
@import url("editor.css");
@tailwind base;
@tailwind components;
@@ -1,5 +1,5 @@
import { Extensions } from "@tiptap/core";
import React from "react";
import React, { useMemo } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
@@ -20,16 +20,20 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
aiHandler,
bubbleMenuEnabled = true,
containerClassName,
dragDropEnabled = true,
extensions: externalExtensions = [],
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
editorProps,
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
isMobile = false,
mentionHandler,
onAssetChange,
onChange,
@@ -41,21 +45,27 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
user,
} = props;
const extensions: Extensions = [];
const extensions: Extensions = useMemo(() => {
const allExtensions = externalExtensions;
if (embedHandler?.issue) {
extensions.push(
WorkItemEmbedExtension({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
if (embedHandler?.issue) {
allExtensions.push(
WorkItemEmbedExtension({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
return allExtensions;
}, [externalExtensions, embedHandler.issue]);
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
disabledExtensions,
dragDropEnabled,
editable,
editorClassName,
editorProps,
embedHandler,
extensions,
fileHandler,
@@ -91,6 +101,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
id={id}
isLoading={!hasServerSynced && !hasServerConnectionFailed}
isMobile={isMobile}
tabIndex={tabIndex}
/>
);
@@ -15,12 +15,22 @@ type Props = {
editorContainerClassName: string;
id: string;
isLoading?: boolean;
isMobile: boolean;
tabIndex?: number;
};
export const PageRenderer = (props: Props) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, isLoading, tabIndex } =
props;
const {
aiHandler,
bubbleMenuEnabled,
displayConfig,
editor,
editorContainerClassName,
id,
isLoading,
isMobile,
tabIndex,
} = props;
return (
<div
@@ -36,9 +46,10 @@ export const PageRenderer = (props: Props) => {
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isMobile={isMobile}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
{editor.isEditable && !isMobile && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} />
@@ -25,6 +25,7 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
fileHandler,
flaggedExtensions,
id,
isMobile = false,
forwardedRef,
handleEditorReady,
initialValue,
@@ -64,6 +65,7 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
isMobile={isMobile}
/>
);
};
@@ -16,10 +16,11 @@ interface EditorContainerProps {
editor: Editor;
editorContainerClassName: string;
id: string;
isMobile: boolean;
}
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id } = props;
const { children, displayConfig, editor, editorContainerClassName, id, isMobile } = props;
const containerRef = useRef<HTMLDivElement>(null);
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
@@ -94,7 +95,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
)}
>
{children}
<LinkViewContainer editor={editor} containerRef={containerRef} />
{!isMobile && <LinkViewContainer editor={editor} containerRef={containerRef} />}
</div>
</>
);
@@ -12,7 +12,11 @@ export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
const { editor, children, id, tabIndex } = props;
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<div
tabIndex={tabIndex}
onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}
className="editor-content"
>
<EditorContent editor={editor} />
{children}
</div>
@@ -12,8 +12,6 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
editable: boolean;
extensions: Extensions;
};
export const EditorWrapper: React.FC<Props> = (props) => {
@@ -22,11 +20,13 @@ export const EditorWrapper: React.FC<Props> = (props) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editable = true,
editorClassName = "",
editorProps,
extensions,
id,
initialValue,
isMobile = false,
fileHandler,
flaggedExtensions,
forwardedRef,
@@ -41,9 +41,10 @@ export const EditorWrapper: React.FC<Props> = (props) => {
} = props;
const editor = useEditor({
editable,
disabledExtensions,
editable,
editorClassName,
editorProps,
enableHistory: true,
extensions,
fileHandler,
@@ -75,6 +76,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isMobile={isMobile}
>
{children?.(editor)}
<div className="flex flex-col">
@@ -21,6 +21,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
forwardedRef,
id,
initialValue,
isMobile = false,
mentionHandler,
} = props;
@@ -47,6 +48,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isMobile={isMobile}
>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} />
@@ -22,6 +22,7 @@ import {
MinusSquare,
Palette,
AlignCenter,
LinkIcon,
} from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
@@ -30,6 +31,7 @@ import {
insertHorizontalRule,
insertImage,
insertTableCommand,
setLinkEditor,
setText,
setTextAlign,
toggleBackgroundColor,
@@ -44,6 +46,7 @@ import {
toggleTaskList,
toggleTextColor,
toggleUnderline,
unsetLinkEditor,
} from "@/helpers/editor-commands";
// types
import { TCommandWithProps, TEditorCommands } from "@/types";
@@ -189,7 +192,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
icon: ImageIcon,
});
export const HorizontalRuleItem = (editor: Editor) =>
export const HorizontalRuleItem = (editor: Editor): EditorMenuItem<"divider"> =>
({
key: "divider",
name: "Divider",
@@ -198,6 +201,19 @@ export const HorizontalRuleItem = (editor: Editor) =>
icon: MinusSquare,
}) as const;
export const LinkItem = (editor: Editor): EditorMenuItem<"link"> =>
({
key: "link",
name: "Link",
isActive: () => editor?.isActive("link"),
command: (props) => {
if (!props) return;
if (props.url) setLinkEditor(editor, props.url, props.text);
else unsetLinkEditor(editor);
},
icon: LinkIcon,
}) as const;
export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({
key: "text-color",
name: "Color",
@@ -255,6 +271,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
ImageItem(editor),
HorizontalRuleItem(editor),
TextColorItem(editor),
LinkItem(editor),
BackgroundColorItem(editor),
TextAlignItem(editor),
];
@@ -28,6 +28,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
extension,
getPos,
imageFromFileSystem,
isMobile,
node,
selected,
setEditorContainer,
@@ -188,11 +189,13 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const handleImageMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (isMobile) editor.commands.blur();
const pos = getPos();
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
},
[editor, getPos]
[editor, getPos, isMobile]
);
// 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)
@@ -251,10 +254,14 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
if (!imageRef.current) {
throw new Error("Image reference not found");
}
if (!resolvedImageSrc) {
throw new Error("No resolved image source available");
}
imageRef.current.src = resolvedImageSrc;
// That's a extra step for mobile since they cannot auto-resolve URLs via img tags like web app can
// due to limitations with cross-origin authentication cookies
const resolvedSrc = await extension?.options?.getImageSource?.(imgNodeSrc);
if (!resolvedSrc) {
throw new Error("No resolved image source available");
}
imageRef.current.src = resolvedSrc.toString();
} catch {
// if the image failed to even restore, then show the error state
setFailedToLoadImage(true);
@@ -282,6 +289,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
alignment={nodeAlignment ?? "left"}
width={size.width}
height={size.height}
isMobile={isMobile}
aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}
src={resolvedImageSrc}
downloadSrc={resolvedDownloadSrc}
@@ -18,13 +18,14 @@ export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "update
};
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
selected: boolean;
isMobile: boolean;
};
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
const [isUploaded, setIsUploaded] = useState(!!imgNodeSrc);
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState<string | undefined>(undefined);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
@@ -43,13 +44,13 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
// 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 (resolvedSrc) {
if (resolvedSrc || imgNodeSrc) {
setIsUploaded(true);
setImageFromFileSystem(undefined);
} else {
setIsUploaded(false);
}
}, [resolvedSrc]);
}, [resolvedSrc, imgNodeSrc]);
useEffect(() => {
if (!imgNodeSrc) {
@@ -11,10 +11,11 @@ type Props = {
activeAlignment: TCustomImageAlignment;
handleChange: (alignment: TCustomImageAlignment) => void;
toggleToolbarViewStatus: (val: boolean) => void;
tooltipDisabled?: boolean;
};
export const ImageAlignmentAction: React.FC<Props> = (props) => {
const { activeAlignment, handleChange, toggleToolbarViewStatus } = props;
const { activeAlignment, handleChange, toggleToolbarViewStatus, tooltipDisabled = false } = props;
// states
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// refs
@@ -30,7 +31,7 @@ export const ImageAlignmentAction: React.FC<Props> = (props) => {
return (
<div ref={dropdownRef} className="h-full relative">
<Tooltip tooltipContent="Align">
<Tooltip disabled={tooltipDisabled} tooltipContent="Align">
<button
type="button"
className="h-full flex items-center gap-1 text-white/60 hover:text-white transition-colors"
@@ -43,7 +44,7 @@ export const ImageAlignmentAction: React.FC<Props> = (props) => {
{isDropdownOpen && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-0.5 h-7 bg-black/80 flex items-center gap-2 px-2 rounded">
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
<Tooltip key={option.value} tooltipContent={option.label}>
<Tooltip disabled={tooltipDisabled} key={option.value} tooltipContent={option.label}>
<button
type="button"
className="flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors"
@@ -4,13 +4,14 @@ import { Tooltip } from "@plane/ui";
type Props = {
src: string;
tooltipDisabled?: boolean;
};
export const ImageDownloadAction: React.FC<Props> = (props) => {
const { src } = props;
const { src, tooltipDisabled = false } = props;
return (
<Tooltip tooltipContent="Download">
<Tooltip disabled={tooltipDisabled} tooltipContent="Download">
<button
type="button"
onClick={() => window.open(src, "_blank")}
@@ -12,6 +12,7 @@ const ZOOM_STEPS = [0.5, 1, 1.5, 2];
type Props = {
aspectRatio: number;
isFullScreenEnabled: boolean;
isMobile: boolean;
downloadSrc: string;
src: string;
toggleFullScreenMode: (val: boolean) => void;
@@ -19,7 +20,7 @@ type Props = {
};
const ImageFullScreenModalWithoutPortal = (props: Props) => {
const { aspectRatio, isFullScreenEnabled, downloadSrc, src, toggleFullScreenMode, width } = props;
const { aspectRatio, isFullScreenEnabled, isMobile, downloadSrc, src, toggleFullScreenMode, width } = props;
// refs
const dragStart = useRef({ x: 0, y: 0 });
const dragOffset = useRef({ x: 0, y: 0 });
@@ -102,6 +103,9 @@ const ImageFullScreenModalWithoutPortal = (props: Props) => {
);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!imgRef.current) return;
const imgWidth = imgRef.current.offsetWidth * magnification;
@@ -233,7 +237,11 @@ const ImageFullScreenModalWithoutPortal = (props: Props) => {
<div className="flex items-center">
<button
type="button"
onClick={() => handleMagnification("decrease")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleMagnification("decrease");
}}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification <= MIN_ZOOM}
aria-label="Zoom out"
@@ -243,7 +251,11 @@ const ImageFullScreenModalWithoutPortal = (props: Props) => {
<span className="text-sm w-12 text-center text-white">{Math.round(100 * magnification)}%</span>
<button
type="button"
onClick={() => handleMagnification("increase")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleMagnification("increase");
}}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification >= MAX_ZOOM}
aria-label="Zoom in"
@@ -251,22 +263,26 @@ const ImageFullScreenModalWithoutPortal = (props: Props) => {
<Plus className="size-4" />
</button>
</div>
<button
type="button"
onClick={() => window.open(downloadSrc, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
aria-label="Download image"
>
<Download className="size-4" />
</button>
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
aria-label="Open image in new tab"
>
<ExternalLink className="size-4" />
</button>
{!isMobile && (
<button
type="button"
onClick={() => window.open(downloadSrc, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
aria-label="Download image"
>
<Download className="size-4" />
</button>
)}
{!isMobile && (
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
aria-label="Open image in new tab"
>
<ExternalLink className="size-4" />
</button>
)}
</div>
</div>
</div>
@@ -13,11 +13,12 @@ type Props = {
width: string;
aspectRatio: number;
};
isMobile: boolean;
toggleToolbarViewStatus: (val: boolean) => void;
};
export const ImageFullScreenActionRoot: React.FC<Props> = (props) => {
const { image, toggleToolbarViewStatus } = props;
const { image, isMobile, toggleToolbarViewStatus } = props;
// states
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
// derived values
@@ -32,12 +33,13 @@ export const ImageFullScreenActionRoot: React.FC<Props> = (props) => {
<ImageFullScreenModal
aspectRatio={aspectRatio}
isFullScreenEnabled={isFullScreenEnabled}
isMobile={isMobile}
src={src}
downloadSrc={downloadSrc}
width={width}
toggleFullScreenMode={setIsFullScreenEnabled}
/>
<Tooltip tooltipContent="View in full screen">
<Tooltip disabled={isMobile} tooltipContent="View in full screen">
<button
type="button"
onClick={(e) => {
@@ -11,6 +11,7 @@ type Props = {
alignment: TCustomImageAlignment;
width: string;
height: string;
isMobile: boolean;
aspectRatio: number;
src: string;
downloadSrc: string;
@@ -18,7 +19,7 @@ type Props = {
};
export const ImageToolbarRoot: React.FC<Props> = (props) => {
const { alignment, downloadSrc, handleAlignmentChange } = props;
const { alignment, downloadSrc, handleAlignmentChange, isMobile } = props;
// states
const [shouldShowToolbar, setShouldShowToolbar] = useState(false);
@@ -32,13 +33,14 @@ export const ImageToolbarRoot: React.FC<Props> = (props) => {
}
)}
>
<ImageDownloadAction src={downloadSrc} />
{!isMobile && <ImageDownloadAction src={downloadSrc} />}
<ImageAlignmentAction
activeAlignment={alignment}
handleChange={handleAlignmentChange}
toggleToolbarViewStatus={setShouldShowToolbar}
tooltipDisabled={isMobile}
/>
<ImageFullScreenActionRoot image={props} toggleToolbarViewStatus={setShouldShowToolbar} />
<ImageFullScreenActionRoot image={props} isMobile={isMobile} toggleToolbarViewStatus={setShouldShowToolbar} />
</div>
</>
);
@@ -16,6 +16,7 @@ import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
isMobile: boolean;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void;
@@ -27,6 +28,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
extension,
failedToLoadImage,
getPos,
isMobile,
loadImageFromFileSystem,
maxFileSize,
node,
@@ -47,9 +49,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
if (!imageEntityId) return;
setIsUploaded(true);
// Update the node view's src attribute post upload
updateAttributes({
src: url,
});
updateAttributes({ src: url });
imageComponentImageFileMap?.delete(imageEntityId);
const pos = getPos();
@@ -125,12 +125,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
uploadFile(meta.file);
} else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
if (meta.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
if (!isMobile) fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId, isMobile]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
@@ -1,4 +1,4 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { NodeViewProps, ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
@@ -8,17 +8,18 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par
// types
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "./components/node-view";
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
isMobile: boolean;
};
export const CustomImageExtension = (props: Props) => {
const { fileHandler, isEditable } = props;
const { fileHandler, isEditable, isMobile } = props;
// derived values
const { getAssetSrc, getAssetDownloadSrc, restore: restoreImageFn } = fileHandler;
@@ -116,7 +117,9 @@ export const CustomImageExtension = (props: Props) => {
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNodeView);
return ReactNodeViewRenderer((props: CustomImageNodeViewProps) => (
<CustomImageNodeView {...props} isMobile={isMobile} />
));
},
});
};
@@ -38,10 +38,16 @@ import { CustomStarterKitExtension } from "./starter-kit";
type TArguments = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
| "disabledExtensions"
| "editable"
| "flaggedExtensions"
| "fileHandler"
| "isMobile"
| "mentionHandler"
| "placeholder"
| "tabIndex"
> & {
enableHistory: boolean;
editable: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
@@ -50,10 +56,11 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
enableHistory,
fileHandler,
flaggedExtensions,
isMobile = false,
mentionHandler,
placeholder,
tabIndex,
editable,
editable = true,
} = args;
const extensions = [
@@ -114,10 +121,12 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
extensions.push(
ImageExtension({
fileHandler,
isMobile,
}),
CustomImageExtension({
fileHandler,
isEditable: editable,
isMobile,
})
);
}
@@ -4,7 +4,7 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par
// types
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "../custom-image/components/node-view";
import { CustomImageNodeView, CustomImageNodeViewProps } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
export type ImageExtensionStorage = {
@@ -13,10 +13,11 @@ export type ImageExtensionStorage = {
type Props = {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isMobile: boolean;
};
export const ImageExtension = (props: Props) => {
const { fileHandler } = props;
const { fileHandler, isMobile } = props;
// derived values
const { getAssetSrc } = fileHandler;
@@ -47,7 +48,9 @@ export const ImageExtension = (props: Props) => {
// render custom image node
addNodeView() {
return ReactNodeViewRenderer(CustomImageNodeView);
return ReactNodeViewRenderer((props: CustomImageNodeViewProps) => (
<CustomImageNodeView {...props} isMobile={isMobile} />
));
},
});
};
@@ -33,10 +33,13 @@ import { CustomImageExtension } from "./custom-image/extension";
import { EmojiExtension } from "./emoji/extension";
import { CustomStarterKitExtension } from "./starter-kit";
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
type Props = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "isMobile" | "mentionHandler"
>;
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;
const { disabledExtensions, fileHandler, flaggedExtensions, isMobile = false, mentionHandler } = props;
const extensions = [
CustomStarterKitExtension({
@@ -90,10 +93,12 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
extensions.push(
ImageExtension({
fileHandler,
isMobile,
}),
CustomImageExtension({
fileHandler,
isEditable: false,
isMobile,
})
);
}
@@ -138,7 +138,21 @@ export const unsetLinkEditor = (editor: Editor) => {
editor.chain().focus().unsetLink().run();
};
export const setLinkEditor = (editor: Editor, url: string) => {
export const setLinkEditor = (editor: Editor, url: string, text?: string) => {
const { selection } = editor.state;
const previousSelection = { from: selection.from, to: selection.to };
if (text) {
editor
.chain()
.focus()
.deleteRange({ from: selection.from, to: selection.to })
.insertContentAt(previousSelection.from, text)
.run();
// Extracting the new selection start point.
const previousFrom = previousSelection.from;
editor.commands.setTextSelection({ from: previousFrom, to: previousFrom + text.length });
}
editor.chain().focus().setLink({ href: url }).run();
};
@@ -17,7 +17,8 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
onChange,
onTransaction,
disabledExtensions,
editable,
dragDropEnabled = true,
editable = true,
editorClassName = "",
editorProps = {},
embedHandler,
@@ -86,7 +87,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
extensions: [
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
dragDropEnabled: dragDropEnabled,
}),
HeadingListExtension,
Collaboration.configure({
+45 -4
View File
@@ -52,9 +52,7 @@ export const useEditor = (props: TEditorHookProps) => {
autofocus,
parseOptions: { preserveWhitespace: true },
editorProps: {
...CoreEditorProps({
editorClassName,
}),
...CoreEditorProps({ editorClassName }),
...editorProps,
},
extensions: [
@@ -129,6 +127,39 @@ export const useEditor = (props: TEditorHookProps) => {
() => ({
...getEditorRefHelpers({ editor, provider }),
blur: () => editor?.commands.blur(),
createSelectionAtCursorPosition: () => {
if (!editor) return;
const { empty } = editor.state.selection;
// if (empty) return null;
if (empty) {
// Get the text content and position info
const { $from } = editor.state.selection;
const textContent = $from.parent.textContent;
const posInNode = $from.parentOffset;
// Find word boundaries
let start = posInNode;
let end = posInNode;
// Move start position backwards until we hit a word boundary
while (start > 0 && /\w/.test(textContent[start - 1])) {
start--;
}
// Move end position forwards until we hit a word boundary
while (end < textContent.length && /\w/.test(textContent[end])) {
end++;
}
// If we found a word, select it using editor commands
if (start !== end) {
const from = $from.start() + start;
const to = $from.start() + end;
editor.commands.setTextSelection({ from, to });
}
}
},
emitRealTimeUpdate: (message) => provider?.sendStateless(message),
executeMenuItemCommand: (props) => {
const { itemKey } = props;
@@ -143,7 +174,14 @@ export const useEditor = (props: TEditorHookProps) => {
console.warn(`No command found for item: ${itemKey}`);
}
},
focus: ({ position = "start", scrollIntoView = false }) => editor?.commands.focus(position, { scrollIntoView }),
getCordsFromPos: (pos?: number) => editor?.view.coordsAtPos(pos ?? editor.state.selection.from),
getCurrentCursorPosition: () => editor?.state.selection.from,
getSelectedNodeAttributes: (attribute) => {
if (!editor) return;
editor.commands.extendMarkRange("link");
return editor.getAttributes(attribute);
},
getSelectedText: () => {
if (!editor) return null;
@@ -151,6 +189,7 @@ export const useEditor = (props: TEditorHookProps) => {
const { from, to, empty } = state.selection;
if (empty) return null;
if (from === to) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
@@ -238,7 +277,8 @@ export const useEditor = (props: TEditorHookProps) => {
editor?.off("transaction", callback);
};
},
scrollToNodeViaDOMCoordinates(behavior, pos) {
redo: () => editor?.commands.redo(),
scrollToNodeViaDOMCoordinates({ pos, behavior = "smooth" }) {
const resolvedPos = pos ?? editor?.state.selection.from;
if (!editor || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
@@ -270,6 +310,7 @@ export const useEditor = (props: TEditorHookProps) => {
if (!document) return;
Y.applyUpdate(document, value);
},
undo: () => editor?.commands.undo(),
}),
[editor]
);
+2 -2
View File
@@ -24,9 +24,9 @@ export type TFileHandler = TReadOnlyFileHandler & {
export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";
export type TEditorFontSize = "small-font" | "large-font";
export type TEditorFontSize = "small-font" | "large-font" | "mobile-font";
export type TEditorLineSpacing = "regular" | "small";
export type TEditorLineSpacing = "regular" | "small" | "mobile-regular";
export type TDisplayConfig = {
fontStyle?: TEditorFontStyle;
+30 -9
View File
@@ -1,6 +1,8 @@
import { Extensions, JSONContent } from "@tiptap/core";
import { Extensions, FocusPosition, JSONContent } from "@tiptap/core";
import { MarkType, NodeType } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
// extension types
import { EditorProps } from "@tiptap/pm/view";
import type { TTextAlign } from "@/extensions";
// helpers
import type { IMarking } from "@/helpers/scroll-to-node";
@@ -42,6 +44,7 @@ export type TEditorCommands =
| "table"
| "image"
| "divider"
| "link"
| "issue-embed"
| "text-color"
| "background-color"
@@ -66,6 +69,10 @@ export type TCommandExtraProps = {
"text-align": {
alignment: TTextAlign;
};
link: {
url: string;
text?: string;
};
};
// Create a utility type that maps a command to its extra props or an empty object if none are defined
@@ -100,8 +107,19 @@ export type EditorReadOnlyRefApi = {
export interface EditorRefApi extends EditorReadOnlyRefApi {
blur: () => void;
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
createSelectionAtCursorPosition: () => void;
focus: ({ position, scrollIntoView }: { position?: FocusPosition; scrollIntoView?: boolean }) => void;
getCordsFromPos: (pos?: number) =>
| {
left: number;
right: number;
top: number;
bottom: number;
}
| undefined;
executeMenuItemCommand: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => void;
getSelectedNodeAttributes: (attribute: string | NodeType | MarkType) => Record<string, any> | undefined;
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
getCurrentCursorPosition: () => number | undefined;
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
@@ -111,10 +129,12 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
onDocumentInfoChange: (callback: (documentInfo: TDocumentInfo) => void) => () => void;
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
onStateChange: (callback: () => void) => () => void;
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
redo: () => void;
scrollToNodeViaDOMCoordinates: ({ pos, behavior }: { pos?: number; behavior?: ScrollBehavior }) => void;
setEditorValueAtCursorPosition: (content: string) => void;
setFocusAtPosition: (position: number) => void;
setProviderDocument: (value: Uint8Array) => void;
undo: () => void;
}
// editor props
@@ -124,7 +144,9 @@ export interface IEditorProps {
containerClassName?: string;
displayConfig?: TDisplayConfig;
disabledExtensions: TExtensions[];
editable?: boolean;
editorClassName?: string;
editorProps?: EditorProps;
extensions?: Extensions;
flaggedExtensions: TExtensions[];
fileHandler: TFileHandler;
@@ -132,6 +154,8 @@ export interface IEditorProps {
handleEditorReady?: (value: boolean) => void;
id: string;
initialValue: string;
isMobile?: boolean;
dragDropEnabled?: boolean;
mentionHandler: TMentionHandler;
onAssetChange?: (assets: TEditorAsset[]) => void;
onChange?: (json: object, html: string) => void;
@@ -144,15 +168,11 @@ export interface IEditorProps {
export type ILiteTextEditorProps = IEditorProps;
export type IRichTextEditorProps = IEditorProps & {
dragDropEnabled?: boolean;
editable: boolean;
};
export type IRichTextEditorProps = IEditorProps;
export interface ICollaborativeDocumentEditorProps
extends Omit<IEditorProps, "extensions" | "initialValue" | "onEnterKeyPress" | "value"> {
extends Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> {
aiHandler?: TAIHandler;
editable: boolean;
embedHandler: TEmbedConfig;
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
@@ -172,6 +192,7 @@ export interface IReadOnlyEditorProps
| "handleEditorReady"
| "id"
| "initialValue"
| "isMobile"
> {
fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
+7 -3
View File
@@ -5,7 +5,13 @@ import type { ICollaborativeDocumentEditorProps, IEditorProps, IReadOnlyEditorPr
type TCoreHookProps = Pick<
IEditorProps,
"disabledExtensions" | "editorClassName" | "extensions" | "flaggedExtensions" | "handleEditorReady"
| "editable"
| "disabledExtensions"
| "dragDropEnabled"
| "editorClassName"
| "extensions"
| "flaggedExtensions"
| "handleEditorReady"
> & {
editorProps?: EditorProps;
};
@@ -25,7 +31,6 @@ export type TEditorHookProps = TCoreHookProps &
| "tabIndex"
| "value"
> & {
editable: boolean;
enableHistory: boolean;
initialValue?: string;
provider?: HocuspocusProvider;
@@ -34,7 +39,6 @@ export type TEditorHookProps = TCoreHookProps &
export type TCollaborativeEditorHookProps = TCoreHookProps &
Pick<
TEditorHookProps,
| "editable"
| "fileHandler"
| "forwardedRef"
| "id"
-9
View File
@@ -30,15 +30,6 @@
height: 0;
}
/* Display Placeholders on every new line. */
.ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
.ProseMirror li blockquote {
margin-top: 10px;
padding-inline-start: 1em;
+43 -1
View File
@@ -66,7 +66,7 @@
--line-height-regular: 1.5rem;
--line-height-code: 1.5rem;
--line-height-list: var(--line-height-regular);
}
}
&.small-font {
--font-size-h1: 1.4rem;
--font-size-h2: 1.2rem;
@@ -88,6 +88,27 @@
--line-height-code: 1.2rem;
--line-height-list: var(--line-height-regular);
}
&.mobile-font {
--font-size-h1: 1.75rem;
--font-size-h2: 1.5rem;
--font-size-h3: 1.375rem;
--font-size-h4: 1.25rem;
--font-size-h5: 1.125rem;
--font-size-h6: 1rem;
--font-size-regular: 0.95rem;
--font-size-code: 0.85rem;
--font-size-list: var(--font-size-regular);
--line-height-h1: 2.25rem;
--line-height-h2: 2rem;
--line-height-h3: 1.75rem;
--line-height-h4: 1.5rem;
--line-height-h5: 1.5rem;
--line-height-h6: 1.5rem;
--line-height-regular: 1.5rem;
--line-height-code: 1.5rem;
--line-height-list: var(--line-height-regular);
}
/* end font sizes and line heights */
/* font styles */
@@ -146,6 +167,27 @@
--divider-padding-top: 0px;
--divider-padding-bottom: 4px;
}
&.line-spacing-mobile-regular {
--heading-1-padding-top: 16px;
--heading-1-padding-bottom: 4px;
--heading-2-padding-top: 16px;
--heading-2-padding-bottom: 4px;
--heading-3-padding-top: 16px;
--heading-3-padding-bottom: 4px;
--heading-4-padding-top: 16px;
--heading-4-padding-bottom: 4px;
--heading-5-padding-top: 12px;
--heading-5-padding-bottom: 4px;
--heading-6-padding-top: 12px;
--heading-6-padding-bottom: 4px;
--paragraph-padding-top: 2px;
--paragraph-padding-bottom: 2px;
--paragraph-padding-between: 4px;
--list-spacing-y: 0px;
--divider-padding-top: 0px;
--divider-padding-bottom: 4px;
}
/* end spacing */
}
/* end font size and style */