[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>
This commit is contained in:
M. Palanikannan 2024-03-11 20:55:24 +05:30 committed by GitHub
parent 8997ee2e3e
commit 899771a678
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 291 additions and 26 deletions

View File

@ -1,3 +1,4 @@
import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
interface EditorClassNames {
@ -18,6 +19,19 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Helper function to find the parent node of a specific type
export function findParentNodeOfType(selection: Selection, typeName: string) {
let depth = selection.$anchor.depth;
while (depth > 0) {
const node = selection.$anchor.node(depth);
if (node.type.name === typeName) {
return { node, pos: selection.$anchor.start(depth) - 1 };
}
depth--;
}
return null;
}
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;

View File

@ -1,5 +1,5 @@
import { Editor } from "@tiptap/react";
import { ReactNode } from "react";
import { FC, ReactNode } from "react";
interface EditorContainerProps {
editor: Editor | null;
@ -8,17 +8,54 @@ interface EditorContainerProps {
hideDragHandle?: () => void;
}
export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => (
<div
id="editor-container"
onClick={() => {
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
onMouseLeave={() => {
hideDragHandle?.();
}}
className={`cursor-text ${editorClassNames}`}
>
{children}
</div>
);
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { editor, editorClassNames, hideDragHandle, children } = props;
const handleContainerClick = () => {
if (!editor) return;
if (!editor.isEditable) return;
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("orderedList") ||
editor.isActive("bulletList") ||
editor.isActive("taskItem") ||
editor.isActive("table") ||
editor.isActive("blockquote") ||
editor.isActive("codeBlock")
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
) {
return;
}
// Insert a new paragraph at the end of the document
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
};
return (
<div
id="editor-container"
onClick={handleContainerClick}
onMouseLeave={() => {
hideDragHandle?.();
}}
className={`cursor-text ${editorClassNames}`}
>
{children}
</div>
);
};

View File

@ -5,6 +5,8 @@ import ImageExt from "@tiptap/extension-image";
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image";
import { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image";
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
interface ImageNode extends ProseMirrorNode {
attrs: {
@ -18,6 +20,12 @@ const IMAGE_NODE_TYPE = "image";
export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) =>
ImageExt.extend({
addKeyboardShortcuts() {
return {
ArrowDown: insertLineBelowImageAction,
ArrowUp: insertLineAboveImageAction,
};
},
addProseMirrorPlugins() {
return [
UploadImagesPlugin(cancelUploadImage),

View File

@ -0,0 +1,45 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { KeyboardShortcutCommand } from "@tiptap/core";
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
const { selection, doc } = editor.state;
const { $from, $to } = selection;
let imageNode: ProseMirrorNode | null = null;
let imagePos: number | null = null;
// Check if the selection itself is an image node
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type.name === "image") {
imageNode = node;
imagePos = pos;
return false; // Stop iterating once an image node is found
}
return true;
});
if (imageNode === null || imagePos === null) return false;
// Since we want to insert above the image, we use the imagePos directly
const insertPos = imagePos;
if (insertPos < 0) return false;
// Check for an existing node immediately before the image
if (insertPos === 0) {
// If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there
editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run();
editor.chain().setTextSelection(insertPos).run();
} else {
const prevNode = doc.nodeAt(insertPos);
if (prevNode && prevNode.type.name === "paragraph") {
// If the previous node is a paragraph, move the cursor there
editor.chain().setTextSelection(insertPos).run();
} else {
return false;
}
}
return true;
};

View File

@ -0,0 +1,46 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { KeyboardShortcutCommand } from "@tiptap/core";
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
const { selection, doc } = editor.state;
const { $from, $to } = selection;
let imageNode: ProseMirrorNode | null = null;
let imagePos: number | null = null;
// Check if the selection itself is an image node
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type.name === "image") {
imageNode = node;
imagePos = pos;
return false; // Stop iterating once an image node is found
}
return true;
});
if (imageNode === null || imagePos === null) return false;
const guaranteedImageNode: ProseMirrorNode = imageNode;
const nextNodePos = imagePos + guaranteedImageNode.nodeSize;
// Check for an existing node immediately after the image
const nextNode = doc.nodeAt(nextNodePos);
if (nextNode && nextNode.type.name === "paragraph") {
// If the next node is a paragraph, move the cursor there
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else if (!nextNode) {
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(nextNodePos + 1)
.run();
} else {
// If the next node is not a paragraph, do not proceed
return false;
}
return true;
};

View File

@ -25,6 +25,8 @@ import { tableControls } from "src/ui/extensions/table/table/table-controls";
import { TableView } from "src/ui/extensions/table/table/table-view";
import { createTable } from "src/ui/extensions/table/table/utilities/create-table";
import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected";
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
export interface TableOptions {
HTMLAttributes: Record<string, any>;
@ -231,6 +233,8 @@ export const Table = Node.create({
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected,
ArrowDown: insertLineBelowTableAction,
ArrowUp: insertLineAboveTableAction,
};
},

View File

@ -0,0 +1,50 @@
import { KeyboardShortcutCommand } from "@tiptap/core";
import { findParentNodeOfType } from "src/lib/utils";
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
// Check if the current selection or the closest node is a table
if (!editor.isActive("table")) return false;
// Get the current selection
const { selection } = editor.state;
// Find the table node and its position
const tableNode = findParentNodeOfType(selection, "table");
if (!tableNode) return false;
const tablePos = tableNode.pos;
// Determine if the selection is in the first row of the table
const firstRow = tableNode.node.child(0);
const selectionPath = (selection.$anchor as any).path;
const selectionInFirstRow = selectionPath.includes(firstRow);
if (!selectionInFirstRow) return false;
// Check if the table is at the very start of the document or its parent node
if (tablePos === 0) {
// The table is at the start, so just insert a paragraph at the current position
editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(tablePos + 1)
.run();
} else {
// The table is not at the start, check for the node immediately before the table
const prevNodePos = tablePos - 1;
if (prevNodePos <= 0) return false;
const prevNode = editor.state.doc.nodeAt(prevNodePos - 1);
if (prevNode && prevNode.type.name === "paragraph") {
// If there's a paragraph before the table, move the cursor to the end of that paragraph
const endOfParagraphPos = tablePos - prevNode.nodeSize;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else {
return false;
}
}
return true;
};

View File

@ -0,0 +1,48 @@
import { KeyboardShortcutCommand } from "@tiptap/core";
import { findParentNodeOfType } from "src/lib/utils";
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
// Check if the current selection or the closest node is a table
if (!editor.isActive("table")) return false;
// Get the current selection
const { selection } = editor.state;
// Find the table node and its position
const tableNode = findParentNodeOfType(selection, "table");
if (!tableNode) return false;
const tablePos = tableNode.pos;
const table = tableNode.node;
// Determine if the selection is in the last row of the table
const rowCount = table.childCount;
const lastRow = table.child(rowCount - 1);
const selectionPath = (selection.$anchor as any).path;
const selectionInLastRow = selectionPath.includes(lastRow);
if (!selectionInLastRow) return false;
// Calculate the position immediately after the table
const nextNodePos = tablePos + table.nodeSize;
// Check for an existing node immediately after the table
const nextNode = editor.state.doc.nodeAt(nextNodePos);
if (nextNode && nextNode.type.name === "paragraph") {
// If the next node is an paragraph, move the cursor there
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else if (!nextNode) {
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(nextNodePos + 1)
.run();
} else {
return false;
}
return true;
};

View File

@ -152,7 +152,7 @@ export const PageRenderer = (props: IPageRenderer) => {
);
return (
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer">
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}

View File

@ -100,10 +100,10 @@ const RichTextEditor = ({
customClassName,
});
React.useEffect(() => {
if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
}, [editor, initialValue]);
// React.useEffect(() => {
// if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
// }, [editor, initialValue]);
//
if (!editor) return null;
return (

View File

@ -121,7 +121,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
<button
key={item.name}
type="button"
onClick={item.command}
onClick={(e) => {
item.command();
e.stopPropagation();
}}
className={cn(
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
{

View File

@ -33,8 +33,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
{ "bg-custom-background-100": isOpen }
)}
onClick={() => {
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
>
<p className="text-base"></p>
@ -60,6 +61,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
ref={inputRef}
type="url"
placeholder="Paste a link"
onClick={(e) => {
e.stopPropagation();
}}
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400"
defaultValue={editor.getAttributes("link").href || ""}
/>
@ -67,9 +71,10 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
<button
type="button"
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
onClick={(e) => {
unsetLinkEditor(editor);
setIsOpen(false);
e.stopPropagation();
}}
>
<Trash className="h-4 w-4" />
@ -78,7 +83,8 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
<button
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
type="button"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
onLinkSubmit();
}}
>

View File

@ -47,7 +47,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
<div className="relative h-full">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
>
<span>{activeItem?.name}</span>
@ -60,9 +63,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
<button
key={item.name}
type="button"
onClick={() => {
onClick={(e) => {
item.command();
setIsOpen(false);
e.stopPropagation();
}}
className={cn(
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",