fix: Task List Behaviour in Editor (#2789)

* better variable names and comments

* drag drop migrated

* custom horizontal rule created

* init transaction hijack

* fixed code block with better contrast, keyboard tripple enter press disabled and syntax highlighting

* fixed link selector closing on open behaviour

* added better keymaps and syntax highlights

* made drag and drop working for code blocks

* fixed drag drop for code blocks

* moved drag drop only to rich text editor

* fixed drag and drop only for description

* enabled drag handles for peek overview and main issues

* got images to old state

* fixed task lists to be smaller

* removed validate image functions and uncessary imports

* table icons svg attributes fixed

* custom list keymap extension added

* more uncessary imports of validate image removed

* removed console logs

* fixed drag-handle styles

* space styles updated for the editor

* removed showing quotes from blockquotes

* removed validateImage for now

* added better comments and improved redundant renders

* removed uncessary console logs

* created util for creating the drag handle element

* fixed file names
This commit is contained in:
M. Palanikannan
2023-11-18 16:20:35 +05:30
committed by sriram veeraghanta
parent 3d8da99eec
commit 2cca0b1e76
24 changed files with 508 additions and 88 deletions

View File

@@ -31,11 +31,10 @@
"@blueprintjs/popover2": "^2.0.10",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-code-block-lowlight": "^2.1.12",
"highlight.js": "^11.8.0",
"lowlight": "^3.0.0",
"@tiptap/extension-color": "^2.1.11",
"@tiptap/extension-image": "^2.1.7",
"@tiptap/extension-link": "^2.1.7",
"@tiptap/extension-list-item": "^2.1.12",
"@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-table": "^2.1.6",
"@tiptap/extension-table-cell": "^2.1.6",
@@ -58,7 +57,9 @@
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.244.0",
"prosemirror-async-query": "^0.0.4",
"react-markdown": "^8.0.7",

View File

@@ -1 +0,0 @@
export type ValidateImage = (assetUrlWithWorkspaceId: string) => Promise<any>;

View File

@@ -0,0 +1 @@
export * from "./list-keymap";

View File

@@ -0,0 +1,30 @@
import { getNodeType } from '@tiptap/core'
import { NodeType } from '@tiptap/pm/model'
import { EditorState } from '@tiptap/pm/state'
export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection
const nodeType = getNodeType(typeOrName, state.schema)
let currentNode = null
let currentDepth = $from.depth
let currentPos = $from.pos
let targetDepth: number | null = null
while (currentDepth > 0 && targetDepth === null) {
currentNode = $from.node(currentDepth)
if (currentNode.type === nodeType) {
targetDepth = currentDepth
} else {
currentDepth -= 1
currentPos -= 1
}
}
if (targetDepth === null) {
return null
}
return { $pos: state.doc.resolve(currentPos), depth: targetDepth }
}

View File

@@ -0,0 +1,20 @@
import { getNodeAtPosition } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "./find-list-item-pos";
export const getNextListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
const [, depth] = getNodeAtPosition(
state,
typeOrName,
listItemPos.$pos.pos + 4,
);
return depth;
};

View File

@@ -0,0 +1,78 @@
import { Editor, isAtStartOfNode, isNodeActive } from "@tiptap/core";
import { Node } from "@tiptap/pm/model";
import { findListItemPos } from "./find-list-item-pos";
import { hasListBefore } from "./has-list-before";
export const handleBackspace = (
editor: Editor,
name: string,
parentListTypes: string[],
) => {
// this is required to still handle the undo handling
if (editor.commands.undoInputRule()) {
return true;
}
// if the cursor is not at the start of a node
// do nothing and proceed
if (!isAtStartOfNode(editor.state)) {
return false;
}
// if the current item is NOT inside a list item &
// the previous item is a list (orderedList or bulletList)
// move the cursor into the list and delete the current item
if (
!isNodeActive(editor.state, name) &&
hasListBefore(editor.state, name, parentListTypes)
) {
const { $anchor } = editor.state.selection;
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
const listDescendants: Array<{ node: Node; pos: number }> = [];
$listPos.node().descendants((node, pos) => {
if (node.type.name === name) {
listDescendants.push({ node, pos });
}
});
const lastItem = listDescendants.at(-1);
if (!lastItem) {
return false;
}
const $lastItemPos = editor.state.doc.resolve(
$listPos.start() + lastItem.pos + 1,
);
return editor
.chain()
.cut(
{ from: $anchor.start() - 1, to: $anchor.end() + 1 },
$lastItemPos.end(),
)
.joinForward()
.run();
}
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
const listItemPos = findListItemPos(name, editor.state);
if (!listItemPos) {
return false;
}
// if current node is a list item and cursor it at start of a list node,
// simply lift the list item i.e. remove it as a list item (task/bullet/ordered)
// irrespective of above node being a list or not
return editor.chain().liftListItem(name).run();
};

View File

@@ -0,0 +1,34 @@
import { Editor, isAtEndOfNode, isNodeActive } from "@tiptap/core";
import { nextListIsDeeper } from "./next-list-is-deeper";
import { nextListIsHigher } from "./next-list-is-higher";
export const handleDelete = (editor: Editor, name: string) => {
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
// if the cursor is not at the end of a node
// do nothing and proceed
if (!isAtEndOfNode(editor.state, name)) {
return false;
}
// check if the next node is a list with a deeper depth
if (nextListIsDeeper(name, editor.state)) {
return editor
.chain()
.focus(editor.state.selection.from + 4)
.lift(name)
.joinBackward()
.run();
}
if (nextListIsHigher(name, editor.state)) {
return editor.chain().joinForward().joinBackward().run();
}
return editor.commands.joinItemForward();
};

View File

@@ -0,0 +1,15 @@
import { EditorState } from '@tiptap/pm/state'
export const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
const { $anchor } = editorState.selection
const previousNodePos = Math.max(0, $anchor.pos - 2)
const previousNode = editorState.doc.resolve(previousNodePos).node()
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
return false
}
return true
}

View File

@@ -0,0 +1,17 @@
import { EditorState } from '@tiptap/pm/state'
export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection
const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2)
if ($targetPos.index() === $targetPos.parent.childCount - 1) {
return false
}
if ($targetPos.nodeAfter?.type.name !== typeOrName) {
return false
}
return true
}

View File

@@ -0,0 +1,17 @@
import { EditorState } from '@tiptap/pm/state'
export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection
const $targetPos = state.doc.resolve($anchor.pos - 2)
if ($targetPos.index() === 0) {
return false
}
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
return false
}
return true
}

View File

@@ -0,0 +1,9 @@
export * from "./find-list-item-pos";
export * from "./get-next-list-depth";
export * from "./handle-backspace";
export * from "./handle-delete";
export * from "./has-list-before";
export * from "./has-list-item-after";
export * from "./has-list-item-before";
export * from "./next-list-is-deeper";
export * from "./next-list-is-higher";

View File

@@ -0,0 +1,19 @@
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "./find-list-item-pos";
import { getNextListDepth } from "./get-next-list-depth";
export const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth > listItemPos.depth) {
return true;
}
return false;
};

View File

@@ -0,0 +1,19 @@
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "./find-list-item-pos";
import { getNextListDepth } from "./get-next-list-depth";
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};

View File

@@ -0,0 +1,94 @@
import { Extension } from "@tiptap/core";
import { handleBackspace, handleDelete } from "./list-helpers";
export type ListKeymapOptions = {
listTypes: Array<{
itemName: string;
wrapperNames: string[];
}>;
};
export const ListKeymap = Extension.create<ListKeymapOptions>({
name: "listKeymap",
addOptions() {
return {
listTypes: [
{
itemName: "listItem",
wrapperNames: ["bulletList", "orderedList"],
},
{
itemName: "taskItem",
wrapperNames: ["taskList"],
},
],
};
},
addKeyboardShortcuts() {
return {
Delete: ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleDelete(editor, itemName)) {
handled = true;
}
});
return handled;
},
"Mod-Delete": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleDelete(editor, itemName)) {
handled = true;
}
});
return handled;
},
Backspace: ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
},
"Mod-Backspace": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
},
};
},
});

View File

@@ -11,7 +11,6 @@ import TableHeader from "./table/table-header/table-header";
import Table from "./table/table";
import TableCell from "./table/table-cell/table-cell";
import TableRow from "./table/table-row/table-row";
import DragDrop from "./drag-drop";
import HorizontalRule from "./horizontal-rule";
import ImageExtension from "./image";
@@ -20,10 +19,10 @@ import { DeleteImage } from "../../types/delete-image";
import { isValidHttpUrl } from "../../lib/utils";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { Mentions } from "../mentions";
import { ValidateImage } from "../../types/validate-image";
import { CustomKeymap } from "./keymap";
import { CustomCodeBlock } from "./code";
import { ListKeymap } from "./custom-list-keymap";
export const CoreEditorExtensions = (
mentionConfig: {
@@ -31,7 +30,6 @@ export const CoreEditorExtensions = (
mentionHighlights: string[];
},
deleteFile: DeleteImage,
validateFile?: ValidateImage,
cancelUploadImage?: () => any,
) => [
StarterKit.configure({
@@ -64,6 +62,7 @@ export const CoreEditorExtensions = (
},
}),
CustomKeymap,
ListKeymap,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
@@ -72,7 +71,7 @@ export const CoreEditorExtensions = (
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtension(deleteFile, validateFile, cancelUploadImage).configure({
ImageExtension(deleteFile, cancelUploadImage).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},

View File

@@ -1,11 +1,10 @@
const icons = {
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
insertLeftTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
length={24}
viewBox="0 -960 960 960"
>
<path
@@ -16,8 +15,7 @@ const icons = {
`,
insertRightTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
length={24}
viewBox="0 -960 960 960"
>
<path
@@ -28,8 +26,7 @@ const icons = {
`,
insertTopTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
length={24}
viewBox="0 -960 960 960"
>
<path
@@ -40,8 +37,7 @@ const icons = {
`,
insertBottomTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
length={24}
viewBox="0 -960 960 960"
>
<path

View File

@@ -1,12 +1,6 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import {
useImperativeHandle,
useRef,
MutableRefObject,
useEffect,
} from "react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { DeleteImage } from "../../types/delete-image";
import { ValidateImage } from "../../types/validate-image";
import { CoreEditorProps } from "../props";
import { CoreEditorExtensions } from "../extensions";
import { EditorProps } from "@tiptap/pm/view";
@@ -17,7 +11,6 @@ import { IMentionSuggestion } from "../../types/mention-suggestion";
interface CustomEditorProps {
uploadFile: UploadImage;
validateFile?: ValidateImage;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
@@ -37,7 +30,6 @@ interface CustomEditorProps {
export const useEditor = ({
uploadFile,
deleteFile,
validateFile,
cancelUploadImage,
editorProps = {},
value,
@@ -62,7 +54,6 @@ export const useEditor = ({
mentionHighlights: mentionHighlights ?? [],
},
deleteFile,
validateFile,
cancelUploadImage,
),
...extensions,