plane/packages/editor/document-editor/src/ui/components/page-renderer.tsx
M. Palanikannan 899771a678
[WEB-434] feat: add support to insert a new empty line on clicking at bottom of the editor (#3856)
* fix: horizontal rule no more causes issues on last node

* fixed the mismatched transaction by using native tiptap stuff

* added support to add new line onclick at bottom if last node is an image

TODO: blockquote at last node

* fix: simplified adding node at end of the document logic

* feat: rewrite entire logic handling all cases

* feat: arrow down and arrow up keys add empty node at top and bottom of doc from first/last row's cells

* feat: added arrow up and down key support to images too

* remove unnecessary console logs

* chore: formatting components

* fix: reduced bottom padding to increase onclick area

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-03-11 20:55:24 +05:30

187 lines
5.4 KiB
TypeScript

import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Editor, ReactRenderer } from "@tiptap/react";
import { useCallback, useRef, useState } from "react";
import { DocumentDetails } from "src/types/editor-types";
import { LinkView, LinkViewProps } from "./links/link-view";
import {
autoUpdate,
computePosition,
flip,
hide,
shift,
useDismiss,
useFloating,
useInteractions,
} from "@floating-ui/react";
type IPageRenderer = {
documentDetails: DocumentDetails;
updatePageTitle: (title: string) => void;
editor: Editor;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
editorClassNames: string;
editorContentCustomClassNames?: string;
hideDragHandle?: () => void;
readonly: boolean;
};
export const PageRenderer = (props: IPageRenderer) => {
const {
documentDetails,
editor,
editorClassNames,
editorContentCustomClassNames,
updatePageTitle,
readonly,
hideDragHandle,
} = props;
const [pageTitle, setPagetitle] = useState(documentDetails.title);
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })],
whileElementsMounted: autoUpdate,
});
const dismiss = useDismiss(context, {
ancestorScroll: true,
});
const { getFloatingProps } = useInteractions([dismiss]);
const handlePageTitleChange = (title: string) => {
setPagetitle(title);
updatePageTitle(title);
};
const [cleanup, setcleanup] = useState(() => () => {});
const floatingElementRef = useRef<HTMLElement | null>(null);
const closeLinkView = () => {
setIsOpen(false);
};
const handleLinkHover = useCallback(
(event: React.MouseEvent) => {
if (!editor) return;
const target = event.target as HTMLElement;
const view = editor.view as EditorView;
if (!target || !view) return;
const pos = view.posAtDOM(target, 0);
if (!pos || pos < 0) return;
if (target.nodeName !== "A") return;
const node = view.state.doc.nodeAt(pos) as Node;
if (!node || !node.isAtom) return;
// we need to check if any of the marks are links
const marks = node.marks;
if (!marks) return;
const linkMark = marks.find((mark) => mark.type.name === "link");
if (!linkMark) return;
if (floatingElementRef.current) {
floatingElementRef.current?.remove();
}
if (cleanup) cleanup();
const href = linkMark.attrs.href;
const componentLink = new ReactRenderer(LinkView, {
props: {
view: "LinkPreview",
url: href,
editor: editor,
from: pos,
to: pos + node.nodeSize,
},
editor,
});
const referenceElement = target as HTMLElement;
const floatingElement = componentLink.element as HTMLElement;
floatingElementRef.current = floatingElement;
const cleanupFunc = autoUpdate(referenceElement, floatingElement, () => {
computePosition(referenceElement, floatingElement, {
placement: "bottom",
middleware: [
flip(),
shift(),
hide({
strategy: "referenceHidden",
}),
],
}).then(({ x, y }) => {
setCoordinates({ x: x - 300, y: y - 50 });
setIsOpen(true);
setLinkViewProps({
onActionCompleteHandler: props.onActionCompleteHandler,
closeLinkView: closeLinkView,
view: "LinkPreview",
url: href,
editor: editor,
from: pos,
to: pos + node.nodeSize,
});
});
});
setcleanup(cleanupFunc);
},
[editor, cleanup]
);
return (
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
className="-mt-2 w-full break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none"
value={pageTitle}
/>
) : (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
className="-mt-2 w-full overflow-x-clip break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none"
value={pageTitle}
disabled
/>
)}
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
<div
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
className={`absolute`}
ref={refs.setFloating}
>
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
</div>
)}
</div>
);
};