[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:
Aaryan Khandelwal
2025-08-04 16:04:09 +05:30
committed by GitHub
parent 7cec92113f
commit c3273b1a85
21 changed files with 332 additions and 84 deletions
@@ -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) {
@@ -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;
};
@@ -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();
};
+43 -2
View File
@@ -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]
);
+2 -2
View File
@@ -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;
+25 -3
View File
@@ -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;
+13 -6
View File
@@ -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"
>;
+43
View File
@@ -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 */