diff --git a/apps/space/ce/hooks/use-editor-flagging.ts b/apps/space/ce/hooks/use-editor-flagging.ts new file mode 100644 index 0000000000..7b4bc38c35 --- /dev/null +++ b/apps/space/ce/hooks/use-editor-flagging.ts @@ -0,0 +1,35 @@ +// editor +import { TExtensions } from "@plane/editor"; + +export type TEditorFlaggingHookReturnType = { + document: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + liteText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + richText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; +}; + +/** + * @description extensions disabled in various editors + */ +export const useEditorFlagging = (anchor: string): TEditorFlaggingHookReturnType => ({ + document: { + disabled: [], + flagged: [], + }, + liteText: { + disabled: [], + flagged: [], + }, + richText: { + disabled: [], + flagged: [], + }, +}); diff --git a/apps/space/core/components/editor/lite-text-editor.tsx b/apps/space/core/components/editor/lite-text-editor.tsx index 96548d55c6..a0597e84d7 100644 --- a/apps/space/core/components/editor/lite-text-editor.tsx +++ b/apps/space/core/components/editor/lite-text-editor.tsx @@ -8,6 +8,7 @@ import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor"; // helpers import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; +import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; type LiteTextEditorWrapperProps = MakeOptional< Omit, @@ -31,9 +32,8 @@ export const LiteTextEditor = React.forwardRef(ref) ? ref.current : null; + const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor); return (
((props, ref) => { - const { anchor, containerClassName, editable, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props; + const { + anchor, + containerClassName, + editable, + workspaceId, + disabledExtensions: additionalDisabledExtensions = [], + ...rest + } = props; const { getMemberById } = useMember(); + const { richText: richTextEditorExtensions } = useEditorFlagging(anchor); + return ( "", workspaceId, })} - flaggedExtensions={flaggedExtensions ?? []} + flaggedExtensions={richTextEditorExtensions.flagged} {...rest} containerClassName={containerClassName} editorClassName="min-h-[100px] max-h-[200px] border-[0.5px] border-custom-border-300 rounded-md pl-3 py-2 overflow-hidden" diff --git a/apps/web/core/store/pages/base-page.ts b/apps/web/core/store/pages/base-page.ts index 9cab1e5cb0..228da540e7 100644 --- a/apps/web/core/store/pages/base-page.ts +++ b/apps/web/core/store/pages/base-page.ts @@ -172,7 +172,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { // page properties id: observable.ref, name: observable.ref, - logo_props: observable, + logo_props: observable.ref, description: observable, description_html: observable.ref, color: observable.ref, diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index 3a01812e4d..10bf9bcde7 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -1,5 +1,4 @@ -// tiptap -import { Extensions } from "@tiptap/core"; +import type { Extensions } from "@tiptap/core"; import { useEditorState } from "@tiptap/react"; import React, { useEffect, useMemo } from "react"; // plane imports @@ -17,27 +16,33 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage"; import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor"; // types import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; -import { EditorRefApi, EventToPayloadMap, ICollaborativeDocumentEditorProps } from "@/types"; +import type { EditorRefApi, EventToPayloadMap, ICollaborativeDocumentEditorProps } from "@/types"; const CollaborativeDocumentEditor: React.FC = (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, pageRestorationInProgress, onAssetChange, onChange, + onEditorFocus, onTransaction, placeholder, realtimeConfig, @@ -50,16 +55,18 @@ const CollaborativeDocumentEditor: React.FC = } = props; const extensions: Extensions = useMemo(() => { - const ext: Extensions = []; + const allExtensions = [...externalExtensions]; + if (embedHandler?.issue) { - ext.push( + allExtensions.push( WorkItemEmbedExtension({ widgetCallback: embedHandler.issue.widgetCallback, }) ); } - return ext; - }, [embedHandler]); + + return allExtensions; + }, [externalExtensions, embedHandler.issue]); // use document editor const { editor, hasServerConnectionFailed, hasServerSynced, titleEditor, isContentInIndexedDb, isIndexedDbSynced } = @@ -67,6 +74,7 @@ const CollaborativeDocumentEditor: React.FC = disabledExtensions, editable, editorClassName, + editorProps, embedHandler, extensions, fileHandler, @@ -74,10 +82,13 @@ const CollaborativeDocumentEditor: React.FC = forwardedRef, handleEditorReady, id, + dragDropEnabled, isSmoothCursorEnabled, + isTouchDevice, mentionHandler, onAssetChange, onChange, + onEditorFocus, onTransaction, placeholder, realtimeConfig, @@ -107,6 +118,7 @@ const CollaborativeDocumentEditor: React.FC = aiHandler={aiHandler} bubbleMenuEnabled={bubbleMenuEnabled} displayConfig={displayConfig} + documentLoaderClassName={documentLoaderClassName} editor={editor} titleEditor={titleEditor} editorContainerClassName={cn(editorContainerClassNames, "document-editor")} @@ -114,6 +126,7 @@ const CollaborativeDocumentEditor: React.FC = isLoading={ (!hasServerSynced && !hasServerConnectionFailed && !isContentInIndexedDb) || pageRestorationInProgress } + isTouchDevice={!!isTouchDevice} tabIndex={tabIndex} /> diff --git a/packages/editor/src/core/components/editors/document/loader.tsx b/packages/editor/src/core/components/editors/document/loader.tsx index ca3fe9da09..3f31b1b96e 100644 --- a/packages/editor/src/core/components/editors/document/loader.tsx +++ b/packages/editor/src/core/components/editors/document/loader.tsx @@ -1,8 +1,13 @@ // plane imports import { Loader } from "@plane/ui"; +import { cn } from "@plane/utils"; -export const DocumentContentLoader = () => ( -
+type Props = { + className?: string; +}; + +export const DocumentContentLoader = ({ className }: Props) => ( +
diff --git a/packages/editor/src/core/components/editors/document/page-renderer.tsx b/packages/editor/src/core/components/editors/document/page-renderer.tsx index d5e3532c03..448faab952 100644 --- a/packages/editor/src/core/components/editors/document/page-renderer.tsx +++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx @@ -11,11 +11,13 @@ type Props = { aiHandler?: TAIHandler; bubbleMenuEnabled: boolean; displayConfig: TDisplayConfig; + documentLoaderClassName?: string; editor: Editor; titleEditor?: Editor; editorContainerClassName: string; id: string; isLoading?: boolean; + isTouchDevice: boolean; tabIndex?: number; }; @@ -24,12 +26,14 @@ export const PageRenderer = (props: Props) => { aiHandler, bubbleMenuEnabled, displayConfig, + documentLoaderClassName, editor, editorContainerClassName, id, + isLoading, + isTouchDevice, tabIndex, titleEditor, - isLoading = false, } = props; return ( @@ -39,7 +43,7 @@ export const PageRenderer = (props: Props) => { })} > {isLoading ? ( - + ) : ( <> {titleEditor && ( @@ -47,6 +51,7 @@ export const PageRenderer = (props: Props) => { @@ -64,9 +69,10 @@ export const PageRenderer = (props: Props) => { editor={editor} editorContainerClassName={editorContainerClassName} id={id} + isTouchDevice={isTouchDevice} > - {editor.isEditable && ( + {editor.isEditable && !isTouchDevice && (
{bubbleMenuEnabled && } diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index 054dafb926..557cb35d21 100644 --- a/packages/editor/src/core/components/editors/editor-container.tsx +++ b/packages/editor/src/core/components/editors/editor-container.tsx @@ -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 = (props) => { - const { children, displayConfig, editor, editorContainerClassName, id } = props; +export const EditorContainer: FC = (props) => { + const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props; + // refs const containerRef = useRef(null); const handleContainerClick = (event: React.MouseEvent) => { @@ -48,17 +50,16 @@ export const EditorContainer: FC = (props) => { return; } - // Get the last node in the document - const docSize = editor.state.doc.content.size; - const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2)); - const lastNode = lastNodePos.node(); + // Get the last child node in the document + const doc = editor.state.doc; + const lastNode = doc.lastChild; // Check if its last node and add new node if (lastNode) { - const isLastNodeEmptyParagraph = - lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0; - // Only insert a new paragraph if the last node is not an empty paragraph and not a doc node - if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") { + const isLastNodeParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH; + // Insert a new paragraph if the last node is not a paragraph and not a doc node + if (!isLastNodeParagraph && lastNode.type.name !== CORE_EXTENSIONS.DOCUMENT) { + // Only insert a new paragraph if the last node is not an empty paragraph and not a doc node const endPosition = editor?.state.doc.content.size; editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run(); } @@ -93,7 +94,7 @@ export const EditorContainer: FC = (props) => { )} > {children} - + {!isTouchDevice && }
); diff --git a/packages/editor/src/core/components/editors/editor-wrapper.tsx b/packages/editor/src/core/components/editors/editor-wrapper.tsx index cf7ef8a208..4b54eeb905 100644 --- a/packages/editor/src/core/components/editors/editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/editor-wrapper.tsx @@ -25,9 +25,11 @@ export const EditorWrapper: React.FC = (props) => { displayConfig = DEFAULT_DISPLAY_CONFIG, editable, editorClassName = "", + editorProps, extensions, id, initialValue, + isTouchDevice, fileHandler, flaggedExtensions, forwardedRef, @@ -35,6 +37,7 @@ export const EditorWrapper: React.FC = (props) => { isSmoothCursorEnabled, mentionHandler, onChange, + onEditorFocus, onTransaction, autofocus, placeholder, @@ -46,6 +49,7 @@ export const EditorWrapper: React.FC = (props) => { editable, disabledExtensions, editorClassName, + editorProps, enableHistory: true, extensions, fileHandler, @@ -54,9 +58,11 @@ export const EditorWrapper: React.FC = (props) => { handleEditorReady, isSmoothCursorEnabled, id, + isTouchDevice, initialValue, mentionHandler, onChange, + onEditorFocus, onTransaction, autofocus, placeholder, @@ -78,6 +84,7 @@ export const EditorWrapper: React.FC = (props) => { editor={editor} editorContainerClassName={editorContainerClassName} id={id} + isTouchDevice={!!isTouchDevice} > {children?.(editor)}
diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index c3aa4d414a..cfa1213c1a 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -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 = (props) => { const imageRef = useRef(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, errorMessage: string) => { @@ -188,11 +194,15 @@ export const CustomImageBlock: React.FC = (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 = (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 = (props) => { updateAttributesSafely({ alignment }, "Failed to update attributes while changing alignment:") } + height={size.height} + isTouchDevice={isTouchDevice} + width={size.width} + src={resolvedImageSrc} /> )} {selected && displayedImageSrc === resolvedImageSrc && ( diff --git a/packages/editor/src/core/extensions/custom-image/components/node-view.tsx b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx index eb38ab4054..48a8ee5f76 100644 --- a/packages/editor/src/core/extensions/custom-image/components/node-view.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx @@ -24,7 +24,7 @@ export const CustomImageNodeView: React.FC = (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(undefined); const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState(undefined); const [imageFromFileSystem, setImageFromFileSystem] = useState(undefined); @@ -43,13 +43,13 @@ export const CustomImageNodeView: React.FC = (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) { diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx index d7e4be9caa..65f42ccfd0 100644 --- a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx @@ -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) => {
- - + {!isTouchDevice && ( + + )} + {!isTouchDevice && ( + + )}
@@ -279,7 +296,10 @@ export const ImageFullScreenModal: React.FC = (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; }; diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx index 2108bfeaae..2d8e736061 100644 --- a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx @@ -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) => { - 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) => { <> - +
); diff --git a/packages/editor/src/core/extensions/custom-image/components/uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/uploader.tsx index 109ec3e305..9902c32390 100644 --- a/packages/editor/src/core/extensions/custom-image/components/uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/uploader.tsx @@ -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) => { diff --git a/packages/editor/src/core/extensions/extensions.ts b/packages/editor/src/core/extensions/extensions.ts index b02bc542ef..0332401fbe 100644 --- a/packages/editor/src/core/extensions/extensions.ts +++ b/packages/editor/src/core/extensions/extensions.ts @@ -39,7 +39,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; isSmoothCursorEnabled: boolean; @@ -52,6 +58,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { enableHistory, fileHandler, flaggedExtensions, + isTouchDevice = false, isSmoothCursorEnabled, mentionHandler, placeholder, @@ -105,6 +112,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { disabledExtensions, fileHandler, isEditable: editable, + isTouchDevice, }), ...CoreEditorAdditionalExtensions({ disabledExtensions, diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx index 48e2776120..437756c791 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx @@ -60,6 +60,7 @@ export const CommandMenuItem: React.FC = (props) => { {item.icon}

{query ? highlightMatch(item.title, query) : item.title}

+ {item.badge} ); }; diff --git a/packages/editor/src/core/extensions/utility.ts b/packages/editor/src/core/extensions/utility.ts index cc5203bd8a..c4a92965b9 100644 --- a/packages/editor/src/core/extensions/utility.ts +++ b/packages/editor/src/core/extensions/utility.ts @@ -35,15 +35,17 @@ export interface UtilityExtensionStorage { assetsUploadStatus: TFileHandler["assetsUploadStatus"]; uploadInProgress: boolean; activeDropbarExtensions: TActiveDropbarExtensions[]; + isTouchDevice: boolean; } type Props = Pick & { 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, UtilityExtensionStorage>({ @@ -76,6 +78,7 @@ export const UtilityExtension = (props: Props) => { assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {}, uploadInProgress: false, activeDropbarExtensions: [], + isTouchDevice, }; }, diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 3d511c904f..c9c25bdaf5 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -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(); }; diff --git a/packages/editor/src/core/helpers/editor-ref.ts b/packages/editor/src/core/helpers/editor-ref.ts index 86ad31c123..816d250250 100644 --- a/packages/editor/src/core/helpers/editor-ref.ts +++ b/packages/editor/src/core/helpers/editor-ref.ts @@ -24,6 +24,7 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => { const { editor, provider } = args; return { + blur: () => editor?.commands.blur(), clearEditor: (emitUpdate = false) => { editor ?.chain() @@ -32,6 +33,38 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => { .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() ?? "

"; @@ -65,7 +98,6 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => { .setContent(content, emitUpdate, { preserveWhitespace: true }) .run(); }, - blur: () => editor?.commands.blur(), emitRealTimeUpdate: (message) => provider?.sendStateless(message), executeMenuItemCommand: (props) => { const { itemKey } = props; @@ -80,7 +112,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; @@ -175,7 +214,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); @@ -207,6 +247,7 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => { if (!document) return; Y.applyUpdate(document, value); }, + undo: () => editor?.commands.undo(), editorHasSynced: () => (provider ? provider.isSynced : false), findAndDeleteNode: ({ attribute, value }: { attribute: string; value: string | string[] }, nodeName: string) => { // We use a single transaction for all deletions diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index 05d59810ac..44b7bc7da4 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -35,7 +35,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => forwardedRef, handleEditorReady, id, + dragDropEnabled = true, + isTouchDevice, mentionHandler, + onEditorFocus, placeholder, realtimeConfig, serverHandler, @@ -134,7 +137,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => // Core extensions SideMenuExtension({ aiEnabled: !disabledExtensions?.includes("ai"), - dragDropEnabled: true, + dragDropEnabled, }), HeadingListExtension, // Collaboration extension @@ -160,10 +163,12 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => flaggedExtensions, forwardedRef, handleEditorReady, + isTouchDevice, isSmoothCursorEnabled, mentionHandler, onAssetChange, onChange, + onEditorFocus, onTransaction, placeholder, provider, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index b47a4d9a76..67f6244cd7 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -27,10 +27,12 @@ export const useEditor = (props: TEditorHookProps) => { handleEditorReady, id = "", initialValue, + isTouchDevice, isSmoothCursorEnabled = false, mentionHandler, onAssetChange, onChange, + onEditorFocus, onTransaction, placeholder, provider, @@ -59,6 +61,7 @@ export const useEditor = (props: TEditorHookProps) => { enableHistory, fileHandler, flaggedExtensions, + isTouchDevice, mentionHandler, placeholder, tabIndex, @@ -72,6 +75,7 @@ export const useEditor = (props: TEditorHookProps) => { }, onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), onDestroy: () => handleEditorReady?.(false), + onFocus: onEditorFocus, }, [editable] ); diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index da00d96076..5a1402332e 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -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; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index aa3c9fb3bd..6011b63a1f 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -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 @@ -42,6 +44,7 @@ export type TEditorCommands = | "table" | "image" | "divider" + | "link" | "issue-embed" | "text-color" | "background-color" @@ -61,6 +64,10 @@ export type TCommandExtraProps = { "text-color": { color: string | undefined; }; + link: { + url: string; + text?: string; + }; "background-color": { color: string | undefined; }; @@ -87,9 +94,16 @@ export type TDocumentInfo = { export type EditorRefApi = { blur: () => void; clearEditor: (emitUpdate?: boolean) => void; - editorHasSynced: () => boolean; + createSelectionAtCursorPosition: () => void; emitRealTimeUpdate: (action: TDocumentEventsServer) => void; executeMenuItemCommand: (props: TCommandWithPropsWithItemKey) => void; + focus: (args: Parameters[0]) => void; + getAttributesWithExtendedMark: ( + mark: string | MarkType, + attribute: string | NodeType | MarkType + ) => Record | undefined; + getCoordsFromPos: (pos?: number) => ReturnType | undefined; + editorHasSynced: () => boolean; findAndDeleteNode: ( { attribute, @@ -117,13 +131,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; }; export type EditorTitleRefApi = EditorRefApi; @@ -137,6 +153,7 @@ export interface IEditorProps { disabledExtensions: TExtensions[]; editable: boolean; editorClassName?: string; + editorProps?: EditorProps; extensions?: Extensions; flaggedExtensions: TExtensions[]; fileHandler: TFileHandler; @@ -144,8 +161,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; isSmoothCursorEnabled: boolean; onEnterKeyPress?: (e?: any) => void; @@ -162,8 +181,11 @@ export type IRichTextEditorProps = IEditorProps & { }; export interface ICollaborativeDocumentEditorProps - extends Omit { + extends Omit { aiHandler?: TAIHandler; + documentLoaderClassName?: string; + dragDropEnabled?: boolean; + editable: boolean; embedHandler: TEmbedConfig; realtimeConfig: TRealtimeConfig; serverHandler?: TServerHandler; diff --git a/packages/editor/src/core/types/hook.ts b/packages/editor/src/core/types/hook.ts index 42df78e328..bebe57bd90 100644 --- a/packages/editor/src/core/types/hook.ts +++ b/packages/editor/src/core/types/hook.ts @@ -1,6 +1,5 @@ import type { HocuspocusProvider } from "@hocuspocus/provider"; import type { Content } from "@tiptap/core"; -import type { EditorProps } from "@tiptap/pm/view"; // local imports import type { EditorTitleRefApi, ICollaborativeDocumentEditorProps, IEditorProps } from "./editor"; @@ -8,13 +7,14 @@ type TCoreHookProps = Pick< IEditorProps, | "disabledExtensions" | "editorClassName" + | "editorProps" | "extensions" | "flaggedExtensions" | "handleEditorReady" + | "isTouchDevice" + | "onEditorFocus" | "isSmoothCursorEnabled" -> & { - editorProps?: EditorProps; -}; +>; export type TEditorHookProps = TCoreHookProps & Pick< @@ -51,7 +51,10 @@ export type TCollaborativeEditorHookProps = TCoreHookProps & | "placeholder" | "tabIndex" > & - Pick & { + Pick< + ICollaborativeDocumentEditorProps, + "dragDropEnabled" | "embedHandler" | "realtimeConfig" | "serverHandler" | "user" + > & { titleRef?: React.MutableRefObject; updatePageProperties?: (pageId: string, messageType: string, payload?: any, performAction?: boolean) => void; }; diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts index 5027c0309e..a8690c90fd 100644 --- a/packages/editor/src/core/types/slash-commands-suggestion.ts +++ b/packages/editor/src/core/types/slash-commands-suggestion.ts @@ -1,7 +1,6 @@ import type { Editor, Range } from "@tiptap/core"; import type { CSSProperties } from "react"; -// types -import { TEditorCommands } from "@/types"; +import type { TEditorCommands } from "@/types"; export type CommandProps = { editor: Editor; @@ -19,4 +18,5 @@ export type ISlashCommandItem = { icon: React.ReactNode; iconContainerStyle?: CSSProperties; command: ({ editor, range }: CommandProps) => void; + badge?: React.ReactNode; }; diff --git a/packages/editor/src/styles/variables.css b/packages/editor/src/styles/variables.css index f03808c1a1..c7726ce2d3 100644 --- a/packages/editor/src/styles/variables.css +++ b/packages/editor/src/styles/variables.css @@ -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 */