Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
186 lines
6.1 KiB
TypeScript
186 lines
6.1 KiB
TypeScript
/**
|
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
* See the LICENSE file for details.
|
|
*/
|
|
|
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
|
import type { Editor } from "@tiptap/react";
|
|
import type { ReactNode } from "react";
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
// plane utils
|
|
import { cn } from "@plane/utils";
|
|
// constants
|
|
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
|
// components
|
|
import type { TCollabValue } from "@/contexts";
|
|
import { LinkContainer } from "@/components/link-container";
|
|
// plugins
|
|
import { nodeHighlightPluginKey } from "@/plugins/highlight";
|
|
// types
|
|
import type { TDisplayConfig } from "@/types";
|
|
|
|
type Props = {
|
|
children: ReactNode;
|
|
displayConfig: TDisplayConfig;
|
|
editor: Editor;
|
|
editorContainerClassName: string;
|
|
id: string;
|
|
isTouchDevice: boolean;
|
|
provider?: HocuspocusProvider | undefined;
|
|
state?: TCollabValue["state"];
|
|
};
|
|
|
|
export function EditorContainer(props: Props) {
|
|
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice, provider, state } = props;
|
|
// refs
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const hasScrolledOnce = useRef(false);
|
|
const scrollToNode = useCallback(
|
|
(nodeId: string) => {
|
|
if (!editor) return false;
|
|
|
|
const doc = editor.state.doc;
|
|
let pos: number | null = null;
|
|
|
|
doc.descendants((node, position) => {
|
|
if (node.attrs && node.attrs.id === nodeId) {
|
|
pos = position;
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (pos === null) {
|
|
return false;
|
|
}
|
|
|
|
const nodePosition = pos;
|
|
const tr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId });
|
|
editor.view.dispatch(tr);
|
|
|
|
requestAnimationFrame(() => {
|
|
const domNode = editor.view.nodeDOM(nodePosition);
|
|
if (domNode instanceof HTMLElement) {
|
|
domNode.scrollIntoView({ behavior: "instant", block: "center" });
|
|
}
|
|
});
|
|
|
|
editor.once("focus", () => {
|
|
const clearTr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId: null });
|
|
editor.view.dispatch(clearTr);
|
|
});
|
|
|
|
hasScrolledOnce.current = true;
|
|
return true;
|
|
},
|
|
|
|
[editor]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const nodeId = window.location.href.split("#")[1];
|
|
|
|
const handleSynced = () => scrollToNode(nodeId);
|
|
|
|
if (nodeId && !hasScrolledOnce.current) {
|
|
if (provider && state) {
|
|
const { hasCachedContent } = state;
|
|
// If the provider is synced or the cached content is available and the server is disconnected, scroll to the node
|
|
if (hasCachedContent) {
|
|
const hasScrolled = handleSynced();
|
|
if (!hasScrolled) {
|
|
provider.on("synced", handleSynced);
|
|
}
|
|
} else if (provider.isSynced) {
|
|
handleSynced();
|
|
} else {
|
|
provider.on("synced", handleSynced);
|
|
}
|
|
} else {
|
|
handleSynced();
|
|
}
|
|
return () => {
|
|
if (provider) {
|
|
provider.off("synced", handleSynced);
|
|
}
|
|
};
|
|
}
|
|
}, [scrollToNode, provider, state]);
|
|
|
|
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
|
if (event.target !== event.currentTarget) return;
|
|
if (!editor) return;
|
|
if (!editor.isEditable) return;
|
|
try {
|
|
if (editor.isFocused) return; // If editor is already focused, do nothing
|
|
|
|
const { selection } = editor.state;
|
|
const currentNode = selection.$from.node();
|
|
|
|
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
|
|
|
|
if (
|
|
currentNode.content.size === 0 && // Check if the current node is empty
|
|
!(
|
|
editor.isActive(CORE_EXTENSIONS.ORDERED_LIST) ||
|
|
editor.isActive(CORE_EXTENSIONS.BULLET_LIST) ||
|
|
editor.isActive(CORE_EXTENSIONS.TASK_ITEM) ||
|
|
editor.isActive(CORE_EXTENSIONS.TABLE) ||
|
|
editor.isActive(CORE_EXTENSIONS.BLOCKQUOTE) ||
|
|
editor.isActive(CORE_EXTENSIONS.CODE_BLOCK)
|
|
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// 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 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();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
|
|
}
|
|
};
|
|
|
|
const handleContainerMouseLeave = () => {
|
|
const dragHandleElement = document.querySelector("#editor-side-menu");
|
|
if (!dragHandleElement?.classList.contains("side-menu-hidden")) {
|
|
dragHandleElement?.classList.add("side-menu-hidden");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={containerRef}
|
|
id={`editor-container-${id}`}
|
|
onClick={handleContainerClick}
|
|
onMouseLeave={handleContainerMouseLeave}
|
|
className={cn(
|
|
`editor-container relative cursor-text line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
|
|
{
|
|
"active-editor": editor?.isFocused && editor?.isEditable,
|
|
},
|
|
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
|
|
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
|
|
editorContainerClassName
|
|
)}
|
|
>
|
|
{children}
|
|
{!isTouchDevice && <LinkContainer editor={editor} containerRef={containerRef} />}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|