Compare commits

...

33 Commits

Author SHA1 Message Date
Palanikannan1437 aae4d34eb0 chore: remove debug statements 2024-04-01 12:11:04 +05:30
Palanikannan1437 fe14ea3577 fix: working on multiple nested lists 2024-04-01 11:01:46 +05:30
Palanikannan1437 b34a7c3dbc working 2024-03-28 19:23:51 +05:30
Palanikannan1437 60e38ebce9 task list item fixed 2024-03-28 16:30:02 +05:30
Palanikannan1437 8e6429f697 Merge branch 'fix/clear-before-chaining-commands' into chore/page-transactions 2024-03-28 16:11:36 +05:30
Palanikannan1437 48254977a0 fixed drag handles stuff 2024-03-28 16:11:18 +05:30
Palanikannan1437 c5814acb09 Merge branch 'fix/drag-handles' into chore/page-transactions 2024-03-28 12:27:55 +05:30
Palanikannan1437 94e83ab946 working lists somewhat 2024-03-28 12:14:57 +05:30
Palanikannan1437 35fe2987ff Merge branch 'fix/image-refocus-pages' into chore/page-transactions 2024-03-28 12:11:36 +05:30
Palanikannan1437 eae2dc9b33 fix: image refocus from external parts 2024-03-28 12:10:36 +05:30
Palanikannan1437 a33106862b Merge branch 'fix/inline-code-blocks-fake-cursor' into chore/page-transactions 2024-03-27 17:08:03 +05:30
Palanikannan1437 51af9e4d12 Merge branch 'feat/list-autojoins' into chore/page-transactions 2024-03-27 17:07:49 +05:30
Palanikannan1437 4b56cc5565 feat: list autojoining added 2024-03-27 17:07:05 +05:30
Palanikannan1437 8279e15dd8 feat: added better seperation in inline code blocks 2024-03-27 17:05:27 +05:30
Palanikannan1437 625205f972 fix: mentions and slash commands now work well with multiple editors in one place 2024-03-27 11:28:53 +05:30
Palanikannan1437 48f913fbe6 feat: added loading indicator logic in mentions 2024-03-26 16:32:07 +05:30
Palanikannan1437 2f4fc63858 Merge branch 'develop' into chore/page-transactions 2024-03-26 15:27:53 +05:30
Palanikannan1437 adfa9c1b1a fix: hiding link selector in the bubble menu if inline code block is selected 2024-03-15 11:25:05 +05:30
Palanikannan1437 f7700b4536 chore: clearNodes after delete in case of selections being present 2024-03-15 11:12:42 +05:30
Palanikannan1437 ec70d68399 fix: clearing nodes to default node i.e paragraph before converting it to other types of nodes
For more reference on what this does, please refer https://tiptap.dev/docs/editor/api/commands/clear-nodes
2024-03-15 10:45:32 +05:30
Palanikannan1437 543168a756 chore: fix imports, ts errors and other things 2024-03-14 13:17:51 +05:30
Palanikannan1437 8b297b2bda fix: juggling back and forth of drag handles in ordered and unordered lists 2024-03-14 13:16:59 +05:30
Palanikannan1437 52b2fbefe1 fix: aligning the drag handles better with the node post css changes of the length 2024-03-14 13:15:38 +05:30
Palanikannan1437 15be7eeda3 fix: hide/cleanup drag handles post drag start 2024-03-14 13:14:44 +05:30
Palanikannan1437 252c8a30bc fix: cleanup/hide drag handles post drop 2024-03-14 13:13:29 +05:30
Palanikannan1437 13ba7d59e0 fix: slash commands are no more shown in the code blocks 2024-03-14 13:10:27 +05:30
Palanikannan1437 768d141729 fix: improved colors and drag handle width 2024-03-14 13:08:24 +05:30
Palanikannan1437 5e703f6601 removed unused types 2024-03-04 14:36:09 +05:30
Palanikannan1437 8bd7737d7b Merge branch 'develop' into chore/page-transactions 2024-03-04 14:23:56 +05:30
Palanikannan1437 26646d512e removed comments and cleaned up types 2024-03-04 14:14:54 +05:30
Palanikannan1437 43fd94827f fixed mentions not showing in modals 2024-03-04 13:20:44 +05:30
Palanikannan1437 a3033203af mentions loading state part done 2024-02-27 16:16:20 +05:30
NarayanBavisetti 875eb054a2 fix: stroing the transactions in page 2024-02-13 13:17:41 +05:30
56 changed files with 1214 additions and 241 deletions
+15
View File
@@ -28,6 +28,8 @@ from plane.db.models import (
# Module imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
def unarchive_archive_page_and_descendants(page_id, archived_at):
# Your SQL query
@@ -92,6 +94,8 @@ class PageViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -128,6 +132,17 @@ class PageViewSet(BaseViewSet):
serializer = PageSerializer(page, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=json.dumps(
{
"description_html": page.description_html,
}
),
page_id=pk,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
@@ -0,0 +1,75 @@
# Python imports
import json
# Django imports
from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# Module imports
from plane.db.models import Page, PageLog
from celery import shared_task
def extract_components(value, tag):
try:
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
for mention_tag in mention_tags:
mention = {
"id": mention_tag.get("id"),
"entity_identifier": mention_tag.get("entity_identifier"),
"entity_name": mention_tag.get("entity_name"),
}
mentions.append(mention)
return mentions
except Exception as e:
return []
@shared_task
def page_transaction(new_value, old_value, page_id):
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
old_value = json.loads(old_value)
new_transactions = []
deleted_transaction_ids = set()
components = ["mention-component", "issue-embed-component", "img"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
project_id=page.project_id,
created_at=timezone.now(),
updated_at=timezone.now(),
)
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
)
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
# Delete the removed transactions
PageLog.objects.filter(
transaction__in=deleted_transaction_ids
).delete()
+1 -1
View File
@@ -81,7 +81,7 @@ class PageLog(ProjectBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.page.name} {self.type}"
return f"{self.page.name} {self.entity_name}"
class PageBlock(ProjectBaseModel):
+1
View File
@@ -50,6 +50,7 @@
"linkifyjs": "^4.1.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.294.0",
"prosemirror-codemark": "^0.4.2",
"react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
@@ -5,7 +5,7 @@ import { CoreEditorExtensions } from "src/ui/extensions";
import { EditorProps } from "@tiptap/pm/view";
import { getTrimmedHTML } from "src/lib/utils";
import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image";
import { UploadImage } from "src/types/upload-image";
import { Selection } from "@tiptap/pm/state";
@@ -29,8 +29,8 @@ interface CustomEditorProps {
extensions?: any;
editorProps?: EditorProps;
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
mentionHighlights?: () => Promise<IMentionHighlight[]>;
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
}
export const useEditor = ({
@@ -59,8 +59,8 @@ export const useEditor = ({
extensions: [
...CoreEditorExtensions(
{
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
mentionSuggestions: mentionSuggestions,
mentionHighlights: mentionHighlights,
},
deleteFile,
restoreFile,
@@ -76,7 +76,7 @@ export const useEditor = ({
setSavedSelection(editor.state.selection);
},
onUpdate: async ({ editor }) => {
setIsSubmitting?.("submitting");
// setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
@@ -107,5 +107,5 @@ export const useEditor = ({
return null;
}
return editor;
return { editor, savedSelection };
};
@@ -3,7 +3,7 @@ import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "src/ui/read-only/props";
import { EditorProps } from "@tiptap/pm/view";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
interface CustomReadOnlyEditorProps {
value: string;
@@ -14,8 +14,7 @@ interface CustomReadOnlyEditorProps {
id: string;
description_html: string;
};
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
mentionHighlights?: () => Promise<IMentionHighlight[]>;
}
export const useReadOnlyEditor = ({
@@ -25,7 +24,6 @@ export const useReadOnlyEditor = ({
editorProps = {},
rerenderOnPropsChange,
mentionHighlights,
mentionSuggestions,
}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor(
{
@@ -37,8 +35,7 @@ export const useReadOnlyEditor = ({
},
extensions: [
...CoreReadOnlyEditorExtensions({
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
mentionHighlights: mentionHighlights,
}),
...extensions,
],
@@ -2,6 +2,7 @@ import { Editor, Range } from "@tiptap/core";
import { startImageUpload } from "src/ui/plugins/upload-image";
import { findTableAncestor } from "src/lib/utils";
import { UploadImage } from "src/types/upload-image";
import { Selection } from "@tiptap/pm/state";
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 1 }).run();
@@ -113,6 +114,7 @@ export const insertImageCommand = (
editor: Editor,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
savedSelection?: Selection | null,
range?: Range
) => {
if (range) editor.chain().focus().deleteRange(range).run();
@@ -122,7 +124,7 @@ export const insertImageCommand = (
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting);
}
};
@@ -42,6 +42,9 @@
}
}
/* Custom list item styles */
/* Custom gap cursor styles */
.ProseMirror-gapcursor:after {
border-top: 1px solid rgb(var(--color-text-100)) !important;
}
@@ -214,3 +217,31 @@ div[data-type="horizontalRule"] {
.moveable-control-box {
z-index: 10 !important;
}
/* Cursor styles for the inline code blocks */
@keyframes blink {
49% {
border-color: unset;
}
50% {
border-color: #fff;
}
99% {
border-color: #fff;
}
}
.no-cursor {
caret-color: transparent;
}
div:focus .fake-cursor,
span:focus .fake-cursor {
margin-right: -1px;
border-left-width: 1.5px;
border-left-style: solid;
animation: blink 1s;
animation-iteration-count: infinite;
position: relative;
z-index: 1;
}
@@ -9,7 +9,6 @@
border-collapse: collapse;
table-layout: fixed;
margin: 0;
margin-bottom: 1rem;
border: 2px solid rgba(var(--color-border-300));
width: 100%;
}
@@ -1,10 +1,18 @@
import { Editor, Range } from "@tiptap/react";
export type IMentionSuggestion = {
id: string;
type: string;
entity_name: string;
entity_identifier: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type CommandProps = {
editor: Editor;
range: Range;
};
export type IMentionHighlight = string;
@@ -53,7 +53,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
onMouseLeave={() => {
hideDragHandle?.();
}}
className={`cursor-text ${editorClassNames}`}
className={`cursor-text ${editorClassNames} ${editor?.isFocused && editor?.isEditable ? "active-editor" : ""}`}
>
{children}
</div>
@@ -0,0 +1,9 @@
import { Extension } from "@tiptap/core";
import codemark from "prosemirror-codemark";
export const CustomCodeMarkPlugin = Extension.create({
name: "codemarkPlugin",
addProseMirrorPlugins() {
return codemark({ markType: this.editor.schema.marks.code });
},
});
@@ -0,0 +1,35 @@
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
export const getPrevListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
let depth = 0;
const pos = listItemPos.$pos;
// Adjust the position to ensure we're within the list item, especially for edge cases
const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0));
// Traverse up the document structure from the adjusted position
for (let d = resolvedPos.depth; d > 0; d--) {
const node = resolvedPos.node(d);
if (node.type.name === "bulletList" || node.type.name === "orderedList") {
// Increment depth for each list ancestor found
depth++;
}
}
// Subtract 1 from the calculated depth to get the parent list's depth
// This adjustment is necessary because the depth calculation includes the current list
// By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist
depth = depth > 0 ? depth - 1 : 0;
// Double the depth value to get results as 2, 4, 6, 8, etc.
depth = depth * 2;
return depth;
};
@@ -4,18 +4,18 @@ import { Node } from "@tiptap/pm/model";
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
import { hasListBefore } from "src/ui/extensions/custom-list-keymap/list-helpers/has-list-before";
import { hasListItemBefore } from "src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before";
import { listItemHasSubList } from "src/ui/extensions/custom-list-keymap/list-helpers/list-item-has-sub-list";
import { isCurrentParagraphASibling } from "src/ui/extensions/custom-list-keymap/list-helpers/is-para-sibling";
import { nextListIsSibling } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-sibling";
import { prevListIsHigher } from "src/ui/extensions/custom-list-keymap/list-helpers/prev-list-is-higher";
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
@@ -53,14 +53,54 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s
return false;
}
// if the cursor is not at the start of a node
// do nothing and proceed
if (!isAtStartOfNode(editor.state)) {
return false;
}
const isParaSibling = isCurrentParagraphASibling(editor.state);
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
const listItemPos = findListItemPos(name, editor.state);
const nextListItemIsSibling = nextListIsSibling(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
const currentNode = listItemPos.$pos.node(listItemPos.depth);
const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode);
if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) {
return false;
}
if (currentListItemHasSubList && isCurrentListItemSublist) {
editor.chain().liftListItem(name).run();
return editor.commands.joinItemBackward();
}
if (isCurrentListItemSublist && nextListItemIsSibling) {
return false;
}
if (isCurrentListItemSublist) {
return false;
}
if (currentListItemHasSubList) {
return false;
}
if (hasListItemBefore(name, editor.state)) {
return editor.chain().liftListItem(name).run();
}
if (!currentListItemHasSubList) {
return false;
}
// otherwise in the end, a backspace should
// always just lift the list item if
// joining / merging is not possible
return editor.chain().liftListItem(name).run();
};
@@ -0,0 +1,22 @@
import { EditorState } from "@tiptap/pm/state";
export const isCurrentParagraphASibling = (state: EditorState): boolean => {
const { $from } = state.selection;
const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item.
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
// Ensure we're in a paragraph and the parent is a list item.
if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") {
let paragraphNodesCount = 0;
listItemNode.forEach((child) => {
if (child.type.name === "paragraph") {
paragraphNodesCount++;
}
});
// If there are more than one paragraph nodes, the current paragraph is a sibling.
return paragraphNodesCount > 1;
}
return false;
};
@@ -0,0 +1,26 @@
import { Editor } from "@tiptap/core";
export function isCursorInSubList(editor: Editor) {
const { selection } = editor.state;
const { $from } = selection;
// Check if the current node is a list item
const listItem = editor.schema.nodes.listItem;
// Traverse up the document tree from the current position
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type === listItem) {
// If the parent of the list item is also a list, it's a sub-list
const parent = $from.node(depth - 1);
if (
parent &&
(parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList)
) {
return true;
}
}
}
return false;
}
@@ -0,0 +1,21 @@
import { getNodeType } from "@tiptap/core";
import { Node } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
export const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
if (!node) {
return false;
}
const nodeType = getNodeType(typeOrName, state.schema);
let hasSubList = false;
node.descendants((child) => {
if (child.type === nodeType) {
hasSubList = true;
}
});
return hasSubList;
};
@@ -0,0 +1,19 @@
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth";
export const nextListIsSibling = (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;
};
@@ -0,0 +1,19 @@
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
import { getPrevListDepth } from "./get-prev-list-depth";
export const prevListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getPrevListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};
@@ -6,6 +6,10 @@ import TiptapUnderline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import BulletList from "@tiptap/extension-bullet-list";
import OrderedList from "@tiptap/extension-ordered-list";
import ListItem from "@tiptap/extension-list-item";
import { Table } from "src/ui/extensions/table/table";
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
@@ -22,17 +26,18 @@ import { CustomKeymap } from "src/ui/extensions/keymap";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomCodeMarkPlugin } from "./custom-code-inline/inline-code-plugin";
export const CoreEditorExtensions = (
mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
},
deleteFile: DeleteImage,
restoreFile: RestoreImage,
@@ -41,17 +46,17 @@ export const CoreEditorExtensions = (
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
class: "",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
class: "",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
class: "",
},
},
code: false,
@@ -63,6 +68,10 @@ export const CoreEditorExtensions = (
width: 2,
},
}),
// BulletList,
// OrderedList,
// ListItem,
CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
@@ -90,7 +99,6 @@ export const CoreEditorExtensions = (
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
@@ -98,20 +106,24 @@ export const CoreEditorExtensions = (
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
class: "flex items-start mt-4",
},
nested: true,
}),
CustomCodeBlockExtension,
CustomCodeMarkPlugin,
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: true,
transformPastedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
Mentions({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
}),
];
@@ -1,4 +1,7 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { canJoin } from "@tiptap/pm/transform";
import { NodeType } from "@tiptap/pm/model";
declare module "@tiptap/core" {
// eslint-disable-next-line no-unused-vars
@@ -12,6 +15,51 @@ declare module "@tiptap/core" {
}
}
function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
if (!tr.isGeneric) return false;
// Find all ranges where we might want to join.
const ranges: Array<number> = [];
for (let i = 0; i < tr.mapping.maps.length; i++) {
const map = tr.mapping.maps[i];
for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]);
map.forEach((_s, _e, from, to) => ranges.push(from, to));
}
// Figure out which joinable points exist inside those ranges,
// by checking all node boundaries in their parent nodes.
const joinable = [];
for (let i = 0; i < ranges.length; i += 2) {
const from = ranges[i],
to = ranges[i + 1];
const $from = tr.doc.resolve(from),
depth = $from.sharedDepth(to),
parent = $from.node(depth);
for (let index = $from.indexAfter(depth), pos = $from.after(depth + 1); pos <= to; ++index) {
const after = parent.maybeChild(index);
if (!after) break;
if (index && joinable.indexOf(pos) == -1) {
const before = parent.child(index - 1);
if (before.type == after.type && before.type === nodeType) joinable.push(pos);
}
pos += after.nodeSize;
}
}
let joined = false;
// Join the joinable points
joinable.sort((a, b) => a - b);
for (let i = joinable.length - 1; i >= 0; i--) {
if (canJoin(tr.doc, joinable[i])) {
newTr.join(joinable[i]);
joined = true;
}
}
return joined;
}
export const CustomKeymap = Extension.create({
name: "CustomKeymap",
@@ -32,6 +80,42 @@ export const CustomKeymap = Extension.create({
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("ordered-list-merging"),
appendTransaction(transactions, oldState, newState) {
// Create a new transaction.
const newTr = newState.tr;
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]);
joined = anotherJoin || joined;
}
if (joined) {
return newTr;
}
},
}),
new Plugin({
key: new PluginKey("unordered-list-merging"),
appendTransaction(transactions, oldState, newState) {
// Create a new transaction.
const newTr = newState.tr;
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]);
joined = anotherJoin || joined;
}
if (joined) {
return newTr;
}
},
}),
];
},
addKeyboardShortcuts() {
return {
"Mod-a": ({ editor }) => {
@@ -5,7 +5,7 @@ import { MentionNodeView } from "src/ui/mentions/mention-node-view";
import { IMentionHighlight } from "src/types/mention-suggestion";
export interface CustomMentionOptions extends MentionOptions {
mentionHighlights: IMentionHighlight[];
mentionHighlights: () => Promise<IMentionHighlight[]>;
readonly?: boolean;
}
@@ -32,6 +32,12 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
redirect_uri: {
default: "/",
},
entity_identifier: {
default: null,
},
entity_name: {
default: null,
},
};
},
@@ -43,17 +49,6 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
return [
{
tag: "mention-component",
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
id: node.getAttribute("data-mention-id") || "",
target: node.getAttribute("data-mention-target") || "",
label: node.innerText.slice(1) || "",
redirect_uri: node.getAttribute("redirect_uri"),
};
},
},
];
},
+80 -6
View File
@@ -1,15 +1,89 @@
// @ts-nocheck
import { Suggestion } from "src/ui/mentions/suggestion";
import { CustomMention } from "src/ui/mentions/custom";
import { IMentionHighlight } from "src/types/mention-suggestion";
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
import { ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) =>
import { MentionList } from "src/ui/mentions/mention-list";
export const Mentions = ({
mentionHighlights,
mentionSuggestions,
readonly,
}: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
readonly: boolean;
}) =>
CustomMention.configure({
HTMLAttributes: {
class: "mention",
},
readonly: readonly,
mentionHighlights: mentionHighlights,
suggestion: Suggestion(mentionSuggestions),
suggestion: {
// @ts-ignore
render: () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
if (!props.clientRect) {
return;
}
component = new ReactRenderer(MentionList, {
props: { ...props, mentionSuggestions },
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector(".active-editor"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-ignore
component?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
component?.destroy();
},
};
},
},
});
@@ -1,36 +1,95 @@
import { Editor } from "@tiptap/react";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { v4 as uuidv4 } from "uuid";
interface MentionListProps {
items: IMentionSuggestion[];
command: (item: { id: string; label: string; target: string; redirect_uri: string }) => void;
command: (item: {
id: string;
label: string;
entity_name: string;
entity_identifier: string;
target: string;
redirect_uri: string;
}) => void;
query: string;
editor: Editor;
mentionSuggestions: () => Promise<IMentionSuggestion[]>;
}
// eslint-disable-next-line react/display-name
export const MentionList = forwardRef((props: MentionListProps, ref) => {
const { query, mentionSuggestions } = props;
const [items, setItems] = useState<IMentionSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false); // New loading state
useEffect(() => {
const fetchSuggestions = async () => {
setIsLoading(true); // Start loading
const suggestions = await mentionSuggestions();
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
const transactionId = uuidv4();
return {
...suggestion,
id: transactionId,
};
});
const filteredSuggestions = mappedSuggestions.filter((suggestion) =>
suggestion.title.toLowerCase().startsWith(query.toLowerCase())
);
setItems(filteredSuggestions);
setIsLoading(false); // End loading
};
fetchSuggestions();
}, [query, mentionSuggestions]);
const selectItem = (index: number) => {
const item = props.items[index];
const item = items[index];
if (item) {
props.command({
id: item.id,
label: item.title,
entity_identifier: item.entity_identifier,
entity_name: item.entity_name,
target: "users",
redirect_uri: item.redirect_uri,
});
}
};
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
setSelectedIndex((selectedIndex + 1) % items.length);
};
const enterHandler = () => {
@@ -39,7 +98,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
useEffect(() => {
setSelectedIndex(0);
}, [props.items]);
}, [items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
@@ -62,10 +121,15 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
},
}));
return props.items && props.items.length !== 0 ? (
<div className="mentions absolute max-h-40 w-48 space-y-0.5 overflow-y-auto rounded-md bg-custom-background-100 p-1 text-sm text-custom-text-300 shadow-custom-shadow-sm">
{props.items.length ? (
props.items.map((item, index) => (
return (
<div
ref={commandListContainer}
className="mentions absolute max-h-40 w-48 space-y-0.5 overflow-y-auto rounded-md bg-custom-background-100 p-1 text-sm text-custom-text-300 shadow-custom-shadow-sm"
>
{isLoading ? (
<div className="flex justify-center items-center h-full text-gray-500">Loading...</div>
) : items.length ? (
items.map((item, index) => (
<div
key={item.id}
className={`flex cursor-pointer items-center gap-2 rounded p-1 hover:bg-custom-background-80 ${
@@ -84,16 +148,13 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
</div>
<div className="flex-grow space-y-1 truncate">
<p className="truncate text-sm font-medium">{item.title}</p>
{/* <p className="text-xs text-gray-400">{item.subtitle}</p> */}
</div>
</div>
))
) : (
<div className="item">No result</div>
<div className="flex justify-center items-center h-full">No results</div>
)}
</div>
) : (
<></>
);
});
@@ -4,11 +4,20 @@ import { NodeViewWrapper } from "@tiptap/react";
import { cn } from "src/lib/utils";
import { useRouter } from "next/router";
import { IMentionHighlight } from "src/types/mention-suggestion";
import { useEffect, useState } from "react";
// eslint-disable-next-line import/no-anonymous-default-export
export const MentionNodeView = (props) => {
const router = useRouter();
const highlights = props.extension.options.mentionHighlights as IMentionHighlight[];
const [highlightsState, setHighlightsState] = useState<IMentionHighlight[]>();
useEffect(() => {
const hightlights = async () => {
const userId = await props.extension.options.mentionHighlights();
setHighlightsState(userId);
};
hightlights();
}, [props.extension.options]);
const handleClick = () => {
if (!props.extension.options.readonly) {
@@ -20,13 +29,13 @@ export const MentionNodeView = (props) => {
<NodeViewWrapper className="mention-component inline w-fit">
<span
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
"bg-yellow-500/20 text-yellow-500": highlights ? highlights.includes(props.node.attrs.id) : false,
"bg-yellow-500/20 text-yellow-500": highlightsState
? highlightsState.includes(props.node.attrs.entity_identifier)
: false,
"cursor-pointer": !props.extension.options.readonly,
// "hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
})}
onClick={handleClick}
data-mention-target={props.node.attrs.target}
data-mention-id={props.node.attrs.id}
>
@{props.node.attrs.label}
</span>
@@ -2,65 +2,80 @@ import { ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
import { MentionList } from "src/ui/mentions/mention-list";
import { v4 as uuidv4 } from "uuid";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { MentionList } from "src/ui/mentions/mention-list";
export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
items: ({ query }: { query: string }) =>
suggestions.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
render: () => {
let reactRenderer: ReactRenderer | null = null;
let popup: any | null = null;
export const getSuggestionItems = (suggestions: IMentionSuggestion[]) => {
return ({ query }: { query: string }) => {
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
const transactionId = uuidv4();
return {
...suggestion,
id: transactionId,
};
});
return mappedSuggestions
.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);
};
};
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
props.editor.storage.mentionsOpen = true;
reactRenderer = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#editor-container"),
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
reactRenderer?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-ignore
reactRenderer?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
reactRenderer?.destroy();
},
};
},
});
// export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
// items: getSuggestionItems(suggestions),
// render: () => {
// let reactRenderer: ReactRenderer | null = null;
// let popup: any | null = null;
//
// return {
// onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
// props.editor.storage.mentionsOpen = true;
// reactRenderer = new ReactRenderer(MentionList, {
// props,
// editor: props.editor,
// });
// // @ts-ignore
// popup = tippy("body", {
// getReferenceClientRect: props.clientRect,
// appendTo: () => document.querySelector("#editor-container"),
// content: reactRenderer.element,
// showOnCreate: true,
// interactive: true,
// trigger: "manual",
// placement: "bottom-start",
// });
// },
//
// onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
// reactRenderer?.updateProps(props);
//
// popup &&
// popup[0].setProps({
// getReferenceClientRect: props.clientRect,
// });
// },
// onKeyDown: (props: { event: KeyboardEvent }) => {
// if (props.event.key === "Escape") {
// popup?.[0].hide();
//
// return true;
// }
//
// const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
//
// if (navigationKeys.includes(props.event.key)) {
// // @ts-ignore
// reactRenderer?.ref?.onKeyDown(props);
// event?.stopPropagation();
// return true;
// }
// return false;
// },
// onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
// props.editor.storage.mentionsOpen = false;
// popup?.[0].destroy();
// reactRenderer?.destroy();
// },
// };
// },
// });
@@ -33,6 +33,7 @@ import {
} from "src/lib/editor-commands";
import { LucideIconType } from "src/types/lucide-icon";
import { UploadImage } from "src/types/upload-image";
import { Selection } from "@tiptap/pm/state";
export interface EditorMenuItem {
name: string;
@@ -135,10 +136,13 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({
export const ImageItem = (
editor: Editor,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
savedSelection?: Selection | null
): EditorMenuItem => ({
name: "image",
isActive: () => editor?.isActive("image"),
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
command: () => {
insertImageCommand(editor, uploadFile, setIsSubmitting, savedSelection);
},
icon: ImageIcon,
});
@@ -134,6 +134,7 @@ export async function startImageUpload(
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
view.focus();
} catch (error) {
console.error("Upload error: ", error);
removePlaceholder(view, id);
@@ -14,7 +14,7 @@ import { TableRow } from "src/ui/extensions/table/table-row/table-row";
import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image";
import { isValidHttpUrl } from "src/lib/utils";
import { Mentions } from "src/ui/mentions";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { IMentionHighlight } from "src/types/mention-suggestion";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
@@ -23,18 +23,17 @@ import { CustomCodeBlockExtension } from "src/ui/extensions/code";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
mentionHighlights?: () => Promise<IMentionHighlight[]>;
}) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
class: "list-disc list-outside leading-3",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
class: "list-decimal list-outside leading-3 -mt-2 -mb-2",
},
},
listItem: {
@@ -74,7 +73,6 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
@@ -82,7 +80,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
class: "flex items-start mt-4",
},
nested: true,
}),
@@ -96,5 +94,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
TableHeader,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
Mentions({
mentionHighlights: mentionConfig.mentionHighlights,
readonly: true,
}),
];
@@ -1,4 +1,4 @@
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { cn, EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Editor, ReactRenderer } from "@tiptap/react";
@@ -15,6 +15,7 @@ import {
useFloating,
useInteractions,
} from "@floating-ui/react";
import BlockMenu from "../menu/block-menu";
type IPageRenderer = {
documentDetails: DocumentDetails;
@@ -170,12 +171,21 @@ export const PageRenderer = (props: IPageRenderer) => {
/>
)}
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
<EditorContainer
hideDragHandle={hideDragHandle}
editor={editor}
editorClassNames={cn(editorClassNames, `h-[100000px]`)}
>
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
{editor && editor.isEditable && (
<>
<BlockMenu editor={editor} />
</>
)}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
@@ -1,6 +1,14 @@
"use client";
import React, { useState } from "react";
import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-core";
import {
UploadImage,
DeleteImage,
RestoreImage,
getEditorClassNames,
useEditor,
IMentionSuggestion,
IMentionHighlight,
} from "@plane/editor-core";
import { DocumentEditorExtensions } from "src/ui/extensions";
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions";
import { EditorHeader } from "src/ui/components/editor-header";
@@ -43,6 +51,9 @@ interface IDocumentEditor {
debouncedUpdatesEnabled?: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
mentionHighlights?: () => Promise<IMentionHighlight[]>;
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
// embed configuration
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
@@ -50,9 +61,6 @@ interface IDocumentEditor {
tabIndex?: number;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
@@ -69,6 +77,8 @@ const DocumentEditor = ({
editorContentCustomClassNames,
value,
uploadFile,
mentionHighlights,
mentionSuggestions,
deleteFile,
restoreFile,
isSubmitting,
@@ -95,7 +105,7 @@ const DocumentEditor = ({
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
const editor = useEditor({
const editorVal = useEditor({
onChange(json, html) {
updateMarkings(json);
onChange(json, html);
@@ -113,9 +123,16 @@ const DocumentEditor = ({
cancelUploadImage,
rerenderOnPropsChange,
forwardedRef,
mentionSuggestions,
mentionHighlights,
extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting),
});
if (!editorVal) {
return null;
}
const { editor, savedSelection } = editorVal;
if (!editor) {
return null;
}
@@ -154,14 +171,21 @@ const DocumentEditor = ({
documentDetails={documentDetails}
isSubmitting={isSubmitting}
/>
<div className="flex-shrink-0 md:hidden border-b border-custom-border-200 pl-3 py-2">
{uploadFile && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
<div className="flex-shrink-0 border-b border-custom-border-200 py-2 pl-3 md:hidden">
{uploadFile && (
<FixedMenu
savedSelection={savedSelection}
editor={editor}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
/>
)}
</div>
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72 hidden md:block">
<div className="frame-renderer flex h-full w-full overflow-y-auto">
<div className="sticky top-0 hidden h-full w-56 flex-shrink-0 md:block lg:w-72">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<div className="page-renderer h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
<PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler}
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef } from "react";
import tippy, { Instance } from "tippy.js";
import { Editor } from "@tiptap/react";
import { CopyIcon, DeleteIcon } from "lucide-react";
interface BlockMenuProps {
editor: Editor;
}
export default function BlockMenu(props: BlockMenuProps) {
const { editor } = props;
const { view } = editor;
const menuRef = useRef<HTMLDivElement>(null);
const popup = useRef<Instance | null>(null);
const handleClickDragHandle = useCallback(
(event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
event.preventDefault();
popup.current?.setProps({
getReferenceClientRect: () => target.getBoundingClientRect(),
});
popup.current?.show();
return;
}
popup.current?.hide();
return;
},
[view]
);
const handleKeyDown = () => {
popup.current?.hide();
};
useEffect(() => {
if (menuRef.current) {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
popup.current = tippy(view.dom, {
getReferenceClientRect: null,
content: menuRef.current,
appendTo: "parent",
trigger: "manual",
interactive: true,
arrow: false,
placement: "left-start",
animation: "shift-away",
maxWidth: 500,
hideOnClick: true,
onShown: () => {
menuRef.current?.focus();
},
});
}
return () => {
popup.current?.destroy();
popup.current = null;
};
}, []);
useEffect(() => {
document.addEventListener("click", handleClickDragHandle);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("click", handleClickDragHandle);
document.addEventListener("keydown", handleKeyDown);
};
}, [handleClickDragHandle]);
return (
<div
ref={menuRef}
className="z-50 max-h-[300px] w-24 overflow-y-auto rounded-lg border border-gray-200 bg-white px-1 py-2 shadow-lg"
>
<button
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
onClick={(e) => {
editor.commands.deleteSelection();
popup.current?.hide();
e.preventDefault();
e.stopPropagation();
}}
>
<div className="grid place-items-center">
<DeleteIcon className="h-3 w-3" />
</div>
<p className="truncate">Delete</p>
</button>
<button
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
onClick={(e) => {
const { view } = editor;
const { state } = view;
const { selection } = state;
editor
.chain()
.insertContentAt(selection.to, selection.content().content.firstChild!.toJSON(), {
updateSelection: true,
})
.focus(selection.to + 1, { scrollIntoView: false }) // Focus the editor at the end
.run();
popup.current?.hide();
e.preventDefault();
e.stopPropagation();
}}
>
<div className="grid place-items-center">
<CopyIcon className="h-3 w-3" />
</div>
<p className="truncate">Duplicate</p>
</button>
</div>
);
}
@@ -18,7 +18,9 @@ import {
findTableAncestor,
EditorMenuItem,
UploadImage,
TodoListItem,
} from "@plane/editor-core";
import { Selection } from "@tiptap/pm/state";
export type BubbleMenuItem = EditorMenuItem;
@@ -26,10 +28,11 @@ type EditorBubbleMenuProps = {
editor: Editor;
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
savedSelection: Selection | null;
};
export const FixedMenu = (props: EditorBubbleMenuProps) => {
const { editor, uploadFile, setIsSubmitting } = props;
const { editor, uploadFile, setIsSubmitting, savedSelection } = props;
const basicMarkItems: BubbleMenuItem[] = [
HeadingOneItem(editor),
@@ -41,14 +44,14 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
StrikeThroughItem(editor),
];
const listItems: BubbleMenuItem[] = [BulletListItem(editor), NumberedListItem(editor)];
const listItems: BubbleMenuItem[] = [BulletListItem(editor), NumberedListItem(editor), TodoListItem(editor)];
const userActionItems: BubbleMenuItem[] = [QuoteItem(editor), CodeItem(editor)];
function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(editor)];
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
items.push(ImageItem(editor, uploadFile, setIsSubmitting, savedSelection));
return items;
}
@@ -1,4 +1,4 @@
import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import { getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core";
import { useRouter } from "next/router";
import { useState, forwardRef, useEffect } from "react";
import { EditorHeader } from "src/ui/components/editor-header";
@@ -22,6 +22,8 @@ interface IDocumentReadOnlyEditor {
documentDetails: DocumentDetails;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
pageDuplicationConfig?: IDuplicationConfig;
onActionCompleteHandler: (action: {
title: string;
@@ -45,6 +47,7 @@ const DocumentReadOnlyEditor = ({
borderOnFocus,
customClassName,
value,
mentionHighlights,
documentDetails,
forwardedRef,
pageDuplicationConfig,
@@ -60,6 +63,7 @@ const DocumentReadOnlyEditor = ({
const editor = useReadOnlyEditor({
value,
mentionHighlights,
forwardedRef,
rerenderOnPropsChange,
extensions: [IssueWidgetPlaceholder()],
@@ -69,6 +73,7 @@ const DocumentReadOnlyEditor = ({
if (editor) {
updateMarkings(editor.getJSON());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
if (!editor) {
@@ -1,9 +1,13 @@
import { Extension } from "@tiptap/core";
import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state";
// @ts-ignore
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
import React from "react";
export interface DragHandleOptions {
dragHandleWidth: number;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
}
function createDragHandleElement(): HTMLElement {
const dragHandleElement = document.createElement("div");
@@ -29,13 +33,8 @@ function createDragHandleElement(): HTMLElement {
return dragHandleElement;
}
export interface DragHandleOptions {
dragHandleWidth: number;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
}
function absoluteRect(node: Element) {
const data = node?.getBoundingClientRect();
const data = node.getBoundingClientRect();
return {
top: data.top,
@@ -57,34 +56,18 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
"pre",
"blockquote",
"h1, h2, h3",
"[data-type=horizontalRule]",
".tableWrapper",
"[data-type=horizontalRule]",
].join(", ")
)
);
}
function nodePosAtDOM(node: Element, view: EditorView) {
const boundingRect = node?.getBoundingClientRect();
if (node.nodeName === "IMG") {
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.pos;
}
if (node.nodeName === "PRE") {
return (
view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.pos! - 1
);
}
function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
}
@@ -96,14 +79,14 @@ function DragHandle(options: DragHandleOptions) {
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + options.dragHandleWidth + 50,
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
const nodePos = nodePosAtDOM(node, view);
if (nodePos === null || nodePos === undefined || nodePos < 0) return;
const nodePos = nodePosAtDOM(node, view, options);
if (nodePos == null || nodePos < 0) return;
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)));
@@ -132,9 +115,9 @@ function DragHandle(options: DragHandleOptions) {
if (!(node instanceof Element)) return;
const nodePos = nodePosAtDOM(node, view);
const nodePos = nodePosAtDOM(node, view, options);
if (nodePos === null || nodePos === undefined || nodePos < 0) return;
if (nodePos === null || nodePos === undefined) return;
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)));
}
@@ -192,11 +175,11 @@ function DragHandle(options: DragHandleOptions) {
}
const node = nodeDOMAtCoords({
x: event.clientX + options.dragHandleWidth,
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) {
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideDragHandle();
return;
}
@@ -207,7 +190,7 @@ function DragHandle(options: DragHandleOptions) {
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
@@ -218,21 +201,22 @@ function DragHandle(options: DragHandleOptions) {
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top + 3}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
wheel: () => {
mousewheel: () => {
hideDragHandle();
},
// dragging className is used for CSS
dragstart: (view) => {
dragenter: (view) => {
view.dom.classList.add("dragging");
hideDragHandle();
},
drop: (view) => {
view.dom.classList.remove("dragging");
hideDragHandle();
},
dragend: (view) => {
view.dom.classList.remove("dragging");
@@ -54,7 +54,20 @@ const Command = Extension.create<SlashCommandOptions>({
props.command({ editor, range });
},
allow({ editor }: { editor: Editor }) {
return !editor.isActive("table");
const { selection } = editor.state;
const parentNode = selection.$from.node(selection.$from.depth);
const blockType = parentNode.type.name;
if (blockType === "codeBlock") {
return false;
}
if (editor.isActive("table")) {
return false;
}
return true;
},
allowSpaces: true,
},
@@ -186,7 +199,7 @@ const getSuggestionItems =
searchTerms: ["img", "photo", "picture", "media"],
icon: <ImageIcon className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
insertImageCommand(editor, uploadFile, setIsSubmitting, null, range);
},
},
{
@@ -330,7 +343,7 @@ const renderItems = () => {
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#editor-container"),
appendTo: () => document.querySelector(".active-editor"),
content: component.element,
showOnCreate: true,
interactive: true,
@@ -2,7 +2,7 @@
position: fixed;
opacity: 1;
transition: opacity ease-in 0.2s;
height: 18px;
height: 20px;
width: 15px;
display: grid;
place-items: center;
@@ -12,9 +12,44 @@
background-color: rgb(var(--color-background-90));
}
.drag-handle:hover {
background-color: rgb(var(--color-background-80));
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
padding: 4px 0px;
outline: none !important;
cursor: grab;
/* background-color: #1f2937; */
background-color: rgba(var(--color-primary-100), 0.2);
transition: background-color 0.2s;
box-shadow: none;
}
.drag-handle:hover {
background-color: #4d4d4d;
cursor: grab;
transition: background-color 0.2s;
}
.ProseMirror img {
transition: filter 0.1s ease-in-out;
&:hover {
cursor: pointer;
filter: brightness(90%);
}
&.ProseMirror-selectednode {
outline: 3px solid #5abbf7;
filter: brightness(90%);
}
}
:not(.dragging) .ProseMirror-selectednode.tableWrapper {
padding: 4px 2px;
background-color: rgba(var(--color-primary-300), 0.1) !important;
box-shadow: rgba(var(--color-primary-100)) 0px 0px 0px 2px inset !important;
}
.drag-handle:active {
background-color: #4d4d4d;
transition: background-color 0.2s;
cursor: grabbing;
}
.drag-handle.hidden {
@@ -51,3 +86,42 @@
background-color: rgba(var(--color-text-200));
border-radius: 50%;
}
/* block menu */
.blockMenu {
padding: 0.25rem;
display: flex;
flex-direction: column;
width: 200px;
gap: 0.25rem;
}
.blockMenu .action {
background: none;
display: flex;
align-items: center;
gap: 0.5rem;
border: none;
padding: 0.1rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.blockMenu .action:disabled {
cursor: not-allowed;
}
.blockMenu .action:hover {
background-color: rgba(var(--color-text-200));
}
.blockMenu .action .actionIconContainer {
width: 1.75rem;
height: 1.75rem;
border: 1px solid red;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.blockMenu .action .actionName {
font-family: inherit;
font-size: 0.95rem;
}
@@ -8,6 +8,7 @@ import {
EditorContentWrapper,
getEditorClassNames,
useEditor,
IMentionHighlight,
} from "@plane/editor-core";
import { FixedMenu } from "src/ui/menus/fixed-menu";
import { LiteTextEditorExtensions } from "src/ui/extensions";
@@ -39,8 +40,8 @@ interface ILiteTextEditor {
};
onEnterKeyPress?: (e?: any) => void;
cancelUploadImage?: () => any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
mentionHighlights?: () => Promise<IMentionHighlight[]>;
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
submitButton?: React.ReactNode;
tabIndex?: number;
}
@@ -78,7 +79,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
tabIndex,
} = props;
const editor = useEditor({
const editorVal = useEditor({
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
@@ -100,8 +101,10 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
customClassName,
});
if (!editor) return null;
if (!editorVal) return null;
const { editor } = editorVal;
if (!editor) return null;
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
@@ -1,5 +1,11 @@
import * as React from "react";
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
IMentionHighlight,
useReadOnlyEditor,
} from "@plane/editor-core";
interface ICoreReadOnlyEditor {
value: string;
@@ -7,7 +13,7 @@ interface ICoreReadOnlyEditor {
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights: string[];
mentionHighlights?: () => Promise<IMentionHighlight[]>;
tabIndex?: number;
}
@@ -4,6 +4,7 @@ import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
IMentionHighlight,
IMentionSuggestion,
RestoreImage,
UploadImage,
@@ -34,8 +35,8 @@ export type IRichTextEditor = {
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
mentionHighlights?: () => Promise<IMentionHighlight[]>;
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
tabIndex?: number;
};
@@ -79,7 +80,7 @@ const RichTextEditor = ({
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
const editor = useEditor({
const editorVal = useEditor({
onChange,
debouncedUpdatesEnabled,
setIsSubmitting,
@@ -106,6 +107,9 @@ const RichTextEditor = ({
// if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
// }, [editor, initialValue]);
//
if (!editorVal) return null;
const { editor } = editorVal;
if (!editor) return null;
return (
@@ -1,5 +1,11 @@
"use client";
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
IMentionHighlight,
useReadOnlyEditor,
} from "@plane/editor-core";
import * as React from "react";
interface IRichTextReadOnlyEditor {
@@ -8,7 +14,7 @@ interface IRichTextReadOnlyEditor {
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights?: string[];
mentionHighlights?: () => Promise<IMentionHighlight[]>;
tabIndex?: number;
}
-1
View File
@@ -19,7 +19,6 @@ export interface IUser {
is_email_verified: boolean;
is_managed: boolean;
is_onboarded: boolean;
is_password_autoset: boolean;
is_tour_completed: boolean;
mobile_number: string | null;
role: string | null;
@@ -45,6 +45,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// refs
const editorRef = useRef<any>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query as {
@@ -53,10 +54,14 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
inboxId: string;
};
// hooks
const { mentionHighlights, mentionSuggestions } = useMention();
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug as string,
projectId: projectId as string,
});
// store hooks
const {
issues: { createInboxIssue },
+5 -1
View File
@@ -46,7 +46,11 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = observer((props) => {
// hooks
const { setShowAlert } = useReloadConfirmations();
// store hooks
const { mentionHighlights, mentionSuggestions } = useMention();
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug as string,
projectId: projectId as string,
});
// form info
const {
handleSubmit,
+5 -1
View File
@@ -27,7 +27,11 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = (props) =>
// states
const [descriptionHTML, setDescriptionHTML] = useState(value);
// store hooks
const { mentionHighlights, mentionSuggestions } = useMention();
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug as string,
projectId: projectId as string,
});
const { getWorkspaceBySlug } = useWorkspace();
// hooks
const debouncedValue = useDebounce(descriptionHTML, 1500);
@@ -10,13 +10,14 @@ import { TActivityOperations } from "./root";
type TIssueActivityCommentRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
activityOperations: TActivityOperations;
showAccessSpecifier?: boolean;
};
export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer((props) => {
const { workspaceSlug, issueId, activityOperations, showAccessSpecifier } = props;
const { workspaceSlug, issueId, activityOperations, showAccessSpecifier, projectId } = props;
// hooks
const {
activity: { getActivityCommentByIssueId },
@@ -20,6 +20,7 @@ import { IssueCommentBlock } from "./comment-block";
const fileService = new FileService();
type TIssueCommentCard = {
projectId: string;
workspaceSlug: string;
commentId: string;
activityOperations: TActivityOperations;
@@ -28,13 +29,16 @@ type TIssueCommentCard = {
};
export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
const { workspaceSlug, commentId, activityOperations, ends, showAccessSpecifier = false } = props;
const { workspaceSlug, projectId, commentId, activityOperations, ends, showAccessSpecifier = false } = props;
// hooks
const {
comment: { getCommentById },
} = useIssueDetail();
const { currentUser } = useUser();
const { mentionHighlights, mentionSuggestions } = useMention();
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug as string,
projectId: projectId as string,
});
// refs
const editorRef = useRef<any>(null);
const showEditorRef = useRef<any>(null);
@@ -17,6 +17,7 @@ import { TActivityOperations } from "../root";
const fileService = new FileService();
type TIssueCommentCreate = {
projectId: string;
workspaceSlug: string;
activityOperations: TActivityOperations;
showAccessSpecifier?: boolean;
@@ -41,11 +42,14 @@ const commentAccess: commentAccessType[] = [
];
export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
const { workspaceSlug, activityOperations, showAccessSpecifier = false } = props;
const { workspaceSlug, projectId, activityOperations, showAccessSpecifier = false } = props;
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
const { mentionHighlights, mentionSuggestions } = useMention();
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug as string,
projectId: projectId as string,
});
// refs
const editorRef = useRef<any>(null);
@@ -88,6 +92,9 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={(e) => {
handleSubmit(onSubmit)(e);
}}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
@@ -8,6 +8,7 @@ import { IssueCommentCard } from "./comment-card";
// types
type TIssueCommentRoot = {
projectId: string;
workspaceSlug: string;
issueId: string;
activityOperations: TActivityOperations;
@@ -15,7 +16,7 @@ type TIssueCommentRoot = {
};
export const IssueCommentRoot: FC<TIssueCommentRoot> = observer((props) => {
const { workspaceSlug, issueId, activityOperations, showAccessSpecifier } = props;
const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier } = props;
// hooks
const {
comment: { getCommentsByIssueId },
@@ -141,12 +141,14 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
{activityTab === "all" ? (
<div className="space-y-3">
<IssueActivityCommentRoot
projectId={projectId}
workspaceSlug={workspaceSlug}
issueId={issueId}
activityOperations={activityOperations}
showAccessSpecifier={project.is_deployed}
/>
<IssueCommentCreate
projectId={projectId}
workspaceSlug={workspaceSlug}
activityOperations={activityOperations}
showAccessSpecifier={project.is_deployed}
@@ -157,12 +159,14 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
) : (
<div className="space-y-3">
<IssueCommentRoot
projectId={projectId}
workspaceSlug={workspaceSlug}
issueId={issueId}
activityOperations={activityOperations}
showAccessSpecifier={project.is_deployed}
/>
<IssueCommentCreate
projectId={projectId}
workspaceSlug={workspaceSlug}
activityOperations={activityOperations}
showAccessSpecifier={project.is_deployed}
+5 -1
View File
@@ -124,7 +124,11 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
} = useApplication();
const { getProjectById } = useProject();
const { areEstimatesEnabledForProject } = useEstimate();
const { mentionHighlights, mentionSuggestions } = useMention();
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug as string,
projectId: defaultProjectId,
});
const {
issue: { getIssueById },
} = useIssueDetail();
+101 -9
View File
@@ -1,11 +1,103 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/contexts/store-context";
// types
import { IMentionStore } from "@/store/mention.store";
import { useRef, useEffect } from "react";
import { ProjectMemberService } from "services/project";
import { UserService } from "services/user.service";
import useSWR from "swr";
import { IProjectMember, IUser } from "@plane/types";
export const useMention = (): IMentionStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useMention must be used within StoreProvider");
return context.mention;
export const useMention = ({ workspaceSlug, projectId }: { workspaceSlug: string; projectId: string }) => {
const userService = new UserService();
const projectMemberService = new ProjectMemberService();
const { data: projectMembers, isLoading: projectMembersLoading } = useSWR(
["projectMembers", workspaceSlug, projectId],
async () => {
const members = await projectMemberService.fetchProjectMembers(workspaceSlug, projectId);
const detailedMembers = await Promise.all(
members.map(async (member) => projectMemberService.getProjectMember(workspaceSlug, projectId, member.id))
);
return detailedMembers;
}
);
const { data: user, isLoading: userDataLoading } = useSWR("currentUser", async () => userService.currentUser());
const projectMembersRef = useRef<IProjectMember[] | undefined>();
const userRef = useRef<IUser | undefined>();
useEffect(() => {
if (projectMembers) {
projectMembersRef.current = projectMembers;
}
}, [projectMembers]);
useEffect(() => {
if (userRef) {
userRef.current = user;
}
}, [user]);
const waitForUserDate = async () =>
new Promise<IUser>((resolve) => {
const checkData = () => {
if (userRef.current) {
resolve(userRef.current);
} else {
setTimeout(checkData, 100);
}
};
checkData();
});
const mentionHighlights = async () => {
if (!userDataLoading && userRef.current) {
return [userRef.current.id];
} else {
const user = await waitForUserDate();
return [user.id];
}
};
// Polling function to wait for projectMembersRef.current to be populated
const waitForData = async () =>
new Promise<IProjectMember[]>((resolve) => {
const checkData = () => {
if (projectMembersRef.current && projectMembersRef.current.length > 0) {
resolve(projectMembersRef.current);
} else {
setTimeout(checkData, 100); // Check every 100ms
}
};
checkData();
});
const mentionSuggestions = async () => {
if (!projectMembersLoading && projectMembersRef.current && projectMembersRef.current.length > 0) {
// If data is already available, return it immediately
return projectMembersRef.current.map((memberDetails) => ({
entity_name: "user_mention",
entity_identifier: `${memberDetails?.member?.id}`,
type: "User",
title: `${memberDetails?.member?.display_name}`,
subtitle: memberDetails?.member?.email ?? "",
avatar: `${memberDetails?.member?.avatar}`,
redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.member?.id}`,
}));
} else {
// Wait for data to be available
const members = await waitForData();
return members.map((memberDetails) => ({
entity_name: "user_mention",
entity_identifier: `${memberDetails?.member?.id}`,
type: "User",
title: `${memberDetails?.member?.display_name}`,
subtitle: memberDetails?.member?.email ?? "",
avatar: `${memberDetails?.member?.avatar}`,
redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.member?.id}`,
}));
}
};
return {
mentionSuggestions,
mentionHighlights,
};
};
@@ -14,7 +14,7 @@ import { PageDetailsHeader } from "@/components/headers/page-details";
import { IssuePeekOverview } from "@/components/issues";
import { EUserProjectRoles } from "@/constants/project";
import { getDate } from "@/helpers/date-time.helper";
import { useApplication, usePage, useUser, useWorkspace } from "@/hooks/store";
import { useApplication, useMention, usePage, useUser, useWorkspace } from "@/hooks/store";
import { useProjectPages } from "@/hooks/store/use-project-specific-pages";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
@@ -74,6 +74,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
? () => fetchProjectPages(workspaceSlug.toString(), projectId.toString())
: null
);
// fetching archived pages from API
useSWR(
workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null,
@@ -84,6 +85,11 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
const pageStore = usePage(pageId as string);
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug: workspaceSlug as string,
projectId: projectId as string,
});
const { setShowAlert } = useReloadConfirmations(pageStore?.isSubmitting === "submitting");
useEffect(
@@ -263,6 +269,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
customClassName={"tracking-tight w-full px-0"}
borderOnFocus={false}
noBorder
mentionHighlights={mentionHighlights}
documentDetails={{
title: pageTitle,
created_by: created_by,
@@ -303,6 +310,8 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
value={pageDescription}
setShouldShowAlert={setShowAlert}
cancelUploadImage={fileService.cancelUpload}
mentionHighlights={mentionHighlights}
mentionSuggestions={mentionSuggestions}
ref={editorRef}
debouncedUpdatesEnabled={false}
setIsSubmitting={setIsSubmitting}
+2 -1
View File
@@ -35,7 +35,8 @@ export class MentionStore implements IMentionStore {
const memberDetails = this.rootStore.memberRoot.project.getProjectMemberDetails(memberId);
return {
id: `${memberDetails?.member?.id}`,
entity_name: "user_mention",
entity_identifier: `${memberDetails?.member?.id}`,
type: "User",
title: `${memberDetails?.member?.display_name}`,
subtitle: memberDetails?.member?.email ?? "",
+1 -1
View File
@@ -130,7 +130,7 @@ export class PageStore implements IPageStore {
});
});
},
{ delay: 3000 }
{ delay: 1500 }
);
const pageTitleDisposer = reaction(
+6 -1
View File
@@ -2733,7 +2733,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^18.2.42":
"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42":
version "18.2.42"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7"
integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==
@@ -6907,6 +6907,11 @@ prosemirror-changeset@^2.2.0:
dependencies:
prosemirror-transform "^1.0.0"
prosemirror-codemark@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/prosemirror-codemark/-/prosemirror-codemark-0.4.2.tgz#b4d0a57c0f1f6c6667e2a1ae7cfb6ba031dfb2e5"
integrity sha512-4n+PnGQToa/vTjn0OiivUvE8/moLtguUAfry8UA4Q8p47MhqT2Qpf2zBLustX5Upi4mSp3z1ZYBqLLovZC6abA==
prosemirror-collab@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33"