forked from github/plane
[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:
parent
8997ee2e3e
commit
899771a678
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
@ -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)}
|
||||
|
@ -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 (
|
||||
|
@ -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",
|
||||
{
|
||||
|
@ -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();
|
||||
}}
|
||||
>
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user