Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aae4d34eb0 | |||
| fe14ea3577 | |||
| b34a7c3dbc | |||
| 60e38ebce9 | |||
| 8e6429f697 | |||
| 48254977a0 | |||
| c5814acb09 | |||
| 94e83ab946 | |||
| 35fe2987ff | |||
| eae2dc9b33 | |||
| a33106862b | |||
| 51af9e4d12 | |||
| 4b56cc5565 | |||
| 8279e15dd8 | |||
| 625205f972 | |||
| 48f913fbe6 | |||
| 2f4fc63858 | |||
| adfa9c1b1a | |||
| f7700b4536 | |||
| ec70d68399 | |||
| 543168a756 | |||
| 8b297b2bda | |||
| 52b2fbefe1 | |||
| 15be7eeda3 | |||
| 252c8a30bc | |||
| 13ba7d59e0 | |||
| 768d141729 | |||
| 5e703f6601 | |||
| 8bd7737d7b | |||
| 26646d512e | |||
| 43fd94827f | |||
| a3033203af | |||
| 875eb054a2 |
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
+35
@@ -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;
|
||||
};
|
||||
+49
-9
@@ -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();
|
||||
};
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
};
|
||||
+19
@@ -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;
|
||||
};
|
||||
+19
@@ -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"),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Vendored
-1
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -130,7 +130,7 @@ export class PageStore implements IPageStore {
|
||||
});
|
||||
});
|
||||
},
|
||||
{ delay: 3000 }
|
||||
{ delay: 1500 }
|
||||
);
|
||||
|
||||
const pageTitleDisposer = reaction(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user