Skip to content

Commit

Permalink
[WEB-434] feat: add support to insert a new empty line on clicking at…
Browse files Browse the repository at this point in the history
… 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>
  • Loading branch information
Palanikannan1437 and sriramveeraghanta authored Mar 11, 2024
1 parent 8997ee2 commit 899771a
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 26 deletions.
14 changes: 14 additions & 0 deletions packages/editor/core/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
interface EditorClassNames {
Expand All @@ -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;
Expand Down
67 changes: 52 additions & 15 deletions packages/editor/core/src/ui/components/editor-container.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Editor } from "@tiptap/react";
import { ReactNode } from "react";
import { FC, ReactNode } from "react";

interface EditorContainerProps {
editor: Editor | null;
Expand All @@ -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>
);
};
8 changes: 8 additions & 0 deletions packages/editor/core/src/ui/extensions/image/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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;
};
4 changes: 4 additions & 0 deletions packages/editor/core/src/ui/extensions/table/table/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -231,6 +233,8 @@ export const Table = Node.create({
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected,
ArrowDown: insertLineBelowTableAction,
ArrowUp: insertLineAboveTableAction,
};
},

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
8 changes: 4 additions & 4 deletions packages/editor/rich-text-editor/src/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
Loading

0 comments on commit 899771a

Please sign in to comment.