mirror of
https://github.com/makeplane/plane.git
synced 2026-04-28 20:19:51 -05:00
[WIKI-568] refactor: add touch device support to editor (#7439)
* refactor: add isTouchDevice prop * chore: handle event propagation in touch devices * refactor: isTouchDevice implementation * chore: misc editor updates and utility functions (#7455) * chore: misc editor updated and utility functions * fix: code review * passed isTouchDevice prop to editor-wrapper * added more props to editor-wrapper. * chore: update types --------- Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com> * fix: remove unnecessary deps --------- Co-authored-by: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
7cec92113f
commit
c3273b1a85
@@ -1,5 +1,5 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import React from "react";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import React, { useMemo } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -13,26 +13,32 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// types
|
||||
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
||||
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
||||
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const {
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
containerClassName,
|
||||
documentLoaderClassName,
|
||||
extensions: externalExtensions = [],
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
editorProps,
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
dragDropEnabled = true,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
@@ -41,21 +47,26 @@ 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,
|
||||
editable,
|
||||
editorClassName,
|
||||
editorProps,
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
@@ -63,9 +74,12 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
dragDropEnabled,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
@@ -87,9 +101,11 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
aiHandler={aiHandler}
|
||||
bubbleMenuEnabled={bubbleMenuEnabled}
|
||||
displayConfig={displayConfig}
|
||||
documentLoaderClassName={documentLoaderClassName}
|
||||
editor={editor}
|
||||
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
|
||||
id={id}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
isLoading={!hasServerSynced && !hasServerConnectionFailed}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
|
||||
@@ -11,16 +11,28 @@ type Props = {
|
||||
aiHandler?: TAIHandler;
|
||||
bubbleMenuEnabled: boolean;
|
||||
displayConfig: TDisplayConfig;
|
||||
documentLoaderClassName?: string;
|
||||
editor: Editor;
|
||||
editorContainerClassName: string;
|
||||
id: string;
|
||||
isLoading?: boolean;
|
||||
isTouchDevice: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: Props) => {
|
||||
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, isLoading, tabIndex } =
|
||||
props;
|
||||
const {
|
||||
aiHandler,
|
||||
bubbleMenuEnabled,
|
||||
displayConfig,
|
||||
documentLoaderClassName,
|
||||
editor,
|
||||
editorContainerClassName,
|
||||
id,
|
||||
isLoading,
|
||||
isTouchDevice,
|
||||
tabIndex,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -29,16 +41,17 @@ export const PageRenderer = (props: Props) => {
|
||||
})}
|
||||
>
|
||||
{isLoading ? (
|
||||
<DocumentContentLoader />
|
||||
<DocumentContentLoader className={documentLoaderClassName} />
|
||||
) : (
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
isTouchDevice={isTouchDevice}
|
||||
>
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor.isEditable && (
|
||||
{editor.isEditable && !isTouchDevice && (
|
||||
<div>
|
||||
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
||||
<BlockMenu editor={editor} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { FC, ReactNode, useRef } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
@@ -10,16 +10,18 @@ import { TDisplayConfig } from "@/types";
|
||||
// components
|
||||
import { LinkViewContainer } from "./link-view-container";
|
||||
|
||||
interface EditorContainerProps {
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
displayConfig: TDisplayConfig;
|
||||
editor: Editor;
|
||||
editorContainerClassName: string;
|
||||
id: string;
|
||||
}
|
||||
isTouchDevice: boolean;
|
||||
};
|
||||
|
||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const { children, displayConfig, editor, editorContainerClassName, id } = props;
|
||||
export const EditorContainer: FC<Props> = (props) => {
|
||||
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
@@ -94,7 +96,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<LinkViewContainer editor={editor} containerRef={containerRef} />
|
||||
{!isTouchDevice && <LinkViewContainer editor={editor} containerRef={containerRef} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,14 +24,17 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
editorProps,
|
||||
extensions,
|
||||
id,
|
||||
initialValue,
|
||||
isTouchDevice,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
handleEditorReady,
|
||||
autofocus,
|
||||
@@ -44,15 +47,18 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
editable,
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
editorProps,
|
||||
enableHistory: true,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
isTouchDevice,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
handleEditorReady,
|
||||
autofocus,
|
||||
@@ -75,6 +81,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
id={id}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
>
|
||||
{children?.(editor)}
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -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",
|
||||
@@ -254,6 +270,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
|
||||
TableItem(editor),
|
||||
ImageItem(editor),
|
||||
HorizontalRuleItem(editor),
|
||||
LinkItem(editor),
|
||||
TextColorItem(editor),
|
||||
BackgroundColorItem(editor),
|
||||
TextAlignItem(editor),
|
||||
|
||||
@@ -2,6 +2,10 @@ import { NodeSelection } from "@tiptap/pm/state";
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||
import { ensurePixelString, getImageBlockId } from "../utils";
|
||||
@@ -57,6 +61,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
|
||||
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
||||
// extension options
|
||||
const isTouchDevice = !!getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
|
||||
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
|
||||
@@ -188,11 +194,15 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const handleImageMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isTouchDevice) {
|
||||
e.preventDefault();
|
||||
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, isTouchDevice]
|
||||
);
|
||||
|
||||
// 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)
|
||||
@@ -254,7 +264,12 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
if (!resolvedImageSrc) {
|
||||
throw new Error("No resolved image source available");
|
||||
}
|
||||
imageRef.current.src = resolvedImageSrc;
|
||||
if (isTouchDevice) {
|
||||
const refreshedSrc = await extension.options.getImageSource?.(imgNodeSrc);
|
||||
imageRef.current.src = refreshedSrc;
|
||||
} else {
|
||||
imageRef.current.src = resolvedImageSrc;
|
||||
}
|
||||
} catch {
|
||||
// if the image failed to even restore, then show the error state
|
||||
setFailedToLoadImage(true);
|
||||
@@ -281,14 +296,15 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
<ImageToolbarRoot
|
||||
alignment={nodeAlignment ?? "left"}
|
||||
editor={editor}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}
|
||||
src={resolvedImageSrc}
|
||||
downloadSrc={resolvedDownloadSrc}
|
||||
handleAlignmentChange={(alignment) =>
|
||||
updateAttributesSafely({ alignment }, "Failed to update attributes while changing alignment:")
|
||||
}
|
||||
height={size.height}
|
||||
isTouchDevice={isTouchDevice}
|
||||
width={size.width}
|
||||
src={resolvedImageSrc}
|
||||
/>
|
||||
)}
|
||||
{selected && displayedImageSrc === resolvedImageSrc && (
|
||||
|
||||
@@ -24,7 +24,7 @@ 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 +43,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) {
|
||||
|
||||
+41
-21
@@ -11,15 +11,16 @@ const ZOOM_STEPS = [0.5, 1, 1.5, 2];
|
||||
|
||||
type Props = {
|
||||
aspectRatio: number;
|
||||
isFullScreenEnabled: boolean;
|
||||
downloadSrc: string;
|
||||
isFullScreenEnabled: boolean;
|
||||
isTouchDevice: boolean;
|
||||
src: string;
|
||||
toggleFullScreenMode: (val: boolean) => void;
|
||||
width: string;
|
||||
};
|
||||
|
||||
const ImageFullScreenModalWithoutPortal = (props: Props) => {
|
||||
const { aspectRatio, isFullScreenEnabled, downloadSrc, src, toggleFullScreenMode, width } = props;
|
||||
const { aspectRatio, isFullScreenEnabled, isTouchDevice, downloadSrc, src, toggleFullScreenMode, width } = props;
|
||||
// refs
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
@@ -233,7 +234,13 @@ const ImageFullScreenModalWithoutPortal = (props: Props) => {
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMagnification("decrease")}
|
||||
onClick={(e) => {
|
||||
if (isTouchDevice) {
|
||||
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 +250,13 @@ 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) => {
|
||||
if (isTouchDevice) {
|
||||
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 +264,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>
|
||||
{!isTouchDevice && (
|
||||
<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>
|
||||
)}
|
||||
{!isTouchDevice && (
|
||||
<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>
|
||||
@@ -279,7 +296,10 @@ export const ImageFullScreenModal: React.FC<Props> = (props) => {
|
||||
if (portal) {
|
||||
modal = ReactDOM.createPortal(modal, portal);
|
||||
} else {
|
||||
console.warn("Portal element #editor-portal not found. Rendering inline.");
|
||||
console.warn("Portal element #editor-portal not found. Rendering in document.body");
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
modal = ReactDOM.createPortal(modal, document.body);
|
||||
}
|
||||
}
|
||||
return modal;
|
||||
};
|
||||
|
||||
+10
-8
@@ -7,17 +7,18 @@ import { ImageFullScreenModal } from "./modal";
|
||||
|
||||
type Props = {
|
||||
image: {
|
||||
downloadSrc: string;
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
aspectRatio: number;
|
||||
downloadSrc: string;
|
||||
height: string;
|
||||
src: string;
|
||||
width: string;
|
||||
};
|
||||
isTouchDevice: boolean;
|
||||
toggleToolbarViewStatus: (val: boolean) => void;
|
||||
};
|
||||
|
||||
export const ImageFullScreenActionRoot: React.FC<Props> = (props) => {
|
||||
const { image, toggleToolbarViewStatus } = props;
|
||||
const { image, isTouchDevice, toggleToolbarViewStatus } = props;
|
||||
// states
|
||||
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
|
||||
// derived values
|
||||
@@ -31,13 +32,14 @@ export const ImageFullScreenActionRoot: React.FC<Props> = (props) => {
|
||||
<>
|
||||
<ImageFullScreenModal
|
||||
aspectRatio={aspectRatio}
|
||||
isFullScreenEnabled={isFullScreenEnabled}
|
||||
src={src}
|
||||
downloadSrc={downloadSrc}
|
||||
isFullScreenEnabled={isFullScreenEnabled}
|
||||
isTouchDevice={isTouchDevice}
|
||||
src={src}
|
||||
width={width}
|
||||
toggleFullScreenMode={setIsFullScreenEnabled}
|
||||
/>
|
||||
<Tooltip tooltipContent="View in full screen">
|
||||
<Tooltip tooltipContent="View in full screen" disabled={isTouchDevice}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -11,16 +11,17 @@ import { ImageFullScreenActionRoot } from "./full-screen";
|
||||
type Props = {
|
||||
alignment: TCustomImageAlignment;
|
||||
editor: Editor;
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
downloadSrc: string;
|
||||
handleAlignmentChange: (alignment: TCustomImageAlignment) => void;
|
||||
height: string;
|
||||
isTouchDevice: boolean;
|
||||
src: string;
|
||||
width: string;
|
||||
};
|
||||
|
||||
export const ImageToolbarRoot: React.FC<Props> = (props) => {
|
||||
const { alignment, editor, downloadSrc, handleAlignmentChange } = props;
|
||||
const { alignment, editor, downloadSrc, handleAlignmentChange, isTouchDevice } = props;
|
||||
// states
|
||||
const [shouldShowToolbar, setShouldShowToolbar] = useState(false);
|
||||
// derived values
|
||||
@@ -36,7 +37,7 @@ export const ImageToolbarRoot: React.FC<Props> = (props) => {
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ImageDownloadAction src={downloadSrc} />
|
||||
{!isTouchDevice && <ImageDownloadAction src={downloadSrc} />}
|
||||
{isEditable && (
|
||||
<ImageAlignmentAction
|
||||
activeAlignment={alignment}
|
||||
@@ -44,7 +45,11 @@ export const ImageToolbarRoot: React.FC<Props> = (props) => {
|
||||
toggleToolbarViewStatus={setShouldShowToolbar}
|
||||
/>
|
||||
)}
|
||||
<ImageFullScreenActionRoot image={props} toggleToolbarViewStatus={setShouldShowToolbar} />
|
||||
<ImageFullScreenActionRoot
|
||||
image={props}
|
||||
isTouchDevice={isTouchDevice}
|
||||
toggleToolbarViewStatus={setShouldShowToolbar}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
const { id: imageEntityId } = node.attrs;
|
||||
// derived values
|
||||
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
|
||||
const isTouchDevice = !!getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
|
||||
|
||||
const onUpload = useCallback(
|
||||
(url: string) => {
|
||||
@@ -125,12 +126,14 @@ 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 (!isTouchDevice) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
hasTriggeredFilePickerRef.current = true;
|
||||
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
|
||||
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId, isTouchDevice]);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -38,7 +38,13 @@ import { CustomStarterKitExtension } from "./starter-kit";
|
||||
|
||||
type TArguments = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
|
||||
| "disabledExtensions"
|
||||
| "flaggedExtensions"
|
||||
| "fileHandler"
|
||||
| "isTouchDevice"
|
||||
| "mentionHandler"
|
||||
| "placeholder"
|
||||
| "tabIndex"
|
||||
> & {
|
||||
enableHistory: boolean;
|
||||
editable: boolean;
|
||||
@@ -50,6 +56,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
isTouchDevice = false,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
@@ -102,6 +109,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
isEditable: editable,
|
||||
isTouchDevice,
|
||||
}),
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
|
||||
@@ -35,15 +35,17 @@ export interface UtilityExtensionStorage {
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
uploadInProgress: boolean;
|
||||
activeDropbarExtensions: TActiveDropbarExtensions[];
|
||||
isTouchDevice: boolean;
|
||||
}
|
||||
|
||||
type Props = Pick<IEditorProps, "disabledExtensions"> & {
|
||||
fileHandler: TFileHandler;
|
||||
isEditable: boolean;
|
||||
isTouchDevice: boolean;
|
||||
};
|
||||
|
||||
export const UtilityExtension = (props: Props) => {
|
||||
const { disabledExtensions, fileHandler, isEditable } = props;
|
||||
const { disabledExtensions, fileHandler, isEditable, isTouchDevice } = props;
|
||||
const { restore } = fileHandler;
|
||||
|
||||
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
|
||||
@@ -76,6 +78,7 @@ export const UtilityExtension = (props: Props) => {
|
||||
assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {},
|
||||
uploadInProgress: false,
|
||||
activeDropbarExtensions: [],
|
||||
isTouchDevice,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -127,7 +127,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();
|
||||
};
|
||||
|
||||
|
||||
@@ -24,9 +24,42 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
const { editor, provider } = args;
|
||||
|
||||
return {
|
||||
blur: () => editor?.commands.blur(),
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
createSelectionAtCursorPosition: () => {
|
||||
if (!editor) return;
|
||||
const { empty } = editor.state.selection;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
@@ -55,7 +88,6 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
setEditorValue: (content, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
blur: () => editor?.commands.blur(),
|
||||
emitRealTimeUpdate: (message) => provider?.sendStateless(message),
|
||||
executeMenuItemCommand: (props) => {
|
||||
const { itemKey } = props;
|
||||
@@ -70,7 +102,14 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
console.warn(`No command found for item: ${itemKey}`);
|
||||
}
|
||||
},
|
||||
focus: (args) => editor?.commands.focus(args),
|
||||
getCoordsFromPos: (pos) => editor?.view.coordsAtPos(pos ?? editor.state.selection.from),
|
||||
getCurrentCursorPosition: () => editor?.state.selection.from,
|
||||
getAttributesWithExtendedMark: (mark, attribute) => {
|
||||
if (!editor) return;
|
||||
editor.commands.extendMarkRange(mark);
|
||||
return editor.getAttributes(attribute);
|
||||
},
|
||||
getSelectedText: () => {
|
||||
if (!editor) return null;
|
||||
|
||||
@@ -165,7 +204,8 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
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);
|
||||
@@ -197,5 +237,6 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
if (!document) return;
|
||||
Y.applyUpdate(document, value);
|
||||
},
|
||||
undo: () => editor?.commands.undo(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,7 +27,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
dragDropEnabled = true,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onEditorFocus,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
@@ -86,7 +89,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
extensions: [
|
||||
SideMenuExtension({
|
||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||
dragDropEnabled: true,
|
||||
dragDropEnabled,
|
||||
}),
|
||||
HeadingListExtension,
|
||||
Collaboration.configure({
|
||||
@@ -107,9 +110,11 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
|
||||
@@ -27,9 +27,11 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
handleEditorReady,
|
||||
id = "",
|
||||
initialValue,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
@@ -57,6 +59,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
isTouchDevice,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
@@ -70,6 +73,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
},
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||
onDestroy: () => handleEditorReady?.(false),
|
||||
onFocus: onEditorFocus,
|
||||
},
|
||||
[editable]
|
||||
);
|
||||
|
||||
@@ -21,9 +21,9 @@ export type TFileHandler = {
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Content, Extensions, JSONContent } from "@tiptap/core";
|
||||
import type { Content, Extensions, JSONContent, RawCommands } from "@tiptap/core";
|
||||
import type { MarkType, NodeType } from "@tiptap/pm/model";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
import type { EditorProps, EditorView } from "@tiptap/pm/view";
|
||||
// extension types
|
||||
import type { TTextAlign } from "@/extensions";
|
||||
// helpers
|
||||
@@ -40,6 +42,7 @@ export type TEditorCommands =
|
||||
| "table"
|
||||
| "image"
|
||||
| "divider"
|
||||
| "link"
|
||||
| "issue-embed"
|
||||
| "text-color"
|
||||
| "background-color"
|
||||
@@ -58,6 +61,10 @@ export type TCommandExtraProps = {
|
||||
"text-color": {
|
||||
color: string | undefined;
|
||||
};
|
||||
link: {
|
||||
url: string;
|
||||
text?: string;
|
||||
};
|
||||
"background-color": {
|
||||
color: string | undefined;
|
||||
};
|
||||
@@ -84,8 +91,15 @@ export type TDocumentInfo = {
|
||||
export type EditorRefApi = {
|
||||
blur: () => void;
|
||||
clearEditor: (emitUpdate?: boolean) => void;
|
||||
createSelectionAtCursorPosition: () => void;
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
executeMenuItemCommand: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => void;
|
||||
focus: (args: Parameters<RawCommands["focus"]>[0]) => void;
|
||||
getAttributesWithExtendedMark: (
|
||||
mark: string | MarkType,
|
||||
attribute: string | NodeType | MarkType
|
||||
) => Record<string, any> | undefined;
|
||||
getCoordsFromPos: (pos?: number) => ReturnType<EditorView["coordsAtPos"]> | undefined;
|
||||
getCurrentCursorPosition: () => number | undefined;
|
||||
getDocument: () => {
|
||||
binary: Uint8Array | null;
|
||||
@@ -103,13 +117,15 @@ export type EditorRefApi = {
|
||||
onDocumentInfoChange: (callback: (documentInfo: TDocumentInfo) => void) => () => void;
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
redo: () => void;
|
||||
scrollSummary: (marking: IMarking) => void;
|
||||
// eslint-disable-next-line no-undef
|
||||
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
|
||||
scrollToNodeViaDOMCoordinates: ({ pos, behavior }: { pos?: number; behavior?: ScrollBehavior }) => void;
|
||||
setEditorValue: (content: string, emitUpdate?: boolean) => void;
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
undo: () => void;
|
||||
};
|
||||
|
||||
// editor props
|
||||
@@ -121,6 +137,7 @@ export interface IEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
editable: boolean;
|
||||
editorClassName?: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: Extensions;
|
||||
flaggedExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
@@ -128,8 +145,10 @@ export interface IEditorProps {
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
isTouchDevice?: boolean;
|
||||
mentionHandler: TMentionHandler;
|
||||
onAssetChange?: (assets: TEditorAsset[]) => void;
|
||||
onEditorFocus?: () => void;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
onTransaction?: () => void;
|
||||
@@ -145,8 +164,11 @@ export type IRichTextEditorProps = IEditorProps & {
|
||||
};
|
||||
|
||||
export interface ICollaborativeDocumentEditorProps
|
||||
extends Omit<IEditorProps, "extensions" | "initialValue" | "onEnterKeyPress" | "value"> {
|
||||
extends Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> {
|
||||
aiHandler?: TAIHandler;
|
||||
documentLoaderClassName?: string;
|
||||
dragDropEnabled?: boolean;
|
||||
editable: boolean;
|
||||
embedHandler: TEmbedConfig;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { Content } from "@tiptap/core";
|
||||
import type { EditorProps } from "@tiptap/pm/view";
|
||||
// local imports
|
||||
import type { ICollaborativeDocumentEditorProps, IEditorProps } from "./editor";
|
||||
|
||||
type TCoreHookProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "editorClassName" | "extensions" | "flaggedExtensions" | "handleEditorReady"
|
||||
> & {
|
||||
editorProps?: EditorProps;
|
||||
};
|
||||
| "disabledExtensions"
|
||||
| "editorClassName"
|
||||
| "editorProps"
|
||||
| "extensions"
|
||||
| "flaggedExtensions"
|
||||
| "handleEditorReady"
|
||||
| "isTouchDevice"
|
||||
| "onEditorFocus"
|
||||
>;
|
||||
|
||||
export type TEditorHookProps = TCoreHookProps &
|
||||
Pick<
|
||||
@@ -46,4 +50,7 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
|
||||
| "placeholder"
|
||||
| "tabIndex"
|
||||
> &
|
||||
Pick<ICollaborativeDocumentEditorProps, "embedHandler" | "realtimeConfig" | "serverHandler" | "user">;
|
||||
Pick<
|
||||
ICollaborativeDocumentEditorProps,
|
||||
"dragDropEnabled" | "embedHandler" | "realtimeConfig" | "serverHandler" | "user"
|
||||
>;
|
||||
|
||||
@@ -88,6 +88,28 @@
|
||||
--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 +168,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 */
|
||||
|
||||
Reference in New Issue
Block a user